From c4e85b1fe33c20b0b6e9cf4e38a2a93b7ed73904 Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Fri, 27 Feb 2026 15:12:22 -0500 Subject: [PATCH] feat(badges): print/review pages, 4-button list, Lucide icons, permissions doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Badge search results list (ae_comp__badge_obj_li): - 4 action buttons per row: Print, Review (nav link), Copy Link (clipboard), Email Link - Visibility rules: unprinted-only for non-edit mode; all non-hidden for trusted+edit - Plain name display (User/EyeOff icon) — name is no longer a print link - Obscured email for non-trusted users - Debug row (ID, CR, UP, PC, FP, LP) in edit mode - All icons converted to Lucide (Font Awesome removed) Badge print page (/print): - 3 header action buttons: Print Now, Review (nav), Email Link - Removed old [badge_id]/+page.svelte placeholder (moved to trash) - Added is_trusted, is_edit_mode, print state derived vars - "Already printed Nx — last [timestamp]" warning inline with name - Removed unused imports (browser, onMount, events_slct) Badge review page (/review): - 3 header action buttons: Print (nav), Copy Link (clipboard), Email Link - Added events_loc for email placeholder + title event name - Added is_edit_mode, print_count, is_printed, copy_status - FA icons replaced with Lucide (ShieldCheck, UserCheck, User) - Title now includes event name (was missing) Infrastructure: - print/+page.ts and review/+page.ts added (non-blocking badge loaders) - ae_comp__badge_review_form.svelte stub created (fields pending) - Fixed: components no longer write to $ae_loc.edit_mode (critical bug) Docs: - NEW: AE__Permissions_and_Security.md — full permissions hierarchy reference - NEW: PROJECT__AE_Events_Badges_Review_Print.md — agent task brief for review form + print font controls - UPDATED: MODULE__AE_Events_Badges.md rev 5 — field permissions spec, header buttons, still-needed list by priority Co-Authored-By: Claude Sonnet 4.6 --- documentation/AE__Permissions_and_Security.md | 174 ++++++++ documentation/MODULE__AE_Events_Badges.md | 268 +++++++++--- .../PROJECT__AE_Events_Badges_Review_Print.md | 339 ++++++++++++++++ .../events/[event_id]/(badges)/README.md | 7 +- .../(badges)/badges/[badge_id]/+page.svelte | 199 --------- .../[badge_id]/ae_comp__badge_obj_view.svelte | 13 +- .../ae_comp__badge_review_form.svelte | 382 ++++++++++++++++++ .../badges/[badge_id]/print/+page.svelte | 198 ++++++++- .../(badges)/badges/[badge_id]/print/+page.ts | 31 ++ .../badges/[badge_id]/review/+page.svelte | 357 +++++++++++++++- .../badges/[badge_id]/review/+page.ts | 31 ++ .../badges/ae_comp__badge_obj_li.svelte | 330 +++++++++------ src/routes/events/[event_id]/+page.svelte | 4 +- .../events/[event_id]/settings/+page.svelte | 2 +- ...ae_comp__event_settings_badges_form.svelte | 123 +++++- 15 files changed, 2046 insertions(+), 412 deletions(-) create mode 100644 documentation/AE__Permissions_and_Security.md create mode 100644 documentation/PROJECT__AE_Events_Badges_Review_Print.md delete mode 100644 src/routes/events/[event_id]/(badges)/badges/[badge_id]/+page.svelte create mode 100644 src/routes/events/[event_id]/(badges)/badges/[badge_id]/ae_comp__badge_review_form.svelte create mode 100644 src/routes/events/[event_id]/(badges)/badges/[badge_id]/print/+page.ts create mode 100644 src/routes/events/[event_id]/(badges)/badges/[badge_id]/review/+page.ts diff --git a/documentation/AE__Permissions_and_Security.md b/documentation/AE__Permissions_and_Security.md new file mode 100644 index 00000000..0e22fb82 --- /dev/null +++ b/documentation/AE__Permissions_and_Security.md @@ -0,0 +1,174 @@ +# Aether — Permissions and Security + +**Last updated:** 2026-02-27 +**Source of truth:** `src/lib/ae_utils/ae_utils__perm_checks.ts`, `src/lib/stores/ae_stores.ts` + +--- + +## Access Level Hierarchy + +Highest to lowest. Each level **inherits all access from every level below it**. + +| Level | `access_type` string | Typical Use | +| --- | --- | --- | +| Super | `super` | OSIT internal — full system access | +| Manager | `manager` | Account managers | +| Administrator | `administrator` | Event/account admins | +| Trusted | `trusted` | **Onsite staff** — site passcode or AE login | +| Public | `public` | Site-wide passcode granted | +| Authenticated | `authenticated` | Identity verified (e.g. IDAA Novi UUID) | +| Anonymous | `anonymous` | Default — not signed in | + +> **Note on Public vs Authenticated:** `public` is a *site-wide* unlock (anyone with the passcode). `authenticated` verifies a *specific identity*. In the hierarchy, public outranks authenticated because it implies broader site access. + +--- + +## `$ae_loc` Store — Permission Flags + +`$ae_loc` is a `persisted()` store (backed by localStorage). Key fields: + +```typescript +$ae_loc.access_type // string: current access type ('anonymous', 'trusted', etc.) + +// Cumulative boolean flags (true = "you have AT LEAST this level") +$ae_loc.anonymous_access // always true +$ae_loc.authenticated_access // true from authenticated and above +$ae_loc.public_access // true from public and above +$ae_loc.trusted_access // true from trusted and above ← most-used gate +$ae_loc.administrator_access // true from administrator and above +$ae_loc.manager_access // true from manager and above +$ae_loc.super_access // true only at super + +// Exclusive check flags (true = "you are EXACTLY this level") +$ae_loc.trusted_check // true only if access_type === 'trusted' +$ae_loc.administrator_check // etc. +// (rarely needed — prefer the _access flags) + +// Behavior flags +$ae_loc.edit_mode // boolean — user preference, see below +$ae_loc.adv_mode // boolean — advanced mode toggle +``` + +### Additional intermediate levels (in permission checks, not in hierarchy order) +`support`, `assistant`, `verified`, `provisional` — appear in `_access` flags but are not part of the canonical `access_level_order`. Treat as internal/intermediate. + +--- + +## Edit Mode — Critical Rules + +`$ae_loc.edit_mode` is a **user preference**, not a permission level. + +**Rules that must never be broken:** +1. **Components must never write to `$ae_loc.edit_mode`** — only the system menu toggle and sign-out/permission-drop handlers may change it. +2. Edit mode is only available to `trusted` and above in 95% of modules (the toggle is hidden from lower-access users). +3. Edit mode persists across navigation — it is NOT reset by page loads or component mounts. +4. Sign-out and permission drops to below `authenticated` should reset `edit_mode` to `false`. + +> **Background:** A bug was fixed (2026-02-27) where `ae_comp__badge_obj_view.svelte` was writing `$ae_loc.edit_mode = false` in a data-loading `$effect`, silently overriding the user's preference on every navigation to the badge print page. + +--- + +## Authentication Methods + +| Method | Grants | Used For | +| --- | --- | --- | +| Site passcode (`site_access_code_kv`) | `trusted`, `public`, or `authenticated` | Onsite staff and event attendees | +| AE Username + Password | `trusted` and above | Staff with AE accounts | +| Novi UUID | `authenticated` | IDAA members (Novi membership system) | + +Passcodes are stored per-level in `$ae_loc.site_access_code_kv`: +```typescript +site_access_code_kv: { + administrator: null, // highest passcode tier + trusted: null, // onsite staff passcode + public: 'public1980', // example + authenticated: 'auth1980' +} +``` + +--- + +## Utility Functions + +### `process_permission_checks(access_type: string)` +Returns a full permission object (`_check` and `_access` flags) for a given access type string. Used when access type changes to update `$ae_loc`. + +```typescript +import { process_permission_checks } from '$lib/ae_utils/ae_utils__perm_checks'; +const checks = process_permission_checks('trusted'); +// checks.trusted_access === true +// checks.administrator_access === false +``` + +### `compare_access_levels(level_a, level_b)` +Returns `1` if `level_a` is higher, `-1` if lower, `0` if equal. Useful for threshold comparisons. + +--- + +## Privacy and Security Rules + +### IDAA — International Doctors in Alcoholics Anonymous +- **ALL IDAA content is private. Always. No exceptions.** +- BB (Bulletin Board / Posts), Archives, Recovery Meetings — all require authentication. +- IDAA users authenticate via Novi UUID at `authenticated` level or higher. +- A prior agent accidentally exposed IDAA BB data publicly — treat any IDAA exposure as Sev-1. + +### Journals +- Private personal data. Always authenticated. Passcode/encryption features exist. +- Never expose journal content publicly. + +### `PUBLIC_AE_API_SECRET_KEY` +- Ongoing Sev-1 audit. Do not introduce new usages. +- Prefer per-request API key headers (`x-aether-api-key` + `x-account-id`). + +### Email Display +Non-trusted users must never see a full email address. Obscure using: +```typescript +// joh***@example.com +function obscure_email(email: string): string { + const at = email.indexOf('@'); + if (at < 0) return email; + return `${email.slice(0, Math.min(3, at))}***${email.slice(at)}`; +} +``` +This pattern lives in `ae_comp__badge_obj_li.svelte` — move to `ae_utils` if needed elsewhere. + +--- + +## Module-Specific Permission Patterns + +### Events — Badges + +| Scenario | Visibility | Print Action | Review Actions | +| --- | --- | --- | --- | +| Anonymous / below trusted | Unprinted only | None (name display only) | Email Review Link button (→ email API) | +| Trusted, not Edit Mode | Unprinted only | Clickable (first print) | Email Review Link button | +| Trusted, Edit Mode | All non-hidden | Clickable incl. reprint; shows `Nx` count | Email Review Link + direct Review Link (clipboard) | + +- Print count badge: shown as `Nx` (e.g. `2×`) next to the printer icon when `print_count >= 1` +- Edit mode for badges: limited to `trusted_access` users (toggle hidden from lower levels) +- `person_passcode` field (for attendee-gated review URL): **not yet in DB** as of 2026-02-27 + +### IDAA +- Auth gate test must be the **first test** in any test file — privacy enforcement is a hard requirement. +- Default required permission: `trusted_access` or higher for module access. + +--- + +## Common Template Patterns + +```svelte + +{#if $ae_loc.trusted_access} + + +{#if $ae_loc.trusted_access && $ae_loc.edit_mode} + + +{#if $ae_loc.administrator_access} + + +{$ae_loc.trusted_access ? email : obscure_email(email)} +``` + +> Never gate purely on `$ae_loc.edit_mode` without also checking a permission level. Edit mode is a UI preference, not a permission grant. diff --git a/documentation/MODULE__AE_Events_Badges.md b/documentation/MODULE__AE_Events_Badges.md index fc0aa850..01692868 100644 --- a/documentation/MODULE__AE_Events_Badges.md +++ b/documentation/MODULE__AE_Events_Badges.md @@ -3,7 +3,7 @@ **Module Path:** `src/routes/events/[event_id]/(badges)/badges/` **API Module:** `src/lib/ae_events/ae_events__event_badge.ts` **Database:** `db_events.badge` (Dexie IndexedDB table) -**Last Updated:** 2026-02-26 (rev 3) +**Last Updated:** 2026-02-27 (rev 5) --- @@ -64,17 +64,23 @@ display_name = badge.full_name_override || badge.full_name || "-- no name --" // ✅ Display still shows "Bob Smith" ``` -### Override Fields (7 total) +### Override Fields | Regular Field | Override Field | Purpose | Editable By | |---|---|---|---| +| `pronouns` | `pronouns_override` | Preferred pronouns | Staff, Attendee | | `professional_title` | `professional_title_override` | Job title display | Staff, Attendee | -| `full_name` | `full_name_override` | Preferred name, pronouns | Staff, Attendee | +| `full_name` | `full_name_override` | Preferred name display | Staff, Attendee | | `affiliations` | `affiliations_override` | Organization display | Staff, Attendee | +| `phone` | `phone_override` | Phone number | Staff, Attendee | | `email` | `email_override` | Contact email override | Staff only | | `location` | `location_override` | City/State/Country display | Staff, Attendee | -| `badge_type` | `badge_type_override` | Badge category label (varies per Event/Template) | Staff only | -| `badge_type_code` | `badge_type_code_override` | Badge access level code (varies per Event/Template) | Staff only | +| `badge_type` | `badge_type_override` | Badge category label text | Staff only | +| `badge_type_code` | `badge_type_code_override` | Badge access level code | Staff only | +| `registration_type` | `registration_type_override` | Registration category label text | Staff only | +| `registration_type_code` | `registration_type_code_override` | Registration category code | Staff only | + +> **Note:** `phone`, `phone_override`, `pronouns_override`, `registration_type`, `registration_type_code`, `registration_type_override`, `registration_type_code_override` may need to be confirmed against the DB schema via `ae_describe event_badge` and added to `properties_to_save` in `ae_events__event_badge.ts` if not already present. ### Sync Safety Rules @@ -149,18 +155,39 @@ def sync_badge_from_external(external_badge_data, existing_badge): 6. **Manager** — All administrator + event configuration 7. **Super** — All manager + cross-event operations -### Current Implementation (v3) +### Current Implementation (v3) — 2026-02-27 -**Quick Edit Feature** ([badge_id]/+page.svelte → ae_comp__badge_obj_view.svelte) +#### Badge Search Results Visibility -**Edit Mode Trigger:** -- URL hash `#review` enables edit mode -- Sets `$ae_loc.edit_mode = true` -- Shows Save/Cancel buttons +| Access Level | Sees | +| --- | --- | +| Below Trusted (incl. anonymous) | Only badges where `print_count < 1` and not hidden | +| Trusted, not Edit Mode | Only badges where `print_count < 1` and not hidden | +| Trusted + Edit Mode | All badges where `hide === false` (including already-printed) | -**Currently Editable Fields:** +#### Print Button Behavior (per result row) + +| Access Level | Print Action | +| --- | --- | +| Below Trusted | No print action — name shown with User icon, non-interactive | +| Trusted, `print_count < 1` | Clickable link → `/print` page, Printer icon | +| Trusted, `print_count >= 1`, not Edit Mode | Disabled (already printed safety lock), shows `Nx` count | +| Trusted, `print_count >= 1`, Edit Mode | Clickable reprint — shows `Nx` count badge next to icon | + +Print count displayed as `[Printer][2×] Name` when `print_count >= 1`. + +#### Review Area Buttons (per result row, up to 3 buttons total) + +| Button | Visible To | Behavior | +| --- | --- | --- | +| Email Review Link | All users | Placeholder `alert()` — will trigger email API | +| Review Link (clipboard) | Trusted + Edit Mode only | Copies `/review` URL to clipboard; shows `Copied!` feedback | +| *(direct Review link)* | *(future)* | *(not yet implemented as separate nav button)* | + +#### Badge Edit Form (`ae_comp__badge_obj_view.svelte`) + +**Currently editable fields (local `edit_mode_active`, not global `edit_mode`):** ```typescript -// Lines 90-96 in ae_comp__badge_obj_view.svelte editable_full_name_override: string | null editable_professional_title_override: string | null editable_affiliations_override: string | null // textarea @@ -170,37 +197,109 @@ editable_email: string | null editable_badge_type_code: string | null ``` -**UI Components:** -- Input fields shown when `edit_mode_active === true` -- Save button calls `handle_save_changes()` (Line 365) -- Cancel button calls `handle_cancel_changes()` (Line 450) -- Only changed fields sent to API (`update_ae_obj__event_badge`) +- Save button → `handle_save_changes()` — only changed fields sent to API +- Cancel button → `handle_cancel_changes()` — reverts to IDB values +- **IMPORTANT:** This component must NEVER write to `$ae_loc.edit_mode` — it uses its own local `edit_mode_active` flag only. (Bug fixed 2026-02-27) -### Future Planned Enhancement +#### Badge Review Form (`ae_comp__badge_review_form.svelte`) -**Event-Level Configuration:** `event.mod_badges_json.edit_permissions` +Form-based review (NOT a badge render). Used by the `/review` page. +- `can_edit_fields: string[]` prop controls which fields are editable per user level +- `['*']` = administrator (all fields) +- `is_staff: boolean` prop shows/hides the source-data panel +- Fields show "(overridden)" label when an override value differs from the base field + +#### Badge Review Page — Header Buttons (implemented 2026-02-27) + +| Button | Visible To | Behavior | +| --- | --- | --- | +| Back → Search (ArrowLeft) | Staff (`has_staff_access`) only | `` | +| Print (Printer icon) | Trusted+, not printed OR Trusted+Edit if printed | ``, shows `Nx` count if reprinting | +| Copy Link (clipboard) | Trusted + Edit Mode only | Copies review URL to clipboard; `Copied!` feedback for 2s | +| Email Link (Mail icon) | All if not printed; Trusted+Edit if printed | Placeholder `alert()` — email API pending | + +#### Badge Print Page — Header Buttons (implemented 2026-02-27) + +| Button | Visible To | Behavior | +| --- | --- | --- | +| Back → Search (ArrowLeft) | Always (when badge loaded) | `` | +| Print Now (Printer icon) | Trusted+, not printed OR Trusted+Edit if printed | Calls `window.print()` directly (convenience duplicate); print count tracked by component button | +| Review (Eye icon) | Trusted + Edit Mode only | `` nav link | +| Email Link (Mail icon) | All if not printed; Trusted+Edit if printed | Placeholder `alert()` — email API pending | + +#### Badge Review Page — Display Sections (planned, not yet built) + +In addition to the editable form, the review page will display: + +1. **Print status** — print count + first/last print timestamps (read-only) +2. **QR Code** — the attendee's badge QR code for scanning at the badge kiosk (for automatic badge search + print flow). QR generation code may be recoverable from the legacy AE Badge version. +3. **Options** (`other_1_code` through `other_8_code`) — shown as `[✓] Option X` if the field has a value; hidden if empty +4. **Tickets** (`ticket_1_code` through `ticket_8_code`) — shown as `[✓] Ticket X` if the field has a value; hidden if empty + +#### Default Field Permissions (hardcoded for now — Axonius first show, mid-April 2026) + +These are hardcoded in `review/+page.svelte` pending connection to `mod_badges_json.edit_permissions`. + +**Attendee (passcode-authenticated / anonymous with link):** +```typescript +[ + 'pronouns_override', + 'full_name_override', + 'professional_title_override', + 'affiliations_override', + 'phone_override', + 'location_override', + 'allow_tracking', // Exhibitor Leads opt-in + 'agree_to_tc', // Terms & Conditions placeholder +] +``` + +**Trusted Staff and above:** +```typescript +[ + 'pronouns_override', + 'full_name_override', + 'professional_title_override', + 'affiliations_override', + 'email_override', + 'phone_override', + 'location_override', + 'badge_type_code_override', // + badge_type_override (text label) + 'registration_type_code_override', // + registration_type_override (text label) + 'option_1' ... 'option_8', // i.e. other_1_code ... other_8_code + 'ticket_1_code' ... 'ticket_8_code', + 'allow_tracking', + 'agree_to_tc', + 'hide', + 'priority', + 'notes', +] +``` + +**Administrator** — `can_edit_fields = ['*']` (all fields) + +**Badge type options (hardcoded for now):** `member`, `non-member`, `guest`, `exhibitor`, `staff`, `test` +(In future: read from Event Badge Template's configured list) + +**Registration type options:** Same list as badge type for now — identical select options. + +#### Future: Per-Event Configuration + +`event.mod_badges_json.edit_permissions` — placeholder settings UI exists in +`ae_comp__event_settings_badges_form.svelte`. Review page uses hardcoded defaults for now. +The settings form and review page are not yet connected. ```json { "authenticated": { - "can_edit": ["full_name_override", "professional_title_override", "affiliations_override", "location_override"], - "requires_approval": false + "can_edit": ["pronouns_override", "full_name_override", "professional_title_override", "affiliations_override", "phone_override", "location_override", "allow_tracking", "agree_to_tc"] }, "trusted": { - "can_edit": ["full_name_override", "professional_title_override", "affiliations_override", "location_override", "email_override"], - "can_view_all": true, - "requires_approval": false - }, - "administrator": { - "can_edit": "*", - "can_bulk_edit": true, - "can_delete": true + "can_edit": ["*attendee_fields", "email_override", "badge_type_code_override", "registration_type_code_override", "option_x", "ticket_x_code", "allow_tracking", "agree_to_tc", "hide", "priority", "notes"] } } ``` -**Status:** Placeholder UI exists, JSON config not yet implemented. - --- ## Search & Filter Capabilities @@ -377,15 +476,14 @@ async function handle_print_badge() { Button has `data-testid="badge-print-btn"` and shows loading/done/error states with icon feedback. ### Print Workflow -1. **Pre-Print:** Check `print_count` to warn if already printed -2. **Print:** `window.print()` — standard browser print dialog. Works well in Chrome, Chromium, and Firefox. Chrome is the recommended browser for onsite badge printing (most stable in production). -3. **Post-Print:** Handled by `handle_print_badge()` — count + timestamps updated -4. **Audit:** Print history available for staff review +1. **Pre-Print:** Badge print page (`/print`) shows "Already printed N times" warning in screen-only header if `print_count >= 1` +2. **Record:** `handle_print_badge()` updates `print_count`, `print_last_datetime`, and `print_first_datetime` (first print only) via API before printing +3. **Print:** `window.print()` — standard browser print dialog, wired and working (2026-02-27) +4. **Redirect:** After 1 second, `goto(/events/{id}/badges)` returns to search +5. **Audit:** `print_first_datetime` and `print_last_datetime` visible in Edit Mode debug row **Browser vs Electron:** Badge printing does NOT require the Electron native app. The standard browser print dialog works well across Chrome, Chromium, and Firefox. The Electron native app is specialized for the **Events Pres Mgmt Launcher only** and should not be assumed available for badge stations. -**Current Status:** Button records print event and updates IDB. The `window.print()` call still needs to be wired to the button — see Future Enhancements. - --- ## Database Schema @@ -483,18 +581,42 @@ delete_ae_obj_id__event_badge({ event_badge_id, event_id, method }) ### Route Structure ``` /events/[event_id]/(badges)/badges/ -├── +layout.svelte # Layout wrapper (minimal) -├── +page.svelte # Badge list + search -├── ae_comp__badge_search.svelte # Search form + filters -├── ae_comp__badge_obj_li.svelte # Badge list display +├── +layout.svelte # Layout wrapper (minimal) +├── +page.svelte # Badge list + search +├── ae_comp__badge_search.svelte # Search form + filters +├── ae_comp__badge_obj_li.svelte # Badge list display (results) ├── ae_comp__badge_create_form.svelte # (Not actively used) ├── ae_comp__badge_upload_form.svelte # Bulk CSV upload └── [badge_id]/ - ├── +page.svelte # Badge detail container - ├── +page.ts # Badge loader (non-blocking) - └── ae_comp__badge_obj_view.svelte # Badge display + edit + ├── ae_comp__badge_obj_view.svelte # Badge rendering + staff edit + print button + ├── ae_comp__badge_review_form.svelte # Form-based field review/edit (attendee + staff) + ├── print/ + │ ├── +page.ts # Non-blocking badge loader (inc_template: true) + │ └── +page.svelte # Print-focused page — screen header + badge render + └── review/ + ├── +page.ts # Non-blocking badge loader (inc_template: false) + └── +page.svelte # Passcode-gated review page ``` +> **Note:** The old `[badge_id]/+page.svelte` placeholder was removed (2026-02-27). The name link in the search results list now goes directly to `/print`. + +#### Badge Print Page (`/print`) +- Screen-only header (`print:hidden`): "Back to Search" link + "Already printed N times" warning +- Badge rendered via `ae_comp__badge_obj_view` with `is_review_mode={false}` +- Print button inside `ae_comp__badge_obj_view` handles count update → `window.print()` → redirect to search +- Page `` includes badge name + event name + +#### Badge Review Page (`/review`) +- Passcode-gated for attendees — URL `?passcode=...` matched against `badge.person_passcode` + - **Note:** `person_passcode` field is not yet in the DB (as of 2026-02-27). Review page accessible to staff via `trusted_access` without a passcode. +- Access hierarchy (checked in order): + 1. Administrator → full access (`can_edit_fields = ['*']`) + 2. Trusted Staff → staff field set + 3. Attendee with valid passcode → attendee field set + 4. No access → passcode entry form shown +- Uses `ae_comp__badge_review_form.svelte` (NOT badge render) +- "Back to Search" link shown for staff only + ### Key Components **Badge List Page** (`+page.svelte`) @@ -577,19 +699,45 @@ None — all current badge tests passing as of 2026-02-26 (f5e98b8c). ## Known Issues & Future Enhancements ### Known Issues -1. **Test Infrastructure:** Mock API routes not connecting to page requests -2. **Session Cold-Start:** Potential race condition on first load (same as pres mgmt module) -3. **Type Definitions:** Some TypeScript errors on external package types (pre-existing) +1. **Session Cold-Start:** Potential race condition on first load (same as pres mgmt module) +2. **Type Definitions:** Some pre-existing TypeScript errors on external package types (not introduced by badge work) +3. **`person_passcode` not in DB:** Attendee-gated review URL (`?passcode=...`) cannot function until this field is added to the `event_badge` schema. The review page falls back to passcode entry form for non-staff. +4. **Print page CSS:** Badge print rendering and `@page` print styles not yet fine-tuned — expected to need work +5. **`mod_badges_json.edit_permissions` not connected:** Settings UI exists but review page uses hardcoded field defaults -### Future Enhancements -1. **Access-Based Edit Permissions:** Implement JSON config for field-level access control -2. **Print Functionality:** Wire up `window.print()` to the print button. Standard browser print API — works well in Chrome/Chromium/Firefox for badge label printing. Electron is NOT needed. -3. **Batch Operations:** Bulk update, bulk print, bulk export -4. **Audit Log:** Track who edited which fields and when -5. **Photo Badges:** Support badge photo upload and display -6. **Custom Badge Layouts:** Dynamic template selection per badge type -7. **Real-Time Sync:** WebSocket updates for multi-device badge printing stations -8. **Approval Workflow:** Require manager approval for certain field changes +### Implemented (2026-02-27) +- ✅ `window.print()` wired to print button (records count first, then prints, then redirects) +- ✅ Dedicated `/print` page — replaces old `[badge_id]/+page.svelte` placeholder +- ✅ Dedicated `/review` page — passcode-gated, access-tiered +- ✅ `ae_comp__badge_review_form.svelte` — stub created, full form fields pending +- ✅ Badge search results visibility rules (unprinted-only for non-edit, all for trusted+edit) +- ✅ Badge list: 4 action buttons per row (Print, Review nav, Copy Link, Email Link) — all Lucide icons +- ✅ Print page: 3 action buttons in header (Print Now, Review nav, Email Link) — all Lucide icons +- ✅ Review page: 3 action buttons in header (Print nav, Copy Link, Email Link) — all Lucide icons +- ✅ Print button: not shown when already printed (unless Edit Mode) +- ✅ Print count shown as `Nx` badge next to printer icon +- ✅ Email obscuring for non-trusted users +- ✅ Email Review Link button (placeholder alert — email API pending) +- ✅ Direct Review Link clipboard copy (trusted + Edit Mode only) +- ✅ Fixed: components no longer write to `$ae_loc.edit_mode` +- ✅ Settings UI for `edit_permissions` per event (`ae_comp__event_settings_badges_form.svelte`) +- ✅ All badge module icons converted to Lucide (Font Awesome removed from badge routes) + +### Still Needed — HIGH PRIORITY (first show: April 2026) +1. **Badge Review Form — actual fields:** `ae_comp__badge_review_form.svelte` is a stub. Needs full field rendering, edit inputs, save/cancel, and the display-only sections (QR code, print status, option/ticket checkmarks). See `PROJECT__AE_Events_Badges_Review_Print.md` for full spec. +2. **Badge Print Page — font size controls:** Screen-only controls to adjust font size for name, professional title, affiliations, and location sections before printing. See project brief. + +### Still Needed — MEDIUM PRIORITY +1. **Email API for review links:** `send_review_email()` is a placeholder `alert()`. Needs actual email send endpoint. +2. **`person_passcode` DB field:** Add to `event_badge` schema to enable attendee-gated review URLs. +3. **Connect `edit_permissions` config:** Read `mod_badges_json.edit_permissions` in review page instead of hardcoded defaults. +4. **Print page CSS / `@page` styles:** Badge rendering, sizing, and print-specific stylesheet. + +### Still Needed — FUTURE / LOW PRIORITY +1. **Batch Operations:** Bulk update, bulk print, bulk export +2. **Audit Log:** Track who edited which fields and when +3. **Photo Badges:** Support badge photo upload and display +4. **Real-Time Sync:** WebSocket updates for multi-device badge printing stations --- @@ -646,6 +794,6 @@ db_events.badge.toArray().then(console.log) --- -**Document Status:** ✅ Complete -**Last Verified:** 2026-02-26 (rev 3 — all badge tests passing) -**Verified Against:** Code commit f5e98b8c (all data integrity tests passing) +**Document Status:** 🔄 In Progress +**Last Verified:** 2026-02-27 (rev 5 — field permissions spec added, header buttons implemented, review form fields pending) +**Verified Against:** Code as of 2026-02-27 (branch ae_app_3x_llm) diff --git a/documentation/PROJECT__AE_Events_Badges_Review_Print.md b/documentation/PROJECT__AE_Events_Badges_Review_Print.md new file mode 100644 index 00000000..b503707d --- /dev/null +++ b/documentation/PROJECT__AE_Events_Badges_Review_Print.md @@ -0,0 +1,339 @@ +# PROJECT: AE Events Badges — Review Form & Print Font Controls + +**Created:** 2026-02-27 +**Branch:** `ae_app_3x_llm` +**Priority:** HIGH — first live event is Axonius, NYC, mid-April 2026 +**Owner:** Scott Idem / One Sky IT + +--- + +## Context + +The Events Badges module is mostly complete for navigation and search. Two key pieces of +functional UI remain unbuilt and are needed before the first show: + +1. **Badge Review Form** — `ae_comp__badge_review_form.svelte` is currently a stub. It + needs actual field rendering, edit inputs gated by access level, save/cancel API calls, + and display-only sections (QR code, print status, option/ticket checkmarks). + +2. **Badge Print Font Controls** — The print page header needs screen-only controls + (hidden during `window.print()`) to bump font sizes for the name, professional title, + affiliations, and location sections before printing. These only affect the `ae_comp__badge_obj_view.svelte` render — not the page layout/template structural dimensions. + +Read `documentation/MODULE__AE_Events_Badges.md` for full module context before starting. + +--- + +## MANDATORY: Before You Start + +1. Run `ae_describe event_badge` (MCP tool) to confirm which fields actually exist in the + DB. Several fields in the spec below may need to be added to `properties_to_save` in + `src/lib/ae_events/ae_events__event_badge.ts` if they are not already saved to IDB. + +2. Fields to specifically confirm exist in `event_badge` schema: + - `pronouns`, `pronouns_override` + - `phone`, `phone_override` + - `allow_tracking` + - `agree_to_tc` + - `other_1_code` through `other_8_code` (the "option" fields) + - `ticket_1_code` through `ticket_8_code` + - `registration_type`, `registration_type_code` + - `registration_type_override`, `registration_type_code_override` + +3. Run `npx svelte-check` before committing. Baseline is **77 errors** (all pre-existing, + none in the badge module files). Do not introduce new errors. + +4. Do NOT write to `$ae_loc.edit_mode` from any badge component. This was a critical + bug (fixed 2026-02-27). See `documentation/AE__Permissions_and_Security.md`. + +--- + +## TASK 1: Badge Review Form (HIGH PRIORITY) + +### File to build +`src/routes/events/[event_id]/(badges)/badges/[badge_id]/ae_comp__badge_review_form.svelte` + +This component is already imported and used by `review/+page.svelte`. Props it receives: + +```typescript +interface Props { + event_id: string; + event_badge_id: string; + lq__event_badge_obj: any; // Svelte 5 store from liveQuery + can_edit_fields: string[]; // Which fields this user can edit + is_staff: boolean; // True if trusted_access or higher + log_lvl?: number; +} +``` + +`can_edit_fields` values: +- `['*']` — administrator (all fields) +- Array of field names — specific editable fields +- `[]` — read-only (shouldn't normally reach this component, but handle it) + +### Helper + +Use a helper derived inside the component: +```typescript +function can_edit(field: string): boolean { + return can_edit_fields.includes('*') || can_edit_fields.includes(field); +} +``` + +### Save / Cancel Pattern + +Follow the Journals module pattern (`src/lib/ae_journals/`). Key points: +- Use `import { events_func } from '$lib/ae_events_functions'` +- Call `events_func.update_ae_obj__event_badge({ event_badge_id, event_id, data_kv })` +- Only send changed fields in `data_kv` (compare against `$lq__event_badge_obj` values) +- Show save/cancel buttons only when something has changed (`has_changes` derived) +- Show a success/error state briefly after save (1-2 seconds, then reset) +- Cancel resets local state back to `$lq__event_badge_obj` values +- Use `data-testid="badge-review-save-btn"` and `data-testid="badge-review-cancel-btn"` + +### Save API Call + +```typescript +await events_func.update_ae_obj__event_badge({ + api_cfg: $ae_api, // from ae_loc store or passed as prop — check how ae_comp__badge_obj_view.svelte does it + event_badge_id: event_badge_id, + event_id: event_id, + data_kv: { /* only changed fields */ } +}); +``` + +Check `ae_comp__badge_obj_view.svelte` for the existing save pattern — it already works +and can be used as reference. + +--- + +### Section 1: Display-Only Status Bar (all access levels) + +Always show at top of form. Read-only. No edit controls. + +``` +Print Status: [Not yet printed] OR [Printed 3× — first: Jan 5 2026, last: Jan 5 2026] +``` + +Use `$lq__event_badge_obj.print_count`, `print_first_datetime`, `print_last_datetime`. +Format datetimes with `ae_util.iso_datetime_formatter(dt, 'datetime_iso_12_no_seconds')`. +Import `ae_util` from `$lib/ae_utils/ae_utils`. + +--- + +### Section 2: QR Code (all access levels) + +Display the attendee's badge QR code. This is the same QR code shown on the printed badge +itself — scanning it at the badge station triggers automatic badge search and print. + +**Check the legacy AE Badge version for existing QR generation code.** Look in: +- `src/lib/ae_events/` for any QR-related utilities +- `ae_comp__badge_obj_view.svelte` — the badge render component almost certainly generates + a QR code already for the printed badge. Reuse that logic/component if possible. + +The QR code value should encode the badge ID or a URL that resolves to the badge. + +--- + +### Section 3: Editable Fields + +Render each field as: read-only display when `!can_edit(field)`, or an `<input>` / +`<select>` / `<textarea>` when `can_edit(field)`. + +Show `(overridden)` label next to override fields when the override value differs from +the base field value. + +#### Attendee-Editable Fields (shown to all access levels with link) + +| Field | Input Type | Notes | +|---|---|---| +| `pronouns_override` | text input | Fallback display: `pronouns` | +| `full_name_override` | text input | Fallback display: `full_name` | +| `professional_title_override` | text input | Fallback display: `professional_title` | +| `affiliations_override` | textarea | Fallback display: `affiliations` | +| `phone_override` | text input (tel) | Fallback display: `phone` | +| `location_override` | text input | Fallback display: `location` | +| `allow_tracking` | checkbox | Label: "Allow exhibitor lead scanning" | +| `agree_to_tc` | checkbox | Label: "I agree to the Terms and Conditions" + placeholder T&C text block | + +#### Staff-Only Additional Fields (shown when `is_staff === true`) + +| Field | Input Type | Notes | +|---|---|---| +| `email_override` | email input | Fallback display: `email` | +| `badge_type_code_override` | select | Options: member, non-member, guest, exhibitor, staff, test; also updates `badge_type_override` text | +| `registration_type_code_override` | select | Same options as badge_type for now; also updates `registration_type_override` | +| `hide` | checkbox | Label: "Hidden from search results" | +| `priority` | number input | | +| `notes` | textarea | | + +#### Staff-Only: Options & Tickets (read-edit, shown when `is_staff === true`) + +**Other/Options** (`other_1_code` through `other_8_code`): +- If field has a value: show as editable text input with label "Option X" +- If field is empty/null: show faintly as "Option X (empty)" — staff can still set it +- These represent event-specific add-ons or membership indicators + +**Tickets** (`ticket_1_code` through `ticket_8_code`): +- Same pattern as options above, label "Ticket X" + +#### Attendee-Only: Options & Tickets (display only) + +When `!is_staff` and the field has a value: show `[✓] Option X` or `[✓] Ticket X`. +When the field is empty: hide entirely (attendees don't see empty slots). + +--- + +### Section 4: Terms & Conditions Block (all, only when `agree_to_tc` in can_edit_fields) + +Placeholder text for now: +``` +By checking this box, I confirm that the information on my badge is correct to the best +of my knowledge. I agree that this badge may be used for identification purposes during +the event and that my attendance may be recorded by exhibitors using the lead scanning +feature if I permit it. +``` + +Show this before the `agree_to_tc` checkbox. If `agree_to_tc` is not in `can_edit_fields`, +hide the entire block. + +--- + +### Field State Pattern (Svelte 5 runes) + +```typescript +// Initialize local editable state from badge object +let local_full_name_override = $state($lq__event_badge_obj?.full_name_override ?? ''); +let local_pronouns_override = $state($lq__event_badge_obj?.pronouns_override ?? ''); +// ... etc for each editable field + +// Detect changes +let has_changes = $derived( + local_full_name_override !== ($lq__event_badge_obj?.full_name_override ?? '') + || local_pronouns_override !== ($lq__event_badge_obj?.pronouns_override ?? '') + // ... etc +); + +// Build changed-fields-only payload +function build_save_payload(): Record<string, any> { + const payload: Record<string, any> = {}; + if (local_full_name_override !== ($lq__event_badge_obj?.full_name_override ?? '')) + payload.full_name_override = local_full_name_override || null; // empty string → null + // ... etc + return payload; +} +``` + +**Important:** Empty string inputs should save as `null` (clears the override, falls back +to base field). Use `value || null` in the payload. + +--- + +## TASK 2: Badge Print Font Size Controls (MEDIUM PRIORITY) + +### Where to add + +`src/routes/events/[event_id]/(badges)/badges/[badge_id]/print/+page.svelte` + +Add a screen-only (`print:hidden`) control panel between the header and the badge render. +This panel lets staff adjust font sizes for the four text-heavy sections before clicking Print. + +### Controls needed + +``` +Font Size Controls (screen only, hidden during print): +[Name] [−] [14px] [+] +[Title] [−] [12px] [+] +[Affiliations] [−] [11px] [+] +[Location] [−] [10px] [+] +``` + +- Start with sensible defaults (match what `ae_comp__badge_obj_view.svelte` currently uses) +- Min/max per field (e.g., 8px–24px for name, 7px–18px for others) +- Pass the sizes as props into `ae_comp__badge_obj_view` + +### Props to add to `ae_comp__badge_obj_view.svelte` + +`ae_comp__badge_obj_view.svelte` currently has internal font size logic. It needs to +accept optional override props: + +```typescript +// New optional props: +font_size_name?: number; // px +font_size_title?: number; // px +font_size_affiliations?: number; // px +font_size_location?: number; // px +``` + +When these props are provided, use them instead of the internally computed sizes. +When not provided, fall back to existing auto-sizing behavior. + +**IMPORTANT:** Do NOT touch structural dimensions (overall badge width/height, header/footer +sizes, template layout). Only the text content font sizes. + +--- + +## Key Files + +| File | Role | +|---|---| +| `[badge_id]/ae_comp__badge_review_form.svelte` | **BUILD THIS** — review form stub | +| `[badge_id]/ae_comp__badge_obj_view.svelte` | Badge render + print button; add font size props | +| `[badge_id]/print/+page.svelte` | Print page; add font size control panel | +| `[badge_id]/review/+page.svelte` | Review page; already wired, passes `can_edit_fields` | +| `src/lib/ae_events/ae_events__event_badge.ts` | API functions: `update_ae_obj__event_badge` | +| `src/lib/ae_events/db_events.ts` | Dexie schema — `properties_to_save` for badge | +| `src/lib/ae_utils/ae_utils.ts` | `ae_util.iso_datetime_formatter()` | +| `documentation/MODULE__AE_Events_Badges.md` | Full module reference | +| `documentation/AE__Permissions_and_Security.md` | Permission flags, edit_mode rules | +| `documentation/GUIDE__AE_API_V3_for_Frontend.md` | V3 API reference | + +## Access Level Reference + +```typescript +// From $ae_loc store (persisted localStorage) +$ae_loc.trusted_access // true = trusted and above (onsite staff) +$ae_loc.administrator_access // true = administrator and above +$ae_loc.edit_mode // boolean — user preference toggle (NEVER write to this from components) +``` + +`is_staff` prop on the review form = `administrator_access || trusted_access`. + +--- + +## Patterns to Follow + +- **Canonical module reference:** `src/lib/ae_journals/` — most complete, most advanced +- **Svelte 5 runes:** `$state`, `$derived`, `$derived.by()`, `$effect` — no legacy `$:` syntax +- **Icons:** Lucide Svelte only — `import { Save, X, Check, ... } from 'lucide-svelte'` +- **No Font Awesome** (`fas fa-*`) anywhere in the badge module +- **Styling:** Tailwind CSS v4 + Skeleton UI utility classes (`btn`, `preset-tonal-*`, `input`, `card`) +- **Commits:** Atomic — one component per commit; run `npx svelte-check` before every commit + +--- + +## What NOT to Do + +- Do NOT touch `@page` CSS or badge template structural dimensions — print layout is out of scope +- Do NOT write to `$ae_loc.edit_mode` from any component +- Do NOT connect `mod_badges_json.edit_permissions` yet — hardcoded field lists are intentional for now +- Do NOT implement the email API — `send_review_email()` placeholder stays as `alert()` +- Do NOT add `person_passcode` DB field — out of scope for this sprint + +--- + +## Testing + +Run existing badge tests after any changes: +```bash +npm run test:unit +npx playwright test tests/events/badges/ +``` + +Baseline: all badge tests passing as of 2026-02-26 (`f5e98b8c`). + +Add `data-testid` attributes to key interactive elements: +- `badge-review-save-btn` +- `badge-review-cancel-btn` +- `badge-review-full-name-input` +- `badge-review-agree-to-tc-checkbox` diff --git a/src/routes/events/[event_id]/(badges)/README.md b/src/routes/events/[event_id]/(badges)/README.md index 536c43a6..b7b9e4f8 100644 --- a/src/routes/events/[event_id]/(badges)/README.md +++ b/src/routes/events/[event_id]/(badges)/README.md @@ -1,3 +1,6 @@ -# Aether (AE) Event Badges Module (v3) +# Aether (AE) Events - Badges Module (v3) -This directory contains the files for the new Event Badges module (v3). Detailed documentation to follow. +This directory contains the files for the new Event Badges module (v3). + +Detailed documentation can be found here: +@documentation/MODULE__AE_Events_Badges.md diff --git a/src/routes/events/[event_id]/(badges)/badges/[badge_id]/+page.svelte b/src/routes/events/[event_id]/(badges)/badges/[badge_id]/+page.svelte deleted file mode 100644 index b6313441..00000000 --- a/src/routes/events/[event_id]/(badges)/badges/[badge_id]/+page.svelte +++ /dev/null @@ -1,199 +0,0 @@ -<script lang="ts"> - import { untrack } from 'svelte'; - interface Props { - /** @type {import('./$types').PageData} */ - data: any; - log_lvl?: number; - } - - let { data, log_lvl = 0 }: Props = $props(); - - // *** Import Svelte specific - // import { goto } from '$app/navigation'; - - // *** Import other supporting libraries - import { browser } from '$app/environment'; - import { liveQuery } from 'dexie'; - - // *** Import Aether specific variables and functions - // import type { key_val } from '$lib/ae_stores'; - // import { ae_util } from '$lib/ae_utils/ae_utils'; - // import { core_func } from '$lib/ae_core_functions'; - import { ae_loc } from '$lib/stores/ae_stores'; - - import { db_events } from '$lib/ae_events/db_events'; - // import { ae_snip, ae_loc, ae_sess, ae_api, ae_trig, slct, slct_trigger } from '$lib/ae_stores'; - import { - events_loc, - events_sess, - events_slct, - events_trigger - } from '$lib/stores/ae_events_stores'; - // import { events_func } from '$lib/ae_events_functions'; - - import Comp_badge_obj_view from './ae_comp__badge_obj_view.svelte'; - import { page } from '$app/state'; - import { LoaderCircle } from 'lucide-svelte'; - - // *** Variables - // Use page.params for robust reactivity in Svelte 5 - let event_badge_id = $derived(page.params.badge_id); - - // Track if we are waiting for initial IDB result - let is_loading_idb = $state(true); - - // let url_test_val = $derived(data.url.searchParams.get('test_val')); - // $effect(() => { - // console.log(`URL test_val = ${url_test_val}`); - // }); - - let lq__event_badge_obj = $derived( - liveQuery(async () => { - if (!event_badge_id) return null; - if (log_lvl) { - console.log( - `*** LiveQuery: lq__event_badge_obj *** event_badge_id=${event_badge_id}` - ); - } - let results = await db_events.badge.get(event_badge_id); - - if (log_lvl) { - console.log( - `*** LiveQuery: lq__event_badge_obj *** results=`, - results - ); - } - return results; - }) - ); - - // SIDE EFFECT: Update loading state when the observable value changes - $effect(() => { - if ($lq__event_badge_obj !== undefined) { - untrack(() => is_loading_idb = false); - } - }); - - let lq__event_badge_template_obj = $derived( - liveQuery(async () => { - let results = await db_events.badge_template.get( - $lq__event_badge_obj?.event_badge_template_id ?? '' - ); // null or undefined does not reset things like '' does - - if (log_lvl) { - console.log( - `*** LiveQuery: lq__event_badge_template_obj *** event_badge_template_id=${ - $lq__event_badge_obj?.event_badge_template_id ?? '' - }`, - results - ); - } - - // Check if results are different than the current session version stored under $events_slct - // if ($events_slct.event_badge_obj && results) { - // if (JSON.stringify($events_slct.event_badge_obj) !== JSON.stringify(results)) { - // $events_slct.event_badge_obj = { ...results }; - // } - // } - - return results; - }) - ); - - let is_review_mode: boolean = $state(false); - - // *** Functions and Logic - - import { onMount } from 'svelte'; - let lq__event_obj: any = $state(undefined); - - onMount(() => { - const observable = liveQuery(() => - db_events.event.get($events_slct?.event_id ?? '') - ); - const subscription = observable.subscribe((value) => { - lq__event_obj = value; - }); - - if (browser && window.location.hash === '#review') { - is_review_mode = true; - $ae_loc.edit_mode = true; - } else { - is_review_mode = false; - $ae_loc.edit_mode = false; - } - - return () => { - subscription.unsubscribe(); - }; - }); -</script> - -<svelte:head> - <title> - Æ: Badge - - {$lq__event_badge_obj?.given_name ?? '-- not set --'} - {$lq__event_badge_obj?.family_name - ? $lq__event_badge_obj?.family_name.charAt(0) + '.' - : ''} - - Badges v3 - - {$events_loc?.title} - - - - - - - -{#if $lq__event_badge_obj} -
-

