-
Notifications
You must be signed in to change notification settings - Fork 173
Expand file tree
/
Copy patheventHistory.ts
More file actions
378 lines (340 loc) · 10.5 KB
/
Copy patheventHistory.ts
File metadata and controls
378 lines (340 loc) · 10.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
import { getDb } from './db';
export type CampaignEventType =
| 'created'
| 'pledged'
| 'claimed'
| 'refunded'
| 'updated'
| 'metadata_updated'
| 'pledge_limit_reached'
| 'archived'
| 'restored';
export interface BlockchainMetadata {
txHash?: string;
ledgerNumber?: number;
ledgerCloseTime?: number;
eventIndex?: number;
contractId?: string;
source?: 'local' | 'soroban';
}
export interface CampaignEvent {
id: number;
campaignId: string;
eventType: CampaignEventType;
timestamp: number;
actor?: string;
amount?: number;
metadata?: Record<string, unknown>;
blockchainMetadata?: BlockchainMetadata;
}
interface EventRow {
id: number;
campaign_id: string;
event_type: string;
timestamp: number;
actor: string | null;
amount: number | null;
metadata: string | null;
blockchain_metadata: string | null;
}
function rowToEvent(row: EventRow): CampaignEvent {
return {
id: row.id,
campaignId: row.campaign_id,
eventType: row.event_type as CampaignEventType,
timestamp: row.timestamp,
actor: row.actor ?? undefined,
amount: row.amount ?? undefined,
metadata: row.metadata ? (JSON.parse(row.metadata) as Record<string, unknown>) : undefined,
blockchainMetadata: row.blockchain_metadata
? (JSON.parse(row.blockchain_metadata) as BlockchainMetadata)
: undefined,
};
}
/**
* Persists a campaign lifecycle event to the database.
*
* @param campaignId - The ID of the campaign this event belongs to.
* @param eventType - The type of event (e.g. "created", "pledged", "claimed", "refunded").
* @param timestamp - Unix timestamp (seconds) when the event occurred.
* @param actor - Optional wallet address of the user who triggered the event.
* @param amount - Optional token amount associated with the event.
* @param metadata - Optional arbitrary key-value data about the event.
* @param blockchainMetadata - Optional on-chain context (tx hash, ledger info, source).
*/
export function recordEvent(
campaignId: string,
eventType: CampaignEventType,
timestamp: number,
actor?: string,
amount?: number,
metadata?: Record<string, unknown>,
blockchainMetadata?: BlockchainMetadata,
): void {
const db = getDb();
db.prepare(
`INSERT INTO campaign_events (campaign_id, event_type, timestamp, actor, amount, metadata, blockchain_metadata)
VALUES (@campaignId, @eventType, @timestamp, @actor, @amount, @metadata, @blockchainMetadata)`,
).run({
campaignId,
eventType,
timestamp,
actor: actor ?? null,
amount: amount ?? null,
metadata: metadata ? JSON.stringify(metadata) : null,
blockchainMetadata: blockchainMetadata ? JSON.stringify(blockchainMetadata) : null,
});
}
export interface CampaignHistoryPage {
data: CampaignEvent[];
total: number;
page: number;
pageSize: number;
hasMore: boolean;
}
/**
* Returns all events for a given campaign in chronological order.
*
* @param campaignId - The ID of the campaign whose history to fetch.
* @returns An array of {@link CampaignEvent} objects sorted by timestamp ascending.
*/
export function getCampaignHistory(campaignId: string): CampaignEvent[] {
const db = getDb();
const rows = db
.prepare(`SELECT * FROM campaign_events WHERE campaign_id = ? ORDER BY timestamp ASC, id ASC`)
.all(campaignId) as EventRow[];
return rows.map(rowToEvent);
}
/**
* Returns a paginated slice of campaign events, newest first.
*/
export function listCampaignHistory(
campaignId: string,
options: { page?: number; pageSize?: number } = {},
): CampaignHistoryPage {
const page = options.page ?? 1;
const pageSize = options.pageSize ?? 20;
const offset = (page - 1) * pageSize;
const db = getDb();
const countRow = db
.prepare(`SELECT COUNT(*) as total FROM campaign_events WHERE campaign_id = ?`)
.get(campaignId) as { total: number };
const total = countRow.total;
const rows = db
.prepare(
`SELECT * FROM campaign_events WHERE campaign_id = ? ORDER BY timestamp DESC, id DESC LIMIT ? OFFSET ?`,
)
.all(campaignId, pageSize, offset) as EventRow[];
return {
data: rows.map(rowToEvent),
total,
page,
pageSize,
hasMore: page * pageSize < total,
};
}
/**
* Looks up a single event by its on-chain transaction hash.
*
* @param txHash - The Soroban transaction hash to search for.
* @returns The matching {@link CampaignEvent}, or `undefined` if not found.
*/
export function getEventByTxHash(txHash: string): CampaignEvent | undefined {
const db = getDb();
const row = db
.prepare(
`SELECT * FROM campaign_events WHERE json_extract(blockchain_metadata, '$.txHash') = ? LIMIT 1`,
)
.get(txHash) as EventRow | undefined;
return row ? rowToEvent(row) : undefined;
}
/**
* Returns all events that were confirmed in a specific ledger.
*
* @param ledgerNumber - The ledger sequence number to filter by.
* @returns An array of {@link CampaignEvent} objects ordered by their event index within the ledger.
*/
export function getEventsByLedger(ledgerNumber: number): CampaignEvent[] {
const db = getDb();
const rows = db
.prepare(
`SELECT * FROM campaign_events WHERE json_extract(blockchain_metadata, '$.ledgerNumber') = ? ORDER BY json_extract(blockchain_metadata, '$.eventIndex') ASC`,
)
.all(ledgerNumber) as EventRow[];
return rows.map(rowToEvent);
}
// ── Unified Timeline ────────────────────────────────────────────────────────
export type TimelineItemType = 'pledge' | 'status_change' | 'update' | 'comment';
export interface TimelineItem {
id: string;
campaignId: string;
type: TimelineItemType;
timestamp: number;
actor?: string;
amount?: number;
assetCode?: string;
metadata?: Record<string, unknown>;
eventType?: string;
}
interface TimelineRow {
source: string;
source_id: number;
campaign_id: string;
timestamp: number;
actor: string | null;
amount: number | null;
asset_code: string | null;
event_type: string | null;
metadata: string | null;
}
export interface TimelinePage {
data: TimelineItem[];
nextCursor?: string;
hasMore: boolean;
}
/**
* Returns a unified timeline for a campaign, merging pledge records and
* campaign lifecycle events into a single sorted stream.
*
* Cursor-based pagination: the cursor encodes the last-seen timestamp and
* source ID as `{timestamp}:{source}:{source_id}`. New items inserted with
* the same timestamp will still appear correctly because the cursor is
* decoded into an anchor point, not a row number.
*/
export function getCampaignTimeline(
campaignId: string,
options: { cursor?: string; limit?: number } = {},
): TimelinePage {
const limit = Math.min(options.limit ?? 20, 100);
const db = getDb();
let cursorTimestamp: number | undefined;
let cursorSource: string | undefined;
let cursorId: number | undefined;
if (options.cursor) {
try {
const decoded = Buffer.from(options.cursor, 'base64').toString('utf-8');
const parts = decoded.split(':');
cursorTimestamp = Number(parts[0]);
cursorSource = parts[1];
cursorId = Number(parts[2]);
} catch {
cursorTimestamp = undefined;
}
}
const rows = db
.prepare(
`
WITH combined AS (
SELECT
'pledge' AS source,
p.id AS source_id,
p.campaign_id,
p.created_at AS timestamp,
p.contributor AS actor,
p.amount AS amount,
p.asset_code AS asset_code,
NULL AS event_type,
NULL AS metadata
FROM pledges p
WHERE p.campaign_id = ?
UNION ALL
SELECT
'event' AS source,
e.id AS source_id,
e.campaign_id,
e.timestamp,
e.actor,
e.amount,
NULL AS asset_code,
e.event_type,
e.metadata
FROM campaign_events e
WHERE e.campaign_id = ?
)
SELECT * FROM combined
WHERE (? IS NULL
OR timestamp < ?
OR (timestamp = ? AND source > ?)
OR (timestamp = ? AND source = ? AND source_id < ?))
ORDER BY timestamp DESC, source ASC, source_id DESC
LIMIT ?
`,
)
.all(
campaignId,
campaignId,
cursorTimestamp ?? null,
cursorTimestamp ?? null,
cursorTimestamp ?? null,
cursorSource ?? null,
cursorTimestamp ?? null,
cursorSource ?? null,
cursorId ?? null,
limit + 1,
) as TimelineRow[];
const hasMore = rows.length > limit;
const items = rows.slice(0, limit).map(rowToTimelineItem);
let nextCursor: string | undefined;
if (hasMore && rows.length > 0) {
const lastRow = rows[limit - 1];
const raw = `${lastRow.timestamp}:${lastRow.source}:${lastRow.source_id}`;
nextCursor = Buffer.from(raw).toString('base64');
}
return { data: items, nextCursor, hasMore };
}
function rowToTimelineItem(row: TimelineRow): TimelineItem {
const isPledge = row.source === 'pledge';
let type: TimelineItemType;
let parsedMetadata: Record<string, unknown> | undefined;
if (isPledge) {
type = 'pledge';
parsedMetadata = undefined;
} else {
parsedMetadata = row.metadata ? (JSON.parse(row.metadata) as Record<string, unknown>) : undefined;
switch (row.event_type) {
case 'created':
case 'claimed':
case 'refunded':
type = 'status_change';
break;
case 'metadata_updated':
type = 'update';
break;
case 'pledged':
type = 'pledge';
break;
case 'pledge_limit_reached':
type = 'status_change';
break;
default:
type = 'status_change';
}
}
const id = `${row.source}:${row.source_id}`;
return {
id,
campaignId: row.campaign_id,
type,
timestamp: row.timestamp,
actor: row.actor ?? undefined,
amount: row.amount ?? undefined,
assetCode: row.asset_code ?? undefined,
metadata: parsedMetadata,
eventType: row.event_type ?? undefined,
};
}
/**
* Returns all events originating from a given source (local backend or Soroban chain).
*
* @param source - `"local"` for off-chain events, `"soroban"` for on-chain events.
* @returns An array of {@link CampaignEvent} objects in chronological order.
*/
export function getEventsBySource(source: 'local' | 'soroban'): CampaignEvent[] {
const db = getDb();
const rows = db
.prepare(
`SELECT * FROM campaign_events WHERE json_extract(blockchain_metadata, '$.source') = ? ORDER BY timestamp ASC, id ASC`,
)
.all(source) as EventRow[];
return rows.map(rowToEvent);
}