badges: per-tier search limits — result cap + min chars, config UI

Add anonymous/auth/trusted search constraints to BadgesRemoteCfg with
conservative defaults (anon: 15 results / 3 chars, auth: 25 / 2,
trusted+: 150 / 1). Configurable per event via mod_badges_json.

- BadgesRemoteCfg + BadgesLocState: 6 new fields with defaults
- sync_config__event_badges: mirrors new fields from mod_badges_json
- +page.svelte: effective_search_limits derived by tier using $ae_loc
  cumulative flags; enforces min_chars guard and result cap on both
  local IDB path and API call
- ae_comp__badge_search: effective_min_chars derived same way; blocks
  search trigger below threshold; shows dynamic hint text
- Fallback broad search (SCENARIO 2) suppressed for non-trusted users
  so no results show on page load without a query
- config/+page.svelte: Search Limits section with 3-column number
  inputs (Anonymous / Auth / Trusted+) for result limit and min chars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-04-07 18:08:10 -04:00
parent be0b8baf62
commit ae9cdaf9f1
5 changed files with 198 additions and 12 deletions

View File

@@ -1095,6 +1095,14 @@ export function sync_config__event_badges({
loc.enable_search_qr = badges_cfg_remote?.enable_search_qr ?? true;
loc.qr_type = badges_cfg_remote?.qr_type ?? null;
// Per-tier search constraints
loc.anon_search_result_limit = badges_cfg_remote?.anon_search_result_limit ?? 15;
loc.anon_search_min_chars = badges_cfg_remote?.anon_search_min_chars ?? 3;
loc.auth_search_result_limit = badges_cfg_remote?.auth_search_result_limit ?? 25;
loc.auth_search_min_chars = badges_cfg_remote?.auth_search_min_chars ?? 2;
loc.trusted_search_result_limit = badges_cfg_remote?.trusted_search_result_limit ?? 150;
loc.trusted_search_min_chars = badges_cfg_remote?.trusted_search_min_chars ?? 1;
// Passcodes and permissions (may be null)
loc.trusted_passcode = badges_cfg_remote?.trusted_passcode ?? null;
loc.administrator_passcode = badges_cfg_remote?.administrator_passcode ?? null;

View File

@@ -19,6 +19,17 @@ export interface BadgesRemoteCfg {
enable_upload_badge_li_btn: boolean; // show the "Upload Badge List" button
enable_search_qr: boolean; // enable QR scan search
// Per-access-tier search limits.
// anonymous — not signed in at all
// auth — public passcode or identity-verified (Authenticated/Public)
// trusted — onsite staff (Trusted) and above; above-trusted inherits trusted limits
anon_search_result_limit: number;
anon_search_min_chars: number;
auth_search_result_limit: number;
auth_search_min_chars: number;
trusted_search_result_limit: number;
trusted_search_min_chars: number;
// QR code configuration
qr_type: string | null; // QR payload format (e.g. 'badge_id', 'url')
@@ -117,6 +128,13 @@ export interface BadgesLocState {
trusted?: { can_edit: string[] | '*' };
administrator?: { can_edit: string[] | '*' };
} | null;
// Per-tier search constraints (mirrored from mod_badges_json)
anon_search_result_limit: number;
anon_search_min_chars: number;
auth_search_result_limit: number;
auth_search_min_chars: number;
trusted_search_result_limit: number;
trusted_search_min_chars: number;
// Timestamp when the remote config was last mirrored locally
remote_cfg_last_synced_on: string | null;
}
@@ -177,6 +195,13 @@ export const badges_loc_defaults: BadgesLocState = {
trusted_passcode: null,
administrator_passcode: null,
edit_permissions: null,
// Per-tier search constraints — conservative defaults; overridden per event via mod_badges_json.
anon_search_result_limit: 15,
anon_search_min_chars: 3,
auth_search_result_limit: 25,
auth_search_min_chars: 2,
trusted_search_result_limit: 150,
trusted_search_min_chars: 1,
remote_cfg_last_synced_on: null
};

View File

@@ -111,8 +111,10 @@ let lq__event_badge_obj_li = $derived.by(() => {
}
// SCENARIO 2: Fallback broad search (Only if no active filters)
// Unauthenticated users must enter a query — never show the full attendee list.
if (
event_id &&
$ae_loc.trusted_access &&
!badges_loc.current.fulltext_search_qry_str &&
badges_loc.current.qry_printed_status === 'all' &&
!badges_loc.current.qry_affiliations &&
@@ -135,6 +137,28 @@ let lq__event_badge_obj_li = $derived.by(() => {
// Standardized Reactive Search Pattern (Aether UI V3)
// 1. Isolate dependencies into a stable derived object
// Resolve per-tier search constraints from the event config (mirrored from mod_badges_json).
// Tier order (highest wins): trusted_access → auth (public/authenticated) → anonymous.
let effective_search_limits = $derived.by(() => {
if ($ae_loc.trusted_access) {
return {
result_limit: badges_loc.current.trusted_search_result_limit,
min_chars: badges_loc.current.trusted_search_min_chars
};
}
if ($ae_loc.authenticated_access) {
// public_access / authenticated — signed in but below trusted
return {
result_limit: badges_loc.current.auth_search_result_limit,
min_chars: badges_loc.current.auth_search_min_chars
};
}
return {
result_limit: badges_loc.current.anon_search_result_limit,
min_chars: badges_loc.current.anon_search_min_chars
};
});
let search_params = $derived({
v: badges_loc.current.search_version,
str: (badges_loc.current.fulltext_search_qry_str ?? '')
@@ -145,7 +169,9 @@ let search_params = $derived({
aff: (badges_loc.current.qry_affiliations ?? '').toLowerCase().trim(),
sort: badges_loc.current.qry_sort_order,
event_id: $events_slct?.event_id,
remote_first: badges_loc.current.qry__remote_first
remote_first: badges_loc.current.qry__remote_first,
result_limit: effective_search_limits.result_limit,
min_chars: effective_search_limits.min_chars
});
// 2. Controlled effect for triggering searches
@@ -188,6 +214,18 @@ async function handle_search_refresh(params: any) {
const type_code = params.type;
const printed_status = params.printed;
const aff_str = params.aff;
const result_limit = params.result_limit;
const min_chars = params.min_chars;
// Defense-in-depth: enforce min_chars even if the search component lets one through.
if (qry_str.length < min_chars) {
untrack(() => {
event_badge_id_li = [];
$events_sess.badges.search_status = 'done';
$events_sess.badges.search_complete = true;
});
return;
}
// 2. FAST PATH: Local IDB Search
if (!remote_first) {
@@ -283,6 +321,9 @@ async function handle_search_refresh(params: any) {
}
});
// Cap results to the effective per-tier limit.
local_results = local_results.slice(0, result_limit);
const local_ids = local_results
.map((b) => b.event_badge_id)
.filter(Boolean);
@@ -340,7 +381,7 @@ async function handle_search_refresh(params: any) {
printed_status: printed_status,
affiliations_qry_str: aff_str || null,
order_by_li: order_by_li,
limit: 150,
limit: result_limit,
log_lvl: 0
});

View File

@@ -8,7 +8,7 @@ let { event_id, log_lvl = 0 }: Props = $props();
// *** Import other supporting libraries
import {
Library,
// Library,
LoaderCircle,
QrCode,
RemoveFormatting,
@@ -19,15 +19,15 @@ import { events_sess } from '$lib/stores/ae_events_stores';
import { badges_loc } from '$lib/stores/ae_events_stores__badges.svelte';
import Element_qr_scanner from '$lib/elements/element_qr_scanner.svelte';
import { ae_util } from '$lib/ae_utils/ae_utils';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
// import { liveQuery } from 'dexie';
// import { db_events } from '$lib/ae_events/db_events';
let lq__event_obj = $derived(
liveQuery(async () => {
if (!event_id) return null;
return await db_events.event.get(event_id);
})
);
// let lq__event_obj = $derived(
// liveQuery(async () => {
// if (!event_id) return null;
// return await db_events.event.get(event_id);
// })
// );
// ISHLT 2024 badge type codes
let badge_type_code_li = [
@@ -46,7 +46,17 @@ let badge_type_code_li = [
{ code: 'test', name: 'Test' }
];
// Resolve the minimum characters required for the current user's access tier.
// Mirrors the 3-tier logic in +page.svelte so both enforce the same threshold.
let effective_min_chars = $derived.by(() => {
if ($ae_loc.trusted_access) return badges_loc.current.trusted_search_min_chars;
if ($ae_loc.authenticated_access) return badges_loc.current.auth_search_min_chars;
return badges_loc.current.anon_search_min_chars;
});
function handle_search_trigger() {
const qry = (badges_loc.current.fulltext_search_qry_str ?? '').trim();
if (qry.length < effective_min_chars) return;
badges_loc.current.search_version++;
}
@@ -160,6 +170,12 @@ function handle_qr_scan_result(event: {
title="Search by name, email, etc. Press Enter." />
</div>
{#if (badges_loc.current.fulltext_search_qry_str ?? '').trim().length < effective_min_chars}
<p class="w-full text-center text-xs opacity-50">
Enter at least {effective_min_chars} character{effective_min_chars === 1 ? '' : 's'} to search
</p>
{/if}
<div class="flex flex-row items-center justify-center gap-1">
<button
type="submit"

View File

@@ -109,7 +109,13 @@ const cfg_defaults: BadgesRemoteCfg = {
authenticated: { can_edit: [...default_authenticated_can_edit] },
trusted: { can_edit: [...default_trusted_can_edit] },
administrator: { can_edit: '*' }
}
},
anon_search_result_limit: 15,
anon_search_min_chars: 3,
auth_search_result_limit: 25,
auth_search_min_chars: 2,
trusted_search_result_limit: 150,
trusted_search_min_chars: 1
};
let draft: BadgesRemoteCfg = $state({ ...cfg_defaults });
@@ -230,6 +236,7 @@ async function save() {
// Section collapse state
let sections: Record<string, boolean> = $state({
ui: true,
search_limits: true,
qr: true,
passcodes: true,
auth_fields: true,
@@ -325,6 +332,95 @@ function toggle(key: string) {
{/if}
</section>
<!-- ================================================================ -->
<!-- SEARCH LIMITS -->
<!-- ================================================================ -->
<section class="border-surface-200-800 rounded-xl border">
<button
type="button"
class="flex w-full items-center justify-between px-4 py-3 text-left font-semibold"
onclick={() => toggle('search_limits')}>
<span>Search Limits per Access Tier</span>
{#if sections.search_limits}<ChevronUp size="1em" />{:else}<ChevronDown size="1em" />{/if}
</button>
{#if sections.search_limits}
<div class="border-surface-200-800 border-t px-4 py-3 space-y-4">
<p class="text-xs text-surface-400">
Controls how many results each access tier can see and how many characters
they must type before a search fires. Trusted and above use the trusted limits.
</p>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<!-- Anonymous -->
<div class="space-y-2">
<p class="text-sm font-semibold">Anonymous <span class="text-xs font-normal text-surface-400">(not signed in)</span></p>
<label class="flex flex-col gap-1">
<span class="text-xs text-surface-500">Result limit</span>
<input
type="number"
class="input input-sm"
min="1"
max="500"
bind:value={draft.anon_search_result_limit} />
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-surface-500">Min characters to search</span>
<input
type="number"
class="input input-sm"
min="1"
max="10"
bind:value={draft.anon_search_min_chars} />
</label>
</div>
<!-- Auth (Public / Authenticated) -->
<div class="space-y-2">
<p class="text-sm font-semibold">Auth <span class="text-xs font-normal text-surface-400">(public passcode / identity-verified)</span></p>
<label class="flex flex-col gap-1">
<span class="text-xs text-surface-500">Result limit</span>
<input
type="number"
class="input input-sm"
min="1"
max="500"
bind:value={draft.auth_search_result_limit} />
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-surface-500">Min characters to search</span>
<input
type="number"
class="input input-sm"
min="1"
max="10"
bind:value={draft.auth_search_min_chars} />
</label>
</div>
<!-- Trusted+ -->
<div class="space-y-2">
<p class="text-sm font-semibold">Trusted+ <span class="text-xs font-normal text-surface-400">(onsite staff and above)</span></p>
<label class="flex flex-col gap-1">
<span class="text-xs text-surface-500">Result limit</span>
<input
type="number"
class="input input-sm"
min="1"
max="1000"
bind:value={draft.trusted_search_result_limit} />
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-surface-500">Min characters to search</span>
<input
type="number"
class="input input-sm"
min="1"
max="10"
bind:value={draft.trusted_search_min_chars} />
</label>
</div>
</div>
</div>
{/if}
</section>
<!-- ================================================================ -->
<!-- QR CONFIG -->
<!-- ================================================================ -->