- Badge: - {#if $lq__event_badge_obj.full_name} - {$lq__event_badge_obj.full_name} - {:else if $lq__event_badge_obj.given_name} - {$lq__event_badge_obj.given_name} - {:else} - -- no name -- - {/if} -

-
- - Back to Search - -
- - {#if $lq__event_badge_obj && $lq__event_badge_obj.event_id && event_badge_id} - - {/if} -{:else if is_loading_idb || !event_badge_id} -
- -

Loading Badge Details...

-
-{:else} -
-

Badge Not Found

-

No record found locally for ID: {event_badge_id}

- -
-{/if} diff --git a/src/routes/events/[event_id]/(badges)/badges/[badge_id]/ae_comp__badge_obj_view.svelte b/src/routes/events/[event_id]/(badges)/badges/[badge_id]/ae_comp__badge_obj_view.svelte index 1ced7ea1..e1649c8a 100644 --- a/src/routes/events/[event_id]/(badges)/badges/[badge_id]/ae_comp__badge_obj_view.svelte +++ b/src/routes/events/[event_id]/(badges)/badges/[badge_id]/ae_comp__badge_obj_view.svelte @@ -125,12 +125,12 @@ editable_badge_type_code = $lq__event_badge_obj.badge_type_code ?? null; + // Only set the local edit state — never touch $ae_loc.edit_mode here. + // $ae_loc.edit_mode is a user preference; this component must not override it. if (is_review_mode) { edit_mode_active = true; - $ae_loc.edit_mode = true; } else { - edit_mode_active = false; // Ensure it starts off if not in review mode - $ae_loc.edit_mode = false; + edit_mode_active = false; } } }); @@ -420,7 +420,6 @@ update_complete = true; if (!is_review_mode) { edit_mode_active = false; - $ae_loc.edit_mode = false; } return; } @@ -437,7 +436,6 @@ update_complete = true; if (!is_review_mode) { edit_mode_active = false; - $ae_loc.edit_mode = false; } // Optionally, refresh the $lq__event_badge_obj if needed, though Dexie might handle it } catch (error) { @@ -469,7 +467,6 @@ } if (!is_review_mode) { edit_mode_active = false; - $ae_loc.edit_mode = false; } update_status = 'idle'; update_complete = true; @@ -509,6 +506,9 @@ print_status = 'done'; console.log(`Badge printed. Count: ${data_to_update.print_count}`); + // Trigger browser print dialog (records the print count first so it's always logged) + if (browser) window.print(); + // Brief success flash, then return to badge search setTimeout(() => { goto(`/events/${event_id}/badges`); @@ -658,7 +658,6 @@ onkeypress={() => { " onclick={() => { edit_mode_active = true; - $ae_loc.edit_mode = true; }} title="Edit Badge Information" data-testid="badge-edit-btn" diff --git a/src/routes/events/[event_id]/(badges)/badges/[badge_id]/ae_comp__badge_review_form.svelte b/src/routes/events/[event_id]/(badges)/badges/[badge_id]/ae_comp__badge_review_form.svelte new file mode 100644 index 00000000..7d1b154c --- /dev/null +++ b/src/routes/events/[event_id]/(badges)/badges/[badge_id]/ae_comp__badge_review_form.svelte @@ -0,0 +1,382 @@ + + +{#if $lq__event_badge_obj} +
+ + + {#if is_staff} +
+

Source Data (from registration system — read only)

+
+ Given Name + {$lq__event_badge_obj.given_name ?? '—'} + Family Name + {$lq__event_badge_obj.family_name ?? '—'} + Full Name + {$lq__event_badge_obj.full_name ?? '—'} + Professional Title + {$lq__event_badge_obj.professional_title ?? '—'} + Affiliations + {$lq__event_badge_obj.affiliations ?? '—'} + Location + {$lq__event_badge_obj.location ?? '—'} + Badge Type + {$lq__event_badge_obj.badge_type_code ?? '—'} +
+
+ {/if} + + +
+

+ Fields below are what will appear on your printed badge. + {#if can_edit_fields.length > 0} + You may edit the highlighted fields. + {/if} +

+ + + {#if can_edit('full_name_override') || is_staff} +
+ + {#if can_edit('full_name_override')} + + {:else} +

+ {$lq__event_badge_obj.full_name_override ?? $lq__event_badge_obj.full_name ?? '—'} +

+ {/if} +
+ {/if} + + + {#if can_edit('professional_title_override') || is_staff} +
+ + {#if can_edit('professional_title_override')} + + {:else} +

+ {$lq__event_badge_obj.professional_title_override ?? $lq__event_badge_obj.professional_title ?? '—'} +

+ {/if} +
+ {/if} + + + {#if can_edit('affiliations_override') || is_staff} +
+ + {#if can_edit('affiliations_override')} + + {:else} +

+ {$lq__event_badge_obj.affiliations_override ?? $lq__event_badge_obj.affiliations ?? '—'} +

+ {/if} +
+ {/if} + + + {#if can_edit('location_override') || is_staff} +
+ + {#if can_edit('location_override')} + + {:else} +

+ {$lq__event_badge_obj.location_override ?? $lq__event_badge_obj.location ?? '—'} +

+ {/if} +
+ {/if} + + + {#if can_edit('email')} +
+ + +
+ {/if} + + + {#if can_edit('badge_type_code')} +
+ + +
+ {/if} +
+ + + {#if can_edit_fields.length > 0} +
+ + + {#if save_status !== 'done'} + + {/if} +
+ {/if} + + + {#if is_staff && $lq__event_badge_obj.print_count !== undefined} +
+

Print count: {$lq__event_badge_obj.print_count ?? 0}

+ {#if $lq__event_badge_obj.print_first_datetime} +

First printed: {new Date($lq__event_badge_obj.print_first_datetime).toLocaleString()}

+ {/if} + {#if $lq__event_badge_obj.print_last_datetime} +

Last printed: {new Date($lq__event_badge_obj.print_last_datetime).toLocaleString()}

+ {/if} +
+ {/if} +
+{/if} diff --git a/src/routes/events/[event_id]/(badges)/badges/[badge_id]/print/+page.svelte b/src/routes/events/[event_id]/(badges)/badges/[badge_id]/print/+page.svelte index 1eb77c40..c1832334 100644 --- a/src/routes/events/[event_id]/(badges)/badges/[badge_id]/print/+page.svelte +++ b/src/routes/events/[event_id]/(badges)/badges/[badge_id]/print/+page.svelte @@ -1,7 +1,199 @@ -

Print Badges

+ + + Æ: Print Badge — + {$lq__event_badge_obj?.full_name_override ?? $lq__event_badge_obj?.full_name ?? '—'} + {$events_loc?.title ? ` — ${$events_loc.title}` : ''} + + -

This page will be used for printing badges.

+{#if $lq__event_badge_obj && $lq__event_badge_obj.event_id && event_badge_id} + + +
+ + +
+ + + + +
+

+ {$lq__event_badge_obj.full_name_override ?? $lq__event_badge_obj.full_name ?? '— no name —'} +

+ {#if is_printed} +

+ Printed {print_count}× + {#if $lq__event_badge_obj.print_last_datetime} + — last {new Date($lq__event_badge_obj.print_last_datetime).toLocaleString()} + {/if} +

+ {/if} +
+
+ + +
+ + + {#if is_trusted && (!is_printed || is_edit_mode)} + + {/if} + + + {#if is_trusted && is_edit_mode} + + + + + {/if} + + + {#if !is_printed || (is_trusted && is_edit_mode)} + + {/if} + +
+
+ + + + +{:else if is_loading_idb || !event_badge_id} +
+ +

Loading Badge...

+
+{:else} +
+

Badge Not Found

+

No record found for ID: {event_badge_id}

+
+ + + Back to Search + +
+
+{/if} diff --git a/src/routes/events/[event_id]/(badges)/badges/[badge_id]/print/+page.ts b/src/routes/events/[event_id]/(badges)/badges/[badge_id]/print/+page.ts new file mode 100644 index 00000000..cbce69b0 --- /dev/null +++ b/src/routes/events/[event_id]/(badges)/badges/[badge_id]/print/+page.ts @@ -0,0 +1,31 @@ +/** @type {import('./$types').PageLoad} */ +console.log(`Events - Badges [badge_id]/print +page.ts start`); + +import { browser } from '$app/environment'; +import { events_func } from '$lib/ae_events_functions'; + +// Loads badge + template for the print page (non-blocking background refresh) +export async function load({ params, parent }) { + const log_lvl: number = 0; + + const parent_data = await parent(); + const account_id = parent_data.account_id; + const ae_acct = parent_data[account_id]; + + if (browser) { + const event_id = params.event_id; + const event_badge_id = params.badge_id; + + if (event_badge_id) { + events_func.load_ae_obj_id__event_badge({ + api_cfg: ae_acct.api, + event_badge_id: event_badge_id, + event_id: event_id, + inc_template: true, + log_lvl: log_lvl + }); + } + } + + return { params }; +} diff --git a/src/routes/events/[event_id]/(badges)/badges/[badge_id]/review/+page.svelte b/src/routes/events/[event_id]/(badges)/badges/[badge_id]/review/+page.svelte index a06feff6..26522173 100644 --- a/src/routes/events/[event_id]/(badges)/badges/[badge_id]/review/+page.svelte +++ b/src/routes/events/[event_id]/(badges)/badges/[badge_id]/review/+page.svelte @@ -1,7 +1,358 @@ -

Review Badges

+ + + Æ: Review Badge — + {$lq__event_badge_obj?.full_name_override ?? $lq__event_badge_obj?.full_name ?? '—'} + {$events_loc?.title ? ` — ${$events_loc.title}` : ''} + + -

This page will be used for reviewing badges.

+{#if $lq__event_badge_obj} + + +
+ + +
+ {#if has_staff_access} + + + + + {/if} +
+

Review Badge

+

+ {$lq__event_badge_obj.full_name_override ?? $lq__event_badge_obj.full_name ?? '— no name —'} +

+
+
+ + +
+ + + {#if is_trusted && (!is_printed || is_edit_mode)} + + + {#if is_printed} + {print_count}× + {/if} + + + {/if} + + + {#if is_trusted && is_edit_mode} + + {/if} + + + {#if !is_printed || (is_trusted && is_edit_mode)} + + {/if} + +
+
+ + {#if has_any_access} +
+ + + {#if is_administrator} +

+ + Administrator access — all fields editable +

+ {:else if is_trusted} +

+ + Staff access — extended fields editable +

+ {:else if has_attendee_access} +

+ + Reviewing your badge information +

+ {/if} + + +
+ + {:else if !passcode_checked && !url_passcode} + +
+

Enter Your Passcode

+

+ Enter the passcode from your badge review email to view and update your badge information. +

+
+ + { if (e.key === 'Enter') check_passcode(entered_passcode); }} + data-testid="badge-review-passcode-input" + /> +
+ {#if passcode_error} +

{passcode_error}

+ {/if} + +
+ + {:else if passcode_checked && !passcode_valid} + +
+
+

Access Denied

+
+

{passcode_error}

+ +
+ {/if} + +{:else if is_loading_idb || !event_badge_id} +
+ +

Loading Badge Information…

+
+{:else} +
+

Badge Not Found

+

No record found for ID: {event_badge_id}

+ +
+{/if} diff --git a/src/routes/events/[event_id]/(badges)/badges/[badge_id]/review/+page.ts b/src/routes/events/[event_id]/(badges)/badges/[badge_id]/review/+page.ts new file mode 100644 index 00000000..64f6a6c9 --- /dev/null +++ b/src/routes/events/[event_id]/(badges)/badges/[badge_id]/review/+page.ts @@ -0,0 +1,31 @@ +/** @type {import('./$types').PageLoad} */ +console.log(`Events - Badges [badge_id]/review +page.ts start`); + +import { browser } from '$app/environment'; +import { events_func } from '$lib/ae_events_functions'; + +// Loads badge + template for the review page (non-blocking background refresh) +export async function load({ params, parent }) { + const log_lvl: number = 0; + + const parent_data = await parent(); + const account_id = parent_data.account_id; + const ae_acct = parent_data[account_id]; + + if (browser) { + const event_id = params.event_id; + const event_badge_id = params.badge_id; + + if (event_badge_id) { + events_func.load_ae_obj_id__event_badge({ + api_cfg: ae_acct.api, + event_badge_id: event_badge_id, + event_id: event_id, + inc_template: false, // Review form doesn't need template + log_lvl: log_lvl + }); + } + } + + return { params }; +} diff --git a/src/routes/events/[event_id]/(badges)/badges/ae_comp__badge_obj_li.svelte b/src/routes/events/[event_id]/(badges)/badges/ae_comp__badge_obj_li.svelte index 34b636f2..0c80866c 100644 --- a/src/routes/events/[event_id]/(badges)/badges/ae_comp__badge_obj_li.svelte +++ b/src/routes/events/[event_id]/(badges)/badges/ae_comp__badge_obj_li.svelte @@ -19,40 +19,93 @@ hide_badge_type = false }: Props = $props(); - // import { type Badge as BadgeType } from '$lib/ae_events/db_events'; import { ae_loc } from '$lib/stores/ae_stores'; + import { events_loc } from '$lib/stores/ae_events_stores'; import { ae_util } from '$lib/ae_utils/ae_utils'; import { LoaderCircle, - Badge, Check, + Eye, EyeOff, Mail, MapPin, Tags, - FileSearch + FileSearch, + Link, + Printer, + User } from 'lucide-svelte'; - // Derived list of visible items (Standardized Pattern 2026-01-27) + // Track per-badge copy state for the "Review Link" clipboard button + let copy_status: Record = $state({}); + + // Access level shortcuts + let is_trusted = $derived($ae_loc.trusted_access === true); + let is_edit_mode = $derived($ae_loc.edit_mode === true); + + /** + * Obscures an email address for display to non-trusted users. + * e.g. john.doe@example.com → joh***@example.com + */ + function obscure_email(email: string | null | undefined): string { + if (!email) return ''; + const at = email.indexOf('@'); + if (at < 0) return email; + const visible = email.slice(0, Math.min(3, at)); + return `${visible}***${email.slice(at)}`; + } + + function build_review_url(event_badge_obj: any): string { + // TODO: append ?passcode=... when person_passcode is added to the event_badge schema + return `/events/${event_badge_obj.event_id}/badges/${event_badge_obj.event_badge_id}/review`; + } + + async function copy_review_link(event_badge_obj: any) { + const url = build_review_url(event_badge_obj); + const full_url = window.location.origin + url; + try { + await navigator.clipboard.writeText(full_url); + copy_status[event_badge_obj.event_badge_id] = 'copied'; + setTimeout(() => { + copy_status[event_badge_obj.event_badge_id] = 'idle'; + }, 2000); + } catch { + console.error('Clipboard write failed'); + } + } + + // TODO: replace alert with actual email API call when available + function send_review_email(event_badge_obj: any) { + const name = + event_badge_obj?.full_name_override + ?? event_badge_obj?.full_name + ?? `${event_badge_obj?.given_name ?? ''} ${event_badge_obj?.family_name ?? ''}`.trim(); + const email = is_trusted + ? (event_badge_obj?.email ?? '(no email on file)') + : obscure_email(event_badge_obj?.email); + const event_name = $events_loc?.title ?? 'this event'; + alert(`PLACEHOLDER: An email will be sent to ${name} at ${email}. Use that link to review your ${event_name} badge.`); + } + let visible_badge_obj_li = $derived( (() => { const list = $lq__event_badge_obj_li; - if (list === undefined || list === null) return null; if (!Array.isArray(list)) return []; const filtered = list.filter((item: any) => { if (!item) return false; - // ADMIN/TRUSTED: See everything - if ($ae_loc.trusted_access) return true; - // PUBLIC: Filter hidden - return !item.hide; + if (is_trusted && is_edit_mode) { + // Edit Mode: show all non-hidden, including already-printed + return !item.hide; + } + // Everyone else (non-trusted or trusted not in edit mode): + // Only show badges that have not been printed yet + return (item.print_count ?? 0) < 1 && !item.hide; }); if (log_lvl) - console.log( - `visible_badge_obj_li: Input=${list.length}, Output=${filtered.length}` - ); + console.log(`visible_badge_obj_li: Input=${list.length}, Output=${filtered.length}`); return filtered; })() ); @@ -67,21 +120,22 @@

Loading badges...

{:else if visible_badge_obj_li.length > 0} -
+

Results:

- - {visible_badge_obj_li.length}× + + {visible_badge_obj_li.length}×
    {#each visible_badge_obj_li as event_badge_obj (event_badge_obj.event_badge_id)} + {@const print_count = event_badge_obj.print_count ?? 0} + {@const is_printed = print_count >= 1} + {@const display_name = + event_badge_obj?.full_name_override + ?? event_badge_obj?.full_name + ?? `${event_badge_obj?.given_name ?? ''} ${event_badge_obj?.family_name ?? ''}`.trim()} +
  • -
    -
    - +
    + + +
    + + + {#if event_badge_obj?.hide} - + {:else} - + {/if} + {display_name} + - - {#if event_badge_obj?.full_name_override} - {event_badge_obj?.full_name_override} - {:else if event_badge_obj?.full_name} - {event_badge_obj?.full_name} - {:else} - {event_badge_obj?.given_name} - {event_badge_obj?.family_name} - {/if} - - - {#if event_badge_obj?.print_count >= 1} - - - {event_badge_obj.print_count} - - {/if} - - - {#if show_sensitive_fields} - + + {#if show_sensitive_fields && event_badge_obj?.email} + - {#if $ae_loc.trusted_access} - {event_badge_obj?.email} - {:else} - {event_badge_obj?.email?.replace( - /^(.{3}).*@/, - '$1...@' - ) ?? ''} - {/if} + {is_trusted + ? event_badge_obj.email + : obscure_email(event_badge_obj.email)} {/if} - {#if !hide_affiliations && event_badge_obj.affiliations} - + + {#if !hide_affiliations && (event_badge_obj?.affiliations_override ?? event_badge_obj?.affiliations)} + - {event_badge_obj.affiliations} + {event_badge_obj.affiliations_override ?? event_badge_obj.affiliations} {/if} - {#if !hide_badge_type && event_badge_obj.badge_type} - + + {#if !hide_badge_type && event_badge_obj?.badge_type} + {event_badge_obj.badge_type} {/if}
    - {#if $ae_loc.trusted_access} - - Review - - {/if} + +
    + + + {#if is_trusted && (!is_printed || is_edit_mode)} + + + {#if is_printed} + {print_count}× + {/if} + + + {/if} + + + {#if is_trusted && is_edit_mode} + + + + + {/if} + + + {#if is_trusted && is_edit_mode} + + {/if} + + + {#if !is_printed || (is_trusted && is_edit_mode)} + + {/if} + +
    - {#if $ae_loc.edit_mode} + + {#if is_edit_mode && is_trusted}
    - ID: - {event_badge_obj?.event_badge_id} - CR: - {ae_util.iso_datetime_formatter( - event_badge_obj.created_on, - 'datetime_iso_12_no_seconds' - )} - UP: - {ae_util.iso_datetime_formatter( - event_badge_obj.updated_on, - 'datetime_iso_12_no_seconds' - )} - {#if event_badge_obj.print_first_datetime} - FP: - {ae_util.iso_datetime_formatter( - event_badge_obj.print_first_datetime, - 'datetime_iso_12_no_seconds' - )} - {/if} - {#if event_badge_obj.print_last_datetime} - LP: - {ae_util.iso_datetime_formatter( - event_badge_obj.print_last_datetime, - 'datetime_iso_12_no_seconds' - )} - {/if} + + ID: + {event_badge_obj?.event_badge_id} + + + CR: + {ae_util.iso_datetime_formatter(event_badge_obj.created_on, 'datetime_iso_12_no_seconds')} + + + UP: + {ae_util.iso_datetime_formatter(event_badge_obj.updated_on, 'datetime_iso_12_no_seconds')} + + + PC: + {print_count} + + + FP: + {event_badge_obj.print_first_datetime + ? ae_util.iso_datetime_formatter(event_badge_obj.print_first_datetime, 'datetime_iso_12_no_seconds') + : '—'} + + + LP: + {event_badge_obj.print_last_datetime + ? ae_util.iso_datetime_formatter(event_badge_obj.print_last_datetime, 'datetime_iso_12_no_seconds') + : '—'} +
    {/if}
  • {/each}
{:else} -
+
-

- No badges found matching your criteria. Try adjusting your - filters. -

+

No badges found matching your criteria. Try adjusting your filters.

{/if} diff --git a/src/routes/events/[event_id]/+page.svelte b/src/routes/events/[event_id]/+page.svelte index 656bac8a..8f40db22 100644 --- a/src/routes/events/[event_id]/+page.svelte +++ b/src/routes/events/[event_id]/+page.svelte @@ -92,12 +92,12 @@ {/each}
- {#if $ae_loc.administrator_access} + {#if $ae_loc.administrator_access && $ae_loc.edit_mode}
-

Administrative Access

+

Event Admin Settings

You have elevated privileges. Use the menu above to access advanced settings and reports.

diff --git a/src/routes/events/[event_id]/settings/+page.svelte b/src/routes/events/[event_id]/settings/+page.svelte index 09f68e02..22f682b4 100644 --- a/src/routes/events/[event_id]/settings/+page.svelte +++ b/src/routes/events/[event_id]/settings/+page.svelte @@ -38,7 +38,7 @@ let show_upload_badge_modal: boolean = $state(false); // Guard: Only allow administrators in edit mode - if (!$ae_loc.administrator_access || !$ae_loc.edit_mode) { + if (!$ae_loc.administrator_access) { if (browser) { alert( 'Access Denied: Administrative privileges and Edit Mode required.' diff --git a/src/routes/events/[event_id]/settings/ae_comp__event_settings_badges_form.svelte b/src/routes/events/[event_id]/settings/ae_comp__event_settings_badges_form.svelte index 0e2b3b33..c668f075 100644 --- a/src/routes/events/[event_id]/settings/ae_comp__event_settings_badges_form.svelte +++ b/src/routes/events/[event_id]/settings/ae_comp__event_settings_badges_form.svelte @@ -8,6 +8,73 @@ let { mod_badges_json = $bindable({}), onsave }: Props = $props(); + /** + * edit_permissions — controls which fields each access level may edit in the badge review form. + * Stored as mod_badges_json.edit_permissions. + * + * Structure: + * authenticated.can_edit — fields attendees (passcode-validated) may edit + * trusted.can_edit — fields trusted staff may edit + * administrator.can_edit — '*' (all) or a specific field list + * + * Default attendee fields: full_name_override, professional_title_override, + * affiliations_override, location_override + * Default trusted fields: above + email, badge_type_code + */ + + const all_attendee_fields = [ + { key: 'full_name_override', label: 'Full Name (override)' }, + { key: 'professional_title_override', label: 'Professional Title (override)' }, + { key: 'affiliations_override', label: 'Affiliations (override)' }, + { key: 'location_override', label: 'Location (override)' } + ]; + + const all_staff_fields = [ + ...all_attendee_fields, + { key: 'email', label: 'Email' }, + { key: 'badge_type_code', label: 'Badge Type Code' } + ]; + + // Ensure edit_permissions sub-object exists + function ensure_permissions() { + if (!mod_badges_json) return; + if (!mod_badges_json.edit_permissions) { + mod_badges_json.edit_permissions = {}; + } + if (!mod_badges_json.edit_permissions.authenticated) { + mod_badges_json.edit_permissions.authenticated = { + can_edit: ['full_name_override', 'professional_title_override', 'affiliations_override', 'location_override'] + }; + } + if (!mod_badges_json.edit_permissions.trusted) { + mod_badges_json.edit_permissions.trusted = { + can_edit: ['full_name_override', 'professional_title_override', 'affiliations_override', 'location_override', 'email', 'badge_type_code'] + }; + } + if (!mod_badges_json.edit_permissions.administrator) { + mod_badges_json.edit_permissions.administrator = { can_edit: '*' }; + } + } + + function is_field_enabled(level: 'authenticated' | 'trusted', field_key: string): boolean { + const cfg = mod_badges_json?.edit_permissions?.[level]?.can_edit; + if (!cfg) return false; + if (cfg === '*') return true; + return Array.isArray(cfg) && cfg.includes(field_key); + } + + function toggle_field(level: 'authenticated' | 'trusted', field_key: string) { + ensure_permissions(); + if (!mod_badges_json?.edit_permissions?.[level]) return; + let fields: string[] = mod_badges_json.edit_permissions[level].can_edit; + if (!Array.isArray(fields)) fields = []; + if (fields.includes(field_key)) { + mod_badges_json.edit_permissions[level].can_edit = fields.filter((f: string) => f !== field_key); + } else { + mod_badges_json.edit_permissions[level].can_edit = [...fields, field_key]; + } + } + function save() { if (onsave && mod_badges_json) onsave(mod_badges_json); } @@ -100,7 +167,61 @@
- {/if} + +
+ + Badge Review — Editable Field Permissions + +
+

+ Controls which fields each access level may edit on the Badge Review page. + Staff (Trusted) defaults include all attendee fields plus Email and Badge Type Code. + Administrators can always edit everything. +

+ + +
+

Attendees (passcode link)

+
+ {#each all_attendee_fields as field} + + {/each} +
+
+ + +
+

Staff (Trusted access)

+
+ {#each all_staff_fields as field} + + {/each} +
+
+ + +

+ Administrators always have access to all fields (not configurable). +

+
+
+ {/if}