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.enable_search_qr = badges_cfg_remote?.enable_search_qr ?? true;
|
||||||
loc.qr_type = badges_cfg_remote?.qr_type ?? null;
|
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)
|
// Passcodes and permissions (may be null)
|
||||||
loc.trusted_passcode = badges_cfg_remote?.trusted_passcode ?? null;
|
loc.trusted_passcode = badges_cfg_remote?.trusted_passcode ?? null;
|
||||||
loc.administrator_passcode = badges_cfg_remote?.administrator_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_upload_badge_li_btn: boolean; // show the "Upload Badge List" button
|
||||||
enable_search_qr: boolean; // enable QR scan search
|
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 code configuration
|
||||||
qr_type: string | null; // QR payload format (e.g. 'badge_id', 'url')
|
qr_type: string | null; // QR payload format (e.g. 'badge_id', 'url')
|
||||||
|
|
||||||
@@ -117,6 +128,13 @@ export interface BadgesLocState {
|
|||||||
trusted?: { can_edit: string[] | '*' };
|
trusted?: { can_edit: string[] | '*' };
|
||||||
administrator?: { can_edit: string[] | '*' };
|
administrator?: { can_edit: string[] | '*' };
|
||||||
} | null;
|
} | 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
|
// Timestamp when the remote config was last mirrored locally
|
||||||
remote_cfg_last_synced_on: string | null;
|
remote_cfg_last_synced_on: string | null;
|
||||||
}
|
}
|
||||||
@@ -177,6 +195,13 @@ export const badges_loc_defaults: BadgesLocState = {
|
|||||||
trusted_passcode: null,
|
trusted_passcode: null,
|
||||||
administrator_passcode: null,
|
administrator_passcode: null,
|
||||||
edit_permissions: 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
|
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)
|
// SCENARIO 2: Fallback broad search (Only if no active filters)
|
||||||
|
// Unauthenticated users must enter a query — never show the full attendee list.
|
||||||
if (
|
if (
|
||||||
event_id &&
|
event_id &&
|
||||||
|
$ae_loc.trusted_access &&
|
||||||
!badges_loc.current.fulltext_search_qry_str &&
|
!badges_loc.current.fulltext_search_qry_str &&
|
||||||
badges_loc.current.qry_printed_status === 'all' &&
|
badges_loc.current.qry_printed_status === 'all' &&
|
||||||
!badges_loc.current.qry_affiliations &&
|
!badges_loc.current.qry_affiliations &&
|
||||||
@@ -135,6 +137,28 @@ let lq__event_badge_obj_li = $derived.by(() => {
|
|||||||
|
|
||||||
// Standardized Reactive Search Pattern (Aether UI V3)
|
// Standardized Reactive Search Pattern (Aether UI V3)
|
||||||
// 1. Isolate dependencies into a stable derived object
|
// 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({
|
let search_params = $derived({
|
||||||
v: badges_loc.current.search_version,
|
v: badges_loc.current.search_version,
|
||||||
str: (badges_loc.current.fulltext_search_qry_str ?? '')
|
str: (badges_loc.current.fulltext_search_qry_str ?? '')
|
||||||
@@ -145,7 +169,9 @@ let search_params = $derived({
|
|||||||
aff: (badges_loc.current.qry_affiliations ?? '').toLowerCase().trim(),
|
aff: (badges_loc.current.qry_affiliations ?? '').toLowerCase().trim(),
|
||||||
sort: badges_loc.current.qry_sort_order,
|
sort: badges_loc.current.qry_sort_order,
|
||||||
event_id: $events_slct?.event_id,
|
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
|
// 2. Controlled effect for triggering searches
|
||||||
@@ -188,6 +214,18 @@ async function handle_search_refresh(params: any) {
|
|||||||
const type_code = params.type;
|
const type_code = params.type;
|
||||||
const printed_status = params.printed;
|
const printed_status = params.printed;
|
||||||
const aff_str = params.aff;
|
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
|
// 2. FAST PATH: Local IDB Search
|
||||||
if (!remote_first) {
|
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
|
const local_ids = local_results
|
||||||
.map((b) => b.event_badge_id)
|
.map((b) => b.event_badge_id)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
@@ -340,7 +381,7 @@ async function handle_search_refresh(params: any) {
|
|||||||
printed_status: printed_status,
|
printed_status: printed_status,
|
||||||
affiliations_qry_str: aff_str || null,
|
affiliations_qry_str: aff_str || null,
|
||||||
order_by_li: order_by_li,
|
order_by_li: order_by_li,
|
||||||
limit: 150,
|
limit: result_limit,
|
||||||
log_lvl: 0
|
log_lvl: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ let { event_id, log_lvl = 0 }: Props = $props();
|
|||||||
|
|
||||||
// *** Import other supporting libraries
|
// *** Import other supporting libraries
|
||||||
import {
|
import {
|
||||||
Library,
|
// Library,
|
||||||
LoaderCircle,
|
LoaderCircle,
|
||||||
QrCode,
|
QrCode,
|
||||||
RemoveFormatting,
|
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 { badges_loc } from '$lib/stores/ae_events_stores__badges.svelte';
|
||||||
import Element_qr_scanner from '$lib/elements/element_qr_scanner.svelte';
|
import Element_qr_scanner from '$lib/elements/element_qr_scanner.svelte';
|
||||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||||
import { liveQuery } from 'dexie';
|
// import { liveQuery } from 'dexie';
|
||||||
import { db_events } from '$lib/ae_events/db_events';
|
// import { db_events } from '$lib/ae_events/db_events';
|
||||||
|
|
||||||
let lq__event_obj = $derived(
|
// let lq__event_obj = $derived(
|
||||||
liveQuery(async () => {
|
// liveQuery(async () => {
|
||||||
if (!event_id) return null;
|
// if (!event_id) return null;
|
||||||
return await db_events.event.get(event_id);
|
// return await db_events.event.get(event_id);
|
||||||
})
|
// })
|
||||||
);
|
// );
|
||||||
|
|
||||||
// ISHLT 2024 badge type codes
|
// ISHLT 2024 badge type codes
|
||||||
let badge_type_code_li = [
|
let badge_type_code_li = [
|
||||||
@@ -46,7 +46,17 @@ let badge_type_code_li = [
|
|||||||
{ code: 'test', name: 'Test' }
|
{ 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() {
|
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++;
|
badges_loc.current.search_version++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +170,12 @@ function handle_qr_scan_result(event: {
|
|||||||
title="Search by name, email, etc. Press Enter." />
|
title="Search by name, email, etc. Press Enter." />
|
||||||
</div>
|
</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">
|
<div class="flex flex-row items-center justify-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -109,7 +109,13 @@ const cfg_defaults: BadgesRemoteCfg = {
|
|||||||
authenticated: { can_edit: [...default_authenticated_can_edit] },
|
authenticated: { can_edit: [...default_authenticated_can_edit] },
|
||||||
trusted: { can_edit: [...default_trusted_can_edit] },
|
trusted: { can_edit: [...default_trusted_can_edit] },
|
||||||
administrator: { 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 });
|
let draft: BadgesRemoteCfg = $state({ ...cfg_defaults });
|
||||||
@@ -230,6 +236,7 @@ async function save() {
|
|||||||
// Section collapse state
|
// Section collapse state
|
||||||
let sections: Record<string, boolean> = $state({
|
let sections: Record<string, boolean> = $state({
|
||||||
ui: true,
|
ui: true,
|
||||||
|
search_limits: true,
|
||||||
qr: true,
|
qr: true,
|
||||||
passcodes: true,
|
passcodes: true,
|
||||||
auth_fields: true,
|
auth_fields: true,
|
||||||
@@ -325,6 +332,95 @@ function toggle(key: string) {
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</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 -->
|
<!-- QR CONFIG -->
|
||||||
<!-- ================================================================ -->
|
<!-- ================================================================ -->
|
||||||
|
|||||||
Reference in New Issue
Block a user