From 037f0c678ddda342bf327d82c339616d55e97134 Mon Sep 17 00:00:00 2001 From: Ehsan <15076484+ehsan0921@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:56:13 +1100 Subject: [PATCH 1/2] Keep bulk invitation defaults separate from guest attendance responses --- public/app.js | 17 +++++++++++++++-- public/index.html | 2 +- src/invitation-management.js | 2 ++ src/mini-api.js | 4 ++-- test/edit-form.test.js | 9 ++++++++- test/invitation-management.test.js | 14 ++++++++++++++ 6 files changed, 42 insertions(+), 6 deletions(-) diff --git a/public/app.js b/public/app.js index 74c15fc..c817a89 100644 --- a/public/app.js +++ b/public/app.js @@ -108,6 +108,7 @@ function format(e, zone = selectedZone()) { return text; } function openTelegram(url) { if (tg?.initData) tg.openTelegramLink(url); else window.open(url, '_blank', 'noopener'); } +let guestListBaseline=''; function invitationGuestLine(guest){return guest.name+(guest.participantMode==='ask' ? ' = ?' : guest.participants ? ' = '+guest.participants+(guest.participantMode==='confirm' ? '!' : guest.participantMode==='fixed' ? '*' : '') : '');} function guestNamesIn(text){return text.split('\n').map(line=>line.split('=')[0].trim()).filter(Boolean);} function validateGuestAddition(name,names){ @@ -639,7 +640,7 @@ function setupForm(event = null) { updateEnding(); const scheduleOnly = compactPicker; $('title').value=event?.title || '';$('location').value=event?.location || '';$('description').value=event?.description || '';$('invite-message').value=event?.inviteMessage || ''; - $('invitation-mode-panel').hidden=compactPicker;$('legacy-invitation-mode').hidden=event?.invitationMode!=='legacy';$('invitation-mode').value=event?.invitationMode!=='named' && event?.isPublic ? 'public' : event?.invitationMode || 'tickets';$('guest-names').value=(event?.invitees || []).map(invitationGuestLine).join('\n'); + $('invitation-mode-panel').hidden=compactPicker;$('legacy-invitation-mode').hidden=event?.invitationMode!=='legacy';$('invitation-mode').value=event?.invitationMode!=='named' && event?.isPublic ? 'public' : event?.invitationMode || 'tickets';$('guest-names').value=event?.initialGuestNames ?? (event?.invitees || []).map(invitationGuestLine).join('\n');guestListBaseline=$('guest-names').value; const lockedMode=event && !event.isOwner ? event.invitationMode : null; for(const [id,mode] of [['tickets-invitation-mode','tickets'],['named-invitation-mode','named'],['public-invitation-mode',event?.invitationMode==='legacy'?'legacy':'tickets'],['legacy-invitation-mode','legacy']])$(id).disabled=!!lockedMode && lockedMode!==mode; $('guest-single').open=false; @@ -919,7 +920,19 @@ $('owner-pricing-form').onsubmit=async event=>{ await refresh();adminData.pricing=state.pricing;renderEvents();$('owner-pricing-status').textContent='Saved. New events use this default; current event prices are unchanged.'; }catch(error){$('owner-pricing-status').textContent=error.message;}finally{$('save-owner-pricing').disabled=false;} }; -for (const b of document.querySelectorAll('[data-tab]')) b.onclick = () => { notice(''); b.dataset.tab === 'create' && (!formReady || $('event-form').hidden) ? setupForm() : go(b.dataset.tab); }; +async function refreshEditGuestList(){ + if(!activeEvent || activeEvent.invitationMode!=='named' || $('guest-names').value!==guestListBaseline)return; + const id=activeEvent.id,baseline=guestListBaseline,navigation=pageNavigationGeneration; + try{ + const {event:latest}=await api('events/'+id); + if(activeEvent?.id!==id || navigation!==pageNavigationGeneration || $('guest-names').value!==baseline)return; + if(latest.cancelled || latest.invitationMode!=='named'){notice('This event has changed. Reopen it from My events.');return;} + const list=latest.initialGuestNames ?? (latest.invitees || []).map(invitationGuestLine).join('\n'); + $('guest-names').value=list;guestListBaseline=list; + activeEvent={...activeEvent,invitees:latest.invitees,invitationsVersion:latest.invitationsVersion,initialGuestNames:list}; + }catch(error){notice(error.message);} +} +for (const b of document.querySelectorAll('[data-tab]')) b.onclick = async () => { notice(''); if(b.dataset.tab==='create' && (!formReady || $('event-form').hidden))setupForm();else {go(b.dataset.tab);if(b.dataset.tab==='create')await refreshEditGuestList();} }; $('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(); diff --git a/public/index.html b/public/index.html index 5dcb654..c4aab93 100644 --- a/public/index.html +++ b/public/index.html @@ -33,7 +33,7 @@

Private link. Guests enter their name to book a ticket or request organiser approval.

+

This list shows the organiser’s initial settings, not guests’ later RSVP counts. Added and removed guests appear when reopened. Final totals use each guest’s response. Up to 100 guests. Name = ? asks how many; = 2! confirms 2; = 2 uses 2 with a change button; = 2* fixes the count at 2. Counts are 1–10, including the guest. Send each link only to its named guest. Opened names and count settings can’t be changed here.

diff --git a/src/invitation-management.js b/src/invitation-management.js index d618305..06d5ec6 100644 --- a/src/invitation-management.js +++ b/src/invitation-management.js @@ -15,6 +15,8 @@ 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'?'*':''}`:''}`; +// Invitation defaults are separate from the guest's later RSVP attendee count. +export const initialGuestNames=e=>Object.values(e.invitees || {}).map(guestLine).join('\n'); 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 diff --git a/src/mini-api.js b/src/mini-api.js index b548f3b..d16a311 100644 --- a/src/mini-api.js +++ b/src/mini-api.js @@ -4,7 +4,7 @@ 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,revokedInvitations,invitationsVersion,addInvitations,removeInvitation,editInvitation,changeInvitationResponse,revokeInvitation,deleteInvitation,hasRecordedResponse} from './invitation-management.js'; +import {initialGuestNames,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'; @@ -90,7 +90,7 @@ export function publicEvent(e, id, username) { 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,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:participantCount(e,g),paymentStatus:g.payment?.status || null})),...Object.entries(e.invitees || {}).filter(([token])=>!Object.values(e.guests).some(g=>g.invitationToken===token)).map(([token,g])=>({id:null,name:g.name,status:'unopened',responded:false,participants:participantCount(e,{...g,invitationToken:token}),confirmed:false}))]} : {}), inviteMessage:e.inviteMessage || '',qrEnabled:e.qrEnabled!==false,oneTimeInvite:oneTimeInvites(e), - ...(manager ? {invitees:managedInvitations(e,username),revokedInvitees:revokedInvitations(e,username),invitationsVersion:invitationsVersion(e),cohosts,cohost:cohosts[0] || null} : {}), + ...(manager ? {initialGuestNames:initialGuestNames(e),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, diff --git a/test/edit-form.test.js b/test/edit-form.test.js index 8ea6094..9bc592c 100644 --- a/test/edit-form.test.js +++ b/test/edit-form.test.js @@ -36,9 +36,16 @@ test('editing fetches fresh details, preserves all attendee count modes and invi // Switching footer tabs must preserve a partly edited event. ids.get('title').value='Unfinished event edit'; tabs.find(b=>b.dataset.tab==='settings').onclick(); - tabs.find(b=>b.dataset.tab==='create').onclick(); + event.initialGuestNames='Alex = 3\nSam = ?\nTaylor = 2!\nJordan = 4*\nMorgan = 2'; + await tabs.find(b=>b.dataset.tab==='create').onclick(); assert.equal(ids.get('title').value,'Unfinished event edit'); assert.equal(ids.get('invite-message').value,'Come celebrate with us.'); + assert.equal(ids.get('guest-names').value,event.initialGuestNames); + ids.get('guest-names').value+='\nUnsaved guest = 2'; + tabs.find(b=>b.dataset.tab==='settings').onclick(); + event.initialGuestNames+='\nAnother server guest'; + await tabs.find(b=>b.dataset.tab==='create').onclick(); + assert.ok(ids.get('guest-names').value.endsWith('Unsaved guest = 2')); // Updating the photo must not overwrite text that has not been saved yet. ids.get('profile-name').value='Unsaved profile name';ids.get('profile-phone').value='+61 400 000 000'; const bootstrapCalls=paths.filter(path=>path==='/api/bootstrap').length; diff --git a/test/invitation-management.test.js b/test/invitation-management.test.js index 8ff5f0a..7f174a0 100644 --- a/test/invitation-management.test.js +++ b/test/invitation-management.test.js @@ -10,6 +10,20 @@ import {issueTicket,verifyTicket} from '../src/tickets.js'; import {checkout,requestRefund,refundResult} from '../src/payments.js'; const requestId='11111111-1111-4111-8111-111111111111'; +test('bulk editor projects live invitation defaults while preserving guest-selected attendance',()=>{ + const f=fixture('Alex = 2\nSam = ?\nTaylor = 3!\nJordan = 4*'); + Object.assign(claimInvitation(f.e,f.token,2),{status:'yes',participants:5,responseRecorded:true,responseVersion:1}); + const response=structuredClone(f.e.guests[2]); + assert.equal(publicEvent(f.e,1,'ExampleBot').initialGuestNames,'Alex = 2\nSam = ?\nTaylor = 3!\nJordan = 4*'); + assert.equal(publicEvent(f.e,2,'ExampleBot').initialGuestNames,undefined); + f.add('Morgan = 2'); + const sam=Object.entries(f.e.invitees).find(([,g])=>g.name==='Sam')[0]; + revokeInvitation(f.e,1,{token:sam,version:invitationsVersion(f.e),requestId:'22222222-2222-4222-8222-222222222222',confirm:true,notify:false},f.data.sessions); + const projection=publicEvent(f.e,1,'ExampleBot'); + assert.equal(projection.initialGuestNames,'Alex = 2\nTaylor = 3!\nJordan = 4*\nMorgan = 2'); + Object.assign(f.e,invitationSettings({guestNames:projection.initialGuestNames},f.e)); + assert.deepEqual(f.e.guests[2],response);assert.equal(responseCounts(f.e).participants,5); +}); function fixture(names='Alex = 2\nSam = ?\nTaylor = 3!'){ const e={id:'0123456789abcdef',owner:1,title:'Club dinner',location:'Private venue',invitationMode:'named',guests:{},media:[],permissions:{},startsAt:'2099-11-25T07:00:00Z',endsAt:'2099-11-25T10:00:00Z'}; Object.assign(e,invitationSettings({guestNames:names},e)); From e87d613f2ece679d0a47cc6284526c6ebba3f2a8 Mon Sep 17 00:00:00 2001 From: Ehsan <15076484+ehsan0921@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:08:57 +1100 Subject: [PATCH 2/2] Keep mocked exchange rate fixture current --- scripts/check-worker.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check-worker.mjs b/scripts/check-worker.mjs index 200bb64..9f41ac0 100644 --- a/scripts/check-worker.mjs +++ b/scripts/check-worker.mjs @@ -510,7 +510,7 @@ try { assert.equal((await api('admin/pricing',{...ownerPricing,defaultStarPrice:-1},999001)).status,400); assert.equal((await api('admin/pricing',ownerPricing,999001)).status,200); assert.equal((await api('admin/overview',null,999001)).data.pricing.defaultStarPrice,50); - await db.prepare("INSERT INTO app_settings(key,value) VALUES ('online-currency-rates',?)").bind(JSON.stringify({rates:{AUD:0.0195},dates:{AUD:'2026-10-05'},retrievedAt:new Date().toISOString(),basis:'organiser-reward'})).run(); + await db.prepare("INSERT INTO app_settings(key,value) VALUES ('online-currency-rates',?)").bind(JSON.stringify({rates:{AUD:0.0195},dates:{AUD:new Date().toISOString().slice(0,10)},retrievedAt:new Date().toISOString(),basis:'organiser-reward'})).run(); const pricedBootstrap=(await api('bootstrap')).data; assert.equal(pricedBootstrap.pricing.rates.AUD,0.0195); assert.equal(pricedBootstrap.pricing.basis,'organiser-reward');