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', () => {
|
describe('useEventsStore lookups & removal', () => {
|
||||||
beforeEach(() => setActivePinia(createPinia()))
|
beforeEach(() => setActivePinia(createPinia()))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,19 @@ export function eventCoordinate(
|
||||||
return `${eventKind(event)}:${event.organizer.pubkey}:${event.id}`
|
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.
|
* Pinia store for cached events from Nostr relays.
|
||||||
* Deduplicates by NIP-52 addressable coordinate (kind:pubkey:d-tag).
|
* 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
|
* version (by `created_at`) replaces an older one *for the same
|
||||||
* coordinate only* — a same-d-tag event from a different author lands
|
* coordinate only* — a same-d-tag event from a different author lands
|
||||||
* under its own coordinate and never clobbers another author's event.
|
* 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) {
|
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 key = eventCoordinate(event)
|
||||||
const existing = eventsMap.value.get(key)
|
const existing = eventsMap.value.get(key)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue