-
Notifications
You must be signed in to change notification settings - Fork 173
Expand file tree
/
Copy patheventHistory.ts
More file actions
244 lines (217 loc) · 7.03 KB
/
Copy patheventHistory.ts
File metadata and controls
244 lines (217 loc) · 7.03 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
import { getDb } from './db';
export type CampaignLifecycleStatus = 'open' | 'funded' | 'claimed' | 'failed' | 'canceled';
export type CampaignEventType =
| 'created'
| 'pledged'
| 'claimed'
| 'refunded'
| 'updated'
| 'campaign_opened'
| 'campaign_funded'
| 'campaign_claimed'
| 'campaign_failed'
| 'campaign_canceled';
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;
}
const STATUS_TRANSITION_EVENTS: Partial<Record<CampaignEventType, CampaignLifecycleStatus>> = {
campaign_opened: 'open',
campaign_funded: 'funded',
campaign_claimed: 'claimed',
campaign_failed: 'failed',
campaign_canceled: 'canceled',
};
const statusCache = new Map<string, CampaignLifecycleStatus>();
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 invalidateCampaignStatusCache(campaignId: string): void {
statusCache.delete(campaignId);
}
export function getDerivedCampaignStatus(
campaignId: string,
fallbackStatus: CampaignLifecycleStatus,
): CampaignLifecycleStatus {
const cachedStatus = statusCache.get(campaignId);
if (cachedStatus !== undefined) {
return cachedStatus;
}
const history = getCampaignHistory(campaignId);
let currentStatus = fallbackStatus;
for (const event of history) {
const nextStatus = STATUS_TRANSITION_EVENTS[event.eventType];
if (nextStatus) {
currentStatus = nextStatus;
}
}
statusCache.set(campaignId, currentStatus);
return currentStatus;
}
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,
});
invalidateCampaignStatusCache(campaignId);
}
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);
}
/**
* 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);
}