
PIFster's community giving model depends on a steady pipeline of charity candidates for the monthly vote. We don't want to be the sole gatekeepers of which charities appear — the community should surface causes that matter to them.
But an open suggestion form on the internet is an invitation for abuse. The design challenge: make it frictionless enough that a visitor suggests a charity in 30 seconds, but resilient enough that bots and bad actors can't flood the system.
The system has five components:
GET /pifster/v1/suggestion/search queries two tables with prefix-first matching and popularity rankingPOST /pifster/v1/charity/suggest with UPSERT semantics, fingerprint idempotency, and multi-layer rate limitingThe lookahead search needs to feel instant. When a user types "habi", they should see "Habitat for Humanity" before they finish the word.
For each data source (charities and suggestions), we run a prefix match first, then fall back to a contains match only if we haven't filled the result limit:
private function searchSuggestions(string $q, string $normalized, int $limit): array
{
$prefixRows = $this->fetchSuggestionRows($q, $normalized, $limit, true);
$rows = $prefixRows;
if (count($prefixRows) < $limit) {
$remaining = $limit - count($prefixRows);
$rows = array_merge(
$rows,
$this->fetchSuggestionRows($q, $normalized, $remaining, false)
);
}
// ... map to response items
}
The buildLikePattern method controls the difference:
private function buildLikePattern(string $value, bool $prefixOnly): string
{
$escaped = $this->db->esc_like($value);
return $prefixOnly ? $escaped . '%' : '%' . $escaped . '%';
}
Prefix matches are faster (can use indexes) and more relevant. Contains matches catch cases where the user types a word that appears mid-name. Running prefix first means you only pay the contains cost when you need more results.
The search queries two tables simultaneously:
pifster_charity — charities already in the system (current, staged, archived)pifster_suggestion — previously suggested charities (pending, approved, rejected)Results are merged with charities taking priority:
$seen = [];
foreach ($charityItems as $item) {
$norm = $item['normalized_name'];
if (!isset($seen[$norm])) {
$items[] = $item;
$seen[$norm] = true;
}
}
foreach ($suggestionItems as $item) {
$norm = $item['normalized_name'];
if (!isset($seen[$norm])) {
$items[] = $item;
$seen[$norm] = true;
}
}
If "Habitat for Humanity" exists in both tables, the official charity entry wins. The user sees the status badge ("current", "archived") so they know this charity is already on the platform.
Suggestions are sorted by suggestion_count_unique DESC — the number of unique visitors who've suggested the same charity. Frequently-suggested charities bubble to the top of the autocomplete, giving the admin team a signal about community demand:
ORDER BY suggestion_count_unique DESC, suggested_name ASC
Charities (already vetted) sort alphabetically. The popularity ranking only applies to the suggestion pool, where it acts as a lightweight voting mechanism before the charity even enters the formal voting pool.
Both tables store a normalized_name alongside the display name. The NormalizationService ensures "Habitat For Humanity" and "habitat-for-humanity" resolve to the same key:
final class NormalizationService
{
public function normalize(string $name): string
{
$name = trim($name);
$name = strtolower($name);
$name = preg_replace('/[^a-z0-9\s-]/', '', $name) ?? '';
$name = preg_replace('/\s+/', ' ', $name) ?? '';
$name = str_replace(' ', '-', $name);
return $name;
}
}
The JS side mirrors this logic for local cache keys:
function normalizeName(value) {
var name = String(value || '').trim().toLowerCase();
name = name.replace(/[^a-z0-9\s-]/g, '');
name = name.replace(/\s+/g, ' ');
name = name.replace(/ /g, '-');
return name;
}
Search results are cached for one hour using WordPress transients:
$cacheKey = 'pif_suggestion_search_v2_' . md5($normalized . '_' . $limit);
$cached = get_transient($cacheKey);
if (is_array($cached)) {
$response = new WP_REST_Response(['items' => $cached, 'cached' => true], 200);
$response->header('Cache-Control', 'public, max-age=3600');
return $response;
}
This matters because typeahead generates many requests per user session. A cache hit avoids the UNION queries entirely.
No React. No npm. No build step. The entire lookahead search is ~490 lines of vanilla JS wrapped in an IIFE.
The search fires on every keystroke, debounced to 180ms. Three layers protect against stale results:
var doSearch = debounce(function () {
var q = nameInput.value.trim();
resetSelection();
if (q.length < 3) {
abortInflightSearch();
setLoading(false);
closeResults();
return;
}
// Local cache check
var normalizedKey = normalizeName(q);
if (localCache[normalizedKey]) {
setLoading(false);
renderResults(localCache[normalizedKey]);
return;
}
// Abort previous in-flight request
if (inflight && typeof inflight.abort === 'function') {
inflight.abort();
}
var ac = new AbortController();
inflight = ac;
searchRequestId += 1;
var requestId = searchRequestId;
setLoading(true);
requestJson(url, { signal: ac.signal })
.then(function (r) {
if (requestId !== searchRequestId) return;
setLoading(false);
localCache[normalizedKey] = r.body.items;
renderResults(r.body.items);
})
.catch(function () {
if (requestId !== searchRequestId) return;
setLoading(false);
closeResults();
});
}, 180);
The loading spinner appears after 120ms, not immediately. This avoids the "flash of spinner" on fast connections:
function setLoading(isLoading) {
if (isLoading) {
loadingTimer = setTimeout(function () {
autocompleteWrap.classList.add('is-loading');
}, spinnerDelayMs);
return;
}
clearTimeout(loadingTimer);
autocompleteWrap.classList.remove('is-loading');
}
Small detail, big UX difference. If the response returns in under 120ms (common with transient caching), the user never sees a spinner at all.
When a user clicks a result, the form auto-populates and shows a contextual hint if the charity already exists:
selected = {
post_id: btn.getAttribute('data-post-id'),
charity_name: btn.getAttribute('data-name'),
charity_url: btn.getAttribute('data-url'),
charity_status: btn.getAttribute('data-status'),
};
nameInput.value = selected.charity_name;
if (selected.charity_url && urlInput) {
urlInput.value = selected.charity_url;
}
if (selected.charity_status) {
setHint('This charity already exists (' + selected.charity_status
+ '). Submitting will register renewed interest.');
}
An unauthenticated public form is a bot magnet. We stack four defenses:
Turnstile is Cloudflare's invisible CAPTCHA replacement. The widget renders in the form, generates a token client-side, and the server verifies it. The submit button stays disabled until Turnstile passes, so humans don't encounter friction — but bots without a browser can't generate a valid token.
A hidden "website" field that's invisible to human users but tempting to bots that auto-fill all form fields:
<div class="pifster-charity-suggest__honeypot" aria-hidden="true">
<label for="pifster-website">Website</label>
<input type="text" id="pifster-website" name="website"
tabindex="-1" autocomplete="off" />
</div>
The CSS pushes it off-screen. Screen readers skip it via aria-hidden. Only bots fill it.
Each submission generates a fingerprint hash from the visitor's IP, user agent, and accept-language header, salted with a WordPress secret:
private function buildFingerprintHash(string $clientIp): string
{
$ua = (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');
$al = (string) ($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '');
$salt = wp_salt('pifster_suggest');
return hash('sha256', $salt . '|' . $clientIp . '|' . $ua . '|' . $al);
}
The events table has a UNIQUE(normalized_name, fingerprint_hash) constraint. Same visitor, same charity = no duplicate count. The ON DUPLICATE KEY UPDATE refreshes the timestamp without incrementing:
$isNewUnique = ($this->db->rows_affected === 1);
$inc = $isNewUnique ? 1 : 0;
This means suggestion_count_unique accurately reflects distinct visitors, not repeat submissions.
Five rate limit buckets using WordPress transients:
$ipShortMax = 12; // Max 12 per IP in 5 minutes
$ipDayMax = 60; // Max 60 per IP in 24 hours
$fpShortMax = 6; // Max 6 per fingerprint in 10 minutes
$fpDayMax = 25; // Max 25 per fingerprint in 24 hours
$topicDayMax = 25; // Max 25 per charity name in 24 hours
The topic-level limit prevents a coordinated campaign from artificially inflating a single charity's suggestion count. Using transients rather than a dedicated rate limit table keeps the implementation simple — they auto-expire.
The suggestion system uses two tables:
pifster_suggestion (the topic table) — one row per unique normalized charity name. This is the aggregate: display name, URL, reason, status, and suggestion_count_unique.
pifster_suggestion_events — one row per unique (charity, visitor) pair. This is the raw event log, used for idempotency and forensics.
The split lets you query the topic table cheaply for autocomplete (it's small and indexed) while keeping full event history for rate limiting and admin review. The UPSERT on the topic table means a new suggestion either creates a row or increments the counter:
INSERT INTO pifster_suggestion (post_id, normalized_name, suggested_name, ...)
VALUES (%d, %s, %s, ...)
ON DUPLICATE KEY UPDATE
suggestion_count_unique = suggestion_count_unique + %d,
last_suggested_at = VALUES(last_suggested_at)
Each topic also gets a WordPress CPT post (pifster-suggestion) so admins can manage suggestions through the standard WordPress admin interface.
The form is rendered by a WordPress shortcode, not built client-side. Drop [pifster_charity_suggest_form] on any page. The JS bootstraps from a localized config object:
$bootstrap = [
'suggestEndpoint' => esc_url_raw(rest_url('pifster/v1/charity/suggest')),
'searchEndpoint' => esc_url_raw(rest_url('pifster/v1/suggestion/search')),
'nonce' => wp_create_nonce('wp_rest'),
'turnstileSiteKey' => $turnstileSiteKey,
];
wp_localize_script('pifster-charity-suggest', 'PIFSTER_CHARITY_SUGGEST', $bootstrap);
No build step. No bundler. The JS reads window.PIFSTER_CHARITY_SUGGEST on load and wires everything up.
Cache-Control header means most of those are cheap.ON DUPLICATE KEY UPDATE pattern with a conditional increment gives you accurate popularity data without a separate aggregation job.PIFster is a community-driven charitable giving platform where donors vote on which charity receives the community's pooled donations each month. Want to suggest a charity? Use the form. Want to see how the rest of the platform works? Check out our posts on pay-what-you-want checkout, magic link authentication, gift memberships, and gamified referrals.