domsniper 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +40 -0
- package/LICENSE +21 -0
- package/README.md +246 -0
- package/package.json +72 -0
- package/src/app.tsx +2062 -0
- package/src/completions.ts +65 -0
- package/src/core/db.ts +1313 -0
- package/src/core/features/asn-lookup.ts +91 -0
- package/src/core/features/backlinks.ts +83 -0
- package/src/core/features/blacklist-check.ts +67 -0
- package/src/core/features/cert-transparency.ts +87 -0
- package/src/core/features/config.ts +81 -0
- package/src/core/features/cors-check.ts +90 -0
- package/src/core/features/dns-details.ts +27 -0
- package/src/core/features/domain-age.ts +33 -0
- package/src/core/features/domain-suggest.ts +87 -0
- package/src/core/features/drop-catch.ts +159 -0
- package/src/core/features/email-security.ts +112 -0
- package/src/core/features/expiring-feed.ts +160 -0
- package/src/core/features/export.ts +74 -0
- package/src/core/features/filter.ts +96 -0
- package/src/core/features/http-probe.ts +46 -0
- package/src/core/features/marketplace.ts +69 -0
- package/src/core/features/path-scanner.ts +123 -0
- package/src/core/features/port-scanner.ts +132 -0
- package/src/core/features/portfolio-bulk.ts +125 -0
- package/src/core/features/portfolio-monitor.ts +214 -0
- package/src/core/features/portfolio.ts +98 -0
- package/src/core/features/price-compare.ts +39 -0
- package/src/core/features/rdap.ts +128 -0
- package/src/core/features/reverse-ip.ts +73 -0
- package/src/core/features/s3-export.ts +99 -0
- package/src/core/features/scoring.ts +121 -0
- package/src/core/features/security-headers.ts +162 -0
- package/src/core/features/session.ts +74 -0
- package/src/core/features/snipe.ts +264 -0
- package/src/core/features/social-check.ts +81 -0
- package/src/core/features/ssl-check.ts +88 -0
- package/src/core/features/subdomain-discovery.ts +53 -0
- package/src/core/features/takeover-detect.ts +143 -0
- package/src/core/features/tech-stack.ts +135 -0
- package/src/core/features/tld-expand.ts +43 -0
- package/src/core/features/variations.ts +134 -0
- package/src/core/features/version-check.ts +58 -0
- package/src/core/features/waf-detect.ts +171 -0
- package/src/core/features/watch.ts +120 -0
- package/src/core/features/wayback.ts +64 -0
- package/src/core/features/webhooks.ts +126 -0
- package/src/core/features/whois-history.ts +99 -0
- package/src/core/features/zone-transfer.ts +75 -0
- package/src/core/index.ts +50 -0
- package/src/core/paths.ts +9 -0
- package/src/core/registrar.ts +413 -0
- package/src/core/theme.ts +140 -0
- package/src/core/types.ts +143 -0
- package/src/core/validate.ts +58 -0
- package/src/core/whois.ts +265 -0
- package/src/index.tsx +1888 -0
- package/src/market-client.ts +186 -0
- package/src/proxy/ca.ts +116 -0
- package/src/proxy/db.ts +175 -0
- package/src/proxy/server.ts +155 -0
- package/tsconfig.json +30 -0
package/src/core/db.ts
ADDED
|
@@ -0,0 +1,1313 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { mkdirSync, existsSync, readdirSync, readFileSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { APP_DIR, DB_FILE } from "./paths.js";
|
|
5
|
+
import type { DomainEntry } from "./types.js";
|
|
6
|
+
|
|
7
|
+
// ─── Ensure directory exists ─────────────────────────────
|
|
8
|
+
function ensureAppDir(): void {
|
|
9
|
+
if (!existsSync(APP_DIR)) {
|
|
10
|
+
mkdirSync(APP_DIR, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// ─── Database singleton ──────────────────────────────────
|
|
15
|
+
let _db: Database | null = null;
|
|
16
|
+
let _dbPath: string | null = null;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Override the database path. Call BEFORE getDb().
|
|
20
|
+
* Use ":memory:" for tests to avoid polluting the real database.
|
|
21
|
+
*/
|
|
22
|
+
export function setDbPath(path: string): void {
|
|
23
|
+
if (_db) { _db.close(); _db = null; }
|
|
24
|
+
_dbPath = path;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getDb(): Database {
|
|
28
|
+
if (_db) return _db;
|
|
29
|
+
const dbPath = _dbPath || DB_FILE;
|
|
30
|
+
if (dbPath !== ":memory:") ensureAppDir();
|
|
31
|
+
_db = new Database(dbPath);
|
|
32
|
+
_db.run("PRAGMA journal_mode = WAL");
|
|
33
|
+
_db.run("PRAGMA foreign_keys = ON");
|
|
34
|
+
_db.run("PRAGMA busy_timeout = 5000");
|
|
35
|
+
migrate(_db);
|
|
36
|
+
return _db;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function closeDb(): void {
|
|
40
|
+
if (_db) {
|
|
41
|
+
_db.close();
|
|
42
|
+
_db = null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── Migration types ─────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
interface Migration {
|
|
49
|
+
name: string;
|
|
50
|
+
sql: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface MigrationRow {
|
|
54
|
+
name: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Migrations ──────────────────────────────────────────
|
|
58
|
+
function migrate(db: Database): void {
|
|
59
|
+
db.run(`
|
|
60
|
+
CREATE TABLE IF NOT EXISTS migrations (
|
|
61
|
+
id INTEGER PRIMARY KEY,
|
|
62
|
+
name TEXT NOT NULL UNIQUE,
|
|
63
|
+
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
64
|
+
)
|
|
65
|
+
`);
|
|
66
|
+
|
|
67
|
+
const applied = new Set(
|
|
68
|
+
db
|
|
69
|
+
.query<MigrationRow, []>("SELECT name FROM migrations")
|
|
70
|
+
.all()
|
|
71
|
+
.map((r) => r.name),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
for (const m of MIGRATIONS) {
|
|
75
|
+
if (!applied.has(m.name)) {
|
|
76
|
+
db.run(m.sql);
|
|
77
|
+
db.run("INSERT INTO migrations (name) VALUES (?)", [m.name]);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const MIGRATIONS: Migration[] = [
|
|
83
|
+
{
|
|
84
|
+
name: "001_create_domains",
|
|
85
|
+
sql: `
|
|
86
|
+
CREATE TABLE IF NOT EXISTS domains (
|
|
87
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
88
|
+
domain TEXT NOT NULL UNIQUE,
|
|
89
|
+
first_seen TEXT NOT NULL DEFAULT (datetime('now')),
|
|
90
|
+
last_scanned TEXT,
|
|
91
|
+
scan_count INTEGER NOT NULL DEFAULT 0,
|
|
92
|
+
tags TEXT DEFAULT '[]',
|
|
93
|
+
notes TEXT DEFAULT ''
|
|
94
|
+
);
|
|
95
|
+
CREATE INDEX IF NOT EXISTS idx_domains_domain ON domains(domain);
|
|
96
|
+
`,
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: "002_create_scans",
|
|
100
|
+
sql: `
|
|
101
|
+
CREATE TABLE IF NOT EXISTS scans (
|
|
102
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
103
|
+
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
|
104
|
+
session_id INTEGER REFERENCES sessions(id) ON DELETE SET NULL,
|
|
105
|
+
scanned_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
106
|
+
status TEXT NOT NULL,
|
|
107
|
+
score INTEGER,
|
|
108
|
+
data TEXT NOT NULL DEFAULT '{}',
|
|
109
|
+
FOREIGN KEY (domain_id) REFERENCES domains(id)
|
|
110
|
+
);
|
|
111
|
+
CREATE INDEX IF NOT EXISTS idx_scans_domain_id ON scans(domain_id);
|
|
112
|
+
CREATE INDEX IF NOT EXISTS idx_scans_scanned_at ON scans(scanned_at);
|
|
113
|
+
CREATE INDEX IF NOT EXISTS idx_scans_status ON scans(status);
|
|
114
|
+
`,
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: "003_create_sessions",
|
|
118
|
+
sql: `
|
|
119
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
120
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
121
|
+
name TEXT NOT NULL,
|
|
122
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
123
|
+
domain_count INTEGER NOT NULL DEFAULT 0,
|
|
124
|
+
metadata TEXT DEFAULT '{}'
|
|
125
|
+
);
|
|
126
|
+
`,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: "004_create_portfolio",
|
|
130
|
+
sql: `
|
|
131
|
+
CREATE TABLE IF NOT EXISTS portfolio (
|
|
132
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
133
|
+
domain TEXT NOT NULL UNIQUE,
|
|
134
|
+
registrar TEXT DEFAULT 'unknown',
|
|
135
|
+
purchase_date TEXT,
|
|
136
|
+
expiry_date TEXT,
|
|
137
|
+
purchase_price REAL DEFAULT 0,
|
|
138
|
+
renewal_price REAL DEFAULT 0,
|
|
139
|
+
currency TEXT DEFAULT 'USD',
|
|
140
|
+
auto_renew INTEGER DEFAULT 0,
|
|
141
|
+
tags TEXT DEFAULT '[]',
|
|
142
|
+
notes TEXT DEFAULT '',
|
|
143
|
+
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
144
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
145
|
+
);
|
|
146
|
+
CREATE INDEX IF NOT EXISTS idx_portfolio_domain ON portfolio(domain);
|
|
147
|
+
CREATE INDEX IF NOT EXISTS idx_portfolio_expiry ON portfolio(expiry_date);
|
|
148
|
+
`,
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: "005_create_whois_history",
|
|
152
|
+
sql: `
|
|
153
|
+
CREATE TABLE IF NOT EXISTS whois_history (
|
|
154
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
155
|
+
domain TEXT NOT NULL,
|
|
156
|
+
snapshot_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
157
|
+
registrar TEXT,
|
|
158
|
+
expiry_date TEXT,
|
|
159
|
+
created_date TEXT,
|
|
160
|
+
updated_date TEXT,
|
|
161
|
+
status TEXT DEFAULT '[]',
|
|
162
|
+
name_servers TEXT DEFAULT '[]',
|
|
163
|
+
available INTEGER DEFAULT 0,
|
|
164
|
+
expired INTEGER DEFAULT 0,
|
|
165
|
+
raw_text TEXT DEFAULT ''
|
|
166
|
+
);
|
|
167
|
+
CREATE INDEX IF NOT EXISTS idx_whois_domain ON whois_history(domain);
|
|
168
|
+
CREATE INDEX IF NOT EXISTS idx_whois_snapshot_at ON whois_history(snapshot_at);
|
|
169
|
+
`,
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: "006_create_cache",
|
|
173
|
+
sql: `
|
|
174
|
+
CREATE TABLE IF NOT EXISTS cache (
|
|
175
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
176
|
+
domain TEXT NOT NULL,
|
|
177
|
+
cache_key TEXT NOT NULL,
|
|
178
|
+
data TEXT NOT NULL,
|
|
179
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
180
|
+
expires_at TEXT NOT NULL,
|
|
181
|
+
UNIQUE(domain, cache_key)
|
|
182
|
+
);
|
|
183
|
+
CREATE INDEX IF NOT EXISTS idx_cache_domain_key ON cache(domain, cache_key);
|
|
184
|
+
CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at);
|
|
185
|
+
`,
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: "007_portfolio_expand",
|
|
189
|
+
sql: `
|
|
190
|
+
ALTER TABLE portfolio ADD COLUMN status TEXT DEFAULT 'active';
|
|
191
|
+
ALTER TABLE portfolio ADD COLUMN category TEXT DEFAULT 'uncategorized';
|
|
192
|
+
ALTER TABLE portfolio ADD COLUMN estimated_value REAL DEFAULT 0;
|
|
193
|
+
ALTER TABLE portfolio ADD COLUMN last_health_check TEXT;
|
|
194
|
+
`,
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
name: "008_create_transactions",
|
|
198
|
+
sql: `
|
|
199
|
+
CREATE TABLE IF NOT EXISTS portfolio_transactions (
|
|
200
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
201
|
+
domain TEXT NOT NULL,
|
|
202
|
+
type TEXT NOT NULL,
|
|
203
|
+
amount REAL NOT NULL,
|
|
204
|
+
currency TEXT DEFAULT 'USD',
|
|
205
|
+
description TEXT DEFAULT '',
|
|
206
|
+
date TEXT NOT NULL DEFAULT (date('now')),
|
|
207
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
208
|
+
);
|
|
209
|
+
CREATE INDEX IF NOT EXISTS idx_txn_domain ON portfolio_transactions(domain);
|
|
210
|
+
CREATE INDEX IF NOT EXISTS idx_txn_date ON portfolio_transactions(date);
|
|
211
|
+
CREATE INDEX IF NOT EXISTS idx_txn_type ON portfolio_transactions(type);
|
|
212
|
+
`,
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
name: "009_create_valuations",
|
|
216
|
+
sql: `
|
|
217
|
+
CREATE TABLE IF NOT EXISTS portfolio_valuations (
|
|
218
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
219
|
+
domain TEXT NOT NULL,
|
|
220
|
+
estimated_value REAL NOT NULL,
|
|
221
|
+
source TEXT DEFAULT 'manual',
|
|
222
|
+
valued_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
223
|
+
);
|
|
224
|
+
CREATE INDEX IF NOT EXISTS idx_val_domain ON portfolio_valuations(domain);
|
|
225
|
+
`,
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
name: "010_create_pipeline",
|
|
229
|
+
sql: `
|
|
230
|
+
CREATE TABLE IF NOT EXISTS acquisition_pipeline (
|
|
231
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
232
|
+
domain TEXT NOT NULL UNIQUE,
|
|
233
|
+
status TEXT NOT NULL DEFAULT 'watching',
|
|
234
|
+
max_bid REAL,
|
|
235
|
+
current_price REAL,
|
|
236
|
+
source TEXT DEFAULT '',
|
|
237
|
+
notes TEXT DEFAULT '',
|
|
238
|
+
priority TEXT DEFAULT 'medium',
|
|
239
|
+
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
240
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
241
|
+
);
|
|
242
|
+
CREATE INDEX IF NOT EXISTS idx_pipeline_status ON acquisition_pipeline(status);
|
|
243
|
+
`,
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
name: "011_create_categories",
|
|
247
|
+
sql: `
|
|
248
|
+
CREATE TABLE IF NOT EXISTS portfolio_categories (
|
|
249
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
250
|
+
name TEXT NOT NULL UNIQUE,
|
|
251
|
+
color TEXT DEFAULT '#5c9cf5',
|
|
252
|
+
description TEXT DEFAULT '',
|
|
253
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
254
|
+
);
|
|
255
|
+
INSERT OR IGNORE INTO portfolio_categories (name, description) VALUES
|
|
256
|
+
('uncategorized', 'Default category'),
|
|
257
|
+
('investments', 'Domains held for resale'),
|
|
258
|
+
('projects', 'Domains used for active projects'),
|
|
259
|
+
('clients', 'Domains managed for clients'),
|
|
260
|
+
('for-sale', 'Domains actively listed for sale'),
|
|
261
|
+
('archived', 'Domains no longer maintained');
|
|
262
|
+
`,
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
name: "012_create_alerts",
|
|
266
|
+
sql: `
|
|
267
|
+
CREATE TABLE IF NOT EXISTS portfolio_alerts (
|
|
268
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
269
|
+
domain TEXT NOT NULL,
|
|
270
|
+
type TEXT NOT NULL,
|
|
271
|
+
severity TEXT NOT NULL DEFAULT 'info',
|
|
272
|
+
message TEXT NOT NULL,
|
|
273
|
+
acknowledged INTEGER DEFAULT 0,
|
|
274
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
275
|
+
);
|
|
276
|
+
CREATE INDEX IF NOT EXISTS idx_alerts_domain ON portfolio_alerts(domain);
|
|
277
|
+
CREATE INDEX IF NOT EXISTS idx_alerts_ack ON portfolio_alerts(acknowledged);
|
|
278
|
+
`,
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
name: "013_create_snipes",
|
|
282
|
+
sql: `
|
|
283
|
+
CREATE TABLE IF NOT EXISTS snipes (
|
|
284
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
285
|
+
domain TEXT NOT NULL UNIQUE,
|
|
286
|
+
status TEXT NOT NULL DEFAULT 'watching',
|
|
287
|
+
phase TEXT NOT NULL DEFAULT 'hourly',
|
|
288
|
+
expiry_date TEXT,
|
|
289
|
+
registrar_provider TEXT,
|
|
290
|
+
max_price REAL,
|
|
291
|
+
check_count INTEGER DEFAULT 0,
|
|
292
|
+
last_checked TEXT,
|
|
293
|
+
last_status TEXT,
|
|
294
|
+
registered_at TEXT,
|
|
295
|
+
notification_sent INTEGER DEFAULT 0,
|
|
296
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
297
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
298
|
+
)
|
|
299
|
+
`,
|
|
300
|
+
},
|
|
301
|
+
];
|
|
302
|
+
|
|
303
|
+
// ─── Row types ───────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
interface DomainRow {
|
|
306
|
+
id: number;
|
|
307
|
+
domain: string;
|
|
308
|
+
first_seen: string;
|
|
309
|
+
last_scanned: string | null;
|
|
310
|
+
scan_count: number;
|
|
311
|
+
tags: string;
|
|
312
|
+
notes: string;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
interface IdRow {
|
|
316
|
+
id: number;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
interface DataRow {
|
|
320
|
+
data: string;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
interface ScanHistoryRow {
|
|
324
|
+
id: number;
|
|
325
|
+
scanned_at: string;
|
|
326
|
+
status: string;
|
|
327
|
+
score: number | null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
interface SessionRow {
|
|
331
|
+
id: number;
|
|
332
|
+
name: string;
|
|
333
|
+
created_at: string;
|
|
334
|
+
domain_count: number;
|
|
335
|
+
metadata: string;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
interface CountRow {
|
|
339
|
+
c: number;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
interface SumRow {
|
|
343
|
+
s: number;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
interface RegistrarCountRow {
|
|
347
|
+
registrar: string;
|
|
348
|
+
c: number;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ─── Domain CRUD ─────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
export function upsertDomain(domain: string): number {
|
|
354
|
+
const db = getDb();
|
|
355
|
+
db.run(
|
|
356
|
+
`INSERT INTO domains (domain) VALUES (?)
|
|
357
|
+
ON CONFLICT(domain) DO UPDATE SET
|
|
358
|
+
last_scanned = datetime('now'),
|
|
359
|
+
scan_count = scan_count + 1`,
|
|
360
|
+
[domain],
|
|
361
|
+
);
|
|
362
|
+
const row = db
|
|
363
|
+
.query<IdRow, [string]>("SELECT id FROM domains WHERE domain = ?")
|
|
364
|
+
.get(domain);
|
|
365
|
+
return row!.id;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function getDomainByName(domain: string): DomainRow | null {
|
|
369
|
+
const db = getDb();
|
|
370
|
+
return db
|
|
371
|
+
.query<DomainRow, [string]>("SELECT * FROM domains WHERE domain = ?")
|
|
372
|
+
.get(domain);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function getAllDomains(
|
|
376
|
+
limit: number = 100,
|
|
377
|
+
offset: number = 0,
|
|
378
|
+
): DomainRow[] {
|
|
379
|
+
const db = getDb();
|
|
380
|
+
return db
|
|
381
|
+
.query<DomainRow, [number, number]>(
|
|
382
|
+
"SELECT * FROM domains ORDER BY last_scanned DESC LIMIT ? OFFSET ?",
|
|
383
|
+
)
|
|
384
|
+
.all(limit, offset);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function searchDomains(
|
|
388
|
+
query: string,
|
|
389
|
+
limit: number = 50,
|
|
390
|
+
): DomainRow[] {
|
|
391
|
+
const db = getDb();
|
|
392
|
+
return db
|
|
393
|
+
.query<DomainRow, [string, number]>(
|
|
394
|
+
"SELECT * FROM domains WHERE domain LIKE ? ORDER BY scan_count DESC LIMIT ?",
|
|
395
|
+
)
|
|
396
|
+
.all(`%${query}%`, limit);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ─── Scan CRUD ───────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
export function saveScan(
|
|
402
|
+
domainId: number,
|
|
403
|
+
status: string,
|
|
404
|
+
data: DomainEntry,
|
|
405
|
+
sessionId?: number,
|
|
406
|
+
score?: number,
|
|
407
|
+
): number {
|
|
408
|
+
const db = getDb();
|
|
409
|
+
const result = db.run(
|
|
410
|
+
`INSERT INTO scans (domain_id, session_id, status, score, data) VALUES (?, ?, ?, ?, ?)`,
|
|
411
|
+
[domainId, sessionId ?? null, status, score ?? null, JSON.stringify(data)],
|
|
412
|
+
);
|
|
413
|
+
return Number(result.lastInsertRowid);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function getLatestScan(domain: string): DomainEntry | null {
|
|
417
|
+
const db = getDb();
|
|
418
|
+
const row = db
|
|
419
|
+
.query<DataRow, [string]>(
|
|
420
|
+
`SELECT s.data FROM scans s
|
|
421
|
+
JOIN domains d ON s.domain_id = d.id
|
|
422
|
+
WHERE d.domain = ?
|
|
423
|
+
ORDER BY s.scanned_at DESC LIMIT 1`,
|
|
424
|
+
)
|
|
425
|
+
.get(domain);
|
|
426
|
+
if (!row) return null;
|
|
427
|
+
try {
|
|
428
|
+
return JSON.parse(row.data) as DomainEntry;
|
|
429
|
+
} catch {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export function getScanHistory(
|
|
435
|
+
domain: string,
|
|
436
|
+
limit: number = 20,
|
|
437
|
+
): ScanHistoryRow[] {
|
|
438
|
+
const db = getDb();
|
|
439
|
+
return db
|
|
440
|
+
.query<ScanHistoryRow, [string, number]>(
|
|
441
|
+
`SELECT s.id, s.scanned_at, s.status, s.score FROM scans s
|
|
442
|
+
JOIN domains d ON s.domain_id = d.id
|
|
443
|
+
WHERE d.domain = ?
|
|
444
|
+
ORDER BY s.scanned_at DESC LIMIT ?`,
|
|
445
|
+
)
|
|
446
|
+
.all(domain, limit);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export function getScanById(scanId: number): DomainEntry | null {
|
|
450
|
+
const db = getDb();
|
|
451
|
+
const row = db
|
|
452
|
+
.query<DataRow, [number]>("SELECT data FROM scans WHERE id = ?")
|
|
453
|
+
.get(scanId);
|
|
454
|
+
if (!row) return null;
|
|
455
|
+
try {
|
|
456
|
+
return JSON.parse(row.data) as DomainEntry;
|
|
457
|
+
} catch {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ─── Session CRUD ────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
export function createSession(name?: string): number {
|
|
465
|
+
const db = getDb();
|
|
466
|
+
const sessionName = name || `scan-${Date.now()}`;
|
|
467
|
+
const result = db.run(
|
|
468
|
+
"INSERT INTO sessions (name) VALUES (?)",
|
|
469
|
+
[sessionName],
|
|
470
|
+
);
|
|
471
|
+
return Number(result.lastInsertRowid);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function updateSessionCount(
|
|
475
|
+
sessionId: number,
|
|
476
|
+
count: number,
|
|
477
|
+
): void {
|
|
478
|
+
const db = getDb();
|
|
479
|
+
db.run(
|
|
480
|
+
"UPDATE sessions SET domain_count = ? WHERE id = ?",
|
|
481
|
+
[count, sessionId],
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export function getSession(sessionId: number): SessionRow | null {
|
|
486
|
+
const db = getDb();
|
|
487
|
+
return db
|
|
488
|
+
.query<SessionRow, [number]>("SELECT * FROM sessions WHERE id = ?")
|
|
489
|
+
.get(sessionId);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export function listAllSessions(limit: number = 50): SessionRow[] {
|
|
493
|
+
const db = getDb();
|
|
494
|
+
return db
|
|
495
|
+
.query<SessionRow, [number]>(
|
|
496
|
+
"SELECT * FROM sessions ORDER BY created_at DESC LIMIT ?",
|
|
497
|
+
)
|
|
498
|
+
.all(limit);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export function deleteSessionById(sessionId: number): void {
|
|
502
|
+
const db = getDb();
|
|
503
|
+
db.run("DELETE FROM sessions WHERE id = ?", [sessionId]);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export function getSessionScans(sessionId: number): DomainEntry[] {
|
|
507
|
+
const db = getDb();
|
|
508
|
+
const rows = db
|
|
509
|
+
.query<DataRow, [number]>(
|
|
510
|
+
"SELECT data FROM scans WHERE session_id = ? ORDER BY id",
|
|
511
|
+
)
|
|
512
|
+
.all(sessionId);
|
|
513
|
+
return rows
|
|
514
|
+
.map((r) => {
|
|
515
|
+
try {
|
|
516
|
+
return JSON.parse(r.data) as DomainEntry;
|
|
517
|
+
} catch {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
})
|
|
521
|
+
.filter((entry): entry is DomainEntry => entry !== null);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ─── Portfolio CRUD ──────────────────────────────────────
|
|
525
|
+
|
|
526
|
+
export interface DbPortfolioDomain {
|
|
527
|
+
id: number;
|
|
528
|
+
domain: string;
|
|
529
|
+
registrar: string;
|
|
530
|
+
purchase_date: string | null;
|
|
531
|
+
expiry_date: string | null;
|
|
532
|
+
purchase_price: number;
|
|
533
|
+
renewal_price: number;
|
|
534
|
+
currency: string;
|
|
535
|
+
auto_renew: number;
|
|
536
|
+
tags: string;
|
|
537
|
+
notes: string;
|
|
538
|
+
added_at: string;
|
|
539
|
+
updated_at: string;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export interface PortfolioDomainDetails {
|
|
543
|
+
registrar?: string;
|
|
544
|
+
purchaseDate?: string;
|
|
545
|
+
expiryDate?: string;
|
|
546
|
+
purchasePrice?: number;
|
|
547
|
+
renewalPrice?: number;
|
|
548
|
+
currency?: string;
|
|
549
|
+
autoRenew?: boolean;
|
|
550
|
+
tags?: string[];
|
|
551
|
+
notes?: string;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export function addPortfolioDomain(
|
|
555
|
+
domain: string,
|
|
556
|
+
details: PortfolioDomainDetails = {},
|
|
557
|
+
): void {
|
|
558
|
+
const db = getDb();
|
|
559
|
+
db.run(
|
|
560
|
+
`INSERT INTO portfolio (domain, registrar, purchase_date, expiry_date, purchase_price, renewal_price, currency, auto_renew, tags, notes)
|
|
561
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
562
|
+
ON CONFLICT(domain) DO UPDATE SET
|
|
563
|
+
registrar = excluded.registrar,
|
|
564
|
+
purchase_date = excluded.purchase_date,
|
|
565
|
+
expiry_date = excluded.expiry_date,
|
|
566
|
+
purchase_price = excluded.purchase_price,
|
|
567
|
+
renewal_price = excluded.renewal_price,
|
|
568
|
+
currency = excluded.currency,
|
|
569
|
+
auto_renew = excluded.auto_renew,
|
|
570
|
+
tags = excluded.tags,
|
|
571
|
+
notes = excluded.notes,
|
|
572
|
+
updated_at = datetime('now')`,
|
|
573
|
+
[
|
|
574
|
+
domain,
|
|
575
|
+
details.registrar || "unknown",
|
|
576
|
+
details.purchaseDate || null,
|
|
577
|
+
details.expiryDate || null,
|
|
578
|
+
details.purchasePrice || 0,
|
|
579
|
+
details.renewalPrice || 0,
|
|
580
|
+
details.currency || "USD",
|
|
581
|
+
details.autoRenew ? 1 : 0,
|
|
582
|
+
JSON.stringify(details.tags || []),
|
|
583
|
+
details.notes || "",
|
|
584
|
+
],
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export function removePortfolioDomain(domain: string): void {
|
|
589
|
+
const db = getDb();
|
|
590
|
+
db.run("DELETE FROM portfolio WHERE domain = ?", [domain]);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export function getPortfolioDomains(): DbPortfolioDomain[] {
|
|
594
|
+
const db = getDb();
|
|
595
|
+
return db
|
|
596
|
+
.query<DbPortfolioDomain, []>(
|
|
597
|
+
"SELECT * FROM portfolio ORDER BY domain",
|
|
598
|
+
)
|
|
599
|
+
.all();
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export function getPortfolioExpiring(
|
|
603
|
+
withinDays: number = 30,
|
|
604
|
+
): DbPortfolioDomain[] {
|
|
605
|
+
const db = getDb();
|
|
606
|
+
return db
|
|
607
|
+
.query<DbPortfolioDomain, [number]>(
|
|
608
|
+
`SELECT * FROM portfolio
|
|
609
|
+
WHERE expiry_date IS NOT NULL
|
|
610
|
+
AND date(expiry_date) <= date('now', '+' || ? || ' days')
|
|
611
|
+
AND date(expiry_date) >= date('now')
|
|
612
|
+
ORDER BY expiry_date`,
|
|
613
|
+
)
|
|
614
|
+
.all(withinDays);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export interface PortfolioStats {
|
|
618
|
+
total: number;
|
|
619
|
+
totalSpent: number;
|
|
620
|
+
expiringIn30: number;
|
|
621
|
+
expiringIn90: number;
|
|
622
|
+
byRegistrar: Record<string, number>;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export function getPortfolioStatsDb(): PortfolioStats {
|
|
626
|
+
const db = getDb();
|
|
627
|
+
const totalRow = db
|
|
628
|
+
.query<CountRow, []>("SELECT COUNT(*) as c FROM portfolio")
|
|
629
|
+
.get();
|
|
630
|
+
const total = totalRow?.c ?? 0;
|
|
631
|
+
|
|
632
|
+
const spentRow = db
|
|
633
|
+
.query<SumRow, []>(
|
|
634
|
+
"SELECT COALESCE(SUM(purchase_price), 0) as s FROM portfolio",
|
|
635
|
+
)
|
|
636
|
+
.get();
|
|
637
|
+
const totalSpent = spentRow?.s ?? 0;
|
|
638
|
+
|
|
639
|
+
const expiringIn30 = getPortfolioExpiring(30).length;
|
|
640
|
+
const expiringIn90 = getPortfolioExpiring(90).length;
|
|
641
|
+
|
|
642
|
+
const registrars = db
|
|
643
|
+
.query<RegistrarCountRow, []>(
|
|
644
|
+
"SELECT registrar, COUNT(*) as c FROM portfolio GROUP BY registrar",
|
|
645
|
+
)
|
|
646
|
+
.all();
|
|
647
|
+
const byRegistrar: Record<string, number> = {};
|
|
648
|
+
for (const r of registrars) {
|
|
649
|
+
byRegistrar[r.registrar] = r.c;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
return { total, totalSpent, expiringIn30, expiringIn90, byRegistrar };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// ─── Portfolio Status & Category ─────────────────────────
|
|
656
|
+
|
|
657
|
+
export type PortfolioStatus = "active" | "parked" | "for-sale" | "development" | "archived";
|
|
658
|
+
export type PipelineStatus = "watching" | "bidding" | "negotiating" | "won" | "lost" | "cancelled";
|
|
659
|
+
export type TransactionType = "purchase" | "renewal" | "sale" | "parking-revenue" | "affiliate-revenue" | "expense" | "refund";
|
|
660
|
+
export type AlertSeverity = "critical" | "warning" | "info";
|
|
661
|
+
|
|
662
|
+
export function updatePortfolioStatus(domain: string, status: PortfolioStatus): void {
|
|
663
|
+
const db = getDb();
|
|
664
|
+
db.run("UPDATE portfolio SET status = ?, updated_at = datetime('now') WHERE domain = ?", [status, domain]);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export function updatePortfolioCategory(domain: string, category: string): void {
|
|
668
|
+
const db = getDb();
|
|
669
|
+
db.run("UPDATE portfolio SET category = ?, updated_at = datetime('now') WHERE domain = ?", [category, domain]);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
export function updatePortfolioValue(domain: string, value: number): void {
|
|
673
|
+
const db = getDb();
|
|
674
|
+
db.run("UPDATE portfolio SET estimated_value = ?, updated_at = datetime('now') WHERE domain = ?", [value, domain]);
|
|
675
|
+
// Also save valuation history
|
|
676
|
+
db.run("INSERT INTO portfolio_valuations (domain, estimated_value, source) VALUES (?, ?, 'manual')", [domain, value]);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
export function getPortfolioByStatus(status: PortfolioStatus): DbPortfolioDomain[] {
|
|
680
|
+
const db = getDb();
|
|
681
|
+
return db.query("SELECT * FROM portfolio WHERE status = ? ORDER BY domain").all(status) as DbPortfolioDomain[];
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
export function getPortfolioByCategory(category: string): DbPortfolioDomain[] {
|
|
685
|
+
const db = getDb();
|
|
686
|
+
return db.query("SELECT * FROM portfolio WHERE category = ? ORDER BY domain").all(category) as DbPortfolioDomain[];
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// ─── Transactions ────────────────────────────────────────
|
|
690
|
+
|
|
691
|
+
export function addTransaction(
|
|
692
|
+
domain: string,
|
|
693
|
+
type: TransactionType,
|
|
694
|
+
amount: number,
|
|
695
|
+
description: string = "",
|
|
696
|
+
date?: string,
|
|
697
|
+
currency: string = "USD"
|
|
698
|
+
): number {
|
|
699
|
+
const db = getDb();
|
|
700
|
+
const result = db.run(
|
|
701
|
+
"INSERT INTO portfolio_transactions (domain, type, amount, currency, description, date) VALUES (?, ?, ?, ?, ?, ?)",
|
|
702
|
+
[domain, type, amount, currency, description, date || new Date().toISOString().split("T")[0]!]
|
|
703
|
+
);
|
|
704
|
+
return Number(result.lastInsertRowid);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
export function getTransactions(domain?: string, limit: number = 50): Array<{
|
|
708
|
+
id: number; domain: string; type: string; amount: number; currency: string; description: string; date: string;
|
|
709
|
+
}> {
|
|
710
|
+
const db = getDb();
|
|
711
|
+
if (domain) {
|
|
712
|
+
return db.query("SELECT * FROM portfolio_transactions WHERE domain = ? ORDER BY date DESC LIMIT ?").all(domain, limit) as any[];
|
|
713
|
+
}
|
|
714
|
+
return db.query("SELECT * FROM portfolio_transactions ORDER BY date DESC LIMIT ?").all(limit) as any[];
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
export function getTransactionsByType(type: TransactionType, limit: number = 100): any[] {
|
|
718
|
+
const db = getDb();
|
|
719
|
+
return db.query("SELECT * FROM portfolio_transactions WHERE type = ? ORDER BY date DESC LIMIT ?").all(type, limit) as any[];
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
export function getDomainPnL(domain: string): { costs: number; revenue: number; profit: number } {
|
|
723
|
+
const db = getDb();
|
|
724
|
+
const costs = (db.query(
|
|
725
|
+
"SELECT COALESCE(SUM(amount), 0) as total FROM portfolio_transactions WHERE domain = ? AND type IN ('purchase','renewal','expense')"
|
|
726
|
+
).get(domain) as { total: number }).total;
|
|
727
|
+
|
|
728
|
+
const revenue = (db.query(
|
|
729
|
+
"SELECT COALESCE(SUM(amount), 0) as total FROM portfolio_transactions WHERE domain = ? AND type IN ('sale','parking-revenue','affiliate-revenue','refund')"
|
|
730
|
+
).get(domain) as { total: number }).total;
|
|
731
|
+
|
|
732
|
+
return { costs, revenue, profit: revenue - costs };
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
export function getPortfolioPnL(): { totalCosts: number; totalRevenue: number; totalProfit: number; domainCount: number } {
|
|
736
|
+
const db = getDb();
|
|
737
|
+
const totalCosts = (db.query(
|
|
738
|
+
"SELECT COALESCE(SUM(amount), 0) as total FROM portfolio_transactions WHERE type IN ('purchase','renewal','expense')"
|
|
739
|
+
).get() as { total: number }).total;
|
|
740
|
+
|
|
741
|
+
const totalRevenue = (db.query(
|
|
742
|
+
"SELECT COALESCE(SUM(amount), 0) as total FROM portfolio_transactions WHERE type IN ('sale','parking-revenue','affiliate-revenue','refund')"
|
|
743
|
+
).get() as { total: number }).total;
|
|
744
|
+
|
|
745
|
+
const domainCount = (db.query("SELECT COUNT(DISTINCT domain) as c FROM portfolio_transactions").get() as { c: number }).c;
|
|
746
|
+
|
|
747
|
+
return { totalCosts, totalRevenue, totalProfit: totalRevenue - totalCosts, domainCount };
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
export function getMonthlyReport(months: number = 12): Array<{
|
|
751
|
+
month: string; costs: number; revenue: number; profit: number;
|
|
752
|
+
}> {
|
|
753
|
+
const db = getDb();
|
|
754
|
+
const rows = db.query(`
|
|
755
|
+
SELECT
|
|
756
|
+
strftime('%Y-%m', date) as month,
|
|
757
|
+
SUM(CASE WHEN type IN ('purchase','renewal','expense') THEN amount ELSE 0 END) as costs,
|
|
758
|
+
SUM(CASE WHEN type IN ('sale','parking-revenue','affiliate-revenue','refund') THEN amount ELSE 0 END) as revenue
|
|
759
|
+
FROM portfolio_transactions
|
|
760
|
+
WHERE date >= date('now', '-' || ? || ' months')
|
|
761
|
+
GROUP BY month
|
|
762
|
+
ORDER BY month DESC
|
|
763
|
+
`).all(months) as Array<{ month: string; costs: number; revenue: number }>;
|
|
764
|
+
|
|
765
|
+
return rows.map((r) => ({ ...r, profit: r.revenue - r.costs }));
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// ─── Valuations ──────────────────────────────────────────
|
|
769
|
+
|
|
770
|
+
export function saveValuation(domain: string, value: number, source: string = "manual"): void {
|
|
771
|
+
const db = getDb();
|
|
772
|
+
db.run("INSERT INTO portfolio_valuations (domain, estimated_value, source) VALUES (?, ?, ?)", [domain, value, source]);
|
|
773
|
+
db.run("UPDATE portfolio SET estimated_value = ?, updated_at = datetime('now') WHERE domain = ?", [value, domain]);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
export function getValuationHistory(domain: string, limit: number = 20): Array<{
|
|
777
|
+
id: number; estimated_value: number; source: string; valued_at: string;
|
|
778
|
+
}> {
|
|
779
|
+
const db = getDb();
|
|
780
|
+
return db.query("SELECT * FROM portfolio_valuations WHERE domain = ? ORDER BY valued_at DESC LIMIT ?").all(domain, limit) as any[];
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
export function getTotalPortfolioValue(): number {
|
|
784
|
+
const db = getDb();
|
|
785
|
+
return (db.query("SELECT COALESCE(SUM(estimated_value), 0) as total FROM portfolio").get() as { total: number }).total;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// ─── Pipeline ────────────────────────────────────────────
|
|
789
|
+
|
|
790
|
+
export function addToPipeline(domain: string, details: {
|
|
791
|
+
maxBid?: number; currentPrice?: number; source?: string; notes?: string; priority?: string;
|
|
792
|
+
} = {}): void {
|
|
793
|
+
const db = getDb();
|
|
794
|
+
db.run(`
|
|
795
|
+
INSERT INTO acquisition_pipeline (domain, max_bid, current_price, source, notes, priority)
|
|
796
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
797
|
+
ON CONFLICT(domain) DO UPDATE SET
|
|
798
|
+
max_bid = excluded.max_bid,
|
|
799
|
+
current_price = excluded.current_price,
|
|
800
|
+
source = excluded.source,
|
|
801
|
+
notes = excluded.notes,
|
|
802
|
+
priority = excluded.priority,
|
|
803
|
+
updated_at = datetime('now')
|
|
804
|
+
`, [domain, details.maxBid ?? null, details.currentPrice ?? null, details.source || "", details.notes || "", details.priority || "medium"]);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
export function updatePipelineStatus(domain: string, status: PipelineStatus): void {
|
|
808
|
+
const db = getDb();
|
|
809
|
+
db.run("UPDATE acquisition_pipeline SET status = ?, updated_at = datetime('now') WHERE domain = ?", [status, domain]);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
export function getPipeline(status?: PipelineStatus): any[] {
|
|
813
|
+
const db = getDb();
|
|
814
|
+
if (status) {
|
|
815
|
+
return db.query("SELECT * FROM acquisition_pipeline WHERE status = ? ORDER BY priority, added_at DESC").all(status) as any[];
|
|
816
|
+
}
|
|
817
|
+
return db.query("SELECT * FROM acquisition_pipeline ORDER BY status, priority, added_at DESC").all() as any[];
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
export function removeFromPipeline(domain: string): void {
|
|
821
|
+
const db = getDb();
|
|
822
|
+
db.run("DELETE FROM acquisition_pipeline WHERE domain = ?", [domain]);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// ─── Categories ──────────────────────────────────────────
|
|
826
|
+
|
|
827
|
+
export function getCategories(): Array<{ id: number; name: string; color: string; description: string; count: number }> {
|
|
828
|
+
const db = getDb();
|
|
829
|
+
return db.query(`
|
|
830
|
+
SELECT c.*, COALESCE(p.cnt, 0) as count
|
|
831
|
+
FROM portfolio_categories c
|
|
832
|
+
LEFT JOIN (SELECT category, COUNT(*) as cnt FROM portfolio GROUP BY category) p ON c.name = p.category
|
|
833
|
+
ORDER BY c.name
|
|
834
|
+
`).all() as any[];
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
export function addCategory(name: string, color: string = "#5c9cf5", description: string = ""): void {
|
|
838
|
+
const db = getDb();
|
|
839
|
+
db.run("INSERT OR IGNORE INTO portfolio_categories (name, color, description) VALUES (?, ?, ?)", [name, color, description]);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// ─── Alerts ──────────────────────────────────────────────
|
|
843
|
+
|
|
844
|
+
export function createAlert(domain: string, type: string, severity: AlertSeverity, message: string): number {
|
|
845
|
+
const db = getDb();
|
|
846
|
+
const result = db.run(
|
|
847
|
+
"INSERT INTO portfolio_alerts (domain, type, severity, message) VALUES (?, ?, ?, ?)",
|
|
848
|
+
[domain, type, severity, message]
|
|
849
|
+
);
|
|
850
|
+
return Number(result.lastInsertRowid);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
export function getUnacknowledgedAlerts(): Array<{
|
|
854
|
+
id: number; domain: string; type: string; severity: string; message: string; created_at: string;
|
|
855
|
+
}> {
|
|
856
|
+
const db = getDb();
|
|
857
|
+
return db.query("SELECT * FROM portfolio_alerts WHERE acknowledged = 0 ORDER BY severity DESC, created_at DESC").all() as any[];
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
export function acknowledgeAlert(alertId: number): void {
|
|
861
|
+
const db = getDb();
|
|
862
|
+
db.run("UPDATE portfolio_alerts SET acknowledged = 1 WHERE id = ?", [alertId]);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
export function acknowledgeAllAlerts(): void {
|
|
866
|
+
const db = getDb();
|
|
867
|
+
db.run("UPDATE portfolio_alerts SET acknowledged = 1 WHERE acknowledged = 0");
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// ─── Portfolio Dashboard Stats ───────────────────────────
|
|
871
|
+
|
|
872
|
+
export function getPortfolioDashboard(): {
|
|
873
|
+
totalDomains: number;
|
|
874
|
+
totalValue: number;
|
|
875
|
+
totalCosts: number;
|
|
876
|
+
totalRevenue: number;
|
|
877
|
+
totalProfit: number;
|
|
878
|
+
expiringIn30: number;
|
|
879
|
+
expiringIn90: number;
|
|
880
|
+
activeAlerts: number;
|
|
881
|
+
pipelineCount: number;
|
|
882
|
+
byStatus: Record<string, number>;
|
|
883
|
+
byCategory: Record<string, number>;
|
|
884
|
+
topValueDomains: Array<{ domain: string; estimated_value: number }>;
|
|
885
|
+
recentTransactions: Array<{ domain: string; type: string; amount: number; date: string }>;
|
|
886
|
+
} {
|
|
887
|
+
const db = getDb();
|
|
888
|
+
const totalDomains = (db.query("SELECT COUNT(*) as c FROM portfolio").get() as { c: number }).c;
|
|
889
|
+
const totalValue = getTotalPortfolioValue();
|
|
890
|
+
const pnl = getPortfolioPnL();
|
|
891
|
+
const exp30 = getPortfolioExpiring(30).length;
|
|
892
|
+
const exp90 = getPortfolioExpiring(90).length;
|
|
893
|
+
const activeAlerts = (db.query("SELECT COUNT(*) as c FROM portfolio_alerts WHERE acknowledged = 0").get() as { c: number }).c;
|
|
894
|
+
const pipelineCount = (db.query("SELECT COUNT(*) as c FROM acquisition_pipeline WHERE status IN ('watching','bidding','negotiating')").get() as { c: number }).c;
|
|
895
|
+
|
|
896
|
+
const statusRows = db.query("SELECT status, COUNT(*) as c FROM portfolio GROUP BY status").all() as Array<{ status: string; c: number }>;
|
|
897
|
+
const byStatus: Record<string, number> = {};
|
|
898
|
+
for (const r of statusRows) byStatus[r.status] = r.c;
|
|
899
|
+
|
|
900
|
+
const catRows = db.query("SELECT category, COUNT(*) as c FROM portfolio GROUP BY category").all() as Array<{ category: string; c: number }>;
|
|
901
|
+
const byCategory: Record<string, number> = {};
|
|
902
|
+
for (const r of catRows) byCategory[r.category] = r.c;
|
|
903
|
+
|
|
904
|
+
const topValueDomains = db.query("SELECT domain, estimated_value FROM portfolio WHERE estimated_value > 0 ORDER BY estimated_value DESC LIMIT 5").all() as Array<{ domain: string; estimated_value: number }>;
|
|
905
|
+
const recentTransactions = db.query("SELECT domain, type, amount, date FROM portfolio_transactions ORDER BY date DESC LIMIT 5").all() as Array<{ domain: string; type: string; amount: number; date: string }>;
|
|
906
|
+
|
|
907
|
+
return {
|
|
908
|
+
totalDomains, totalValue,
|
|
909
|
+
totalCosts: pnl.totalCosts, totalRevenue: pnl.totalRevenue, totalProfit: pnl.totalProfit,
|
|
910
|
+
expiringIn30: exp30, expiringIn90: exp90,
|
|
911
|
+
activeAlerts, pipelineCount,
|
|
912
|
+
byStatus, byCategory,
|
|
913
|
+
topValueDomains, recentTransactions,
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// ─── Tax Export ───────────────────────────────────────────
|
|
918
|
+
|
|
919
|
+
export function getTaxExportData(year: number): Array<{
|
|
920
|
+
domain: string;
|
|
921
|
+
purchaseDate: string;
|
|
922
|
+
purchasePrice: number;
|
|
923
|
+
saleDate: string | null;
|
|
924
|
+
salePrice: number | null;
|
|
925
|
+
holdingDays: number | null;
|
|
926
|
+
profit: number;
|
|
927
|
+
currency: string;
|
|
928
|
+
}> {
|
|
929
|
+
const db = getDb();
|
|
930
|
+
const yearStr = String(year);
|
|
931
|
+
|
|
932
|
+
// Get all domains with transactions in the given year
|
|
933
|
+
const domains = db.query(`
|
|
934
|
+
SELECT DISTINCT domain FROM portfolio_transactions
|
|
935
|
+
WHERE strftime('%Y', date) = ?
|
|
936
|
+
`).all(yearStr) as Array<{ domain: string }>;
|
|
937
|
+
|
|
938
|
+
return domains.map((d) => {
|
|
939
|
+
const purchases = db.query(
|
|
940
|
+
"SELECT amount, date FROM portfolio_transactions WHERE domain = ? AND type = 'purchase' ORDER BY date ASC LIMIT 1"
|
|
941
|
+
).get(d.domain) as { amount: number; date: string } | null;
|
|
942
|
+
|
|
943
|
+
const sales = db.query(
|
|
944
|
+
"SELECT amount, date FROM portfolio_transactions WHERE domain = ? AND type = 'sale' AND strftime('%Y', date) = ? ORDER BY date DESC LIMIT 1"
|
|
945
|
+
).get(d.domain, yearStr) as { amount: number; date: string } | null;
|
|
946
|
+
|
|
947
|
+
const totalCosts = (db.query(
|
|
948
|
+
"SELECT COALESCE(SUM(amount), 0) as total FROM portfolio_transactions WHERE domain = ? AND type IN ('purchase', 'renewal', 'expense')"
|
|
949
|
+
).get(d.domain) as { total: number }).total;
|
|
950
|
+
|
|
951
|
+
const totalRevenue = (db.query(
|
|
952
|
+
"SELECT COALESCE(SUM(amount), 0) as total FROM portfolio_transactions WHERE domain = ? AND type IN ('sale', 'parking-revenue', 'affiliate-revenue') AND strftime('%Y', date) = ?"
|
|
953
|
+
).get(d.domain, yearStr) as { total: number }).total;
|
|
954
|
+
|
|
955
|
+
let holdingDays: number | null = null;
|
|
956
|
+
if (purchases && sales) {
|
|
957
|
+
holdingDays = Math.floor((new Date(sales.date).getTime() - new Date(purchases.date).getTime()) / 86400000);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
return {
|
|
961
|
+
domain: d.domain,
|
|
962
|
+
purchaseDate: purchases?.date || "",
|
|
963
|
+
purchasePrice: purchases?.amount || 0,
|
|
964
|
+
saleDate: sales?.date || null,
|
|
965
|
+
salePrice: sales?.amount || null,
|
|
966
|
+
holdingDays,
|
|
967
|
+
profit: totalRevenue - totalCosts,
|
|
968
|
+
currency: "USD",
|
|
969
|
+
};
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// ─── WHOIS History ───────────────────────────────────────
|
|
974
|
+
|
|
975
|
+
export interface WhoisSnapshotInput {
|
|
976
|
+
registrar: string | null;
|
|
977
|
+
expiryDate: string | null;
|
|
978
|
+
createdDate: string | null;
|
|
979
|
+
updatedDate: string | null;
|
|
980
|
+
status: string[];
|
|
981
|
+
nameServers: string[];
|
|
982
|
+
available: boolean;
|
|
983
|
+
expired: boolean;
|
|
984
|
+
rawText?: string;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
export interface WhoisHistoryRow {
|
|
988
|
+
id: number;
|
|
989
|
+
domain: string;
|
|
990
|
+
snapshot_at: string;
|
|
991
|
+
registrar: string | null;
|
|
992
|
+
expiry_date: string | null;
|
|
993
|
+
created_date: string | null;
|
|
994
|
+
updated_date: string | null;
|
|
995
|
+
available: number;
|
|
996
|
+
expired: number;
|
|
997
|
+
status: string;
|
|
998
|
+
name_servers: string;
|
|
999
|
+
raw_text: string;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
export function saveWhoisSnapshotDb(
|
|
1003
|
+
domain: string,
|
|
1004
|
+
snapshot: WhoisSnapshotInput,
|
|
1005
|
+
): number {
|
|
1006
|
+
const db = getDb();
|
|
1007
|
+
const result = db.run(
|
|
1008
|
+
`INSERT INTO whois_history (domain, registrar, expiry_date, created_date, updated_date, status, name_servers, available, expired, raw_text)
|
|
1009
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1010
|
+
[
|
|
1011
|
+
domain,
|
|
1012
|
+
snapshot.registrar,
|
|
1013
|
+
snapshot.expiryDate,
|
|
1014
|
+
snapshot.createdDate,
|
|
1015
|
+
snapshot.updatedDate,
|
|
1016
|
+
JSON.stringify(snapshot.status),
|
|
1017
|
+
JSON.stringify(snapshot.nameServers),
|
|
1018
|
+
snapshot.available ? 1 : 0,
|
|
1019
|
+
snapshot.expired ? 1 : 0,
|
|
1020
|
+
snapshot.rawText || "",
|
|
1021
|
+
],
|
|
1022
|
+
);
|
|
1023
|
+
return Number(result.lastInsertRowid);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
export function getWhoisHistoryDb(
|
|
1027
|
+
domain: string,
|
|
1028
|
+
limit: number = 20,
|
|
1029
|
+
): WhoisHistoryRow[] {
|
|
1030
|
+
const db = getDb();
|
|
1031
|
+
return db
|
|
1032
|
+
.query<WhoisHistoryRow, [string, number]>(
|
|
1033
|
+
"SELECT * FROM whois_history WHERE domain = ? ORDER BY snapshot_at DESC LIMIT ?",
|
|
1034
|
+
)
|
|
1035
|
+
.all(domain, limit);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
export function getWhoisHistoryCountDb(domain: string): number {
|
|
1039
|
+
const db = getDb();
|
|
1040
|
+
const row = db
|
|
1041
|
+
.query<CountRow, [string]>(
|
|
1042
|
+
"SELECT COUNT(*) as c FROM whois_history WHERE domain = ?",
|
|
1043
|
+
)
|
|
1044
|
+
.get(domain);
|
|
1045
|
+
return row?.c ?? 0;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// ─── Cache ───────────────────────────────────────────────
|
|
1049
|
+
|
|
1050
|
+
interface CacheDataRow {
|
|
1051
|
+
data: string;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
export function getCached(domain: string, key: string): string | null {
|
|
1055
|
+
const db = getDb();
|
|
1056
|
+
// Clean expired entries
|
|
1057
|
+
db.run("DELETE FROM cache WHERE expires_at < datetime('now')");
|
|
1058
|
+
const row = db
|
|
1059
|
+
.query<CacheDataRow, [string, string]>(
|
|
1060
|
+
"SELECT data FROM cache WHERE domain = ? AND cache_key = ? AND expires_at > datetime('now')",
|
|
1061
|
+
)
|
|
1062
|
+
.get(domain, key);
|
|
1063
|
+
return row?.data ?? null;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
export function setCache(
|
|
1067
|
+
domain: string,
|
|
1068
|
+
key: string,
|
|
1069
|
+
data: string,
|
|
1070
|
+
ttlMinutes: number = 60,
|
|
1071
|
+
): void {
|
|
1072
|
+
const db = getDb();
|
|
1073
|
+
db.run(
|
|
1074
|
+
`INSERT INTO cache (domain, cache_key, data, expires_at)
|
|
1075
|
+
VALUES (?, ?, ?, datetime('now', '+' || ? || ' minutes'))
|
|
1076
|
+
ON CONFLICT(domain, cache_key) DO UPDATE SET
|
|
1077
|
+
data = excluded.data,
|
|
1078
|
+
created_at = datetime('now'),
|
|
1079
|
+
expires_at = excluded.expires_at`,
|
|
1080
|
+
[domain, key, data, ttlMinutes],
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
export function clearCache(domain?: string): number {
|
|
1085
|
+
const db = getDb();
|
|
1086
|
+
if (domain) {
|
|
1087
|
+
const result = db.run("DELETE FROM cache WHERE domain = ?", [domain]);
|
|
1088
|
+
return result.changes;
|
|
1089
|
+
}
|
|
1090
|
+
const result = db.run("DELETE FROM cache");
|
|
1091
|
+
return result.changes;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
export function clearExpiredCache(): number {
|
|
1095
|
+
const db = getDb();
|
|
1096
|
+
const result = db.run(
|
|
1097
|
+
"DELETE FROM cache WHERE expires_at < datetime('now')",
|
|
1098
|
+
);
|
|
1099
|
+
return result.changes;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// ─── Stats ───────────────────────────────────────────────
|
|
1103
|
+
|
|
1104
|
+
export interface DbStats {
|
|
1105
|
+
totalDomains: number;
|
|
1106
|
+
totalScans: number;
|
|
1107
|
+
totalSessions: number;
|
|
1108
|
+
portfolioSize: number;
|
|
1109
|
+
whoisSnapshots: number;
|
|
1110
|
+
cacheEntries: number;
|
|
1111
|
+
dbSizeBytes: number;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
export function getDbStats(): DbStats {
|
|
1115
|
+
const db = getDb();
|
|
1116
|
+
|
|
1117
|
+
const domains =
|
|
1118
|
+
db
|
|
1119
|
+
.query<CountRow, []>("SELECT COUNT(*) as c FROM domains")
|
|
1120
|
+
.get()?.c ?? 0;
|
|
1121
|
+
const scans =
|
|
1122
|
+
db
|
|
1123
|
+
.query<CountRow, []>("SELECT COUNT(*) as c FROM scans")
|
|
1124
|
+
.get()?.c ?? 0;
|
|
1125
|
+
const sessions =
|
|
1126
|
+
db
|
|
1127
|
+
.query<CountRow, []>("SELECT COUNT(*) as c FROM sessions")
|
|
1128
|
+
.get()?.c ?? 0;
|
|
1129
|
+
const portfolio =
|
|
1130
|
+
db
|
|
1131
|
+
.query<CountRow, []>("SELECT COUNT(*) as c FROM portfolio")
|
|
1132
|
+
.get()?.c ?? 0;
|
|
1133
|
+
const whois =
|
|
1134
|
+
db
|
|
1135
|
+
.query<CountRow, []>("SELECT COUNT(*) as c FROM whois_history")
|
|
1136
|
+
.get()?.c ?? 0;
|
|
1137
|
+
const cache =
|
|
1138
|
+
db
|
|
1139
|
+
.query<CountRow, []>(
|
|
1140
|
+
"SELECT COUNT(*) as c FROM cache WHERE expires_at > datetime('now')",
|
|
1141
|
+
)
|
|
1142
|
+
.get()?.c ?? 0;
|
|
1143
|
+
|
|
1144
|
+
// Get file size
|
|
1145
|
+
let dbSizeBytes = 0;
|
|
1146
|
+
try {
|
|
1147
|
+
const file = Bun.file(DB_FILE);
|
|
1148
|
+
dbSizeBytes = file.size;
|
|
1149
|
+
} catch {
|
|
1150
|
+
// File may not exist yet
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
return {
|
|
1154
|
+
totalDomains: domains,
|
|
1155
|
+
totalScans: scans,
|
|
1156
|
+
totalSessions: sessions,
|
|
1157
|
+
portfolioSize: portfolio,
|
|
1158
|
+
whoisSnapshots: whois,
|
|
1159
|
+
cacheEntries: cache,
|
|
1160
|
+
dbSizeBytes,
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// ─── Data import from legacy JSON ────────────────────────
|
|
1165
|
+
|
|
1166
|
+
interface LegacyPortfolioFile {
|
|
1167
|
+
domains?: Array<{
|
|
1168
|
+
domain: string;
|
|
1169
|
+
registrar?: string;
|
|
1170
|
+
purchaseDate?: string;
|
|
1171
|
+
expiryDate?: string;
|
|
1172
|
+
purchasePrice?: number;
|
|
1173
|
+
renewalPrice?: number;
|
|
1174
|
+
currency?: string;
|
|
1175
|
+
autoRenew?: boolean;
|
|
1176
|
+
tags?: string[];
|
|
1177
|
+
notes?: string;
|
|
1178
|
+
}>;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
interface LegacySessionFile {
|
|
1182
|
+
id?: string;
|
|
1183
|
+
domains?: Array<DomainEntry & { status?: string }>;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
export function importLegacyPortfolio(jsonPath: string): number {
|
|
1187
|
+
if (!existsSync(jsonPath)) return 0;
|
|
1188
|
+
try {
|
|
1189
|
+
const content = readFileSync(jsonPath, "utf-8");
|
|
1190
|
+
const data = JSON.parse(content) as LegacyPortfolioFile;
|
|
1191
|
+
if (!data || !Array.isArray(data.domains)) return 0;
|
|
1192
|
+
let count = 0;
|
|
1193
|
+
for (const d of data.domains) {
|
|
1194
|
+
addPortfolioDomain(d.domain, {
|
|
1195
|
+
registrar: d.registrar,
|
|
1196
|
+
purchaseDate: d.purchaseDate,
|
|
1197
|
+
expiryDate: d.expiryDate,
|
|
1198
|
+
purchasePrice: d.purchasePrice,
|
|
1199
|
+
renewalPrice: d.renewalPrice,
|
|
1200
|
+
currency: d.currency,
|
|
1201
|
+
autoRenew: d.autoRenew,
|
|
1202
|
+
tags: d.tags,
|
|
1203
|
+
notes: d.notes,
|
|
1204
|
+
});
|
|
1205
|
+
count++;
|
|
1206
|
+
}
|
|
1207
|
+
return count;
|
|
1208
|
+
} catch {
|
|
1209
|
+
return 0;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
export function importLegacySessions(sessionDir: string): number {
|
|
1214
|
+
if (!existsSync(sessionDir)) return 0;
|
|
1215
|
+
try {
|
|
1216
|
+
const files = readdirSync(sessionDir).filter((f: string) =>
|
|
1217
|
+
f.endsWith(".json"),
|
|
1218
|
+
);
|
|
1219
|
+
let count = 0;
|
|
1220
|
+
for (const f of files) {
|
|
1221
|
+
try {
|
|
1222
|
+
const content = readFileSync(join(sessionDir, f), "utf-8");
|
|
1223
|
+
const session = JSON.parse(content) as LegacySessionFile;
|
|
1224
|
+
if (!session || !Array.isArray(session.domains)) continue;
|
|
1225
|
+
const sessionId = createSession(
|
|
1226
|
+
session.id || f.replace(".json", ""),
|
|
1227
|
+
);
|
|
1228
|
+
for (const d of session.domains) {
|
|
1229
|
+
const domainId = upsertDomain(d.domain);
|
|
1230
|
+
saveScan(domainId, d.status || "unknown", d, sessionId);
|
|
1231
|
+
}
|
|
1232
|
+
updateSessionCount(sessionId, session.domains.length);
|
|
1233
|
+
count++;
|
|
1234
|
+
} catch {
|
|
1235
|
+
continue;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
return count;
|
|
1239
|
+
} catch {
|
|
1240
|
+
return 0;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// ─── Snipe CRUD ──────────────────────────────────────────
|
|
1245
|
+
|
|
1246
|
+
export type SnipeStatus = "watching" | "expiring" | "dropping" | "registering" | "registered" | "failed" | "cancelled";
|
|
1247
|
+
export type SnipePhase = "hourly" | "frequent" | "aggressive";
|
|
1248
|
+
|
|
1249
|
+
export function addSnipe(domain: string, details: {
|
|
1250
|
+
expiryDate?: string; registrarProvider?: string; maxPrice?: number;
|
|
1251
|
+
} = {}): number {
|
|
1252
|
+
const db = getDb();
|
|
1253
|
+
const result = db.run(`
|
|
1254
|
+
INSERT INTO snipes (domain, expiry_date, registrar_provider, max_price)
|
|
1255
|
+
VALUES (?, ?, ?, ?)
|
|
1256
|
+
ON CONFLICT(domain) DO UPDATE SET
|
|
1257
|
+
status = 'watching',
|
|
1258
|
+
phase = 'hourly',
|
|
1259
|
+
expiry_date = COALESCE(excluded.expiry_date, snipes.expiry_date),
|
|
1260
|
+
registrar_provider = COALESCE(excluded.registrar_provider, snipes.registrar_provider),
|
|
1261
|
+
max_price = COALESCE(excluded.max_price, snipes.max_price),
|
|
1262
|
+
updated_at = datetime('now')
|
|
1263
|
+
`, [domain, details.expiryDate || null, details.registrarProvider || null, details.maxPrice || null]);
|
|
1264
|
+
return Number(result.lastInsertRowid);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
export function getSnipe(domain: string): any | null {
|
|
1268
|
+
return getDb().query("SELECT * FROM snipes WHERE domain = ?").get(domain);
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
export function getActiveSnipes(): any[] {
|
|
1272
|
+
return getDb().query("SELECT * FROM snipes WHERE status IN ('watching', 'expiring', 'dropping', 'registering') ORDER BY expiry_date ASC").all();
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
export function getAllSnipes(): any[] {
|
|
1276
|
+
return getDb().query("SELECT * FROM snipes ORDER BY created_at DESC").all();
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
export function updateSnipeStatus(domain: string, status: SnipeStatus, phase?: SnipePhase): void {
|
|
1280
|
+
const db = getDb();
|
|
1281
|
+
if (phase) {
|
|
1282
|
+
db.run("UPDATE snipes SET status = ?, phase = ?, updated_at = datetime('now') WHERE domain = ?", [status, phase, domain]);
|
|
1283
|
+
} else {
|
|
1284
|
+
db.run("UPDATE snipes SET status = ?, updated_at = datetime('now') WHERE domain = ?", [status, domain]);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
export function updateSnipeCheck(domain: string, lastStatus: string): void {
|
|
1289
|
+
getDb().run("UPDATE snipes SET check_count = check_count + 1, last_checked = datetime('now'), last_status = ?, updated_at = datetime('now') WHERE domain = ?", [lastStatus, domain]);
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
export function markSnipeRegistered(domain: string): void {
|
|
1293
|
+
getDb().run("UPDATE snipes SET status = 'registered', registered_at = datetime('now'), updated_at = datetime('now') WHERE domain = ?", [domain]);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
export function removeSnipe(domain: string): void {
|
|
1297
|
+
getDb().run("DELETE FROM snipes WHERE domain = ?", [domain]);
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
export function getSnipeStats(): { total: number; watching: number; expiring: number; dropping: number; registered: number; failed: number } {
|
|
1301
|
+
const db = getDb();
|
|
1302
|
+
const rows = db.query("SELECT status, COUNT(*) as c FROM snipes GROUP BY status").all() as Array<{ status: string; c: number }>;
|
|
1303
|
+
const stats: Record<string, number> = {};
|
|
1304
|
+
for (const r of rows) stats[r.status] = r.c;
|
|
1305
|
+
return {
|
|
1306
|
+
total: Object.values(stats).reduce((a, b) => a + b, 0),
|
|
1307
|
+
watching: stats["watching"] || 0,
|
|
1308
|
+
expiring: stats["expiring"] || 0,
|
|
1309
|
+
dropping: stats["dropping"] || 0,
|
|
1310
|
+
registered: stats["registered"] || 0,
|
|
1311
|
+
failed: stats["failed"] || 0,
|
|
1312
|
+
};
|
|
1313
|
+
}
|