diff --git a/Cargo.lock b/Cargo.lock index 3aff6360..95961692 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1304,6 +1304,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -3166,6 +3187,7 @@ dependencies = [ "chrono", "chrono-tz", "clap", + "csv", "deadpool-postgres", "emojis", "figment", diff --git a/Cargo.toml b/Cargo.toml index 12f0aff9..2d0350ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ cached = { version = "0.59.0", features = ["async"] } clap = { version = "4.6.1", features = ["derive"] } chrono = { version = "0.4.44", features = ["serde"] } chrono-tz = { version = "0.10.4", features = ["serde"] } +csv = "1.4.0" deadpool-postgres = { version = "0.14.1", features = ["serde"] } emojis = "0.8.1" figment = { version = "0.10.19", features = ["yaml", "env"] } diff --git a/database/tests/functions/dashboard-group/search_event_attendees.sql b/database/tests/functions/dashboard-group/search_event_attendees.sql index d4348b19..5ff55fbd 100644 --- a/database/tests/functions/dashboard-group/search_event_attendees.sql +++ b/database/tests/functions/dashboard-group/search_event_attendees.sql @@ -3,7 +3,7 @@ -- ============================================================================ begin; -select plan(5); +select plan(6); -- ============================================================================ -- VARIABLES @@ -197,6 +197,22 @@ select is( 'Should return paginated attendees when limit and offset are provided' ); +-- Should return full attendee list when pagination is omitted +select is( + search_event_attendees( + :'groupID'::uuid, + '{"event_id":"00000000-0000-0000-0000-000000000041"}'::jsonb + )::jsonb, + jsonb_build_object( + 'attendees', '[ + {"checked_in": true, "created_at": 1704067200, "user_id": "00000000-0000-0000-0000-000000000031", "username": "alice", "checked_in_at": 1704103200, "amount_minor": 2500, "company": "Cloud Corp", "currency_code": "USD", "discount_code": "SAVE5", "event_purchase_id": "00000000-0000-0000-0000-000000000071", "name": "Alice", "photo_url": "https://e/u1.png", "refund_request_status": null, "ticket_title": "General admission", "title": "Principal Engineer"}, + {"checked_in": false, "created_at": 1704153600, "user_id": "00000000-0000-0000-0000-000000000032", "username": "bob", "checked_in_at": null, "amount_minor": null, "company": null, "currency_code": null, "discount_code": null, "event_purchase_id": null, "name": null, "photo_url": "https://e/u2.png", "refund_request_status": null, "ticket_title": null, "title": null} + ]'::jsonb, + 'total', 2 + ), + 'Should return full attendee list when pagination is omitted' +); + -- Should return attendees for event2 select is( search_event_attendees( diff --git a/ocg-server/Cargo.toml b/ocg-server/Cargo.toml index 338194ae..7e0e57bd 100644 --- a/ocg-server/Cargo.toml +++ b/ocg-server/Cargo.toml @@ -21,6 +21,7 @@ cached = { workspace = true } clap = { workspace = true } chrono = { workspace = true } chrono-tz = { workspace = true } +csv = { workspace = true } deadpool-postgres = { workspace = true } emojis = { workspace = true } figment = { workspace = true } diff --git a/ocg-server/src/handlers/dashboard/group/attendees.rs b/ocg-server/src/handlers/dashboard/group/attendees.rs index 294320e7..574e15f3 100644 --- a/ocg-server/src/handlers/dashboard/group/attendees.rs +++ b/ocg-server/src/handlers/dashboard/group/attendees.rs @@ -4,7 +4,10 @@ use anyhow::Result; use askama::Template; use axum::{ extract::{Path, RawQuery, State}, - http::{StatusCode, header::CONTENT_TYPE}, + http::{ + StatusCode, + header::{CONTENT_DISPOSITION, CONTENT_TYPE}, + }, response::{Html, IntoResponse}, }; use chrono::Duration; @@ -356,6 +359,58 @@ pub(crate) async fn send_event_custom_notification( Ok(StatusCode::NO_CONTENT.into_response()) } +// Download handlers. + +/// Downloads a CSV file with all attendees for a specific event. +#[instrument(skip_all, err)] +pub(crate) async fn download_csv( + SelectedCommunityId(community_id): SelectedCommunityId, + SelectedGroupId(group_id): SelectedGroupId, + State(db): State, + Path(event_id): Path, +) -> Result { + // Fetch event summary and all attendees + let search_filters = AttendeesFilters { + event_id, + limit: None, + offset: None, + }; + let (event, search_attendees_results) = tokio::try_join!( + db.get_event_summary(community_id, group_id, event_id), + db.search_event_attendees(group_id, &search_filters) + )?; + + // Prepare CSV response + let mut writer = csv::WriterBuilder::new() + .terminator(csv::Terminator::Any(b'\n')) + .from_writer(vec![]); + writer + .write_record(["Name", "Company", "Title"]) + .map_err(anyhow::Error::from)?; + for attendee in &search_attendees_results.attendees { + writer + .write_record([ + attendee.name.as_deref().unwrap_or(&attendee.username), + attendee.company.as_deref().unwrap_or_default(), + attendee.title.as_deref().unwrap_or_default(), + ]) + .map_err(anyhow::Error::from)?; + } + let csv = writer.into_inner().map_err(anyhow::Error::from)?; + let file_name = format!("event-{}-attendees.csv", event.slug); + + Ok(( + [ + (CONTENT_TYPE, "text/csv; charset=utf-8".to_string()), + ( + CONTENT_DISPOSITION, + format!("attachment; filename=\"{file_name}\""), + ), + ], + csv, + )) +} + // Types. /// Form data for custom event notifications. diff --git a/ocg-server/src/handlers/dashboard/group/attendees/tests.rs b/ocg-server/src/handlers/dashboard/group/attendees/tests.rs index cad6c8f0..5d760e98 100644 --- a/ocg-server/src/handlers/dashboard/group/attendees/tests.rs +++ b/ocg-server/src/handlers/dashboard/group/attendees/tests.rs @@ -3,7 +3,7 @@ use axum::{ body::{Body, to_bytes}, http::{ HeaderValue, Request, StatusCode, - header::{CACHE_CONTROL, CONTENT_TYPE, COOKIE}, + header::{CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE, COOKIE}, }, }; use axum_login::tower_sessions::session; @@ -281,6 +281,98 @@ async fn test_approve_refund_request_returns_internal_server_error_when_payments assert!(bytes.is_empty()); } +#[tokio::test] +async fn test_download_csv_success() { + // Setup identifiers and data structures + let community_id = Uuid::new_v4(); + let event_id = Uuid::new_v4(); + let group_id = Uuid::new_v4(); + let session_id = session::Id::default(); + let user_id = Uuid::new_v4(); + let auth_hash = "hash".to_string(); + let session_record = sample_session_record( + session_id, + user_id, + &auth_hash, + Some(community_id), + Some(group_id), + ); + let mut attendee = sample_attendee(); + attendee.name = Some("Doe, Jane".to_string()); + attendee.company = Some("Example \"Cloud\"".to_string()); + attendee.title = Some("Principal\nEngineer".to_string()); + let mut attendee_without_name = sample_attendee(); + attendee_without_name.name = None; + attendee_without_name.username = "anonymous-attendee".to_string(); + attendee_without_name.company = None; + attendee_without_name.title = None; + let event = sample_event_summary(event_id, group_id); + let output = crate::templates::dashboard::group::attendees::AttendeesOutput { + attendees: vec![attendee, attendee_without_name], + total: 2, + }; + + // Setup database mock + let mut db = MockDB::new(); + db.expect_get_session() + .times(1) + .withf(move |id| *id == session_id) + .returning(move |_| Ok(Some(session_record.clone()))); + db.expect_get_user_by_id() + .times(1) + .withf(move |id| *id == user_id) + .returning(move |_| Ok(Some(sample_auth_user(user_id, &auth_hash)))); + db.expect_user_has_group_permission() + .times(1) + .withf(move |cid, gid, uid, permission| { + *cid == community_id && *gid == group_id && *uid == user_id && permission == GroupPermission::Read + }) + .returning(|_, _, _, _| Ok(true)); + db.expect_search_event_attendees() + .times(1) + .withf(move |gid, filters| { + *gid == group_id + && filters.event_id == event_id + && filters.limit.is_none() + && filters.offset.is_none() + }) + .returning(move |_, _| Ok(output.clone())); + db.expect_get_event_summary() + .times(1) + .withf(move |cid, gid, eid| *cid == community_id && *gid == group_id && *eid == event_id) + .returning(move |_, _, _| Ok(event.clone())); + + // Setup notifications manager mock + let nm = MockNotificationsManager::new(); + + // Setup router and send request + let router = TestRouterBuilder::new(db, nm).build().await; + let request = Request::builder() + .method("GET") + .uri(format!("/dashboard/group/events/{event_id}/attendees.csv")) + .header(COOKIE, format!("id={session_id}")) + .body(Body::empty()) + .unwrap(); + let response = router.oneshot(request).await.unwrap(); + let (parts, body) = response.into_parts(); + let bytes = to_bytes(body, usize::MAX).await.unwrap(); + + // Check response matches expectations + assert_eq!(parts.status, StatusCode::OK); + assert_eq!( + parts.headers.get(CONTENT_TYPE).unwrap(), + &HeaderValue::from_static("text/csv; charset=utf-8"), + ); + assert_eq!( + parts.headers.get(CONTENT_DISPOSITION).unwrap(), + &HeaderValue::from_static("attachment; filename=\"event-ghi9abc-attendees.csv\""), + ); + assert_eq!( + String::from_utf8(bytes.to_vec()).unwrap(), + "Name,Company,Title\n\"Doe, Jane\",\"Example \"\"Cloud\"\"\",\"Principal\nEngineer\"\nanonymous-attendee,,\n", + ); +} + #[tokio::test] async fn test_generate_check_in_qr_code_success() { // Setup identifiers and data structures diff --git a/ocg-server/src/router/dashboard.rs b/ocg-server/src/router/dashboard.rs index 2b1e1fb2..60a87649 100644 --- a/ocg-server/src/router/dashboard.rs +++ b/ocg-server/src/router/dashboard.rs @@ -217,6 +217,10 @@ pub(super) fn setup_group_dashboard_router(state: &State) -> Router { "/events/{event_id}/attendees", get(dashboard::group::attendees::list_page), ) + .route( + "/events/{event_id}/attendees.csv", + get(dashboard::group::attendees::download_csv), + ) .route( "/events/{event_id}/invitation-requests", get(dashboard::group::invitation_requests::list_page), diff --git a/ocg-server/static/css/styles.src.css b/ocg-server/static/css/styles.src.css index 492ab4b5..2bb45f45 100644 --- a/ocg-server/static/css/styles.src.css +++ b/ocg-server/static/css/styles.src.css @@ -651,6 +651,7 @@ input:placeholder-shown { .icon-clock { --icon-url: url('/static/images/icons/clock.svg'); } .icon-close { --icon-url: url('/static/images/icons/close.svg'); } .icon-copy { --icon-url: url('/static/images/icons/copy.svg'); } +.icon-csv { --icon-url: url('/static/images/icons/csv.svg'); } .icon-date { --icon-url: url('/static/images/icons/date.svg'); } .icon-docs { --icon-url: url('/static/images/icons/docs.svg'); } .icon-email { --icon-url: url('/static/images/icons/email.svg'); } diff --git a/ocg-server/static/images/icons/csv.svg b/ocg-server/static/images/icons/csv.svg new file mode 100644 index 00000000..5c18f77d --- /dev/null +++ b/ocg-server/static/images/icons/csv.svg @@ -0,0 +1 @@ + diff --git a/ocg-server/static/js/dashboard/group/attendees.js b/ocg-server/static/js/dashboard/group/attendees.js index f5544bc7..f4ae1788 100644 --- a/ocg-server/static/js/dashboard/group/attendees.js +++ b/ocg-server/static/js/dashboard/group/attendees.js @@ -11,6 +11,7 @@ const dataKey = "attendeeNotificationReady"; const refundModalId = "attendee-refund-modal"; const refundApproveButtonId = "attendee-refund-approve"; const refundRejectButtonId = "attendee-refund-reject"; +const attendeeActionsDropdownSelector = "[data-attendee-actions-dropdown]"; const resolveAttendeesRoot = (root = document) => { if (root instanceof Element && root.id === "attendees-content") { @@ -91,6 +92,24 @@ const processRefundActionButton = (button) => { } }; +/** + * Close the attendee actions dropdown. + * @param {Document|Element} [root=document] Query root. + * @returns {void} + */ +const closeAttendeeActionsDropdown = (root = document) => { + root.querySelector?.(attendeeActionsDropdownSelector)?.classList.add("hidden"); +}; + +/** + * Toggle the attendee actions dropdown. + * @param {Document|Element} [root=document] Query root. + * @returns {void} + */ +const toggleAttendeeActionsDropdown = (root = document) => { + root.querySelector?.(attendeeActionsDropdownSelector)?.classList.toggle("hidden"); +}; + /** * Apply trigger data to the refund review modal. * @param {HTMLElement} triggerButton Refund review trigger button. @@ -181,6 +200,52 @@ const initializeAttendeeNotification = (root) => { }); }; +/** + * Initialize the attendee actions dropdown. + * @param {Document|Element} [root=document] Query root. + */ +const initializeAttendeeActionsMenu = (root = document) => { + if (!(root instanceof Element) || root.dataset.attendeeActionsMenuReady === "true") { + return; + } + + root.dataset.attendeeActionsMenuReady = "true"; + + root.addEventListener("click", (event) => { + const target = event.target instanceof Element ? event.target : null; + const trigger = target?.closest("#attendee-actions-button"); + if (trigger instanceof HTMLElement && root.contains(trigger)) { + event.stopPropagation(); + toggleAttendeeActionsDropdown(root); + return; + } + + const menuItem = target?.closest(`${attendeeActionsDropdownSelector} a`); + if (menuItem instanceof HTMLAnchorElement && root.contains(menuItem)) { + closeAttendeeActionsDropdown(root); + return; + } + + if (!target?.closest(attendeeActionsDropdownSelector)) { + closeAttendeeActionsDropdown(root); + } + }); + + document.addEventListener("click", (event) => { + const target = event.target instanceof Element ? event.target : null; + if (target && !root.contains(target)) { + closeAttendeeActionsDropdown(root); + } + }); + + root.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + closeAttendeeActionsDropdown(root); + queryElementById(root, "attendee-actions-button")?.focus(); + } + }); +}; + /** * Initialize check-in toggle checkboxes with optimistic UI updates. * @param {Document|Element} [root=document] Query root. @@ -283,6 +348,7 @@ const initializeAttendeesFeatures = (root = document) => { return; } + initializeAttendeeActionsMenu(attendeesRoot); initializeAttendeeNotification(attendeesRoot); initializeQrCodeModal(attendeesRoot); initializeRefundReviewModal(attendeesRoot); diff --git a/ocg-server/templates/dashboard/group/attendees_list.html b/ocg-server/templates/dashboard/group/attendees_list.html index cd208128..b4b55030 100644 --- a/ocg-server/templates/dashboard/group/attendees_list.html +++ b/ocg-server/templates/dashboard/group/attendees_list.html @@ -47,6 +47,31 @@ disabled title="No attendees to send emails to." {% endif -%} data-event-id="{{ event.event_id }}">Send email +
+ + +
diff --git a/tests/e2e/dashboard/group/events/attendees.spec.ts b/tests/e2e/dashboard/group/events/attendees.spec.ts index f51ee94d..b9e11278 100644 --- a/tests/e2e/dashboard/group/events/attendees.spec.ts +++ b/tests/e2e/dashboard/group/events/attendees.spec.ts @@ -1,3 +1,4 @@ +import { readFile } from "node:fs/promises"; import type { Page } from "@playwright/test"; import { expect, test } from "../../../fixtures"; @@ -355,6 +356,49 @@ test.describe("group dashboard attendees tab", () => { ).toHaveAttribute("title", "No attendees to send emails to."); }); + test("organizer can download attendees as CSV from the attendees tab", async ({ + organizerGroupPage, + }) => { + const attendeesContent = await openAttendeesTab( + organizerGroupPage, + "Full Event With Waitlist", + TEST_EVENT_IDS.alpha.waitlistLab, + ); + + const actionsButton = attendeesContent.getByRole("button", { + name: "Open attendee actions menu", + }); + await expect(actionsButton).toBeVisible(); + await actionsButton.click(); + + const downloadCsvLink = attendeesContent.getByRole("menuitem", { + name: "Download CSV", + }); + await expect(downloadCsvLink).toBeVisible(); + await expect(downloadCsvLink).toHaveAttribute( + "href", + `/dashboard/group/events/${TEST_EVENT_IDS.alpha.waitlistLab}/attendees.csv`, + ); + + const [download] = await Promise.all([ + organizerGroupPage.waitForEvent("download"), + downloadCsvLink.click(), + ]); + const downloadPath = await download.path(); + + if (!downloadPath) { + throw new Error("Expected attendee CSV download to have a local file path."); + } + + expect(download.suggestedFilename()).toBe( + "event-alpha-waitlist-lab-attendees.csv", + ); + const csvContents = await readFile(downloadPath, "utf8"); + expect(csvContents).toContain( + "Name,Company,Title\nE2E Organizer One,,\n", + ); + }); + test.describe("payment-enabled attendee refund flows", () => { test.skip(!E2E_PAYMENTS_ENABLED, "Payments are disabled in this environment."); diff --git a/tests/unit/dashboard/group/attendees.test.js b/tests/unit/dashboard/group/attendees.test.js index 1d2c0a9a..7d977398 100644 --- a/tests/unit/dashboard/group/attendees.test.js +++ b/tests/unit/dashboard/group/attendees.test.js @@ -27,6 +27,36 @@ describe("dashboard group attendees", () => { dispatchHtmxLoad(); }; + it("toggles and closes the attendee actions menu", () => { + document.body.innerHTML = ` +
+ + +
+ `; + + initializeAttendeesUi(); + + const button = document.getElementById("attendee-actions-button"); + const dropdown = document.getElementById("attendee-actions-menu"); + + button.click(); + expect(dropdown.classList.contains("hidden")).to.equal(false); + + dropdown.querySelector("a")?.click(); + expect(dropdown.classList.contains("hidden")).to.equal(true); + + button.click(); + expect(dropdown.classList.contains("hidden")).to.equal(false); + + document.body.click(); + expect(dropdown.classList.contains("hidden")).to.equal(true); + }); + it("updates the attendee notification endpoint before opening the modal", () => { document.body.innerHTML = `