fix(events): collapse own-event draft + relay copy into one card #127
2 changed files with 117 additions and 0 deletions
|
|
@ -92,6 +92,71 @@ describe('useEventsStore.upsertEvent', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('useEventsStore.upsertEvent draft↔published reconciliation', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
|
||||
// A provisional draft is the caller's own LNbits event surfaced via REST
|
||||
// before its NIP-52 event is on a relay: empty pubkey, isMine, no ticket
|
||||
// info. The relay copy lands under the real publisher pubkey (which is
|
||||
// NOT the user's login key), so the two have different coordinates.
|
||||
function makeDraft(overrides: Partial<Event> = {}): Event {
|
||||
return makeEvent({
|
||||
id: 'my-event',
|
||||
organizer: { pubkey: '' },
|
||||
isMine: true,
|
||||
lnbitsStatus: 'approved',
|
||||
createdAt: new Date('2026-06-01T00:00:00Z'),
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
function makePublished(overrides: Partial<Event> = {}): Event {
|
||||
return makeEvent({
|
||||
id: 'my-event',
|
||||
organizer: { pubkey: 'the-architect' }, // resolve_for_wallet key
|
||||
createdAt: new Date('2026-06-02T00:00:00Z'),
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
it('collapses to one card when the published copy arrives after the draft', () => {
|
||||
const store = useEventsStore()
|
||||
store.upsertEvent(makeDraft())
|
||||
store.upsertEvent(makePublished())
|
||||
|
||||
expect(store.events).toHaveLength(1)
|
||||
const only = store.getEventById('my-event')
|
||||
expect(only?.organizer.pubkey).toBe('the-architect')
|
||||
// Ownership carries over even though the publisher key != login key.
|
||||
expect(only?.isMine).toBe(true)
|
||||
})
|
||||
|
||||
it('collapses to one card when the draft arrives after the published copy', () => {
|
||||
const store = useEventsStore()
|
||||
store.upsertEvent(makePublished())
|
||||
store.upsertEvent(makeDraft())
|
||||
|
||||
expect(store.events).toHaveLength(1)
|
||||
const only = store.getEventById('my-event')
|
||||
expect(only?.organizer.pubkey).toBe('the-architect')
|
||||
expect(only?.isMine).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the draft alone while no published copy exists (pending review)', () => {
|
||||
const store = useEventsStore()
|
||||
store.upsertEvent(makeDraft({ lnbitsStatus: 'proposed' }))
|
||||
|
||||
expect(store.events).toHaveLength(1)
|
||||
expect(store.getEventById('my-event')?.lnbitsStatus).toBe('proposed')
|
||||
})
|
||||
|
||||
it('does not fold across two genuinely different authors (no empty pubkey)', () => {
|
||||
const store = useEventsStore()
|
||||
store.upsertEvent(makeEvent({ id: 'concert', organizer: { pubkey: 'alice' } }))
|
||||
store.upsertEvent(makeEvent({ id: 'concert', organizer: { pubkey: 'bob' } }))
|
||||
expect(store.events).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useEventsStore lookups & removal', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,19 @@ export function eventCoordinate(
|
|||
return `${eventKind(event)}:${event.organizer.pubkey}:${event.id}`
|
||||
}
|
||||
|
||||
/**
|
||||
* A *provisional draft* is the caller's own LNbits event surfaced via REST
|
||||
* (`loadOwnEvents` → `ticketedEventToEvent`) before its NIP-52 event is on
|
||||
* a relay. It has no Nostr author yet, so its coordinate carries an empty
|
||||
* pubkey. The relay-published copy of the same event lands under the real
|
||||
* publisher's coordinate (`resolve_for_wallet`, which is NOT the user's
|
||||
* Nostr login key), so the two never share a coordinate and must be
|
||||
* reconciled by d-tag — see {@link upsertEvent}.
|
||||
*/
|
||||
function isProvisionalDraft(event: Pick<Event, 'organizer'>): boolean {
|
||||
return event.organizer.pubkey === ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Pinia store for cached events from Nostr relays.
|
||||
* Deduplicates by NIP-52 addressable coordinate (kind:pubkey:d-tag).
|
||||
|
|
@ -73,8 +86,47 @@ export const useEventsStore = defineStore('events', () => {
|
|||
* version (by `created_at`) replaces an older one *for the same
|
||||
* coordinate only* — a same-d-tag event from a different author lands
|
||||
* under its own coordinate and never clobbers another author's event.
|
||||
*
|
||||
* Draft↔published reconciliation: a provisional draft (the caller's own
|
||||
* REST event, empty-pubkey coordinate) and the relay-published copy of
|
||||
* the same event have *different* coordinates, so without intervention a
|
||||
* creator would see their own event twice once it's published. We keep a
|
||||
* single card per d-tag: the published copy wins (real author, live
|
||||
* ticket counts) and inherits the draft's `isMine` so the creator keeps
|
||||
* the Yours badge + Hosting filter — the publisher key differs from their
|
||||
* Nostr login key, so ownership can't be re-derived by pubkey match.
|
||||
*/
|
||||
function upsertEvent(event: Event) {
|
||||
const incomingIsDraft = isProvisionalDraft(event)
|
||||
|
||||
// Look for an existing entry for the same d-tag with the OPPOSITE
|
||||
// draft/published status — the pair that needs reconciling.
|
||||
let pairKey: string | undefined
|
||||
let pair: Event | undefined
|
||||
for (const [k, e] of eventsMap.value) {
|
||||
if (e.id === event.id && isProvisionalDraft(e) !== incomingIsDraft) {
|
||||
pairKey = k
|
||||
pair = e
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (pair && pairKey) {
|
||||
if (incomingIsDraft) {
|
||||
// Published copy already present — fold the draft's ownership into
|
||||
// it and drop the draft (don't add a second card).
|
||||
if (event.isMine && !pair.isMine) {
|
||||
eventsMap.value.set(pairKey, { ...pair, isMine: true })
|
||||
lastUpdated.value = new Date()
|
||||
}
|
||||
return
|
||||
}
|
||||
// Incoming is the published copy superseding a draft — inherit the
|
||||
// draft's ownership, then remove the draft so only one card remains.
|
||||
if (pair.isMine) event.isMine = true
|
||||
eventsMap.value.delete(pairKey)
|
||||
}
|
||||
|
||||
const key = eventCoordinate(event)
|
||||
const existing = eventsMap.value.get(key)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue