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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 -->
|
||||
<!-- ================================================================ -->
|
||||
|
||||
Reference in New Issue
Block a user