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
37 changes: 29 additions & 8 deletions packages/admin/app/composables/useAttendees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ref, computed } from 'vue'
import { h160ToSs58, walletAddressToH160 } from '@festival/shared/utils/address'
import { useWalletStore } from '@festival/shared/host/wallet'
import { festivalState } from '@festival/shared/cache/festival-state'
import { pendingCheckins } from '@festival/shared/cache/pending'
import { bootLoadAdmin } from './useBootLoad'

export interface CheckedInAttendee {
Expand All @@ -17,14 +18,34 @@ export interface CheckedInAttendee {
export function useAttendees(festivalAddress: string) {
const search = ref('')

const attendees = computed<CheckedInAttendee[]>(() =>
festivalState.user.festivalPoaps
.map((p) => ({
address: p.data.attendee,
checkedInAt: Number(p.data.issuedAt),
}))
.sort((a, b) => b.checkedInAt - a.checkedInAt),
)
const attendees = computed<CheckedInAttendee[]>(() => {
const fromPoaps = festivalState.user.festivalPoaps.map((p) => ({
address: p.data.attendee,
checkedInAt: Number(p.data.issuedAt),
}))
const seen = new Set(fromPoaps.map((a) => a.address.toLowerCase()))

// POAP data only refreshes on boot loads, so bridge the gap with rows the
// chain already confirmed checked-in (event-fed attendees array) and this
// device's in-flight check-ins. Their exact timestamp arrives with the
// POAP on the next load; until then they're simply "just now".
const nowSec = Math.floor(Date.now() / 1000)
const bridged: CheckedInAttendee[] = []
for (const a of festivalState.festival?.attendees ?? []) {
if (a.isCheckedIn && !seen.has(a.address.toLowerCase())) {
seen.add(a.address.toLowerCase())
bridged.push({ address: a.address, checkedInAt: nowSec })
}
}
for (const addr of pendingCheckins()) {
if (!seen.has(addr)) {
seen.add(addr)
bridged.push({ address: addr as `0x${string}`, checkedInAt: nowSec })
}
}

return [...fromPoaps, ...bridged].sort((a, b) => b.checkedInAt - a.checkedInAt)
})

const filtered = computed(() => {
const q = search.value.trim().toLowerCase()
Expand Down
132 changes: 98 additions & 34 deletions packages/admin/app/composables/useBootLoad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,25 @@ import {
persistToCache,
type SessionEntry,
} from '@festival/shared/cache/festival-state'
import { mergeAttendees, mergeSessions, mergePoaps, maxBig } from '@festival/shared/cache/merge'
import type { ContractRole } from '@festival/shared/permissions'

interface BootLoadOptions {
at?: 'best' | 'finalized'
}

/**
* A load that started for the previous account must not write user fields
* after a switch, so every user write checks this first. Global festival
* state stays safe to write from any run.
*/
function userStillCurrent(userAddress: `0x${string}` | null): boolean {
return (
(festivalState.user.address?.toLowerCase() ?? null) ===
(userAddress?.toLowerCase() ?? null)
)
}

const ROLE_ORDER: { hash: `0x${string}`; label: ContractRole }[] = [
{ hash: ROLES.DEFAULT_ADMIN_ROLE, label: 'ADMIN' },
{ hash: ROLES.MANAGER_ROLE, label: 'MANAGER' },
Expand All @@ -41,6 +54,19 @@ export async function bootLoadAdmin(
): Promise<void> {
festivalState.loading = true
festivalState.error = null

// Account switch: the previous user's per-user fields must not leak into
// the new user's view or win the per-user merges below — resetting makes
// those merges start from empty for a new account.
const sameUser =
(festivalState.user.address?.toLowerCase() ?? null) ===
(userAddress?.toLowerCase() ?? null)
if (!sameUser) {
festivalState.user.ticketTokenId = 0n
festivalState.user.festivalPoaps = []
festivalState.user.sessionPoaps = []
festivalState.user.roles = []
}
festivalState.user.address = userAddress

// Cache-first paint. Show last-known data instantly while chain reads run.
Expand Down Expand Up @@ -131,37 +157,55 @@ async function runRound1(

festivalState.festival = {
address: festivalAddress,
details,
details: {
...details,
registeredCount: maxBig(
festivalState.festival?.details.registeredCount ?? 0n,
details.registeredCount,
),
},
metadata: festivalState.festival?.metadata ?? null,
attendees: attendeesRaw[0].map((a, i) => ({
address: a,
isCheckedIn: attendeesRaw[1][i] ?? false,
})),
attendees: mergeAttendees(
festivalState.festival?.attendees ?? [],
attendeesRaw[0].map((a, i) => ({
address: a,
isCheckedIn: attendeesRaw[1][i] ?? false,
})),
),
}

// User's roles. Derived locally from the per-role hasRole results.
festivalState.user.roles = ROLE_ORDER.filter((_, i) => hasRoleRaw[i]).map((r) => r.label)
if (userStillCurrent(userAddress)) {
festivalState.user.roles = ROLE_ORDER.filter((_, i) => hasRoleRaw[i]).map((r) => r.label)
}

// Stub role holders. R2 fills members per role.
festivalState.roles = ROLE_ORDER.map(({ hash }) => ({ role: hash, members: [] }))

// Stub session entries; R2 fills details/attendees.
festivalState.sessions = sessionsRaw.map<SessionEntry>((address) => ({
address,
details: {
metadataCid: '0x0000000000000000000000000000000000000000000000000000000000000000' as `0x${string}`,
creator: '0x0000000000000000000000000000000000000000' as `0x${string}`,
poapContract: '0x0000000000000000000000000000000000000000' as `0x${string}`,
parentFestival: festivalAddress,
startTime: 0n,
endTime: 0n,
cancelled: false,
registeredCount: 0n,
},
metadata: null,
attendees: [],
poapTokenIds: [],
}))
// Stub session entries; R2 upgrades via merge. mergeSessions keeps any
// already-real entry (from cache or a prior load) from regressing to a stub.
festivalState.sessions = mergeSessions(
festivalState.sessions,
sessionsRaw.map<SessionEntry>((address) => ({
address,
details: {
metadataCid: '0x0000000000000000000000000000000000000000000000000000000000000000' as `0x${string}`,
creator: '0x0000000000000000000000000000000000000000' as `0x${string}`,
poapContract: '0x0000000000000000000000000000000000000000' as `0x${string}`,
parentFestival: festivalAddress,
startTime: 0n,
endTime: 0n,
cancelled: false,
registeredCount: 0n,
// Zero so keepPositive never lets a stub beat the real chain value.
flagCount: 0n,
flagThreshold: 0n,
},
metadata: null,
attendees: [],
poapTokenIds: [],
})),
)

if (isNonZeroCid(details.metadataCid)) {
void fetchFestivalMetadata(details.metadataCid)
Expand Down Expand Up @@ -236,13 +280,29 @@ async function runRound2(
flagThreshold: results[off + 3] as bigint,
}

const entry = festivalState.sessions[i]
// Find by address, not by index: mergeSessions can reorder the array
// (union by address), so positional indexing would write to the wrong
// session on a warm re-run.
const addr = sessionAddrs[i]!
const entry = festivalState.sessions.find(
(s) => s.address.toLowerCase() === addr.toLowerCase(),
)
if (entry) {
entry.details = details
entry.attendees = attendeesRaw[0].map((a, idx) => ({
address: a,
isCheckedIn: attendeesRaw[1][idx] ?? false,
}))
// Same latches mergeSession applies elsewhere. A stale read cannot
// uncancel a session, shrink its count, or roll back its flags.
entry.details = {
...details,
cancelled: entry.details.cancelled || details.cancelled,
registeredCount: maxBig(entry.details.registeredCount, details.registeredCount),
flagCount: maxBig(entry.details.flagCount, details.flagCount),
}
entry.attendees = mergeAttendees(
entry.attendees,
attendeesRaw[0].map((a, idx) => ({
address: a,
isCheckedIn: attendeesRaw[1][idx] ?? false,
})),
)
if (isNonZeroCid(details.metadataCid)) {
metadataTargets.push({ address: entry.address, cid: details.metadataCid })
}
Expand All @@ -269,11 +329,15 @@ async function runRound2(
// list view shows all check-in timestamps).
const festPoapOffset = memberOffset
if (festPoapTokenIds.length > 0) {
festivalState.user.festivalPoaps = festPoapTokenIds.map((tokenId, idx) => ({
poapContract: FESTIVAL_POAP_ADDRESS,
tokenId,
data: results[festPoapOffset + idx] as POAPData,
}))
// Union: mints are monotonic, so a stale read can't drop a POAP.
festivalState.user.festivalPoaps = mergePoaps(
festivalState.user.festivalPoaps,
festPoapTokenIds.map((tokenId, idx) => ({
poapContract: FESTIVAL_POAP_ADDRESS,
tokenId,
data: results[festPoapOffset + idx] as POAPData,
})),
)
}
}

Expand Down
11 changes: 10 additions & 1 deletion packages/admin/app/composables/useCheckIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { readIsRegistered } from "@festival/shared/contracts/festival-reads";
import { batchRead } from "@festival/shared/contracts/multicall";
import { formatTxError } from "@festival/shared/contracts/errors";
import { useWalletStore } from "@festival/shared/host/wallet";
import { addPending, dropPending } from "@festival/shared/cache/pending";
import {
shortenAddress,
ss58ToH160,
Expand Down Expand Up @@ -120,9 +121,11 @@ export function useCheckIn(festivalAddress: string) {
txStatus.value = "preparing";
error.value = null;

// Captured before the tx so a late failure still drops the right key,
// even if the operator already moved on to the next attendee.
const attendeeH160 = ss58ToH160(attendeeSS58.value);
try {
const wallet = useWalletStore();
const attendeeH160 = ss58ToH160(attendeeSS58.value);
const registered = accountStatus.value?.registered ?? false;
const fnName = registered ? "checkIn" : "manualCheckIn";

Expand All @@ -135,6 +138,9 @@ export function useCheckIn(festivalAddress: string) {
walletAddress: wallet.address,
onStatus: (s) => {
txStatus.value = s;
// Attendee list shows the check-in immediately; the overlay rolls
// back on failure and is GC'd once the CheckedIn event confirms.
if (s === "broadcasting") addPending("checkin", attendeeH160);
if (s === "in-block") {
addRecentCheckin({
address: shortenAddress(attendeeSS58.value!),
Expand All @@ -147,6 +153,7 @@ export function useCheckIn(festivalAddress: string) {
},
});
} catch (e: any) {
dropPending("checkin", attendeeH160);
txStatus.value = "error";
error.value = formatTxError(e);
errorSource.value = "transaction";
Expand Down Expand Up @@ -177,6 +184,7 @@ export function useCheckIn(festivalAddress: string) {
walletAddress: wallet.address,
onStatus: (s) => {
txStatus.value = s;
if (s === "broadcasting") addPending("checkin", attendeeH160);
if (s === "in-block") {
addRecentCheckin({
address: toDisplaySs58(address),
Expand All @@ -188,6 +196,7 @@ export function useCheckIn(festivalAddress: string) {
},
});
} catch (e: any) {
dropPending("checkin", toH160(address));
txStatus.value = "error";
error.value = formatTxError(e);
throw new Error(error.value);
Expand Down
Loading
Loading