Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
-- ============================================================================

begin;
select plan(5);
select plan(6);

-- ============================================================================
-- VARIABLES
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions ocg-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
57 changes: 56 additions & 1 deletion ocg-server/src/handlers/dashboard/group/attendees.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<DynDB>,
Path(event_id): Path<Uuid>,
) -> Result<impl IntoResponse, HandlerError> {
// 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.
Expand Down
94 changes: 93 additions & 1 deletion ocg-server/src/handlers/dashboard/group/attendees/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions ocg-server/src/router/dashboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,10 @@ pub(super) fn setup_group_dashboard_router(state: &State) -> Router<State> {
"/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),
Expand Down
1 change: 1 addition & 0 deletions ocg-server/static/css/styles.src.css
Original file line number Diff line number Diff line change
Expand Up @@ -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'); }
Expand Down
1 change: 1 addition & 0 deletions ocg-server/static/images/icons/csv.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
66 changes: 66 additions & 0 deletions ocg-server/static/js/dashboard/group/attendees.js
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -283,6 +348,7 @@ const initializeAttendeesFeatures = (root = document) => {
return;
}

initializeAttendeeActionsMenu(attendeesRoot);
initializeAttendeeNotification(attendeesRoot);
initializeQrCodeModal(attendeesRoot);
initializeRefundReviewModal(attendeesRoot);
Expand Down
Loading
Loading