diff --git a/.dev.vars.example b/.dev.vars.example index 161837c..4aa5754 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -5,3 +5,9 @@ TELEGRAM_WEBHOOK_SECRET=replace_with_a_random_development_secret # Your numeric Telegram user ID; blank disables the admin panel. SUPER_ADMIN_ID= + +# Development access restriction. An empty whitelist permits everyone. +# Production always ignores this feature. IDs remain private Worker secrets. +APP_ENV=development +TEST_WHITELIST_ENABLED=false +WHITELIST_USER_IDS= diff --git a/docs/development.md b/docs/development.md index 62b92a2..25aca85 100644 --- a/docs/development.md +++ b/docs/development.md @@ -131,6 +131,10 @@ Keep real IDs in private Worker secrets or bot commands. `WHITELIST_USER_IDS` su Removing the final tester also opens access to everyone; every change confirms whether access is restricted or open. Invalid configuration restricts access until the superadmin resets it. Payment and refund confirmations already issued by Telegram are still recorded after a tester is removed. Existing queued notifications are preserved. +In the development Mini App, open **Profile → Super admin → Dev access** to turn the whitelist on or off and add or remove numeric Telegram user IDs. The panel warns before enabling restrictions or removing the last allowed user. The superadmin always retains access. Turning it off keeps the saved IDs and permits everyone; turning it on restricts access when the list contains IDs. An empty ordinary whitelist still permits everyone. These changes apply immediately to the development bot and Mini App and survive redeployment. The `TEST_WHITELIST_ENABLED` variable is the initial default; the saved panel setting takes precedence. Production does not expose these controls or honor the development setting. + +When development contains a production snapshot, its protection takes priority: the panel cannot turn access off, and removing the last tester leaves admin-only access. Database refresh retains the saved switch and whitelist together with the other development settings. + This restriction applies to the Cloudflare Worker, including its webhook and Mini App APIs. Use `wrangler dev` for local access-control testing; the standalone `npm start` polling process does not enforce this hosted feature. The whitelist command is advertised only in the configured superadmin's private chat. ## Copy production data to dev diff --git a/docs/features.md b/docs/features.md index 68151be..9437cfc 100644 --- a/docs/features.md +++ b/docs/features.md @@ -68,9 +68,9 @@ The owner's edit page has a red event-actions section with Cancel event and Dele ## Tickets and check-in -Confirmed guests receive a unique ticket code. Confirmation requires an accepted response plus any required approval and verified payment. Private locations and ticket instructions stay locked until those requirements are satisfied. +Confirmed guests can request a six-digit check-in code from their ticket page. It expires at the next minute; **Refresh code** requests a new one. Codes are generated on demand, without background polling or scheduled generation. Confirmation requires an accepted response plus any required approval and verified payment. Private locations and ticket instructions stay locked until those requirements are satisfied. -Hosts can enter a code to check its validity, then check in the entire group. The result shows the guest name and participant count. A repeat check reports the existing check-in; a newly recorded check-in notifies the guest in Telegram. +Owners and co-hosts can use the event editor's check-in keypad to enter a code and check in the entire group. The result shows the guest name and participant count. A repeat check reports the existing check-in; a newly recorded check-in notifies the guest in Telegram. Existing QR tickets and check-in records remain valid. Ticket validation checks the current event, acceptance, approval, and payment state. Cancelled or no-longer-confirmed tickets fail validation. An event with a saved finish time also rejects tickets after that time; an event without a finish time has no automatic end-time ticket expiry. diff --git a/docs/invitations.md b/docs/invitations.md index d9ee7b3..a50cf43 100644 --- a/docs/invitations.md +++ b/docs/invitations.md @@ -77,15 +77,19 @@ A guest's selected count survives reopening the invitation. Editable counts can In the bot, open **Manage → Invitation links** to see guest-name buttons, with ten guests per page and two buttons per row. When the complete invitation and link fit Telegram's 256-character copy-button limit, tapping the name copies them immediately. Longer invitations open that guest in **App**; tap their name there to copy the full message and personal link. Invitation details offer **Share invite**, **Copy invite** (or **Copy invite in App** for longer text), and **Copy link only**. Without App, the bot displays the full invitation so you can select and copy it. Messages are not truncated to fit the button. -The Mini App's **Guest invitations** view shows each invitation's responses, comments and attendee counts, plus totals for responses and accepted people. Filter by **Accepted**, **Declined**, **Maybe**, **Later** or **Unanswered**, and combine the filter with a guest-name search. Unanswered includes invitations with no response and guests who chose Later. A reusable invitation can show several responses and appear in more than one filter. +The Mini App's **Guest invitations** view shows each invitation's current response, comments and attendee counts, plus totals for responses and accepted people. Recorded response times appear in your timezone. Opening an invite without responding is shown as awaiting a response; it does not count as choosing Respond later. Filter by **Accepted**, **Declined**, **Maybe**, **Later**, **Unanswered** or **Revoked**, and combine the filter with a guest-name search. Unanswered includes invitations with no response and guests who chose Later. A reusable invitation can show several responses and appear in more than one filter. Older responses may have no recorded timestamp. -Each guest has **Share invite**, **Copy invite**, and **Copy link** actions. **Copy invite** includes the personal message and link; **Copy link** copies only that guest's URL. Guest-name buttons also copy the full invitation, and previews are expandable. If clipboard access fails, the requested text or URL is selected for manual copying. Guest names and unused links are private to the event hosts. Send personal links privately: possession of a link does not prove someone's real-world identity. +Each guest card keeps **Copy invite** and **Copy link** visible. **Copy invite** includes the personal message and link; **Copy link** copies only that guest's URL. Guest-name buttons also copy the full invitation, and previews are expandable. The three-dot menu contains **Share invite**, **Edit invite**, **Change response**, **Revoke invite** and **See history**. If clipboard access fails, the requested text or URL is selected for manual copying. Guest names and unused links are private to the event hosts. Send personal links privately: possession of a link does not prove someone's real-world identity. Owners and co-hosts can expand **Add guests** and choose **Add one guest** or **Add a list**, without replacing existing links or responses. The individual form provides a name, a count from 1 to 10, and plain-language choices for one person, asking for a count, an editable preset, count confirmation or a fixed count. The same individual controls are available beside the text list when creating or editing an event. Adding a guest to an unsaved event appends a line to that draft; it does not send an invitation. Duplicate names need a distinguishing label. -The **Guest list** also has **Add guest** and **Invitations** shortcuts. Each named guest has **Invitation** and **Revoke invitation** actions; revocation opens the matching personal invitation's removal choices. **Remove invite** asks whether to **Notify guest** or **Remove silently**, with **Keep invite** to cancel. Notification is available only after a Telegram account has opened the invitation. Removing an invitation revokes its link and ticket, removes its responses from attendance totals, and clears related reminders and unfinished response steps. For reusable invitations, this affects every response associated with that link. Payment records remain available for separate refund handling; removal does not issue a refund. These named-invitation controls are not shown for shared ticket-booking links. +The **Guest list** also has **Add guest** and **Invitations** shortcuts. Each named guest has **Invitation** and **Revoke invitation** actions. **Revoke invite** offers an optional notification, off by default. Revocation disables the link and ticket, removes its responses from attendance totals, and clears related reminders and unfinished response steps. For reusable invitations, this affects every response associated with that link. Revoked cards remain available through the **Revoked** filter, with their history and a separate **Delete invite** confirmation. Deleting removes the card from the invitation list while retaining private audit, check-in and payment records. Neither action issues a refund. These named-invitation controls are not shown for shared ticket-booking links. -Links stay stable when you save the same guest names and count settings. The general event editor can remove unopened names, revoking their links. Once an invitation has been opened, that editor cannot remove or rename it, or change its count or selection mode. Use the dedicated **Remove invite** action above to revoke an opened or answered invitation. Changes are checked against the current guest list and responses; if another host or guest changes them first, refresh and review before retrying. This edit protection is separate from the one-time-use lock described below. +**Edit invite** changes the name, attendee count or attendee setting without replacing its personal link. Linked guest responses use the updated name and count rules. Attendance changes are blocked for checked-in guests and while protected payment or refund states apply; a name-only correction remains possible. The general event editor still protects opened invitations from bulk removal or attendee changes: use the dedicated per-invitation actions instead. Changes are checked against the current guest list and responses; if another host or guest changes them first, refresh and review before retrying. This edit protection is separate from the one-time-use lock described below. + +**Change response** records Accept, Reject, Maybe or Respond later for a guest who has already opened the invitation in Telegram. For reusable links, select the actual guest response to change. Choose whether to notify that guest; silent saving is the default. Unopened invitations cannot create a response or ticket for an unknown Telegram account. Acceptance still follows payment requirements and attendee rules. Checked-in guests and protected payments cannot have their attendance changed here. + +**See history** shows recorded creation, first opening by each guest, responses, response changes, edits and revocation with timestamps in your saved timezone. Organiser changes include whether notification was requested. History starts when activity is recorded by this version; older invitations may have incomplete history, and missing past timestamps are not invented. History is visible only to event hosts. Editing loads the latest event details, including the invitation message, guest list, payment settings and existing banner preview. Saving without selecting a replacement image preserves the current banner. @@ -139,7 +143,9 @@ For contributions without an RSVP, enable uploads and the separate public media Hosts can review accepted responses and people totals separately, distinguish confirmed attendance from approval requests and unpaid bookings, and filter the roster by response state. Named lists also show unopened guest placeholders. An unopened name can appear in the roster, but the bot cannot contact a Telegram account that has not interacted with it. -Confirmed guests receive a unique ticket code. With QR codes enabled, the Mini App also provides a scannable ticket. Owners and co-hosts can verify a code or scan the QR and check in the entire group. A new check-in notifies the guest; repeat checks show that the ticket was already checked in. +Confirmed guests open **Check-in code** to request a six-digit code. It expires at the next minute; a countdown and **Refresh code** button help them request another. Codes are generated only when requested, with no background polling or scheduled generation. Owners and co-hosts open **Check in guests** from the event editor and enter the code using the numeric keypad to check in the entire group. A new check-in notifies the guest; repeat checks show that the ticket was already checked in. + +With QR codes enabled, the ticket page also provides a scannable ticket. Existing ticket QR codes and saved check-in records continue to work. QR codes start off for new events. Turning them off hides guest QR options and upload QR generation, while manual ticket codes, check-in by code and media upload links still work. Existing events without a saved QR setting retain QR functionality until it is disabled. Ticket QR payloads contain an event ID and ticket code, not the hosting URL. diff --git a/public/app.js b/public/app.js index 45d34ac..82655a5 100644 --- a/public/app.js +++ b/public/app.js @@ -9,7 +9,7 @@ const compactPicker = picker || deadlinePicker; const deviceZone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; let state = { events: [], preference: {} }, activeEvent = null, createdEvent = null, previewSequence = 0; let requestId = crypto.randomUUID(); -let formReady=false; +let formReady=false,formSaving=false,currentView='home',pageNavigationGeneration=0; function isManager(event){return event?.isManager===true || event?.isOwner===true;} function oneTimeInviteEnabled(event){return event?.oneTimeInvite ?? (event?.invitationMode==='named');} function priceLabel(e){if(['bank','link'].includes(e.paymentMethod))return 'Paid · '+e.displayPrice;return e.starPrice ? '⭐ '+e.starPrice+' Stars '+(e.starPricing==='person'?'per person':'per group') : 'Free';} @@ -37,6 +37,7 @@ function updatePricePreview(){ } let listFilter = 'all'; let adminData = null, adminMode = 'events'; +let adminWhitelist = null, whitelistBusy = false; let bannerPreviewUrl,bannerLoadGeneration=0; let profilePhotoUrl,profilePhotoGeneration=0; let botIconUrl,botIconGeneration=0; @@ -71,9 +72,13 @@ async function api(path, body) { return data; } function go(tab) { + if(formSaving){notice('Your event is saving. Please wait.');return;} const target = tab === 'pending' ? 'events' : tab; if (tab === 'events' || tab === 'pending') { listFilter = tab === 'pending' ? 'pending' : 'all'; renderEvents(); } if (tab === 'admin' && !state.user?.isSuperAdmin) return; + if(target!=='gallery')dismissPendingMedia?.(); + dismissTicket(); + pageNavigationGeneration++;currentView=target;updateAppClose(); for (const name of ['home','events', 'create', 'settings', 'admin', 'gallery', 'explore']) $(name + '-view').hidden = name !== target; if(tab==='home'){renderHome();loadHomeSuggestions();} if (tab === 'explore') loadExplore(); @@ -83,6 +88,7 @@ function go(tab) { } window.scrollTo(0, 0); } +function updateAppClose(){const exits=compactPicker || currentView==='home',label=exits && initData && typeof tg?.close==='function'?'Close App':compactPicker?'Return to Telegram':'Close this page and return Home';$('app-close').setAttribute('aria-label',label);$('app-close').title=label;$('app-close').disabled=formSaving;$('cancel-edit').disabled=formSaving;$('edit-delete-event').disabled=formSaving;$('edit-cancel-event').disabled=formSaving || !!activeEvent?.cancelled;$('edit-checkin').disabled=formSaving || !!activeEvent?.cancelled;} function dateInZone(instant, zone) { const parts = Object.fromEntries(new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(new Date(instant)).map(p => [p.type, p.value])); return `${parts.year}-${parts.month}-${parts.day}`; @@ -141,11 +147,13 @@ function share(e) { if(e.invitationMode==='named' && isManager(e))return openNam function element(tag, text, className) { const node = document.createElement(tag); node.textContent = text; if (className) node.className = className; return node; } function action(text, fn, className = 'secondary') { const b = element('button', text, className); b.type = 'button'; b.onclick = async () => { if(b.disabled)return; b.disabled=true; try { await fn(); } catch(error) { notice(error.message); } finally { b.disabled=false; } }; return b; } async function openGuestList(id) { + const navigation=pageNavigationGeneration; const {event:e}=await api(`events/${id}`); + if(navigation!==pageNavigationGeneration)return; if(!isManager(e))return; const roster=e.guestRoster || [],accepted=roster.filter(g=>g.status==='yes'); - $('guest-list-title').textContent=e.title+' · Guest list'; - $('guest-list-summary').textContent=`Accepted: ${accepted.length} responses · ${accepted.reduce((sum,g)=>sum+g.participants,0)} people. Confirmed: ${roster.filter(g=>g.confirmed).reduce((sum,g)=>sum+g.participants,0)} people.`; + $('guest-list-title').textContent='Guest list';$('guest-list-event').textContent=e.title; + $('guest-list-summary').textContent=`${accepted.reduce((sum,g)=>sum+g.participants,0)} people accepted · ${accepted.length} responses · ${roster.filter(g=>g.confirmed).reduce((sum,g)=>sum+g.participants,0)} confirmed`; const filters=$('guest-list-filters');filters.replaceChildren(); const named=e.invitationMode==='named',readOnly=e.cancelled || e.group==='Past events'; const invitationFor=guest=>guest.id!=null ? e.invitees?.find(invite=>invite.responses?.some(response=>response.id===guest.id)) : e.invitees?.find(invite=>invite.name===guest.name); @@ -154,77 +162,111 @@ async function openGuestList(id) { $('guest-list-invitations').hidden=!named;$('guest-list-invitations').onclick=()=>showInvitations(); const render=filter=>{ const rows=$('guest-list-rows');rows.replaceChildren(); - const visible=roster.filter(g=>filter==='all' || (filter==='unanswered' ? ['later','unopened'].includes(g.status) : filter==='pending' ? g.status==='yes' && !g.confirmed : g.status===filter)); + const visible=roster.filter(g=>filter==='all' || (filter==='unanswered' ? ['later','unopened'].includes(g.status) : filter==='pending' ? g.status==='yes' && !g.confirmed : g.status===filter && (filter!=='later' || g.responded!==false))); for(const g of visible){ - const row=element('article','','admin-guest');row.append(element('p',`${g.name} · ${g.status==='yes' ? g.confirmed?'Confirmed':g.approval==='pending'?'Awaiting approval':'Awaiting payment' : {no:'Rejected',maybe:'Maybe',later:'Respond later',unopened:'Not opened'}[g.status] || g.status}${g.participants?' · '+g.participants+' people':''}`)); + const status=g.status==='yes' ? g.confirmed?'yes':g.approval==='pending'?'pending':'payment' : g.status==='later' && g.responded===false?'unanswered':g.status,label={yes:'Accepted',pending:'Awaiting approval',payment:'Awaiting payment',no:'Declined',maybe:'Maybe',later:'Respond later',unanswered:'Awaiting response',unopened:'Not opened'}[status] || 'Awaiting response'; + const row=element('article','','admin-guest guest-roster-row');row.dataset.status=status;const heading=element('div','','guest-roster-heading');heading.append(element('strong',g.name));if(g.participants)heading.append(element('span',g.participants+' '+(g.participants===1?'person':'people'),'small'));const badge=element('span',label,'invitation-status');badge.dataset.status=status;row.append(heading,badge); const invite=named && invitationFor(g); - if(invite){const controls=element('div','','event-actions guest-inline-actions');controls.append(action('✉️ Invitation',()=>showInvitations(invite)));const revoke=action('🚫 Revoke invitation',()=>showInvitations(invite,{revoke:true}),'text-button');revoke.disabled=readOnly;controls.append(revoke);row.append(controls);}rows.append(row); + if(invite){const more=element('details','','event-more guest-roster-more'),toggle=element('summary','⋯');toggle.setAttribute('aria-label','Guest options for '+g.name);const controls=element('div','','event-more-panel');controls.append(action('✉️ Invitation',()=>showInvitations(invite)));const edit=action('✏️ Edit invite',()=>showInvitations(invite,{edit:true})),response=action('↻ Change RSVP',()=>showInvitations(invite,{response:true,responseUserId:g.id}));edit.disabled=readOnly;response.disabled=readOnly || !Number.isSafeInteger(g.id) || !invite.responses?.some(reply=>reply.id===g.id);controls.append(edit,response);const revoke=action('🚫 Revoke invitation',()=>showInvitations(invite,{revoke:true}),'secondary invitation-danger');revoke.disabled=readOnly;controls.append(revoke);more.append(toggle,controls);row.append(more);}rows.append(row); } if(!visible.length)rows.append(element('p','No guests in this list.','muted')); for(const b of filters.children)b.setAttribute('aria-pressed',String(b.dataset.filter===filter)); }; - for(const [filter,label] of [['all','All'],['yes','Accepted'],['pending','Pending'],['maybe','Maybe'],['no','Rejected'],['unanswered','Unanswered']]){const b=action(label,()=>render(filter));b.dataset.filter=filter;filters.append(b);} + for(const [filter,label] of [['all','All'],['yes','Accepted'],['pending','Pending'],['maybe','Maybe'],['no','Declined'],['later','Later'],['unanswered','Unanswered']]){const b=action(label,()=>render(filter));b.dataset.filter=filter;filters.append(b);} $('guest-list-manage').onclick=()=>openTelegram(e.inviteUrl.split('?')[0]+'?start=manage_'+e.id); $('guest-list-close').onclick=()=>$('guest-list-dialog').close();render('all');$('guest-list-dialog').showModal(); } -async function openTicket(id) { - const {ticket}=await api(`events/${id}/ticket`,{}); - $('ticket-title').textContent=ticket.title; - $('ticket-name').textContent=`${ticket.name} · ${ticket.participants} ${ticket.participants===1?'person':'people'}`; - $('ticket-image').hidden=!ticket.image;if(ticket.image)$('ticket-image').src=ticket.image;else $('ticket-image').removeAttribute('src');$('ticket-code').textContent=ticket.code; - $('ticket-message').textContent=ticket.checkedInAt?'Already checked in.':ticket.image?'Show this QR code at the event.':'Show this ticket code at the event.'; - $('ticket-copy').onclick=async()=>{try{await navigator.clipboard.writeText(ticket.code);$('ticket-message').textContent='Ticket code copied.';}catch{$('ticket-message').textContent='Select the code to copy it.';}}; - $('ticket-close').onclick=()=>$('ticket-dialog').close();$('ticket-dialog').showModal(); +let ticketGeneration=0,ticketTimer=null,ticketView=null,ticketBusy=false; +function stopTicketTimer(){if(ticketTimer!==null)clearTimeout(ticketTimer);ticketTimer=null;} +function dismissTicket(){ticketGeneration++;stopTicketTimer();ticketView=null;ticketBusy=false;$('ticket-copy').disabled=true;if($('ticket-dialog').open)$('ticket-dialog').close();} +function updateTicketCountdown(){ + stopTicketTimer();if(!ticketView || !$('ticket-dialog').open)return; + const remaining=Math.max(0,Math.ceil((ticketView.expiresAt-Date.now())/1000)); + $('ticket-copy').disabled=ticketBusy || remaining===0 || !!ticketView.checkedInAt; + $('ticket-countdown').textContent=ticketView.checkedInAt?'Already checked in.':remaining?'Expires in '+Math.floor(remaining/60)+':'+String(remaining%60).padStart(2,'0'):'Code expired. Tap Refresh code.'; + if(!remaining && !ticketView.checkedInAt)$('ticket-message').textContent='Refresh to get a current check-in code.'; + if(remaining && !ticketView.checkedInAt){const generation=ticketGeneration;ticketTimer=setTimeout(()=>{if(generation===ticketGeneration)updateTicketCountdown();},Math.min(1000,Math.max(1,ticketView.expiresAt-Date.now())));} } +async function openTicket(id) { + const navigation=pageNavigationGeneration,generation=++ticketGeneration,startedAt=Date.now();stopTicketTimer();ticketView=null;ticketBusy=true; + $('ticket-copy').disabled=true;$('ticket-refresh').disabled=true;$('ticket-code').textContent='······';$('ticket-countdown').textContent='Generating code…'; + $('ticket-refresh').onclick=()=>{if(!ticketBusy)return openTicket(id);}; + try{ + const {ticket}=await api(`events/${id}/ticket`,{}); + if(navigation!==pageNavigationGeneration || generation!==ticketGeneration)return; + const lifetime=Date.parse(ticket.codeExpiresAt)-Date.parse(ticket.serverTime); + if(!/^\d{6}$/.test(ticket.code) || !Number.isFinite(lifetime))throw Error('Could not generate a check-in code. Try Refresh code.'); + ticketView={...ticket,expiresAt:startedAt+Math.max(0,lifetime)}; + $('ticket-title').textContent=ticket.title;$('ticket-name').textContent=`${ticket.name} · ${ticket.participants} ${ticket.participants===1?'person':'people'}`; + $('ticket-image').hidden=!ticket.image;if(ticket.image)$('ticket-image').src=ticket.image;else $('ticket-image').removeAttribute('src');$('ticket-code').textContent=ticket.code; + $('ticket-message').textContent=ticket.checkedInAt?'Already checked in.':'Show this code to the organiser. Refresh if it expires.'; + if(!$('ticket-dialog').open)$('ticket-dialog').showModal(); + }catch(error){if(navigation===pageNavigationGeneration && generation===ticketGeneration){$('ticket-countdown').textContent='Code unavailable.';if($('ticket-dialog').open)$('ticket-message').textContent=error.message;else notice(error.message);}} + finally{if(generation===ticketGeneration){ticketBusy=false;$('ticket-refresh').disabled=false;updateTicketCountdown();}} +} +$('ticket-copy').onclick=async()=>{updateTicketCountdown();if(!ticketView || $('ticket-copy').disabled)return;const generation=ticketGeneration,code=ticketView.code;try{await navigator.clipboard.writeText(code);if(generation===ticketGeneration)$('ticket-message').textContent='Code copied.';}catch{if(generation===ticketGeneration)$('ticket-message').textContent='Select the code to copy it.';}}; +$('ticket-close').onclick=()=>$('ticket-dialog').close(); +$('ticket-dialog').onclose=()=>{ticketGeneration++;stopTicketTimer();ticketView=null;ticketBusy=false;$('ticket-copy').disabled=true;$('ticket-code').textContent='';$('ticket-countdown').textContent='';}; let checkinGeneration=0; async function openCheckin(id) { - const {event}=await api(`events/${id}`);if(!isManager(event))return; - const generation=++checkinGeneration;let busy=false,verifiedCode=null; - const input=$('checkin-code'),result=$('checkin-result'),confirm=$('checkin-confirm'); - input.disabled=false;$('checkin-check').disabled=false;$('checkin-scan').disabled=false; + const navigation=pageNavigationGeneration; + const {event}=await api(`events/${id}`);if(navigation!==pageNavigationGeneration || !isManager(event))return; + const generation=++checkinGeneration;let busy=false,verifiedCode=null,scannedCode=null,completedCode=null; + const input=$('checkin-code'),result=$('checkin-result'),confirm=$('checkin-confirm'),checkButton=$('checkin-check'),keypad=$('checkin-keypad');keypad.replaceChildren(); + input.disabled=false;$('checkin-scan').disabled=false; $('checkin-scan').hidden=event.qrEnabled===false; $('checkin-event').textContent=event.title;input.value='';result.textContent='';confirm.hidden=true; - input.oninput=()=>{verifiedCode=null;confirm.hidden=true;result.textContent='';}; + const active=()=>generation===checkinGeneration && $('checkin-dialog').open; + const codeValue=()=>scannedCode || input.value; + const update=()=>{for(let index=0;index<6;index++){$('checkin-digit-'+index).textContent=input.value[index] || '·';$('checkin-digit-'+index).classList.toggle('current',index===input.value.length);}checkButton.disabled=busy || completedCode===codeValue() || !(verifiedCode===codeValue() && !!verifiedCode || /^\d{6}$/.test(input.value));for(const button of keypad.children)button.disabled=busy;input.disabled=busy;$('checkin-scan').disabled=busy;confirm.hidden=true;}; + input.oninput=()=>{input.value=input.value.replace(/\D/g,'').slice(0,6);verifiedCode=scannedCode=completedCode=null;result.textContent='';result.className='';update();}; const check=async(mark=false)=>{ - if(busy)return;busy=true; - const code=input.value.trim();confirm.hidden=true;$('checkin-check').disabled=true;$('checkin-scan').disabled=true;input.disabled=true; + if(busy || !active())return; + const code=codeValue();if(mark && !(/^\d{6}$/.test(code) || code===verifiedCode))return;if(!mark && !code)return;busy=true;update();result.textContent=mark?'Checking in…':'Checking QR…';result.className=''; try{ const {ticket}=await api(`events/${id}/ticket-check`,{code,checkIn:mark}); - if(generation!==checkinGeneration || !$('checkin-dialog').open)return; + if(!active())return; result.textContent=ticket.valid ? `${ticket.alreadyCheckedIn?'⚠ Already checked in':mark?'✓ Checked in':'✓ Valid ticket'}\n${ticket.name} · ${ticket.participants} ${ticket.participants===1?'person':'people'}${ticket.checkedInAt?'\n'+format({startsAt:ticket.checkedInAt}):''}` : '✕ '+ticket.reason; - verifiedCode=ticket.valid && !ticket.checkedInAt ? code:null;confirm.hidden=!verifiedCode; - result.className=ticket.valid ? '' : 'error'; - }catch(error){if(generation===checkinGeneration)result.textContent=error.message;} - finally{busy=false;if(generation===checkinGeneration){$('checkin-check').disabled=false;$('checkin-scan').disabled=false;input.disabled=false;}} + verifiedCode=ticket.valid && !ticket.checkedInAt && !ticket.alreadyCheckedIn ? code:null; + if(ticket.valid && (mark || ticket.checkedInAt || ticket.alreadyCheckedIn))completedCode=code; + result.className=ticket.valid ? ticket.alreadyCheckedIn?'checkin-warning':'checkin-success' : 'error'; + }catch(error){if(active()){result.textContent=error.message;result.className='error';}} + finally{busy=false;if(active())update();} }; - $('checkin-check').onclick=()=>check();confirm.onclick=()=>{if(verifiedCode && input.value.trim()===verifiedCode)return check(true);}; + checkButton.onclick=()=>{if(!checkButton.disabled)return check(true);};confirm.onclick=()=>{if(verifiedCode)return check(true);}; + input.onkeydown=keyboard=>{if(keyboard.key==='Enter'){keyboard.preventDefault();if(!checkButton.disabled)void check(true);}}; + for(const value of ['1','2','3','4','5','6','7','8','9','Clear','0','⌫']){const button=action(value,()=>{if(busy || !active() || /^\d$/.test(value) && input.value.length>=6)return;input.value=value==='Clear'?'':value==='⌫'?input.value.slice(0,-1):input.value+value;input.oninput();},'secondary');button.setAttribute('aria-label',value==='⌫'?'Delete last digit':value==='Clear'?'Clear code':value);keypad.append(button);} $('checkin-scan').onclick=()=>{ if(!tg?.showScanQrPopup || (tg.isVersionAtLeast && !tg.isVersionAtLeast('6.4'))){result.textContent='QR scanning is unavailable here. Enter the ticket code instead.';return;} - try{tg.showScanQrPopup({text:'Scan the guest’s ticket QR.'},text=>{input.value=text;void check();return true;});}catch{result.textContent='Could not open the scanner. Enter the ticket code instead.';} + try{tg.showScanQrPopup({text:'Scan the guest’s ticket QR.'},text=>{if(!active())return true;scannedCode=String(text).trim();verifiedCode=completedCode=null;input.value=/^\d{6}$/.test(scannedCode)?scannedCode:'';update();void check();return true;});}catch{result.textContent='Could not open the scanner. Enter the ticket code instead.';} }; $('checkin-close').onclick=()=>$('checkin-dialog').close(); $('checkin-dialog').onclose=()=>{checkinGeneration++;try{tg?.closeScanQrPopup?.();}catch{}}; - $('checkin-dialog').showModal(); + $('checkin-dialog').showModal();update(); } -const {openGallery,showQr}=setupGallery({$,api,element,action,go,notice,openTelegram,initData}); +const {openGallery,showQr,dismissPendingMedia}=setupGallery({$,api,element,action,go,notice,openTelegram,initData}); const {cancelEvent,deleteEvent}=setupEventActions({$,api,refresh,notice,onEnded:event=>{ state.events=state.events.filter(item=>item.id!==event.id); if(activeEvent?.id===event.id){activeEvent=null;formReady=false;$('event-form').hidden=true;$('success').hidden=true;} if(createdEvent?.id===event.id)createdEvent=null; go('events'); }}); +let confirmationSession=null; function confirmAction(message, operation) { const dialog = $('confirm-dialog'); - if (dialog.open) return Promise.resolve(false); - const labels={delete:['Delete event?','Delete event','Keep event'],cancel:['Cancel event?','Cancel event','Keep event'],'cohost-revoke':['Revoke co-host access?','Revoke access','Keep access'],'cohost-cancel-link':['Cancel co-host invite?','Cancel invite link','Keep link'],'cohost-rotate':['Replace co-host invite?','Create new link','Keep current link']}[operation] || ['Confirm action','Confirm','Back']; + if (dialog.open || confirmationSession) return Promise.resolve(false); + const labels={delete:['Delete event?','Delete event','Keep event'],cancel:['Cancel event?','Cancel event','Keep event'],'cohost-revoke':['Revoke co-host access?','Revoke access','Keep access'],'cohost-cancel-link':['Cancel co-host invite?','Cancel invite link','Keep link'],'cohost-rotate':['Replace co-host invite?','Create new link','Keep current link'],'whitelist-enable':['Restrict dev access?','Turn on whitelist','Keep current access'],'whitelist-first':['Restrict dev access?','Add user and restrict','Back'],'whitelist-disable':['Open dev access?','Turn off whitelist','Keep restricted'],'whitelist-last':['Remove the last user?','Remove user','Keep user'],'whitelist-repair':['Repair access setting?','Repair and turn on','Back']}[operation] || ['Confirm action','Confirm','Back']; $('confirm-title').textContent = labels[0]; $('confirm-message').textContent = message; $('confirm-proceed').textContent = labels[1];$('confirm-back').textContent=labels[2]; return new Promise(resolve => { - const finish = accepted => { dialog.close(); resolve(accepted); }; + const session={settled:false,closed:false};confirmationSession=session; + const finish = accepted => {if(session.settled)return;session.settled=true;if(dialog.open)dialog.close();else if(session.closed && confirmationSession===session)confirmationSession=null;resolve(accepted);}; $('confirm-back').onclick = () => finish(false); + $('confirm-close').onclick = () => finish(false); $('confirm-proceed').onclick = () => finish(true); dialog.oncancel = event => { event.preventDefault(); finish(false); }; + dialog.onclose=()=>{if(confirmationSession!==session)return;session.closed=true;confirmationSession=null;if(!session.settled)finish(false);}; dialog.showModal(); $('confirm-back').focus(); }); } @@ -286,7 +328,7 @@ async function changeCoHost(event,operation,entry){ finally{setCoHostBusy(false);} } async function openCoHost(id){ - const generation=++cohostGeneration,{event}=await api(`events/${id}`);if(generation!==cohostGeneration)return; + const navigation=pageNavigationGeneration,generation=++cohostGeneration,{event}=await api(`events/${id}`);if(generation!==cohostGeneration || navigation!==pageNavigationGeneration)return; if(!event.isOwner)throw Error('Only the event owner can manage co-hosts.'); cohostEventId=id;renderCoHost(event);$('cohost-settings').open=false;$('cohost-label').value='';$('cohost-status').textContent='';$('cohost-dialog').showModal(); } @@ -312,7 +354,7 @@ function renderHome(){ if(e.invitationMode==='named')actions.append(action('✉️ Invitations',()=>openNamedLinks(e))); } if(isManager(e) || e.status==='yes' && e.permissions?.viewMedia)actions.append(action('🗂 Shared media',()=>openGallery(e.id))); - if(!isManager(e) && e.ticket)actions.append(action('🎟 My ticket',()=>openTicket(e.id))); + if(!isManager(e) && e.ticket)actions.append(action('🎟 Check-in code',()=>openTicket(e.id))); card.append(actions);list.append(card);} } } @@ -354,15 +396,15 @@ function renderEvents() { if(priceEstimate(e))card.append(element('p',priceEstimate(e),'small muted')); if (e.starPrice) card.append(element('p', '⭐ ' + e.starPrice + ' Stars ' + (e.starPricing === 'person' ? 'per person' : 'per group') + (e.paymentStatus ? ' · ' + e.paymentStatus.replaceAll('_',' ') : ''), 'small muted')); if (e.participants) card.append(element('p', 'Your group: ' + e.participants + (e.participants === 1 ? ' person' : ' people'), 'small muted')); - if (e.ticket) card.append(element('p', `🎟 ${e.ticket.name}${e.ticket.code ? ' · ' + e.ticket.code : ''}${e.ticket.info ? '\n' + e.ticket.info : ''}`, 'time-preview')); + if (e.ticket) card.append(element('p', `🎟 ${e.ticket.name}${e.ticket.info ? '\n' + e.ticket.info : ''}`, 'time-preview')); if (e.startsAt && selectedZone() !== e.timezone) card.append(element('p', 'Organiser time: ' + format({ startsAt: e.startsAt }, e.timezone), 'small muted')); if (e.counts) card.append(element('div', e.invitationMode==='tickets' ? `${e.counts.yes} confirmed bookings · ${e.counts.participants} people · ${e.counts.pending} approval requests · ${e.counts.awaitingPayment} awaiting payment` : `${e.counts.participants} people coming (${e.counts.yes} responses) · ${e.counts.pendingParticipants || 0} people awaiting approval · ${e.counts.awaitingPayment || 0} awaiting payment · ${e.counts.maybe} tentative · ${e.counts.no} declined · ${e.counts.later} later`, 'counts')); else card.append(element('div', 'Guest list is private to the organiser.', 'counts')); const actions = element('div', '', 'event-actions'); actions.append(action('💬 Open in chat', () => openTelegram(e.inviteUrl), 'primary')); if (isManager(e) && !e.cancelled) actions.append(action('✏️ Edit event', () => editEvent(e.id))); if(isManager(e))actions.append(action('👥 Guest list',()=>openGuestList(e.id))); - if(isManager(e) && !e.cancelled)actions.append(action(e.qrEnabled!==false?'📷 Scan tickets':'🎟 Check tickets',()=>openCheckin(e.id))); - if(e.ticket && e.qrEnabled!==false)actions.append(action('🔳 Ticket QR',()=>openTicket(e.id))); + if(isManager(e) && !e.cancelled)actions.append(action('🎟 Check in guests',()=>openCheckin(e.id))); + if(e.ticket)actions.append(action('🎟 Check-in code',()=>openTicket(e.id))); if((e.starPrice || ['bank','link'].includes(e.paymentMethod)) && !isManager(e) && e.status==='yes' && e.approval==='approved' && e.paymentStatus!=='paid')actions.append(action(e.starPrice?'⭐ Pay with Stars':'💳 Payment instructions',()=>openTelegram(e.inviteUrl.split('?')[0]+'?start=pay_'+e.id))); if(e.invitationMode==='named' && isManager(e))actions.append(action('✉️ Guest invitations',()=>openNamedLinks(e)));else actions.append(action('📋 Copy link', async () => { try { await navigator.clipboard.writeText(e.inviteUrl); notice('✓ Event link copied.'); } catch { notice('Could not copy the link. Use Share invite under the three-dot menu.'); } })); const more = document.createElement('details'); more.className = 'event-more'; @@ -391,7 +433,8 @@ function renderEvents() { document.addEventListener('click', event => { for (const menu of document.querySelectorAll('.event-more[open]')) if (!menu.contains(event.target) || event.target.closest('button')) menu.open=false; }); document.addEventListener('keydown', event => { if (event.key==='Escape') for (const menu of document.querySelectorAll('.event-more[open]')) { menu.open=false; menu.querySelector('summary').focus(); } }); async function editEvent(id){ - try{const result=await api(`events/${id}`);setupForm(result.event);}catch(error){notice(error.message);} + const navigation=pageNavigationGeneration; + try{const result=await api(`events/${id}`);if(navigation===pageNavigationGeneration)setupForm(result.event);}catch(error){if(navigation===pageNavigationGeneration)notice(error.message);} } function selectedInvitationMode(){ const type=$('invitation-mode').value; @@ -418,32 +461,35 @@ let invitationLinksGeneration=0; $('invitation-links-close').onclick=()=>$('invitation-links-dialog').close(); $('invitation-links-dialog').onclose=()=>{invitationLinksGeneration++;}; async function openNamedLinks(event,selectedGuest,options={}){ - const generation=++invitationLinksGeneration; + const navigation=pageNavigationGeneration,generation=++invitationLinksGeneration; try{ let {event:e}=await api(`events/${event.id}`); - if(generation!==invitationLinksGeneration)return; - if(!isManager(e))throw Error('Only event managers can see invitation links.'); + if(generation!==invitationLinksGeneration || navigation!==pageNavigationGeneration)return; + if(!isManager(e)){$('invitation-links-list').replaceChildren();if($('invitation-links-dialog').open)$('invitation-links-dialog').close();throw Error('Only event managers can see invitation links.');} const search=$('invitation-links-search'),filter=$('invitation-links-filter'),status=$('invitation-links-status'); search.value='';search.hidden=false;filter.value='all';status.textContent=''; $('invitation-add').open=options.add===true;$('invitation-add-names').value='';$('invitation-single').open=options.add===true;$('invitation-bulk').open=false; - let busy=false,mutationButtons=[],addRequest,singleRequest,focusSelected=true,singleEditor; - const current=()=>generation===invitationLinksGeneration; + let busy=false,mutationButtons=[],mutationInputs=[],addRequest,singleRequest,focusSelected=true,singleEditor,panelState=null; + const mutationRequests=new Map(); + const current=()=>generation===invitationLinksGeneration && navigation===pageNavigationGeneration; const readOnly=()=>e.cancelled || e.group==='Past events'; const responses=guest=>Array.isArray(guest.responses) ? guest.responses : ['yes','no','maybe','later'].includes(guest.status) ? [{name:guest.name,status:guest.status,participants:guest.participants || 1}] : []; - const matches=(guest,value)=>value==='all' || (value==='unanswered' ? !responses(guest).length || responses(guest).some(g=>g.status==='later') : responses(guest).some(g=>g.status===value)); - const setBusy=value=>{busy=value;$('invitation-add-submit').disabled=value || readOnly();$('invitation-add-names').disabled=value || readOnly();singleEditor?.setDisabled(value || readOnly());for(const b of mutationButtons)b.disabled=value || readOnly() || b.dataset.unavailable==='true';}; + const matches=(guest,value)=>value==='all' || (value==='revoked' ? guest.revoked===true : !guest.revoked && (value==='unanswered' ? !responses(guest).length || responses(guest).some(g=>g.status==='later') : responses(guest).some(g=>g.status===value && (value!=='later' || g.responded!==false)))); + const setBusy=value=>{busy=value;$('invitation-add-submit').disabled=value || readOnly();$('invitation-add-names').disabled=value || readOnly();singleEditor?.setDisabled(value || readOnly());for(const b of [...mutationButtons,...mutationInputs])b.disabled=value || readOnly() || b.dataset.unavailable==='true';}; const updateEvent=value=>{e=value;const index=state.events.findIndex(item=>item.id===e.id);if(index>=0)state.events[index]=e;renderEvents();renderHome();}; - const refreshList=async()=>{const result=await api(`events/${e.id}`);if(!current())return;if(!isManager(result.event))throw Error('Your event management access has changed.');updateEvent(result.event);render();}; + const refreshList=async()=>{const result=await api(`events/${e.id}`);if(!current())return;if(!isManager(result.event)){$('invitation-links-list').replaceChildren();$('invitation-links-dialog').close();notice('Your event management access has changed.');return;}updateEvent(result.event);render();}; + const mutationRequest=(operation,payload)=>{const key=operation+':'+payload.token,value=JSON.stringify(payload),previous=mutationRequests.get(key);if(previous?.value===value)return {...payload,requestId:previous.id};const id=crypto.randomUUID();mutationRequests.set(key,{value,id});return {...payload,requestId:id};}; const mutate=async(operation,payload,guestName='Guest',source='bulk')=>{ if(busy || !current())return; setBusy(true);status.textContent='Saving…'; try{ const result=await api(`events/${e.id}/invitations/${operation}`,{...payload,version:e.invitationsVersion}); if(!current())return; - updateEvent(result.event); + updateEvent(result.event);panelState=null; if(operation==='add'){if(source==='bulk'){$('invitation-add-names').value='';$('invitation-add').open=false;addRequest=null;}else singleRequest=null;} render(); - status.textContent=operation==='add' ? 'Guests added. Their links are ready to share.' : result.alreadyRemoved ? guestName+'’s invitation was already removed.' : payload.notify ? result.notifyCount ? guestName+'’s invitation removed. Guest notified.' : guestName+'’s invitation removed. No Telegram account was available to notify.' : guestName+'’s invitation removed silently.'; + const done={edit:'Invitation updated.',response:'Response updated.',revoke:'Invitation revoked. Its link and ticket are disabled.',delete:'Invitation deleted.'}; + status.textContent=operation==='add' ? 'Guests added. Their links are ready to share.' : (done[operation] || 'Invitation updated.')+(payload.notify ? result.notifyCount ? ' Guest notified.' : ' No Telegram notification was sent.' : ['response','revoke'].includes(operation) ? ' Guest not notified.' : ''); return true; }catch(error){ if(!current())return; @@ -456,46 +502,98 @@ async function openNamedLinks(event,selectedGuest,options={}){ const render=()=>{ if(!current())return; $('invitation-links-note').textContent=oneTimeInviteEnabled(e) ? 'Send each link only to its named guest. Accept, Decline or Maybe locks it to that guest; Respond later does not. The same guest can change their RSVP.' : 'These links can be used by more than one guest. Each response is shown below.'; - const list=$('invitation-links-list');list.replaceChildren();mutationButtons=[]; - const guests=[...(e.invitees || [])],selected=selectedGuest && guests.find(guest=>guest.token===selectedGuest || (()=>{try{return new URL(guest.url).searchParams.get('start')==='i_'+e.id+'_'+selectedGuest;}catch{return false;}})()); + const list=$('invitation-links-list');list.replaceChildren();mutationButtons=[];mutationInputs=[]; + const guests=[...(e.invitees || []),...(e.revokedInvitees || []).map(guest=>({...guest,revoked:true}))],selected=selectedGuest && guests.find(guest=>guest.token===selectedGuest || (()=>{try{return new URL(guest.url).searchParams.get('start')==='i_'+e.id+'_'+selectedGuest;}catch{return false;}})()); if(selected){guests.splice(guests.indexOf(selected),1);guests.unshift(selected);} else if(selectedGuest)status.textContent='That invitation is unavailable. Choose a guest below.'; - const replies=guests.flatMap(responses),finalReplies=replies.filter(g=>['yes','no','maybe'].includes(g.status)); - $('invitation-links-summary').textContent=`${guests.length} invitations · ${finalReplies.length} responses · ${replies.filter(g=>g.status==='yes').reduce((sum,g)=>sum+(g.participants || 1),0)} people accepted`; + const activeGuests=guests.filter(guest=>!guest.revoked),replies=activeGuests.flatMap(responses),finalReplies=replies.filter(g=>['yes','no','maybe'].includes(g.status)),revokedCount=guests.length-activeGuests.length; + $('invitation-links-summary').textContent=`${activeGuests.length} invitations · ${finalReplies.length} responses · ${replies.filter(g=>g.status==='yes').reduce((sum,g)=>sum+(g.participants || 1),0)} people accepted${revokedCount?' · '+revokedCount+' revoked':''}`; const selection=filter.value || 'all';filter.replaceChildren(); - for(const [value,label] of [['all','All'],['yes','Accepted'],['no','Declined'],['maybe','Maybe'],['later','Later'],['unanswered','Unanswered']]){const option=document.createElement('option');option.value=value;option.textContent=label+' ('+guests.filter(guest=>matches(guest,value)).length+')';filter.append(option);}filter.value=selection; - const rows=[];let selectedButton; + for(const [value,label] of [['all','All'],['yes','Accepted'],['no','Declined'],['maybe','Maybe'],['later','Later'],['unanswered','Unanswered'],['revoked','Revoked']]){const option=document.createElement('option');option.value=value;option.textContent=label+' ('+guests.filter(guest=>matches(guest,value)).length+')';filter.append(option);}filter.value=selection; + const rows=[];let selectedButton,panelFocus; for(const guest of guests){ const count=guest.participants || 1,askCount=guest.participantMode==='ask' || (!guest.participants && e.askParticipantCount),countLabel=askCount ? 'Guest chooses attendee count' : count+' '+(count===1?'attendee':'attendees'),countRule=!askCount && guest.participants ? guest.participantMode==='confirm' ? ' · Confirm count on acceptance' : guest.participantMode==='fixed' ? ' · Fixed count' : ' · Guest can change count' : ''; - const replyList=responses(guest),responseLabel=replyList.length>1 ? replyList.length+' responses' : {yes:'Accepted',no:'Declined',maybe:'Maybe',later:'Awaiting RSVP'}[replyList[0]?.status] || 'Not opened'; - const linkLabel=oneTimeInviteEnabled(e) ? guest.claimed ? 'Locked to one guest' : 'One-time link' : 'Reusable link'; - const text=inviteText(e,guest)+'\n\n'+guest.url,row=element('article','','panel invitation-entry'+(guest===selected?' invitation-selected':'')); + const replyList=responses(guest),responseStates=[...new Set(replyList.map(reply=>reply.responded===false?'unanswered':reply.status))],responseLabel=guest.revoked ? 'Revoked' : responseStates.length>1 ? 'Mixed responses' : {yes:'Accepted',no:'Declined',maybe:'Maybe',later:'Respond later',unanswered:'Awaiting response'}[responseStates[0]] || 'Not opened'; + const linkLabel=guest.revoked ? 'Link disabled' : oneTimeInviteEnabled(e) ? guest.claimed ? 'Locked to one guest' : 'One-time link' : 'Reusable link'; + const text=inviteText(e,guest)+'\n\n'+guest.url,row=element('article','','panel invitation-entry'+(guest===selected?' invitation-selected':'')+(guest.revoked?' invitation-revoked':''));row.dataset.token=guest.token || ''; const preview=element('details','','invitation-preview'),fullText=document.createElement('textarea'),linkText=document.createElement('input');fullText.value=text;fullText.readOnly=true;fullText.rows=9;fullText.setAttribute('aria-label','Full invitation for '+guest.name);linkText.value=guest.url;linkText.readOnly=true;linkText.setAttribute('aria-label','Personal link for '+guest.name);preview.append(element('summary','Preview invitation'),fullText,linkText); const copyInvite=async()=>{try{await navigator.clipboard.writeText(text);if(generation===invitationLinksGeneration)$('invitation-links-status').textContent='Invitation copied for '+guest.name+'.';}catch{if(generation!==invitationLinksGeneration)return;preview.open=true;fullText.focus();fullText.select();$('invitation-links-status').textContent='Could not copy. Select and copy the full invitation shown for '+guest.name+'.';}}; const copyLink=async()=>{try{await navigator.clipboard.writeText(guest.url);if(generation===invitationLinksGeneration)$('invitation-links-status').textContent='Link copied for '+guest.name+'.';}catch{if(generation!==invitationLinksGeneration)return;preview.open=true;linkText.focus();linkText.select();$('invitation-links-status').textContent='Could not copy. Select and copy the personal link shown for '+guest.name+'.';}}; - const copy=action(guest.name,copyInvite,'primary invitation-copy');copy.setAttribute('aria-label','Copy invitation for '+guest.name); - const actions=element('div','','event-actions invitation-actions');actions.append(action('Share invite',()=>openTelegram(`https://t.me/share/url?url=${encodeURIComponent(guest.url)}&text=${encodeURIComponent(inviteText(e,guest))}`)),action('Copy invite',copyInvite),action('Copy link',copyLink)); - const invitationState=element('p','','small muted');invitationState.append(element(askCount?'span':'strong',countLabel),element('span',countRule+' · '+responseLabel+' · '+linkLabel));row.append(copy,invitationState); + const copy=action(guest.name,copyInvite,'primary invitation-copy');copy.setAttribute('aria-label',guest.revoked ? guest.name+' · invitation revoked' : 'Copy invitation for '+guest.name);copy.disabled=guest.revoked===true; + const actions=element('div','','event-actions invitation-actions');const copyInviteButton=action('Copy invite',copyInvite),copyLinkButton=action('Copy link',copyLink);copyInviteButton.disabled=copyLinkButton.disabled=guest.revoked===true;if(!guest.revoked)actions.append(copyInviteButton,copyLinkButton); + const invitationState=element('p','','small muted');invitationState.append(element(askCount?'span':'strong',countLabel),element('span',countRule+' · '+linkLabel)); + const currentStatus=element('div','','invitation-current-status'),statusBadge=element('strong',responseLabel+(replyList.length>1?' · '+replyList.length+' guests':''),'invitation-status');statusBadge.dataset.status=guest.revoked?'revoked':responseStates.length>1?'mixed':responseStates[0] || 'unopened';statusBadge.setAttribute('aria-label','Current response: '+statusBadge.textContent);currentStatus.append(element('span','Current response','small muted'),statusBadge); + const responseDates=[...(guest.history || []).filter(entry=>['responded','changed'].includes(entry.type)).map(entry=>entry.at),...replyList.map(reply=>reply.respondedAt)].filter(value=>typeof value==='string' && Number.isFinite(new Date(value).getTime())).sort((a,b)=>new Date(a)-new Date(b)); + if(responseDates.length){const instant=new Date(responseDates.at(-1)),time=element('time','Last response: '+new Intl.DateTimeFormat(undefined,{timeZone:selectedZone(),dateStyle:'medium',timeStyle:'short'}).format(instant),'small muted invitation-last-response');time.setAttribute('datetime',instant.toISOString());currentStatus.append(time);}row.append(copy,currentStatus,invitationState); if(replyList.length){ const replyDetails=element('details','','invitation-responses');replyDetails.open=replyList.length===1; replyDetails.append(element('summary',replyList.length>1 ? 'View '+replyList.length+' responses' : 'Response details')); - for(const g of replyList){const label={yes:'Accepted',no:'Declined',maybe:'Maybe',later:'Later'}[g.status] || g.status;replyDetails.append(element('p',`${g.name || guest.name}: ${label}${g.status==='yes'?' · '+(g.participants || 1)+' '+((g.participants || 1)===1?'person':'people'):''}${g.status==='yes' && g.paymentStatus && g.paymentStatus!=='paid'?' · Awaiting payment':''}${g.comment?'\n'+g.comment:''}`,'small invitation-response'));} + for(const g of replyList){const label=g.responded===false?'Awaiting response':{yes:'Accepted',no:'Declined',maybe:'Maybe',later:'Respond later'}[g.status] || g.status,line=element('p',`${g.name || guest.name}: ${label}${g.status==='yes'?' · '+(g.participants || 1)+' '+((g.participants || 1)===1?'person':'people'):''}${g.status==='yes' && g.paymentStatus && g.paymentStatus!=='paid'?' · Awaiting payment':''}${g.comment?'\n'+g.comment:''}`,'small invitation-response');if(typeof g.respondedAt==='string' && Number.isFinite(new Date(g.respondedAt).getTime())){const instant=new Date(g.respondedAt),time=element('time','Last response: '+new Intl.DateTimeFormat(undefined,{timeZone:selectedZone(),dateStyle:'medium',timeStyle:'short'}).format(instant),'small muted');time.setAttribute('datetime',instant.toISOString());line.append(time);}replyDetails.append(line);} row.append(replyDetails); } - const removePanel=element('div','','invitation-remove');removePanel.hidden=true; - const notify=action('Notify guest',()=>mutate('remove',{token:guest.token,notify:true,confirm:true},guest.name));notify.dataset.unavailable=String(guest.canNotify===false); - const silent=action('Remove silently',()=>mutate('remove',{token:guest.token,notify:false,confirm:true},guest.name)); - const keep=action('Keep invite',()=>{removePanel.hidden=true;}); - const remove=action('Remove invite',()=>{if(busy)return;for(const item of rows)item.removePanel.hidden=true;removePanel.hidden=false;keep.focus();},'text-button invitation-remove-button'); - remove.setAttribute('aria-label','Remove invite for '+guest.name); - if(guest===selected && options.revoke && focusSelected)removePanel.hidden=false; - const removalActions=element('div','','event-actions');removalActions.append(notify,silent,keep); - removePanel.append(element('p',`Remove ${guest.name}’s invite? Its link and ticket will stop working.${replyList.length>1?' All responses using this link will be removed.':''}${guest.hasPayments?' Payment records remain; handle refunds separately in Payments.':''}${guest.canNotify===false?' No Telegram account is available to notify yet.':''}`,'small'),removalActions); - mutationButtons.push(remove,notify,silent,keep);row.append(actions,preview,remove,removePanel);list.append(row);rows.push({row,guest,removePanel,name:[guest.name,...replyList.map(g=>g.name || '')].join(' ').toLocaleLowerCase()});if(guest===selected)selectedButton=options.revoke?keep:copy; + const more=element('details','','event-more invitation-more'),toggle=element('summary','⋯');toggle.setAttribute('aria-label','More options for '+guest.name);const menu=element('div','','event-more-panel');more.append(toggle,menu); + const panel=element('section','','invitation-management');panel.hidden=true;panel.setAttribute('aria-label','Manage invitation for '+guest.name); + const showPanel=kind=>{if(busy)return;panelState={token:guest.token,kind,focus:true};render();}; + const menuAction=(label,kind,unavailable=false,danger=false)=>{const button=action(label,()=>showPanel(kind),danger?'secondary invitation-danger':'secondary');button.dataset.unavailable=String(unavailable);mutationButtons.push(button);menu.append(button);return button;}; + const editableReplies=replyList.filter(reply=>Number.isSafeInteger(reply.id) && reply.id>0); + if(!guest.revoked){menu.append(action('📤 Share invite',()=>{more.open=false;openTelegram(`https://t.me/share/url?url=${encodeURIComponent(guest.url)}&text=${encodeURIComponent(inviteText(e,guest))}`);}));menuAction('✏️ Edit invite','edit');menuAction('↻ Change response','response',!editableReplies.length);if(!editableReplies.length)menu.append(element('p','The guest must open their invitation first.','small muted invitation-menu-note'));menuAction('🚫 Revoke invite','revoke',false,true);} + else menuAction('🗑 Delete invite','delete',false,true); + menu.append(action('🕘 See history',()=>showPanel('history'))); + if(guest===selected && options.revoke && focusSelected && !guest.revoked)panelState={token:guest.token,kind:'revoke',focus:true}; + if(guest===selected && options.edit && focusSelected && !guest.revoked)panelState={token:guest.token,kind:'edit',focus:true}; + if(guest===selected && options.response && focusSelected && !guest.revoked)panelState={token:guest.token,kind:'response',focus:true,draft:{userId:String(options.responseUserId)}}; + if(panelState && panelState.token===guest.token && (guest.revoked && !['history','delete'].includes(panelState.kind) || panelState.kind==='response' && !editableReplies.length))panelState=null; + if(panelState && panelState.token===guest.token){ + const kind=panelState.kind,draft=panelState.draft ||= {};panel.hidden=false;panel.dataset.kind=kind; + const close=action('Back',()=>{panelState=null;render();}); + const title={edit:'Edit invite',response:'Change response',revoke:'Revoke invitation?',delete:'Delete invitation?',history:'Invitation history'}[kind] || 'Invitation',heading=element('h3',title);heading.tabIndex=-1;panel.append(heading); + const field=(label,type,key,value)=>{const input=document.createElement(type==='select'?'select':'input');if(type!=='select')input.type=type;input.dataset.field=key;input.value=draft[key] ?? value;const wrapper=element('label',label);wrapper.append(input);panel.append(wrapper);mutationInputs.push(input);return {input,wrapper};}; + const selectValues=(input,values,initial)=>{for(const [value,label] of values){const option=element('option',label);option.value=String(value);input.append(option);}input.value=String(initial);}; + const checkbox=(label,key,description='',unavailable=false)=>{const input=document.createElement('input');input.type='checkbox';input.dataset.field=key;input.checked=draft[key]===true;input.dataset.unavailable=String(unavailable);const wrapper=element('label','','check-option'),caption=element('span',label);if(description)caption.append(element('span',description,'check-note'));wrapper.append(input,caption);panel.append(wrapper);mutationInputs.push(input);input.onchange=()=>{draft[key]=input.checked;};return input;}; + const footer=element('div','','event-actions invitation-panel-actions'); + if(kind==='edit'){ + const {input:name}=field('Guest name','text','name',guest.name);name.maxLength=100;name.autocomplete='off';name.oninput=()=>{draft.name=name.value;}; + const modeValue=guest.participantMode==='ask'?'ask':['confirm','fixed'].includes(guest.participantMode)?guest.participantMode:guest.participants?'editable':'one'; + const {input:mode}=field('Attendee setting','select','mode',modeValue);selectValues(mode,[['one','One person'],['editable','Set a count · guest can change'],['ask','Ask guest how many'],['confirm','Ask guest to confirm count'],['fixed','Fixed count · no changes']],draft.mode ?? modeValue); + const {input:count,wrapper:countField}=field('Number of people','select','count',guest.participants || 1);selectValues(count,Array.from({length:10},(_,i)=>[i+1,String(i+1)]),draft.count ?? guest.participants ?? 1); + const update=()=>{draft.mode=mode.value;draft.count=count.value;countField.hidden=!['editable','confirm','fixed'].includes(mode.value);};mode.onchange=count.onchange=update;update(); + if(guest.hasPayments)panel.append(element('p','Paid tickets may prevent attendee changes. Manage refunds in Payments.','small muted')); + const save=action('Save invite',async()=>{const value=name.value.trim();if(!value || value.length>100 || /[=\r\n]/.test(value)){status.textContent='Enter a name of up to 100 characters, without = or line breaks.';name.focus();return;}if((e.invitees || []).some(invite=>invite.token!==guest.token && invite.name.toLocaleLowerCase()===value.toLocaleLowerCase())){status.textContent='That name is already in the list.';name.focus();return;}const participantMode={one:'default',editable:'preset',ask:'ask',confirm:'confirm',fixed:'fixed'}[mode.value],participants=['one','ask'].includes(mode.value)?null:Number(count.value);if(!participantMode || participants!==null && (!Number.isInteger(participants) || participants<1 || participants>10)){status.textContent='Choose a count from 1 to 10.';return;}draft.name=name.value;await mutate('edit',mutationRequest('edit',{token:guest.token,name:value,participants,participantMode}),guest.name);},'primary');mutationButtons.push(save);footer.append(save,close);panelFocus=name; + }else if(kind==='response'){ + const initial=editableReplies.length===1?editableReplies[0].id:''; + const {input:person}=field('Guest response','select','userId',initial);selectValues(person,[['','Choose a guest'],...editableReplies.map(reply=>[reply.id,reply.name || guest.name])],draft.userId ?? initial); + const responseValue=draft.status || editableReplies.find(reply=>String(reply.id)===person.value)?.status || 'yes'; + const {input:answer}=field('Response','select','status',responseValue);selectValues(answer,[['yes','Accepted'],['no','Declined'],['maybe','Maybe'],['later','Respond later']],responseValue); + const selectedReply=editableReplies.find(reply=>String(reply.id)===person.value),responseCount=draft.count || selectedReply?.selectedParticipants || selectedReply?.participants || guest.participants || 1; + const {input:count,wrapper:countField}=field('Number of people','select','count',responseCount);selectValues(count,Array.from({length:10},(_,i)=>[i+1,String(i+1)]),responseCount);count.dataset.unavailable=String(guest.participantMode==='fixed' || !guest.participants && !guest.participantMode); + const update=()=>{draft.userId=person.value;draft.status=answer.value;draft.count=count.value;countField.hidden=answer.value!=='yes';}; + person.onchange=()=>{const reply=editableReplies.find(item=>String(item.id)===person.value);answer.value=reply?.status || 'yes';count.value=String(reply?.selectedParticipants || reply?.participants || guest.participants || 1);update();};answer.onchange=count.onchange=update;update(); + if(guest.participantMode==='fixed')panel.append(element('p','The invitation’s attendee count is fixed.','small muted'));else if(!guest.participants && !guest.participantMode)panel.append(element('p','This invitation is for one person. Edit its attendee setting to change that.','small muted')); + if(guest.hasPayments)panel.append(element('p','Payment or check-in records may prevent this response change. Manage refunds in Payments.','small muted')); + const notify=checkbox('Notify guest','notify','Send the updated response to their Telegram chat.',guest.canNotify===false); + const save=action('Save response',async()=>{const reply=editableReplies.find(item=>String(item.id)===person.value),participants=Number(count.value);if(!reply){status.textContent='Choose the guest whose response you want to change.';person.focus();return;}if(answer.value==='yes' && (!Number.isInteger(participants) || participants<1 || participants>10)){status.textContent='Choose a count from 1 to 10.';return;}await mutate('response',mutationRequest('response',{token:guest.token,userId:reply.id,status:answer.value,...(answer.value==='yes'?{participants}:{}),notify:notify.checked && guest.canNotify!==false}),guest.name);},'primary');mutationButtons.push(save);footer.append(save,close);panelFocus=person; + }else if(kind==='revoke'){ + panel.append(element('p',`Revoke ${guest.name}’s invitation? Its link and ticket will stop working.${replyList.length>1?' This affects every response using this link.':''} You can still see its history in Revoked invitations.`,'small')); + const notify=checkbox('Notify guest','notify',guest.canNotify===false?'No Telegram account is available to notify yet.':'Send a revocation message in Telegram.',guest.canNotify===false),remove=checkbox('Delete after revoking','deleteAfter','Remove it from the invitation list. Audit records are retained. You’ll confirm next.'); + if(guest.hasPayments)panel.append(element('p','Payment records remain. Handle refunds separately in Payments.','small muted')); + const revoke=action('Revoke invite',async()=>{const deleteAfter=remove.checked,done=await mutate('revoke',mutationRequest('revoke',{token:guest.token,notify:notify.checked && guest.canNotify!==false,confirm:true}),guest.name);if(done && deleteAfter && current()){panelState={token:guest.token,kind:'delete',focus:true};filter.value='revoked';render();status.textContent='Invitation revoked. Confirm deletion below.';}},'danger-button');mutationButtons.push(revoke);footer.append(revoke,close);panelFocus=close; + }else if(kind==='delete'){ + panel.append(element('p',`Delete ${guest.name}’s revoked invitation from this list? This cannot be undone here. Its link stays disabled; audit records are retained.`,'small')); + const remove=action('Delete permanently',()=>mutate('delete',mutationRequest('delete',{token:guest.token,confirm:true}),guest.name),'danger-button');mutationButtons.push(remove);footer.append(remove,close);panelFocus=close; + }else if(kind==='history'){ + const history=Array.isArray(guest.history)?guest.history:[],labels={created:'Invite created',opened:'Invitation opened',responded:'Response received',changed:'Response changed',edited:'Invite edited',revoked:'Invitation revoked',deleted:'Invitation deleted'},statuses={yes:'Accepted',no:'Declined',maybe:'Maybe',later:'Respond later'},modes={default:'One person',preset:'Guest can change count',ask:'Guest chooses count',confirm:'Guest confirms count',fixed:'Fixed count'}; + panel.append(element('p','Times shown in '+selectedZone()+'.','small muted')); + if(!history.some(entry=>entry.type==='created'))panel.append(element('p',history.length?'Earlier activity may not be recorded. History tracking starts with this update.':'No recorded history yet. Earlier activity isn’t available.','small muted')); + const timeline=element('ol','','invitation-history'); + for(const entry of history){const item=element('li'),title=labels[entry.type] || 'Invitation updated',details=[];if(entry.name)details.push(entry.previousName && entry.previousName!==entry.name?entry.previousName+' → '+entry.name:entry.name);if(entry.status)details.push(entry.previousStatus && entry.previousStatus!==entry.status?(statuses[entry.previousStatus] || entry.previousStatus)+' → '+(statuses[entry.status] || entry.status):statuses[entry.status] || entry.status);if(entry.participants)details.push((entry.previousParticipants && entry.previousParticipants!==entry.participants?entry.previousParticipants+' → ':'')+entry.participants+' '+(entry.participants===1?'person':'people'));if(entry.participantMode)details.push(entry.previousParticipantMode && entry.previousParticipantMode!==entry.participantMode?(modes[entry.previousParticipantMode] || 'Previous attendee setting')+' → '+(modes[entry.participantMode] || 'Attendee setting updated'):modes[entry.participantMode] || 'Attendee setting updated');if(entry.actorRole==='organiser')details.push('By organiser');if(entry.notify===true)details.push('Notification requested');else if(entry.notify===false)details.push('Guest not notified');item.append(element('strong',title));if(details.length)item.append(element('p',details.join(' · '),'small'));const instant=new Date(typeof entry.at==='string'?entry.at:NaN),time=element('time',Number.isFinite(instant.getTime())?new Intl.DateTimeFormat(undefined,{timeZone:selectedZone(),dateStyle:'medium',timeStyle:'short'}).format(instant):'Date unavailable','small muted');if(Number.isFinite(instant.getTime()))time.setAttribute('datetime',instant.toISOString());item.append(time);timeline.append(item);}panel.append(timeline);footer.append(close);panelFocus=heading; + } + panel.append(footer);if(!panelState.focus)panelFocus=null;panelState.focus=false; + } + row.append(more,actions,...(guest.revoked?[]:[preview]),panel);list.append(row);rows.push({row,guest,name:[guest.name,...replyList.map(g=>g.name || '')].join(' ').toLocaleLowerCase()});if(guest===selected)selectedButton=copy; } const applyFilter=()=>{const term=search.value.trim().toLocaleLowerCase();for(const item of rows)item.row.hidden=!item.name.includes(term) || !matches(item.guest,filter.value);$('invitation-links-empty').hidden=rows.some(item=>!item.row.hidden);}; search.oninput=applyFilter;filter.onchange=applyFilter;applyFilter();setBusy(busy); - if(focusSelected && selectedButton && !rows.find(item=>item.guest===selected)?.row.hidden){selectedButton.focus();selectedButton.scrollIntoView?.({block:'nearest'});}focusSelected=false; + if(panelFocus){panelFocus.focus();panelFocus.scrollIntoView?.({block:'nearest'});}else if(focusSelected && selectedButton && !rows.find(item=>item.guest===selected)?.row.hidden){selectedButton.focus();selectedButton.scrollIntoView?.({block:'nearest'});}focusSelected=false; }; singleEditor=setupGuestEditor('invitation-single',async guest=>{ validateGuestAddition(guest.name,(e.invitees || []).map(invite=>invite.name)); @@ -516,6 +614,7 @@ async function loadBannerPreview(id,generation){ }catch(error){if(generation===bannerLoadGeneration)window.reportAppError?.(error,'Banner preview');} } function setupForm(event = null) { + if(formSaving){notice('Your event is saving. Please wait.');return;} formReady=true; activeEvent = event; createdEvent = null; requestId = crypto.randomUUID(); $('event-form').reset(); $('event-form').hidden = false; $('success').hidden = true; $('form-error').hidden = true; @@ -577,6 +676,7 @@ function setupForm(event = null) { $('save-event').textContent = deadlinePicker ? 'Set response deadline' : picker ? 'Use this time & continue' : event ? 'Save event settings' : 'Create event & get invite'; $('cancel-edit').hidden = !event; $('edit-event-actions').hidden=!event?.isOwner || compactPicker; + $('edit-checkin-panel').hidden=!event || !isManager(event) || compactPicker;$('edit-checkin').disabled=!!event?.cancelled; $('edit-cancel-event').disabled=!!event?.cancelled; $('save-event').disabled=!!event?.cancelled; const zone = deadlinePicker ? state.session?.timezone || selectedZone() : event?.timezone || selectedZone(); options('event-zone', zone); @@ -604,7 +704,7 @@ async function refresh() { $('connection-status').textContent=`Connected as ${data.user.firstName}${data.user.id ? ' · Telegram ID '+data.user.id : ''}${data.user.isSuperAdmin ? ' · Super admin' : ''}`; const hasPending = data.events.some(e => e.invitationMode!=='tickets' && !isManager(e) && !e.cancelled && e.status === 'later'); document.querySelector('[data-tab="pending"]').hidden = !hasPending; - if (!hasPending && listFilter === 'pending') go('events'); + if (!hasPending && listFilter === 'pending') {listFilter='all';if(currentView==='events' && !formSaving)go('events');} $('admin-tab').hidden = !data.user.isSuperAdmin; const profileName=state.preference.profileName || data.user.firstName; $('header-name').textContent=profileName; @@ -645,12 +745,85 @@ function filterExplore(){const text=$('explore-search').value.trim().toLowerCase $('explore-search').oninput=filterExplore; async function loadAdmin() { $('admin-refresh').disabled = true; $('admin-error').hidden = true; - try { adminData = await api('admin/overview'); - $('owner-default-price').value=adminData.pricing?.defaultStarPrice || 0; $('owner-default-unit').value=adminData.pricing?.defaultStarPricing || 'person'; -renderAdmin(); } - catch (error) { $('admin-error').textContent=error.message; $('admin-error').hidden=false; $('admin-list').replaceChildren(); } + const results=await Promise.allSettled([api('admin/overview'),loadAdminWhitelist()]); + try { + if(results[0].status==='rejected')throw results[0].reason; + adminData=results[0].value; + $('owner-default-price').value=adminData.pricing?.defaultStarPrice || 0; $('owner-default-unit').value=adminData.pricing?.defaultStarPricing || 'person'; + renderAdmin(); + if(results[1].status==='rejected')throw new Error('Dev access: '+results[1].reason.message); + } + catch (error) { $('admin-error').textContent=error.message; $('admin-error').hidden=false;if(results[0].status==='rejected')$('admin-list').replaceChildren(); } finally { $('admin-refresh').disabled=false; } } +function setWhitelistBusy(busy) { + whitelistBusy=busy; + $('admin-whitelist').setAttribute('aria-busy',String(busy)); + for(const control of $('admin-whitelist').querySelectorAll('input,button'))control.disabled=busy; + $('whitelist-enabled').disabled=busy || !!adminWhitelist?.snapshotProtected; +} +function renderAdminWhitelist() { + const data=adminWhitelist,section=$('admin-whitelist'); + section.hidden=!state.user?.isSuperAdmin || data?.supported!==true; + if(section.hidden)return; + const ids=Array.isArray(data.ids)?data.ids:[]; + $('whitelist-enabled').checked=!!data.enabled || !!data.snapshotProtected; + $('whitelist-toggle-note').textContent=data.snapshotProtected ? 'Copied production data stays restricted to listed testers and the super admin.' : 'Off allows everyone. On allows listed users; an empty list also allows everyone.'; + $('whitelist-summary').textContent=data.snapshotProtected ? '🔒 Dev access · protected snapshot' : data.restricted ? '🔒 Dev access · restricted' : '🔒 Dev access · open'; + $('whitelist-access').textContent=data.invalid ? (data.restricted ? 'The saved access settings need attention. Access is restricted for safety; repair the setting and update the user IDs below.' : 'Anyone can access the dev bot and Mini App. The saved whitelist needs attention; repair the access setting before using it.') : data.snapshotProtected ? (ids.length ? 'Only the listed users and the super admin can use this development snapshot.' : 'This development snapshot is available to the super admin only. Add user IDs to invite testers.') : data.restricted ? 'Only the listed users and the super admin can access the dev bot and Mini App.' : data.enabled ? 'The list is empty, so anyone can access the dev bot and Mini App. Add a user ID to restrict access.' : 'Whitelist is off. Anyone can access the dev bot and Mini App.'; + $('whitelist-snapshot-note').hidden=!data.snapshotProtected; + $('whitelist-repair-options').hidden=!data.invalid; + $('whitelist-repair-note').hidden=!data.invalid; + $('whitelist-empty').hidden=ids.length>0; + $('whitelist-empty').textContent=data.invalid ? 'No valid user IDs are shown. Repair the access setting, then add a valid user ID.' : data.snapshotProtected ? 'No testers added yet.' : 'No user IDs added yet. An empty whitelist leaves access open.'; + const list=$('whitelist-users');list.replaceChildren(); + for(const id of ids){ + const row=element('li','','whitelist-user'),label=element('code',String(id)); + const remove=action('✕ Remove',()=>changeAdminWhitelist({action:'remove',ids:String(id)})); + remove.className='secondary';remove.setAttribute('aria-label','Remove Telegram user ID '+String(id)); + row.append(label,remove);list.append(row); + } + setWhitelistBusy(whitelistBusy); +} +async function loadAdminWhitelist() { + if(!state.user?.isSuperAdmin){adminWhitelist=null;$('admin-whitelist').hidden=true;return;} + setWhitelistBusy(true); + try { adminWhitelist=await api('admin/whitelist');renderAdminWhitelist(); } + finally { setWhitelistBusy(false); } +} +async function changeAdminWhitelist(body,repair=false) { + if(whitelistBusy || !adminWhitelist?.supported || !state.user?.isSuperAdmin)return; + const data=adminWhitelist; + setWhitelistBusy(true);$('whitelist-status').textContent=''; + try { + let warning='',operation=''; + if(repair){ + warning='Repair turns on a valid whitelist access setting. When valid user IDs are saved, only listed users and the super admin can enter. If the user list still needs attention after repair, add a valid user ID below. Your super admin access is always kept.'+(data.snapshotProtected ? ' Copied production data stays protected.' : ' An empty valid list leaves access open.');operation='whitelist-repair'; + }else if(body.action==='set-enabled'){ + warning=body.enabled ? 'When the whitelist is on and has user IDs, other users cannot access the dev bot or Mini App. Only listed users and the super admin can enter. An empty list leaves access open. Your super admin access is always kept.' : 'Turning off the whitelist allows everyone to access the dev bot and Mini App. Saved user IDs will stay in the list.'; + operation=body.enabled?'whitelist-enable':'whitelist-disable'; + }else if(body.action==='remove' && data.ids?.length===1 && data.enabled && !data.snapshotProtected){ + warning='Removing the last user ID leaves the whitelist empty. Anyone will be able to access the dev bot and Mini App.';operation='whitelist-last'; + }else if(body.action==='add' && !data.ids?.length && data.enabled && !data.snapshotProtected){ + warning='Adding the first user ID will restrict access. Other users cannot access the dev bot or Mini App. Only listed users and the super admin can enter.';operation='whitelist-first'; + } + if(warning && !await confirmAction(warning,operation))return; + adminWhitelist=await api('admin/whitelist',body);renderAdminWhitelist(); + if(body.action==='add')$('whitelist-user-id').value=''; + $('whitelist-status').textContent=repair ? (adminWhitelist.invalid ? 'Access setting repaired. Add a valid user ID to repair the saved list.' : 'Access setting repaired and whitelist turned on.') : body.action==='set-enabled' ? (body.enabled?'Whitelist turned on.':'Whitelist turned off.') : body.action==='add' ? 'User added to the whitelist.' : 'User removed from the whitelist.'; + }catch(error){$('whitelist-status').textContent=error.message;} + finally {renderAdminWhitelist();setWhitelistBusy(false);} +} +$('whitelist-enabled').onchange=event=>{ + const enabled=event.target.checked;event.target.checked=!!adminWhitelist?.enabled; + changeAdminWhitelist({action:'set-enabled',enabled}); +}; +$('whitelist-repair').onclick=()=>changeAdminWhitelist({action:'set-enabled',enabled:true},true); +$('whitelist-add-form').onsubmit=event=>{ + event.preventDefault();const input=$('whitelist-user-id'),id=input.value.trim(); + if(!/^[1-9]\d{0,15}$/.test(id)){$('whitelist-status').textContent='Enter a valid numeric Telegram user ID.';input.focus();return;} + changeAdminWhitelist({action:'add',ids:id}); +}; function renderAdmin() { if (!adminData) return; $('admin-summary').textContent=`${adminData.events.length} events · ${adminData.users.length} users · ${adminData.events.filter(e=>e.group==='Upcoming events').length} upcoming`; @@ -692,11 +865,13 @@ $('owner-pricing-form').onsubmit=async event=>{ }; for (const b of document.querySelectorAll('[data-tab]')) b.onclick = () => { notice(''); b.dataset.tab === 'create' && (!formReady || $('event-form').hidden) ? setupForm() : go(b.dataset.tab); }; $('home-brand').onclick=event=>{event.preventDefault();go('home');}; +$('app-close').onclick=()=>{if(formSaving)return;pageNavigationGeneration++;dismissPendingMedia?.();dismissTicket();if((compactPicker || currentView==='home') && initData && typeof tg?.close==='function'){try{tg.close();}catch(error){notice(error.message);}}else if(compactPicker)notice('Close this window to return to Telegram.');else go('home');};updateAppClose(); $('hero-create').onclick = () => setupForm(); $('banner').onchange = () => { ++bannerLoadGeneration; if (bannerPreviewUrl) URL.revokeObjectURL(bannerPreviewUrl); const file=$('banner').files[0]; $('banner-preview').hidden=!file; if (file) { bannerPreviewUrl=URL.createObjectURL(file); $('banner-preview').src=bannerPreviewUrl; } }; -$('cancel-edit').onclick = () => {formReady=false;go('events');}; -$('edit-cancel-event').onclick=()=>activeEvent && cancelEvent(activeEvent); -$('edit-delete-event').onclick=()=>activeEvent && deleteEvent(activeEvent); +$('cancel-edit').onclick = () => {if(formSaving)return;formReady=false;go('events');}; +$('edit-cancel-event').onclick=()=>!formSaving && activeEvent && cancelEvent(activeEvent); +$('edit-delete-event').onclick=()=>!formSaving && activeEvent && deleteEvent(activeEvent); +$('edit-checkin').onclick=()=>!formSaving && !$('edit-checkin').disabled && activeEvent && openCheckin(activeEvent.id); $('refresh').onclick = async () => { $('refresh').disabled = true; try { await refresh(); notice(''); } catch (e) { notice(e.message); } finally { $('refresh').disabled = false; } }; for (const id of ['date', 'time', 'event-zone']) $(id).addEventListener('change', () => { clearTimeout(timer); timer = setTimeout(preview, 180); }); for (const [search, select] of [['event-zone-search', 'event-zone'], ['local-zone-search', 'local-zone']]) $(search).oninput = () => options(select, $(select).value, $(search).value); @@ -731,7 +906,7 @@ function revealFormError(message){ for(const [pattern,id] of groups)if(pattern.test(message))$(id).open=true; } $('event-form').onsubmit = async event => { - event.preventDefault(); $('save-event').disabled = true; $('form-error').hidden = true; + event.preventDefault();if(formSaving)return;formSaving=true;updateAppClose(); $('save-event').disabled = true; $('form-error').hidden = true; const mode=selectedInvitationMode(),named=mode==='named'; const payload = { oneTimeInvite:$('one-time-invite').checked,inviteMessage:$('invite-message').value,qrEnabled:$('qr-enabled').checked,askPhone:document.getElementById('ask-phone').checked,askComments:document.getElementById('ask-comments').checked,invitationMode:mode,guestNames:named ? $('guest-names').value : undefined,paymentMethod:$('stars-enabled').checked ? $('payment-method').value : 'free',displayPrice:$('display-price').value,paymentInstructions:$('payment-instructions').value,paymentUrl:$('payment-url').value,starPrice:$('stars-enabled').checked && $('payment-method').value==='stars' ? Number($('stars-price').value) : 0,starPricing:$('stars-pricing').value,paymentTerms:$('payment-terms').value, askParticipantCount: !named && $('ask-participant-count').checked, isPublic: $('invitation-mode').value === 'public', allowLinkUploads: $('allow-link-uploads').checked, ...endingInput(), defaultReminder: Number($('default-reminder').value), date: $('date').value, time: $('time').value, timezone: $('event-zone').value, permissions: { guestList: $('allow-guest-list').checked, uploadMedia: $('allow-upload-media').checked, viewMedia: $('allow-view-media').checked }, requireApproval: !named && $('require-approval').checked, hideLocation: $('hide-location').checked, ticketInfo: $('ticket-info').value, deadlineDate: $('deadline-enabled').checked ? $('deadline-date').value : '', deadlineTime: $('deadline-enabled').checked ? $('deadline-time').value : '' }; try { @@ -759,7 +934,7 @@ $('event-form').onsubmit = async event => { } tg?.HapticFeedback?.notificationOccurred('success'); } catch (e) { revealFormError(e.message);$('form-error').textContent = e.message; $('form-error').hidden = false; tg?.HapticFeedback?.notificationOccurred('error'); } - finally { $('save-event').disabled = false; } + finally { formSaving=false;updateAppClose();$('save-event').disabled = false; } }; $('share-event').onclick = () => createdEvent && share(createdEvent); async function loadProfilePhoto(){ @@ -797,7 +972,8 @@ $('another-event').onclick = () => go('events'); if (!initData) { notice('This launch did not include your Telegram login. Reopen using App in the bot menu or the App button in a message.'); const empty=element('div','Your events are private. Use the authenticated App button to load them.','empty'); - empty.append(action('App',()=>{const url='https://t.me/XEvents_bot?start=app';if(tg?.openTelegramLink)tg.openTelegramLink(url);else window.open(url,'_blank','noopener');})); + const botLink=$('telegram-bot-link')?.getAttribute('href') || ''; + if(/^https:\/\/t\.me\/[A-Za-z0-9_]{5,32}$/.test(botLink))empty.append(action('App',()=>{const url=botLink+'?start=app';if(tg?.openTelegramLink)tg.openTelegramLink(url);else window.open(url,'_blank','noopener');})); $('event-list').replaceChildren(empty); $('home-view').hidden=true;$('events-view').hidden=false; $('save-event').disabled = true; $('save-zone').disabled = true; $('refresh').disabled = true; diff --git a/public/event-actions.js b/public/event-actions.js index 3989a78..23e0f15 100644 --- a/public/event-actions.js +++ b/public/event-actions.js @@ -2,6 +2,7 @@ export function setupEventActions({ $, api, refresh, onEnded = () => {}, notice const dialog = $('event-end-dialog'); const proceed = $('event-end-confirm'); const back = $('event-end-back'); + const close = $('event-end-close'); const remove = $('event-end-delete'); const status = $('event-end-status'); let active = null; @@ -9,6 +10,7 @@ export function setupEventActions({ $, api, refresh, onEnded = () => {}, notice function setBusy(busy) { proceed.disabled = busy; back.disabled = busy; + if (close) close.disabled = busy; remove.disabled = busy; dialog.setAttribute('aria-busy', String(busy)); } @@ -39,6 +41,7 @@ export function setupEventActions({ $, api, refresh, onEnded = () => {}, notice resolve(result); }; back.onclick = () => { if (!session.busy) finish(false); }; + if (close) close.onclick = back.onclick; dialog.oncancel = event => { event.preventDefault(); if (!session.busy) finish(false); diff --git a/public/gallery.js b/public/gallery.js index 704cc69..8be1e62 100644 --- a/public/gallery.js +++ b/public/gallery.js @@ -1,6 +1,7 @@ export function setupGallery({ $, api, element, action, go, notice, openTelegram, initData }) { let galleryEvent = null; let generation=0; + let popupGeneration=0,qrGeneration=0; const urls = new Set(); const filename = file => file.filename==='photo'?'photo.jpg':file.filename==='video'?'video.mp4':file.filename; function showMedia(id,file,blob,url) { @@ -9,7 +10,8 @@ export function setupGallery({ $, api, element, action, go, notice, openTelegram else { $('video-full').src=url; } $('media-popup-status').textContent=''; $('media-save').onclick=()=>saveBlob(blob,filename(file)); - $('media-send').onclick=async()=>{ $('media-send').disabled=true; try { await api(`events/${id}/media/${file.id}/send`,{}); $('media-popup-status').textContent='Sent to your Telegram chat.'; } catch(error) { $('media-popup-status').textContent=error.message; } finally { $('media-send').disabled=false; } }; + const showing=popupGeneration; + $('media-send').onclick=async()=>{ $('media-send').disabled=true; try { await api(`events/${id}/media/${file.id}/send`,{}); if(showing===popupGeneration)$('media-popup-status').textContent='Sent to your Telegram chat.'; } catch(error) { if(showing===popupGeneration)$('media-popup-status').textContent=error.message; } finally { $('media-send').disabled=false; } }; $('photo-dialog').showModal(); } function saveBlob(blob, filename) { const url=URL.createObjectURL(blob); const link=document.createElement('a');link.href=url;link.download=filename;link.click();setTimeout(()=>URL.revokeObjectURL(url),60000); } @@ -34,28 +36,34 @@ export function setupGallery({ $, api, element, action, go, notice, openTelegram const card=element('article','','gallery-item');const body=element('div','','gallery-item-body'); if(kind==='photo' || (kind==='video' && file.hasThumbnail)) { const img=document.createElement('img');img.alt=file.caption || `${kind==='video'?'Video preview':'Photo'} shared by ${file.name}`;img.loading='lazy';card.append(img); - const observer=new IntersectionObserver(async entries=>{if(!entries.some(entry=>entry.isIntersecting))return;observer.disconnect();try{const blob=await mediaBlob(id,file.id,file.hasThumbnail);if(!img.isConnected)return;const url=URL.createObjectURL(blob);urls.add(url);img.src=url;img.onclick=async()=>{try{const full=file.hasThumbnail?await mediaBlob(id,file.id):blob;if(!img.isConnected)return;const fullUrl=file.hasThumbnail?URL.createObjectURL(full):url;urls.add(fullUrl);showMedia(id,previewFile,full,fullUrl);}catch(error){notice(error.message);}};}catch(error){img.remove();body.prepend(element('p',error.message,'small muted'));}});observer.observe(img); + const observer=new IntersectionObserver(async entries=>{if(!entries.some(entry=>entry.isIntersecting))return;observer.disconnect();try{const blob=await mediaBlob(id,file.id,file.hasThumbnail);if(!img.isConnected || current!==generation)return;const url=URL.createObjectURL(blob);urls.add(url);img.src=url;img.onclick=async()=>{const pending=++popupGeneration;try{const full=file.hasThumbnail?await mediaBlob(id,file.id):blob;if(!img.isConnected || current!==generation || pending!==popupGeneration)return;const fullUrl=file.hasThumbnail?URL.createObjectURL(full):url;urls.add(fullUrl);showMedia(id,previewFile,full,fullUrl);}catch(error){if(current===generation && pending===popupGeneration)notice(error.message);}};}catch(error){if(current!==generation)return;img.remove();body.prepend(element('p',error.message,'small muted'));}});observer.observe(img); }else if(kind==='video'){ const video=document.createElement('video');video.muted=true;video.playsInline=true;video.preload='metadata';video.setAttribute('aria-label',file.caption || 'Video preview');card.append(video); - const observer=new IntersectionObserver(async entries=>{if(!entries.some(entry=>entry.isIntersecting))return;observer.disconnect();try{const blob=await mediaBlob(id,file.id);if(!video.isConnected)return;const url=URL.createObjectURL(blob);urls.add(url);video.src=url;video.onloadeddata=()=>{try{video.currentTime=.1;}catch{}};video.onclick=()=>showMedia(id,previewFile,blob,url);}catch(error){video.remove();body.prepend(element('p',error.message,'small muted'));}});observer.observe(video); + const observer=new IntersectionObserver(async entries=>{if(!entries.some(entry=>entry.isIntersecting))return;observer.disconnect();try{const blob=await mediaBlob(id,file.id);if(!video.isConnected || current!==generation)return;const url=URL.createObjectURL(blob);urls.add(url);video.src=url;video.onloadeddata=()=>{try{video.currentTime=.1;}catch{}};video.onclick=()=>{if(current!==generation)return;++popupGeneration;showMedia(id,previewFile,blob,url);};}catch(error){if(current!==generation)return;video.remove();body.prepend(element('p',error.message,'small muted'));}});observer.observe(video); } body.append(element('strong',file.filename==='photo'?'Photo':file.filename),element('p',`Shared by ${file.name}${file.caption?'\n'+file.caption:''}`,'small muted')); const buttons=element('div','','event-actions'); buttons.append(action('Download',async()=>{try{saveBlob(await mediaBlob(id,file.id),file.filename==='photo'?'photo.jpg':file.filename==='video'?'video.mp4':file.filename);}catch(error){notice(error.message);}})); buttons.append(action('Send to Telegram',async()=>{try{await api(`events/${id}/media/${file.id}/send`,{});notice('File sent to your Telegram chat.');}catch(error){notice(error.message);}})); - if(kind==='video')buttons.append(action('Play video',async()=>{try{const blob=await mediaBlob(id,file.id);const url=URL.createObjectURL(blob);urls.add(url);showMedia(id,previewFile,blob,url);}catch(error){notice(error.message);}})); + if(kind==='video')buttons.append(action('Play video',async()=>{const pending=++popupGeneration;try{const blob=await mediaBlob(id,file.id);if(current!==generation || pending!==popupGeneration)return;const url=URL.createObjectURL(blob);urls.add(url);showMedia(id,previewFile,blob,url);}catch(error){if(current===generation && pending===popupGeneration)notice(error.message);}})); body.append(buttons);card.append(body);list.append(card); } }catch(error){if(current!==generation)return;$('gallery-list').replaceChildren();$('gallery-summary').textContent='';$('gallery-error').textContent=error.message;$('gallery-error').hidden=false;} } async function showQr(id) { - try{const qr=await api(`events/${id}/upload-qr`);$('upload-qr').src=qr.image; $('qr-description').textContent=qr.anyone ? 'Anyone with this link can add media without an RSVP. They can view Shared media if you enable viewing. Private event details stay hidden.' : 'Existing event guests can scan this code to add media. Enable uploads by link in event settings to let anyone contribute without an RSVP.'; + const pending=++qrGeneration; + try{const qr=await api(`events/${id}/upload-qr`);if(pending!==qrGeneration)return;$('upload-qr').src=qr.image; $('qr-description').textContent=qr.anyone ? 'Anyone with this link can add media without an RSVP. They can view Shared media if you enable viewing. Private event details stay hidden.' : 'Existing event guests can scan this code to add media. Enable uploads by link in event settings to let anyone contribute without an RSVP.'; $('qr-share').onclick=()=>openTelegram(`https://t.me/share/url?url=${encodeURIComponent(qr.link)}&text=${encodeURIComponent('Share your event photos and files')}`); $('qr-download').onclick=()=>{const bytes=Uint8Array.from(atob(qr.image.split(',')[1]),character=>character.charCodeAt(0));saveBlob(new Blob([bytes],{type:'image/gif'}),'event-upload-qr.gif');};$('qr-dialog').showModal(); - }catch(error){notice(error.message);} + }catch(error){if(pending===qrGeneration)notice(error.message);} } $('gallery-back').onclick=()=>go('events');$('gallery-refresh').onclick=()=>galleryEvent && openGallery(galleryEvent); $('qr-close').onclick=()=>$('qr-dialog').close();$('photo-close').onclick=()=>$('photo-dialog').close(); - $('photo-dialog').onclose=()=>{ $('video-full').pause(); $('video-full').removeAttribute('src'); $('video-full').load(); }; - return {openGallery,showQr}; + $('qr-dialog').onclose=()=>{qrGeneration++;}; + $('photo-dialog').onclose=()=>{ popupGeneration++;$('video-full').pause(); $('video-full').removeAttribute('src'); $('video-full').load(); }; + function dismissPendingMedia(){ + generation++;popupGeneration++;qrGeneration++; + for(const id of ['photo-dialog','qr-dialog'])if($(id).open)$(id).close(); + } + return {openGallery,showQr,dismissPendingMedia}; } diff --git a/public/index.html b/public/index.html index d8b4f4c..4846644 100644 --- a/public/index.html +++ b/public/index.html @@ -12,7 +12,7 @@
-
XEventsMAKE TIME FOR YOUR PEOPLE
+
XEventsMAKE TIME FOR YOUR PEOPLE

Connecting to your Telegram account…

@@ -29,6 +29,7 @@ - +
-

Shared photo
-

Your ticket

Ticket QR code

-

Check tickets

-

Guest list

+

Shared media

Shared photo
+

Your ticket

Your check-in code

+

Check in guests

Ask the guest to open their check-in code.

+

Guest list

-
+
-
+
👤 Add guests
👤 Add one guest

📋 Add a list

Name = 2 uses two people with a change button; = ? asks; = 2! confirms; = 2* fixes the count at two. Counts include the guest and are 1–10.

Link rules

Tap a name to copy the full invitation and personal link.

-

Upload link & QR code

Scan to send event files in Telegram.

QR code for event uploads
+

Upload link & QR code

Scan to send event files in Telegram.

QR code for event uploads
-

Co-hosts

+

Co-hosts

Add co-host @@ -113,7 +131,7 @@

-

Confirm action

-

Cancel event?

+

Confirm action

+

Cancel event?

diff --git a/public/style.css b/public/style.css index e0a7bc7..bd4c233 100644 --- a/public/style.css +++ b/public/style.css @@ -9,6 +9,7 @@ fieldset.panel{min-width:0}fieldset legend{font-size:18px;font-weight:700;letter .admin-guest { white-space:pre-wrap; overflow-wrap:anywhere; border-top:1px solid #dce5df; padding:14px 0; } #admin-view p { white-space:pre-wrap; overflow-wrap:anywhere; } #admin-view summary { cursor:pointer; padding:12px 0; font-weight:700; } +#admin-whitelist summary{padding:0;line-height:1.5}#admin-whitelist[open] summary{margin-bottom:16px}.whitelist-warning{padding:12px 14px;border:1px solid #e7c78d;border-radius:10px;background:#fff4de;color:#72531e;font-size:13px}.whitelist-warning strong{font-weight:750}#admin-whitelist .check-option{margin-top:16px}#admin-whitelist .check-option .check-note{float:none}#whitelist-access,#whitelist-status{font-size:13px}#whitelist-status:empty{display:none}.whitelist-add-row{display:flex;gap:8px;align-items:stretch}.whitelist-add-row input{flex:1;min-width:0}.whitelist-add-row button{flex-shrink:0}.whitelist-users{list-style:none;padding:0;margin:12px 0 0}.whitelist-user{display:flex;align-items:center;justify-content:space-between;gap:12px;border-top:1px solid var(--line);padding:10px 0}.whitelist-user code{min-width:0;overflow-wrap:anywhere;font-size:15px}.whitelist-user button{margin:0;flex-shrink:0;font-size:12px}#admin-whitelist input:disabled{cursor:not-allowed}#admin-whitelist button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}body.dark .whitelist-warning{background:#3e3323;color:#f6deac;border-color:#6b5938} #confirm-dialog { width:calc(100% - 36px); max-width:460px; padding:24px; border:1px solid var(--line); border-radius:18px; background:var(--panel); color:var(--text); } #confirm-dialog::backdrop { background:rgba(0,0,0,.5); } #confirm-dialog p { font-size:14px; overflow-wrap:anywhere; } @@ -24,6 +25,9 @@ fieldset.panel{min-width:0}fieldset legend{font-size:18px;font-weight:700;letter #qr-dialog::backdrop,#photo-dialog::backdrop { background:rgba(0,0,0,.65); } #guest-list-dialog { width:calc(100% - 32px); max-width:600px; max-height:85dvh; overflow:auto; background:var(--panel); color:var(--text); border:1px solid var(--line); border-radius:18px; padding:20px; } #guest-list-dialog::backdrop { background:rgba(0,0,0,.65); } +#guest-list-dialog .dialog-heading { position:sticky; top:-20px; z-index:1; display:flex; align-items:center; justify-content:space-between; gap:12px; padding:16px 0; background:var(--panel); } +#guest-list-dialog .dialog-heading h2 { min-width:0; margin:0; overflow-wrap:anywhere; } +#guest-list-close { flex:0 0 44px; width:44px; min-height:44px; margin:0; padding:0; font-size:26px; line-height:1; } #guest-list-filters { display:flex; flex-wrap:wrap; gap:6px; } #guest-list-filters [aria-pressed="true"] { background:var(--accent); color:var(--panel); } #ticket-dialog,#checkin-dialog { width:calc(100% - 32px); max-width:480px; max-height:85dvh; overflow:auto; background:var(--panel); color:var(--text); border:1px solid var(--line); border-radius:18px; padding:20px; } @@ -56,7 +60,7 @@ fieldset.panel{min-width:0}fieldset legend{font-size:18px;font-weight:700;letter @media(max-width:420px){.home-bot-icon{width:64px;height:64px;right:16px;bottom:28px}.brand a{font-size:18px}.wordmark-note{font-size:7px;letter-spacing:1px}} #invitation-links-dialog{width:calc(100% - 32px);max-width:600px;max-height:85dvh;overflow:auto;background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:18px;padding:20px}#invitation-links-dialog::backdrop{background:rgba(0,0,0,.65)}#invitation-links-dialog h3{margin-top:0}#invitation-links-dialog .panel{margin:16px 0}.invite-message{white-space:pre-line;color:var(--text)}.invite-link{font-size:12px;overflow-wrap:anywhere;color:var(--muted);user-select:all} -#invitation-links-dialog .invitation-actions{display:grid;grid-template-columns:repeat(3,minmax(0,1fr))}#invitation-links-dialog .invitation-actions button{min-width:0;padding:10px 6px}#invitation-links-dialog .invitation-preview input{margin-top:10px} +#invitation-links-dialog .invitation-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}#invitation-links-dialog .invitation-actions button{min-width:0;min-height:46px;padding:10px 6px}#invitation-links-dialog .invitation-preview input{margin-top:10px} .invitation-filters{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:14px 0}.invitation-filters label{margin:0 0 8px}.invitation-filters input{margin:0;height:48px}.invitation-filters select{font-size:13px;min-height:48px;padding:10px 8px}.invitation-toolbar{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:start;margin:12px 0}.invitation-toolbar>button{grid-column:2;grid-row:1;margin:0}.invitation-toolbar details>summary{cursor:pointer;min-height:44px;padding:12px;border:1px solid var(--line);border-radius:10px;font-size:13px;font-weight:650}.invitation-toolbar details[open]{grid-column:1/-1;grid-row:2}.invitation-toolbar form{margin:14px 0}.invitation-guidance{margin:12px 0;color:var(--muted);font-size:12px}.invitation-guidance>summary{cursor:pointer}.invitation-guidance p{margin:10px 0}.invitation-responses{margin:12px 0;font-size:12px}.invitation-responses>summary{cursor:pointer;color:var(--muted)}.invitation-response{white-space:pre-line;overflow-wrap:anywhere;margin:8px 0}.invitation-remove{padding:12px;background:var(--bg);border:1px solid var(--line);border-radius:10px;margin-top:12px}.invitation-remove p{margin:0}.invitation-remove .event-actions{margin-top:12px}.invitation-remove-button{display:block;margin-top:12px;padding-left:0;min-height:44px}.invitation-remove .event-actions button{flex:1 1 100px} #invitation-links-dialog .dialog-heading{position:sticky;top:-20px;z-index:1;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 0;background:var(--panel)}#invitation-links-dialog .dialog-heading h2{margin:0}#invitation-links-dialog .dialog-heading button{flex-shrink:0} @@ -68,3 +72,16 @@ fieldset.panel{min-width:0}fieldset legend{font-size:18px;font-weight:700;letter .event-danger-section{background:#fff0ee;border-color:#e7aaa5;color:#792822}.event-danger-section p{font-size:13px;color:inherit;margin:8px 0}.event-danger-section .event-actions button{flex:1 1 130px}.danger-button,body.dark .danger-button{background:#a32d29;color:#fff}.danger-button:focus-visible{outline:3px solid #d6534c;outline-offset:3px}body.dark .event-danger-section{background:#3d2425;border-color:#94524e;color:#ffe3df}#event-end-dialog{width:calc(100% - 32px);max-width:480px;max-height:85dvh;overflow:auto;background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:18px;padding:22px}#event-end-dialog::backdrop{background:rgba(0,0,0,.65)}#event-end-dialog p{font-size:14px;overflow-wrap:anywhere}#event-end-dialog .event-actions{justify-content:flex-end}#event-end-dialog .event-actions button{flex:1 1 130px}#event-end-dialog .check-option{border-color:var(--line)} .home-shortcuts{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:0 0 22px}.home-shortcuts button{min-width:0;margin:0;padding:12px 6px;font-size:12px}.home-event-actions button{flex:1 1 calc(50% - 8px);min-width:0}.event-card:focus-visible{outline:2px solid var(--accent);outline-offset:3px}.guest-editor{padding:12px 0;margin:12px 0}.guest-editor>summary{cursor:pointer;font-size:13px;font-weight:650}.guest-editor-fields{display:grid;grid-template-columns:minmax(0,1fr) 100px;gap:12px;align-items:start;margin-top:14px}.guest-editor-fields>*{min-width:0}.guest-editor-fields label{margin-top:0}.guest-editor-fields input,.guest-editor-fields select{width:100%}.guest-editor-mode{grid-column:1/-1}.guest-inline-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}.guest-inline-actions button{font-size:12px;padding:10px 12px;margin:0}.guest-editor .error{margin-top:12px}@media(max-width:360px){.guest-editor-fields{grid-template-columns:minmax(0,1fr) 80px;gap:8px}} .guest-editor-fields:has(>[id$="-count-field"][hidden])>div:first-child{grid-column:1/-1} + +.invitation-current-status{display:flex;align-items:center;gap:6px 8px;flex-wrap:wrap;margin:14px 0 8px}.invitation-current-status>.small{font-size:11px}.invitation-status{display:inline-block;padding:5px 9px;border-radius:8px;background:var(--bg);border:1px solid var(--line);font-size:12px;line-height:1.4}.invitation-status[data-status=yes]{background:#e8f1e9;color:#186449;border-color:#b7d6c3}.invitation-status[data-status=no],.invitation-status[data-status=revoked]{background:#fff0ee;color:#8a2c27;border-color:#e7aaa5}.invitation-status[data-status=maybe]{background:#fff4de;color:#75541c;border-color:#e7c78d}.invitation-last-response{display:block;flex-basis:100%;line-height:1.5}.invitation-response time{display:block;margin-top:4px;line-height:1.5}body.dark .invitation-status[data-status=yes]{background:#264b3b;color:#c4ecd5;border-color:#426d57}body.dark .invitation-status[data-status=no],body.dark .invitation-status[data-status=revoked]{background:#3d2425;color:#ffcbc4;border-color:#80504c}body.dark .invitation-status[data-status=maybe]{background:#3e3323;color:#f6deac;border-color:#6b5938} + +.invitation-entry{position:relative;overflow:visible}.invitation-entry button:disabled,.invitation-entry input:disabled,.invitation-entry select:disabled{cursor:not-allowed}.invitation-entry>.invitation-copy{width:calc(100% - 52px);min-height:46px}.invitation-more{top:22px;right:22px}.invitation-more>summary{width:44px;height:46px;border:1px solid var(--line);background:var(--bg)}.invitation-more .event-more-panel{top:52px;width:min(252px,calc(100vw - 110px));z-index:2}.invitation-menu-note{padding:0 10px;line-height:1.5;margin:6px 0 12px}.invitation-management{margin-top:16px;padding:16px;border:1px solid var(--line);border-radius:12px;background:var(--bg);overflow-wrap:anywhere}.invitation-management h3{font-size:16px;margin:0 0 12px}.invitation-management>label{margin-top:14px}.invitation-management label input,.invitation-management label select{margin-top:8px;min-height:46px}.invitation-management .check-option{padding:14px 0 0;margin-top:14px}.invitation-management .check-option input{margin-top:2px;min-height:20px}.invitation-management .check-option>span{line-height:1.45}.invitation-panel-actions button{flex:1 1 110px;min-height:46px}.invitation-danger,body.dark .invitation-danger{color:#a32d29;border-color:#d9a7a3}body.dark .invitation-danger{color:#ffb8b1;border-color:#80504c}.invitation-revoked>.invitation-copy{background:var(--bg);color:var(--muted);opacity:1;cursor:default}.invitation-history{padding-left:20px;margin:14px 0}.invitation-history li{padding:0 0 16px 8px;border-left:2px solid var(--line);line-height:1.5}.invitation-history li:last-child{padding-bottom:0}.invitation-history strong{font-size:13px}.invitation-history p{margin:4px 0}.invitation-history time{display:block}.invitation-management button:focus-visible,.invitation-entry button:focus-visible{outline:2px solid var(--accent);outline-offset:3px}@media(max-width:360px){.invitation-more{top:17px;right:17px}.invitation-management{padding:12px}} + +.brand{position:sticky;top:0;z-index:6;display:grid;grid-template-columns:minmax(0,1fr) auto 44px;gap:8px;align-items:center;background:var(--bg);padding:10px 0;margin:0 0 24px}.brand>a{min-width:0;overflow:hidden}.brand>a>.logo{flex:0 0 38px;width:38px;height:38px}.brand>a>span:last-child{min-width:0;overflow:hidden;text-overflow:ellipsis}.brand .header-profile{max-width:126px;min-width:0;padding:4px;gap:6px;min-height:44px}.brand .header-profile img,.brand .avatar-initial{width:32px;height:32px}.brand #header-name{min-width:0}.page-close{width:44px;min-height:44px;margin:0;padding:0;font-size:27px;line-height:1}.page-close:focus-visible,dialog .dialog-close:focus-visible{outline:2px solid var(--accent);outline-offset:3px} +#photo-dialog .dialog-heading,#ticket-dialog .dialog-heading,#checkin-dialog .dialog-heading,#guest-list-dialog .dialog-heading,#invitation-links-dialog .dialog-heading,#qr-dialog .dialog-heading,#cohost-dialog .dialog-heading,#confirm-dialog .dialog-heading,#event-end-dialog .dialog-heading{position:sticky;top:calc(-1 * var(--dialog-padding,20px));z-index:2;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 0;background:var(--panel)} +dialog .dialog-heading h2{min-width:0;margin:0;overflow-wrap:anywhere}dialog .dialog-heading .dialog-close{flex:0 0 44px;width:44px;height:44px;min-height:44px;margin:0;padding:0;font-size:27px;line-height:1}#confirm-dialog{--dialog-padding:24px;max-height:85dvh;overflow:auto}#event-end-dialog{--dialog-padding:22px}#photo-dialog,#qr-dialog{max-height:85dvh;overflow:auto}#photo-dialog .event-actions{margin-top:8px}#invitation-links-status{top:55px} +#guest-list-event{margin:0 0 8px;overflow-wrap:anywhere}#guest-list-summary{font-size:12px;line-height:1.5;margin:0 0 10px}#guest-list-filters{gap:6px;margin:10px 0}#guest-list-filters button{min-height:44px;padding:8px 10px;font-size:12px}#guest-list-filters [aria-pressed=true]{background:#126b5b;color:#fff}body.dark #guest-list-filters [aria-pressed=true]{background:#277d63;color:#fff}.guest-list-tools{margin:10px 0}.guest-list-tools button{flex:1 1 100px;min-height:44px}#guest-list-manage{width:100%}.guest-roster-row{position:relative;white-space:normal;padding:12px;margin:8px 0;border:1px solid var(--line);border-radius:12px;background:var(--bg);color:var(--text)}.guest-roster-heading{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;padding-right:44px;overflow-wrap:anywhere}.guest-roster-heading strong{font-size:14px;line-height:1.45;min-width:0}.guest-roster-heading>span{font-weight:650;font-size:12px}.guest-roster-row>.invitation-status{margin-top:7px;max-width:calc(100% - 44px)}.guest-roster-more{top:10px;right:10px}.guest-roster-more>summary{width:44px;height:44px;border:1px solid var(--line);font-size:25px;background:var(--panel)}.guest-roster-more .event-more-panel{top:48px;width:min(252px,calc(100vw - 92px))}.guest-roster-row[data-status=yes]{background:#edf6ef}.guest-roster-row[data-status=no]{background:#fff0ee}.guest-roster-row[data-status=maybe]{background:#fff6e4}.guest-roster-row[data-status=later]{background:#edf3ff}.guest-roster-row[data-status=pending]{background:#f4efff}.guest-roster-row[data-status=payment]{background:#eef5fb}.guest-roster-row[data-status=unanswered],.guest-roster-row[data-status=unopened]{background:#f3f4f0}.invitation-status[data-status=later]{background:#e8efff;color:#345384;border-color:#bdcceb}.invitation-status[data-status=pending]{background:#eee5ff;color:#614486;border-color:#d3bfec}.invitation-status[data-status=payment]{background:#e4effa;color:#365775;border-color:#bcd1e5} +body.dark .guest-roster-row[data-status=yes]{background:#203c2e}body.dark .guest-roster-row[data-status=no]{background:#3a2526}body.dark .guest-roster-row[data-status=maybe]{background:#3d3425}body.dark .guest-roster-row[data-status=later]{background:#253347}body.dark .guest-roster-row[data-status=pending]{background:#352b43}body.dark .guest-roster-row[data-status=payment]{background:#263443}body.dark .guest-roster-row[data-status=unanswered],body.dark .guest-roster-row[data-status=unopened]{background:#2a3330}body.dark .invitation-status[data-status=later]{background:#2c3f5c;color:#d0e0ff;border-color:#49648c}body.dark .invitation-status[data-status=pending]{background:#423355;color:#e7d5ff;border-color:#6b508b}body.dark .invitation-status[data-status=payment]{background:#2b4159;color:#d4eaff;border-color:#486889} +@media(max-width:480px){.brand .wordmark-note{display:none}.brand>a{font-size:18px;gap:8px}.brand .header-profile{max-width:112px}}@media(max-width:360px){.brand{gap:6px}.brand .header-profile{max-width:96px}.brand #header-name{font-size:12px}.guest-roster-row{padding:10px}.guest-roster-more{top:8px;right:8px}} + +.edit-checkin-panel{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px;margin:0 0 18px;border:1px solid var(--line);border-radius:12px;background:var(--panel)}.edit-checkin-panel button{margin:0;min-height:44px;font-size:13px}.ticket-code-label{text-align:center;margin:18px 0 8px}#ticket-code{font-size:clamp(32px,8vw,42px);letter-spacing:6px;font-weight:750;font-variant-numeric:tabular-nums;line-height:1.5;overflow-wrap:anywhere}#ticket-countdown{text-align:center;min-height:20px;margin:6px 0 16px;font-variant-numeric:tabular-nums}.ticket-code-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));margin-top:12px}.ticket-code-actions button{min-width:0;min-height:46px}.ticket-code-actions button:disabled{cursor:not-allowed}#ticket-message{font-size:13px;line-height:1.5;margin:14px 0 8px}#checkin-event{margin:0 0 8px;overflow-wrap:anywhere}#checkin-scan{display:block;width:100%;margin:0 0 14px;min-height:44px}.checkin-code-entry{position:relative}.checkin-code-cells{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:6px}.checkin-code-cells span{display:grid;place-items:center;min-height:54px;background:var(--bg);border:1px solid var(--line);border-radius:9px;font-size:28px;font-weight:700;font-variant-numeric:tabular-nums;line-height:1}.checkin-code-cells .current{border-color:var(--accent)}#checkin-code{position:absolute;inset:0;width:100%;height:100%;min-height:54px;opacity:0;cursor:text}.checkin-code-entry:focus-within{outline:2px solid var(--accent);outline-offset:4px;border-radius:9px}#checkin-help{font-size:12px;margin:10px 0 14px;line-height:1.5}.checkin-keypad{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.checkin-keypad button{min-width:0;min-height:48px;margin:0;padding:10px;font-size:20px;line-height:1.2;background:var(--bg);font-variant-numeric:tabular-nums}.checkin-keypad button:nth-child(10){font-size:14px}.checkin-keypad button:focus-visible,#checkin-check:focus-visible{outline:2px solid var(--accent);outline-offset:3px}#checkin-check{margin-top:12px;min-height:48px}#checkin-result:not(:empty){margin:14px 0 0;padding:12px;border-radius:10px;font-size:14px;line-height:1.5}#checkin-result.checkin-success{background:#e8f1e9;color:#186449}#checkin-result.checkin-warning{background:#fff4de;color:#75541c}body.dark #checkin-result.checkin-success{background:#264b3b;color:#c4ecd5}body.dark #checkin-result.checkin-warning{background:#3e3323;color:#f6deac}#checkin-result:empty{display:none}@media(max-width:360px){.checkin-code-cells{gap:4px}.checkin-code-cells span{min-height:48px;font-size:25px}.checkin-keypad{gap:6px}.checkin-keypad button{min-height:46px}.edit-checkin-panel{padding:10px;gap:8px}.edit-checkin-panel>span{font-size:11px}.edit-checkin-panel button{padding:10px;font-size:12px}} diff --git a/scripts/check-worker.mjs b/scripts/check-worker.mjs index 1c1e073..e0f8f3e 100644 --- a/scripts/check-worker.mjs +++ b/scripts/check-worker.mjs @@ -4,16 +4,252 @@ import assert from 'node:assert/strict'; import { createHmac } from 'node:crypto'; const telegramCalls=[]; +const serveAssets = async request => { + const pathname=new URL(request.url).pathname; + const file=pathname==='/' ? 'index.html' : pathname.slice(1); + if(!['index.html','app.js','errors.js','gallery.js','event-actions.js','style.css'].includes(file))return new Response('Not found',{status:404}); + const headers={'Content-Type':file.endsWith('.js') ? 'application/javascript' : file.endsWith('.css') ? 'text/css' : 'text/html'}; + if(file==='index.html') { + headers.ETag='"fixture-index"';headers['Last-Modified']='Thu, 01 Oct 2026 00:00:00 GMT'; + if(request.headers.get('If-None-Match')===headers.ETag || request.headers.get('If-Modified-Since')===headers['Last-Modified'])return new Response(null,{status:304,headers}); + } + return new Response(await readFile('public/'+file),{headers}); +}; + +async function checkTestAccess() { + // Each fixture has its own D1 and mock transport; no live bot or user data is used. + const admin=710001,seedUser=710101,blockedUser=710102,otherUser=710103; + const authData=uid=>{ + const params=new URLSearchParams({auth_date:String(Math.floor(Date.now()/1000)),user:JSON.stringify({id:uid,first_name:'Access tester'}),query_id:'access-query'}); + const data=[...params.entries()].sort(([a],[b])=>a.localeCompare(b)).map(([key,value])=>`${key}=${value}`).join('\n'); + const key=createHmac('sha256','WebAppData').update('access-test-token').digest(); + params.set('hash',createHmac('sha256',key).update(data).digest('hex'));return params.toString(); + }; + async function fixture(bindings={}) { + const calls=[]; + const worker=new Miniflare(convertV4MiniflareOptions({workers:[{ + name:'test-access',modules:true,scriptPath:'.wrangler/build/worker.js',compatibilityDate:'2026-10-05',compatibilityFlags:['nodejs_compat'],d1Databases:['DB'], + bindings:{APP_ENV:'development',TEST_WHITELIST_ENABLED:'true',SUPER_ADMIN_ID:String(admin),BOT_USERNAME:'fictionalAccessBot',APP_URL:'https://access.test/app',TELEGRAM_BOT_TOKEN:'access-test-token',TELEGRAM_WEBHOOK_SECRET:'access-test-secret',...bindings}, + serviceBindings:{ASSETS:serveAssets}, + outboundService:async request=>{ + const method=new URL(request.url).pathname.split('/').at(-1); + const params=request.headers.get('Content-Type')?.includes('application/json')?await request.clone().json():null; + calls.push({method,params}); + if(request.url.includes('/file/bot'))return new Response(new Uint8Array([255,216,255]),{headers:{'Content-Type':'image/jpeg'}}); + return Response.json({ok:true,result:method==='getFile'?{file_path:'photos/access.jpg'}:{}}); + } + }]})); + try { + const db=await worker.getD1Database('DB'); + await db.exec((await readFile('migrations/0001_initial.sql','utf8')).replace(/\n/g,' ')); + for(const path of ['migrations/0002_delivery_lease.sql','migrations/0003_mini_app.sql'])await db.exec(await readFile(path,'utf8')); + const api=(path,uid=blockedUser,method='GET',input)=>worker.dispatchFetch('https://access.test/api/'+path,{method,headers:{Authorization:'tma '+authData(uid),'Content-Type':'application/json'},...(input===undefined?{}:{body:JSON.stringify(input)})}); + const update=async body=>{ + for(let attempt=0;attempt<12;attempt++){ + const response=await worker.dispatchFetch('https://access.test/telegram',{method:'POST',headers:{'X-Telegram-Bot-Api-Secret-Token':'access-test-secret'},body:JSON.stringify(body)}); + const text=await response.text(); + if(response.status===503 && attempt<11){await new Promise(resolve=>setTimeout(resolve,Math.min(25*(attempt+1),200)));continue;} + assert.equal(response.status,200,text);return; + } + }; + const message=(update_id,text,uid=admin,extra={})=>update({update_id,message:{from:{id:uid,first_name:'Access tester'},chat:{id:uid,type:'private'},text,...extra}}); + const setting=async()=>{const row=await db.prepare("SELECT value FROM app_settings WHERE key='test-whitelist'").first();return row?JSON.parse(row.value):null;}; + const saveSetting=value=>db.prepare("INSERT INTO app_settings(key,value) VALUES ('test-whitelist',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(value).run(); + const records=async()=>(await db.prepare('SELECT kind,id,data FROM records ORDER BY kind,id').all()).results; + return {worker,db,calls,api,update,message,setting,saveSetting,records}; + }catch(error){await worker.dispose();throw error;} + } + const access=await fixture({WHITELIST_USER_IDS:String(seedUser)}); + try { + const {worker,db,calls,api,update,message,setting,saveSetting,records}=access; + assert.equal((await worker.dispatchFetch('https://access.test/api/bootstrap')).status,401); + assert.equal((await api('bootstrap',seedUser)).status,200); + assert.equal((await api('bootstrap',admin)).status,200,'The configured admin must remain able to manage testers.'); + // Hold delivery so command retries can be checked against the exact durable outbox. + await db.prepare("INSERT INTO delivery_lease(id,owner,expires) VALUES (1,'access-fixture',unixepoch()+3600)").run(); + const eventId='7100000000000001',inviteToken='71000000000000000000000000000001',cohostToken='71000000000000000000000000000002',mediaId='710000000001'; + const event={id:eventId,owner:admin,title:'Private access fixture',when:'Future test event',startsAt:'2099-12-05T18:00:00Z',timezone:'UTC',location:'Fictional venue',description:'',invitationMode:'named',oneTimeInvite:true,guests:{[blockedUser]:{name:'Existing tester',status:'yes',invitationToken:inviteToken}},invitees:{[inviteToken]:{name:'Named tester',participants:1,participantMode:'preset'}},permissions:{guestList:true,uploadMedia:true,viewMedia:true},banner:'access-banner',media:[{id:mediaId,fileId:'access-image',type:'photo',name:'fictional.jpg',user:blockedUser}],cohostLinks:[{id:'7100000000000002',token:cohostToken,label:'Test co-host',status:'pending',cohost:null,createdAt:'2026-01-01T00:00:00Z'}]}; + await db.prepare("INSERT INTO records(kind,id,data) VALUES ('events',?,?)").bind(eventId,JSON.stringify(event)).run(); + await db.prepare("INSERT INTO records(kind,id,data) VALUES ('preferences',?,?)").bind(String(blockedUser),JSON.stringify({profilePhoto:'access-profile',timezone:'UTC'})).run(); + const beforeDenied=await records(),beforeCalls=calls.length; + for(const [path,method,input] of [ + ['bootstrap','GET'],['explore?timezone=UTC','GET'],['profile/photo','GET'],['profile/photo','DELETE'],['profile/photo','POST',{}],['branding/icon','GET'], + [`events/${eventId}`,'GET'],[`events/${eventId}/banner`,'GET'],[`events/${eventId}/banner`,'POST',{}], + [`events/${eventId}/gallery`,'GET'],[`events/${eventId}/upload-qr`,'GET'],[`events/${eventId}/media/${mediaId}`,'GET'],[`events/${eventId}/media/${mediaId}/thumbnail`,'GET'],[`events/${eventId}/media/${mediaId}/send`,'POST',{}], + ['preferences','POST',{timezone:'Europe/London'}],['events','POST',{}],['admin/overview','GET'] + ]){ + const response=await api(path,blockedUser,method,input); + assert.equal(response.status,403,path+' must enforce test access before its route handler.'); + assert.equal(response.headers.get('Cache-Control'),'no-store'); + const body=await response.json();assert.match(body.error,/test|access/i); + assert.doesNotMatch(JSON.stringify(body),/710001|710101|Private access fixture|Fictional venue|access-profile/); + } + assert.equal(calls.length,beforeCalls,'Denied profile, banner and media requests must not call Telegram.'); + assert.deepEqual(await records(),beforeDenied,'Denied API requests must not change profiles, events or sessions.'); + for(const [offset,text] of ['/new',`/start e_${eventId}`,`/start i_${eventId}_${inviteToken}`,`/start c_${eventId}_${cohostToken}`].entries())await message(710010+offset,text,blockedUser); + await update({update_id:710020,callback_query:{id:'blocked-callback',from:{id:blockedUser,first_name:'Access tester'},data:`r:${eventId}:no:0`}}); + assert.deepEqual(await records(),beforeDenied,'Old callbacks and deep links must not bypass test access or claim invitations.'); + const callbackDenial=await db.prepare("SELECT params FROM outbox WHERE method='answerCallbackQuery' AND json_extract(params,'$.callback_query_id')='blocked-callback'").first(); + assert.ok(callbackDenial);assert.match(JSON.parse(callbackDenial.params).text,/test|access/i); + await update({update_id:710021,pre_checkout_query:{id:'blocked-checkout',from:{id:blockedUser},invoice_payload:'fictional-order',currency:'XTR',total_amount:10}}); + const deniedCheckout=calls.find(call=>call.method==='answerPreCheckoutQuery' && call.params?.pre_checkout_query_id==='blocked-checkout'); + assert.equal(deniedCheckout?.params.ok,false);assert.deepEqual(await records(),beforeDenied); + await message(710030,'/whitelist add '+blockedUser,seedUser); + assert.equal(await setting(),null,'A permitted tester must not gain administrator commands.'); + await message(710031,'/whitelist add '+blockedUser,admin,{chat:{id:-710001,type:'group'}}); + assert.equal(await setting(),null,'Whitelist identities must never be managed or listed in group chats.'); + await message(710032,'/whitelist list'); + assert.equal(await setting(),null,'Listing seeded access must not create an override.'); + await message(710033,`/whitelist add ${blockedUser} ${otherUser}`); + assert.deepEqual((await setting()).sort(),[seedUser,blockedUser,otherUser].map(String).sort()); + assert.equal((await api('bootstrap',blockedUser)).status,200); + const commandRows=async()=>(await db.prepare("SELECT id,method,params FROM outbox WHERE id LIKE '710033:%' ORDER BY id").all()).results; + const beforeReplay=await commandRows();assert.ok(beforeReplay.length); + await message(710033,`/whitelist add ${blockedUser} ${otherUser}`); + assert.deepEqual(await commandRows(),beforeReplay,'Telegram redelivery must not duplicate the command response.'); + assert.equal((await db.prepare('SELECT COUNT(*) AS n FROM processed WHERE id=710033').first()).n,1); + await message(710034,`/whitelist remove ${seedUser} ${blockedUser}`); + assert.deepEqual(await setting(),[String(otherUser)]); + assert.equal((await api('bootstrap',seedUser)).status,403);assert.equal((await api('bootstrap',blockedUser)).status,403); + await message(710035,'/whitelist clear');assert.deepEqual(await setting(),[]); + assert.equal((await api('bootstrap',blockedUser)).status,200,'An explicit empty override must open access even with a nonempty seed.'); + await message(710036,'/whitelist add '+blockedUser);assert.deepEqual(await setting(),[String(blockedUser)]); + await message(710037,'/whitelist add invalid 0 9007199254740992');assert.deepEqual(await setting(),[String(blockedUser)],'Invalid input must never silently open or partially alter access.'); + await Promise.all([message(710038,'/whitelist add '+seedUser),message(710039,'/whitelist add '+otherUser)]); + assert.deepEqual((await setting()).sort(),[seedUser,blockedUser,otherUser].map(String).sort(),'Concurrent commands must preserve both changes.'); + await message(710040,`/whitelist remove ${seedUser} ${blockedUser} ${otherUser}`);assert.deepEqual(await setting(),[]); + assert.equal((await api('bootstrap',otherUser)).status,200,'Removing the last tester must restore the documented open mode.'); + for(const invalid of ['not-json','{}','["710101","invalid"]','["0"]','["9007199254740992"]']){ + await saveSetting(invalid);assert.equal((await api('bootstrap',seedUser)).status,403,'Malformed stored access settings must fail closed.'); + assert.equal((await api('bootstrap',admin)).status,200); + } + await message(710041,'/whitelist clear');assert.deepEqual(await setting(),[],'The admin must be able to recover invalid stored access.'); + // Incoming successful/refunded payment service messages remain auditable after access is revoked. + const orderId='71000000000000000000000000000003',charge='fictional-access-charge'; + const paidEvent={...event,id:'7100000000000003',invitationMode:'tickets',starPrice:10,starPricing:'group',paymentTerms:'Fictional fixture refund terms.',guests:{[blockedUser]:{name:'Paying tester',status:'yes',participants:1,payment:{order:orderId,status:'processing'}}}}; + const order={id:orderId,event:paidEvent.id,owner:admin,title:paidEvent.title,amount:10,unitPrice:10,pricing:'group',terms:paidEvent.paymentTerms,participants:1,status:'pending',createdAt:'2026-01-01T00:00:00Z',checkoutId:'earlier-checkout'}; + await db.prepare("INSERT INTO records(kind,id,data) VALUES ('events',?,?)").bind(paidEvent.id,JSON.stringify(paidEvent)).run(); + await db.prepare("UPDATE records SET data=? WHERE kind='preferences' AND id=?").bind(JSON.stringify({starOrders:{[orderId]:order}}),String(blockedUser)).run(); + await saveSetting(JSON.stringify([String(seedUser)])); + const payment={currency:'XTR',total_amount:10,invoice_payload:orderId,telegram_payment_charge_id:charge}; + await message(710050,undefined,blockedUser,{successful_payment:payment}); + const storedOrder=async()=>JSON.parse((await db.prepare("SELECT data FROM records WHERE kind='preferences' AND id=?").bind(String(blockedUser)).first()).data).starOrders[orderId]; + assert.equal((await storedOrder()).status,'paid');assert.equal((await storedOrder()).charge,charge); + await message(710050,undefined,blockedUser,{successful_payment:payment});assert.equal((await storedOrder()).status,'paid'); + await message(710051,undefined,blockedUser,{refunded_payment:payment});assert.equal((await storedOrder()).status,'refunded'); + assert.equal((await api('bootstrap',blockedUser)).status,403,'Payment bookkeeping must not grant normal app access.'); + }finally{await access.worker.dispose();} + for(const scenario of [ + {name:'empty seed',bindings:{WHITELIST_USER_IDS:''},status:200}, + {name:'malformed seed',bindings:{WHITELIST_USER_IDS:'710101,invalid'},status:403}, + {name:'production ignores dev flag',bindings:{APP_ENV:'production',WHITELIST_USER_IDS:String(seedUser)},status:200}, + {name:'disabled dev gate',bindings:{TEST_WHITELIST_ENABLED:'false',WHITELIST_USER_IDS:String(seedUser)},status:200} + ]){ + const test=await fixture(scenario.bindings); + try { + assert.equal((await test.api('bootstrap',blockedUser)).status,scenario.status,scenario.name); + assert.equal((await test.api('bootstrap',admin)).status,200,scenario.name+' admin access'); + if(scenario.status===200){ + await test.message(710060,'/new',blockedUser); + assert.ok(await test.db.prepare("SELECT data FROM records WHERE kind='sessions' AND id=?").bind(String(blockedUser)).first(),scenario.name+' bot access'); + } + if(scenario.bindings.APP_ENV==='production'){ + await test.message(710061,'/whitelist add '+seedUser); + assert.equal(await test.setting(),null,scenario.name+' must not enable whitelist administration'); + } + }finally{await test.worker.dispose();} + } + const controls=await fixture({WHITELIST_USER_IDS:String(seedUser)}); + try { + const {db,api,message,setting,calls}=controls; + assert.equal((await api('admin/whitelist',seedUser)).status,403,'Permitted testers cannot read admin access settings.'); + const initial=await api('admin/whitelist',admin); + assert.equal(initial.status,200); + assert.deepEqual(await initial.json(),{supported:true,enabled:true,restricted:true,ids:[String(seedUser)],snapshotProtected:false,invalid:false}); + const change=async input=>{ + const response=await api('admin/whitelist',admin,'POST',input); + assert.equal(response.status,200,JSON.stringify(await response.clone().json())); + return response.json(); + }; + const initialCalls=calls.length; + const added=await change({action:'add',ids:`${otherUser}, ${otherUser}`}); + assert.deepEqual([...added.ids].sort(),[seedUser,otherUser].map(String).sort(),'Panel additions preserve the secret seed and deduplicate IDs.'); + assert.equal(calls.length,initialCalls,'Panel edits must not send bot messages.'); + for(const input of [{action:'add',ids:'invalid'},{action:'remove',ids:otherUser},{action:'set-enabled',enabled:'false'},{action:'unknown'}]){ + assert.equal((await api('admin/whitelist',admin,'POST',input)).status,400,'Invalid access edits must be rejected.'); + assert.deepEqual((await setting()).sort(),[seedUser,otherUser].map(String).sort()); + } + assert.equal((await change({action:'set-enabled',enabled:false})).restricted,false); + assert.equal((await api('bootstrap',blockedUser)).status,200,'Saved off switch opens normal development without discarding its IDs.'); + await message(710080,'/new',blockedUser); + assert.ok(await db.prepare("SELECT data FROM records WHERE kind='sessions' AND id=?").bind(String(blockedUser)).first(),'Bot messages obey the saved switch too.'); + const disabledAdd=await change({action:'add',ids:String(blockedUser)}); + assert.equal(disabledAdd.enabled,false);assert.ok(disabledAdd.ids.includes(String(blockedUser))); + await change({action:'remove',ids:String(otherUser)}); + assert.equal((await change({action:'set-enabled',enabled:true})).restricted,true); + assert.equal((await api('bootstrap',otherUser)).status,403); + assert.equal((await api('bootstrap',seedUser)).status,200); + await change({action:'remove',ids:`${seedUser} ${blockedUser}`}); + assert.equal((await api('bootstrap',otherUser)).status,200,'An empty ordinary list preserves the existing open-access rule.'); + await change({action:'add',ids:String(seedUser)}); + await db.prepare("INSERT INTO app_settings(key,value) VALUES ('production-snapshot','{}')").run(); + const copied=await (await api('admin/whitelist',admin)).json();assert.equal(copied.snapshotProtected,true);assert.equal(copied.restricted,true); + assert.equal((await api('admin/whitelist',admin,'POST',{action:'set-enabled',enabled:false})).status,400,'Panel cannot disable production-snapshot protection.'); + const emptyCopy=await change({action:'remove',ids:String(seedUser)});assert.equal(emptyCopy.restricted,true);assert.deepEqual(emptyCopy.ids,[]); + assert.equal((await api('bootstrap',seedUser)).status,403,'Removing the final copied-data tester keeps admin-only access.'); + assert.equal((await api('bootstrap',admin)).status,200); + }finally{await controls.worker.dispose();} + const productionControls=await fixture({APP_ENV:'production',WHITELIST_USER_IDS:String(seedUser)}); + try { + assert.equal((await (await productionControls.api('admin/whitelist',admin)).json()).supported,false); + assert.equal((await productionControls.api('admin/whitelist',admin,'POST',{action:'set-enabled',enabled:true})).status,403); + assert.equal(await productionControls.setting(),null); + assert.equal((await productionControls.api('bootstrap',blockedUser)).status,200,'Production access cannot be restricted by the development panel.'); + }finally{await productionControls.worker.dispose();} + const snapshot=await fixture({TEST_WHITELIST_ENABLED:'false',WHITELIST_USER_IDS:''}); + try { + const {worker,db,calls,api,message,setting}=snapshot; + await db.prepare("INSERT INTO app_settings(key,value) VALUES ('production-snapshot','not-json')").run(); + const health=await (await worker.dispatchFetch('https://access.test/')).json(); + assert.equal(health.databaseRefreshProtection,1); + assert.equal((await api('bootstrap',blockedUser)).status,403,'A production copy must stay restricted even with the dev whitelist switch disabled.'); + assert.equal((await api('bootstrap',admin)).status,200); + await message(710070,'/whitelist add '+seedUser); + assert.deepEqual(await setting(),[String(seedUser)]); + assert.equal((await api('bootstrap',seedUser)).status,200); + await message(710071,'/whitelist clear'); + assert.deepEqual(await setting(),[]); + assert.equal((await api('bootstrap',seedUser)).status,403,'Clearing testers must not expose a production copy.'); + await message(710072,'/whitelist add '+seedUser); + const audit={starOrders:{'snapshot-order':{id:'snapshot-order',owner:admin,title:'Copied financial audit',amount:10,status:'paid',charge:'snapshot-charge'}}}; + await db.prepare("INSERT INTO records(kind,id,data) VALUES ('preferences',?,?)").bind(String(seedUser),JSON.stringify(audit)).run(); + for(const [id,method,params] of [ + ['snapshot-foreign','sendMessage',{chat_id:blockedUser,text:'Copied recipient fixture'}], + ['snapshot-invoice','sendInvoice',{chat_id:seedUser,title:'Blocked invoice fixture'}], + ['snapshot-refund','refundStarPayment',{user_id:seedUser,telegram_payment_charge_id:'snapshot-charge'}], + ['snapshot-permitted','sendMessage',{chat_id:seedUser,text:'Allowed tester fixture'}] + ])await db.prepare('INSERT INTO outbox(id,method,params) VALUES (?,?,?)').bind(id,method,JSON.stringify(params)).run(); + await message(710073,'/whitelist list'); + for(let attempt=0;attempt<100;attempt++){ + if(!(await db.prepare("SELECT COUNT(*) AS n FROM outbox WHERE id LIKE 'snapshot-%'").first()).n)break; + await new Promise(resolve=>setTimeout(resolve,25)); + } + assert.equal((await db.prepare("SELECT COUNT(*) AS n FROM outbox WHERE id LIKE 'snapshot-%'").first()).n,0); + assert.equal(calls.some(call=>call.params?.text==='Copied recipient fixture'),false); + assert.equal(calls.some(call=>['sendInvoice','refundStarPayment'].includes(call.method)),false); + assert.ok(calls.some(call=>call.params?.text==='Allowed tester fixture')); + const preserved=JSON.parse((await db.prepare("SELECT data FROM records WHERE kind='preferences' AND id=?").bind(String(seedUser)).first()).data); + assert.deepEqual(preserved,audit,'Suppressing copied refunds must preserve the imported payment audit.'); + assert.ok(calls.some(call=>/Only the super admin has access/.test(call.params?.text || '')),'The clear command must describe snapshot admin-only access.'); + }finally{await snapshot.worker.dispose();} + console.log('Development access integration passed: environment isolation, empty and seeded lists, private admin commands, concurrent edits, Telegram retries, route privacy, callback and link claims, checkout denial and payment/refund audit preservation. Telegram mocked.'); +} + const mf = new Miniflare(convertV4MiniflareOptions({ workers: [{ name: 'test', modules: true, scriptPath: '.wrangler/build/worker.js', compatibilityDate: '2026-10-05', compatibilityFlags: ['nodejs_compat'], d1Databases: ['DB'], bindings: { SUPER_ADMIN_ID: '999001', BOT_USERNAME: 'XEvents_bot', APP_URL: 'https://test/app', TELEGRAM_BOT_TOKEN: 'fake', TELEGRAM_WEBHOOK_SECRET: 'test-secret' }, - serviceBindings: { ASSETS: async request => { - const pathname=new URL(request.url).pathname; - const file=pathname==='/' ? 'index.html' : pathname.slice(1); - if(!['index.html','app.js','errors.js','gallery.js','event-actions.js','style.css'].includes(file))return new Response('Not found',{status:404}); - return new Response(await readFile('public/'+file),{headers:{'Content-Type':file.endsWith('.js') ? 'application/javascript' : file.endsWith('.css') ? 'text/css' : 'text/html'}}); - } }, + serviceBindings: { ASSETS: serveAssets }, outboundService: async request => { if(request.method==='POST' && request.headers.get('Content-Type')?.includes('application/json'))telegramCalls.push({method:new URL(request.url).pathname.split('/').at(-1),params:await request.clone().json()}); if (request.url.includes('/file/bot')) return new Response(new Uint8Array([255,216,255]), { headers: { 'Content-Type':'image/jpeg' } }); @@ -30,7 +266,45 @@ try { await db.exec(await readFile('migrations/0003_mini_app.sql', 'utf8')); assert.equal((await mf.dispatchFetch('https://test/')).status, 200); const appPage=await mf.dispatchFetch('https://test/app'); - assert.equal(appPage.status,200);assert.match(await appPage.text(),/id="event-end-dialog"/); + const appHtml=await appPage.text(); + assert.equal(appPage.status,200);assert.match(appHtml,/id="event-end-dialog"/); + assert.match(appHtml,/id="telegram-bot-link"[^>]*href="https:\/\/t\.me\/XEvents_bot"/); + // A separate Worker must use its own bot even before Telegram authentication. + const development = new Miniflare(convertV4MiniflareOptions({ workers: [{ + name:'development-links', modules:true, scriptPath:'.wrangler/build/worker.js', compatibilityDate:'2026-10-05', compatibilityFlags:['nodejs_compat'], + bindings:{BOT_USERNAME:'fictionalDevelopmentBot',APP_URL:'https://development.test/app'}, + serviceBindings:{ASSETS:serveAssets}, + outboundService:async()=>{throw new Error('Development link checks must not make outbound requests.');} + }] })); + try { + const validators={'If-None-Match':'"fixture-index"','If-Modified-Since':'Thu, 01 Oct 2026 00:00:00 GMT'}; + assert.equal((await serveAssets(new Request('https://assets.test/',{headers:validators}))).status,304); + let developmentBotLink; + for(const path of ['/app','/app/']) { + const page=await development.dispatchFetch('https://development.test'+path,{headers:validators}); + assert.equal(page.status,200); + assert.equal(page.headers.get('ETag'),null);assert.equal(page.headers.get('Last-Modified'),null); + for(const name of ['Content-Type','Cache-Control','X-Content-Type-Options','Referrer-Policy','Content-Security-Policy'])assert.equal(page.headers.get(name),appPage.headers.get(name)); + const html=await page.text(); + developmentBotLink=html.match(/id="telegram-bot-link"[^>]*href="([^"]+)"/)?.[1]; + assert.equal(developmentBotLink,'https://t.me/fictionalDevelopmentBot'); + assert.doesNotMatch(html,/https:\/\/t\.me\/XEvents_bot/); + } + const module=await development.dispatchFetch('https://development.test/app.js'); + assert.equal(module.status,200); + const source=await module.text(); + assert.doesNotMatch(source,/https:\/\/t\.me\/XEvents_bot/); + const fallback=source.match(/^if \(!initData\) \{\r?\n([\s\S]*?)\r?\n\} else \{/m)?.[1]; + assert.ok(fallback,'The unauthenticated launcher must remain available.'); + const launched=[],buttons=[],nodes=new Map(); + const node=id=>{if(!nodes.has(id))nodes.set(id,{replaceChildren(){},getAttribute:()=>developmentBotLink});return nodes.get(id);}; + const runFallback=new Function('$','notice','element','action','tg','window','options','deviceZone',fallback); + const launch=()=>runFallback(node,()=>{},()=>({append:button=>buttons.push(button)}),(label,onclick)=>({label,onclick}),{openTelegramLink:url=>launched.push(url)},{},()=>{},'UTC'); + launch();assert.equal(buttons.length,1);assert.equal(buttons[0].label,'App');buttons[0].onclick(); + assert.deepEqual(launched,['https://t.me/fictionalDevelopmentBot?start=app']); + developmentBotLink='https://t.me/';buttons.length=0; + launch();assert.equal(buttons.length,0,'The unconfigured static page must not send guests to another bot.'); + } finally { await development.dispose(); } const appModule=await mf.dispatchFetch('https://test/app.js'); assert.equal(appModule.status,200); const imports=[...(await appModule.text()).matchAll(/import\s+.*?from\s+['"]\.\/([^'"]+)['"]/g)].map(match=>match[1]); @@ -123,7 +397,7 @@ try { await db.prepare("UPDATE records SET data=json_set(data,'$.responseDeadline','2020-01-01T00:00:00Z') WHERE kind='events' AND id=?").bind(privateId).run(); await callback(26, `r:${privateId}:no`, 456); assert.equal((await api('bootstrap', null, 456)).data.events[0].status, 'yes'); await callback(27, `approve:${privateId}:456`); - guestView = (await api('bootstrap', null, 456)).data.events[0]; assert.equal(guestView.approval, 'approved'); assert.equal(guestView.location, 'SECRET LOCATION'); assert.equal(guestView.ticket.info, 'SECRET TICKET'); assert.ok(guestView.ticket.code); + guestView = (await api('bootstrap', null, 456)).data.events[0]; assert.equal(guestView.approval, 'approved'); assert.equal(guestView.location, 'SECRET LOCATION'); assert.equal(guestView.ticket.info, 'SECRET TICKET'); assert.equal(guestView.ticket.code,undefined); assert.equal((await api('bootstrap')).data.events.find(e => e.id === privateId).counts.yes, 1); assert.equal((await api('bootstrap', null, 789)).data.events.length, 0); assert.equal((await api(`events/${privateId}/reminder`, {minutes:60},456)).status,200); @@ -263,13 +537,48 @@ try { assert.equal((await api(`events/${ticketId}`,null,456)).data.event.approval,'pending'); assert.equal((await api(`events/${ticketId}/ticket`,{},456)).status,400); await callback(5005,`approve:${ticketId}:456`); - const qrTicket=await api(`events/${ticketId}/ticket`,{},456);assert.equal(qrTicket.status,200);assert.match(qrTicket.data.ticket.image,/^data:image\/gif;base64,/); - const ticketCode=qrTicket.data.ticket.code; - assert.equal((await api(`events/${ticketId}/ticket-check`,{code:ticketCode},456)).status,400); + const ticketStored=async()=>JSON.parse((await db.prepare("SELECT data FROM records WHERE kind='events' AND id=?").bind(ticketId).first()).data); + assert.equal((await ticketStored()).guests[456].ticketCode,undefined,'Approval must not generate a short check-in code.'); + const unopenedTicketState=await ticketStored(); + unopenedTicketState.guests[457]={name:'Unopened legacy booking',status:'yes',approval:'approved'}; + await db.prepare("UPDATE records SET data=? WHERE kind='events' AND id=?").bind(JSON.stringify(unopenedTicketState),ticketId).run(); + // Request near a minute boundary only after the next minute starts, keeping the + // integration independent of wall-clock timing. No live event or bot is used. + const currentTicket=async()=>{ + let result=await api(`events/${ticketId}/ticket`,{},456); + assert.equal(result.status,200,JSON.stringify(result.data)); + const left=Date.parse(result.data.ticket.codeExpiresAt)-Date.now(); + if(left<2500){await new Promise(resolve=>setTimeout(resolve,Math.max(0,left)+50));result=await api(`events/${ticketId}/ticket`,{},456);assert.equal(result.status,200);} + return result; + }; + const qrTicket=await currentTicket();assert.match(qrTicket.data.ticket.image,/^data:image\/gif;base64,/);assert.match(qrTicket.data.ticket.code,/^\d{6}$/); + const ticketState=await ticketStored(),ticketCode=ticketState.guests[456].ticket; + assert.equal(ticketState.guests[457].ticket,undefined);assert.equal(ticketState.guests[457].ticketCode,undefined,'Opening one ticket must tolerate and preserve unopened legacy bookings.'); + assert.match(ticketCode,/^[A-F0-9]{12}$/);assert.match(ticketState.guests[456].ticketCode.secret,/^[a-f0-9]{64}$/); + assert.equal(ticketState.guests[456].ticketCode.ticket,ticketCode); + const issuedAt=Date.parse(qrTicket.data.ticket.serverTime),expiresAt=Date.parse(qrTicket.data.ticket.codeExpiresAt); + assert.equal(expiresAt,(Math.floor(issuedAt/60000)+1)*60000);assert.equal(ticketState.guests[456].ticketCode.issuedMinute,Math.floor(issuedAt/60000)); + assert.equal((await api(`events/${ticketId}/ticket-check`,{code:qrTicket.data.ticket.code},456)).status,400); + assert.equal((await ticketStored()).ticketCheckAttempts,undefined,'Guests cannot mutate organiser rate limits.'); + const otpValidated=await api(`events/${ticketId}/ticket-check`,{code:qrTicket.data.ticket.code}); + assert.equal(otpValidated.status,200);assert.equal(otpValidated.data.ticket.valid,true);assert.equal(otpValidated.data.ticket.code,ticketCode); + const guestTicketProjection=(await api(`events/${ticketId}`,null,456)).data.event; + assert.equal(guestTicketProjection.ticket.code,undefined);assert.equal(guestTicketProjection.ticketCode,undefined); + const managerTicketProjection=(await api(`events/${ticketId}`)).data.event; + const ticketSeed=ticketState.guests[456].ticketCode.secret; + for(const projection of [guestTicketProjection,managerTicketProjection,(await api('bootstrap',null,456)).data,(await api('admin/overview',null,999001)).data]){ + const projected=JSON.stringify(projection);assert.ok(!projected.includes(ticketSeed));assert.ok(!projected.includes('"ticketCode":'));assert.ok(!projected.includes('"code":"'+qrTicket.data.ticket.code+'"')); + } + assert.deepEqual((await ticketStored()).guests[456].ticketCode,ticketState.guests[456].ticketCode,'Reading app pages must not rotate or issue a code.'); + const expiredTicketState=await ticketStored();expiredTicketState.guests[456].ticketCode.issuedMinute--; + await db.prepare("UPDATE records SET data=? WHERE kind='events' AND id=?").bind(JSON.stringify(expiredTicketState),ticketId).run(); + assert.equal((await api(`events/${ticketId}/ticket-check`,{code:qrTicket.data.ticket.code})).data.ticket.valid,false,'Only codes requested in the current minute may validate.'); + const refreshedTicket=await currentTicket();assert.match(refreshedTicket.data.ticket.code,/^\d{6}$/); + assert.equal((await ticketStored()).guests[456].ticket,ticketCode,'Refreshing a short code preserves legacy QR and check-in identity.'); const checkinMessages=()=>telegramCalls.filter(c=>c.method==='sendMessage' && c.params.chat_id===456 && c.params.text?.startsWith('✅ Checked in\n'+ticketInput.title)); const validated=await api(`events/${ticketId}/ticket-check`,{code:`XE1:${ticketId}:${ticketCode}`});assert.equal(validated.data.ticket.valid,true);assert.equal(validated.data.ticket.checkedInAt,null); assert.equal(checkinMessages().length,0); - const admitted=await api(`events/${ticketId}/ticket-check`,{code:ticketCode,checkIn:true});assert.equal(admitted.data.ticket.valid,true);assert.equal(admitted.data.ticket.alreadyCheckedIn,false); + const admitted=await api(`events/${ticketId}/ticket-check`,{code:refreshedTicket.data.ticket.code,checkIn:true});assert.equal(admitted.data.ticket.valid,true);assert.equal(admitted.data.ticket.alreadyCheckedIn,false);assert.equal(admitted.data.ticket.code,ticketCode); // The notification commits with attendance; background delivery may still be draining older messages. const queuedCheckins=async()=> (await db.prepare("SELECT params FROM outbox WHERE method='sendMessage' AND json_extract(params,'$.chat_id')=456 AND json_extract(params,'$.text')=?").bind('✅ Checked in\n'+ticketInput.title+'\n1 person').all()).results; const queued=await queuedCheckins();assert.ok(checkinMessages().length===1 || queued.length===1); @@ -278,6 +587,16 @@ try { const stillQueued=await queuedCheckins();assert.ok(stillQueued.length<=1);assert.ok(checkinMessages().length<=1); assert.ok(checkinMessages().length===1 || stillQueued.length===1); const checkedEvent=JSON.parse((await db.prepare("SELECT data FROM records WHERE kind='events' AND id=?").bind(ticketId).first()).data);assert.equal(checkedEvent.checkIns[ticketCode].userId,456); + const rateTicket=await currentTicket(),rateState=await ticketStored(); + rateState.ticketCheckAttempts={123:{minute:Math.floor(Date.parse(rateTicket.data.ticket.serverTime)/60000),failed:9}}; + await db.prepare("UPDATE records SET data=? WHERE kind='events' AND id=?").bind(JSON.stringify(rateState),ticketId).run(); + const wrongCode=rateTicket.data.ticket.code==='000000'?'999999':'000000'; + assert.equal((await api(`events/${ticketId}/ticket-check`,{code:wrongCode})).data.ticket.valid,false); + assert.equal((await ticketStored()).ticketCheckAttempts[123].failed,10,'Failed short-code attempts must persist in D1.'); + const throttledTicket=await api(`events/${ticketId}/ticket-check`,{code:rateTicket.data.ticket.code}); + assert.equal(throttledTicket.data.ticket.valid,false);assert.match(throttledTicket.data.ticket.reason,/Too many incorrect codes/); + assert.equal((await api(`events/${ticketId}/ticket-check`,{code:ticketCode})).data.ticket.valid,true,'Rate limiting must leave legacy QR/manual recovery usable.'); + assert.deepEqual((await ticketStored()).checkIns,checkedEvent.checkIns,'Expired and throttled OTP attempts cannot change attendance.'); await api(`events/${ticketId}/cancel`,{confirm:true});assert.equal((await api(`events/${ticketId}/ticket-check`,{code:ticketCode})).data.ticket.valid,false); const namedInput={...ticketInput,title:'Named workflow',invitationMode:'named',askParticipantCount:true,inviteMessage:'Join our club celebration!',guestNames:'Alex Smith = 3\nSam Jones',requestId:'77777777-7777-7777-7777-777777777777'}; const namedEvent=await api('events',namedInput);assert.equal(namedEvent.status,200);assert.equal(namedEvent.data.event.invitees.length,2);assert.equal(namedEvent.data.event.oneTimeInvite,true);assert.equal(namedEvent.data.event.requireApproval,false);assert.equal(namedEvent.data.event.askParticipantCount,false); @@ -533,13 +852,13 @@ try { const guestCohostInvite=await api(`events/${cohostId}/cohost/invite`,{version:cohostRevoked.data.event.cohostVersion,label:'Guest helper'});assert.equal(guestCohostInvite.status,200); await message(8007,'/start '+new URL(guestCohostInvite.data.event.cohostInviteUrl).searchParams.get('start'),902); const retainedGuest=(await cohostStored()).guests[902];assert.equal(retainedGuest.ticket,cohostGuest.ticket);assert.equal(retainedGuest.participants,2);assert.equal(retainedGuest.status,'yes'); - assert.equal((await api(`events/${cohostId}/ticket`,{},902)).data.ticket.code,cohostGuest.ticket); + assert.match((await api(`events/${cohostId}/ticket`,{},902)).data.ticket.code,/^\d{6}$/);assert.equal((await cohostStored()).guests[902].ticket,cohostGuest.ticket); assert.equal((await api(`events/${cohostId}`)).data.event.counts.participants,2); const guestCohostOwner=(await api(`events/${cohostId}`)).data.event; const guestCohostLinkId=guestCohostOwner.cohostLinks.find(entry=>entry.status==='active' && entry.cohost.id===902).id; assert.equal((await api(`events/${cohostId}/cohost/revoke`,{version:guestCohostOwner.cohostVersion,linkId:guestCohostLinkId})).status,200); const restoredGuest=(await api(`events/${cohostId}`,null,902)).data.event; - assert.equal(restoredGuest.isManager,false);assert.equal(restoredGuest.isOwner,false);assert.equal(restoredGuest.ticket.code,cohostGuest.ticket);assert.equal(restoredGuest.participants,2); + assert.equal(restoredGuest.isManager,false);assert.equal(restoredGuest.isOwner,false);assert.equal(restoredGuest.ticket.code,undefined);assert.equal((await cohostStored()).guests[902].ticket,cohostGuest.ticket);assert.equal(restoredGuest.participants,2); assert.equal((await api(`events/${cohostId}/gallery`,null,902)).status,403); const paidCohostInput={...cohostInput,title:'Paid co-host editing',invitationMode:'tickets',paymentMethod:'bank',displayPrice:'AUD $25',paymentInstructions:'Pay with your booking name as reference.',paymentTerms:'Contact the event owner about refunds.',requestId:'abababab-abab-abab-abab-abababababab'}; const paidCohostCreated=await api('events',paidCohostInput);assert.equal(paidCohostCreated.status,200); @@ -581,7 +900,7 @@ try { assert.deepEqual(legacyClaimed.cohosts.map(host=>host.id),[1001,1002]);assert.ok((await api('bootstrap',null,1002)).data.events.some(e=>e.id===legacyCohostId && e.isManager)); const legacyRevoked=await api(`events/${legacyCohostId}/cohost/revoke`,{version:legacyClaimed.cohostVersion,linkId:legacyOwner.cohostLinks[0].id});assert.equal(legacyRevoked.status,200); const legacyRestored=(await api(`events/${legacyCohostId}`,null,1001)).data.event; - assert.equal(legacyRestored.isManager,false);assert.equal(legacyRestored.ticket.code,'ABCDEF987654');assert.equal((await api(`events/${legacyCohostId}`,null,1002)).data.event.isManager,true); + assert.equal(legacyRestored.isManager,false);assert.equal(legacyRestored.ticket.code,undefined);assert.equal((await api(`events/${legacyCohostId}`,null,1002)).data.event.isManager,true); const canonicalLegacy=JSON.parse((await db.prepare("SELECT data FROM records WHERE kind='events' AND id=?").bind(legacyCohostId).first()).data); assert.deepEqual(canonicalLegacy.guests,legacyStored.guests); // A stale compatibility alias cannot resurrect access or leak a reusable event via bootstrap. @@ -785,6 +1104,130 @@ try { managedView=await invitationView(); assert.equal((await api(invitationPath+'/add',{...addition,requestId:'88812300-0000-4000-8000-000000000005',version:managedView.invitationsVersion},invitationOwner)).status,400); assert.equal((await api(invitationPath+'/remove',{token:managedView.invitees[0].token,notify:false,confirm:true,version:managedView.invitationsVersion},invitationOwner)).status,400); + // The compact invitation tools enforce permissions, optimistic concurrency and + // idempotency in the Worker, including the notification outbox and private audit. + const toolsOwner=898123,toolsGuest=898201,toolsOtherGuest=898202,toolsObserver=898203; + const toolsRequest=n=>'89812300-0000-4000-8000-'+String(n).padStart(12,'0'); + const toolsCreated=await api('events',{...managementInput,title:'Invitation tool integration',guestNames:'Editable = 2\nObserver\nUnopened = ?',requestId:toolsRequest(1)},toolsOwner); + assert.equal(toolsCreated.status,200,JSON.stringify(toolsCreated.data)); + const toolsId=toolsCreated.data.event.id,toolsPath=`events/${toolsId}/invitations`; + const toolsStored=async()=>JSON.parse((await db.prepare("SELECT data FROM records WHERE kind='events' AND id=?").bind(toolsId).first()).data); + const toolsView=async uid=>(await api(`events/${toolsId}`,null,uid??toolsOwner)).data.event; + const toolsInvite=toolsCreated.data.event.invitees.find(invite=>invite.name==='Editable'); + const toolsObserverInvite=toolsCreated.data.event.invitees.find(invite=>invite.name==='Observer'); + const toolsUnopenedInvite=toolsCreated.data.event.invitees.find(invite=>invite.name==='Unopened'); + let toolsUpdate=98000; + const toolsOpen=async(invite,uid)=>message(toolsUpdate++,'/start '+new URL(invite.url).searchParams.get('start'),uid); + await toolsOpen(toolsInvite,toolsGuest);await toolsOpen(toolsInvite,toolsOtherGuest);await toolsOpen(toolsObserverInvite,toolsObserver); + await callback(toolsUpdate++,`r:${toolsId}:yes:0`,toolsGuest); + let toolsCurrent=await toolsView(); + assert.equal(toolsCurrent.invitees.find(invite=>invite.token===toolsInvite.token).responses.length,2); + const trackedHistory=toolsCurrent.invitees.find(invite=>invite.token===toolsInvite.token).history; + assert.ok(trackedHistory.some(entry=>entry.type==='created')); + assert.equal(trackedHistory.filter(entry=>entry.type==='opened').length,2); + assert.ok(trackedHistory.some(entry=>entry.type==='responded' && entry.userId===toolsGuest && entry.status==='yes')); + const editTool={token:toolsInvite.token,version:toolsCurrent.invitationsVersion,requestId:toolsRequest(2),name:'Edited household',participants:3,participantMode:'preset'}; + const responseTool={token:toolsInvite.token,version:toolsCurrent.invitationsVersion,requestId:toolsRequest(3),userId:toolsGuest,status:'maybe',notify:false}; + const revokeTool={token:toolsInvite.token,version:toolsCurrent.invitationsVersion,requestId:toolsRequest(4),confirm:true,notify:false}; + const deleteTool={token:toolsInvite.token,version:toolsCurrent.invitationsVersion,requestId:toolsRequest(5),confirm:true}; + const beforeDeniedTools=await toolsStored(),beforeDeniedToolIds=new Set((await invitationOutbox()).map(row=>row.id)); + for(const [action,input] of [['edit',editTool],['response',responseTool],['revoke',revokeTool],['delete',deleteTool]])assert.equal((await api(toolsPath+'/'+action,input,toolsObserver)).status,400); + assert.deepEqual(await toolsStored(),beforeDeniedTools,'A guest cannot mutate invitation settings, responses or the private audit.'); + assert.equal((await invitationOutbox()).filter(row=>!beforeDeniedToolIds.has(row.id)).length,0); + const observerBefore=await toolsView(toolsObserver); + assert.equal(observerBefore.invitees,undefined);assert.equal(observerBefore.revokedInvitees,undefined);assert.equal(observerBefore.invitationHistory,undefined); + assert.equal((await api(toolsPath+'/delete',deleteTool,toolsOwner)).status,400,'Deleting an active invitation must require revocation first.'); + assert.equal((await api(toolsPath+'/edit',{...editTool,version:'0'.repeat(16)},toolsOwner)).status,400); + await db.prepare("INSERT INTO records(kind,id,data) VALUES ('sessions',?,?) ON CONFLICT(kind,id) DO UPDATE SET data=excluded.data").bind(String(toolsGuest),JSON.stringify({event:toolsId,step:'comment',response:{...(await toolsStored()).guests[toolsGuest]}})).run(); + const editedTool=await api(toolsPath+'/edit',editTool,toolsOwner); + assert.equal(editedTool.status,200,JSON.stringify(editedTool.data));assert.equal(editedTool.data.edited,true); + const editedInvite=editedTool.data.event.invitees.find(invite=>invite.token===toolsInvite.token); + assert.equal(editedInvite.url,toolsInvite.url);assert.equal(editedInvite.name,'Edited household');assert.equal(editedInvite.participants,3); + assert.equal(editedInvite.participantMode,null);assert.ok(editedInvite.history.some(entry=>entry.type==='edited')); + for(const uid of [toolsGuest,toolsOtherGuest])assert.equal((await toolsStored()).guests[uid].name,'Edited household'); + assert.equal((await toolsStored()).guests[toolsGuest].participants,3);assert.equal(await invitationSession(toolsGuest),null); + assert.equal((await api(toolsPath+'/edit',editTool,toolsOwner)).data.alreadyApplied,true); + toolsCurrent=await toolsView(); + assert.equal((await api(toolsPath+'/response',{...responseTool,version:toolsCurrent.invitationsVersion,userId:undefined},toolsOwner)).status,400,'Reusable links need an explicit guest response.'); + assert.equal((await api(toolsPath+'/response',{...responseTool,token:toolsUnopenedInvite.token,version:toolsCurrent.invitationsVersion,userId:899999},toolsOwner)).status,400,'Unopened links must not invent Telegram guests.'); + const silentResponse={...responseTool,version:toolsCurrent.invitationsVersion}; + const beforeSilentToolIds=new Set((await invitationOutbox()).map(row=>row.id)); + const silentlyChanged=await api(toolsPath+'/response',silentResponse,toolsOwner); + assert.equal(silentlyChanged.status,200,JSON.stringify(silentlyChanged.data));assert.equal(silentlyChanged.data.changed,true);assert.equal(silentlyChanged.data.notifyCount,0); + assert.equal((await toolsStored()).guests[toolsGuest].status,'maybe');assert.equal((await toolsStored()).guests[toolsOtherGuest].status,'later'); + assert.equal((await invitationOutbox()).filter(row=>!beforeSilentToolIds.has(row.id)).length,0,'Silent response changes must not enqueue any Telegram messages.'); + const silentToolReplay=await api(toolsPath+'/response',silentResponse,toolsOwner); + assert.equal(silentToolReplay.status,200);assert.equal(silentToolReplay.data.alreadyApplied,true);assert.equal(silentToolReplay.data.notifyCount,0); + toolsCurrent=await toolsView(); + const notifiedResponse={...responseTool,status:'no',notify:true,version:toolsCurrent.invitationsVersion,requestId:toolsRequest(6)}; + const beforeNotifiedToolIds=new Set((await invitationOutbox()).map(row=>row.id)); + const notifiedTool=await api(toolsPath+'/response',notifiedResponse,toolsOwner); + assert.equal(notifiedTool.status,200,JSON.stringify(notifiedTool.data));assert.equal(notifiedTool.data.notifyCount,1); + const notifiedToolRows=async()=>(await invitationOutbox()).filter(row=>!beforeNotifiedToolIds.has(row.id)); + assert.equal((await notifiedToolRows()).length,1);assert.equal((await notifiedToolRows())[0].params.chat_id,toolsGuest); + assert.match((await notifiedToolRows())[0].params.text,/organiser recorded.*Declined/); + const notifiedToolReplay=await api(toolsPath+'/response',notifiedResponse,toolsOwner); + assert.equal(notifiedToolReplay.status,200);assert.equal(notifiedToolReplay.data.alreadyApplied,true);assert.equal(notifiedToolReplay.data.notifyCount,0); + assert.equal((await notifiedToolRows()).length,1,'A repeated request must never enqueue the notification twice.'); + assert.equal((await api(toolsPath+'/response',{...notifiedResponse,requestId:toolsRequest(7)},toolsOwner)).status,400,'Fresh requests cannot reuse a stale version.'); + toolsCurrent=await toolsView(); + const acceptedTool=await api(toolsPath+'/response',{...responseTool,status:'yes',participants:3,version:toolsCurrent.invitationsVersion,requestId:toolsRequest(8)},toolsOwner); + assert.equal(acceptedTool.status,200,JSON.stringify(acceptedTool.data));assert.equal(acceptedTool.data.notifyCount,0); + const toolsTicket=(await toolsStored()).guests[toolsGuest].ticket; + assert.equal((await api(`events/${toolsId}/ticket-check`,{code:toolsTicket},toolsOwner)).data.ticket.valid,true); + // Private phone/payment fields remain in the archive without entering its public timeline. + const privateToolState=await toolsStored(); + Object.assign(privateToolState.guests[toolsGuest],{phone:'fictional-private-phone',payment:{status:'refunded',order:'fictional-refunded-order',amount:17}}); + await db.prepare("UPDATE records SET data=? WHERE kind='events' AND id=?").bind(JSON.stringify(privateToolState),toolsId).run(); + const toolsCheckedIn=await api(`events/${toolsId}/ticket-check`,{code:toolsTicket,checkIn:true},toolsOwner); + assert.equal(toolsCheckedIn.status,200);assert.equal(toolsCheckedIn.data.ticket.valid,true); + toolsCurrent=await toolsView(); + const beforeCheckedInChange=await toolsStored(),beforeCheckedInIds=new Set((await invitationOutbox()).map(row=>row.id)); + assert.equal((await api(toolsPath+'/response',{...responseTool,status:'no',notify:true,version:toolsCurrent.invitationsVersion,requestId:toolsRequest(10)},toolsOwner)).status,400); + assert.equal((await api(toolsPath+'/edit',{...editTool,participants:4,version:toolsCurrent.invitationsVersion,requestId:toolsRequest(11)},toolsOwner)).status,400); + assert.deepEqual(await toolsStored(),beforeCheckedInChange,'Check-in prevents RSVP or attendance changes.'); + assert.equal((await invitationOutbox()).filter(row=>!beforeCheckedInIds.has(row.id)).length,0); + toolsCurrent=await toolsView(); + const currentRevoke={...revokeTool,version:toolsCurrent.invitationsVersion}; + const beforeRevokeToolIds=new Set((await invitationOutbox()).map(row=>row.id)); + const revokedTool=await api(toolsPath+'/revoke',currentRevoke,toolsOwner); + assert.equal(revokedTool.status,200,JSON.stringify(revokedTool.data));assert.equal(revokedTool.data.revoked,true);assert.equal(revokedTool.data.notifyCount,0); + assert.equal(revokedTool.data.event.invitees.some(invite=>invite.token===toolsInvite.token),false); + const revokedToolDto=revokedTool.data.event.revokedInvitees.find(invite=>invite.token===toolsInvite.token); + assert.equal(revokedToolDto.url,null);assert.equal(revokedToolDto.canNotify,false);assert.equal(revokedToolDto.canChangeResponse,false); + assert.ok(revokedToolDto.history.some(entry=>entry.type==='revoked'));assert.doesNotMatch(JSON.stringify(revokedToolDto.history),/fictional-private-phone|fictional-refunded-order/); + assert.equal((await invitationOutbox()).filter(row=>!beforeRevokeToolIds.has(row.id)).length,0); + for(const uid of [toolsGuest,toolsOtherGuest]){ + assert.equal((await toolsStored()).guests[uid],undefined);assert.equal((await toolsStored()).reminders?.[uid],undefined); + assert.equal((await api(`events/${toolsId}`,null,uid)).status,403); + } + assert.equal((await api(`events/${toolsId}/ticket-check`,{code:toolsTicket},toolsOwner)).data.ticket.valid,false); + assert.equal((await api(`events/${toolsId}/ticket`,{},toolsGuest)).status,400); + await toolsOpen(toolsInvite,toolsGuest);assert.equal((await toolsStored()).guests[toolsGuest],undefined); + const observerAfter=await toolsView(toolsObserver);assert.equal(observerAfter.revokedInvitees,undefined);assert.equal(observerAfter.invitees,undefined); + assert.equal((await api(toolsPath+'/revoke',currentRevoke,toolsOwner)).data.alreadyApplied,true); + toolsCurrent=await toolsView(); + const currentDelete={...deleteTool,version:toolsCurrent.invitationsVersion}; + const deletedTool=await api(toolsPath+'/delete',currentDelete,toolsOwner); + assert.equal(deletedTool.status,200,JSON.stringify(deletedTool.data));assert.equal(deletedTool.data.deleted,true); + assert.equal(deletedTool.data.event.revokedInvitees.some(invite=>invite.token===toolsInvite.token),false); + const deletedToolState=await toolsStored(); + assert.ok(deletedToolState.removedInvitations[toolsInvite.token].deletedAt); + assert.equal(deletedToolState.removedInvitations[toolsInvite.token].responses[toolsGuest].phone,'fictional-private-phone'); + assert.equal(deletedToolState.removedInvitations[toolsInvite.token].responses[toolsGuest].payment.order,'fictional-refunded-order'); + assert.equal(deletedToolState.removedInvitations[toolsInvite.token].responses[toolsGuest].ticket,toolsTicket); + assert.equal(deletedToolState.checkIns[toolsTicket].userId,toolsGuest);assert.equal(deletedToolState.checkIns[toolsTicket].participants,3); + assert.equal(deletedToolState.invitationHistory[toolsInvite.token].filter(entry=>entry.type==='deleted').length,1); + assert.equal((await api(toolsPath+'/delete',currentDelete,toolsOwner)).data.alreadyApplied,true); + assert.equal((await toolsStored()).invitationHistory[toolsInvite.token].filter(entry=>entry.type==='deleted').length,1); + toolsCurrent=await toolsView(); + const notifiedRevoke={...revokeTool,token:toolsObserverInvite.token,notify:true,version:toolsCurrent.invitationsVersion,requestId:toolsRequest(9)}; + const beforeNotifyRevokeIds=new Set((await invitationOutbox()).map(row=>row.id)); + const notifiedRevocation=await api(toolsPath+'/revoke',notifiedRevoke,toolsOwner); + assert.equal(notifiedRevocation.status,200);assert.equal(notifiedRevocation.data.notifyCount,1); + assert.equal((await api(toolsPath+'/revoke',notifiedRevoke,toolsOwner)).data.alreadyApplied,true); + const notifyRevokeRows=(await invitationOutbox()).filter(row=>!beforeNotifyRevokeIds.has(row.id)); + assert.equal(notifyRevokeRows.length,1);assert.equal(notifyRevokeRows[0].params.chat_id,toolsObserver);assert.match(notifyRevokeRows[0].params.text,/revoked/); // Fixed named counts survive create/edit/add; guests cannot use stale or forged count controls. const fixedOwner=890123,fixedGuestId=890201,flexibleGuestId=890202; const fixedInput={...managementInput,title:'Fixed attendee invitations',guestNames:'Fixed Guest = 2*\nFlexible Guest = 2',requestId:'89012300-0000-4000-8000-000000000001'}; @@ -838,5 +1281,6 @@ try { assert.equal((await api(fixedPath,null,flexibleGuestId)).data.event.participants,3); assert.equal((await api(fixedPath,null,fixedOwner)).data.event.counts.participants,5); await db.prepare('DELETE FROM delivery_lease WHERE owner=?').bind(invitationDeliveryOwner).run(); - console.log('Worker integration passed: fixed personal attendee counts and live count button captions, invitation response summaries, add/revoke with notify or silent removal, exact durable notifications, native button event creation, profiles, private photos, ticket bookings, personal RSVP links, multiple tagged co-host links and isolated revocation, legacy co-host migration, approvals, payments, durable refunds and media. Telegram mocked.'); + console.log('Worker integration passed: compact invitation tools, private history and archives, edits, response changes, revocation/deletion and idempotent notifications; fixed personal attendee counts and live count button captions, invitation response summaries, add/revoke with notify or silent removal, exact durable notifications, native button event creation, profiles, private photos, ticket bookings, private expiring check-in codes and legacy QR compatibility, personal RSVP links, multiple tagged co-host links and isolated revocation, legacy co-host migration, approvals, payments, durable refunds and media. Telegram mocked.'); } finally { await mf.dispose(); } +await checkTestAccess(); diff --git a/scripts/deployment-config.mjs b/scripts/deployment-config.mjs index 908c17b..0a03c28 100644 --- a/scripts/deployment-config.mjs +++ b/scripts/deployment-config.mjs @@ -41,7 +41,12 @@ export async function buildConfig(privateConfig, environment) { return { ...base, name: privateConfig.name, - vars: { BOT_USERNAME: privateConfig.vars.BOT_USERNAME, APP_URL: privateConfig.vars.APP_URL, APP_ENV: environment }, + vars: { + BOT_USERNAME: privateConfig.vars.BOT_USERNAME, + APP_URL: privateConfig.vars.APP_URL, + APP_ENV: environment, + TEST_WHITELIST_ENABLED: environment === 'development' && String(privateConfig.vars.TEST_WHITELIST_ENABLED).trim() === 'true' ? 'true' : 'false' + }, d1_databases: [{ binding: 'DB', database_name: privateConfig.d1_databases[0].database_name, database_id: privateConfig.d1_databases[0].database_id, migrations_dir: 'migrations' }], triggers: { crons: privateConfig.triggers?.crons ?? (environment === 'development' ? [] : base.triggers.crons) } }; diff --git a/src/bot.js b/src/bot.js index d1637d6..345075b 100644 --- a/src/bot.js +++ b/src/bot.js @@ -2,12 +2,14 @@ import {paymentMethod,paidEvent} from './event-payment.js'; import {invitationMode,claimInvitation,invitationSettings,invitationParticipantMode,oneTimeInvites,invitationAvailable,consumeInvitation,reconcileInvitationClaims} from './invitations.js'; import {isManager,cohostEntries,cohostIds,cohostGuard,cohostLink,createCohostInvite,revokeCohost,claimCohost} from './cohosts.js'; import {invitationLinksCard,invitationCopyCard} from './invitation-links.js'; +import {appendInvitationHistory} from './invitation-history.js'; import {startChatCreation,chatCreationMessage,chatCreationCallback} from './chat-creation.js'; import {manualInstructions,manualReport,manualConfirm} from './manual-payment.js'; import { priceText } from './pricing.js'; +import { issueTicket } from './tickets.js'; import { randomBytes } from 'node:crypto'; import { invoice, checkout, successful, orderFor, requestRefund, refundResult } from './payments.js'; -import { eventTime } from './time.js'; +import { eventTime, InputError } from './time.js'; import { uploadLink, shareUploadLink,asksPhone,asksComments,requiresApproval,asksParticipantCount,hidesLocation } from './permissions.js'; import { upcoming, eventGroup, setReminder, reminderOptions, reminderLabel, applyDefaultReminder } from './reminders.js'; import { permissions, permissionLabels, can, guests, confirmed, canSeeLocation, responsesClosed, responseCounts, participantCount } from './permissions.js'; @@ -19,6 +21,7 @@ const paired = buttons => Array.from({ length: Math.ceil(buttons.length / 2) }, const name = u => [u.first_name, u.last_name].filter(Boolean).join(' ') || u.username || 'Guest'; const clean = (s, max = 1000) => typeof s === 'string' ? s.trim().slice(0, max) : ''; const changeCountLabel = count => `${count} ${count===1?'person':'people'} · Change`; +const hasRecordedResponse = guest => typeof guest?.responseRecorded==='boolean' ? guest.responseRecorded : guest?.responseVersion>0 || ['yes','no','maybe'].includes(guest?.status); const menu = { new: '🎉 Create event', events: '📅 My events', help: '❓ Help', home: '🏠 Main menu', cancel: '✖️ Cancel input', skip: '⏭ Skip', done: '✅ Finish uploads', name: '👤 Use Telegram name', app: 'App', picker: '🗓 Pick date & time', pending: '⏳ Pending invitations' }; const reply = (...rows) => ({ keyboard: rows.map(row => row.map(text => typeof text === 'string' ? { text } : text)), resize_keyboard: true, is_persistent: true }); // The authenticated App launcher lives in Telegram's built-in chat menu. @@ -72,7 +75,9 @@ export class Bot { if(s.countOnly){ if(s.response.participants===participantCount(e,e.guests[id])){this.session(id);return this.card(id,e);} if(s.response.status==='yes')return this.saveResponse(id,e,s.response); - e.guests[id]={...e.guests[id],participants:s.response.participants,responseVersion:(e.guests[id].responseVersion || 0)+1}; + const previous=e.guests[id]; + e.guests[id]={...previous,participants:s.response.participants,responseVersion:(previous.responseVersion || 0)+1}; + appendInvitationHistory(e,previous.invitationToken,hasRecordedResponse(previous)?'changed':'edited',{actorRole:'guest',actorId:id,userId:id,name:previous.name,...(hasRecordedResponse(previous)?{status:previous.status,previousStatus:previous.status}:{}),participants:s.response.participants,previousParticipants:participantCount(e,previous)}); this.session(id);return this.card(id,e); } return this.afterIdentity(id,e,s); @@ -124,11 +129,17 @@ export class Bot { await this.home(id, events.length ? '⏳ Invitations you haven’t responded to yet. Tap an event to respond.' : 'You have no unanswered invitations.'); for (const e of events) await this.send(id, `${e.title}\n${this.time(e, id)}${responsesClosed(e) ? '\nResponses closed — deadline passed' : ''}`, keyboard([button('Open invitation', `v:${e.id}`)])); } - async ticket(id, e) { + async ticket(id, e, requested = false) { const g = e.guests[id]; if (!g || !confirmed(e, g) || e.owner === id || e.cancelled) return this.send(id, 'Your invitation details will be available after your response is approved.'); g.ticket ||= randomBytes(6).toString('hex').toUpperCase(); - return this.long(id, `🎟 ${invitationMode(e)==='tickets' ? 'YOUR TICKET' : 'YOUR INVITATION'}\n\n${e.title}\nGuest: ${g.name}\nPeople: ${participantCount(e, g)}\nTicket: ${g.ticket}\n\n🗓 ${this.time(e, id)}\n${priceText(e,this.db.preferences[id],this.pricing || this.db.preferences._pricing)}\n📍 ${e.location || 'Location to follow'}${e.ticketInfo ? '\n\n' + e.ticketInfo : ''}\n\n✅ Your place is confirmed.`, keyboard(...(this.appUrl && e.qrEnabled!==false?[[this.miniButton('🎟 Ticket QR',`?ticket=${e.id}`)]]:[]),[button('Back to event', `v:${e.id}`)])); + let codeText=''; + if(!this.appUrl && requested){ + try{codeText=`\nCheck-in code: ${issueTicket(e,id).code}\nValid this minute. Request your ticket again to refresh.`;} + catch(error){if(!(error instanceof InputError))throw error;return this.send(id,error.message,keyboard([button('Back to event',`v:${e.id}`)]));} + } + const codeButton=this.appUrl ? this.miniButton('🎟 Check-in code',`?ticket=${e.id}`) : button('🎟 Check-in code',`ticket:${e.id}`); + return this.long(id, `🎟 ${invitationMode(e)==='tickets' ? 'YOUR TICKET' : 'YOUR INVITATION'}\n\n${e.title}\nGuest: ${g.name}\nPeople: ${participantCount(e, g)}${codeText}\n\n🗓 ${this.time(e, id)}\n${priceText(e,this.db.preferences[id],this.pricing || this.db.preferences._pricing)}\n📍 ${e.location || 'Location to follow'}${e.ticketInfo ? '\n\n' + e.ticketInfo : ''}\n\n✅ Your place is confirmed.`, keyboard([codeButton],[button('Back to event', `v:${e.id}`)])); } paymentInfo(id,e){return paymentMethod(e)==='stars'?invoice(this,id,e):manualInstructions(this,id,e);} async beginAcceptance(id,e,from){ @@ -190,17 +201,19 @@ export class Bot { if(e.guests[id]?.payment?.status==='reported')return this.send(id,'The organiser is reviewing your payment. Ask them to resolve or clear the report before changing your response.'); if (['paid','processing','refund_pending','refund_failed'].includes(e.guests[id]?.payment?.status)) return this.send(id,'Your paid or processing booking cannot be changed. Contact /paysupport to request a refund first.'); if (responsesClosed(e)) { this.session(id); await this.home(id, 'The response deadline has passed. Your unfinished response was not saved.'); return this.card(id, e); } - const previous=e.guests[id]; + const previous=e.guests[id],hadResponse=hasRecordedResponse(previous); if(e.checkIns?.[previous?.ticket]){this.session(id);return this.send(id,'You are already checked in. Contact the organiser to change your invitation.',keyboard([button('Back to event',`v:${e.id}`)]));} if(previous?.status===response.status && response.status==='yes' && previous.approval==='approved' && JSON.stringify(previous.answers || [])===JSON.stringify(response.answers || []) && previous.name===response.name && previous.phone===response.phone && previous.comment===response.comment && participantCount(e,previous)===participantCount(e,response)){this.session(id);return this.card(id,e);} consumeInvitation(e,id,response,this.db.sessions); response.responseVersion=(previous?.responseVersion || 0)+1; + response.responseRecorded=true; if (response.status === 'yes') { response.participants = participantCount(e, response); response.approval = requiresApproval(e) ? 'pending' : 'approved'; if (requiresApproval(e) || paidEvent(e)) delete response.ticket; else response.ticket = randomBytes(6).toString('hex').toUpperCase(); } else { delete response.approval; delete response.ticket; } e.guests[id] = response; this.session(id); + appendInvitationHistory(e,response.invitationToken,hadResponse?'changed':'responded',{actorRole:'guest',actorId:id,userId:id,name:response.name,status:response.status,...(hadResponse?{previousStatus:previous.status,previousParticipants:participantCount(e,previous)}:{}),participants:participantCount(e,response)}); if (response.status === 'yes') applyDefaultReminder(e, id); await this.home(id, invitationMode(e)==='tickets' ? requiresApproval(e) ? 'Your ticket request is saved. The organiser will send your ticket after approval and any required payment.' : paidEvent(e) ? 'Booking saved. Complete payment to receive your ticket.' : 'Your ticket is booked.' : response.status === 'yes' && requiresApproval(e) ? '✅ Your acceptance request is saved. The organiser will send your invitation details and ticket after approving your response.' : paidEvent(e) && response.status === 'yes' ? 'Acceptance saved. Complete payment to confirm your ticket.' : '✅ Response saved.'); await this.notifyManagers(e, `${e.title}\n${response.name}: ${response.approval === 'pending' ? '⏳ Awaiting approval' : labels[response.status]}${response.status === 'yes' ? '\nPeople: ' + participantCount(e, response) : ''}${response.comment ? '\nComment: ' + response.comment : ''}`, response.approval === 'pending' ? keyboard([button('✅ Approve', `approve:${e.id}:${id}:${response.responseVersion || 0}`), button('❌ Reject', `reject:${e.id}:${id}:${response.responseVersion || 0}`)]) : undefined); @@ -241,7 +254,7 @@ export class Bot { if (can(e, id, 'uploadMedia')) extras.push(button('📎 Add media', `u:${e.id}`)); if (this.appUrl && can(e, id, 'viewMedia')) extras.push(this.miniButton('🗂 Shared media', `?gallery=${e.id}`)); if(this.appUrl && host)extras.push(this.miniButton(e.qrEnabled!==false?'Scan tickets':'Check tickets',`?checkin=${e.id}`)); - if(this.appUrl && e.qrEnabled!==false && !host && confirmed(e,e.guests[id]))extras.push(this.miniButton('🎟 Ticket QR',`?ticket=${e.id}`)); + if(this.appUrl && !host && confirmed(e,e.guests[id]))extras.push(this.miniButton('🎟 Check-in code',`?ticket=${e.id}`)); if (upcoming(e)) extras.push(button('🔔 Reminder', `reminder:${e.id}`)); if (canSeeLocation(e, id) && e.location) extras.push(e.location.length <= 256 ? { text: '📋 Copy address', copy_text: { text: e.location } } : button('📋 Copy address', `address:${e.id}`)); if (host && shareUploadLink(e,this.username)) extras.push(this.appUrl && e.qrEnabled!==false ? this.miniButton('Upload QR code',`?qr=${e.id}`) : {text:'Share upload link',url:`https://t.me/share/url?url=${encodeURIComponent(shareUploadLink(e,this.username))}`}); @@ -619,11 +632,13 @@ export class Bot { await this.clearButtons(id,q.message); if(version!==undefined && version!==String(e.guests[id]?.responseVersion || 0))return this.send(id,'This RSVP message is out of date. Open the event in My events for your current response.',keyboard([button('Open event',`v:${e.id}`)])); if (responsesClosed(e)) return this.card(id, e); - if(arg===e.guests[id]?.status && !(this.db.sessions[id]?.step==='responseChoice' && this.db.sessions[id]?.event===eid))return arg==='later' ? this.pendingInvitations(id) : this.card(id,e); + if(arg===e.guests[id]?.status && !(this.db.sessions[id]?.step==='responseChoice' && this.db.sessions[id]?.event===eid) && !(arg==='later' && !hasRecordedResponse(e.guests[id])))return arg==='later' ? this.pendingInvitations(id) : this.card(id,e); if (arg === 'later') { if(e.checkIns?.[e.guests[id]?.ticket]){this.session(id);return this.send(id,'You are already checked in. Contact the organiser to change your invitation.');} + const previous=e.guests[id],hadResponse=hasRecordedResponse(previous); consumeInvitation(e,id,{...e.guests[id],status:'later'},this.db.sessions); - e.guests[id] = { ...e.guests[id], status: 'later',responseVersion:(e.guests[id]?.responseVersion || 0)+1 }; delete e.guests[id].approval; delete e.guests[id].ticket; this.session(id); + e.guests[id] = { ...e.guests[id], status: 'later',responseRecorded:true,responseVersion:(e.guests[id]?.responseVersion || 0)+1 }; delete e.guests[id].approval; delete e.guests[id].ticket; this.session(id); + appendInvitationHistory(e,previous.invitationToken,hadResponse?'changed':'responded',{actorRole:'guest',actorId:id,userId:id,name:previous.name,status:'later',...(hadResponse?{previousStatus:previous.status}:{}),participants:participantCount(e,e.guests[id])}); return this.pendingInvitations(id); } if(arg==='yes' && invitationMode(e)==='named')return this.beginAcceptance(id,e,q.from); @@ -643,7 +658,7 @@ export class Bot { if (action === 'c') { return this.send(id, isManager(e,id) ? 'You’re an event host — no RSVP needed.' : 'Choose an RSVP to add or update your comment.'); } - if (action === 'ticket') return this.ticket(id, e); + if (action === 'ticket') return this.ticket(id, e, true); if (action === 'approve' || action === 'reject') { const guestId = Number(arg); const guest = e.guests[guestId]; await this.clearButtons(id,q.message); diff --git a/src/invitation-history.js b/src/invitation-history.js new file mode 100644 index 0000000..b2475e2 --- /dev/null +++ b/src/invitation-history.js @@ -0,0 +1,36 @@ +const types=new Set(['created','opened','responded','changed','edited','revoked','deleted']); +const statuses=new Set(['yes','no','maybe','later']); +const modes=new Set(['default','preset','ask','confirm','fixed']); +const validId=id=>Number.isSafeInteger(id) && id>0; +const validCount=count=>Number.isSafeInteger(count) && count>=1 && count<=10; +const validDate=value=>typeof value==='string' && Number.isFinite(Date.parse(value)); + +function safeDetails(details) { + const result={}; + for(const key of ['actorId','userId'])if(validId(details[key]))result[key]=details[key]; + if(!result.actorId && validId(details.actor))result.actorId=details.actor; + if(['guest','organiser'].includes(details.actorRole))result.actorRole=details.actorRole; + for(const key of ['status','previousStatus'])if(statuses.has(details[key]))result[key]=details[key]; + for(const key of ['participants','previousParticipants'])if(validCount(details[key]))result[key]=details[key]; + for(const key of ['name','previousName'])if(typeof details[key]==='string')result[key]=details[key].slice(0,100); + for(const key of ['participantMode','previousParticipantMode'])if(modes.has(details[key]))result[key]=details[key]; + if(typeof details.notify==='boolean')result.notify=details.notify; + return result; +} + +// Audit entries contain no phone numbers, comments, payment details, or invitation +// links. Legacy invitations keep an empty timeline until a real action is recorded. +export function appendInvitationHistory(e,token,type,details={},now=Date.now()) { + if(!e || !/^[a-f0-9]{32}$/.test(token || '') || !types.has(type))return; + const at=new Date(now); + if(!Number.isFinite(at.getTime()))return; + e.invitationHistory ||= {}; + const history=Array.isArray(e.invitationHistory[token]) ? e.invitationHistory[token] : []; + e.invitationHistory[token]=history; + history.push({type,at:at.toISOString(),...safeDetails(details)}); +} + +export function invitationHistory(e,token) { + const history=e?.invitationHistory?.[token]; + return Array.isArray(history) ? history.filter(entry=>types.has(entry?.type) && validDate(entry.at)).map(entry=>({type:entry.type,at:entry.at,...safeDetails(entry)})) : []; +} diff --git a/src/invitation-management.js b/src/invitation-management.js index 9d68d90..d618305 100644 --- a/src/invitation-management.js +++ b/src/invitation-management.js @@ -1,25 +1,49 @@ import {createHash} from 'node:crypto'; import {InputError} from './time.js'; import {isManager} from './cohosts.js'; -import {invitationMode,invitationSettings,namedLink} from './invitations.js'; -import {confirmed,participantCount} from './permissions.js'; +import {invitationMode,invitationSettings,namedLink,consumeInvitation,invitationAvailable} from './invitations.js'; +import {confirmed,participantCount,requiresApproval} from './permissions.js'; +import {paidEvent} from './event-payment.js'; +import {applyDefaultReminder} from './reminders.js'; +import {ensureTicket} from './tickets.js'; +import {appendInvitationHistory,invitationHistory} from './invitation-history.js'; const digest=value=>createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0,16); const validToken=token=>typeof token==='string' && /^[a-f0-9]{32}$/.test(token); const validRequest=id=>typeof id==='string' && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(id); +const validCount=count=>Number.isSafeInteger(count) && count>=1 && count<=10; +const protectedPayment=g=>['reported','paid','processing','refund_pending','refund_failed'].includes(g.payment?.status); const linkedGuests=(e,token)=>Object.entries(e.guests || {}).filter(([uid,g])=>Number(uid)!==e.owner && g.invitationToken===token); const guestLine=g=>`${g.name}${g.participantMode==='ask' ? ' = ?' : g.participants ? ` = ${g.participants}${g.participantMode==='confirm'?'!':g.participantMode==='fixed'?'*':''}`:''}`; +export const hasRecordedResponse=(guest,history,id)=>typeof guest.responseRecorded==='boolean' ? guest.responseRecorded : !!guest.responseVersion || ['yes','no','maybe'].includes(guest.status) || history.some(entry=>['responded','changed'].includes(entry.type) && entry.userId===id); // Includes responses and payment/check-in changes so a stale removal cannot silently // remove a different response from the one the organiser reviewed. -export const invitationsVersion=e=>digest([e?.invitees || {},e?.guests || {},e?.checkIns || {}]); +export const invitationsVersion=e=>digest([e?.invitees || {},e?.guests || {},e?.checkIns || {},e?.removedInvitations || {},e?.invitationHistory || {},e?.oneTimeInvite,e?.cancelled,e?.endsAt,e?.responseDeadline,e?.paymentMethod,e?.starPrice,e?.starPricing]); + +function invitationDto(e,token,g,username,entries,revoked=false) { + const history=invitationHistory(e,token); + const responses=entries.map(([uid,guest])=>{ + const id=Number(uid); + const latest=history.filter(entry=>['responded','changed'].includes(entry.type) && entry.userId===id).reduce((last,entry)=>!last || Date.parse(entry.at)>=Date.parse(last.at)?entry:last,null); + const responded=hasRecordedResponse(guest,history,id); + return {id,name:guest.name,status:guest.status,responded,respondedAt:responded?latest?.at || null:null,participants:guest.status==='yes'?participantCount(e,guest):0,selectedParticipants:participantCount(e,guest),comment:guest.comment || '',confirmed:!revoked && confirmed(e,guest),paymentStatus:guest.payment?.status || null}; + }); + const statuses=[...new Set(responses.map(response=>response.status))]; + const responseCounts=Object.fromEntries(['yes','no','maybe','later'].map(status=>[status,responses.filter(response=>response.status===status).length])); + return {token,name:g.name,participants:g.participants || null,participantMode:g.participantMode || null,claimed:!!g.respondedBy || responses.some(response=>['yes','no','maybe'].includes(response.status)),status:revoked?'revoked':statuses.length>1?'mixed':statuses[0] || null,url:revoked?null:namedLink(e,token,username),responses,responseCounts,canNotify:!revoked && responses.length>0,canChangeResponse:!revoked && responses.length>0,hasPayments:entries.some(([,guest])=>protectedPayment(guest)),history,historyIncomplete:!history.some(entry=>entry.type==='created'),...(revoked?{revoked:true,revokedAt:e.removedInvitations[token].revokedAt}:{})}; +} export function managedInvitations(e,username) { - return Object.entries(e.invitees || {}).map(([token,g])=>{ - const responses=linkedGuests(e,token).map(([uid,guest])=>({id:Number(uid),name:guest.name,status:guest.status,participants:guest.status==='yes'?participantCount(e,guest):0,comment:guest.comment || '',confirmed:confirmed(e,guest),paymentStatus:guest.payment?.status || null})); - const statuses=[...new Set(responses.map(response=>response.status))]; - const responseCounts=Object.fromEntries(['yes','no','maybe','later'].map(status=>[status,responses.filter(response=>response.status===status).length])); - return {token,name:g.name,participants:g.participants || null,participantMode:g.participantMode || null,claimed:!!g.respondedBy || responses.some(response=>['yes','no','maybe'].includes(response.status)),status:statuses.length>1?'mixed':statuses[0] || null,url:namedLink(e,token,username),responses,responseCounts,canNotify:responses.length>0,hasPayments:responses.some(response=>['reported','paid','processing','refund_pending','refund_failed'].includes(response.paymentStatus))}; + return Object.entries(e.invitees || {}).map(([token,g])=>invitationDto(e,token,g,username,linkedGuests(e,token))); +} + +export function revokedInvitations(e,username) { + return Object.entries(e.removedInvitations || {}).filter(([,archive])=>archive.revokedAt && !archive.deletedAt && archive.invite).map(([token,archive])=>{ + // Fixed-count rules are stored in the archived invitation, not the now absent + // live link, so use that rule only to project the historical response summary. + const snapshot={...e,invitees:{[token]:archive.invite}}; + return invitationDto(snapshot,token,archive.invite,username,Object.entries(archive.responses || {}),true); }); } @@ -45,14 +69,153 @@ export function addInvitations(e,actor,input,now=Date.now()) { currentVersion(e,input.version); const before=Object.keys(e.invitees || {}).length; const all=[...Object.values(e.invitees || {}).map(guestLine),guestNames].join('\n'); - const next=invitationSettings({guestNames:all},e); + const next=invitationSettings({guestNames:all},e,now,actor); const added=Object.keys(next.invitees).length-before; + const newTokens=Object.keys(next.invitees).filter(token=>!e.invitees?.[token]); Object.assign(e,next); + for(const token of newTokens){ + e.invitees[token].createdAt ||= new Date(now).toISOString(); + if(!invitationHistory(e,token).some(entry=>entry.type==='created'))appendInvitationHistory(e,token,'created',{actorId:actor,actorRole:'organiser',name:e.invitees[token].name},now); + } e.invitationAddRequests ||= {}; e.invitationAddRequests[input.requestId]={actor,guestNames,at:new Date(now).toISOString()}; return {added}; } +function mutationRequest(e,actor,input,action) { + if(!validRequest(input.requestId))throw new InputError('Refresh the invitation list and try again.'); + const {version,requestId,...fields}=input; + const fingerprint=digest([action,Object.fromEntries(Object.entries(fields).sort(([a],[b])=>a.localeCompare(b)))]); + const prior=e.invitationManagementRequests?.[requestId]; + if(prior){ + if(prior.actor!==actor || prior.fingerprint!==fingerprint)throw new InputError('This request has already been used. Refresh and try again.'); + return {fingerprint,retry:{...prior.result,alreadyApplied:true,recipients:[],notifyCount:0}}; + } + currentVersion(e,version); + return {fingerprint}; +} +function completeMutation(e,actor,input,fingerprint,result,now) { + e.invitationManagementRequests ||= {}; + const {recipients,...saved}=result; + e.invitationManagementRequests[input.requestId]={actor,fingerprint,result:saved,at:new Date(now).toISOString()}; + return result; +} +function activeInvite(e,token) { + if(!validToken(token))throw new InputError('Choose an invitation to manage.'); + const invite=e.invitees?.[token]; + if(!invite)throw new InputError('This invitation is no longer available. Refresh the list.'); + return invite; +} +function clearInvitationSessions(e,token,recipients,sessions) { + for(const [uid,session] of Object.entries(sessions))if(Number(uid)!==e.owner && session.event===e.id && (recipients.includes(Number(uid)) || session.response?.invitationToken===token))delete sessions[uid]; +} + +export function editInvitation(e,actor,input,sessions={},now=Date.now()) { + managerOnly(e,actor,now); + const request=mutationRequest(e,actor,input,'edit');if(request.retry)return request.retry; + const invite=activeInvite(e,input.token); + if(typeof input.name!=='string' || !input.name.trim() || input.name.trim().length>100 || /[\n\r=]/.test(input.name))throw new InputError('Use a guest name of 1–100 characters, without line breaks or =.'); + const name=input.name.trim(),mode=input.participantMode; + if(Object.entries(e.invitees).some(([token,g])=>token!==input.token && g.name===name))throw new InputError('That guest name is already used. Add a label to distinguish them.'); + if(!['default','preset','ask','confirm','fixed'].includes(mode))throw new InputError('Choose how the guest sets their attendee count.'); + const participants=input.participants; + if(['default','ask'].includes(mode) ? participants!==null : !validCount(participants))throw new InputError('Choose 1–10 people, or no preset for Ask guest and One person.'); + const previousMode=invite.participantMode || (invite.participants?'preset':'default'); + const attendanceChanged=previousMode!==mode || (invite.participants || null)!==participants; + const responses=linkedGuests(e,input.token); + if(attendanceChanged && responses.some(([,g])=>protectedPayment(g) || e.checkIns?.[g.ticket]))throw new InputError('Refund active payments or resolve check-in before changing attendee settings. You can still edit the guest name.'); + const previousName=invite.name,previousParticipants=invite.participants; + invite.name=name; + if(participants===null)delete invite.participants;else invite.participants=participants; + if(['ask','confirm','fixed'].includes(mode))invite.participantMode=mode;else delete invite.participantMode; + for(const [uid,g] of responses){ + g.name=name; + if(attendanceChanged){ + g.participants=participants ?? (mode==='ask' && validCount(g.participants)?g.participants:1); + // An organiser editing an unopened RSVP's attendee settings does not turn + // the automatic Later placeholder into a response from that guest. + if(hasRecordedResponse(g,invitationHistory(e,input.token),Number(uid)))g.responseVersion=(g.responseVersion || 0)+1; + } + } + if(attendanceChanged || previousName!==name){ + clearInvitationSessions(e,input.token,responses.map(([uid])=>Number(uid)),sessions); + appendInvitationHistory(e,input.token,'edited',{actorId:actor,actorRole:'organiser',name,previousName,participants,previousParticipants,participantMode:mode,previousParticipantMode:previousMode},now); + } + return completeMutation(e,actor,input,request.fingerprint,{edited:true,recipients:[]},now); +} + +export function changeInvitationResponse(e,actor,input,sessions={},now=Date.now()) { + managerOnly(e,actor,now); + const request=mutationRequest(e,actor,input,'response');if(request.retry)return request.retry; + const invite=activeInvite(e,input.token); + if(typeof input.notify!=='boolean')throw new InputError('Choose whether to notify the guest.'); + if(!['yes','no','maybe','later'].includes(input.status))throw new InputError('Choose Accept, Decline, Maybe, or Respond later.'); + const responses=linkedGuests(e,input.token); + if(!responses.length)throw new InputError('The guest must open their invitation before you can record a response.'); + if(!Number.isSafeInteger(input.userId) || input.userId<=0)throw new InputError('Choose the guest response to change.'); + const entry=responses.find(([uid])=>Number(uid)===input.userId); + if(!entry)throw new InputError('That guest response is no longer available. Refresh the list.'); + const [uid,guest]=entry; + if(!invitationAvailable(e,input.userId,input.token))throw new InputError('This invitation belongs to another guest.'); + if(input.participants!==undefined && !validCount(input.participants))throw new InputError('Choose 1–10 people.'); + if(invite.participantMode==='fixed' && input.participants!==undefined && input.participants!==invite.participants)throw new InputError('This invitation has a fixed attendee count. Edit the invitation settings first.'); + if(!invite.participants && !invite.participantMode && input.participants!==undefined && input.participants!==1)throw new InputError('This invitation is for one person. Edit its attendee settings first.'); + const previousStatus=guest.status,previousParticipants=participantCount(e,guest); + const response={...guest,status:input.status}; + if(input.participants!==undefined)response.participants=input.participants; + const participants=participantCount(e,response); + const recorded=hasRecordedResponse(guest,invitationHistory(e,input.token),input.userId); + const changed=previousStatus!==input.status || input.status==='yes' && participants!==previousParticipants || input.status==='later' && !recorded; + if(changed && protectedPayment(guest))throw new InputError('Resolve or refund the active payment before changing this response.'); + if(changed && e.checkIns?.[guest.ticket])throw new InputError('This guest is already checked in. Their response cannot be changed.'); + if(!changed)return completeMutation(e,actor,input,request.fingerprint,{changed:false,notifyCount:0,recipients:[]},now); + consumeInvitation(e,input.userId,response,sessions); + response.responseVersion=(guest.responseVersion || 0)+1; + response.responseRecorded=true; + if(response.status==='yes'){ + response.participants=participants; + response.approval=requiresApproval(e)?'pending':'approved'; + if(paidEvent(e) || requiresApproval(e))delete response.ticket; + }else{ + delete response.approval;delete response.ticket; + if(e.reminders)delete e.reminders[uid]; + } + e.guests[uid]=response; + if(sessions[uid]?.event===e.id)delete sessions[uid]; + if(confirmed(e,response)){ensureTicket(e,input.userId);applyDefaultReminder(e,input.userId,now);} + appendInvitationHistory(e,input.token,recorded?'changed':'responded',{actorId:actor,actorRole:'organiser',userId:input.userId,name:response.name,status:response.status,previousStatus,participants:response.status==='yes'?participants:undefined,previousParticipants:previousStatus==='yes'?previousParticipants:undefined,notify:input.notify},now); + return completeMutation(e,actor,input,request.fingerprint,{changed:true,status:response.status,notifyCount:input.notify?1:0,recipients:input.notify?[input.userId]:[]},now); +} + +export function revokeInvitation(e,actor,input,sessions={},now=Date.now()) { + managerOnly(e,actor,now); + const request=mutationRequest(e,actor,input,'revoke');if(request.retry)return request.retry; + if(input.confirm!==true)throw new InputError('Confirm revoking this invitation first.'); + if(typeof input.notify!=='boolean')throw new InputError('Choose whether to notify the guest.'); + const invite=activeInvite(e,input.token); + const responses=linkedGuests(e,input.token),recipients=responses.map(([uid])=>Number(uid)); + const at=new Date(now).toISOString(); + e.removedInvitations ||= {}; + e.removedInvitations[input.token]={invite:structuredClone(invite),responses:structuredClone(Object.fromEntries(responses)),removedBy:actor,removedAt:at,revokedBy:actor,revokedAt:at,notify:input.notify}; + delete e.invitees[input.token]; + for(const [uid] of responses){delete e.guests[uid];if(e.reminders)delete e.reminders[uid];} + clearInvitationSessions(e,input.token,recipients,sessions); + appendInvitationHistory(e,input.token,'revoked',{actorId:actor,actorRole:'organiser',name:invite.name,notify:input.notify},now); + return completeMutation(e,actor,input,request.fingerprint,{revoked:true,notifyCount:input.notify?recipients.length:0,recipients:input.notify?recipients:[]},now); +} + +export function deleteInvitation(e,actor,input,now=Date.now()) { + managerOnly(e,actor,now); + const request=mutationRequest(e,actor,input,'delete');if(request.retry)return request.retry; + if(input.confirm!==true)throw new InputError('Confirm deleting this revoked invitation first.'); + if(!validToken(input.token))throw new InputError('Choose an invitation to delete.'); + const archive=e.removedInvitations?.[input.token]; + if(e.invitees?.[input.token] || !archive?.revokedAt || archive.deletedAt)throw new InputError('Revoke the invitation before deleting it from this list.'); + archive.deletedAt=new Date(now).toISOString();archive.deletedBy=actor; + appendInvitationHistory(e,input.token,'deleted',{actorId:actor,actorRole:'organiser',name:archive.invite.name},now); + return completeMutation(e,actor,input,request.fingerprint,{deleted:true,recipients:[]},now); +} + export function removeInvitation(e,actor,input,sessions={},now=Date.now()) { managerOnly(e,actor,now); if(input.confirm!==true)throw new InputError('Confirm removing this invitation first.'); diff --git a/src/invitations.js b/src/invitations.js index 8ac7502..6016698 100644 --- a/src/invitations.js +++ b/src/invitations.js @@ -1,6 +1,7 @@ import {randomBytes} from 'node:crypto'; import {InputError} from './time.js'; import {responsesClosed,invitationParticipants,invitationParticipantMode} from './permissions.js'; +import {appendInvitationHistory} from './invitation-history.js'; export {invitationParticipants,invitationParticipantMode} from './permissions.js'; export const invitationMode=e=>e.invitationMode || 'legacy'; @@ -59,7 +60,7 @@ export function reconcileInvitationClaims(e,sessions={}) { if(oneTimeInvites(e))for(const [uid,guest] of Object.entries(e.guests || {}))if(Number(uid)!==e.owner && finalResponse(guest) && (invitationMode(e)!=='named' || e.invitees?.[guest.invitationToken]))consumeInvitation(e,Number(uid),guest,sessions); } export const namedLink=(e,token,username)=>`https://t.me/${username}?start=i_${e.id}_${token}`; -export function invitationSettings(input,e={}) { +export function invitationSettings(input,e={},now=Date.now(),actorId=e.owner) { const mode=input.invitationMode ?? invitationMode(e); if(!['tickets','named','legacy'].includes(mode))throw new InputError('Choose ticket booking or named invitations.'); if(e.id && mode!==invitationMode(e) && (Object.keys(e.guests || {}).length || Object.keys(e.invitees || {}).length))throw new InputError('Create a new event to change the invitation mode after links or responses exist.'); @@ -80,8 +81,15 @@ export function invitationSettings(input,e={}) { const emptyExisting=!!e.id && invitationMode(e)==='named' && !Object.keys(e.invitees || {}).length; if(!names.length && !emptyExisting || names.length>100 || names.some(n=>n.length>100) || new Set(names).size!==names.length)throw new InputError('Use 1–100 unique guest names, up to 100 characters each. Add a label to distinguish guests with the same name.'); const previous=Object.entries(e.invitees || {}),invitees={}; + const historyEvent={invitationHistory:structuredClone(e.invitationHistory || {})}; + const removedInvitations=structuredClone(e.removedInvitations || {}); const opened=token=>Object.values(e.guests || {}).some(g=>g.invitationToken===token); for(const [token,g] of previous)if(!names.includes(g.name) && (g.claimedBy || opened(token)))throw new InputError('A claimed invitation cannot be removed from the guest list.'); + for(const [token,invite] of previous)if(!names.includes(invite.name)){ + const at=new Date(now).toISOString(); + removedInvitations[token]={invite:structuredClone(invite),responses:{},removedBy:actorId,removedAt:at,revokedBy:actorId,revokedAt:at,notify:false}; + appendInvitationHistory(historyEvent,token,'revoked',{actorRole:'organiser',actorId,name:invite.name,notify:false},now); + } for(const entry of entries){ const existing=previous.find(([,g])=>g.name===entry.name),token=existing?.[0] || randomBytes(16).toString('hex'); const unchanged=existing && existing[1].participants===entry.participants && existing[1].participantMode===entry.participantMode; @@ -94,8 +102,10 @@ export function invitationSettings(input,e={}) { if(entry.participantMode===undefined)delete invitees[token].participantMode; else invitees[token].participantMode=entry.participantMode; } + if(!existing)appendInvitationHistory(historyEvent,token,'created',{actorRole:'organiser',actorId,name:entry.name,participants:entry.participants,participantMode:entry.participantMode || (entry.participants?'preset':'default')},now); + else if(!unchanged)appendInvitationHistory(historyEvent,token,'edited',{actorRole:'organiser',actorId,name:entry.name,previousName:existing[1].name,participants:entry.participants,previousParticipants:existing[1].participants,participantMode:entry.participantMode || (entry.participants?'preset':'default'),previousParticipantMode:existing[1].participantMode || (existing[1].participants?'preset':'default')},now); } - return {...result,invitees,...singleUseSettings(input,e,mode,invitees)}; + return {...result,invitees,invitationHistory:historyEvent.invitationHistory,...(Object.keys(removedInvitations).length?{removedInvitations}:{}),...singleUseSettings(input,e,mode,invitees)}; } export function claimInvitation(e,token,id) { const invite=e?.invitees?.[token]; @@ -103,10 +113,12 @@ export function claimInvitation(e,token,id) { if(e.guests[id]?.invitationToken && e.guests[id].invitationToken!==token)throw new InputError('You already have a personal invitation for this event.'); if(!e.guests[id] && responsesClosed(e))throw new InputError('The response deadline has passed.'); if(e.guests[id] && !e.guests[id].invitationToken)throw new InputError('You already have a booking for this event.'); - e.guests[id] ||= {name:invite.name,invitationToken:token,status:'later',phone:'',answers:[],comment:''}; + const firstOpen=!e.guests[id]; + e.guests[id] ||= {name:invite.name,invitationToken:token,status:'later',responseRecorded:false,phone:'',answers:[],comment:''}; e.guests[id].name=invite.name; const participants=invitationParticipants(e,e.guests[id]); const selected=e.guests[id].participants; if(participants!==null && (invitationParticipantMode(e,e.guests[id])==='fixed' || !Number.isSafeInteger(selected) || selected<1 || selected>10))e.guests[id].participants=participants; + if(firstOpen)appendInvitationHistory(e,token,'opened',{actorRole:'guest',actorId:id,userId:id,name:invite.name}); return e.guests[id]; } diff --git a/src/mini-api.js b/src/mini-api.js index fee25ca..e374850 100644 --- a/src/mini-api.js +++ b/src/mini-api.js @@ -1,11 +1,13 @@ -import {parseEventPayment,paymentMethod} from './event-payment.js'; +import {parseEventPayment,paymentMethod,paidEvent} from './event-payment.js'; import {issueTicket,verifyTicket} from './tickets.js'; import {profileFields,profilePreference,profilePhotoApi} from './profile.js'; import {readOnlinePricing} from './exchange.js'; import {invitationMode,invitationSettings,oneTimeInvites,invitationAvailable,reconcileInvitationClaims} from './invitations.js'; -import {managedInvitations,invitationsVersion,addInvitations,removeInvitation} from './invitation-management.js'; +import {managedInvitations,revokedInvitations,invitationsVersion,addInvitations,removeInvitation,editInvitation,changeInvitationResponse,revokeInvitation,deleteInvitation,hasRecordedResponse} from './invitation-management.js'; +import {invitationHistory} from './invitation-history.js'; import {isManager,cohostEntries,cohostLink,cohostVersion,createCohostInvite,revokeCohost} from './cohosts.js'; import { authenticate } from './mini-auth.js'; +import { mayUseTestApp, testAccessMessage, TestAccessError, whitelistStatus, applyWhitelistChange } from './test-access.js'; import { parsePricing, currencyCodes, localCurrency } from './pricing.js'; import { mediaApi } from './media-api.js'; import { shareUploadLink,asksPhone,asksComments,requiresApproval,asksParticipantCount,hidesLocation } from './permissions.js'; @@ -85,9 +87,9 @@ export function publicEvent(e, id, username) { return { invitationMode:invitationMode(e),guestName:e.owner!==id ? e.guests[id]?.name || null : null, askPhone:asksPhone(e),askComments:asksComments(e), - ...(manager ? {guestRoster:[...Object.entries(e.guests).filter(([uid])=>Number(uid)!==e.owner).map(([uid,g])=>({id:Number(uid),name:g.name,status:g.status,approval:g.status==='yes' ? requiresApproval(e) ? g.approval || 'pending' : 'approved' : null,confirmed:confirmed(e,g),participants:g.status==='yes'?participantCount(e,g):0,paymentStatus:g.payment?.status || null})),...Object.entries(e.invitees || {}).filter(([token])=>!Object.values(e.guests).some(g=>g.invitationToken===token)).map(([,g])=>({id:null,name:g.name,status:'unopened',participants:0,confirmed:false}))]} : {}), + ...(manager ? {guestRoster:[...Object.entries(e.guests).filter(([uid])=>Number(uid)!==e.owner).map(([uid,g])=>({id:Number(uid),name:g.name,status:g.status,responded:hasRecordedResponse(g,invitationHistory(e,g.invitationToken),Number(uid)),approval:g.status==='yes' ? requiresApproval(e) ? g.approval || 'pending' : 'approved' : null,confirmed:confirmed(e,g),participants:g.status==='yes'?participantCount(e,g):0,paymentStatus:g.payment?.status || null})),...Object.entries(e.invitees || {}).filter(([token])=>!Object.values(e.guests).some(g=>g.invitationToken===token)).map(([,g])=>({id:null,name:g.name,status:'unopened',responded:false,participants:0,confirmed:false}))]} : {}), inviteMessage:e.inviteMessage || '',qrEnabled:e.qrEnabled!==false,oneTimeInvite:oneTimeInvites(e), - ...(manager ? {invitees:managedInvitations(e,username),invitationsVersion:invitationsVersion(e),cohosts,cohost:cohosts[0] || null} : {}), + ...(manager ? {invitees:managedInvitations(e,username),revokedInvitees:revokedInvitations(e,username),invitationsVersion:invitationsVersion(e),cohosts,cohost:cohosts[0] || null} : {}), ...(owner ? {cohostLinks:entries.map(entry=>({id:entry.id,label:entry.label,status:entry.status,createdAt:entry.createdAt,cohost:entry.cohost,url:cohostLink(e,username,entry.id),...(entry.revokedAt ? {revokedAt:entry.revokedAt}:{})})),cohostInviteUrl:cohostLink(e,username),cohostVersion:cohostVersion(e)} : {}), id: e.id, title: e.title, when: e.when, location: canSeeLocation(e, id) ? e.location : null, description: e.description, startsAt: e.startsAt, timezone: e.timezone, localDate: e.localDate, localTime: e.localTime, @@ -106,7 +108,7 @@ export function publicEvent(e, id, username) { askParticipantCount: asksParticipantCount(e), participants: e.guests[id]?.status === 'yes' ? participantCount(e, e.guests[id]) : null, requireApproval: requiresApproval(e), hideLocation: hidesLocation(e), responseDeadline: e.responseDeadline || null, responsesClosed: responsesClosed(e), deadlineDate: e.deadlineDate || '', deadlineTime: e.deadlineTime || '', deadlineTimezone: e.deadlineTimezone || e.timezone || null, ...(manager ? { ticketInfo: e.ticketInfo || '' } : {}), - ticket: e.owner !== id && confirmed(e, e.guests[id]) && !e.cancelled ? { code: e.guests[id].ticket || '', name: e.guests[id].name, info: e.ticketInfo || '' } : null, + ticket: e.owner !== id && confirmed(e, e.guests[id]) && !e.cancelled ? { name: e.guests[id].name, info: e.ticketInfo || '', participants:participantCount(e,e.guests[id]),checkedInAt:e.checkIns?.[e.guests[id].ticket]?.at || null } : null, counts: can(e, id, 'guestList') ? responseCounts(e) : null, approval: e.owner !== id && e.guests[id]?.status === 'yes' ? (!requiresApproval(e) || e.guests[id]?.approval === 'approved') ? 'approved' : 'pending' : null, status: e.owner === id ? null : e.guests[id]?.status || null @@ -118,6 +120,26 @@ export async function miniApi(request, env) { const respond = (data, status = 200) => Response.json(data, { status, headers }); const user = authenticate(request.headers.get('Authorization')?.replace(/^tma /, ''), env.TELEGRAM_BOT_TOKEN); if (!user) return respond({ error: 'Open the planner inside Telegram. If it was open for a while, close and reopen it.' }, 401); + if (!await mayUseTestApp(env, user)) return respond({ error: testAccessMessage }, 403); + // The identity comes only from verified Telegram data. A database refresh can + // finish between authorization and a later route read or mutation. + const invocation = { ...env, snapshotUser: user }; + try { + const response = await miniApiForUser(request, invocation, user); + if (!await mayUseTestApp(invocation, user)) { + if (response.body) await response.body.cancel().catch(() => {}); + return respond({ error: testAccessMessage }, 403); + } + return response; + } catch (error) { + if (error instanceof TestAccessError || !await mayUseTestApp(invocation, user)) return respond({ error: testAccessMessage }, 403); + throw error; + } +} + +async function miniApiForUser(request, env, user) { + const headers = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' }; + const respond = (data, status = 200) => Response.json(data, { status, headers }); const path = new URL(request.url).pathname; const profileResponse=await profilePhotoApi(request,env,user);if(profileResponse)return profileResponse; const eventMatch=path.match(/^\/api\/events\/([a-f0-9]{16})$/); @@ -139,13 +161,30 @@ export async function miniApi(request, env) { if (mediaResponse) return mediaResponse; if (path.startsWith('/api/admin')) { if (!isSuperAdmin(user, env)) return respond({ error: 'Super admin access required.' }, 403); + if (path === '/api/admin/whitelist') { + if (request.method === 'GET') return respond(await whitelistStatus(env)); + if (request.method === 'POST') { + if (env.APP_ENV !== 'development') return respond({ error: 'The test whitelist is available only in development.' }, 403); + const raw = await request.text(); + if (raw.length > 14000) return respond({ error: 'Too much text.' }, 413); + try { + const input = JSON.parse(raw); + const status = await mutateState(env, (_data, _bot, settings) => applyWhitelistChange(env, input, settings)); + return respond(status); + } catch (error) { + if (error instanceof TestAccessError) throw error; + return respond({ error: error instanceof InputError || error instanceof BusyError ? error.message : 'Could not save test access settings.' }, error instanceof BusyError ? 503 : 400); + } + } + return respond({ error: 'Not found.' }, 404); + } if(path==='/api/admin/pricing' && request.method==='POST') { const raw=await request.text();if(raw.length>12000)return respond({error:'Too much text.'},413); try { const settings=parsePricing(JSON.parse(raw)); await mutateState(env,data=>{data.preferences._pricing=settings;}); return respond({settings}); - }catch(e){return respond({error:e instanceof InputError ? e.message : 'Could not save pricing settings.'},e instanceof BusyError ? 503 : 400);} + }catch(e){if(e instanceof TestAccessError)throw e;return respond({error:e instanceof InputError ? e.message : 'Could not save pricing settings.'},e instanceof BusyError ? 503 : 400);} } if (path !== '/api/admin/overview' || request.method !== 'GET') return respond({ error: 'Not found.' }, 404); await rememberUser(env, user); @@ -175,6 +214,7 @@ export async function miniApi(request, env) { const form = await new Response(bytes, { headers: { 'Content-Type': request.headers.get('Content-Type') || '' } }).formData(); const photo = form.get('photo'); if (!(photo instanceof File) || !photo.size || photo.size > 5 * 1024 * 1024 || !['image/jpeg', 'image/png', 'image/webp'].includes(photo.type)) return respond({ error: 'Choose a JPG, PNG, or WebP photo smaller than 5 MB.' }, 400); + if (!await mayUseTestApp(env, user)) throw new TestAccessError(); const upload = new FormData(); upload.set('chat_id', String(user.id)); upload.set('photo', photo); upload.set('caption', `Banner for ${event.title}`); const result = await (await fetch(`https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/sendPhoto`, { method: 'POST', body: upload, signal: AbortSignal.timeout(20000) })).json(); const fileId = result.result?.photo?.at(-1)?.file_id; @@ -186,7 +226,7 @@ export async function miniApi(request, env) { return { event: publicEvent(current, user.id, env.BOT_USERNAME) }; }); return respond(value); - } catch (error) { console.error('banner_failed', error.name); return respond({ error: error instanceof BusyError ? error.message : 'Could not load or save the banner. Please try again.' }, 503); } + } catch (error) { if(error instanceof TestAccessError)throw error;console.error('banner_failed', error.name); return respond({ error: error instanceof BusyError ? error.message : 'Could not load or save the banner. Please try again.' }, 503); } } if (path === '/api/bootstrap' && request.method === 'GET') { await rememberUser(env, user); @@ -210,11 +250,18 @@ export async function miniApi(request, env) { if (path === '/api/preview') return respond(schedule(input)); const value = await mutateState(env, async (data, bot) => { const id = user.id; - const invitationMatch=path.match(/^\/api\/events\/([a-f0-9]{16})\/invitations\/(add|remove)$/); + const invitationMatch=path.match(/^\/api\/events\/([a-f0-9]{16})\/invitations\/(add|remove|edit|response|revoke|delete)$/); if(invitationMatch){ - const e=data.events[invitationMatch[1]]; - const result=invitationMatch[2]==='add' ? addInvitations(e,id,input) : removeInvitation(e,id,input,data.sessions); - for(const uid of result.recipients || [])await bot.send(uid,`Your invitation to ${e.title} has been removed by the organiser.`); + const e=data.events[invitationMatch[1]],action=invitationMatch[2]; + const result=action==='add' ? addInvitations(e,id,input) : action==='remove' ? removeInvitation(e,id,input,data.sessions) : action==='edit' ? editInvitation(e,id,input,data.sessions) : action==='response' ? changeInvitationResponse(e,id,input,data.sessions) : action==='revoke' ? revokeInvitation(e,id,input,data.sessions) : deleteInvitation(e,id,input); + const labels={yes:'Accepted',no:'Declined',maybe:'Maybe',later:'Respond later'}; + for(const uid of result.recipients || []){ + if(action==='response'){ + const count=participantCount(e,e.guests[uid]); + const attendance=result.status==='yes'?`\n${count} ${count===1?'person':'people'}${paidEvent(e)&&!confirmed(e,e.guests[uid])?' · Payment required to confirm your place.':''}`:''; + await bot.send(uid,`${e.title}\nThe organiser recorded your response: ${labels[result.status]}.${attendance}`,{inline_keyboard:[[{text:'Open event',callback_data:`v:${e.id}`}]]}); + }else await bot.send(uid,`Your invitation to ${e.title} has been ${action==='remove'?'removed':'revoked'} by the organiser.`); + } const {recipients,...publicResult}=result; return {...publicResult,event:publicEvent(e,id,env.BOT_USERNAME)}; } @@ -315,7 +362,7 @@ export async function miniApi(request, env) { if (!isManager(e,id) || e.cancelled) throw new InputError('Only an organiser can change an active event.'); if(e.owner!==id)cohostSettings(input,e); if(input.guestNames!==undefined && input.invitationsVersion!==undefined && input.invitationsVersion!==invitationsVersion(e))throw new InputError('Invitations or responses have changed. Refresh the event before editing the guest list.'); - const inviteSettings=invitationSettings(input,e); + const inviteSettings=invitationSettings(input,e,Date.now(),id); if(input.title!==undefined)e.title=field(input.title,'Event name',100,true); if(input.location!==undefined)e.location=field(input.location,'Location',300); if(input.description!==undefined)e.description=field(input.description,'Description',1500); @@ -330,6 +377,7 @@ export async function miniApi(request, env) { }); return respond(value); } catch (e) { + if (e instanceof TestAccessError) throw e; if (e instanceof BusyError) return respond({ error: e.message }, 503); // Validation errors are controlled strings; never expose database or fetch errors. if (e instanceof InputError) return respond({ error: e.message }, 400); diff --git a/src/payments.js b/src/payments.js index 84573b5..c0ccc77 100644 --- a/src/payments.js +++ b/src/payments.js @@ -1,5 +1,6 @@ import { randomBytes } from 'node:crypto'; import { participantCount,requiresApproval } from './permissions.js'; +import { snapshotPaymentMessage } from './snapshot-mode.js'; export const starTotal = (e, g) => e.starPrice * (e.starPricing === 'person' ? participantCount(e, g) : 1); export function orderFor(data, uid, token) { return data.preferences[uid]?.starOrders?.[token]; } @@ -8,6 +9,7 @@ export function validOrder(data, uid, order) { return !!(e && !e.cancelled && e.owner === order.owner && e.starPrice > 0 && e.starPrice===order.unitPrice && e.starPricing===order.pricing && e.paymentTerms===order.terms && g?.status === 'yes' && (!requiresApproval(e) || g.approval === 'approved') && g.payment?.status !== 'paid' && order.status === 'pending' && g.payment?.order === order.id && starTotal(e,g) === order.amount && participantCount(e,g) === order.participants && (!e.startsAt || Date.parse(e.startsAt) > Date.now())); } export async function invoice(bot, uid, e, consent = false) { + if (bot.productionSnapshot) return bot.send(uid, snapshotPaymentMessage); const g=e.guests[uid]; if (!e.starPrice || e.cancelled || g?.status !== 'yes' || (requiresApproval(e) && g.approval !== 'approved')) return bot.send(uid,'Payment is available after your acceptance is approved.'); if (g.payment?.status === 'paid') return bot.send(uid,'Your payment is already confirmed.'); @@ -29,6 +31,7 @@ export async function invoice(bot, uid, e, consent = false) { return bot.api('sendInvoice',{chat_id:uid,title:e.title.slice(0,32),description:`Event admission · ${order.participants} participant(s)`.slice(0,255),payload:order.id,provider_token:'',currency:'XTR',prices:[{label:'Event admission',amount:order.amount}],start_parameter:`e_${e.id}`}); } export function checkout(bot,q) { + if (bot.productionSnapshot) return {pre_checkout_query_id:q.id,ok:false,error_message:snapshotPaymentMessage}; const order=orderFor(bot.db,q.from.id,q.invoice_payload); const ok=q.currency==='XTR' && validOrder(bot.db,q.from.id,order) && q.total_amount===order.amount && (!order.checkoutId || order.checkoutId===q.id); if(ok) { order.checkoutId=q.id; order.checkoutAt ||= new Date().toISOString(); bot.db.events[order.event].guests[q.from.id].payment.status='processing'; } @@ -51,6 +54,7 @@ export async function successful(bot,m) { await bot.ticket(uid,e); } export async function requestRefund(bot,uid,order) { + if (bot.productionSnapshot) return bot.send(uid, snapshotPaymentMessage); if(!order?.charge || !['paid','refund_failed'].includes(order.status))return; order.status='refund_pending'; const g=bot.db.events[order.event]?.guests[uid]; diff --git a/src/snapshot-mode.js b/src/snapshot-mode.js new file mode 100644 index 0000000..36c9989 --- /dev/null +++ b/src/snapshot-mode.js @@ -0,0 +1,27 @@ +import { isSuperAdmin } from './admin.js'; +import { accessPolicy } from './test-access.js'; + +export const snapshotPaymentMessage = 'Payments and refunds are disabled for this development database copy.'; + +export async function snapshotActive(env) { + if (env.APP_ENV !== 'development') return false; + // Presence is intentional: malformed or empty metadata must not disable protection. + const row = await env.DB.prepare("SELECT value FROM app_settings WHERE key='production-snapshot'").first(); + return row !== null && row !== undefined; +} + +export async function snapshotDeliveryAllowed(env, method, params = {}) { + if (!await snapshotActive(env)) return true; + if (['sendInvoice', 'createInvoiceLink', 'refundStarPayment'].includes(method)) return false; + const recipients = ['chat_id', 'user_id'].filter(key => params[key] !== undefined).map(key => params[key]); + if (!recipients.length) return true; + let policy; + for (const value of recipients) { + const text = String(value); + if (!/^[1-9]\d*$/.test(text) || !Number.isSafeInteger(Number(text))) return false; + if (isSuperAdmin({ id: Number(text) }, env)) continue; + policy ||= await accessPolicy(env, true); + if (policy.invalid || !policy.ids.includes(text)) return false; + } + return true; +} diff --git a/src/test-access.js b/src/test-access.js new file mode 100644 index 0000000..280264f --- /dev/null +++ b/src/test-access.js @@ -0,0 +1,159 @@ +import { isSuperAdmin } from './admin.js'; +import { mutateState } from './worker-store.js'; +import { snapshotActive } from './snapshot-mode.js'; +import { InputError } from './time.js'; + +export const testAccessMessage = 'This test app is limited to approved testers.'; +export class TestAccessError extends Error { + constructor() { super(testAccessMessage); } +} +export const whitelistEnabled = env => env.APP_ENV === 'development' && String(env.TEST_WHITELIST_ENABLED).trim() === 'true'; + +function validId(value) { + const text = String(value); + return /^[1-9]\d*$/.test(text) && Number.isSafeInteger(Number(text)); +} + +function parseIds(value) { + if (typeof value !== 'string') return null; + if (!value.trim()) return []; + const ids = value.split(/[,\s]+/).filter(Boolean); + return ids.length && ids.every(validId) ? [...new Set(ids)] : null; +} + +async function whitelistSwitch(env) { + const stored = await env.DB.prepare("SELECT value FROM app_settings WHERE key='test-whitelist-enabled'").first(); + if (!stored) return { enabled: whitelistEnabled(env), invalid: false }; + try { + const enabled = JSON.parse(stored.value); + if (typeof enabled === 'boolean') return { enabled, invalid: false }; + } catch {} + // An invalid override must never quietly open a restricted test environment. + return { enabled: true, invalid: true }; +} + +async function whitelistIds(env) { + const stored = await env.DB.prepare("SELECT value FROM app_settings WHERE key='test-whitelist'").first(); + let ids; + if (stored) { + try { + const value = JSON.parse(stored.value); + ids = Array.isArray(value) && value.every(id => typeof id === 'string' && validId(id)) ? [...new Set(value)] : null; + } catch { ids = null; } + } else ids = parseIds(env.WHITELIST_USER_IDS ?? ''); + return { ids: ids ?? [], invalid: ids === null }; +} + +export async function accessPolicy(env, force = false) { + if (env.APP_ENV !== 'development') return { enabled: false, ids: [], invalid: false }; + const option = await whitelistSwitch(env); + if (!option.enabled && !force) return { enabled: false, ids: [], invalid: false }; + const list = await whitelistIds(env); + return { enabled: true, ids: list.ids, invalid: option.invalid || list.invalid }; +} + +function statusFrom(option, list, snapshot) { + return { + supported: true, + enabled: option.enabled, + restricted: snapshot || (option.enabled && (option.invalid || list.invalid || list.ids.length > 0)), + ids: list.ids, + snapshotProtected: snapshot, + invalid: option.invalid || list.invalid + }; +} + +export async function whitelistStatus(env) { + if (env.APP_ENV !== 'development') return { supported: false, enabled: false, restricted: false, ids: [], snapshotProtected: false, invalid: false }; + const snapshot = await snapshotActive(env); + return statusFrom(await whitelistSwitch(env), await whitelistIds(env), snapshot); +} + +// Run inside mutateState, so simultaneous admin edits share its lease and batch. +export async function applyWhitelistChange(env, input, settings) { + if (env.APP_ENV !== 'development') throw new InputError('The test whitelist is available only in development.'); + if (!input || typeof input !== 'object' || Array.isArray(input) || !['set-enabled', 'add', 'remove'].includes(input.action)) throw new InputError('Choose a whitelist action.'); + const snapshot = await snapshotActive(env); + const option = await whitelistSwitch(env); + const list = await whitelistIds(env); + if (input.action === 'set-enabled') { + if (typeof input.enabled !== 'boolean') throw new InputError('Choose whether the whitelist is on or off.'); + if (snapshot && !input.enabled) throw new InputError('The whitelist must stay on while development contains copied production data.'); + option.enabled = input.enabled; + option.invalid = false; + settings.setAppSetting('test-whitelist-enabled', JSON.stringify(input.enabled)); + } else { + const requested = typeof input.ids === 'string' && input.ids.length <= 12000 ? parseIds(input.ids) : null; + if (!requested?.length || requested.length > 100) throw new InputError('Enter up to 100 numeric Telegram user IDs, separated by spaces or commas.'); + if (list.invalid && input.action !== 'add') throw new InputError('The whitelist is invalid. Add valid user IDs to reset it.'); + const ids = new Set(list.ids); + for (const id of requested) input.action === 'add' ? ids.add(id) : ids.delete(id); + if (input.action === 'add' && ids.size > 1000) throw new InputError('The whitelist can contain at most 1000 users.'); + list.ids = [...ids]; + list.invalid = false; + settings.setAppSetting('test-whitelist', JSON.stringify(list.ids)); + } + return statusFrom(option, list, snapshot); +} + +export async function mayUseTestApp(env, user) { + if (env.APP_ENV !== 'development' || isSuperAdmin(user, env)) return true; + if (!Number.isSafeInteger(user?.id) || user.id <= 0 || user.is_bot) return false; + const snapshot = await snapshotActive(env); + const policy = await accessPolicy(env, snapshot); + if (!policy.enabled) return true; + return !policy.invalid && ((!snapshot && !policy.ids.length) || policy.ids.includes(String(user.id))); +} + +function statusText(ids, snapshot = false, enabled = true) { + if (snapshot) return ids.length ? `Database copy protected. Restricted to ${ids.length} tester${ids.length === 1 ? '' : 's'} and the super admin.` : 'Database copy protected. Only the super admin has access.'; + if (!enabled) return 'Whitelist off. Open to everyone. Saved tester IDs are preserved.'; + return ids.length ? `Restricted to ${ids.length} tester${ids.length === 1 ? '' : 's'}. Super admin always has access.` : 'Whitelist empty. Open to everyone.'; +} + +export async function handleWhitelistCommand(env, update) { + const message = update.message; + if (message?.chat?.type !== 'private' || typeof message.text !== 'string') return null; + const command = message.text.trim().match(/^\/whitelist(?:@([A-Za-z0-9_]+))?(?:\s+([\s\S]*))?$/i); + if (!command || (command[1] && command[1].toLowerCase() !== String(env.BOT_USERNAME).toLowerCase())) return null; + await mutateState(env, async (_data, bot, settings) => { + const reply = text => bot.send(message.chat.id, text); + if (!isSuperAdmin(message.from, env)) return reply('Only the super admin can manage test access.'); + const snapshot = !!bot.productionSnapshot; + if (env.APP_ENV !== 'development') return reply('Test whitelist is disabled in this environment.'); + const input = (command[2] || 'list').trim(); + const [operation, ...rest] = input.split(/\s+/); + const action = operation.toLowerCase(); + if (!['list', 'add', 'remove', 'clear'].includes(action) || (['list', 'clear'].includes(action) && rest.length)) { + return reply('Use /whitelist, /whitelist add ID, /whitelist remove ID, or /whitelist clear.'); + } + const status = await whitelistStatus(env); + const policy = await accessPolicy(env, true); + if (action !== 'clear' && policy.invalid) return reply('Whitelist configuration is invalid. Use /whitelist clear to reset it.'); + if (action === 'list') { + await reply(statusText(policy.ids, snapshot, status.enabled)); + let page = ''; + for (const id of policy.ids) { + if (page.length + id.length + 1 > 3500) { await reply(page); page = ''; } + page += `${page ? '\n' : ''}${id}`; + } + if (page) await reply(page); + return; + } + let ids = []; + if (action !== 'clear') { + const requested = parseIds(rest.join(' ')); + if (!requested?.length) return reply('Enter numeric Telegram user IDs, separated by spaces or commas.'); + const current = new Set(policy.ids); + for (const id of requested) action === 'add' ? current.add(id) : current.delete(id); + ids = [...current]; + } + if (action === 'clear' && (await whitelistSwitch(env)).invalid) { + status.enabled = whitelistEnabled(env); + settings.setAppSetting('test-whitelist-enabled', JSON.stringify(status.enabled)); + } + settings.setAppSetting('test-whitelist', JSON.stringify(ids)); + await reply(statusText(ids, snapshot, status.enabled)); + }, update.update_id); + return new Response('OK'); +} diff --git a/src/tickets.js b/src/tickets.js index f57c0ad..7a53fb6 100644 --- a/src/tickets.js +++ b/src/tickets.js @@ -1,20 +1,58 @@ import qrcode from 'qrcode-generator'; -import {randomBytes} from 'node:crypto'; +import {createHmac,randomBytes} from 'node:crypto'; import {confirmed,participantCount} from './permissions.js'; import {InputError} from './time.js'; import {isManager} from './cohosts.js'; -export function issueTicket(e,id) { +const minuteAt=now=>Math.floor(now/60000); +const secretValid=value=>typeof value==='string' && /^[a-f0-9]{64}$/.test(value); +const activeCodeState=(g,minute)=>!!g.ticketCode && typeof g.ticket==='string' && g.ticketCode.ticket===g.ticket && secretValid(g.ticketCode.secret) && g.ticketCode.issuedMinute===minute; + +function minuteCode(e,g,secret,minute) { + const step=Buffer.alloc(8);step.writeBigUInt64BE(BigInt(minute)); + const digest=createHmac('sha256',Buffer.from(secret,'hex')).update(`XEvents:check-in:${e.id}:${g.ticket}:`).update(step).digest(); + const offset=digest[digest.length-1]&15; + return String((digest.readUInt32BE(offset)&0x7fffffff)%1000000).padStart(6,'0'); +} + +// Creating an accepted booking ensures its stable QR/audit identity only. A +// rotating check-in code is issued separately, when its owner opens the ticket. +export function ensureTicket(e,id) { const g=e?.guests[id]; if(!e || e.owner===id || e.cancelled || !confirmed(e,g))throw new InputError('Your ticket is not confirmed.'); g.ticket ||= randomBytes(6).toString('hex').toUpperCase(); + return g.ticket; +} + +export function issueTicket(e,id,now=Date.now()) { + ensureTicket(e,id); + if(e.endsAt && Date.parse(e.endsAt)<=now)throw new InputError('Event has finished. Check-in is closed.'); + const g=e.guests[id],minute=minuteAt(now); + let state=g.ticketCode; + if(!state || state.ticket!==g.ticket || !secretValid(state.secret))state={ticket:g.ticket,secret:randomBytes(32).toString('hex')}; + let code; + for(let attempt=0;attempt<5;attempt++){ + code=minuteCode(e,g,state.secret,minute); + const collision=Object.entries(e.guests).some(([uid,guest])=>Number(uid)!==id && Number(uid)!==e.owner && confirmed(e,guest) && activeCodeState(guest,minute) && minuteCode(e,guest,guest.ticketCode.secret,minute)===code); + if(!collision)break; + if(attempt===4)throw new InputError('A unique check-in code is not available. Try again in a moment.'); + state={ticket:g.ticket,secret:randomBytes(32).toString('hex')}; + } + g.ticketCode={...state,issuedMinute:minute}; let image=null; if(e.qrEnabled!==false){ const payload=`XE1:${e.id}:${g.ticket}`; const qr=qrcode(0,'M');qr.addData(payload);qr.make();image=qr.createDataURL(6,24); } - return {title:e.title,name:g.name,code:g.ticket,participants:participantCount(e,g),image,checkedInAt:e.checkIns?.[g.ticket]?.at || null}; + return {title:e.title,name:g.name,code,codeExpiresAt:new Date((minute+1)*60000).toISOString(),serverTime:new Date(now).toISOString(),participants:participantCount(e,g),image,checkedInAt:e.checkIns?.[g.ticket]?.at || null}; +} + +function failedAttempt(e,actor,minute) { + e.ticketCheckAttempts ||= {}; + const prior=e.ticketCheckAttempts[actor]; + e.ticketCheckAttempts[actor]={minute,failed:prior?.minute===minute ? (prior.failed || 0)+1:1}; } + export function verifyTicket(e,actor,input,checkIn=false,now=Date.now()) { if(!isManager(e,actor))throw new InputError('Only an organiser can check tickets.'); if(typeof input!=='string' || input.length>100)return {valid:false,reason:'Invalid ticket code.'}; @@ -22,13 +60,20 @@ export function verifyTicket(e,actor,input,checkIn=false,now=Date.now()) { const scanned=value.match(/^XE1:([A-F0-9]{16}):([A-F0-9]{12})$/); if(scanned && e.qrEnabled===false)return {valid:false,reason:'QR codes are disabled for this event. Enter the ticket code instead.'}; if(scanned && scanned[1]!==e.id.toUpperCase())return {valid:false,reason:'This ticket belongs to another event.'}; - const code=scanned ? scanned[2]:value; - if(!/^[A-F0-9]{12}$/.test(code))return {valid:false,reason:'Invalid ticket code.'}; + const supplied=scanned ? scanned[2]:value; + const short=!scanned && /^\d{6}$/.test(supplied); + if(!short && !/^[A-F0-9]{12}$/.test(supplied))return {valid:false,reason:'Enter a six-digit code or scan the ticket QR.'}; if(e.cancelled)return {valid:false,reason:'Event cancelled.'}; if(e.endsAt && Date.parse(e.endsAt)<=now)return {valid:false,reason:'Event has finished.'}; - const entry=Object.entries(e.guests).find(([uid,g])=>Number(uid)!==e.owner && g.ticket===code); - if(!entry || !confirmed(e,entry[1]))return {valid:false,reason:'Ticket is not valid or no longer confirmed.'}; - const [uid,g]=entry,prior=e.checkIns?.[code]; + const minute=minuteAt(now),attempts=e.ticketCheckAttempts?.[actor]; + if(short && attempts?.minute===minute && attempts.failed>=10)return {valid:false,reason:'Too many incorrect codes. Try next minute or scan the ticket QR.'}; + const matches=Object.entries(e.guests).filter(([uid,g])=>Number(uid)!==e.owner && confirmed(e,g) && (short ? activeCodeState(g,minute) && minuteCode(e,g,g.ticketCode.secret,minute)===supplied : g.ticket===supplied)); + if(matches.length!==1){ + if(short)failedAttempt(e,actor,minute); + return {valid:false,reason:matches.length>1?'This code matches several tickets. Use the ticket QR, or request a new code next minute.':short?'Code expired or incorrect. Ask the guest to open their current ticket code.':'Ticket is not valid or no longer confirmed.'}; + } + const [uid,g]=matches[0],code=g.ticket,prior=e.checkIns?.[code]; + if(Object.entries(e.guests).some(([otherId,other])=>otherId!==uid && Number(otherId)!==e.owner && other.ticket===code) || prior?.userId!==undefined && prior.userId!==Number(uid))return {valid:false,reason:'This ticket needs organiser review before check-in.'}; if(checkIn && !prior){e.checkIns ||= {};e.checkIns[code]={userId:Number(uid),at:new Date(now).toISOString(),participants:participantCount(e,g)};} return {valid:true,code,name:g.name,participants:participantCount(e,g),alreadyCheckedIn:!!prior,checkedInAt:e.checkIns?.[code]?.at || null}; } diff --git a/src/worker-store.js b/src/worker-store.js index 9c166fb..fae4d09 100644 --- a/src/worker-store.js +++ b/src/worker-store.js @@ -1,5 +1,7 @@ import { Bot } from './bot.js'; import {readOnlinePricing} from './exchange.js'; +import { snapshotActive } from './snapshot-mode.js'; +import { mayUseTestApp, TestAccessError } from './test-access.js'; export class BusyError extends Error {} @@ -8,6 +10,7 @@ export async function mutateState(env, action, updateId) { const lock = await env.DB.prepare('INSERT INTO lease (id, owner, expires) VALUES (1, ?, unixepoch()+60) ON CONFLICT(id) DO UPDATE SET owner=excluded.owner, expires=excluded.expires WHERE lease.expires < unixepoch() RETURNING owner').bind(owner).first(); if (!lock) throw new BusyError('Please try again in a moment.'); try { + if (env.snapshotUser && !await mayUseTestApp(env, env.snapshotUser)) throw new TestAccessError(); if (updateId !== undefined && await env.DB.prepare('SELECT id FROM processed WHERE id=?').bind(updateId).first()) return; const { results } = await env.DB.prepare("SELECT kind,id,data FROM records WHERE kind IN ('events','sessions','preferences')").all(); const data = { events: {}, sessions: {}, preferences: {} }; @@ -15,9 +18,15 @@ export async function mutateState(env, action, updateId) { const before = new Map(results.map(r => [`${r.kind}:${r.id}`, r.data])); const messages = []; const bot = new Bot({ data }, async (method, params) => { messages.push({ method, params }); return {}; }, env.BOT_USERNAME, env.APP_URL); + bot.productionSnapshot = await snapshotActive(env); bot.pricing=await readOnlinePricing(env,data.preferences._pricing); - const value = await action(data, bot); - const batch = [env.DB.prepare('INSERT INTO commits(owner) VALUES (?)').bind(owner)]; + const settings = []; + const value = await action(data, bot, { + setAppSetting(key, value) { + settings.push(env.DB.prepare('INSERT INTO app_settings(key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').bind(key, value)); + } + }); + const batch = [env.DB.prepare('INSERT INTO commits(owner) VALUES (?)').bind(owner), ...settings]; for (const kind of ['events', 'sessions', 'preferences']) { for (const [id, record] of Object.entries(data[kind])) { const serialized = JSON.stringify(record); diff --git a/src/worker.js b/src/worker.js index d45045e..433f0a6 100644 --- a/src/worker.js +++ b/src/worker.js @@ -1,10 +1,12 @@ import { mutateState, BusyError } from './worker-store.js'; import { miniApi } from './mini-api.js'; -import { rememberUser } from './admin.js'; +import { isSuperAdmin, rememberUser } from './admin.js'; import { sendDueReminders } from './reminders.js'; import { checkout, refundResult } from './payments.js'; import {refreshOnlineRates} from './exchange.js'; import { botCommands, botCommandsVersion } from './telegram-menu.js'; +import { handleWhitelistCommand, mayUseTestApp, testAccessMessage } from './test-access.js'; +import { snapshotActive, snapshotDeliveryAllowed } from './snapshot-mode.js'; export async function authorized(request, secret) { if (!secret) return false; @@ -15,6 +17,7 @@ export async function authorized(request, secret) { } async function telegram(env, method, params) { + if (!await snapshotDeliveryAllowed(env, method, params)) return { ok: false, error_code: 403, snapshotBlocked: true }; let response; try { response = await fetch(`https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/${method}`, { @@ -26,14 +29,34 @@ async function telegram(env, method, params) { export async function processUpdate(env, update) { try { + const user = update.pre_checkout_query?.from || update.callback_query?.from || (update.message?.chat?.type === 'private' ? update.message.from : null); + // Payments already charged must still be recorded or refunded after access changes. + if (user && !update.message?.successful_payment && !update.message?.refunded_payment && !await mayUseTestApp(env, user)) { + if (update.pre_checkout_query) { + const result = await telegram(env, 'answerPreCheckoutQuery', { pre_checkout_query_id: update.pre_checkout_query.id, ok: false, error_message: testAccessMessage }); + return new Response(result.ok ? 'OK' : 'Retry', { status: result.ok ? 200 : 503 }); + } + await mutateState(env, (_data, bot) => update.callback_query + ? bot.api('answerCallbackQuery', { callback_query_id: update.callback_query.id, text: testAccessMessage, show_alert: true }) + : bot.send(update.message.chat.id, testAccessMessage), update.update_id); + return new Response('OK'); + } + const whitelistResponse = await handleWhitelistCommand(env, update); + if (whitelistResponse) return whitelistResponse; if (update.pre_checkout_query) { const decision = await mutateState(env, (data, bot) => checkout(bot, update.pre_checkout_query)); const result = await telegram(env, 'answerPreCheckoutQuery', decision); return new Response(result.ok ? 'OK' : 'Retry', {status: result.ok ? 200 : 503}); } - const user = update.callback_query?.from || (update.message?.chat?.type === 'private' ? update.message.from : null); if (user) await rememberUser(env, user); - await mutateState(env, (data, bot) => bot.handle(update), update.update_id); + await mutateState(env, async (data, bot) => { + if (user && !update.message?.successful_payment && !update.message?.refunded_payment && !await mayUseTestApp(env, user)) { + return update.callback_query + ? bot.api('answerCallbackQuery', { callback_query_id: update.callback_query.id, text: testAccessMessage, show_alert: true }) + : bot.send(update.message.chat.id, testAccessMessage); + } + return bot.handle(update); + }, update.update_id); return new Response('OK'); } catch (e) { if (e instanceof BusyError) return new Response('Busy; retry', { status: 503 }); @@ -53,7 +76,7 @@ export async function drainOutbox(env) { const row = await env.DB.prepare('UPDATE outbox SET due=unixepoch()+60, attempts=attempts+1 WHERE id=? AND due<=unixepoch() RETURNING *').bind(id).first(); if (!row) continue; const result = await telegram(env, row.method, JSON.parse(row.params)); - if (row.method === 'refundStarPayment' && (result.ok || [400,403].includes(result.error_code))) { + if (row.method === 'refundStarPayment' && !result.snapshotBlocked && (result.ok || [400,403].includes(result.error_code))) { const params=JSON.parse(row.params); await mutateState(env, (data,bot) => refundResult(bot,params.user_id,params.telegram_payment_charge_id,result.ok)); } @@ -82,16 +105,33 @@ export default { } if (request.method === 'GET' && (url.pathname === '/app' || url.pathname === '/app/' || ['/app.js', '/errors.js', '/gallery.js', '/event-actions.js', '/style.css'].includes(url.pathname))) { const target = new URL(request.url); - if (url.pathname === '/app' || url.pathname === '/app/') target.pathname = '/'; - const asset = await env.ASSETS.fetch(new Request(target, request)); + const appPage = url.pathname === '/app' || url.pathname === '/app/'; + if (appPage) target.pathname = '/'; + const assetRequest = new Request(target, request); + if (appPage) { + assetRequest.headers.delete('If-None-Match'); + assetRequest.headers.delete('If-Modified-Since'); + } + const asset = await env.ASSETS.fetch(assetRequest); const headers = new Headers(asset.headers); + if (appPage) { + headers.delete('ETag'); + headers.delete('Last-Modified'); + } headers.set('Cache-Control', 'no-cache'); headers.set('X-Content-Type-Options', 'nosniff'); headers.set('Referrer-Policy', 'no-referrer'); headers.set('Content-Security-Policy', "default-src 'self'; script-src 'self' https://telegram.org; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; base-uri 'self'; object-src 'none'"); - return new Response(asset.body, { status: asset.status, headers }); + const response = new Response(asset.body, { status: asset.status, headers }); + if (appPage && asset.ok && typeof env.BOT_USERNAME === 'string' && /^[A-Za-z0-9_]{5,32}$/.test(env.BOT_USERNAME)) { + const botUrl = `https://t.me/${env.BOT_USERNAME}`; + return new HTMLRewriter().on('#telegram-bot-link', { + element(element) { element.setAttribute('href', botUrl); } + }).transform(response); + } + return response; } - if (request.method === 'GET' && url.pathname === '/') return Response.json({ service: 'XEvents', status: 'running' }); + if (request.method === 'GET' && url.pathname === '/') return Response.json({ service: 'XEvents', status: 'running', ...(env.APP_ENV === 'development' ? { databaseRefreshProtection: 1 } : {}) }); if (url.pathname === '/setup' && request.method === 'POST') { if (!await authorized(request, env.TELEGRAM_WEBHOOK_SECRET)) return new Response('Unauthorized', { status: 401 }); const me = await telegram(env, 'getMe', {}); @@ -123,7 +163,10 @@ export default { async scheduled(controller, env, ctx) { ctx.waitUntil(refreshOnlineRates(env).catch(()=>console.error('online_rates_storage_failed'))); ctx.waitUntil(configureMiniApp(env)); - ctx.waitUntil(mutateState(env, (data, bot) => sendDueReminders(data, bot)).then(() => drainOutbox(env)).catch(error => { if (!(error instanceof BusyError)) console.error('reminder_processing_failed'); })); + ctx.waitUntil((async () => { + if (!await snapshotActive(env)) await mutateState(env, (data, bot) => bot.productionSnapshot ? undefined : sendDueReminders(data, bot)); + await drainOutbox(env); + })().catch(error => { if (!(error instanceof BusyError)) console.error('reminder_processing_failed'); })); ctx.waitUntil(env.DB.prepare('DELETE FROM processed WHERE at < unixepoch()-604800').run()); } }; @@ -140,6 +183,20 @@ export async function configureMiniApp(env) { const result = await telegram(env, 'setMyCommands', { commands: botCommands }); if (result.ok) await env.DB.prepare("INSERT INTO app_settings(key,value) VALUES ('bot-commands',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(botCommandsVersion).run(); } + const adminId = Number(env.SUPER_ADMIN_ID); + if (isSuperAdmin({ id: adminId }, env)) { + const adminCommands = await env.DB.prepare("SELECT value FROM app_settings WHERE key='test-admin-commands'").first(); + // The administrator can edit saved tester IDs even while the switch is off. + const enabled = env.APP_ENV === 'development'; + const version = `${botCommandsVersion}:${adminId}:${enabled}`; + if ((enabled || adminCommands) && adminCommands?.value !== version) { + const result = await telegram(env, 'setMyCommands', { + scope: { type: 'chat', chat_id: adminId }, + commands: enabled ? [...botCommands, { command: 'whitelist', description: 'Manage test access' }] : botCommands + }); + if (result.ok) await env.DB.prepare("INSERT INTO app_settings(key,value) VALUES ('test-admin-commands',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(version).run(); + } + } const setting = await env.DB.prepare("SELECT value FROM app_settings WHERE key='mini-menu'").first(); const menuVersion=`App:${env.APP_URL}`; if (setting?.value === menuVersion) return; diff --git a/test/cohost-ui.test.js b/test/cohost-ui.test.js index 70fc2da..88bb30a 100644 --- a/test/cohost-ui.test.js +++ b/test/cohost-ui.test.js @@ -9,7 +9,7 @@ const eventFixture=fields=>({id:'0123456789abcdef',title:'Club evening',location const pendingLink=(id,label)=>({id,label,status:'pending',createdAt:'2026-10-07T00:00:00Z',cohost:null,url:'https://t.me/test?start=cohost_'+id}); const activeLink=(id,label,person)=>({...pendingLink(id,label),status:'active',url:null,cohost:{id:2,name:'Alex',username:'alex',joinedAt:'2026-10-07T00:00:00Z',...person}}); -async function harness(initial,{scheduleError,clipboardMode='ok',search='',invitationError,telegramErrors={}}={}){ +async function harness(initial,{scheduleError,clipboardMode='ok',search='',invitationError,telegramErrors={},telegramClose=true,clock=null,ticketResponses=[],ticketCheckResponses=[]}={}){ class El{ constructor(tag=''){this.tag=tag;this.children=[];this.dataset={};this.value='';this.files=[];this.attributes={};this.listeners={};this.open=false;this.classList={toggle(){},add(){},remove(){}};} append(...children){this.children.push(...children);this.firstChild=this.children[0];} @@ -24,10 +24,12 @@ async function harness(initial,{scheduleError,clipboardMode='ok',search='',invit const ids=new Map([...html.matchAll(/id="([^"]+)"/g)].map(match=>[match[1],new El()])); const tabs=[...html.matchAll(/data-tab="([^"]+)"/g)].map(match=>{const el=match[1]==='admin'?ids.get('admin-tab'):new El();el.dataset.tab=match[1];return el;}); const document={body:new El(),getElementById:id=>ids.get(id),createElement:tag=>new El(tag),addEventListener(){},querySelector:selector=>selector==='.bottom-nav'?new El():tabs.find(el=>selector.includes('"'+el.dataset.tab+'"')),querySelectorAll:selector=>selector==='[data-tab]'?tabs:[]}; - const telegramLinks=[],telegramStartupCalls=[],copied=[],calls=[],errors=[],errorContexts=[]; + const telegramLinks=[],telegramStartupCalls=[],scanCallbacks=[],copied=[],calls=[],errors=[],errorContexts=[]; const telegramCall=method=>{telegramStartupCalls.push(method);if(telegramErrors[method])throw new Error(telegramErrors[method]);}; - const window={Telegram:{WebApp:{initData:'test-session',ready(){telegramCall('ready');},expand(){telegramCall('expand');},onEvent(){telegramCall('onEvent');},openTelegramLink:url=>telegramLinks.push(url)}},scrollTo(){},reportAppError:(error,context)=>{errors.push(error.message);errorContexts.push(context);}}; - let event=initial,token=0,revision=0,invitationHold; + const window={Telegram:{WebApp:{initData:'test-session',ready(){telegramCall('ready');},expand(){telegramCall('expand');},onEvent(){telegramCall('onEvent');},close(){telegramCall('close');},showScanQrPopup:(options,callback)=>scanCallbacks.push(callback),closeScanQrPopup(){},openTelegramLink:url=>telegramLinks.push(url)}},scrollTo(){},reportAppError:(error,context)=>{errors.push(error.message);errorContexts.push(context);}}; + if(!telegramClose)delete window.Telegram.WebApp.close; + let event=initial,token=0,revision=0,invitationHold,eventReadHold,eventSaveHold,ticketHold,ticketCheckHold; + const ticketQueue=[...ticketResponses],ticketCheckQueue=[...ticketCheckResponses],timers=new Map();let timerSequence=0; const project=value=>({...value,cohosts:(value.cohostLinks || []).filter(link=>link.status==='active').map(link=>link.cohost),cohost:(value.cohostLinks || []).find(link=>link.status==='active')?.cohost || null,cohostInviteUrl:[...(value.cohostLinks || [])].reverse().find(link=>link.status==='pending')?.url || null}); const response=(data,status=200)=>({ok:status<400,status,json:async()=>data,blob:async()=>new Blob(['image'],{type:'image/jpeg'})}); const fetcher=async(path,options={})=>{ @@ -36,18 +38,31 @@ async function harness(initial,{scheduleError,clipboardMode='ok',search='',invit if(path==='/api/bootstrap')return response({user:{id:initial.isOwner?1:2,firstName:'User',isSuperAdmin:false},preference:{timezone:'Australia/Sydney'},pricing:{rates:{}},currencyCodes:['AUD'],events:[event]}); if(path.startsWith('/api/explore?'))return response({events:[]}); if(path==='/api/branding/icon')return response({error:'Not found'},404); - if(path==='/api/events/'+event.id)return response({event}); - if(path.endsWith('/invitations/add') || path.endsWith('/invitations/remove')){ + if(path==='/api/events/'+event.id){if(eventReadHold){const hold=eventReadHold;eventReadHold=null;await hold;}return response({event});} + if(path==='/api/events/'+event.id+'/ticket'){if(ticketHold){const hold=ticketHold;ticketHold=null;await hold;}const now=clock?.now ?? Date.now(),item=ticketQueue.shift();if(item?.error)return response({error:item.error},item.status || 400);return response({ticket:{title:event.title,name:'Alex',participants:2,code:'004281',codeExpiresAt:new Date(now+30000).toISOString(),serverTime:new Date(now).toISOString(),image:null,...item}});} + if(path.endsWith('/ticket-check')){if(ticketCheckHold){const hold=ticketCheckHold;ticketCheckHold=null;await hold;}const item=ticketCheckQueue.shift();if(item?.error)return response({error:item.error},item.status || 400);return response({ticket:item || {valid:false,reason:'Fictional ticket not found.'}});} + if(/\/invitations\/(add|edit|response|revoke|delete|remove)$/.test(path)){ if(invitationHold){const hold=invitationHold;invitationHold=null;await hold;} if(body.version!==event.invitationsVersion)return response({error:'Invitations changed. Review the current list.'},409); if(invitationError)return response({error:invitationError.message},invitationError.status || 400); - let invitees=[...event.invitees]; + let invitees=[...event.invitees],revokedInvitees=[...(event.revokedInvitees || [])]; if(path.endsWith('/add'))for(const line of body.guestNames.split('\n').filter(line=>line.trim())){ const match=line.match(/^\s*(.*?)\s*(?:=\s*(\?|\d+[!*]?))?\s*$/),rule=match[2],guestToken=(++token).toString(16).padStart(32,'0'); invitees.push({name:match[1],token:guestToken,url:'https://t.me/test?start=i_'+event.id+'_'+guestToken,participants:rule && rule!=='?' ? parseInt(rule,10) : null,participantMode:rule==='?' ? 'ask' : rule?.endsWith('!') ? 'confirm' : rule?.endsWith('*') ? 'fixed' : null,claimed:false,status:null,responses:[],responseCounts:{yes:0,no:0,maybe:0,later:0}}); } + else if(path.endsWith('/edit'))invitees=invitees.map(guest=>guest.token===body.token?{...guest,name:body.name,participants:body.participants,participantMode:body.participantMode,history:[...(guest.history || []),{type:'edited',at:'2026-10-08T02:00:00Z',name:body.name,previousName:guest.name,participants:body.participants,actorRole:'organiser'}]}:guest); + else if(path.endsWith('/response'))invitees=invitees.map(guest=>{ + if(guest.token!==body.token)return guest; + const replies=guest.responses.map(reply=>reply.id===body.userId?{...reply,status:body.status,participants:body.status==='yes'?body.participants || 1:0,responded:true,respondedAt:'2026-10-08T02:00:00Z'}:reply),previous=guest.responses.find(reply=>reply.id===body.userId),statuses=[...new Set(replies.map(reply=>reply.status))]; + return {...guest,responses:replies,status:statuses.length>1?'mixed':statuses[0],history:[...(guest.history || []),{type:'changed',at:'2026-10-08T02:00:00Z',status:body.status,previousStatus:previous?.status,participants:body.participants,actorRole:'organiser',notify:body.notify}]}; + }); + else if(path.endsWith('/revoke')){ + const guest=invitees.find(guest=>guest.token===body.token); + if(guest)revokedInvitees.push({...guest,revoked:true,revokedAt:'2026-10-08T02:00:00Z',canNotify:false,history:[...(guest.history || []),{type:'revoked',at:'2026-10-08T02:00:00Z',name:guest.name,notify:body.notify,actorRole:'organiser'}]}); + invitees=invitees.filter(guest=>guest.token!==body.token); + }else if(path.endsWith('/delete'))revokedInvitees=revokedInvitees.filter(guest=>guest.token!==body.token); else invitees=invitees.filter(guest=>guest.token!==body.token); - event={...event,invitees,invitationsVersion:(++revision).toString(16).padStart(16,'0')}; + event={...event,invitees,revokedInvitees,invitationsVersion:(++revision).toString(16).padStart(16,'0')}; return response({event,notifyCount:body.notify ? 1 : 0}); } if(path.endsWith('/cohost/invite') || path.endsWith('/cohost/revoke')){ @@ -58,8 +73,8 @@ async function harness(initial,{scheduleError,clipboardMode='ok',search='',invit event=project({...event,cohostLinks:links,cohostVersion:(++revision).toString(16).padStart(16,'0')}); return response({event}); } - if(path==='/api/events'){event={...event,...body};return response({event});} - if(path.endsWith('/schedule')){if(scheduleError)return response({error:scheduleError},400);event={...event,...body};return response({event});} + if(path==='/api/events'){if(eventSaveHold){const hold=eventSaveHold;eventSaveHold=null;await hold;}event={...event,...body};return response({event});} + if(path.endsWith('/schedule')){if(eventSaveHold){const hold=eventSaveHold;eventSaveHold=null;await hold;}if(scheduleError)return response({error:scheduleError},400);event={...event,...body};return response({event});} if(path==='/api/preview')return response({startsAt:event.startsAt,timezone:event.timezone}); return response({}); }; @@ -67,12 +82,15 @@ async function harness(initial,{scheduleError,clipboardMode='ok',search='',invit const AsyncFunction=Object.getPrototypeOf(async function(){}).constructor; const navigator={}; if(clipboardMode!=='absent')navigator.clipboard={writeText:async text=>{if(clipboardMode==='denied')throw Error('Clipboard denied.');copied.push(text);}}; - await new AsyncFunction('window','document','location','fetch','crypto','navigator','setupGallery','setupEventActions',source)(window,document,{search},fetcher,webcrypto,navigator,setupGallery,setupEventActions); + const TestDate=clock?class extends Date{constructor(...args){super(...(args.length?args:[clock.now]));}static now(){return clock.now;}}:Date; + const schedule=clock?(callback,delay=0)=>{const id=++timerSequence;timers.set(id,{callback,at:clock.now+delay});return id;}:setTimeout; + const unschedule=clock?id=>timers.delete(id):clearTimeout; + await new AsyncFunction('window','document','location','fetch','crypto','navigator','setupGallery','setupEventActions','Date','setTimeout','clearTimeout',source)(window,document,{search},fetcher,webcrypto,navigator,setupGallery,setupEventActions,TestDate,schedule,unschedule); await new Promise(resolve=>setImmediate(resolve)); const descendants=node=>node.children.flatMap(child=>[child,...descendants(child)]); const findButton=(id,label)=>descendants(ids.get(id)).find(node=>node.tag==='button' && node.textContent===label); const findEntry=id=>ids.get('cohost-list').children.find(node=>node.dataset.linkId===id); - return {ids,calls,errors,errorContexts,copied,telegramLinks,telegramStartupCalls,findButton,findEntry,descendants,setClipboardMode:value=>{clipboardMode=value;},setEvent:value=>{event=value;},getEvent:()=>event,setInvitationError:value=>{invitationError=value;},holdNextInvitationMutation:()=>{let release;invitationHold=new Promise(resolve=>{release=resolve;});return release;}}; + return {ids,tabs,calls,errors,errorContexts,copied,telegramLinks,telegramStartupCalls,scanCallbacks,findButton,findEntry,descendants,setClipboardMode:value=>{clipboardMode=value;},setEvent:value=>{event=value;},getEvent:()=>event,setInvitationError:value=>{invitationError=value;},holdNextInvitationMutation:()=>{let release;invitationHold=new Promise(resolve=>{release=resolve;});return release;},holdNextEventRead:()=>{let release;eventReadHold=new Promise(resolve=>{release=resolve;});return release;},holdNextEventSave:()=>{let release;eventSaveHold=new Promise(resolve=>{release=resolve;});return release;},holdNextTicket:()=>{let release;ticketHold=new Promise(resolve=>{release=resolve;});return release;},holdNextTicketCheck:()=>{let release;ticketCheckHold=new Promise(resolve=>{release=resolve;});return release;},pendingTimers:()=>timers.size,advanceTime:milliseconds=>{clock.now+=milliseconds;for(const [id,timer] of [...timers])if(timer.at<=clock.now){timers.delete(id);timer.callback();}}}; } for(const method of ['ready','expand','onEvent'])test('a Telegram '+method+' bridge failure keeps Home, My events and Create usable',async()=>{ @@ -94,9 +112,108 @@ for(const method of ['ready','expand','onEvent'])test('a Telegram '+method+' bri assert.strictEqual(f.getEvent(),initial); }); +test('the page close button returns every secondary view Home and preserves an unfinished event draft',async()=>{ + const f=await harness(eventFixture()); + f.ids.get('hero-create').onclick();f.ids.get('title').value='Unfinished picnic';f.ids.get('guest-names').value='Alex = 2*'; + assert.equal(f.ids.get('app-close').attributes['aria-label'],'Close this page and return Home');f.ids.get('app-close').onclick();assert.equal(f.ids.get('home-view').hidden,false);assert.equal(f.ids.get('title').value,'Unfinished picnic');assert.equal(f.ids.get('guest-names').value,'Alex = 2*'); + f.tabs.find(tab=>tab.dataset.tab==='create').onclick();assert.equal(f.ids.get('create-view').hidden,false);assert.equal(f.ids.get('title').value,'Unfinished picnic'); + for(const tab of ['events','settings','explore']){f.tabs.find(button=>button.dataset.tab===tab).onclick();assert.equal(f.ids.get(tab+'-view').hidden,false);f.ids.get('app-close').onclick();assert.equal(f.ids.get('home-view').hidden,false);} + assert.equal(f.calls.filter(call=>call.body && !call.path.startsWith('/api/preview')).length,0);assert.equal(f.telegramStartupCalls.filter(method=>method==='close').length,0); + assert.equal(f.ids.get('app-close').attributes['aria-label'],'Close App');f.ids.get('app-close').onclick();assert.equal(f.telegramStartupCalls.filter(method=>method==='close').length,1); +}); + +test('standalone Home close remains usable and both compact pickers close back to Telegram',async()=>{ + const standalone=await harness(eventFixture(),{telegramClose:false});standalone.ids.get('app-close').onclick();assert.equal(standalone.ids.get('home-view').hidden,false);assert.deepEqual(standalone.errors,[]);assert.equal(standalone.telegramStartupCalls.includes('close'),false); + for(const mode of ['picker','deadline']){const f=await harness(eventFixture(),{search:'?mode='+mode+'&session=fictional'});assert.equal(f.ids.get('create-view').hidden,false);assert.equal(f.ids.get('app-close').attributes['aria-label'],'Close App');f.ids.get('app-close').onclick();assert.equal(f.telegramStartupCalls.filter(method=>method==='close').length,1);assert.equal(f.calls.some(call=>call.path==='/api/picker' || call.path==='/api/draft-deadline'),false);} +}); + +test('saving an event disables close and competing form navigation until the save settles',async()=>{ + for(const editing of [false,true]){ + const f=await harness(eventFixture({paymentMethod:'free',starPrice:0}));if(editing)await f.findButton('event-list','✏️ Edit event').onclick();else f.ids.get('hero-create').onclick();f.ids.get('title').value='Picnic draft';const release=f.holdNextEventSave(),saving=f.ids.get('event-form').onsubmit({preventDefault(){}}); + assert.equal(f.ids.get('app-close').disabled,true);for(const id of ['cancel-edit','edit-cancel-event','edit-delete-event']){assert.equal(f.ids.get(id).disabled,true);f.ids.get(id).onclick();}assert.equal(f.ids.get('event-end-dialog').open,false); + f.ids.get('app-close').onclick();f.ids.get('hero-create').onclick();f.tabs.find(tab=>tab.dataset.tab==='events').onclick();assert.equal(f.ids.get('create-view').hidden,false);assert.equal(f.ids.get('title').value,'Picnic draft');assert.match(f.ids.get('notice').textContent,/saving/);assert.equal(f.calls.filter(call=>call.path==='/api/events' || call.path.endsWith('/schedule')).length,1); + release();await saving;assert.equal(f.ids.get('app-close').disabled,false);assert.equal(f.ids.get('edit-delete-event').disabled,false);assert.equal(f.getEvent().title,'Picnic draft');f.ids.get('app-close').onclick();assert.equal(f.ids.get('home-view').hidden,false); + } +}); + +test('closing a page while manager details load prevents a late dialog or editor from reopening',async()=>{ + for(const [label,dialog] of [['👥 Guest list','guest-list-dialog'],['🎟 Check in guests','checkin-dialog'],['🤝 Co-hosts','cohost-dialog'],['✉️ Guest invitations','invitation-links-dialog'],['✏️ Edit event',null]]){ + const f=await harness(eventFixture());f.tabs.find(tab=>tab.dataset.tab==='events').onclick();const release=f.holdNextEventRead(),opening=f.findButton('event-list',label).onclick();f.ids.get('app-close').onclick();release();await opening;assert.equal(f.ids.get('home-view').hidden,false,label);assert.equal(f.ids.get('create-view').hidden,true,label);if(dialog)assert.equal(f.ids.get(dialog).open,false,label); + } + const f=await harness(eventFixture({ticket:{name:'Alex',code:'FICTIONAL1234'}}));f.tabs.find(tab=>tab.dataset.tab==='events').onclick();const release=f.holdNextTicket(),opening=f.findButton('event-list','🎟 Check-in code').onclick();f.ids.get('app-close').onclick();release();await opening;assert.equal(f.ids.get('ticket-dialog').open,false);assert.equal(f.ids.get('home-view').hidden,false); +}); + +test('the ticket-check close button shuts the dialog and a delayed native scan cannot overwrite a newer session',async()=>{ + const f=await harness(eventFixture());await f.findButton('event-list','🎟 Check in guests').onclick();f.ids.get('checkin-scan').onclick();assert.equal(f.scanCallbacks.length,1);f.ids.get('checkin-close').onclick();assert.equal(f.ids.get('checkin-dialog').open,false);await f.findButton('event-list','🎟 Check in guests').onclick();f.ids.get('checkin-code').value='CURRENT';assert.equal(f.scanCallbacks[0]('STALE'),true);assert.equal(f.ids.get('checkin-code').value,'CURRENT');assert.equal(f.calls.filter(call=>call.path.endsWith('/ticket-check')).length,0); +}); + +test('check-in codes are generated on demand, preserve leading zeroes and expire without automatic requests',async()=>{ + const clock={now:Date.parse('2026-10-08T00:00:30Z')},f=await harness(eventFixture({qrEnabled:false,ticket:{name:'Alex',participants:2}}),{clock,ticketResponses:[{code:'004281'},{code:'005729'}]}); + const requests=()=>f.calls.filter(call=>call.path.endsWith('/ticket'));assert.equal(requests().length,0);assert.equal(f.pendingTimers(),0);await f.findButton('event-list','🎟 Check-in code').onclick();assert.equal(requests().length,1);assert.equal(f.ids.get('ticket-code').textContent,'004281');assert.equal(f.ids.get('ticket-image').hidden,true);assert.equal(f.ids.get('ticket-countdown').textContent,'Expires in 0:30');assert.equal(f.pendingTimers(),1);await f.ids.get('ticket-copy').onclick();assert.equal(f.copied.at(-1),'004281'); + f.advanceTime(30000);assert.equal(f.ids.get('ticket-copy').disabled,true);assert.match(f.ids.get('ticket-countdown').textContent,/Code expired/);assert.equal(f.pendingTimers(),0);await f.ids.get('ticket-copy').onclick();assert.equal(f.copied.length,1);assert.equal(requests().length,1); + await f.ids.get('ticket-refresh').onclick();assert.equal(requests().length,2);assert.equal(f.ids.get('ticket-code').textContent,'005729');assert.equal(f.ids.get('ticket-copy').disabled,false);assert.equal(f.pendingTimers(),1);f.ids.get('ticket-close').onclick();assert.equal(f.pendingTimers(),0);assert.equal(f.ids.get('ticket-code').textContent,'');f.advanceTime(60000);assert.equal(requests().length,2); +}); + +test('Home opens confirmed check-in codes with QR disabled and server time controls expiry despite a wrong device clock',async()=>{ + const clock={now:Date.parse('2026-10-08T00:00:00Z')},f=await harness(eventFixture({isOwner:false,isManager:false,status:'yes',qrEnabled:false,ticket:{name:'Alex',participants:2}}),{clock,ticketResponses:[{serverTime:'2035-10-08T12:34:40Z',codeExpiresAt:'2035-10-08T12:35:00Z'}]});assert.equal(f.calls.some(call=>call.path.endsWith('/ticket')),false);await f.findButton('home-upcoming','🎟 Check-in code').onclick();assert.equal(f.ids.get('ticket-dialog').open,true);assert.equal(f.ids.get('ticket-countdown').textContent,'Expires in 0:20');f.advanceTime(20000);assert.equal(f.ids.get('ticket-copy').disabled,true);f.ids.get('ticket-close').onclick(); +}); + +test('closing or navigating during a code refresh clears its timer and ignores the late response',async()=>{ + const clock={now:Date.parse('2026-10-08T00:00:00Z')},f=await harness(eventFixture({ticket:{name:'Alex',participants:2}}),{clock});await f.findButton('event-list','🎟 Check-in code').onclick();const release=f.holdNextTicket(),refreshing=f.ids.get('ticket-refresh').onclick();assert.equal(f.ids.get('ticket-copy').disabled,true);assert.equal(f.pendingTimers(),0);f.ids.get('ticket-close').onclick();release();await refreshing;assert.equal(f.ids.get('ticket-dialog').open,false);assert.equal(f.pendingTimers(),0);assert.equal(f.ids.get('ticket-code').textContent,''); + await f.findButton('event-list','🎟 Check-in code').onclick();assert.equal(f.pendingTimers(),1);f.tabs.find(tab=>tab.dataset.tab==='settings').onclick();assert.equal(f.ids.get('ticket-dialog').open,false);assert.equal(f.pendingTimers(),0); +}); + +test('an expired code refresh error remains visible and only an explicit retry generates another code',async()=>{ + const clock={now:Date.parse('2026-10-08T00:00:00Z')},f=await harness(eventFixture({ticket:{name:'Alex',participants:2}}),{clock,ticketResponses:[{code:'004281'},{error:'Please try again.'},{code:'009123'}]});await f.findButton('event-list','🎟 Check-in code').onclick();f.advanceTime(30000);await f.ids.get('ticket-refresh').onclick();assert.match(f.ids.get('ticket-message').textContent,/Please try again/);assert.equal(f.ids.get('ticket-copy').disabled,true);assert.equal(f.pendingTimers(),0);f.advanceTime(60000);assert.equal(f.calls.filter(call=>call.path.endsWith('/ticket')).length,2);await f.ids.get('ticket-refresh').onclick();assert.equal(f.ids.get('ticket-code').textContent,'009123');assert.equal(f.ids.get('ticket-copy').disabled,false);f.ids.get('ticket-close').onclick(); +}); + +test('the six-digit number pad keeps leading zeroes and checks in directly with one request',async()=>{ + const f=await harness(eventFixture(),{ticketCheckResponses:[{valid:true,name:'Alex and Sam',participants:2,checkedInAt:'2026-10-08T00:00:00Z'}]});await f.findButton('event-list','🎟 Check in guests').onclick();assert.equal(f.ids.get('checkin-check').disabled,true);assert.deepEqual(f.ids.get('checkin-keypad').children.map(button=>button.textContent),['1','2','3','4','5','6','7','8','9','Clear','0','⌫']); + for(const digit of '004281')await f.findButton('checkin-keypad',digit).onclick();assert.equal(f.ids.get('checkin-code').value,'004281');assert.deepEqual(Array.from({length:6},(_,i)=>f.ids.get('checkin-digit-'+i).textContent),[...'004281']);await f.findButton('checkin-keypad','9').onclick();assert.equal(f.ids.get('checkin-code').value,'004281');assert.equal(f.ids.get('checkin-check').disabled,false); + const release=f.holdNextTicketCheck(),checking=f.ids.get('checkin-check').onclick();assert.equal(f.ids.get('checkin-check').disabled,true);assert.ok(f.ids.get('checkin-keypad').children.every(button=>button.disabled));await f.ids.get('checkin-check').onclick();assert.equal(f.calls.filter(call=>call.path.endsWith('/ticket-check')).length,1);release();await checking;assert.deepEqual(f.calls.find(call=>call.path.endsWith('/ticket-check')).body,{code:'004281',checkIn:true});assert.match(f.ids.get('checkin-result').textContent,/✓ Checked in/);assert.match(f.ids.get('checkin-result').textContent,/Alex and Sam · 2 people/);assert.equal(f.ids.get('checkin-check').disabled,true); + await f.findButton('checkin-keypad','⌫').onclick();assert.equal(f.ids.get('checkin-code').value,'00428');assert.equal(f.ids.get('checkin-check').disabled,true);await f.findButton('checkin-keypad','Clear').onclick();assert.equal(f.ids.get('checkin-code').value,'');assert.ok(Array.from({length:6},(_,i)=>f.ids.get('checkin-digit-'+i).textContent).every(value=>value==='·'));f.ids.get('checkin-close').onclick(); +}); + +test('typed codes remain six-digit strings and an already checked-in ticket displays a useful result',async()=>{ + const f=await harness(eventFixture(),{ticketCheckResponses:[{valid:true,alreadyCheckedIn:true,name:'Alex',participants:1,checkedInAt:'2026-10-08T00:00:00Z'}]});await f.findButton('event-list','🎟 Check in guests').onclick();f.ids.get('checkin-code').value='00a428199';f.ids.get('checkin-code').oninput();assert.equal(f.ids.get('checkin-code').value,'004281');let prevented=false;f.ids.get('checkin-code').onkeydown({key:'Enter',preventDefault(){prevented=true;}});await new Promise(resolve=>setImmediate(resolve));assert.equal(prevented,true);assert.deepEqual(f.calls.find(call=>call.path.endsWith('/ticket-check')).body,{code:'004281',checkIn:true});assert.match(f.ids.get('checkin-result').textContent,/Already checked in/);assert.match(f.ids.get('checkin-result').textContent,/Alex · 1 person/);assert.equal(f.ids.get('checkin-result').className,'checkin-warning');f.ids.get('checkin-close').onclick(); +}); + +test('legacy QR tickets are verified before their direct check-in action and never converted to a numeric code',async()=>{ + const code='XE1:fictional-legacy-ticket',f=await harness(eventFixture(),{ticketCheckResponses:[{valid:true,name:'Alex',participants:2},{valid:true,name:'Alex',participants:2,checkedInAt:'2026-10-08T00:00:00Z'}]});await f.findButton('event-list','🎟 Check in guests').onclick();f.ids.get('checkin-scan').onclick();f.scanCallbacks[0](code);await new Promise(resolve=>setImmediate(resolve));assert.deepEqual(f.calls.find(call=>call.path.endsWith('/ticket-check')).body,{code,checkIn:false});assert.equal(f.ids.get('checkin-code').value,'');assert.match(f.ids.get('checkin-result').textContent,/Valid ticket/);assert.equal(f.ids.get('checkin-check').disabled,false);await f.ids.get('checkin-check').onclick();assert.deepEqual(f.calls.filter(call=>call.path.endsWith('/ticket-check'))[1].body,{code,checkIn:true});f.ids.get('checkin-close').onclick(); +}); + +test('a late check-in result cannot overwrite a newly opened check-in session',async()=>{ + const f=await harness(eventFixture(),{ticketCheckResponses:[{valid:true,name:'First guest',participants:2,checkedInAt:'2026-10-08T00:00:00Z'}]});await f.findButton('event-list','🎟 Check in guests').onclick();f.ids.get('checkin-code').value='004281';f.ids.get('checkin-code').oninput();const release=f.holdNextTicketCheck(),checking=f.ids.get('checkin-check').onclick();f.ids.get('checkin-close').onclick();await f.findButton('event-list','🎟 Check in guests').onclick();f.ids.get('checkin-code').value='009123';f.ids.get('checkin-code').oninput();release();await checking;assert.equal(f.ids.get('checkin-code').value,'009123');assert.equal(f.ids.get('checkin-result').textContent,'');assert.equal(f.ids.get('checkin-check').disabled,false);f.ids.get('checkin-close').onclick(); +}); + +test('owners and co-hosts can check in guests from Edit and new events omit that entry',async()=>{ + for(const isOwner of [true,false]){const f=await harness(eventFixture({isOwner,isCoHost:!isOwner}));await f.findButton('event-list','✏️ Edit event').onclick();assert.equal(f.ids.get('edit-checkin-panel').hidden,false);await f.ids.get('edit-checkin').onclick();assert.equal(f.ids.get('checkin-dialog').open,true);f.ids.get('checkin-close').onclick();f.ids.get('hero-create').onclick();assert.equal(f.ids.get('edit-checkin-panel').hidden,true);} +}); + +test('closing or dismissing a confirmation cancels the operation and permits the next confirmation',async()=>{ + for(const close of [f=>f.ids.get('confirm-close').onclick(),f=>f.ids.get('confirm-dialog').close()]){ + const f=await harness(eventFixture({cohostLinks:[pendingLink('aaaaaaaaaaaaaaaa','Door')]}));await f.findButton('event-list','🤝 Co-hosts').onclick();const cancelling=f.findButton('cohost-list','Cancel invite').onclick();assert.equal(f.ids.get('confirm-dialog').open,true);close(f);await cancelling;assert.equal(f.ids.get('confirm-dialog').open,false);assert.equal(f.calls.filter(call=>call.path.endsWith('/cohost/revoke')).length,0); + const next=f.findButton('cohost-list','Cancel invite').onclick();assert.equal(f.ids.get('confirm-dialog').open,true);f.ids.get('confirm-close').onclick();await next;assert.equal(f.calls.filter(call=>call.path.endsWith('/cohost/revoke')).length,0); + } +}); + +test('a queued native confirmation close cannot dismiss or mutate a different confirmation',async()=>{ + const f=await harness(eventFixture({cohostLinks:[pendingLink('aaaaaaaaaaaaaaaa','Door'),pendingLink('bbbbbbbbbbbbbbbb','Registration')]}));await f.findButton('event-list','🤝 Co-hosts').onclick();const dialog=f.ids.get('confirm-dialog');let queuedClose;dialog.close=()=>{dialog.open=false;queuedClose=dialog.onclose;}; + const first=f.findButton('cohost-list','Cancel invite').onclick();f.ids.get('confirm-proceed').onclick();await first;assert.equal(f.calls.filter(call=>call.path.endsWith('/cohost/revoke')).length,1); + await f.findButton('cohost-list','Cancel invite').onclick();assert.equal(dialog.open,false,'a new prompt must wait until the old native close is delivered');assert.equal(f.calls.filter(call=>call.path.endsWith('/cohost/revoke')).length,1);queuedClose(); + const next=f.findButton('cohost-list','Cancel invite').onclick();assert.equal(dialog.open,true);f.ids.get('confirm-close').onclick();queuedClose();await next;assert.equal(f.calls.filter(call=>call.path.endsWith('/cohost/revoke')).length,1); +}); + +test('compact guest list status badges distinguish approval, payment, actual Later and opened invitations',async()=>{ + const guests=[{id:11,name:'Alex',status:'yes',participants:2,confirmed:true,responded:true},{id:12,name:'Morgan',status:'yes',participants:1,confirmed:false,approval:'pending',responded:true},{id:13,name:'Sam',status:'yes',participants:3,confirmed:false,approval:'approved',responded:true},{id:14,name:'Taylor',status:'no',participants:0,responded:true},{id:15,name:'Casey',status:'maybe',participants:0,responded:true},{id:16,name:'Jordan',status:'later',participants:0,responded:true},{id:17,name:'Drew',status:'later',participants:0,responded:false},{id:null,name:'Jess',status:'unopened',participants:0,responded:false}]; + const f=await harness(eventFixture({guestRoster:guests,invitees:guests.map((guest,index)=>managedInvite(guest.name,index+50,guest.id?[guest]:[]))}));await f.findButton('event-list','👥 Guest list').onclick();assert.equal(f.ids.get('guest-list-title').textContent,'Guest list');assert.equal(f.ids.get('guest-list-event').textContent,'Club evening');assert.match(f.ids.get('guest-list-summary').textContent,/6 people accepted/); + const rows=f.ids.get('guest-list-rows').children;assert.deepEqual(rows.map(row=>row.dataset.status),['yes','pending','payment','no','maybe','later','unanswered','unopened']);assert.deepEqual(rows.map(row=>f.descendants(row).find(node=>node.className==='invitation-status').textContent),['Accepted','Awaiting approval','Awaiting payment','Declined','Maybe','Respond later','Awaiting response','Not opened']);assert.match(textOf(f,rows[0]),/Alex/);assert.match(textOf(f,rows[0]),/2 people/);assert.equal(rows[0].children.some(node=>node.tag==='details' && node.className==='event-more guest-roster-more'),true); + await f.findButton('guest-list-filters','Later').onclick();assert.equal(f.ids.get('guest-list-rows').children.length,1);assert.match(textOf(f,f.ids.get('guest-list-rows').children[0]),/Jordan/);f.ids.get('guest-list-close').onclick();assert.equal(f.ids.get('guest-list-dialog').open,false); +}); + test('co-host events expose management actions and keep payment fields read-only',async()=>{ const f=await harness(eventFixture({isOwner:false,isCoHost:true})); - for(const label of ['✏️ Edit event','👥 Guest list','📷 Scan tickets','✉️ Guest invitations','🗂 Shared media'])assert.ok(f.findButton('event-list',label),label+' should be available'); + for(const label of ['✏️ Edit event','👥 Guest list','🎟 Check in guests','✉️ Guest invitations','🗂 Shared media'])assert.ok(f.findButton('event-list',label),label+' should be available'); for(const label of ['🤝 Co-hosts','🛑 Cancel event','🗑 Delete event','💳 Payments & refunds'])assert.equal(f.findButton('event-list',label),undefined,label+' should remain owner-only'); assert.ok(f.descendants(f.ids.get('home-upcoming')).some(node=>node.textContent==='Co-hosting')); assert.ok(f.descendants(f.ids.get('event-list')).some(node=>node.textContent==='CO-HOSTING')); @@ -308,11 +425,11 @@ test('collapsed settings expand for invalid required fields and payment errors', test('personal invitation rows distinguish an unanswered open link from a locked RSVP and a reusable link',async()=>{ const f=await harness(eventFixture({oneTimeInvite:true,invitees:[{name:'Alex',status:'later',claimed:false,url:'https://t.me/test?start=alex'},{name:'Sam',status:'yes',claimed:true,url:'https://t.me/test?start=sam'}]})); await f.findButton('event-list','✉️ Guest invitations').onclick(); - let rows=f.descendants(f.ids.get('invitation-links-list')).filter(node=>['p','span'].includes(node.tag)).map(node=>node.textContent).join('\n'); - assert.match(rows,/Awaiting RSVP · One-time link/);assert.match(rows,/Accepted · Locked to one guest/);assert.doesNotMatch(rows,/Not opened/); + let rows=f.descendants(f.ids.get('invitation-links-list')).map(node=>node.textContent).join('\n'); + assert.match(rows,/Respond later/);assert.match(rows,/One-time link/);assert.match(rows,/Accepted/);assert.match(rows,/Locked to one guest/);assert.doesNotMatch(rows,/Not opened/); f.setEvent({...f.getEvent(),oneTimeInvite:false});await f.findButton('event-list','✉️ Guest invitations').onclick(); - rows=f.descendants(f.ids.get('invitation-links-list')).filter(node=>['p','span'].includes(node.tag)).map(node=>node.textContent).join('\n'); - assert.match(rows,/Accepted · Reusable link/);assert.match(f.ids.get('invitation-links-note').textContent,/more than one guest/); + rows=f.descendants(f.ids.get('invitation-links-list')).map(node=>node.textContent).join('\n'); + assert.match(rows,/Accepted/);assert.match(rows,/Reusable link/);assert.match(f.ids.get('invitation-links-note').textContent,/more than one guest/); }); test('guest invitation actions copy the full invitation or only that guest link and preserve sharing',async()=>{ @@ -329,13 +446,15 @@ test('guest invitation actions copy the full invitation or only that guest link const rows=f.ids.get('invitation-links-list').children; const rowButton=(row,label)=>f.descendants(row).find(node=>node.tag==='button' && node.textContent===label); for(const [row,name,url] of [[rows[0],'Alex','https://t.me/test?start=alex'],[rows[1],'Sam','https://t.me/test?start=sam']]){ - for(const label of ['Share invite','Copy invite','Copy link'])assert.ok(rowButton(row,label),name+' should have '+label); + for(const label of ['📤 Share invite','Copy invite','Copy link'])assert.ok(rowButton(row,label),name+' should have '+label); + const mainActions=row.children.find(node=>node.className==='event-actions invitation-actions');assert.deepEqual(mainActions.children.map(button=>button.textContent),['Copy invite','Copy link']); + const more=row.children.find(node=>node.tag==='details' && node.className==='event-more invitation-more');assert.equal(more.open,false);assert.ok(f.descendants(more).includes(rowButton(row,'📤 Share invite'))); await rowButton(row,name).onclick();const personal=f.copied.at(-1); await rowButton(row,'Copy invite').onclick();assert.equal(f.copied.at(-1),personal); await rowButton(row,'Copy link').onclick();assert.equal(f.copied.at(-1),url); } await f.findButton('invitation-links-list','Sam').onclick();assert.match(f.copied.at(-1),/^Dear Sam,/);assert.match(f.copied.at(-1),/Please choose how many people/);assert.match(f.copied.at(-1),/start=sam$/); - await f.findButton('invitation-links-list','Share invite').onclick(); + await f.findButton('invitation-links-list','📤 Share invite').onclick(); const shared=new URL(f.telegramLinks.at(-1));assert.equal(shared.searchParams.get('url'),'https://t.me/test?start=alex');assert.equal(shared.searchParams.get('text')+'\n\n'+shared.searchParams.get('url'),alex); assert.equal(f.ids.get('invitation-links-status').textContent,'Invitation copied for Sam.'); }); @@ -370,7 +489,7 @@ test('denied or unavailable clipboard exposes selectable full text and a denied await f.findButton('event-list','✉️ Guest invitations').onclick(); const copy=f.findButton('invitation-links-list','Copy invite');await copy.onclick(); const nodes=f.descendants(f.ids.get('invitation-links-list')),fullText=nodes.find(node=>node.tag==='textarea'); - assert.equal(nodes.find(node=>node.tag==='details').open,true);assert.equal(fullText.readOnly,true);assert.equal(fullText.focused,true);assert.equal(fullText.selected,true); + assert.equal(nodes.find(node=>node.className==='invitation-preview').open,true);assert.equal(fullText.readOnly,true);assert.equal(fullText.focused,true);assert.equal(fullText.selected,true); assert.match(fullText.value,/^Dear Alex,/);assert.match(fullText.value,/Bring a scarf\./);assert.match(fullText.value,/start=guest$/);assert.doesNotMatch(fullText.value,/Club house/); assert.equal(f.copied.length,0);assert.match(f.ids.get('invitation-links-status').textContent,/Could not copy/);assert.doesNotMatch(f.ids.get('invitation-links-status').textContent,/Invitation copied/);assert.equal(copy.disabled,false); if(clipboardMode==='denied'){f.setClipboardMode('ok');await copy.onclick();assert.equal(f.copied.at(-1),fullText.value);assert.equal(f.ids.get('invitation-links-status').textContent,'Invitation copied for Alex.');} @@ -385,7 +504,7 @@ test('link-only copy failures expose a selectable personal URL and can recover w const copy=f.findButton('invitation-links-list','Copy link');await copy.onclick(); const nodes=f.descendants(f.ids.get('invitation-links-list')),link=nodes.find(node=>node.tag==='input' && node.attributes['aria-label']==='Personal link for Alex'); assert.ok(link);assert.equal(link.value,url);assert.equal(link.readOnly,true);assert.equal(link.focused,true);assert.equal(link.selected,true); - assert.equal(nodes.find(node=>node.tag==='details').open,true);assert.equal(f.copied.length,0);assert.equal(copy.disabled,false); + assert.equal(nodes.find(node=>node.className==='invitation-preview').open,true);assert.equal(f.copied.length,0);assert.equal(copy.disabled,false); assert.match(f.ids.get('invitation-links-status').textContent,/Could not copy/);assert.doesNotMatch(f.ids.get('invitation-links-status').textContent,/Link copied/); if(clipboardMode==='denied'){f.setClipboardMode('ok');await copy.onclick();assert.equal(f.copied.at(-1),url);assert.match(f.ids.get('invitation-links-status').textContent,/Link copied/);} } @@ -424,6 +543,8 @@ const managedEvent=fields=>eventFixture({paymentMethod:'free',starPrice:0,invita const invitationRows=f=>f.ids.get('invitation-links-list').children; const invitationRow=(f,name)=>invitationRows(f).find(row=>f.descendants(row).some(node=>node.tag==='button' && node.textContent===name)); const rowAction=(f,row,label)=>f.descendants(row).find(node=>node.tag==='button' && node.textContent===label); +const rowField=(f,row,key)=>f.descendants(row).find(node=>node.dataset?.field===key); +const rowPanel=(f,row)=>f.descendants(row).find(node=>node.className==='invitation-management'); const visibleInvitations=f=>invitationRows(f).filter(row=>!row.hidden).map(row=>f.descendants(row).find(node=>node.tag==='button').textContent); const textOf=(f,node)=>[node.textContent,...f.descendants(node).map(child=>child.textContent)].filter(Boolean).join('\n'); @@ -437,7 +558,7 @@ test('invitation manager shows all response outcomes and filters reusable links await f.findButton('event-list','✉️ Guest invitations').onclick(); const summary=textOf(f,f.ids.get('invitation-links-summary')); assert.match(summary,/5 invitations/);assert.match(summary,/4 responses/);assert.match(summary,/5 people accepted/); - assert.deepEqual(f.ids.get('invitation-links-filter').children.map(option=>option.textContent),['All (5)','Accepted (2)','Declined (1)','Maybe (1)','Later (1)','Unanswered (2)']); + assert.deepEqual(f.ids.get('invitation-links-filter').children.map(option=>option.textContent),['All (5)','Accepted (2)','Declined (1)','Maybe (1)','Later (1)','Unanswered (2)','Revoked (0)']); const alex=textOf(f,invitationRow(f,'Alex')); assert.match(alex,/Taylor/);assert.match(alex,/Accepted/);assert.match(alex,/Declined/);assert.match(alex,/See you there\./); for(const [filter,names] of [['all',['Alex','Sam','Morgan','Drew','Jess']],['yes',['Alex','Sam']],['no',['Alex']],['maybe',['Morgan']],['later',['Drew']],['unanswered',['Drew','Jess']]]){ @@ -479,34 +600,33 @@ test('owner and co-host can append named guests with count rules without resetti } }); -test('removing one invitation asks whether to notify and sends the exact chosen action',async()=>{ - for(const [label,notify] of [['Notify guest',true],['Remove silently',false]]){ +test('revoking one invitation explicitly chooses notification and preserves its history separately',async()=>{ + for(const notify of [true,false]){ const alex=managedInvite('Alex',11,[{name:'Alex',status:'yes',participants:2}]),sam=managedInvite('Sam',12); const f=await harness(managedEvent({invitees:[alex,sam]}));await f.findButton('event-list','✉️ Guest invitations').onclick(); - const row=invitationRow(f,'Alex');await rowAction(f,row,'Remove invite').onclick(); - assert.equal(f.calls.filter(call=>call.path.endsWith('/invitations/remove')).length,0); - for(const choice of ['Notify guest','Remove silently','Keep invite'])assert.ok(rowAction(f,row,choice)); + await rowAction(f,invitationRow(f,'Alex'),'🚫 Revoke invite').onclick();const row=invitationRow(f,'Alex'); + assert.equal(f.calls.filter(call=>call.path.endsWith('/invitations/revoke')).length,0); + assert.equal(rowField(f,row,'notify').checked,false);assert.equal(rowField(f,row,'deleteAfter').checked,false); const question=textOf(f,row);assert.match(question,/Alex/);assert.match(question,/notif|notify/i); - await rowAction(f,row,label).onclick(); - assert.deepEqual(f.calls.find(call=>call.path.endsWith('/invitations/remove')).body,{token:alex.token,notify,confirm:true,version:'0000000000000000'}); - assert.equal(invitationRow(f,'Alex'),undefined);assert.ok(invitationRow(f,'Sam'));assert.deepEqual(f.getEvent().invitees,[sam]);assert.deepEqual(f.errors,[]); + rowField(f,row,'notify').checked=notify;await rowAction(f,row,'Revoke invite').onclick(); + const request=f.calls.find(call=>call.path.endsWith('/invitations/revoke'));assert.deepEqual({...request.body,requestId:undefined},{token:alex.token,notify,confirm:true,version:'0000000000000000',requestId:undefined});assert.match(request.body.requestId,/^[a-f0-9-]{36}$/i); + assert.ok(invitationRow(f,'Alex'));assert.ok(invitationRow(f,'Sam'));assert.deepEqual(f.getEvent().invitees,[sam]);assert.equal(f.getEvent().revokedInvitees[0].history.at(-1).type,'revoked');assert.equal(f.getEvent().revokedInvitees[0].token,alex.token);assert.equal(f.calls.filter(call=>call.path.endsWith('/invitations/delete')).length,0);assert.deepEqual(f.errors,[]); } }); -test('keeping an invitation cancels removal without any mutation',async()=>{ +test('Back cancels invitation revocation without any mutation',async()=>{ const alex=managedInvite('Alex',11,[{name:'Alex',status:'no',participants:0}]); const f=await harness(managedEvent({invitees:[alex]}));await f.findButton('event-list','✉️ Guest invitations').onclick(); - const row=invitationRow(f,'Alex');await rowAction(f,row,'Remove invite').onclick();await rowAction(f,row,'Keep invite').onclick(); - assert.equal(f.calls.filter(call=>call.path.endsWith('/invitations/remove')).length,0);assert.deepEqual(f.getEvent().invitees,[alex]);assert.ok(invitationRow(f,'Alex')); + await rowAction(f,invitationRow(f,'Alex'),'🚫 Revoke invite').onclick();await rowAction(f,invitationRow(f,'Alex'),'Back').onclick(); + assert.equal(f.calls.filter(call=>call.path.endsWith('/invitations/revoke')).length,0);assert.deepEqual(f.getEvent().invitees,[alex]);assert.ok(invitationRow(f,'Alex'));assert.equal(rowPanel(f,invitationRow(f,'Alex')).hidden,true); }); -test('an unopened invitation offers silent removal and explains why there is no guest to notify',async()=>{ +test('an unopened invitation disables response and notification while allowing revocation',async()=>{ const f=await harness(managedEvent({invitees:[managedInvite('Alex',11)]}));await f.findButton('event-list','✉️ Guest invitations').onclick(); - const row=invitationRow(f,'Alex');await rowAction(f,row,'Remove invite').onclick(); - assert.equal(rowAction(f,row,'Notify guest').disabled,true);assert.equal(!!rowAction(f,row,'Remove silently').disabled,false); - assert.match(textOf(f,row),/not opened|no guest|not yet|cannot notify|can't notify/i); - await rowAction(f,row,'Notify guest').onclick();assert.equal(f.calls.filter(call=>call.path.endsWith('/invitations/remove')).length,0); - await rowAction(f,row,'Remove silently').onclick();assert.equal(f.getEvent().invitees.length,0); + assert.equal(rowAction(f,invitationRow(f,'Alex'),'↻ Change response').disabled,true);assert.match(textOf(f,invitationRow(f,'Alex')),/guest must open/); + await rowAction(f,invitationRow(f,'Alex'),'↻ Change response').onclick();assert.equal(f.calls.filter(call=>call.path.endsWith('/invitations/response')).length,0); + await rowAction(f,invitationRow(f,'Alex'),'🚫 Revoke invite').onclick();const row=invitationRow(f,'Alex');assert.equal(rowField(f,row,'notify').disabled,true);assert.match(textOf(f,row),/No Telegram account is available/); + await rowAction(f,row,'Revoke invite').onclick();assert.equal(f.getEvent().invitees.length,0);assert.equal(f.calls.find(call=>call.path.endsWith('/invitations/revoke')).body.notify,false); }); test('an add validation error keeps guest input and existing links visible for correction',async()=>{ @@ -516,20 +636,20 @@ test('an add validation error keeps guest input and existing links visible for c f.setInvitationError(null);f.ids.get('invitation-add-names').value='Casey = 2';await f.ids.get('invitation-add-form').onsubmit({preventDefault(){}});assert.equal(f.getEvent().invitees.length,2); }); -test('a remove failure keeps the invitation and displays the server error instead of reporting success',async()=>{ +test('a revoke failure keeps the invitation and displays the server error instead of reporting success',async()=>{ const alex=managedInvite('Alex',11,[{name:'Alex',status:'yes',participants:2}]),f=await harness(managedEvent({invitees:[alex]}),{invitationError:{status:400,message:'Refund this ticket before removing its invitation.'}}); - await f.findButton('event-list','✉️ Guest invitations').onclick();const row=invitationRow(f,'Alex');await rowAction(f,row,'Remove invite').onclick();await rowAction(f,row,'Notify guest').onclick(); + await f.findButton('event-list','✉️ Guest invitations').onclick();await rowAction(f,invitationRow(f,'Alex'),'🚫 Revoke invite').onclick();await rowAction(f,invitationRow(f,'Alex'),'Revoke invite').onclick(); assert.deepEqual(f.getEvent().invitees,[alex]);assert.ok(invitationRow(f,'Alex'));assert.match(f.ids.get('invitation-links-status').textContent,/Refund this ticket/);assert.doesNotMatch(f.ids.get('invitation-links-status').textContent,/removed|notified/i); }); -test('a response arriving before removal refreshes the list and requires a new explicit choice',async()=>{ +test('a response arriving before revocation refreshes the list and requires another explicit confirmation',async()=>{ const alex=managedInvite('Alex',11),f=await harness(managedEvent({invitees:[alex]}));await f.findButton('event-list','✉️ Guest invitations').onclick(); - f.ids.get('invitation-links-search').value='alex';f.ids.get('invitation-links-search').oninput();const row=invitationRow(f,'Alex');await rowAction(f,row,'Remove invite').onclick(); + f.ids.get('invitation-links-search').value='alex';f.ids.get('invitation-links-search').oninput();await rowAction(f,invitationRow(f,'Alex'),'🚫 Revoke invite').onclick();const row=invitationRow(f,'Alex'); const responded=managedInvite('Alex',11,[{name:'Alex',status:'yes',participants:2}]);f.setEvent({...f.getEvent(),invitationsVersion:'1111111111111111',invitees:[responded]}); - await rowAction(f,row,'Remove silently').onclick(); - assert.equal(f.calls.filter(call=>call.path.endsWith('/invitations/remove')).length,1);assert.deepEqual(f.getEvent().invitees,[responded]);assert.match(f.ids.get('invitation-links-status').textContent,/Invitations changed/);assert.match(textOf(f,invitationRow(f,'Alex')),/Accepted/);assert.equal(f.ids.get('invitation-links-search').value,'alex'); - const freshRow=invitationRow(f,'Alex');await rowAction(f,freshRow,'Remove invite').onclick();await rowAction(f,freshRow,'Remove silently').onclick(); - assert.equal(f.calls.filter(call=>call.path.endsWith('/invitations/remove')).at(-1).body.version,'1111111111111111');assert.equal(f.getEvent().invitees.length,0); + await rowAction(f,row,'Revoke invite').onclick(); + assert.equal(f.calls.filter(call=>call.path.endsWith('/invitations/revoke')).length,1);assert.deepEqual(f.getEvent().invitees,[responded]);assert.match(f.ids.get('invitation-links-status').textContent,/Invitations changed/);assert.match(textOf(f,invitationRow(f,'Alex')),/Accepted/);assert.equal(f.ids.get('invitation-links-search').value,'alex'); + await rowAction(f,invitationRow(f,'Alex'),'Revoke invite').onclick(); + assert.equal(f.calls.filter(call=>call.path.endsWith('/invitations/revoke')).at(-1).body.version,'1111111111111111');assert.equal(f.getEvent().invitees.length,0); }); test('closing invitation management ignores a late add response and a later opening fetches current invitations',async()=>{ @@ -540,19 +660,21 @@ test('closing invitation management ignores a late add response and a later open await f.findButton('event-list','✉️ Guest invitations').onclick();assert.ok(invitationRow(f,'Casey')); }); -test('a pending invitation mutation blocks competing add and remove actions',async()=>{ +test('a pending revocation blocks competing mutations and disables notification controls',async()=>{ const f=await harness(managedEvent({invitees:[managedInvite('Alex',11,[{name:'Alex',status:'yes',participants:2}])]}));await f.findButton('event-list','✉️ Guest invitations').onclick(); - const row=invitationRow(f,'Alex');await rowAction(f,row,'Remove invite').onclick();const notify=rowAction(f,row,'Notify guest'),silent=rowAction(f,row,'Remove silently'); - const release=f.holdNextInvitationMutation();const removing=notify.onclick(); - assert.equal(silent.disabled,true);assert.equal(f.ids.get('invitation-add-submit').disabled,true);assert.equal(f.ids.get('invitation-add-names').disabled,true); - await silent.onclick();f.ids.get('invitation-add-names').value='Casey';await f.ids.get('invitation-add-form').onsubmit({preventDefault(){}}); - assert.equal(f.calls.filter(call=>/\/invitations\/(?:add|remove)$/.test(call.path)).length,1); + await rowAction(f,invitationRow(f,'Alex'),'🚫 Revoke invite').onclick();const row=invitationRow(f,'Alex'),revoke=rowAction(f,row,'Revoke invite'),edit=rowAction(f,row,'✏️ Edit invite'); + const release=f.holdNextInvitationMutation();const removing=revoke.onclick(); + assert.equal(edit.disabled,true);assert.equal(rowField(f,row,'notify').disabled,true);assert.equal(f.ids.get('invitation-add-submit').disabled,true);assert.equal(f.ids.get('invitation-add-names').disabled,true); + await edit.onclick();f.ids.get('invitation-add-names').value='Casey';await f.ids.get('invitation-add-form').onsubmit({preventDefault(){}}); + assert.equal(f.calls.filter(call=>/\/invitations\/(?:add|revoke)$/.test(call.path)).length,1); release();await removing;assert.equal(f.getEvent().invitees.length,0);assert.equal(f.ids.get('invitation-add-submit').disabled,false); }); -test('removing the last invitation keeps management available and new guests can be added later',async()=>{ +test('revoking then deleting the last invitation requires a separate deletion confirmation and keeps additions available',async()=>{ const f=await harness(managedEvent({invitees:[managedInvite('Alex',11)]}));await f.findButton('event-list','✉️ Guest invitations').onclick(); - const row=invitationRow(f,'Alex');await rowAction(f,row,'Remove invite').onclick();await rowAction(f,row,'Remove silently').onclick(); + await rowAction(f,invitationRow(f,'Alex'),'🚫 Revoke invite').onclick();const row=invitationRow(f,'Alex');rowField(f,row,'deleteAfter').checked=true;await rowAction(f,row,'Revoke invite').onclick(); + assert.equal(f.getEvent().invitees.length,0);assert.equal(f.getEvent().revokedInvitees.length,1);assert.equal(f.calls.filter(call=>call.path.endsWith('/invitations/delete')).length,0);assert.match(textOf(f,invitationRow(f,'Alex')),/cannot be undone/); + await rowAction(f,invitationRow(f,'Alex'),'Delete permanently').onclick();assert.equal(f.getEvent().revokedInvitees.length,0);assert.equal(f.calls.find(call=>call.path.endsWith('/invitations/delete')).body.confirm,true); assert.equal(invitationRows(f).length,0);assert.match(f.ids.get('invitation-links-summary').textContent,/0 invitations/); f.ids.get('invitation-links-close').onclick();assert.ok(f.findButton('event-list','✉️ Guest invitations'));await f.findButton('event-list','✉️ Guest invitations').onclick(); f.ids.get('invitation-add-names').value='Sam';await f.ids.get('invitation-add-form').onsubmit({preventDefault(){}});assert.ok(invitationRow(f,'Sam')); @@ -567,6 +689,115 @@ test('refresh gets current responses without clearing a search, response filter assert.equal(f.ids.get('invitation-links-search').value,'alex');assert.equal(f.ids.get('invitation-links-filter').value,'yes');assert.equal(f.ids.get('invitation-add-names').value,'Casey = ?');assert.match(f.ids.get('invitation-links-status').textContent,/refreshed/i); }); +test('editing one invitation preserves its token, claim, responses and other invites for every attendee setting',async()=>{ + for(const [mode,count,participantMode,participants] of [['one','1','default',null],['editable','4','preset',4],['ask','1','ask',null],['confirm','3','confirm',3],['fixed','10','fixed',10]]){ + const reply={id:18,name:'Alex',status:'maybe',participants:0},alex=managedInvite('Alex',11,[reply]),sam=managedInvite('Sam',12); + const f=await harness(managedEvent({invitees:[alex,sam]}));await f.findButton('event-list','✉️ Guest invitations').onclick(); + await rowAction(f,invitationRow(f,'Alex'),'✏️ Edit invite').onclick();const row=invitationRow(f,'Alex'); + rowField(f,row,'name').value='Alex Smith';rowField(f,row,'mode').value=mode;rowField(f,row,'mode').onchange();rowField(f,row,'count').value=count; + await rowAction(f,row,'Save invite').onclick();const request=f.calls.find(call=>call.path.endsWith('/invitations/edit')); + assert.deepEqual({...request.body,requestId:undefined},{token:alex.token,name:'Alex Smith',participants,participantMode,version:'0000000000000000',requestId:undefined});assert.match(request.body.requestId,/^[a-f0-9-]{36}$/i); + const edited=f.getEvent().invitees[0];assert.equal(edited.token,alex.token);assert.equal(edited.claimed,alex.claimed);assert.deepEqual(edited.responses,[reply]);assert.deepEqual(f.getEvent().invitees[1],sam);assert.equal(edited.history.at(-1).type,'edited');assert.equal(rowPanel(f,invitationRow(f,'Alex Smith')).hidden,true); + await rowAction(f,invitationRow(f,'Alex Smith'),'Copy invite').onclick();assert.match(f.copied.at(-1),/^Dear Alex Smith,/);assert.ok(f.copied.at(-1).endsWith(alex.url)); + } +}); + +test('invitation edits reject duplicate names, malformed names and counts over ten without mutating',async()=>{ + const f=await harness(managedEvent({invitees:[managedInvite('Alex',11),managedInvite('Sam',12)]}));await f.findButton('event-list','✉️ Guest invitations').onclick();await rowAction(f,invitationRow(f,'Alex'),'✏️ Edit invite').onclick();const row=invitationRow(f,'Alex'); + for(const name of ['','Bad = 2','Sam']){rowField(f,row,'name').value=name;await rowAction(f,row,'Save invite').onclick();assert.equal(f.calls.filter(call=>call.path.endsWith('/invitations/edit')).length,0);assert.ok(f.ids.get('invitation-links-status').textContent);} + rowField(f,row,'name').value='Alex';rowField(f,row,'mode').value='fixed';rowField(f,row,'mode').onchange();rowField(f,row,'count').value='11';await rowAction(f,row,'Save invite').onclick();assert.match(f.ids.get('invitation-links-status').textContent,/1 to 10/);assert.equal(f.calls.filter(call=>call.path.endsWith('/invitations/edit')).length,0); +}); + +test('failed invitation edits retain the draft and reuse the request ID when retried',async()=>{ + const original=managedInvite('Alex',11,[{id:18,name:'Alex',status:'yes',participants:2}]),f=await harness(managedEvent({invitees:[original]}),{invitationError:{message:'This paid ticket needs a refund first.'}});await f.findButton('event-list','✉️ Guest invitations').onclick();await rowAction(f,invitationRow(f,'Alex'),'✏️ Edit invite').onclick();let row=invitationRow(f,'Alex'); + rowField(f,row,'name').value='Alex Smith';rowField(f,row,'mode').value='confirm';rowField(f,row,'mode').onchange();rowField(f,row,'count').value='3';rowField(f,row,'count').onchange();await rowAction(f,row,'Save invite').onclick(); + row=invitationRow(f,'Alex');assert.equal(rowField(f,row,'name').value,'Alex Smith');assert.equal(rowField(f,row,'mode').value,'confirm');assert.equal(rowField(f,row,'count').value,'3');assert.match(f.ids.get('invitation-links-status').textContent,/needs a refund/);assert.deepEqual(f.getEvent().invitees,[original]); + f.setInvitationError(null);await rowAction(f,row,'Save invite').onclick();const requests=f.calls.filter(call=>call.path.endsWith('/invitations/edit'));assert.equal(requests[0].body.requestId,requests[1].body.requestId);assert.ok(invitationRow(f,'Alex Smith')); +}); + +test('reusable invitation response changes require choosing the exact guest and preserve other responses',async()=>{ + for(const [status,notify] of [['yes',true],['no',false],['maybe',true],['later',false]]){ + const alex={id:18,name:'Alex',status:'yes',participants:2,selectedParticipants:2},sam={id:19,name:'Sam',status:'no',participants:0,selectedParticipants:4},invite=managedInvite('Team',11,[alex,sam]); + const f=await harness(managedEvent({oneTimeInvite:false,invitees:[invite]}));await f.findButton('event-list','✉️ Guest invitations').onclick();await rowAction(f,invitationRow(f,'Team'),'↻ Change response').onclick();const row=invitationRow(f,'Team'); + assert.equal(rowField(f,row,'userId').value,'');assert.equal(rowField(f,row,'notify').checked,false);await rowAction(f,row,'Save response').onclick();assert.equal(f.calls.filter(call=>call.path.endsWith('/invitations/response')).length,0);assert.match(f.ids.get('invitation-links-status').textContent,/Choose the guest/); + rowField(f,row,'userId').value='19';rowField(f,row,'userId').onchange();assert.equal(rowField(f,row,'status').value,'no');assert.equal(rowField(f,row,'count').value,'4'); + rowField(f,row,'status').value=status;rowField(f,row,'status').onchange();rowField(f,row,'notify').checked=notify;await rowAction(f,row,'Save response').onclick(); + const request=f.calls.find(call=>call.path.endsWith('/invitations/response'));assert.deepEqual({...request.body,requestId:undefined},{token:invite.token,userId:19,status,...(status==='yes'?{participants:4}:{}),notify,version:'0000000000000000',requestId:undefined});assert.match(request.body.requestId,/^[a-f0-9-]{36}$/i);assert.deepEqual(f.getEvent().invitees[0].responses[0],alex);assert.equal(f.getEvent().invitees[0].responses[1].status,status); + } +}); + +test('fixed and one-person invitations disable attendee changes in the response form',async()=>{ + for(const fields of [{participants:3,participantMode:'fixed'},{participants:null,participantMode:null}]){ + const invite={...managedInvite('Alex',11,[{id:18,name:'Alex',status:'yes',participants:fields.participants || 1}]),...fields}; + const f=await harness(managedEvent({invitees:[invite]}));await f.findButton('event-list','✉️ Guest invitations').onclick();await rowAction(f,invitationRow(f,'Alex'),'↻ Change response').onclick();const row=invitationRow(f,'Alex');assert.equal(rowField(f,row,'count').disabled,true);assert.equal(rowField(f,row,'count').value,String(fields.participants || 1));assert.match(textOf(f,row),fields.participantMode==='fixed'?/count is fixed/:/for one person/); + } +}); + +test('response errors keep the selected guest and notification choice and reuse the request on retry',async()=>{ + const invite=managedInvite('Alex',11,[{id:18,name:'Alex',status:'yes',participants:2}]),f=await harness(managedEvent({invitees:[invite]}),{invitationError:{message:'This guest is already checked in.'}});await f.findButton('event-list','✉️ Guest invitations').onclick();await rowAction(f,invitationRow(f,'Alex'),'↻ Change response').onclick();let row=invitationRow(f,'Alex');rowField(f,row,'status').value='no';rowField(f,row,'status').onchange();rowField(f,row,'notify').checked=true;rowField(f,row,'notify').onchange();await rowAction(f,row,'Save response').onclick(); + row=invitationRow(f,'Alex');assert.equal(rowField(f,row,'userId').value,'18');assert.equal(rowField(f,row,'status').value,'no');assert.equal(rowField(f,row,'notify').checked,true);assert.match(f.ids.get('invitation-links-status').textContent,/already checked in/);assert.equal(f.getEvent().invitees[0].responses[0].status,'yes'); + f.setInvitationError(null);await rowAction(f,row,'Save response').onclick();const requests=f.calls.filter(call=>call.path.endsWith('/invitations/response'));assert.equal(requests[0].body.requestId,requests[1].body.requestId);assert.equal(f.getEvent().invitees[0].responses[0].status,'no'); +}); + +test('revoked invitations are filterable, retain history and cannot be copied, shared or edited',async()=>{ + const active=managedInvite('Alex',11,[{id:18,name:'Alex',status:'yes',participants:2}]),archived={...managedInvite('Sam',12,[{id:19,name:'Sam',status:'yes',participants:4}]),revoked:true,history:[{type:'created',at:'2026-10-07T00:00:00Z'},{type:'revoked',at:'2026-10-08T00:00:00Z'}]}; + const f=await harness(managedEvent({invitees:[active],revokedInvitees:[archived]}));await f.findButton('event-list','✉️ Guest invitations').onclick();assert.match(f.ids.get('invitation-links-summary').textContent,/1 invitations · 1 responses · 2 people accepted · 1 revoked/); + const row=invitationRow(f,'Sam');assert.equal(rowAction(f,row,'Sam').disabled,true);for(const label of ['Copy invite','Copy link','📤 Share invite','✏️ Edit invite','↻ Change response','🚫 Revoke invite'])assert.equal(rowAction(f,row,label),undefined);await rowAction(f,row,'Sam').onclick();assert.equal(f.copied.length,0); + f.ids.get('invitation-links-filter').value='yes';f.ids.get('invitation-links-filter').onchange();assert.deepEqual(visibleInvitations(f),['Alex']);f.ids.get('invitation-links-filter').value='revoked';f.ids.get('invitation-links-filter').onchange();assert.deepEqual(visibleInvitations(f),['Sam']);await rowAction(f,row,'🕘 See history').onclick();assert.match(textOf(f,invitationRow(f,'Sam')),/Invitation revoked/); +}); + +test('deleting an archived invitation can be cancelled and only deletes the selected revoked row',async()=>{ + const archived={...managedInvite('Sam',12),revoked:true,history:[{type:'revoked',at:'2026-10-08T00:00:00Z'}]},other={...managedInvite('Taylor',13),revoked:true},active=managedInvite('Alex',11),f=await harness(managedEvent({invitees:[active],revokedInvitees:[archived,other]}));await f.findButton('event-list','✉️ Guest invitations').onclick(); + await rowAction(f,invitationRow(f,'Sam'),'🗑 Delete invite').onclick();await rowAction(f,invitationRow(f,'Sam'),'Back').onclick();assert.equal(f.calls.filter(call=>call.path.endsWith('/invitations/delete')).length,0);assert.deepEqual(f.getEvent().revokedInvitees,[archived,other]); + await rowAction(f,invitationRow(f,'Sam'),'🗑 Delete invite').onclick();assert.match(textOf(f,invitationRow(f,'Sam')),/audit records are retained/);await rowAction(f,invitationRow(f,'Sam'),'Delete permanently').onclick();const request=f.calls.find(call=>call.path.endsWith('/invitations/delete'));assert.equal(request.body.token,archived.token);assert.equal(request.body.confirm,true);assert.deepEqual(f.getEvent().invitees,[active]);assert.deepEqual(f.getEvent().revokedInvitees,[other]);assert.equal(invitationRow(f,'Sam'),undefined); +}); + +test('invitation history displays tracked actions, exact timestamps and attendee changes in the profile timezone',async()=>{ + const types=['created','opened','responded','changed','edited','revoked'],history=types.map((type,index)=>({type,at:'2026-10-08T0'+index+':00:00Z',name:index===4?'Alex Smith':'Alex',...(index===3?{status:'no',previousStatus:'yes',actorRole:'organiser',notify:true}:{}),...(index===4?{previousName:'Alex',participants:3,previousParticipants:2,participantMode:'fixed',previousParticipantMode:'preset',actorRole:'organiser'}:{}),...(index===5?{notify:false}:{})})); + const f=await harness(managedEvent({invitees:[{...managedInvite('Alex',11),history}]}));await f.findButton('event-list','✉️ Guest invitations').onclick();await rowAction(f,invitationRow(f,'Alex'),'🕘 See history').onclick();const row=invitationRow(f,'Alex'),text=textOf(f,row),times=f.descendants(rowPanel(f,row)).filter(node=>node.tag==='time'); + for(const label of ['Invite created','Invitation opened','Response received','Response changed','Invite edited','Invitation revoked','Times shown in Australia/Sydney.','Accepted → Declined','Alex → Alex Smith','2 → 3 people','Guest can change count → Fixed count','Notification requested','Guest not notified'])assert.ok(text.includes(label),label); + assert.equal(times.length,history.length);for(let i=0;i{ + const f=await harness(managedEvent({invitees:[managedInvite('Alex',11)]}));await f.findButton('event-list','✉️ Guest invitations').onclick();await rowAction(f,invitationRow(f,'Alex'),'🕘 See history').onclick();const row=invitationRow(f,'Alex');assert.match(textOf(f,row),/No recorded history yet. Earlier activity isn’t available/);assert.equal(f.descendants(row).filter(node=>node.tag==='time').length,0);assert.doesNotMatch(textOf(f,row),/Invite created|Invitation opened/); +}); + +test('current invitation responses stay visible without expanding details and distinguish opened from Respond later',async()=>{ + const names=[['Accepted','yes',true],['Declined','no',true],['Maybe','maybe',true],['Respond later','later',true],['Awaiting response','later',false]]; + const guests=names.map(([name,status,responded],i)=>managedInvite(name,i+21,[{id:i+101,name,status,responded,respondedAt:null,participants:status==='yes'?2:0}]));guests.push(managedInvite('Unopened',30)); + const f=await harness(managedEvent({invitees:guests}));await f.findButton('event-list','✉️ Guest invitations').onclick(); + for(const [name] of names){const row=invitationRow(f,name),status=row.children.find(node=>node.className==='invitation-current-status'),badge=f.descendants(status).find(node=>node.className==='invitation-status');assert.ok(status);assert.notEqual(status.hidden,true);assert.equal(badge.textContent,name);assert.equal(badge.attributes['aria-label'],'Current response: '+name);assert.equal(f.descendants(status).some(node=>node.tag==='time'),false);} + const unopened=invitationRow(f,'Unopened'),badge=f.descendants(unopened).find(node=>node.className==='invitation-status');assert.equal(badge.textContent,'Not opened'); + f.ids.get('invitation-links-filter').value='later';f.ids.get('invitation-links-filter').onchange();assert.deepEqual(visibleInvitations(f),['Respond later']);f.ids.get('invitation-links-filter').value='unanswered';f.ids.get('invitation-links-filter').onchange();assert.deepEqual(visibleInvitations(f),['Respond later','Awaiting response','Unopened']); +}); + +test('mixed reusable invitations show the latest genuine response and each guest response time',async()=>{ + const first='2026-10-08T01:00:00Z',last='2026-10-08T02:00:00Z',invite={...managedInvite('Team',11,[{id:18,name:'Alex',status:'yes',responded:true,respondedAt:first,participants:2},{id:19,name:'Sam',status:'no',responded:true,respondedAt:last,participants:0}]),history:[{type:'responded',at:first,userId:18,status:'yes'},{type:'changed',at:last,userId:19,status:'no'},{type:'edited',at:'2026-10-09T00:00:00Z',name:'Team'}]}; + const f=await harness(managedEvent({oneTimeInvite:false,invitees:[invite]}));await f.findButton('event-list','✉️ Guest invitations').onclick();const row=invitationRow(f,'Team'),status=row.children.find(node=>node.className==='invitation-current-status'),badge=f.descendants(status).find(node=>node.className==='invitation-status'),time=f.descendants(status).find(node=>node.tag==='time');assert.equal(badge.textContent,'Mixed responses · 2 guests');assert.equal(badge.dataset.status,'mixed');assert.equal(time.attributes.datetime,new Date(last).toISOString());assert.equal(time.textContent,'Last response: '+new Intl.DateTimeFormat(undefined,{timeZone:'Australia/Sydney',dateStyle:'medium',timeStyle:'short'}).format(new Date(last))); + const details=f.descendants(row).find(node=>node.className==='invitation-responses');assert.equal(details.open,false);assert.deepEqual(f.descendants(details).filter(node=>node.tag==='time').map(node=>node.attributes.datetime),[first,last].map(at=>new Date(at).toISOString()));assert.match(textOf(f,details),/Alex: Accepted/);assert.match(textOf(f,details),/Sam: Declined/); +}); + +test('legacy accepted and newly opened invitations do not invent a last-response timestamp',async()=>{ + const legacy=managedInvite('Alex',11,[{id:18,name:'Alex',status:'yes',participants:2}]),opened={...managedInvite('Sam',12,[{id:19,name:'Sam',status:'later',responded:false,respondedAt:null,participants:0}]),history:[{type:'opened',at:'2026-10-08T01:00:00Z',userId:19},{type:'edited',at:'2026-10-09T00:00:00Z',name:'Sam'}]}; + const f=await harness(managedEvent({invitees:[legacy,opened]}));await f.findButton('event-list','✉️ Guest invitations').onclick();for(const name of ['Alex','Sam']){const row=invitationRow(f,name);assert.equal(f.descendants(row).filter(node=>node.tag==='time').length,0);assert.doesNotMatch(textOf(f,row),/Last response:/);} +}); + +test('a manager access change during refresh closes the invitation dialog and clears private rows',async()=>{ + const f=await harness(managedEvent({invitees:[managedInvite('Alex',11)]}));await f.findButton('event-list','✉️ Guest invitations').onclick();f.setEvent({...f.getEvent(),isOwner:false,isManager:false,isCoHost:false,invitees:[]});await f.ids.get('invitation-links-refresh').onclick();assert.equal(f.ids.get('invitation-links-dialog').open,false);assert.equal(invitationRows(f).length,0);assert.match(f.ids.get('notice').textContent,/management access has changed/); +}); + +test('refresh closes an edit or response form when another manager revokes that invitation',async()=>{ + for(const label of ['✏️ Edit invite','↻ Change response']){ + const invite=managedInvite('Alex',11,[{id:18,name:'Alex',status:'yes',participants:2}]),f=await harness(managedEvent({invitees:[invite]}));await f.findButton('event-list','✉️ Guest invitations').onclick();await rowAction(f,invitationRow(f,'Alex'),label).onclick();assert.equal(rowPanel(f,invitationRow(f,'Alex')).hidden,false); + f.setEvent({...f.getEvent(),invitationsVersion:'1111111111111111',invitees:[],revokedInvitees:[{...invite,revoked:true}]});await f.ids.get('invitation-links-refresh').onclick();const row=invitationRow(f,'Alex');assert.equal(rowPanel(f,row).hidden,true);assert.equal(rowAction(f,row,'Save invite'),undefined);assert.equal(rowAction(f,row,'Save response'),undefined);assert.equal(rowAction(f,row,'Alex').disabled,true); + } +}); + +test('closing while a response change is pending prevents a late result from reopening its form',async()=>{ + const f=await harness(managedEvent({invitees:[managedInvite('Alex',11,[{id:18,name:'Alex',status:'yes',participants:2}])]}));await f.findButton('event-list','✉️ Guest invitations').onclick();await rowAction(f,invitationRow(f,'Alex'),'↻ Change response').onclick();const row=invitationRow(f,'Alex');rowField(f,row,'status').value='maybe';rowField(f,row,'status').onchange();const release=f.holdNextInvitationMutation(),saving=rowAction(f,row,'Save response').onclick();f.ids.get('invitation-links-close').onclick();release();await saving;assert.equal(f.ids.get('invitation-links-dialog').open,false);assert.equal(f.getEvent().invitees[0].responses[0].status,'maybe');await f.findButton('event-list','✉️ Guest invitations').onclick();assert.match(textOf(f,invitationRow(f,'Alex')),/Maybe/);assert.equal(rowPanel(f,invitationRow(f,'Alex')).hidden,true); +}); + test('one-by-one guests append to an unsaved event list and preserve every attendee setting',async()=>{ const f=await harness(managedEvent());f.ids.get('hero-create').onclick(); f.ids.get('invitation-mode').value='named';f.ids.get('invitation-mode').onchange();f.ids.get('guest-names').value='Existing = 2!'; @@ -611,11 +842,31 @@ test('guest roster opens manual additions and revokes the exact invitation mappe const f=await harness(managedEvent({invitees:[wrong,right],guestRoster:[{id:19,name:'Alex',status:'maybe',participants:0},{id:null,name:'Unopened',status:'unopened',participants:0}]})); await f.findButton('event-list','👥 Guest list').onclick();assert.equal(f.ids.get('guest-list-add').hidden,false); await f.findButton('guest-list-rows','🚫 Revoke invitation').onclick();assert.equal(f.ids.get('guest-list-dialog').open,false);assert.equal(f.ids.get('invitation-links-dialog').open,true); - const row=invitationRow(f,'Sam'),panel=f.descendants(row).find(node=>node.className==='invitation-remove');assert.equal(panel.hidden,false);assert.equal(f.descendants(invitationRow(f,'Alex')).find(node=>node.className==='invitation-remove').hidden,true); - await rowAction(f,row,'Remove silently').onclick();assert.equal(f.calls.find(call=>call.path.endsWith('/invitations/remove')).body.token,right.token); + const row=invitationRow(f,'Sam');assert.equal(rowPanel(f,row).hidden,false);assert.equal(rowPanel(f,invitationRow(f,'Alex')).hidden,true); + await rowAction(f,row,'Revoke invite').onclick();assert.equal(f.calls.find(call=>call.path.endsWith('/invitations/revoke')).body.token,right.token); f.ids.get('invitation-links-close').onclick();await f.findButton('event-list','👥 Guest list').onclick();await f.ids.get('guest-list-add').onclick();assert.equal(f.ids.get('guest-list-dialog').open,false);assert.equal(f.ids.get('invitation-add').open,true);assert.equal(f.ids.get('invitation-single').open,true);assert.equal(f.ids.get('invitation-single-name').focused,true); }); +test('guest roster RSVP shortcut selects the exact guest and supports silent or notified changes',async()=>{ + for(const notify of [false,true]){ + const alex={id:18,name:'Alex',status:'yes',participants:2},sam={id:19,name:'Sam',status:'maybe',participants:0},invite=managedInvite('Team',11,[alex,sam]); + const f=await harness(managedEvent({isOwner:false,isManager:true,invitees:[invite],guestRoster:[sam]})); + await f.findButton('event-list','👥 Guest list').onclick();await f.findButton('guest-list-rows','↻ Change RSVP').onclick(); + const row=invitationRow(f,'Team');assert.equal(f.ids.get('guest-list-dialog').open,false);assert.equal(rowPanel(f,row).dataset.kind,'response');assert.equal(rowField(f,row,'userId').value,'19');assert.equal(rowField(f,row,'status').value,'maybe'); + rowField(f,row,'status').value='no';rowField(f,row,'status').onchange();rowField(f,row,'notify').checked=notify; + await rowAction(f,row,'Save response').onclick();const request=f.calls.find(call=>call.path.endsWith('/invitations/response')); + assert.equal(request.body.userId,19);assert.equal(request.body.token,invite.token);assert.equal(request.body.status,'no');assert.equal(request.body.notify,notify);assert.deepEqual(f.getEvent().invitees[0].responses[0],alex); + } +}); + +test('guest roster edit shortcut opens the invitation editor and unopened guests cannot change RSVP',async()=>{ + const invite=managedInvite('Alex',11),f=await harness(managedEvent({invitees:[invite],guestRoster:[{id:null,name:'Alex',status:'unopened'}]})); + await f.findButton('event-list','👥 Guest list').onclick();assert.equal(f.findButton('guest-list-rows','↻ Change RSVP').disabled,true); + await f.findButton('guest-list-rows','✏️ Edit invite').onclick();assert.equal(rowPanel(f,invitationRow(f,'Alex')).dataset.kind,'edit'); + f.ids.get('invitation-links-close').onclick();f.setEvent({...f.getEvent(),group:'Past events'});await f.findButton('event-list','👥 Guest list').onclick(); + assert.equal(f.findButton('guest-list-rows','✏️ Edit invite').disabled,true);assert.equal(f.findButton('guest-list-rows','↻ Change RSVP').disabled,true); +}); + test('ticket-booking guest rosters omit named-invite add and revoke controls',async()=>{ const f=await harness(managedEvent({invitationMode:'tickets',invitees:[],guestRoster:[{id:19,name:'Alex',status:'yes',participants:1,confirmed:true}]}));await f.findButton('event-list','👥 Guest list').onclick(); assert.equal(f.ids.get('guest-list-add').hidden,true);assert.equal(f.ids.get('guest-list-invitations').hidden,true);assert.equal(f.findButton('guest-list-rows','🚫 Revoke invitation'),undefined);assert.ok(f.ids.get('guest-list-manage').onclick); @@ -625,7 +876,7 @@ test('individual additions share the mutation guard and cannot update a closed i const f=await harness(managedEvent({invitees:[managedInvite('Alex',11)]}));await f.findButton('event-list','✉️ Guest invitations').onclick(); const release=f.holdNextInvitationMutation();f.ids.get('invitation-single-name').value='Sam';const adding=f.ids.get('invitation-single-add').onclick(); assert.equal(f.ids.get('invitation-single-add').disabled,true);assert.equal(f.ids.get('invitation-add-submit').disabled,true);assert.equal(f.ids.get('invitation-single-mode').disabled,true); - await rowAction(f,invitationRow(f,'Alex'),'Remove invite').onclick();assert.equal(f.calls.filter(call=>call.path.endsWith('/invitations/remove')).length,0); + await rowAction(f,invitationRow(f,'Alex'),'🚫 Revoke invite').onclick();assert.equal(f.calls.filter(call=>call.path.endsWith('/invitations/revoke')).length,0); f.ids.get('invitation-links-close').onclick();release();await adding;assert.equal(f.ids.get('invitation-links-dialog').open,false);assert.equal(f.ids.get('invitation-single-name').value,'Sam'); await f.findButton('event-list','✉️ Guest invitations').onclick();assert.ok(invitationRow(f,'Sam'));assert.equal(f.ids.get('invitation-single-name').value,'');assert.equal(f.ids.get('invitation-single-add').disabled,false); }); @@ -633,7 +884,7 @@ test('individual additions share the mutation guard and cannot update a closed i test('unopened roster revocation uses its personal link and finished events disable guest mutations',async()=>{ const invite=managedInvite('Alex',11),f=await harness(managedEvent({invitees:[invite],guestRoster:[{id:null,name:'Alex',status:'unopened',participants:0}]})); await f.findButton('event-list','👥 Guest list').onclick();await f.findButton('guest-list-rows','🚫 Revoke invitation').onclick(); - const row=invitationRow(f,'Alex');assert.equal(f.descendants(row).find(node=>node.className==='invitation-remove').hidden,false);assert.equal(rowAction(f,row,'Notify guest').disabled,true); + const row=invitationRow(f,'Alex');assert.equal(rowPanel(f,row).hidden,false);assert.equal(rowField(f,row,'notify').disabled,true); f.ids.get('invitation-links-close').onclick();f.setEvent({...f.getEvent(),group:'Past events'});await f.findButton('event-list','👥 Guest list').onclick();assert.equal(f.ids.get('guest-list-add').disabled,true);assert.equal(f.findButton('guest-list-rows','🚫 Revoke invitation').disabled,true); await f.ids.get('guest-list-invitations').onclick();assert.equal(f.ids.get('invitation-single-add').disabled,true);assert.equal(f.ids.get('invitation-add-submit').disabled,true);await f.ids.get('invitation-single-add').onclick();assert.equal(f.calls.filter(call=>call.path.endsWith('/invitations/add')).length,0); }); diff --git a/test/deployment.test.js b/test/deployment.test.js index 2baff44..6eddffd 100644 --- a/test/deployment.test.js +++ b/test/deployment.test.js @@ -35,14 +35,28 @@ test('invalid configuration reports no private input or parser excerpts', () => }); test('generated config takes application and assets from source and never includes credentials', async () => { - const dev = development(); dev.main = 'untrusted.js'; dev.assets = { directory: '../private' }; dev.vars.TELEGRAM_BOT_TOKEN = 'fictional-secret'; dev.triggers = { crons: [] }; dev.env = { production: production() }; + const dev = development(); dev.main = 'untrusted.js'; dev.assets = { directory: '../private' }; dev.vars.TELEGRAM_BOT_TOKEN = 'fictional-secret'; dev.vars.WHITELIST_USER_IDS = '900000001,900000002'; dev.triggers = { crons: [] }; dev.env = { production: production() }; const config = await buildConfig(dev, 'development'); assert.equal(config.main, 'src/worker.js'); assert.equal(config.assets.directory, './public'); assert.equal(config.assets.binding, 'ASSETS'); assert.equal(config.d1_databases[0].binding, 'DB'); assert.equal(config.d1_databases[0].migrations_dir, 'migrations'); assert.equal(config.vars.APP_ENV, 'development'); assert.deepEqual(config.triggers.crons, []); - assert.equal(config.vars.TELEGRAM_BOT_TOKEN, undefined); assert.equal(config.env, undefined); - assert.doesNotMatch(JSON.stringify(config), /fictional-secret|untrusted|private/); + assert.equal(config.vars.TELEGRAM_BOT_TOKEN, undefined); assert.equal(config.vars.WHITELIST_USER_IDS, undefined); assert.equal(config.env, undefined); + assert.doesNotMatch(JSON.stringify(config), /fictional-secret|900000001|900000002|untrusted|private/); +}); + +test('test whitelist enablement is explicit and limited to development', async () => { + assert.equal((await buildConfig(development(), 'development')).vars.TEST_WHITELIST_ENABLED, 'false'); + for (const value of ['true', ' true ', true]) { + const dev = development(); dev.vars.TEST_WHITELIST_ENABLED = value; + assert.equal((await buildConfig(dev, 'development')).vars.TEST_WHITELIST_ENABLED, 'true'); + const prod = production(); prod.vars.TEST_WHITELIST_ENABLED = value; + assert.equal((await buildConfig(prod, 'production')).vars.TEST_WHITELIST_ENABLED, 'false'); + } + for (const value of ['false', 'TRUE', '1', '', false, null]) { + const dev = development(); dev.vars.TEST_WHITELIST_ENABLED = value; + assert.equal((await buildConfig(dev, 'development')).vars.TEST_WHITELIST_ENABLED, 'false'); + } }); test('development cron stays disabled until explicitly configured', async () => { diff --git a/test/event-actions-ui.test.js b/test/event-actions-ui.test.js index f1a5223..a6e60c6 100644 --- a/test/event-actions-ui.test.js +++ b/test/event-actions-ui.test.js @@ -68,12 +68,16 @@ test('direct delete has a separate permanent removal confirmation', async () => assert.equal(f.calls[0].path, 'events/' + event.id + '/delete'); }); -test('keep event and Escape dismiss without posting', async () => { +test('keep event, top close and Escape dismiss without posting', async () => { const f = harness(); let result = f.cancelEvent(fixture()); f.$('event-end-back').onclick(); assert.equal(await result, false); result = f.deleteEvent(fixture()); + f.$('event-end-close').onclick(); + assert.equal(await result, false); + assert.equal(f.$('event-end-dialog').open, false); + result = f.deleteEvent(fixture()); let prevented = false; f.$('event-end-dialog').oncancel({ preventDefault() { prevented = true; } }); assert.equal(prevented, true); @@ -114,10 +118,12 @@ test('busy confirmation blocks double posts, dismissal and changing action', asy const posting = f.$('event-end-confirm').onclick(); assert.equal(f.$('event-end-confirm').disabled, true); assert.equal(f.$('event-end-back').disabled, true); + assert.equal(f.$('event-end-close').disabled, true); assert.equal(f.$('event-end-delete').disabled, true); assert.equal(f.$('event-end-dialog').attributes['aria-busy'], 'true'); await f.$('event-end-confirm').onclick(); f.$('event-end-back').onclick(); + f.$('event-end-close').onclick(); f.$('event-end-dialog').oncancel({ preventDefault() {} }); assert.equal(f.$('event-end-dialog').open, true); assert.equal(await f.deleteEvent(fixture()), false); diff --git a/test/gallery-ux.test.js b/test/gallery-ux.test.js index ffa0952..5200bc0 100644 --- a/test/gallery-ux.test.js +++ b/test/gallery-ux.test.js @@ -2,6 +2,42 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {setupGallery} from '../public/gallery.js'; +function deferred(){let resolve,reject;const promise=new Promise((yes,no)=>{resolve=yes;reject=no;});return {promise,resolve,reject};} +function galleryHarness(t,api=async()=>({title:'Club meetup',media:[],canUpload:false})){ + class Node{ + constructor(tag='div',text=''){this.tagName=tag.toUpperCase();this.textContent=text;this.children=[];this.attributes={};this.isConnected=true;this.open=false;this.opens=0;this.pauses=0;this.loads=0;} + append(...children){this.children.push(...children);} + prepend(...children){this.children.unshift(...children);} + replaceChildren(...children){for(const child of this.children)child.isConnected=false;this.children=children;} + setAttribute(key,value){this.attributes[key]=value;} + removeAttribute(key){delete this.attributes[key];delete this[key];} + remove(){this.isConnected=false;} + showModal(){this.open=true;this.opens++;} + close(){this.open=false;this.onclose?.();} + pause(){this.pauses++;} + load(){this.loads++;} + } + const globals={document:globalThis.document,IntersectionObserver:globalThis.IntersectionObserver,fetch:globalThis.fetch}; + t.after(()=>{for(const [name,value] of Object.entries(globals)){if(value===undefined)delete globalThis[name];else globalThis[name]=value;}}); + globalThis.document={createElement:tag=>new Node(tag)}; + const observers=[]; + globalThis.IntersectionObserver=class{ + constructor(callback){this.callback=callback;observers.push(this);} + observe(node){this.node=node;} + disconnect(){} + intersect(){return this.callback([{isIntersecting:true,target:this.node}]);} + }; + const fetches=[],calls=[],notices=[],routes=[],nodes=new Map(); + let fetchResult=()=>Promise.resolve(new Response(new Blob(['fictional media'],{type:'video/mp4'}))); + globalThis.fetch=(...args)=>{fetches.push(args);return fetchResult(...args);}; + const $=id=>{if(!nodes.has(id))nodes.set(id,new Node());return nodes.get(id);}; + const element=(tag,text='',className='')=>Object.assign(new Node(tag,text),{className}); + const action=(text,onclick)=>Object.assign(new Node('button',text),{onclick}); + const controls=setupGallery({$,api:(...args)=>{calls.push(args);return api(...args);},element,action,go:route=>routes.push(route),notice:message=>notices.push(message),openTelegram(){},initData:'fictional-init-data'}); + const descendants=node=>[node,...node.children.flatMap(descendants)]; + return {$,calls,notices,routes,fetches,observers,...controls,setFetch(fn){fetchResult=fn;},button:text=>descendants($('gallery-list')).find(node=>node.tagName==='BUTTON' && node.textContent===text)}; +} + test('opening another gallery clears the previous event upload action while loading and after failure',async()=>{ const nodes=new Map(); const $=id=>{ @@ -33,3 +69,58 @@ test('opening another gallery clears the previous event upload action while load assert.equal($('gallery-error').hidden,false); assert.equal($('gallery-error').textContent,'Gallery unavailable.'); }); + +test('closing the page ignores a late gallery response and leaves its upload action disabled',async t=>{ + const pending=deferred(),f=galleryHarness(t,()=>pending.promise); + const loading=f.openGallery('first'); + f.dismissPendingMedia(); + pending.resolve({title:'Late private event',media:[],canUpload:true,uploadUrl:'https://t.me/fictional_bot?start=late'}); + await loading; + assert.equal(f.$('gallery-title').textContent,'Shared media'); + assert.equal(f.$('gallery-upload').hidden,true);assert.equal(f.$('gallery-upload').onclick,null); + assert.equal(f.$('photo-dialog').opens,0);assert.equal(f.$('qr-dialog').opens,0); + assert.deepEqual(f.routes,['gallery']);assert.deepEqual(f.notices,[]); +}); + +test('closing the page cancels a pending QR popup while a later explicit request still opens',async t=>{ + const pending=deferred(),qr={image:'data:image/gif;base64,R0lG',link:'https://t.me/fictional_bot?start=upload',anyone:false}; + const f=galleryHarness(t,path=>path.includes('/first/')?pending.promise:Promise.resolve(qr)); + const loading=f.showQr('first');f.dismissPendingMedia();pending.resolve(qr);await loading; + assert.equal(f.$('qr-dialog').opens,0);assert.equal(f.$('qr-dialog').open,false); + await f.showQr('second');assert.equal(f.$('qr-dialog').opens,1);assert.equal(f.$('qr-dialog').open,true); + f.dismissPendingMedia();assert.equal(f.$('qr-dialog').open,false); + assert.ok(f.calls.every(([,body])=>body===undefined),'Closing a QR popup must not write anything.'); +}); + +test('a delayed video preview cannot reopen after leaving and closing a playing preview stops video',async t=>{ + const video={id:'111111111111',type:'video',filename:'club-video.mp4',name:'Fictional guest',hasThumbnail:false}; + const f=galleryHarness(t,async()=>({title:'Club meetup',media:[video],canUpload:false})); + await f.openGallery('first'); + const pending=deferred();f.setFetch(()=>pending.promise); + const loading=f.button('Play video').onclick(); + f.dismissPendingMedia();pending.resolve(new Response(new Blob(['fictional video'],{type:'video/mp4'})));await loading; + assert.equal(f.$('photo-dialog').opens,0); + await f.openGallery('second'); + f.setFetch(()=>Promise.resolve(new Response(new Blob(['fictional video'],{type:'video/mp4'})))); + await f.button('Play video').onclick(); + assert.equal(f.$('photo-dialog').open,true);assert.ok(f.$('video-full').src); + f.dismissPendingMedia(); + assert.equal(f.$('photo-dialog').open,false);assert.equal(f.$('video-full').src,undefined); + assert.equal(f.$('video-full').pauses,1);assert.equal(f.$('video-full').loads,1); + assert.ok(f.calls.every(([,body])=>body===undefined),'Closing a preview must not send or change event files.'); +}); + +test('closing a photo popup suppresses an older pending preview without blocking a new click',async t=>{ + const photo={id:'222222222222',type:'photo',filename:'club-photo.jpg',name:'Fictional guest',hasThumbnail:true}; + const f=galleryHarness(t,async()=>({title:'Club meetup',media:[photo],canUpload:false})); + await f.openGallery('first');await f.observers[0].intersect(); + const image=f.observers[0].node; + await image.onclick();assert.equal(f.$('photo-dialog').opens,1); + const pending=deferred();f.setFetch(()=>pending.promise); + const loading=image.onclick();f.$('photo-close').onclick(); + pending.resolve(new Response(new Blob(['fictional photo'],{type:'image/jpeg'})));await loading; + assert.equal(f.$('photo-dialog').opens,1);assert.equal(f.$('photo-dialog').open,false); + f.setFetch(()=>Promise.resolve(new Response(new Blob(['fictional photo'],{type:'image/jpeg'})))); + await image.onclick();assert.equal(f.$('photo-dialog').opens,2);assert.equal(f.$('photo-dialog').open,true); + f.dismissPendingMedia(); +}); diff --git a/test/home-actions.test.js b/test/home-actions.test.js index f829609..ac3f390 100644 --- a/test/home-actions.test.js +++ b/test/home-actions.test.js @@ -102,7 +102,7 @@ for(const role of ['owner','cohost'])test('Home '+role+' actions open event tool const button=f.button(card,label);assert.ok(button,label+' must be available to '+role);await button.onclick(); assert.deepEqual(f.calls.at(-1),{kind,args:[arg]}); } - assert.equal(f.button(card,'🎟 My ticket'),undefined); + assert.equal(f.button(card,'🎟 Check-in code'),undefined); assert.equal(f.button(card,'🛑 Cancel event'),undefined,'Cancellation belongs in event management.'); }); @@ -115,14 +115,14 @@ test('Home attendee shortcuts respect media permission and ticket confirmation', ]){ const f=harness([event(fields)]);f.ui.renderHome();const card=f.cards('home-upcoming')[0]; assert.equal(!!f.button(card,'🗂 Shared media'),fields.status==='yes' && fields.permissions.viewMedia); - assert.equal(!!f.button(card,'🎟 My ticket'),!!fields.ticket); + assert.equal(!!f.button(card,'🎟 Check-in code'),!!fields.ticket); for(const label of ['✏️ Edit event','👥 Guest list','✉️ Invitations','🛑 Cancel event'])assert.equal(f.button(card,label),undefined,label+' must remain a management action.'); } }); test('Confirmed Home tickets open in app even when QR codes are disabled',async()=>{ const e=event({ticket:{name:'Guest',code:'TICKET'},qrEnabled:false}),f=harness([e]);f.ui.renderHome(); - await f.button(f.cards('home-upcoming')[0],'🎟 My ticket').onclick(); + await f.button(f.cards('home-upcoming')[0],'🎟 Check-in code').onclick(); assert.deepEqual(f.calls,[{kind:'ticket',args:[e.id]}]); }); diff --git a/test/invitation-history-flow.test.js b/test/invitation-history-flow.test.js new file mode 100644 index 0000000..99ad7d2 --- /dev/null +++ b/test/invitation-history-flow.test.js @@ -0,0 +1,102 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {invitationSettings,claimInvitation} from '../src/invitations.js'; +import {invitationHistory} from '../src/invitation-history.js'; +import {Bot} from '../src/bot.js'; + +function fixture(guestNames='Alex = 2') { + const e={id:'0123456789abcdef',owner:1,title:'Community dinner',invitationMode:'named',guests:{},media:[],permissions:{},startsAt:'2099-12-05T08:00:00Z',timezone:'UTC',askPhone:false,askComments:false}; + Object.assign(e,invitationSettings({guestNames},e)); + const token=Object.keys(e.invitees)[0],data={events:{[e.id]:e},sessions:{},preferences:{}},calls=[]; + const bot=new Bot({data},async(method,params)=>{calls.push({method,...params});return {};},'ExampleBot'); + const open=(id=2)=>bot.handle({message:{chat:{id,type:'private'},from:{id,first_name:'Guest'},text:`/start i_${e.id}_${token}`}}); + const response=(status,id=2,version=e.guests[id]?.responseVersion || 0)=>bot.handle({callback_query:{id:'query',from:{id,first_name:'Guest'},data:`r:${e.id}:${status}:${version}`}}); + return {e,token,data,calls,bot,open,response,history:()=>invitationHistory(e,token)}; +} + +test('new invitations record creation and successful first opens with real timestamps',async()=>{ + const f=fixture(); + assert.deepEqual(f.history().map(row=>row.type),['created']); + assert.ok(Number.isFinite(Date.parse(f.history()[0].at))); + await f.open();await f.open(); + assert.deepEqual(f.history().map(row=>row.type),['created','opened']); + assert.equal(f.history()[1].userId,2); + await f.response('yes'); + const before=structuredClone(f.history());await f.open(3); + assert.deepEqual(f.history(),before);assert.equal(f.e.guests[3],undefined); +}); + +test('existing invitations keep unknown past timestamps instead of inventing history',()=>{ + const token='a'.repeat(32),legacy={id:'0123456789abcdef',owner:1,invitationMode:'named',invitees:{[token]:{name:'Alex',claimedBy:null}},guests:{}}; + const next=invitationSettings({guestNames:'Alex\nSam'},legacy); + assert.deepEqual(invitationHistory(next,token),[]); + const added=Object.keys(next.invitees).find(key=>key!==token); + assert.deepEqual(invitationHistory(next,added).map(row=>row.type),['created']); + assert.equal(legacy.invitationHistory,undefined); +}); + +test('bulk editor records real edits and revokes unopened removed links without losing their history',()=>{ + const f=fixture('Alex = 2\nSam'),removed=Object.keys(f.e.invitees)[1],at=Date.parse('2026-10-08T10:00:00Z'); + const next=invitationSettings({guestNames:'Alex = 3'},f.e,at,6); + assert.equal(next.invitees[f.token].participants,3); + assert.equal(invitationHistory(next,f.token).at(-1).type,'edited'); + assert.equal(invitationHistory(next,f.token).at(-1).actorId,6); + assert.equal(invitationHistory(next,removed).at(-1).type,'revoked'); + assert.equal(next.removedInvitations[removed].revokedAt,'2026-10-08T10:00:00.000Z'); + assert.equal(next.removedInvitations[removed].invite.name,'Sam'); + assert.ok(f.e.invitees[removed]);assert.equal(f.e.removedInvitations,undefined); +}); + +test('completed RSVP and later changes are tracked without duplicate stale-button entries',async()=>{ + const f=fixture();await f.open();await f.response('yes'); + assert.deepEqual(f.history().map(row=>row.type),['created','opened','responded']); + assert.equal(f.history().at(-1).status,'yes');assert.equal(f.history().at(-1).participants,2); + await f.response('maybe');assert.equal(f.history().at(-1).type,'changed'); + assert.equal(f.history().at(-1).previousStatus,'yes');assert.equal(f.history().at(-1).status,'maybe'); + const history=structuredClone(f.history());await f.response('no',2,1); + assert.deepEqual(f.history(),history);assert.equal(f.e.guests[2].status,'maybe'); +}); + +test('explicit Respond later is recorded once even though an opened invite starts as later',async()=>{ + const f=fixture();await f.open();await f.response('later'); + assert.equal(f.history().at(-1).type,'responded');assert.equal(f.history().at(-1).status,'later'); + const history=structuredClone(f.history());await f.response('later');assert.deepEqual(f.history(),history); + assert.equal(f.e.invitees[f.token].respondedBy,undefined); + await f.response('yes');await f.response('later'); + assert.equal(f.history().at(-1).type,'changed');assert.equal(f.history().at(-1).previousStatus,'yes'); + assert.equal(f.e.invitees[f.token].respondedBy,2); +}); + +test('nonaccepted guest count changes are audited with before and after counts',async()=>{ + const f=fixture();await f.open();await f.response('maybe'); + const previous=f.e.guests[2]; + f.data.sessions[2]={event:f.e.id,countOnly:true,baseVersion:previous.responseVersion,response:{...previous},step:'participants'}; + await f.bot.chooseParticipants(2,f.e,f.data.sessions[2],4); + assert.equal(f.e.guests[2].participants,4);assert.equal(f.history().at(-1).type,'changed'); + assert.equal(f.history().at(-1).previousParticipants,2);assert.equal(f.history().at(-1).participants,4); +}); + +test('choosing an attendee count before RSVP stays awaiting a response until an explicit choice',async()=>{ + for(const status of ['later','yes']){ + const f=fixture();await f.open(); + const previous=f.e.guests[2]; + f.data.sessions[2]={event:f.e.id,countOnly:true,baseVersion:previous.responseVersion || 0,response:{...previous},step:'participants'}; + await f.bot.chooseParticipants(2,f.e,f.data.sessions[2],3); + assert.equal(f.e.guests[2].responseRecorded,false); + assert.equal(f.history().at(-1).type,'edited');assert.equal(f.history().at(-1).status,undefined); + await f.response(status); + assert.equal(f.e.guests[2].responseRecorded,true); + assert.equal(f.history().at(-1).type,'responded');assert.equal(f.history().at(-1).status,status); + } +}); + +test('refused payment or checked-in RSVP changes do not add false history',async()=>{ + for(const protectedState of ['paid','reported','checked-in']){ + const f=fixture();await f.open();await f.response('yes'); + const guest=f.e.guests[2]; + if(protectedState==='checked-in')f.e.checkIns={[guest.ticket]:{userId:2}}; + else guest.payment={status:protectedState}; + const before=structuredClone(f.history());await f.bot.saveResponse(2,f.e,{...guest,status:'no'}); + assert.deepEqual(f.history(),before);assert.equal(f.e.guests[2].status,'yes'); + } +}); diff --git a/test/invitation-management.test.js b/test/invitation-management.test.js index 4c63d0b..8ff5f0a 100644 --- a/test/invitation-management.test.js +++ b/test/invitation-management.test.js @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import {addInvitations,removeInvitation,invitationsVersion,managedInvitations} from '../src/invitation-management.js'; +import {addInvitations,removeInvitation,invitationsVersion,managedInvitations,revokedInvitations,editInvitation,changeInvitationResponse,revokeInvitation,deleteInvitation} from '../src/invitation-management.js'; +import {appendInvitationHistory,invitationHistory} from '../src/invitation-history.js'; import {invitationSettings,claimInvitation,invitationAvailable} from '../src/invitations.js'; import {publicEvent} from '../src/mini-api.js'; import {Bot} from '../src/bot.js'; @@ -168,3 +169,228 @@ test('removed unpaid invoice can no longer pass checkout',()=>{ f.data.preferences[2]={starOrders:{order:{id:'order',event:f.e.id,owner:1,unitPrice:10,pricing:'group',terms:'Terms',amount:10,participants:2,status:'pending'}}}; f.remove();assert.equal(checkout(f.bot,{id:'checkout',from:{id:2},currency:'XTR',total_amount:10,invoice_payload:'order'}).ok,false); }); + +const mutationInput=(f,fields={})=>({token:f.token,version:invitationsVersion(f.e),requestId,...fields}); +const editInput=(f,fields={})=>mutationInput(f,{name:'Alex Smith',participants:3,participantMode:'preset',...fields}); +const responseInput=(f,fields={})=>mutationInput(f,{userId:2,status:'yes',participants:2,notify:false,...fields}); +const revokeInput=(f,fields={})=>mutationInput(f,{confirm:true,notify:false,...fields}); + +test('editing keeps the invitation token and history, updates linked guests, and clears partial responses',()=>{ + const f=fixture();f.e.oneTimeInvite=false; + const guest=claimInvitation(f.e,f.token,2);Object.assign(guest,{status:'yes',participants:2,approval:'approved'}); + issueTicket(f.e,2);const ticket=guest.ticket;claimInvitation(f.e,f.token,3); + f.data.sessions={2:{event:f.e.id,step:'comment',response:{...guest}},3:{event:f.e.id,step:'phone'},8:{event:'other',step:'upload'}}; + const before=invitationHistory(f.e,f.token).length; + const input=editInput(f),result=editInvitation(f.e,1,input,f.data.sessions,Date.parse('2090-01-01T00:00:00Z')); + assert.equal(result.edited,true);assert.equal(f.e.invitees[f.token].name,'Alex Smith'); + assert.equal(f.e.guests[2].name,'Alex Smith');assert.equal(f.e.guests[3].name,'Alex Smith'); + assert.equal(f.e.guests[2].participants,3);assert.equal(f.e.guests[2].ticket,ticket); + assert.deepEqual(f.data.sessions,{8:{event:'other',step:'upload'}}); + assert.equal(invitationHistory(f.e,f.token).length,before+1); + assert.deepEqual(invitationHistory(f.e,f.token).at(-1),{type:'edited',at:'2090-01-01T00:00:00.000Z',actorId:1,actorRole:'organiser',name:'Alex Smith',previousName:'Alex',participants:3,previousParticipants:2,participantMode:'preset',previousParticipantMode:'preset'}); + assert.equal(editInvitation(f.e,1,input,f.data.sessions).alreadyApplied,true); + assert.equal(invitationHistory(f.e,f.token).length,before+1); + assert.throws(()=>editInvitation(f.e,1,{...input,name:'Different name'},f.data.sessions)); +}); + +test('invite edit rejects malformed and duplicate names, invalid settings, and stale lists without changes',()=>{ + for(const fields of [{name:''},{name:'A\nB'},{name:'A = 2'},{name:'Sam'},{participants:11},{participantMode:'invalid'},{participantMode:'ask',participants:2},{participantMode:'default',participants:1},{version:'stale'},{requestId:undefined}]){ + const f=fixture(),before=structuredClone(f.e); + assert.throws(()=>editInvitation(f.e,1,editInput(f,fields),f.data.sessions));assert.deepEqual(f.e,before); + } + for(const mode of ['default','ask','confirm','fixed']){ + const f=fixture();editInvitation(f.e,1,editInput(f,{participantMode:mode,participants:['default','ask'].includes(mode)?null:4})); + assert.equal(f.e.invitees[f.token].participantMode,['default'].includes(mode)?undefined:mode); + } +}); + +test('active payment and checked-in attendees can be renamed but their attendance settings stay protected',()=>{ + for(const status of ['reported','paid','processing','refund_pending','refund_failed','checked-in']){ + const f=fixture(),guest=claimInvitation(f.e,f.token,2); + Object.assign(guest,{status:'yes',participants:2,approval:'approved',ticket:'ABCDEF123456'}); + if(status==='checked-in')f.e.checkIns={ABCDEF123456:{userId:2,at:'2090-01-01T00:00:00Z',participants:2}}; + else guest.payment={status,order:'private-order'}; + const before=structuredClone(f.e); + assert.throws(()=>editInvitation(f.e,1,editInput(f),f.data.sessions),/payment|check-in/);assert.deepEqual(f.e,before); + editInvitation(f.e,1,editInput(f,{participants:2}),f.data.sessions); + assert.equal(f.e.guests[2].name,'Alex Smith');assert.equal(f.e.guests[2].ticket,'ABCDEF123456');assert.deepEqual(f.e.guests[2].payment,before.guests[2].payment);assert.deepEqual(f.e.checkIns,before.checkIns); + } +}); + +test('organiser RSVP requires an opened real guest and explicitly selects one reusable response',()=>{ + const f=fixture(),before=structuredClone(f.e); + assert.throws(()=>changeInvitationResponse(f.e,1,responseInput(f)),/open their invitation/);assert.deepEqual(f.e,before); + f.e.oneTimeInvite=false;claimInvitation(f.e,f.token,2);claimInvitation(f.e,f.token,3); + for(const userId of [undefined,null,'2',4])assert.throws(()=>changeInvitationResponse(f.e,1,responseInput(f,{userId}))); + f.data.sessions={2:{event:f.e.id,step:'phone'},3:{event:f.e.id,step:'phone'}}; + changeInvitationResponse(f.e,1,responseInput(f),f.data.sessions); + assert.equal(f.e.guests[2].status,'yes');assert.equal(f.e.guests[3].status,'later');assert.equal(f.data.sessions[2],undefined);assert.ok(f.data.sessions[3]); + assert.equal(invitationHistory(f.e,f.token).at(-1).actorRole,'organiser');assert.equal(invitationHistory(f.e,f.token).at(-1).userId,2); +}); + +test('organiser RSVP consumes the single-use link for its actual guest, keeps deadlines, and sends no retry notification',()=>{ + const f=fixture();claimInvitation(f.e,f.token,2);claimInvitation(f.e,f.token,3); + f.e.responseDeadline='2000-01-01T00:00:00Z';f.e.defaultReminder=120; + const input=responseInput(f,{notify:true}); + assert.deepEqual(changeInvitationResponse(f.e,1,input,f.data.sessions),{changed:true,status:'yes',notifyCount:1,recipients:[2]}); + assert.equal(f.e.invitees[f.token].respondedBy,2);assert.equal(f.e.guests[3],undefined);assert.equal(f.e.responseDeadline,'2000-01-01T00:00:00Z'); + assert.equal(invitationAvailable(f.e,3,f.token),false);assert.equal(verifyTicket(f.e,1,f.e.guests[2].ticket).valid,true);assert.equal(f.e.reminders[2].minutes,120); + assert.equal(f.e.guests[2].ticketCode,undefined,'organiser acceptance does not issue a rotating code before the guest requests it'); + const history=structuredClone(f.e.invitationHistory); + assert.deepEqual(changeInvitationResponse(f.e,1,input,f.data.sessions),{changed:true,status:'yes',notifyCount:0,alreadyApplied:true,recipients:[]});assert.deepEqual(f.e.invitationHistory,history); + const reordered=Object.fromEntries(Object.entries(input).reverse());assert.equal(changeInvitationResponse(f.e,1,reordered,f.data.sessions).alreadyApplied,true); + assert.throws(()=>changeInvitationResponse(f.e,1,{...input,status:'no'},f.data.sessions)); +}); + +test('organiser RSVP rejects invalid counts, fixed count changes and protected attendance atomically',()=>{ + for(const fields of [{status:'invalid'},{notify:undefined},{notify:'false'},{participants:0},{participants:11},{participants:'2'},{version:'stale'},{requestId:undefined}]){ + const f=fixture();claimInvitation(f.e,f.token,2);const before=structuredClone(f.e); + assert.throws(()=>changeInvitationResponse(f.e,1,responseInput(f,fields),f.data.sessions));assert.deepEqual(f.e,before); + } + for(const names of ['Alex = 2*','Alex']){ + const f=fixture(names);claimInvitation(f.e,f.token,2); + assert.throws(()=>changeInvitationResponse(f.e,1,responseInput(f,{participants:3})),/fixed attendee|one person/); + } + for(const status of ['reported','paid','processing','refund_pending','refund_failed','checked-in']){ + const f=fixture(),guest=claimInvitation(f.e,f.token,2);Object.assign(guest,{status:'yes',approval:'approved',ticket:'ABCDEF123456'}); + if(status==='checked-in')f.e.checkIns={ABCDEF123456:{userId:2,at:'2090-01-01'}};else guest.payment={status}; + const before=structuredClone(f.e); + assert.throws(()=>changeInvitationResponse(f.e,1,responseInput(f,{status:'no'})));assert.deepEqual(f.e,before); + assert.throws(()=>changeInvitationResponse(f.e,1,responseInput(f,{participants:3})));assert.deepEqual(f.e,before); + } +}); + +test('leaving acceptance revokes ticket and reminders while later remains linked to the same holder',()=>{ + for(const status of ['no','maybe','later']){ + const f=fixture(),guest=claimInvitation(f.e,f.token,2);Object.assign(guest,{status:'yes',approval:'approved'});issueTicket(f.e,2); + f.e.invitees[f.token].respondedBy=2;f.e.reminders={2:{minutes:120}};const ticket=guest.ticket; + changeInvitationResponse(f.e,1,responseInput(f,{status})); + assert.equal(f.e.guests[2].status,status);assert.equal(f.e.guests[2].ticket,undefined);assert.equal(f.e.reminders[2],undefined);assert.equal(verifyTicket(f.e,1,ticket).valid,false);assert.equal(f.e.invitees[f.token].respondedBy,2); + } +}); + +test('organiser acceptance preserves payment requirements and never creates an unpaid ticket',()=>{ + const f=fixture();Object.assign(f.e,{paymentMethod:'stars',starPrice:10,starPricing:'person'});claimInvitation(f.e,f.token,2); + changeInvitationResponse(f.e,1,responseInput(f)); + assert.equal(f.e.guests[2].status,'yes');assert.equal(f.e.guests[2].approval,'approved');assert.equal(f.e.guests[2].ticket,undefined);assert.equal(f.e.guests[2].payment,undefined);assert.equal(responseCounts(f.e).awaitingPayment,1); +}); + +test('organiser records explicit Respond later once instead of treating an opened placeholder as an RSVP',()=>{ + const f=fixture();claimInvitation(f.e,f.token,2); + const input=responseInput(f,{status:'later',notify:true}); + assert.equal(changeInvitationResponse(f.e,1,input).changed,true); + assert.equal(invitationHistory(f.e,f.token).at(-1).type,'responded'); + assert.equal(invitationHistory(f.e,f.token).at(-1).status,'later'); + const before=structuredClone(f.e.invitationHistory); + assert.equal(changeInvitationResponse(f.e,1,input).alreadyApplied,true); + const next=responseInput(f,{status:'later',notify:true,requestId:'22222222-2222-4222-8222-222222222222'}); + assert.equal(changeInvitationResponse(f.e,1,next).changed,false);assert.deepEqual(f.e.invitationHistory,before); +}); + +test('revocation retains a manager-only timeline and archived RSVP, invalidates access, and deletion retains audit records',()=>{ + const f=fixture(),guest=claimInvitation(f.e,f.token,2);Object.assign(guest,{status:'yes',participants:2,approval:'approved',ticket:'ABCDEF123456',phone:'private phone',comment:'private comment',payment:{status:'paid',order:'private-order'}}); + f.e.checkIns={ABCDEF123456:{userId:2,at:'2090-01-01T00:00:00Z',participants:2}};f.e.reminders={2:{minutes:120}}; + f.data.preferences[2]={starOrders:{'private-order':{status:'paid',charge:'private-charge'}}}; + f.data.sessions[2]={event:f.e.id,step:'upload'};const input=revokeInput(f,{notify:true}); + assert.deepEqual(revokeInvitation(f.e,1,input,f.data.sessions,Date.parse('2090-01-01T01:00:00Z')),{revoked:true,notifyCount:1,recipients:[2]}); + assert.equal(f.e.guests[2],undefined);assert.equal(f.e.invitees[f.token],undefined);assert.equal(f.e.reminders[2],undefined);assert.equal(f.data.sessions[2],undefined); + assert.ok(f.e.checkIns.ABCDEF123456);assert.equal(f.data.preferences[2].starOrders['private-order'].charge,'private-charge');assert.equal(verifyTicket(f.e,1,'ABCDEF123456').valid,false); + const dto=publicEvent(f.e,1,'ExampleBot'),revoked=dto.revokedInvitees[0]; + assert.equal(revoked.token,f.token);assert.equal(revoked.url,null);assert.equal(revoked.revoked,true);assert.equal(revoked.responses[0].participants,2);assert.equal(revoked.responses[0].confirmed,false);assert.equal(revoked.history.at(-1).type,'revoked'); + assert.doesNotMatch(JSON.stringify(dto),/private phone|private-order|private-charge|ABCDEF123456/);assert.equal(publicEvent(f.e,9,'ExampleBot').revokedInvitees,undefined); + assert.equal(revokeInvitation(f.e,1,input,f.data.sessions).alreadyApplied,true); + const deletion=mutationInput(f,{confirm:true,requestId:'22222222-2222-4222-8222-222222222222'}); + assert.equal(deleteInvitation(f.e,1,deletion).deleted,true);assert.deepEqual(revokedInvitations(f.e,'ExampleBot'),[]);assert.equal(f.e.removedInvitations[f.token].responses[2].payment.order,'private-order');assert.equal(invitationHistory(f.e,f.token).at(-1).type,'deleted'); + assert.equal(deleteInvitation(f.e,1,deletion).alreadyApplied,true); +}); + +test('revoke and delete require manager permission, current versions, and explicit confirmations',()=>{ + for(const operation of [editInvitation,changeInvitationResponse,revokeInvitation,deleteInvitation]){ + const f=fixture();claimInvitation(f.e,f.token,2); + assert.throws(()=>operation(f.e,2,mutationInput(f),f.data.sessions),/Only an organiser/); + } + const f=fixture(),before=structuredClone(f.e); + assert.throws(()=>deleteInvitation(f.e,1,mutationInput(f,{confirm:true})),/Revoke/);assert.deepEqual(f.e,before); + for(const fields of [{confirm:false},{notify:undefined},{requestId:undefined},{version:'stale'}]){ + assert.throws(()=>revokeInvitation(f.e,1,revokeInput(f,fields),f.data.sessions));assert.deepEqual(f.e,before); + } + f.e.cohost={id:6,name:'Assistant host'};revokeInvitation(f.e,6,revokeInput(f),f.data.sessions); + assert.equal(revokedInvitations(f.e,'ExampleBot').length,1); +}); + +test('history sanitizes manager DTOs without inventing timestamps or leaking arbitrary stored fields',()=>{ + const f=fixture();delete f.e.invitationHistory; + assert.deepEqual(managedInvitations(f.e,'ExampleBot')[0].history,[]);assert.equal(managedInvitations(f.e,'ExampleBot')[0].historyIncomplete,true); + appendInvitationHistory(f.e,undefined,'responded',{phone:'private'},0);assert.equal(f.e.invitationHistory,undefined); + appendInvitationHistory(f.e,f.token,'opened',{userId:2,name:'Alex',phone:'private',payment:{charge:'private'}},Date.parse('2090-01-01T00:00:00Z')); + f.e.invitationHistory[f.token].push({type:'responded',at:'2090-01-01T01:00:00Z',status:'yes',phone:'private',ticket:'private',actorId:1,actorRole:'organiser',userId:2}); + const dto=managedInvitations(f.e,'ExampleBot')[0];assert.equal(dto.history.length,2);assert.doesNotMatch(JSON.stringify(dto.history),/private|phone|ticket|charge/); + const version=invitationsVersion(f.e);appendInvitationHistory(f.e,f.token,'edited',{name:'Alex'},Date.parse('2090-01-01T02:00:00Z'));assert.notEqual(invitationsVersion(f.e),version); +}); + +test('manager response DTO distinguishes opened placeholders, explicit Later responses, and unknown legacy response times',()=>{ + const f=fixture();f.e.oneTimeInvite=false; + claimInvitation(f.e,f.token,2);claimInvitation(f.e,f.token,3);claimInvitation(f.e,f.token,4);claimInvitation(f.e,f.token,5); + Object.assign(f.e.guests[3],{status:'yes',responseVersion:0}); + Object.assign(f.e.guests[4],{status:'later',responseVersion:1}); + for(const uid of [3,4,5])delete f.e.guests[uid].responseRecorded; + const older='2090-01-01T01:00:00Z',latest='2090-01-01T03:00:00Z'; + appendInvitationHistory(f.e,f.token,'responded',{userId:5,status:'yes'},Date.parse(older)); + appendInvitationHistory(f.e,f.token,'changed',{userId:5,status:'later'},Date.parse(latest)); + appendInvitationHistory(f.e,f.token,'changed',{userId:5,status:'maybe'},Date.parse('2090-01-01T02:00:00Z')); + appendInvitationHistory(f.e,f.token,'changed',{userId:3,status:'no'},Date.parse('2090-01-01T04:00:00Z')); + const byId=Object.fromEntries(managedInvitations(f.e,'ExampleBot')[0].responses.map(response=>[response.id,response])); + assert.equal(byId[2].responded,false);assert.equal(byId[2].respondedAt,null); + assert.equal(byId[3].responded,true);assert.equal(byId[3].respondedAt,'2090-01-01T04:00:00.000Z'); + assert.equal(byId[4].responded,true);assert.equal(byId[4].respondedAt,null); + assert.equal(byId[5].responded,true);assert.equal(byId[5].respondedAt,'2090-01-01T03:00:00.000Z'); + delete f.e.invitationHistory; + const legacy=managedInvitations(f.e,'ExampleBot')[0].responses.find(response=>response.id===3); + assert.equal(legacy.responded,true);assert.equal(legacy.respondedAt,null); + assert.equal(publicEvent(f.e,2,'ExampleBot').invitees,undefined); +}); + +test('editing a placeholder attendee count keeps it awaiting its first actual response',()=>{ + const f=fixture();claimInvitation(f.e,f.token,2); + editInvitation(f.e,1,editInput(f)); + const response=managedInvitations(f.e,'ExampleBot')[0].responses[0]; + assert.equal(response.responded,false);assert.equal(response.respondedAt,null);assert.equal(f.e.guests[2].responseVersion,undefined); + changeInvitationResponse(f.e,1,responseInput(f,{status:'later',requestId:'22222222-2222-4222-8222-222222222222'})); + assert.equal(managedInvitations(f.e,'ExampleBot')[0].responses[0].responded,true); +}); + +test('count-only response versions do not replace an explicit unanswered marker',()=>{ + const f=fixture();claimInvitation(f.e,f.token,2); + Object.assign(f.e.guests[2],{responseRecorded:false,responseVersion:3,participants:4}); + appendInvitationHistory(f.e,f.token,'edited',{actorRole:'guest',actorId:2,userId:2,participants:4,previousParticipants:2},Date.parse('2090-01-01T00:00:00Z')); + let response=managedInvitations(f.e,'ExampleBot')[0].responses[0]; + assert.equal(response.responded,false);assert.equal(response.respondedAt,null); + editInvitation(f.e,1,editInput(f)); + assert.equal(f.e.guests[2].responseVersion,3);assert.equal(managedInvitations(f.e,'ExampleBot')[0].responses[0].responded,false); + changeInvitationResponse(f.e,1,responseInput(f,{status:'later',requestId:'22222222-2222-4222-8222-222222222222'}),{},Date.parse('2090-01-01T01:00:00Z')); + response=managedInvitations(f.e,'ExampleBot')[0].responses[0]; + assert.equal(f.e.guests[2].responseRecorded,true);assert.equal(f.e.guests[2].responseVersion,4); + assert.equal(response.responded,true);assert.equal(response.respondedAt,'2090-01-01T01:00:00.000Z');assert.equal(invitationHistory(f.e,f.token).at(-1).type,'responded'); +}); + +test('manager guest roster distinguishes awaiting replies from explicit Later and legacy responses',()=>{ + const f=fixture();f.e.oneTimeInvite=false; + for(const uid of [2,3,4,5,6,7,8])claimInvitation(f.e,f.token,uid); + Object.assign(f.e.guests[3],{responseRecorded:false,responseVersion:4,participants:3,phone:'private phone',comment:'private comment'}); + Object.assign(f.e.guests[4],{responseRecorded:true,responseVersion:1}); + for(const uid of [5,6,7])delete f.e.guests[uid].responseRecorded; + f.e.guests[5].status='yes';f.e.guests[6].responseVersion=1; + appendInvitationHistory(f.e,f.token,'responded',{userId:7,status:'later'},Date.parse('2090-01-01T01:00:00Z')); + Object.assign(f.e.guests[8],{responseRecorded:false,responseVersion:10,status:'yes'}); + f.e.guests[1]={name:'Owner',status:'yes'}; + const roster=publicEvent(f.e,1,'ExampleBot').guestRoster; + const byId=Object.fromEntries(roster.filter(g=>g.id!==null).map(g=>[g.id,g])); + assert.equal(byId[1],undefined);assert.equal(byId[2].responded,false);assert.equal(byId[3].responded,false); + for(const uid of [4,5,6,7])assert.equal(byId[uid].responded,true); + assert.equal(byId[8].responded,false); + for(const guest of roster.filter(g=>g.id===null)){assert.equal(guest.status,'unopened');assert.equal(guest.responded,false);} + assert.doesNotMatch(JSON.stringify(roster),/private phone|private comment|invitationToken|responseRecorded|responseVersion/); + assert.equal(publicEvent(f.e,2,'ExampleBot').guestRoster,undefined); + f.e.invitationMode='legacy';f.e.guests[9]={name:'Legacy guest',status:'maybe'}; + assert.equal(publicEvent(f.e,1,'ExampleBot').guestRoster.find(g=>g.id===9).responded,true); +}); diff --git a/test/invite-ux.test.js b/test/invite-ux.test.js index 4cea48a..3b32e30 100644 --- a/test/invite-ux.test.js +++ b/test/invite-ux.test.js @@ -72,15 +72,29 @@ test('custom invitation message reaches event cards and public or personal Teleg } }); -test('QR disabled removes guest QR buttons while preserving a confirmed ticket and code',async()=>{ +test('check-in codes are requested from the ticket page even with QR disabled',async()=>{ const f=fixture('tickets',{qrEnabled:false,askPhone:false,askComments:false}); await f.open();await f.accept();await f.msg(2,'Ticket holder'); assert.equal(f.e.guests[2].status,'yes');assert.ok(f.e.guests[2].ticket); const ticketMessage=f.calls.find(c=>c.chat_id===2 && c.text?.includes('YOUR TICKET')); - assert.ok(ticketMessage.text.includes(`Ticket: ${f.e.guests[2].ticket}`)); + assert.equal(ticketMessage.text.includes(f.e.guests[2].ticket),false); + assert.equal(f.e.guests[2].ticketCode,undefined,'confirmation does not generate a code in the background'); + assert.ok(ticketMessage.reply_markup.inline_keyboard.flat().some(b=>b.text==='🎟 Check-in code' && b.web_app.url.endsWith(`?ticket=${f.e.id}`))); assert.equal(f.calls.some(c=>c.chat_id===2 && c.reply_markup?.inline_keyboard?.flat().some(b=>/QR/.test(b.text))),false); f.calls.length=0;await f.cb(2,`ticket:${f.e.id}`); assert.equal(f.calls.some(c=>c.reply_markup?.inline_keyboard?.flat().some(b=>/QR/.test(b.text))),false); f.e.qrEnabled=true;f.calls.length=0;await f.bot.ticket(2,f.e); - assert.ok(f.calls.some(c=>c.reply_markup?.inline_keyboard?.flat().some(b=>b.text==='🎟 Ticket QR'))); + assert.ok(f.calls.some(c=>c.reply_markup?.inline_keyboard?.flat().some(b=>b.text==='🎟 Check-in code'))); +}); + +test('bot-only installations show an on-demand six-digit code instead of the permanent ticket identifier',async()=>{ + const f=fixture('tickets',{qrEnabled:false,askPhone:false,askComments:false});f.bot.appUrl=''; + await f.open();await f.accept();await f.msg(2,'Ticket holder'); + assert.equal(f.e.guests[2].ticketCode,undefined); + f.calls.length=0;await f.cb(2,`ticket:${f.e.id}`); + const message=f.calls.find(c=>c.chat_id===2 && c.text?.includes('YOUR TICKET')); + assert.match(message.text,/Check-in code: \d{6}\nValid this minute/); + assert.equal(message.text.includes(f.e.guests[2].ticket),false); + f.e.endsAt='2000-01-01T00:00:00Z';f.calls.length=0;await f.cb(2,`ticket:${f.e.id}`); + assert.match(f.calls.at(-1).text,/finished/); }); diff --git a/test/named-settings.test.js b/test/named-settings.test.js index 40344d8..c9cab03 100644 --- a/test/named-settings.test.js +++ b/test/named-settings.test.js @@ -35,7 +35,7 @@ test('legacy approval privacy remains until named acceptance, then confirmation g.status='yes';assert.equal(confirmed(f.e,g),true);assert.equal(canSeeLocation(f.e,2),true); assert.equal(responseCounts(f.e).yes,1);assert.equal(responseCounts(f.e).pending,0); const ticket=issueTicket(f.e,2);assert.ok(ticket.code);assert.equal(ticket.participants,1); - const accepted=publicEvent(f.e,2,'ExampleBot');assert.equal(accepted.location,'Private venue');assert.equal(accepted.approval,'approved');assert.equal(accepted.ticket.code,ticket.code); + const accepted=publicEvent(f.e,2,'ExampleBot');assert.equal(accepted.location,'Private venue');assert.equal(accepted.approval,'approved');assert.equal(accepted.ticket.code,undefined);assert.equal(accepted.ticket.name,g.name);assert.equal(accepted.ticket.participants,ticket.participants); const owner=publicEvent(f.e,1,'ExampleBot');assert.equal(owner.guestRoster[0].approval,'approved'); const admin=adminOverview([{kind:'events',id:f.e.id,data:JSON.stringify(f.e)}]).events[0];assert.equal(admin.requireApproval,false);assert.equal(admin.askParticipantCount,false);assert.equal(admin.hideLocation,true);assert.equal(admin.guests[0].approval,'approved'); }); diff --git a/test/snapshot-mode.test.js b/test/snapshot-mode.test.js new file mode 100644 index 0000000..741d1ab --- /dev/null +++ b/test/snapshot-mode.test.js @@ -0,0 +1,181 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import worker, { drainOutbox, processUpdate } from '../src/worker.js'; +import { snapshotActive, snapshotDeliveryAllowed, snapshotPaymentMessage } from '../src/snapshot-mode.js'; +import { invoice, checkout, requestRefund } from '../src/payments.js'; +import { miniApi } from '../src/mini-api.js'; +import { createHmac } from 'node:crypto'; + +function apiRequest(path, input) { + const params = new URLSearchParams({ auth_date: String(Math.floor(Date.now() / 1000)), user: JSON.stringify({ id: 111001, first_name: 'Fictional tester' }), query_id: 'fictional-snapshot-query' }); + const text = [...params.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${key}=${value}`).join('\n'); + const key = createHmac('sha256', 'WebAppData').update('snapshot-test-token').digest(); + params.set('hash', createHmac('sha256', key).update(text).digest('hex')); + return new Request('https://fictional.test/api/' + path, { method: input ? 'POST' : 'GET', headers: { Authorization: 'tma ' + params.toString(), 'Content-Type': 'application/json' }, ...(input ? { body: JSON.stringify(input) } : {}) }); +} + +function fixture({ snapshot = { value: '{}' }, stored = { value: '["111002"]' }, ...variables } = {}) { + return { + APP_ENV: 'development', SUPER_ADMIN_ID: '999001', ...variables, + DB: { + prepare(sql) { + if (sql === "SELECT value FROM app_settings WHERE key='production-snapshot'") return { async first() { return snapshot; } }; + if (sql === "SELECT value FROM app_settings WHERE key='test-whitelist-enabled'") return { async first() { return null; } }; + if (sql === "SELECT value FROM app_settings WHERE key='test-whitelist'") return { async first() { return stored; } }; + assert.fail('Unexpected database query.'); + } + } + }; +} + +test('only development activates the snapshot marker and any marker value protects data', async () => { + for (const value of ['', 'null', 'false', 'not-json', null]) assert.equal(await snapshotActive(fixture({ snapshot: { value } })), true); + assert.equal(await snapshotActive(fixture({ snapshot: null })), false); + for (const APP_ENV of ['production', undefined, 'preview']) { + const env = { APP_ENV, DB: { prepare() { assert.fail('Production must ignore the marker.'); } } }; + assert.equal(await snapshotActive(env), false); + assert.equal(await snapshotDeliveryAllowed(env, 'refundStarPayment', { user_id: 111001 }), true); + } +}); + +test('copied database deliveries only reach admin and explicitly listed testers', async () => { + const env = fixture({ TEST_WHITELIST_ENABLED: 'false' }); + for (const chat_id of [999001, '999001', 111002, '111002']) assert.equal(await snapshotDeliveryAllowed(env, 'sendMessage', { chat_id }), true); + for (const chat_id of [111001, -111002, 0, '@FictionalChannel', '00111002', Number.MAX_SAFE_INTEGER + 1]) assert.equal(await snapshotDeliveryAllowed(env, 'sendMessage', { chat_id }), false); + assert.equal(await snapshotDeliveryAllowed(env, 'sendMessage', { chat_id: 999001, user_id: 111001 }), false); + assert.equal(await snapshotDeliveryAllowed(env, 'sendMessage', { user_id: 111002 }), true); + assert.equal(await snapshotDeliveryAllowed(env, 'setMyCommands', { scope: { type: 'chat', chat_id: 999001 } }), true); + assert.equal(await snapshotDeliveryAllowed(env, 'answerCallbackQuery', { callback_query_id: 'fictional-callback' }), true); +}); + +test('empty and malformed tester lists fail closed for outbound snapshot delivery', async () => { + for (const stored of [{ value: '[]' }, { value: 'not-json' }, null]) { + const env = fixture({ stored }); + assert.equal(await snapshotDeliveryAllowed(env, 'sendPhoto', { chat_id: 111002 }), false); + assert.equal(await snapshotDeliveryAllowed(env, 'sendPhoto', { chat_id: 999001 }), true); + } +}); + +test('snapshot financial methods are blocked for every recipient', async () => { + for (const method of ['sendInvoice', 'createInvoiceLink', 'refundStarPayment']) { + assert.equal(await snapshotDeliveryAllowed(fixture(), method, { chat_id: 999001, user_id: 999001 }), false); + assert.equal(await snapshotDeliveryAllowed(fixture({ snapshot: null }), method, { chat_id: 111001 }), true); + } +}); + +test('payment entry points leave imported orders and guest payment audit unchanged', async () => { + const event = { id: 'fictional', guests: { 111002: { payment: { status: 'paid', order: 'fictional-order' } } } }; + const order = { id: 'fictional-order', charge: 'fictional-charge', status: 'paid' }; + const before = JSON.stringify({ event, order }); + const sent = []; + const bot = { productionSnapshot: true, send: async (...args) => sent.push(args), api() { assert.fail('No financial API request is permitted.'); } }; + await invoice(bot, 111002, event, true); + assert.deepEqual(checkout(bot, { id: 'fictional-checkout' }), { pre_checkout_query_id: 'fictional-checkout', ok: false, error_message: snapshotPaymentMessage }); + await requestRefund(bot, 111002, order); + assert.equal(JSON.stringify({ event, order }), before); + assert.deepEqual(sent, [[111002, snapshotPaymentMessage], [111002, snapshotPaymentMessage]]); +}); + +test('development health advertises database refresh safeguards without exposing configuration', async () => { + for (const APP_ENV of ['production', 'development']) { + const response = await worker.fetch(new Request('https://fictional.test/'), { APP_ENV }, {}); + const body = await response.json(); + assert.equal(body.databaseRefreshProtection, APP_ENV === 'development' ? 1 : undefined); + assert.deepEqual(Object.keys(body).sort(), APP_ENV === 'development' ? ['databaseRefreshProtection', 'service', 'status'] : ['service', 'status']); + } +}); + +test('suppressed refund deliveries do not contact Telegram or rewrite imported audit state', async t => { + const env = fixture(); + const base = env.DB.prepare.bind(env.DB), removed = []; + const refund = { id: 'fictional-refund', due: 0, method: 'refundStarPayment', params: JSON.stringify({ user_id: 111002, telegram_payment_charge_id: 'fictional-charge' }) }; + env.DB.prepare = sql => { + if (sql.startsWith('INSERT INTO delivery_lease')) return { bind() { return this; }, async first() { return { owner: 'fictional-lease' }; } }; + if (sql === 'SELECT id,due FROM outbox ORDER BY rowid LIMIT 20') return { async all() { return { results: [refund] }; } }; + if (sql.startsWith('UPDATE outbox SET due=unixepoch()+60')) return { bind() { return this; }, async first() { return refund; } }; + if (sql === 'DELETE FROM outbox WHERE id=?') return { bind(id) { removed.push(id); return this; }, async run() {} }; + if (sql === 'DELETE FROM delivery_lease WHERE owner=?') return { bind() { return this; }, async run() {} }; + return base(sql); + }; + t.mock.method(globalThis, 'fetch', () => assert.fail('Suppressed refunds must never reach Telegram.')); + await drainOutbox(env); + assert.deepEqual(removed, ['fictional-refund']); +}); + +test('snapshot scheduler preserves reminder state while configuration and rates tasks remain available', async () => { + const env = fixture(), base = env.DB.prepare.bind(env.DB), pending = []; + env.DB.prepare = sql => { + if (sql === 'SELECT value FROM app_settings WHERE key=?') return { bind() { return this; }, async first() { return { value: JSON.stringify({ retrievedAt: new Date().toISOString() }) }; } }; + if (sql.startsWith('INSERT INTO delivery_lease')) return { bind() { return this; }, async first() { return null; } }; + if (sql === 'DELETE FROM processed WHERE at < unixepoch()-604800') return { async run() {} }; + return base(sql); + }; + await worker.scheduled({}, env, { waitUntil(promise) { pending.push(promise); } }); + await Promise.all(pending); + assert.equal(pending.length, 4); +}); + +test('a Mini App read crossing the database refresh cannot return copied private event data', async () => { + let copied = false; + const event = { id: '1110000000000001', owner: 111001, title: 'Private copied event fixture', when: 'Future', guests: {}, startsAt: '2099-01-01T00:00:00Z', location: 'Private copied venue fixture' }; + const env = { APP_ENV: 'development', TEST_WHITELIST_ENABLED: 'false', TELEGRAM_BOT_TOKEN: 'snapshot-test-token', BOT_USERNAME: 'FictionalSnapshotBot', DB: { + prepare(sql) { + if (sql === "SELECT value FROM app_settings WHERE key='production-snapshot'") return { async first() { return copied ? { value: '{}' } : null; } }; + if (sql === "SELECT value FROM app_settings WHERE key='test-whitelist-enabled'") return { async first() { return null; } }; + if (sql === "SELECT value FROM app_settings WHERE key='test-whitelist'") return { async first() { return { value: '[]' }; } }; + if (sql === "SELECT data FROM records WHERE kind='events' AND id=?") return { bind() { return this; }, async first() { copied = true; return { data: JSON.stringify(event) }; } }; + assert.fail('Unexpected database query.'); + } + } }; + const response = await miniApi(apiRequest('events/' + event.id), env); + assert.equal(response.status, 403); + assert.equal(response.headers.get('Cache-Control'), 'no-store'); + assert.doesNotMatch(await response.text(), /Private copied|venue|1110000000000001/); +}); + +test('a Mini App mutation rechecks access under its lease before loading copied records', async () => { + let copied = false, released = false; + const env = { APP_ENV: 'development', TEST_WHITELIST_ENABLED: 'false', TELEGRAM_BOT_TOKEN: 'snapshot-test-token', snapshotUser: { id: 999001 }, SUPER_ADMIN_ID: '999001', DB: { + prepare(sql) { + if (sql === "SELECT value FROM app_settings WHERE key='production-snapshot'") return { async first() { return copied ? { value: '{}' } : null; } }; + if (sql === "SELECT value FROM app_settings WHERE key='test-whitelist-enabled'") return { async first() { return null; } }; + if (sql === "SELECT value FROM app_settings WHERE key='test-whitelist'") return { async first() { return { value: '[]' }; } }; + if (sql.startsWith('INSERT INTO lease')) return { bind() { return this; }, async first() { copied = true; return { owner: 'fictional-lease' }; } }; + if (sql === 'DELETE FROM lease WHERE owner=?') return { bind() { return this; }, async run() { released = true; } }; + assert.fail('Blocked mutations must not read or write copied application records.'); + } + } }; + const response = await miniApi(apiRequest('preferences', { timezone: 'UTC', snapshotUser: { id: 999001 } }), env); + assert.equal(response.status, 403); + assert.equal(released, true); + assert.match((await response.json()).error, /test|access/i); +}); + +test('a Telegram callback crossing a refresh does not render the copied event', async () => { + let copied = false; + const queued = []; + const event = { id: '1110000000000001', owner: 111001, title: 'Private copied Telegram fixture', guests: {}, location: 'Private copied venue' }; + const env = { APP_ENV: 'development', TEST_WHITELIST_ENABLED: 'false', BOT_USERNAME: 'FictionalSnapshotBot', DB: { + prepare(sql) { + if (sql === "SELECT value FROM app_settings WHERE key='production-snapshot'") return { async first() { return copied ? { value: '{}' } : null; } }; + if (sql === "SELECT value FROM app_settings WHERE key='test-whitelist-enabled'") return { async first() { return null; } }; + if (sql === "SELECT value FROM app_settings WHERE key='test-whitelist'") return { async first() { return { value: '[]' }; } }; + if (sql.startsWith('INSERT INTO lease')) return { bind() { return this; }, async first() { copied = true; return { owner: 'fictional-lease' }; } }; + if (sql.startsWith("INSERT INTO records(kind,id,data) VALUES ('users'")) return { bind() { return this; }, async run() {} }; + if (sql === 'SELECT id FROM processed WHERE id=?') return { bind() { return this; }, async first() { return null; } }; + if (sql === "SELECT kind,id,data FROM records WHERE kind IN ('events','sessions','preferences')") return { async all() { return { results: [{ kind: 'events', id: event.id, data: JSON.stringify(event) }] }; } }; + if (sql === 'SELECT value FROM app_settings WHERE key=?') return { bind() { return this; }, async first() { return null; } }; + if (sql === 'INSERT INTO outbox(id,method,params) VALUES (?,?,?)') return { bind(id, method, params) { queued.push({ id, method, params: JSON.parse(params) }); return this; } }; + if (['INSERT INTO commits(owner) VALUES (?)', 'INSERT INTO processed(id,at) VALUES (?,unixepoch())', 'DELETE FROM commits WHERE owner=?'].includes(sql)) return { bind() { return this; } }; + if (sql === 'DELETE FROM lease WHERE owner=?') return { bind() { return this; }, async run() {} }; + assert.fail('The blocked callback must not mutate copied application records.'); + }, + async batch() {} + } }; + const response = await processUpdate(env, { update_id: 1111001, callback_query: { id: 'fictional-racing-callback', from: { id: 111001, first_name: 'Fictional tester' }, data: 'v:' + event.id } }); + assert.equal(response.status, 200); + assert.equal(queued.length, 1); + assert.equal(queued[0].method, 'answerCallbackQuery'); + assert.match(queued[0].params.text, /test|access/i); + assert.doesNotMatch(JSON.stringify(queued), /Private copied/); +}); diff --git a/test/telegram-menu.test.js b/test/telegram-menu.test.js index a885db3..1828d91 100644 --- a/test/telegram-menu.test.js +++ b/test/telegram-menu.test.js @@ -59,3 +59,26 @@ test('new bot configuration keeps the authenticated built-in App button alongsid assert.equal(f.settings.get('stars-webhook'), f.env.APP_URL); assert.equal(f.settings.get('bot-commands'), botCommandsVersion); }); + +test('the test whitelist command appears only in the configured admin private chat', async t => { + const f = fixture(t, { commandVersion: botCommandsVersion }); + Object.assign(f.env, { APP_ENV: 'development', TEST_WHITELIST_ENABLED: 'true', SUPER_ADMIN_ID: '900000001' }); + await configureMiniApp(f.env); + assert.equal(f.calls.length, 1); + assert.deepEqual(f.calls[0].scope, { type: 'chat', chat_id: 900000001 }); + assert.deepEqual(f.calls[0].commands.map(item => item.command), ['help', 'cancel', 'paysupport', 'whitelist']); + await configureMiniApp(f.env); + assert.equal(f.calls.length, 1); + f.env.TEST_WHITELIST_ENABLED = 'false'; + await configureMiniApp(f.env); + assert.equal(f.calls.length, 1, 'The admin can manage saved dev IDs while the access switch is off.'); + assert.ok(f.calls.at(-1).commands.some(item => item.command === 'whitelist')); + assert.deepEqual(f.calls.at(-1).scope, { type: 'chat', chat_id: 900000001 }); +}); + +test('production never registers the test command even when its flag is enabled', async t => { + const f = fixture(t, { commandVersion: botCommandsVersion }); + Object.assign(f.env, { APP_ENV: 'production', TEST_WHITELIST_ENABLED: 'true', SUPER_ADMIN_ID: '900000001' }); + await configureMiniApp(f.env); + assert.deepEqual(f.calls, []); +}); diff --git a/test/test-access.test.js b/test/test-access.test.js new file mode 100644 index 0000000..d1fc428 --- /dev/null +++ b/test/test-access.test.js @@ -0,0 +1,280 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { accessPolicy, mayUseTestApp, whitelistEnabled, whitelistStatus, applyWhitelistChange, handleWhitelistCommand } from '../src/test-access.js'; + +const development = { APP_ENV: 'development', TEST_WHITELIST_ENABLED: 'true' }; + +function fixture({ stored = null, snapshot = null, override = null, seed = '', ...variables } = {}) { + let reads = 0; + const env = { + ...development, + WHITELIST_USER_IDS: seed, + ...variables, + DB: { + prepare(sql) { + if (sql === "SELECT value FROM app_settings WHERE key='production-snapshot'") return { async first() { return snapshot; } }; + if (sql === "SELECT value FROM app_settings WHERE key='test-whitelist-enabled'") return { async first() { return override; } }; + assert.equal(sql, "SELECT value FROM app_settings WHERE key='test-whitelist'"); + return { async first() { reads++; return stored; } }; + } + } + }; + return { env, reads: () => reads }; +} + +test('test access requires both the development environment and its explicit switch', () => { + assert.equal(whitelistEnabled(development), true); + assert.equal(whitelistEnabled({ ...development, TEST_WHITELIST_ENABLED: ' true ' }), true); + for (const env of [ + {}, + { APP_ENV: 'development' }, + { ...development, TEST_WHITELIST_ENABLED: 'false' }, + { ...development, TEST_WHITELIST_ENABLED: '' }, + { ...development, APP_ENV: 'production' }, + { TEST_WHITELIST_ENABLED: 'true' } + ]) assert.equal(whitelistEnabled(env), false); +}); + +test('production ignores the test list without reading its database', async () => { + const env = { + ...development, + APP_ENV: 'production', + WHITELIST_USER_IDS: 'malformed', + DB: { prepare() { assert.fail('Production must not read the test whitelist.'); } } + }; + assert.deepEqual(await accessPolicy(env), { enabled: false, ids: [], invalid: false }); + assert.equal(await mayUseTestApp(env, { id: 111001 }), true); +}); + +test('disabling the test access switch preserves open development access', async () => { + const { env } = fixture({ TEST_WHITELIST_ENABLED: 'false', seed: '111002' }); + assert.deepEqual(await accessPolicy(env), { enabled: false, ids: [], invalid: false }); + assert.equal(await mayUseTestApp(env, { id: 111001 }), true); +}); + +test('database copies require explicit testers even when the whitelist switch is disabled', async () => { + const { env } = fixture({ snapshot: { value: '' }, TEST_WHITELIST_ENABLED: 'false', seed: '111002', SUPER_ADMIN_ID: '999001' }); + assert.equal(await mayUseTestApp(env, { id: 111002 }), true); + assert.equal(await mayUseTestApp(env, { id: 111001 }), false); + assert.equal(await mayUseTestApp(env, { id: 999001 }), true); + assert.deepEqual(await accessPolicy(env, true), { enabled: true, ids: ['111002'], invalid: false }); +}); + +test('an empty or malformed list never opens a production database copy', async () => { + for (const stored of [null, { value: '[]' }, { value: 'not-json' }]) { + const { env } = fixture({ snapshot: { value: 'not-json' }, stored, SUPER_ADMIN_ID: '999001' }); + assert.equal(await mayUseTestApp(env, { id: 111001 }), false); + assert.equal(await mayUseTestApp(env, { id: 999001 }), true); + } +}); + +test('forcing test access policy still cannot enable a production restriction', async () => { + const env = { APP_ENV: 'production', DB: { prepare() { assert.fail('Production must not read copied database restrictions.'); } } }; + assert.deepEqual(await accessPolicy(env, true), { enabled: false, ids: [], invalid: false }); + assert.equal(await mayUseTestApp(env, { id: 111001 }), true); +}); + +test('an absent or blank seed opens access to verified human users', async () => { + for (const seed of ['', ' \n\t ', undefined, null]) { + const { env } = fixture({ seed }); + assert.deepEqual(await accessPolicy(env), { enabled: true, ids: [], invalid: false }); + assert.equal(await mayUseTestApp(env, { id: 111001 }), true); + } +}); + +test('seed IDs accept commas and whitespace and remove duplicates', async () => { + const { env } = fixture({ seed: '111001, 111002\n111001\t111003' }); + assert.deepEqual(await accessPolicy(env), { enabled: true, ids: ['111001', '111002', '111003'], invalid: false }); + assert.equal(await mayUseTestApp(env, { id: 111002 }), true); + assert.equal(await mayUseTestApp(env, { id: 111004 }), false); +}); + +test('an explicitly cleared stored list overrides a populated or invalid seed', async () => { + for (const seed of ['111002', 'malformed']) { + const { env } = fixture({ stored: { value: '[]' }, seed }); + assert.deepEqual(await accessPolicy(env), { enabled: true, ids: [], invalid: false }); + assert.equal(await mayUseTestApp(env, { id: 111001 }), true); + } +}); + +test('stored tester IDs override the seed and remove duplicates', async () => { + const { env } = fixture({ stored: { value: '["111002","111002","111003"]' }, seed: '111001' }); + assert.deepEqual(await accessPolicy(env), { enabled: true, ids: ['111002', '111003'], invalid: false }); + assert.equal(await mayUseTestApp(env, { id: 111001 }), false); + assert.equal(await mayUseTestApp(env, { id: 111003 }), true); +}); + +test('malformed nonempty seeds fail closed', async () => { + for (const seed of [',,', '111001;111002', '111001,invalid', '0', '-1', '00111001', '1.1', '1e5', '9007199254740992', 111001, ['111001']]) { + const { env } = fixture({ seed }); + assert.deepEqual(await accessPolicy(env), { enabled: true, ids: [], invalid: true }); + assert.equal(await mayUseTestApp(env, { id: 111001 }), false); + } +}); + +test('malformed stored values fail closed rather than falling back to an open seed', async () => { + for (const value of ['not-json', '', 'null', '{}', '"111001"', '[111001]', '["111001",null]', '["0"]', '["00111001"]', '["9007199254740992"]', null]) { + const { env } = fixture({ stored: { value }, seed: '' }); + assert.deepEqual(await accessPolicy(env), { enabled: true, ids: [], invalid: true }); + assert.equal(await mayUseTestApp(env, { id: 111001 }), false); + } +}); + +test('the configured super admin bypasses invalid policy and database failure', async () => { + const env = { + ...development, + SUPER_ADMIN_ID: '999001\n', + WHITELIST_USER_IDS: 'malformed', + DB: { prepare() { assert.fail('Super admin access must not depend on the whitelist database.'); } } + }; + assert.equal(await mayUseTestApp(env, { id: 999001 }), true); +}); + +test('client flags or malformed administrator configuration cannot bypass the list', async () => { + for (const SUPER_ADMIN_ID of ['', 'invalid', '0999001', '9007199254740992']) { + const { env } = fixture({ seed: '111002', SUPER_ADMIN_ID }); + assert.equal(await mayUseTestApp(env, { id: 999001, isSuperAdmin: true, username: 'admin' }), false); + } +}); + +test('development access rejects unverified identity shapes before reading the policy', async () => { + const { env, reads } = fixture(); + for (const user of [null, {}, { id: '111001' }, { id: 0 }, { id: -1 }, { id: 1.5 }, { id: Number.MAX_SAFE_INTEGER + 1 }, { id: 111001, is_bot: true }]) { + assert.equal(await mayUseTestApp(env, user), false); + } + assert.equal(reads(), 0); +}); + +test('database failure cannot silently open development access', async () => { + const env = { + ...development, + DB: { prepare() { return { async first() { throw new Error('Database unavailable'); } }; } } + }; + await assert.rejects(accessPolicy(env), /Database unavailable/); + await assert.rejects(mayUseTestApp(env, { id: 111001 }), /Database unavailable/); +}); + +test('a stored switch overrides environment defaults without redeployment', async () => { + const disabled = fixture({ override: { value: 'false' }, seed: '111002' }).env; + assert.equal(await mayUseTestApp(disabled, { id: 111001 }), true); + assert.deepEqual(await accessPolicy(disabled), { enabled: false, ids: [], invalid: false }); + assert.deepEqual(await whitelistStatus(disabled), { supported: true, enabled: false, restricted: false, ids: ['111002'], snapshotProtected: false, invalid: false }); + const enabled = fixture({ TEST_WHITELIST_ENABLED: 'false', override: { value: 'true' }, seed: '111002' }).env; + assert.equal(await mayUseTestApp(enabled, { id: 111001 }), false); + assert.equal(await mayUseTestApp(enabled, { id: 111002 }), true); +}); + +test('malformed stored switches fail closed, including with an empty tester list', async () => { + for (const value of ['', 'null', '"false"', '0', '{}', 'not-json']) { + const { env } = fixture({ override: { value }, TEST_WHITELIST_ENABLED: 'false', SUPER_ADMIN_ID: '999001' }); + assert.deepEqual(await accessPolicy(env), { enabled: true, ids: [], invalid: true }); + assert.equal(await mayUseTestApp(env, { id: 111001 }), false); + assert.equal(await mayUseTestApp(env, { id: 999001 }), true); + assert.equal((await whitelistStatus(env)).restricted, true); + } +}); + +test('production admin whitelist status never reads stored development settings', async () => { + const env = { APP_ENV: 'production', DB: { prepare() { assert.fail('No production whitelist reads.'); } } }; + assert.deepEqual(await whitelistStatus(env), { supported: false, enabled: false, restricted: false, ids: [], snapshotProtected: false, invalid: false }); + await assert.rejects(applyWhitelistChange(env, { action: 'set-enabled', enabled: true }, {}), /only in development/); +}); + +test('admin whitelist changes preserve seed IDs, de-duplicate and remove exact IDs', async () => { + const { env } = fixture({ seed: '111002 111003' }); + const writes = []; + const settings = { setAppSetting: (key, value) => writes.push([key, value]) }; + const added = await applyWhitelistChange(env, { action: 'add', ids: '111004, 111003' }, settings); + assert.deepEqual(added.ids, ['111002', '111003', '111004']); + assert.deepEqual(writes, [['test-whitelist', '["111002","111003","111004"]']]); + const removed = await applyWhitelistChange(env, { action: 'remove', ids: '111002' }, settings); + assert.deepEqual(removed.ids, ['111003']); + assert.equal(removed.restricted, true); + const emptied = await applyWhitelistChange(fixture({ seed: '111002' }).env, { action: 'remove', ids: '111002' }, settings); + assert.equal(emptied.enabled, true); + assert.equal(emptied.restricted, false); +}); + +test('switching access off retains tester IDs and validates boolean input', async () => { + const { env } = fixture({ stored: { value: '["111002"]' } }); + const writes = []; + const settings = { setAppSetting: (key, value) => writes.push([key, value]) }; + assert.deepEqual(await applyWhitelistChange(env, { action: 'set-enabled', enabled: false }, settings), { supported: true, enabled: false, restricted: false, ids: ['111002'], snapshotProtected: false, invalid: false }); + assert.deepEqual(writes, [['test-whitelist-enabled', 'false']]); + await assert.rejects(applyWhitelistChange(env, { action: 'set-enabled', enabled: 'false' }, settings), /on or off/); +}); + +test('admin edits cannot turn off snapshot restrictions, even with an empty list', async () => { + const { env } = fixture({ snapshot: { value: '' }, override: { value: 'false' } }); + const writes = []; + const settings = { setAppSetting: (...value) => writes.push(value) }; + assert.deepEqual(await whitelistStatus(env), { supported: true, enabled: false, restricted: true, ids: [], snapshotProtected: true, invalid: false }); + await assert.rejects(applyWhitelistChange(env, { action: 'set-enabled', enabled: false }, settings), /copied production data/); + assert.deepEqual(writes, []); + const added = await applyWhitelistChange(env, { action: 'add', ids: '111002' }, settings); + assert.equal(added.snapshotProtected, true); + assert.equal(added.restricted, true); + assert.deepEqual(added.ids, ['111002']); +}); + +test('admin can recover malformed policy explicitly without accepting invalid IDs', async () => { + const { env } = fixture({ stored: { value: 'not-json' }, override: { value: 'not-json' } }); + const writes = []; + const settings = { setAppSetting: (...value) => writes.push(value) }; + for (const ids of ['invalid', '0', '00111002', '1e5', '9007199254740992', '', ['111002'], 111002, '1 '.repeat(7000)]) await assert.rejects(applyWhitelistChange(env, { action: 'add', ids }, settings), /numeric Telegram user IDs/); + await assert.rejects(applyWhitelistChange(env, { action: 'remove', ids: '111002' }, settings), /invalid/); + const resetList = await applyWhitelistChange(env, { action: 'add', ids: '111002' }, settings); + assert.deepEqual(resetList.ids, ['111002']); + assert.equal(resetList.invalid, true); // The invalid switch still needs its own explicit reset. + assert.equal(resetList.restricted, true); + const resetSwitch = await applyWhitelistChange(env, { action: 'set-enabled', enabled: true }, settings); + assert.equal(resetSwitch.enabled, true); + assert.deepEqual(writes, [['test-whitelist', '["111002"]'], ['test-whitelist-enabled', 'true']]); +}); + +test('admin whitelist list size limits do not discard existing entries', async () => { + const ids = Array.from({ length: 1000 }, (_, index) => String(111000 + index)); + const { env } = fixture({ stored: { value: JSON.stringify(ids) } }); + const settings = { setAppSetting() { assert.fail('Invalid updates must not write settings.'); } }; + await assert.rejects(applyWhitelistChange(env, { action: 'add', ids: '119999' }, settings), /at most 1000/); + await assert.rejects(applyWhitelistChange(env, { action: 'add', ids: ids.slice(0, 101).join(' ') }, settings), /up to 100/); + for (const input of [null, [], {}, { action: 'clear' }]) await assert.rejects(applyWhitelistChange(env, input, settings), /action/); +}); + +test('the legacy clear command repairs an invalid switch and retains valid switches', async () => { + for (const [override, expected] of [['not-json', 'true'], ['false', 'false'], ['true', 'true']]) { + const saved = new Map([['test-whitelist-enabled', override], ['test-whitelist', 'not-json']]); + const replies = []; + const env = { ...development, SUPER_ADMIN_ID: '999001', BOT_USERNAME: 'FictionalTesterBot', DB: { + prepare(sql) { + let values = []; + return { + bind(...input) { values = input; return this; }, + async first() { + if (sql.startsWith('INSERT INTO lease')) return { owner: 'fictional-lease' }; + if (sql === "SELECT value FROM app_settings WHERE key='production-snapshot'") return null; + const key = sql.match(/^SELECT value FROM app_settings WHERE key='([^']+)'$/)?.[1]; + if (key) return saved.has(key) ? { value: saved.get(key) } : null; + if (sql === 'SELECT value FROM app_settings WHERE key=?' || sql === 'SELECT id FROM processed WHERE id=?') return null; + assert.fail('Unexpected command fixture read.'); + }, + async all() { + assert.equal(sql, "SELECT kind,id,data FROM records WHERE kind IN ('events','sessions','preferences')"); + return { results: [] }; + }, + async run() { assert.equal(sql, 'DELETE FROM lease WHERE owner=?'); }, + apply() { + if (sql.startsWith('INSERT INTO app_settings')) saved.set(values[0], values[1]); + else if (sql === 'INSERT INTO outbox(id,method,params) VALUES (?,?,?)') replies.push(JSON.parse(values[2]).text); + } + }; + }, + async batch(statements) { for (const statement of statements) statement.apply(); } + } }; + const response = await handleWhitelistCommand(env, { update_id: 111001, message: { chat: { id: 999001, type: 'private' }, from: { id: 999001 }, text: '/whitelist clear' } }); + assert.equal(response.status, 200); + assert.equal(saved.get('test-whitelist-enabled'), expected); + assert.equal(saved.get('test-whitelist'), '[]'); + assert.match(replies[0], /Open to everyone/); + } +}); diff --git a/test/tickets.test.js b/test/tickets.test.js index 22b0f50..8279610 100644 --- a/test/tickets.test.js +++ b/test/tickets.test.js @@ -1,44 +1,139 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import {issueTicket,verifyTicket} from '../src/tickets.js'; +import {issueTicket,ensureTicket,verifyTicket} from '../src/tickets.js'; +import {publicEvent} from '../src/mini-api.js'; +import {adminOverview} from '../src/admin.js'; +const now=Date.parse('2030-10-24T08:00:20Z'); function fixture(){return {id:'0123456789abcdef',owner:1,title:'Club match',askParticipantCount:true,requireApproval:true,guests:{2:{name:'Alex',status:'yes',approval:'approved',participants:4}},media:[]};} -test('QR tickets are private, stable and verify against current approval/payment state',()=>{ - const e=fixture(),ticket=issueTicket(e,2); - assert.match(ticket.code,/^[A-F0-9]{12}$/);assert.match(ticket.image,/^data:image\/gif;base64,/); - assert.equal(issueTicket(e,2).code,ticket.code);assert.equal(ticket.participants,4); - assert.throws(()=>issueTicket(e,3));assert.throws(()=>issueTicket(e,1));assert.throws(()=>verifyTicket(e,2,ticket.code)); - const checked=verifyTicket(e,1,`XE1:${e.id}:${ticket.code}`);assert.equal(checked.valid,true);assert.equal(checked.name,'Alex');assert.equal(checked.checkedInAt,null);assert.equal(e.checkIns,undefined); - assert.equal(verifyTicket(e,1,'XE1:ffffffffffffffff:'+ticket.code).valid,false); - assert.equal(verifyTicket(e,1,'not-a-ticket').valid,false); - e.guests[2].approval='pending';assert.equal(verifyTicket(e,1,ticket.code).valid,false);assert.throws(()=>issueTicket(e,2)); - e.guests[2].approval='approved';e.starPrice=100;e.paymentMethod='stars';assert.equal(verifyTicket(e,1,ticket.code).valid,false); - e.guests[2].payment={status:'paid'};assert.equal(verifyTicket(e,1,ticket.code).valid,true); - e.guests[2].payment.status='refund_pending';assert.equal(verifyTicket(e,1,ticket.code).valid,false); -}); -test('check-in is idempotent and old, cancelled, finished or declined tickets fail',()=>{ - const e=fixture(),ticket=issueTicket(e,2),now=Date.parse('2030-10-24T08:00:00Z'); - const first=verifyTicket(e,1,ticket.code.toLowerCase(),true,now);assert.equal(first.alreadyCheckedIn,false);assert.ok(first.checkedInAt); +const qr=(e,id=2)=>`XE1:${e.id}:${e.guests[id].ticket}`; + +test('opening a confirmed ticket requests a private six-digit code while QR and audit identity stay stable',()=>{ + const e=fixture();const stable=ensureTicket(e,2); + assert.match(stable,/^[A-F0-9]{12}$/);assert.equal(e.guests[2].ticketCode,undefined); + const ticket=issueTicket(e,2,now),state=structuredClone(e.guests[2].ticketCode); + assert.match(ticket.code,/^\d{6}$/);assert.match(ticket.image,/^data:image\/gif;base64,/); + assert.equal(ticket.serverTime,'2030-10-24T08:00:20.000Z');assert.equal(ticket.codeExpiresAt,'2030-10-24T08:01:00.000Z'); + assert.equal(issueTicket(e,2,now+1000).code,ticket.code);assert.deepEqual(e.guests[2].ticketCode,state); + assert.equal(ticket.participants,4);assert.equal(e.guests[2].ticket,stable);assert.match(state.secret,/^[a-f0-9]{64}$/); + assert.throws(()=>issueTicket(e,3,now));assert.throws(()=>issueTicket(e,1,now));assert.throws(()=>verifyTicket(e,2,ticket.code,false,now)); + const checked=verifyTicket(e,1,qr(e),false,now);assert.equal(checked.valid,true);assert.equal(checked.name,'Alex');assert.equal(checked.checkedInAt,null);assert.equal(e.checkIns,undefined); + assert.equal(verifyTicket(e,1,`XE1:ffffffffffffffff:${stable}`,false,now).valid,false); + assert.equal(verifyTicket(e,1,'not-a-ticket',false,now).valid,false); +}); + +test('six-digit codes expire at the exact server minute and refresh only on an explicit ticket request',()=>{ + const e=fixture();e.guests[2].ticket='ABCDEF123456';e.guests[2].ticketCode={ticket:e.guests[2].ticket,secret:'1'.repeat(64),issuedMinute:null}; + const ticket=issueTicket(e,2,now),expiry=Date.parse(ticket.codeExpiresAt),secret=e.guests[2].ticketCode.secret; + assert.equal(verifyTicket(e,1,ticket.code,false,expiry-1).valid,true); + assert.equal(verifyTicket(e,1,ticket.code,false,expiry).valid,false);assert.equal(verifyTicket(e,1,ticket.code,false,expiry+59000).valid,false); + assert.equal(e.guests[2].ticketCode.issuedMinute,Math.floor(now/60000)); + const refreshed=issueTicket(e,2,expiry); + assert.match(refreshed.code,/^\d{6}$/);assert.notEqual(refreshed.code,ticket.code); + assert.equal(refreshed.codeExpiresAt,'2030-10-24T08:02:00.000Z');assert.equal(e.guests[2].ticketCode.secret,secret); + assert.equal(verifyTicket(e,1,refreshed.code,false,expiry).valid,true);assert.equal(verifyTicket(e,1,ticket.code,false,expiry).valid,false); +}); + +test('code verification follows current approval, payment and revocation state',()=>{ + const e=fixture(),ticket=issueTicket(e,2,now); + assert.equal(verifyTicket(e,1,ticket.code,false,now).valid,true); + e.guests[2].approval='pending';assert.equal(verifyTicket(e,1,ticket.code,false,now).valid,false);assert.throws(()=>issueTicket(e,2,now)); + e.guests[2].approval='approved';e.starPrice=100;e.paymentMethod='stars';assert.equal(verifyTicket(e,1,ticket.code,false,now).valid,false); + e.guests[2].payment={status:'paid'};assert.equal(verifyTicket(e,1,ticket.code,false,now).valid,true); + e.guests[2].payment.status='refund_pending';assert.equal(verifyTicket(e,1,ticket.code,false,now).valid,false); + e.guests[2].payment.status='paid';delete e.guests[2];assert.equal(verifyTicket(e,1,ticket.code,false,now).valid,false); +}); + +test('check-in stays idempotent across rotating codes and uses the unchanged internal ticket key',()=>{ + const e=fixture(),ticket=issueTicket(e,2,now),stable=e.guests[2].ticket; + const first=verifyTicket(e,1,ticket.code,true,now);assert.equal(first.alreadyCheckedIn,false);assert.ok(first.checkedInAt); const second=verifyTicket(e,1,ticket.code,true,now+1000);assert.equal(second.alreadyCheckedIn,true);assert.equal(second.checkedInAt,first.checkedInAt);assert.equal(Object.keys(e.checkIns).length,1); - e.cancelled=true;assert.equal(verifyTicket(e,1,ticket.code,true,now).valid,false); - e.cancelled=false;e.endsAt=new Date(now).toISOString();assert.equal(verifyTicket(e,1,ticket.code,true,now).valid,false); - delete e.endsAt;e.guests[2].status='no';assert.equal(verifyTicket(e,1,ticket.code).valid,false); - e.guests[2].status='yes';e.guests[2].ticket='A'.repeat(12);assert.equal(verifyTicket(e,1,ticket.code).valid,false); - assert.equal(verifyTicket(e,1,e.guests[2].ticket).alreadyCheckedIn,false); -}); - -test('disabling QR codes retains manual tickets and blocks scanning until enabled',()=>{ - const e=fixture();e.qrEnabled=false; - const ticket=issueTicket(e,2); - assert.equal(ticket.image,null);assert.match(ticket.code,/^[A-F0-9]{12}$/); - assert.equal(verifyTicket(e,1,ticket.code).valid,true); - assert.equal(verifyTicket(e,1,`XE1:${e.id}:${ticket.code}`).valid,false); - assert.match(verifyTicket(e,1,`XE1:${e.id}:${ticket.code}`).reason,/QR codes are disabled/); - e.qrEnabled=true; - const enabled=issueTicket(e,2); - assert.equal(enabled.code,ticket.code);assert.match(enabled.image,/^data:image\/gif;base64,/); - assert.equal(verifyTicket(e,1,`XE1:${e.id}:${ticket.code}`).valid,true); - e.qrEnabled=false; - assert.equal(issueTicket(e,2).image,null); - assert.equal(verifyTicket(e,1,ticket.code,true).valid,true); + assert.equal(e.checkIns[stable].userId,2);assert.equal(e.checkIns[ticket.code],undefined); + const refreshed=issueTicket(e,2,Date.parse(ticket.codeExpiresAt));assert.equal(refreshed.checkedInAt,first.checkedInAt); + const next=verifyTicket(e,1,refreshed.code,true,Date.parse(ticket.codeExpiresAt));assert.equal(next.alreadyCheckedIn,true);assert.equal(next.checkedInAt,first.checkedInAt);assert.equal(Object.keys(e.checkIns).length,1); + e.cancelled=true;assert.equal(verifyTicket(e,1,refreshed.code,true,now).valid,false); + e.cancelled=false;e.endsAt=new Date(now).toISOString();assert.equal(verifyTicket(e,1,refreshed.code,true,now).valid,false); + delete e.endsAt;e.guests[2].status='no';assert.equal(verifyTicket(e,1,refreshed.code,false,now).valid,false); +}); + +test('existing QR and old manual ticket codes remain valid and honor QR disabling',()=>{ + const e=fixture();e.guests[2].ticket='ABCDEF123456';e.qrEnabled=false; + assert.equal(verifyTicket(e,1,'abcdef123456',false,now).valid,true);assert.equal(e.guests[2].ticketCode,undefined); + const ticket=issueTicket(e,2,now);assert.equal(ticket.image,null);assert.match(ticket.code,/^\d{6}$/); + assert.equal(verifyTicket(e,1,ticket.code,false,now).valid,true);assert.equal(verifyTicket(e,1,qr(e),false,now).valid,false); + assert.match(verifyTicket(e,1,qr(e),false,now).reason,/QR codes are disabled/); + e.qrEnabled=true;const enabled=issueTicket(e,2,now);assert.equal(enabled.code,ticket.code);assert.match(enabled.image,/^data:image\/gif;base64,/); + assert.equal(verifyTicket(e,1,qr(e),false,now).valid,true); +}); + +test('replacing a stable ticket invalidates its old rotating state without modifying historical check-ins',()=>{ + const e=fixture(),old=issueTicket(e,2,now),oldStable=e.guests[2].ticket,oldSecret=e.guests[2].ticketCode.secret; + verifyTicket(e,1,old.code,true,now);e.guests[2].ticket='ABCDEF123456'; + assert.equal(verifyTicket(e,1,old.code,false,now).valid,false);assert.equal(verifyTicket(e,1,oldStable,false,now).valid,false); + assert.ok(e.checkIns[oldStable]); + const replacement=issueTicket(e,2,now);assert.notEqual(e.guests[2].ticketCode.secret,oldSecret); + assert.equal(e.guests[2].ticketCode.ticket,'ABCDEF123456');assert.equal(replacement.checkedInAt,null); + assert.equal(verifyTicket(e,1,replacement.code,false,now).valid,true); +}); + +test('ambiguous current codes fail closed and an explicit request resolves collision without changing ticket identity',()=>{ + const e=fixture(),minute=Math.floor(now/60000); + e.guests[2].ticket='00000000011C'; + e.guests[2].ticketCode={ticket:e.guests[2].ticket,secret:'1'.repeat(64),issuedMinute:minute}; + const ticket=issueTicket(e,2,now),stable=e.guests[2].ticket; + e.guests[3]={...structuredClone(e.guests[2]),name:'Sam',ticket:'0000000002D5',ticketCode:{ticket:'0000000002D5',secret:'1'.repeat(64),issuedMinute:minute}}; + assert.equal(ticket.code,'263365'); + const collision=verifyTicket(e,1,ticket.code,true,now);assert.equal(collision.valid,false);assert.match(collision.reason,/several tickets/);assert.equal(e.checkIns,undefined); + assert.equal(verifyTicket(e,1,stable,false,now).valid,true); + const replacement=issueTicket(e,2,now);assert.notEqual(replacement.code,ticket.code);assert.equal(e.guests[2].ticket,stable); + assert.equal(verifyTicket(e,1,replacement.code,false,now).name,'Alex');assert.equal(verifyTicket(e,1,ticket.code,false,now).name,'Sam'); +}); + +test('ambiguous stable audit identities and another guest check-in never admit the wrong holder',()=>{ + const e=fixture(),ticket=issueTicket(e,2,now),stable=e.guests[2].ticket; + e.guests[3]={...structuredClone(e.guests[2]),name:'Sam',ticketCode:undefined}; + assert.equal(verifyTicket(e,1,ticket.code,true,now).valid,false);assert.equal(e.checkIns,undefined); + delete e.guests[3];e.checkIns={[stable]:{userId:3,participants:1,at:'2030-10-24T07:00:00Z'}}; + const prior=structuredClone(e.checkIns); + assert.equal(verifyTicket(e,1,ticket.code,true,now).valid,false);assert.deepEqual(e.checkIns,prior); +}); + +test('incorrect six-digit attempts are limited per organiser and event minute; successful entries do not consume guesses',()=>{ + const e=fixture(),ticket=issueTicket(e,2,now);e.cohost={id:3,name:'Co-host'}; + const incorrect=ticket.code==='000000'?'000001':'000000'; + for(let i=0;i<12;i++)assert.equal(verifyTicket(e,1,ticket.code,false,now).valid,true); + assert.equal(e.ticketCheckAttempts,undefined); + for(let i=0;i<10;i++)assert.equal(verifyTicket(e,1,incorrect,false,now).valid,false); + assert.match(verifyTicket(e,1,ticket.code,false,now).reason,/Too many incorrect/); + assert.equal(verifyTicket(e,3,ticket.code,false,now).valid,true);assert.equal(verifyTicket(e,1,qr(e),false,now).valid,true); + const next=Date.parse(ticket.codeExpiresAt),refreshed=issueTicket(e,2,next); + assert.equal(verifyTicket(e,1,refreshed.code,false,next).valid,true); + assert.throws(()=>verifyTicket(e,4,incorrect,false,next));assert.equal(e.ticketCheckAttempts[4],undefined); +}); + +test('ticket bootstrap and administration projections never disclose private rotating-code state',()=>{ + const e=fixture();issueTicket(e,2,now);const state=e.guests[2].ticketCode; + const own=publicEvent(e,2,'ExampleBot'),manager=publicEvent(e,1,'ExampleBot'); + assert.equal(own.ticket.code,undefined);assert.equal(own.ticket.name,'Alex');assert.equal(own.ticket.participants,4); + for(const projection of [own,manager,adminOverview([{kind:'events',id:e.id,data:JSON.stringify(e)}])]){ + assert.doesNotMatch(JSON.stringify(projection),/ticketCode|issuedMinute|ticketCheckAttempts/);assert.equal(JSON.stringify(projection).includes(state.secret),false); + } +}); + +test('minute calculations remain safe after 2038',()=>{ + const e=fixture(),future=Date.parse('2099-12-31T23:59:45Z'),ticket=issueTicket(e,2,future); + assert.equal(ticket.codeExpiresAt,'2100-01-01T00:00:00.000Z');assert.equal(verifyTicket(e,1,ticket.code,false,future).valid,true); + assert.equal(verifyTicket(e,1,ticket.code,false,Date.parse(ticket.codeExpiresAt)).valid,false); +}); + +test('finished events cannot issue apparently live check-in codes',()=>{ + const e=fixture();e.endsAt=new Date(now).toISOString(); + assert.throws(()=>issueTicket(e,2,now),/Event has finished/);assert.equal(e.guests[2].ticketCode,undefined); + assert.ok(ensureTicket(e,2)); +}); + +test('confirmed guests without issued tickets do not break another guest code request or verification',()=>{ + const e=fixture();e.guests[3]={name:'Sam',status:'yes',approval:'approved'};e.guests[4]={name:'Taylor',status:'yes',approval:'approved',ticket:undefined}; + const ticket=issueTicket(e,2,now);assert.match(ticket.code,/^\d{6}$/);assert.equal(verifyTicket(e,1,ticket.code,false,now).valid,true); + assert.equal(e.guests[3].ticket,undefined);assert.equal(e.guests[3].ticketCode,undefined);assert.equal(e.guests[4].ticketCode,undefined); });