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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions public/app.js

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

2 changes: 1 addition & 1 deletion public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
<form id="event-form">
<div id="invitation-mode-panel" class="panel"><label for="invitation-mode">Event type</label><select id="invitation-mode"><option value="tickets" id="tickets-invitation-mode">🎟 Ticket booking — Private</option><option value="named" id="named-invitation-mode">✉️ Named invitations — Private</option><option value="public" id="public-invitation-mode">🌍 Public event</option><option value="legacy" id="legacy-invitation-mode" hidden>Existing RSVP event — Private</option></select><p class="small muted" id="invitation-mode-note">Private link. Guests enter their name to book a ticket or request organiser approval.</p><div id="guest-names-panel" hidden>
<details id="guest-single" class="guest-editor"><summary>👤 Add one guest</summary><div class="guest-editor-fields"><div><label for="guest-single-name">Guest name</label><input id="guest-single-name" maxlength="100" autocomplete="off" placeholder="Alex"></div><div id="guest-single-count-field"><label for="guest-single-count">People</label><select id="guest-single-count" aria-label="Number of people"></select></div><div class="guest-editor-mode"><label for="guest-single-mode">Attendee setting</label><select id="guest-single-mode"><option value="one">One person</option><option value="editable">Set a count · guest can change</option><option value="ask">Ask guest how many</option><option value="confirm">Ask guest to confirm count</option><option value="fixed">Fixed count · no changes</option></select></div></div><button type="button" id="guest-single-add" class="secondary">+ Add guest to list</button><p id="guest-single-status" class="small" role="status" aria-live="polite"></p></details>
<label for="guest-names">📋 Guest list — one name per line</label><textarea id="guest-names" maxlength="10000" rows="5" placeholder="Alex = ?&#10;Sam = 2!&#10;Taylor = 2&#10;Casey = 2*"></textarea><p class="small muted">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.</p></div><label class="check-option"><input type="checkbox" id="one-time-invite"><span>One-time invitation links<span class="check-note" id="one-time-invite-note">Anyone with the event link can request a ticket.</span></span></label></div>
<label for="guest-names">📋 Guest list — initial attendee settings</label><textarea id="guest-names" maxlength="10000" rows="5" placeholder="Alex = ?&#10;Sam = 2!&#10;Taylor = 2&#10;Casey = 2*"></textarea><p class="small muted">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.</p></div><label class="check-option"><input type="checkbox" id="one-time-invite"><span>One-time invitation links<span class="check-note" id="one-time-invite-note">Anyone with the event link can request a ticket.</span></span></label></div>
<div id="event-details" class="panel">
<label for="title">Event name</label><input id="title" name="title" placeholder="Dinner, a birthday, a weekend adventure…" maxlength="100" required>
<label for="location">Where are we meeting? <span>optional</span></label><input id="location" name="location" placeholder="Address, meeting point, online link, or add later" maxlength="300">
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-worker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
2 changes: 2 additions & 0 deletions src/invitation-management.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/mini-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion test/edit-form.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions test/invitation-management.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading