@hasna/microservices 0.0.3 → 0.0.5

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.
Files changed (68) hide show
  1. package/bin/index.js +63 -0
  2. package/bin/mcp.js +63 -0
  3. package/dist/index.js +63 -0
  4. package/microservices/microservice-ads/package.json +27 -0
  5. package/microservices/microservice-ads/src/cli/index.ts +605 -0
  6. package/microservices/microservice-ads/src/db/campaigns.ts +797 -0
  7. package/microservices/microservice-ads/src/db/database.ts +93 -0
  8. package/microservices/microservice-ads/src/db/migrations.ts +60 -0
  9. package/microservices/microservice-ads/src/index.ts +39 -0
  10. package/microservices/microservice-ads/src/mcp/index.ts +480 -0
  11. package/microservices/microservice-contracts/package.json +27 -0
  12. package/microservices/microservice-contracts/src/cli/index.ts +770 -0
  13. package/microservices/microservice-contracts/src/db/contracts.ts +925 -0
  14. package/microservices/microservice-contracts/src/db/database.ts +93 -0
  15. package/microservices/microservice-contracts/src/db/migrations.ts +141 -0
  16. package/microservices/microservice-contracts/src/index.ts +43 -0
  17. package/microservices/microservice-contracts/src/mcp/index.ts +617 -0
  18. package/microservices/microservice-domains/package.json +27 -0
  19. package/microservices/microservice-domains/src/cli/index.ts +691 -0
  20. package/microservices/microservice-domains/src/db/database.ts +93 -0
  21. package/microservices/microservice-domains/src/db/domains.ts +1164 -0
  22. package/microservices/microservice-domains/src/db/migrations.ts +60 -0
  23. package/microservices/microservice-domains/src/index.ts +65 -0
  24. package/microservices/microservice-domains/src/mcp/index.ts +536 -0
  25. package/microservices/microservice-hiring/package.json +27 -0
  26. package/microservices/microservice-hiring/src/cli/index.ts +741 -0
  27. package/microservices/microservice-hiring/src/db/database.ts +93 -0
  28. package/microservices/microservice-hiring/src/db/hiring.ts +1085 -0
  29. package/microservices/microservice-hiring/src/db/migrations.ts +89 -0
  30. package/microservices/microservice-hiring/src/index.ts +80 -0
  31. package/microservices/microservice-hiring/src/lib/scoring.ts +206 -0
  32. package/microservices/microservice-hiring/src/mcp/index.ts +709 -0
  33. package/microservices/microservice-payments/package.json +27 -0
  34. package/microservices/microservice-payments/src/cli/index.ts +609 -0
  35. package/microservices/microservice-payments/src/db/database.ts +93 -0
  36. package/microservices/microservice-payments/src/db/migrations.ts +81 -0
  37. package/microservices/microservice-payments/src/db/payments.ts +1204 -0
  38. package/microservices/microservice-payments/src/index.ts +51 -0
  39. package/microservices/microservice-payments/src/mcp/index.ts +683 -0
  40. package/microservices/microservice-payroll/package.json +27 -0
  41. package/microservices/microservice-payroll/src/cli/index.ts +643 -0
  42. package/microservices/microservice-payroll/src/db/database.ts +93 -0
  43. package/microservices/microservice-payroll/src/db/migrations.ts +95 -0
  44. package/microservices/microservice-payroll/src/db/payroll.ts +1377 -0
  45. package/microservices/microservice-payroll/src/index.ts +48 -0
  46. package/microservices/microservice-payroll/src/mcp/index.ts +666 -0
  47. package/microservices/microservice-shipping/package.json +27 -0
  48. package/microservices/microservice-shipping/src/cli/index.ts +606 -0
  49. package/microservices/microservice-shipping/src/db/database.ts +93 -0
  50. package/microservices/microservice-shipping/src/db/migrations.ts +69 -0
  51. package/microservices/microservice-shipping/src/db/shipping.ts +1093 -0
  52. package/microservices/microservice-shipping/src/index.ts +53 -0
  53. package/microservices/microservice-shipping/src/mcp/index.ts +533 -0
  54. package/microservices/microservice-social/package.json +27 -0
  55. package/microservices/microservice-social/src/cli/index.ts +689 -0
  56. package/microservices/microservice-social/src/db/database.ts +93 -0
  57. package/microservices/microservice-social/src/db/migrations.ts +88 -0
  58. package/microservices/microservice-social/src/db/social.ts +1046 -0
  59. package/microservices/microservice-social/src/index.ts +46 -0
  60. package/microservices/microservice-social/src/mcp/index.ts +655 -0
  61. package/microservices/microservice-subscriptions/package.json +27 -0
  62. package/microservices/microservice-subscriptions/src/cli/index.ts +715 -0
  63. package/microservices/microservice-subscriptions/src/db/database.ts +93 -0
  64. package/microservices/microservice-subscriptions/src/db/migrations.ts +125 -0
  65. package/microservices/microservice-subscriptions/src/db/subscriptions.ts +1256 -0
  66. package/microservices/microservice-subscriptions/src/index.ts +41 -0
  67. package/microservices/microservice-subscriptions/src/mcp/index.ts +631 -0
  68. package/package.json +1 -1
@@ -0,0 +1,797 @@
1
+ /**
2
+ * Campaign, Ad Group, and Ad CRUD operations
3
+ */
4
+
5
+ import { getDatabase } from "./database.js";
6
+
7
+ // --- Types ---
8
+
9
+ export type Platform = "google" | "meta" | "linkedin" | "tiktok";
10
+ export type CampaignStatus = "draft" | "active" | "paused" | "completed";
11
+
12
+ export interface Campaign {
13
+ id: string;
14
+ platform: Platform;
15
+ name: string;
16
+ status: CampaignStatus;
17
+ budget_daily: number;
18
+ budget_total: number;
19
+ spend: number;
20
+ impressions: number;
21
+ clicks: number;
22
+ conversions: number;
23
+ roas: number;
24
+ start_date: string | null;
25
+ end_date: string | null;
26
+ created_at: string;
27
+ updated_at: string;
28
+ metadata: Record<string, unknown>;
29
+ }
30
+
31
+ interface CampaignRow {
32
+ id: string;
33
+ platform: Platform;
34
+ name: string;
35
+ status: CampaignStatus;
36
+ budget_daily: number;
37
+ budget_total: number;
38
+ spend: number;
39
+ impressions: number;
40
+ clicks: number;
41
+ conversions: number;
42
+ roas: number;
43
+ start_date: string | null;
44
+ end_date: string | null;
45
+ created_at: string;
46
+ updated_at: string;
47
+ metadata: string;
48
+ }
49
+
50
+ function rowToCampaign(row: CampaignRow): Campaign {
51
+ return {
52
+ ...row,
53
+ metadata: JSON.parse(row.metadata || "{}"),
54
+ };
55
+ }
56
+
57
+ export interface AdGroup {
58
+ id: string;
59
+ campaign_id: string;
60
+ name: string;
61
+ targeting: Record<string, unknown>;
62
+ status: CampaignStatus;
63
+ created_at: string;
64
+ }
65
+
66
+ interface AdGroupRow {
67
+ id: string;
68
+ campaign_id: string;
69
+ name: string;
70
+ targeting: string;
71
+ status: CampaignStatus;
72
+ created_at: string;
73
+ }
74
+
75
+ function rowToAdGroup(row: AdGroupRow): AdGroup {
76
+ return {
77
+ ...row,
78
+ targeting: JSON.parse(row.targeting || "{}"),
79
+ };
80
+ }
81
+
82
+ export interface Ad {
83
+ id: string;
84
+ ad_group_id: string;
85
+ headline: string;
86
+ description: string | null;
87
+ creative_url: string | null;
88
+ status: CampaignStatus;
89
+ metrics: Record<string, unknown>;
90
+ created_at: string;
91
+ }
92
+
93
+ interface AdRow {
94
+ id: string;
95
+ ad_group_id: string;
96
+ headline: string;
97
+ description: string | null;
98
+ creative_url: string | null;
99
+ status: CampaignStatus;
100
+ metrics: string;
101
+ created_at: string;
102
+ }
103
+
104
+ function rowToAd(row: AdRow): Ad {
105
+ return {
106
+ ...row,
107
+ metrics: JSON.parse(row.metrics || "{}"),
108
+ };
109
+ }
110
+
111
+ // --- Campaign CRUD ---
112
+
113
+ export interface CreateCampaignInput {
114
+ platform: Platform;
115
+ name: string;
116
+ status?: CampaignStatus;
117
+ budget_daily?: number;
118
+ budget_total?: number;
119
+ spend?: number;
120
+ impressions?: number;
121
+ clicks?: number;
122
+ conversions?: number;
123
+ roas?: number;
124
+ start_date?: string;
125
+ end_date?: string;
126
+ metadata?: Record<string, unknown>;
127
+ }
128
+
129
+ export function createCampaign(input: CreateCampaignInput): Campaign {
130
+ const db = getDatabase();
131
+ const id = crypto.randomUUID();
132
+ const metadata = JSON.stringify(input.metadata || {});
133
+
134
+ db.prepare(
135
+ `INSERT INTO campaigns (id, platform, name, status, budget_daily, budget_total, spend, impressions, clicks, conversions, roas, start_date, end_date, metadata)
136
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
137
+ ).run(
138
+ id,
139
+ input.platform,
140
+ input.name,
141
+ input.status || "draft",
142
+ input.budget_daily ?? 0,
143
+ input.budget_total ?? 0,
144
+ input.spend ?? 0,
145
+ input.impressions ?? 0,
146
+ input.clicks ?? 0,
147
+ input.conversions ?? 0,
148
+ input.roas ?? 0,
149
+ input.start_date || null,
150
+ input.end_date || null,
151
+ metadata
152
+ );
153
+
154
+ return getCampaign(id)!;
155
+ }
156
+
157
+ export function getCampaign(id: string): Campaign | null {
158
+ const db = getDatabase();
159
+ const row = db.prepare("SELECT * FROM campaigns WHERE id = ?").get(id) as CampaignRow | null;
160
+ return row ? rowToCampaign(row) : null;
161
+ }
162
+
163
+ export interface ListCampaignsOptions {
164
+ platform?: Platform;
165
+ status?: CampaignStatus;
166
+ search?: string;
167
+ limit?: number;
168
+ offset?: number;
169
+ }
170
+
171
+ export function listCampaigns(options: ListCampaignsOptions = {}): Campaign[] {
172
+ const db = getDatabase();
173
+ const conditions: string[] = [];
174
+ const params: unknown[] = [];
175
+
176
+ if (options.platform) {
177
+ conditions.push("platform = ?");
178
+ params.push(options.platform);
179
+ }
180
+
181
+ if (options.status) {
182
+ conditions.push("status = ?");
183
+ params.push(options.status);
184
+ }
185
+
186
+ if (options.search) {
187
+ conditions.push("(name LIKE ?)");
188
+ const q = `%${options.search}%`;
189
+ params.push(q);
190
+ }
191
+
192
+ let sql = "SELECT * FROM campaigns";
193
+ if (conditions.length > 0) {
194
+ sql += " WHERE " + conditions.join(" AND ");
195
+ }
196
+ sql += " ORDER BY created_at DESC";
197
+
198
+ if (options.limit) {
199
+ sql += " LIMIT ?";
200
+ params.push(options.limit);
201
+ }
202
+ if (options.offset) {
203
+ sql += " OFFSET ?";
204
+ params.push(options.offset);
205
+ }
206
+
207
+ const rows = db.prepare(sql).all(...params) as CampaignRow[];
208
+ return rows.map(rowToCampaign);
209
+ }
210
+
211
+ export interface UpdateCampaignInput {
212
+ platform?: Platform;
213
+ name?: string;
214
+ status?: CampaignStatus;
215
+ budget_daily?: number;
216
+ budget_total?: number;
217
+ spend?: number;
218
+ impressions?: number;
219
+ clicks?: number;
220
+ conversions?: number;
221
+ roas?: number;
222
+ start_date?: string | null;
223
+ end_date?: string | null;
224
+ metadata?: Record<string, unknown>;
225
+ }
226
+
227
+ export function updateCampaign(
228
+ id: string,
229
+ input: UpdateCampaignInput
230
+ ): Campaign | null {
231
+ const db = getDatabase();
232
+ const existing = getCampaign(id);
233
+ if (!existing) return null;
234
+
235
+ const sets: string[] = [];
236
+ const params: unknown[] = [];
237
+
238
+ if (input.platform !== undefined) {
239
+ sets.push("platform = ?");
240
+ params.push(input.platform);
241
+ }
242
+ if (input.name !== undefined) {
243
+ sets.push("name = ?");
244
+ params.push(input.name);
245
+ }
246
+ if (input.status !== undefined) {
247
+ sets.push("status = ?");
248
+ params.push(input.status);
249
+ }
250
+ if (input.budget_daily !== undefined) {
251
+ sets.push("budget_daily = ?");
252
+ params.push(input.budget_daily);
253
+ }
254
+ if (input.budget_total !== undefined) {
255
+ sets.push("budget_total = ?");
256
+ params.push(input.budget_total);
257
+ }
258
+ if (input.spend !== undefined) {
259
+ sets.push("spend = ?");
260
+ params.push(input.spend);
261
+ }
262
+ if (input.impressions !== undefined) {
263
+ sets.push("impressions = ?");
264
+ params.push(input.impressions);
265
+ }
266
+ if (input.clicks !== undefined) {
267
+ sets.push("clicks = ?");
268
+ params.push(input.clicks);
269
+ }
270
+ if (input.conversions !== undefined) {
271
+ sets.push("conversions = ?");
272
+ params.push(input.conversions);
273
+ }
274
+ if (input.roas !== undefined) {
275
+ sets.push("roas = ?");
276
+ params.push(input.roas);
277
+ }
278
+ if (input.start_date !== undefined) {
279
+ sets.push("start_date = ?");
280
+ params.push(input.start_date);
281
+ }
282
+ if (input.end_date !== undefined) {
283
+ sets.push("end_date = ?");
284
+ params.push(input.end_date);
285
+ }
286
+ if (input.metadata !== undefined) {
287
+ sets.push("metadata = ?");
288
+ params.push(JSON.stringify(input.metadata));
289
+ }
290
+
291
+ if (sets.length === 0) return existing;
292
+
293
+ sets.push("updated_at = datetime('now')");
294
+ params.push(id);
295
+
296
+ db.prepare(
297
+ `UPDATE campaigns SET ${sets.join(", ")} WHERE id = ?`
298
+ ).run(...params);
299
+
300
+ return getCampaign(id);
301
+ }
302
+
303
+ export function deleteCampaign(id: string): boolean {
304
+ const db = getDatabase();
305
+ const result = db.prepare("DELETE FROM campaigns WHERE id = ?").run(id);
306
+ return result.changes > 0;
307
+ }
308
+
309
+ export function pauseCampaign(id: string): Campaign | null {
310
+ return updateCampaign(id, { status: "paused" });
311
+ }
312
+
313
+ export function resumeCampaign(id: string): Campaign | null {
314
+ return updateCampaign(id, { status: "active" });
315
+ }
316
+
317
+ export function countCampaigns(): number {
318
+ const db = getDatabase();
319
+ const row = db.prepare("SELECT COUNT(*) as count FROM campaigns").get() as { count: number };
320
+ return row.count;
321
+ }
322
+
323
+ // --- Ad Group CRUD ---
324
+
325
+ export interface CreateAdGroupInput {
326
+ campaign_id: string;
327
+ name: string;
328
+ targeting?: Record<string, unknown>;
329
+ status?: CampaignStatus;
330
+ }
331
+
332
+ export function createAdGroup(input: CreateAdGroupInput): AdGroup {
333
+ const db = getDatabase();
334
+ const id = crypto.randomUUID();
335
+ const targeting = JSON.stringify(input.targeting || {});
336
+
337
+ db.prepare(
338
+ `INSERT INTO ad_groups (id, campaign_id, name, targeting, status)
339
+ VALUES (?, ?, ?, ?, ?)`
340
+ ).run(
341
+ id,
342
+ input.campaign_id,
343
+ input.name,
344
+ targeting,
345
+ input.status || "draft"
346
+ );
347
+
348
+ return getAdGroup(id)!;
349
+ }
350
+
351
+ export function getAdGroup(id: string): AdGroup | null {
352
+ const db = getDatabase();
353
+ const row = db.prepare("SELECT * FROM ad_groups WHERE id = ?").get(id) as AdGroupRow | null;
354
+ return row ? rowToAdGroup(row) : null;
355
+ }
356
+
357
+ export function listAdGroups(campaign_id?: string): AdGroup[] {
358
+ const db = getDatabase();
359
+ let sql = "SELECT * FROM ad_groups";
360
+ const params: unknown[] = [];
361
+
362
+ if (campaign_id) {
363
+ sql += " WHERE campaign_id = ?";
364
+ params.push(campaign_id);
365
+ }
366
+
367
+ sql += " ORDER BY created_at DESC";
368
+
369
+ const rows = db.prepare(sql).all(...params) as AdGroupRow[];
370
+ return rows.map(rowToAdGroup);
371
+ }
372
+
373
+ export function deleteAdGroup(id: string): boolean {
374
+ const db = getDatabase();
375
+ const result = db.prepare("DELETE FROM ad_groups WHERE id = ?").run(id);
376
+ return result.changes > 0;
377
+ }
378
+
379
+ // --- Ad CRUD ---
380
+
381
+ export interface CreateAdInput {
382
+ ad_group_id: string;
383
+ headline: string;
384
+ description?: string;
385
+ creative_url?: string;
386
+ status?: CampaignStatus;
387
+ metrics?: Record<string, unknown>;
388
+ }
389
+
390
+ export function createAd(input: CreateAdInput): Ad {
391
+ const db = getDatabase();
392
+ const id = crypto.randomUUID();
393
+ const metrics = JSON.stringify(input.metrics || {});
394
+
395
+ db.prepare(
396
+ `INSERT INTO ads (id, ad_group_id, headline, description, creative_url, status, metrics)
397
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
398
+ ).run(
399
+ id,
400
+ input.ad_group_id,
401
+ input.headline,
402
+ input.description || null,
403
+ input.creative_url || null,
404
+ input.status || "draft",
405
+ metrics
406
+ );
407
+
408
+ return getAd(id)!;
409
+ }
410
+
411
+ export function getAd(id: string): Ad | null {
412
+ const db = getDatabase();
413
+ const row = db.prepare("SELECT * FROM ads WHERE id = ?").get(id) as AdRow | null;
414
+ return row ? rowToAd(row) : null;
415
+ }
416
+
417
+ export function listAds(ad_group_id?: string): Ad[] {
418
+ const db = getDatabase();
419
+ let sql = "SELECT * FROM ads";
420
+ const params: unknown[] = [];
421
+
422
+ if (ad_group_id) {
423
+ sql += " WHERE ad_group_id = ?";
424
+ params.push(ad_group_id);
425
+ }
426
+
427
+ sql += " ORDER BY created_at DESC";
428
+
429
+ const rows = db.prepare(sql).all(...params) as AdRow[];
430
+ return rows.map(rowToAd);
431
+ }
432
+
433
+ export function deleteAd(id: string): boolean {
434
+ const db = getDatabase();
435
+ const result = db.prepare("DELETE FROM ads WHERE id = ?").run(id);
436
+ return result.changes > 0;
437
+ }
438
+
439
+ // --- Aggregation helpers ---
440
+
441
+ export interface CampaignStats {
442
+ total_campaigns: number;
443
+ active_campaigns: number;
444
+ total_spend: number;
445
+ total_impressions: number;
446
+ total_clicks: number;
447
+ total_conversions: number;
448
+ avg_roas: number;
449
+ }
450
+
451
+ export function getCampaignStats(): CampaignStats {
452
+ const db = getDatabase();
453
+ const row = db.prepare(`
454
+ SELECT
455
+ COUNT(*) as total_campaigns,
456
+ SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active_campaigns,
457
+ COALESCE(SUM(spend), 0) as total_spend,
458
+ COALESCE(SUM(impressions), 0) as total_impressions,
459
+ COALESCE(SUM(clicks), 0) as total_clicks,
460
+ COALESCE(SUM(conversions), 0) as total_conversions,
461
+ COALESCE(AVG(CASE WHEN roas > 0 THEN roas END), 0) as avg_roas
462
+ FROM campaigns
463
+ `).get() as CampaignStats;
464
+ return row;
465
+ }
466
+
467
+ export interface SpendByPlatform {
468
+ platform: Platform;
469
+ total_spend: number;
470
+ campaign_count: number;
471
+ }
472
+
473
+ export function getSpendByPlatform(): SpendByPlatform[] {
474
+ const db = getDatabase();
475
+ const rows = db.prepare(`
476
+ SELECT
477
+ platform,
478
+ COALESCE(SUM(spend), 0) as total_spend,
479
+ COUNT(*) as campaign_count
480
+ FROM campaigns
481
+ GROUP BY platform
482
+ ORDER BY total_spend DESC
483
+ `).all() as SpendByPlatform[];
484
+ return rows;
485
+ }
486
+
487
+ export function getPlatforms(): string[] {
488
+ const db = getDatabase();
489
+ const rows = db.prepare(
490
+ "SELECT DISTINCT platform FROM campaigns ORDER BY platform"
491
+ ).all() as { platform: string }[];
492
+ return rows.map((r) => r.platform);
493
+ }
494
+
495
+ // --- QoL Features ---
496
+
497
+ // 1. Bulk pause/resume by platform
498
+ export interface BulkUpdateResult {
499
+ updated_count: number;
500
+ platform: Platform;
501
+ new_status: CampaignStatus;
502
+ }
503
+
504
+ export function bulkPause(platform: Platform): BulkUpdateResult {
505
+ const db = getDatabase();
506
+ const result = db.prepare(
507
+ "UPDATE campaigns SET status = 'paused', updated_at = datetime('now') WHERE platform = ? AND status = 'active'"
508
+ ).run(platform);
509
+ return { updated_count: result.changes, platform, new_status: "paused" };
510
+ }
511
+
512
+ export function bulkResume(platform: Platform): BulkUpdateResult {
513
+ const db = getDatabase();
514
+ const result = db.prepare(
515
+ "UPDATE campaigns SET status = 'active', updated_at = datetime('now') WHERE platform = ? AND status = 'paused'"
516
+ ).run(platform);
517
+ return { updated_count: result.changes, platform, new_status: "active" };
518
+ }
519
+
520
+ // 2. Performance ranking
521
+ export type RankMetric = "roas" | "ctr" | "spend";
522
+
523
+ export function getRankedCampaigns(sortBy: RankMetric = "roas", limit: number = 10): Campaign[] {
524
+ const db = getDatabase();
525
+ let orderExpr: string;
526
+
527
+ switch (sortBy) {
528
+ case "roas":
529
+ orderExpr = "roas DESC";
530
+ break;
531
+ case "ctr":
532
+ // CTR = clicks / impressions (handle division by zero)
533
+ orderExpr = "CASE WHEN impressions > 0 THEN CAST(clicks AS REAL) / impressions ELSE 0 END DESC";
534
+ break;
535
+ case "spend":
536
+ orderExpr = "spend DESC";
537
+ break;
538
+ default:
539
+ orderExpr = "roas DESC";
540
+ }
541
+
542
+ const rows = db.prepare(
543
+ `SELECT * FROM campaigns ORDER BY ${orderExpr} LIMIT ?`
544
+ ).all(limit) as CampaignRow[];
545
+ return rows.map(rowToCampaign);
546
+ }
547
+
548
+ // 3. Budget alerts
549
+ export interface BudgetStatus {
550
+ campaign_id: string;
551
+ campaign_name: string;
552
+ over_budget: boolean;
553
+ daily_remaining: number;
554
+ total_remaining: number;
555
+ pct_used: number;
556
+ days_active: number;
557
+ expected_spend: number;
558
+ }
559
+
560
+ export function checkBudgetStatus(id: string): BudgetStatus | null {
561
+ const campaign = getCampaign(id);
562
+ if (!campaign) return null;
563
+
564
+ const now = new Date();
565
+ let daysActive = 1;
566
+
567
+ if (campaign.start_date) {
568
+ const start = new Date(campaign.start_date);
569
+ const diffMs = now.getTime() - start.getTime();
570
+ daysActive = Math.max(1, Math.ceil(diffMs / (1000 * 60 * 60 * 24)));
571
+ }
572
+
573
+ const expectedSpend = campaign.budget_daily * daysActive;
574
+ const overBudget = campaign.budget_total > 0
575
+ ? campaign.spend > campaign.budget_total
576
+ : campaign.spend > expectedSpend;
577
+
578
+ const dailySpentToday = daysActive > 0 ? campaign.spend / daysActive : campaign.spend;
579
+ const dailyRemaining = Math.max(0, campaign.budget_daily - dailySpentToday);
580
+
581
+ const totalRemaining = campaign.budget_total > 0
582
+ ? Math.max(0, campaign.budget_total - campaign.spend)
583
+ : Math.max(0, expectedSpend - campaign.spend);
584
+
585
+ const pctUsed = campaign.budget_total > 0
586
+ ? (campaign.spend / campaign.budget_total) * 100
587
+ : expectedSpend > 0 ? (campaign.spend / expectedSpend) * 100 : 0;
588
+
589
+ return {
590
+ campaign_id: id,
591
+ campaign_name: campaign.name,
592
+ over_budget: overBudget,
593
+ daily_remaining: Math.round(dailyRemaining * 100) / 100,
594
+ total_remaining: Math.round(totalRemaining * 100) / 100,
595
+ pct_used: Math.round(pctUsed * 100) / 100,
596
+ days_active: daysActive,
597
+ expected_spend: Math.round(expectedSpend * 100) / 100,
598
+ };
599
+ }
600
+
601
+ // 4. Cross-platform comparison
602
+ export interface PlatformComparison {
603
+ platform: Platform;
604
+ campaign_count: number;
605
+ total_spend: number;
606
+ total_impressions: number;
607
+ total_clicks: number;
608
+ total_conversions: number;
609
+ avg_roas: number;
610
+ avg_ctr: number;
611
+ avg_cpa: number;
612
+ }
613
+
614
+ export function comparePlatforms(): PlatformComparison[] {
615
+ const db = getDatabase();
616
+ const rows = db.prepare(`
617
+ SELECT
618
+ platform,
619
+ COUNT(*) as campaign_count,
620
+ COALESCE(SUM(spend), 0) as total_spend,
621
+ COALESCE(SUM(impressions), 0) as total_impressions,
622
+ COALESCE(SUM(clicks), 0) as total_clicks,
623
+ COALESCE(SUM(conversions), 0) as total_conversions,
624
+ COALESCE(AVG(CASE WHEN roas > 0 THEN roas END), 0) as avg_roas,
625
+ CASE WHEN SUM(impressions) > 0
626
+ THEN CAST(SUM(clicks) AS REAL) / SUM(impressions) * 100
627
+ ELSE 0
628
+ END as avg_ctr,
629
+ CASE WHEN SUM(conversions) > 0
630
+ THEN SUM(spend) / SUM(conversions)
631
+ ELSE 0
632
+ END as avg_cpa
633
+ FROM campaigns
634
+ GROUP BY platform
635
+ ORDER BY total_spend DESC
636
+ `).all() as PlatformComparison[];
637
+ return rows;
638
+ }
639
+
640
+ // 5. CSV export
641
+ export function exportCampaigns(format: "csv" | "json" = "csv"): string {
642
+ const campaigns = listCampaigns();
643
+
644
+ if (format === "json") {
645
+ return JSON.stringify(campaigns, null, 2);
646
+ }
647
+
648
+ // CSV format
649
+ const headers = [
650
+ "id", "platform", "name", "status", "budget_daily", "budget_total",
651
+ "spend", "impressions", "clicks", "conversions", "roas",
652
+ "start_date", "end_date", "created_at", "updated_at",
653
+ ];
654
+
655
+ const rows = campaigns.map((c) =>
656
+ headers.map((h) => {
657
+ const val = c[h as keyof Campaign];
658
+ if (val === null || val === undefined) return "";
659
+ if (typeof val === "object") return JSON.stringify(val);
660
+ const str = String(val);
661
+ // Escape CSV fields that contain commas or quotes
662
+ if (str.includes(",") || str.includes('"') || str.includes("\n")) {
663
+ return `"${str.replace(/"/g, '""')}"`;
664
+ }
665
+ return str;
666
+ }).join(",")
667
+ );
668
+
669
+ return [headers.join(","), ...rows].join("\n");
670
+ }
671
+
672
+ // 6. Campaign cloning
673
+ export function cloneCampaign(id: string, newName: string): Campaign | null {
674
+ const original = getCampaign(id);
675
+ if (!original) return null;
676
+
677
+ const db = getDatabase();
678
+
679
+ // Clone the campaign itself (reset metrics)
680
+ const cloned = createCampaign({
681
+ platform: original.platform,
682
+ name: newName,
683
+ status: "draft",
684
+ budget_daily: original.budget_daily,
685
+ budget_total: original.budget_total,
686
+ start_date: original.start_date || undefined,
687
+ end_date: original.end_date || undefined,
688
+ metadata: original.metadata,
689
+ });
690
+
691
+ // Clone ad groups and their ads
692
+ const adGroups = listAdGroups(id);
693
+ for (const ag of adGroups) {
694
+ const clonedGroup = createAdGroup({
695
+ campaign_id: cloned.id,
696
+ name: ag.name,
697
+ targeting: ag.targeting,
698
+ status: "draft",
699
+ });
700
+
701
+ const ads = listAds(ag.id);
702
+ for (const ad of ads) {
703
+ createAd({
704
+ ad_group_id: clonedGroup.id,
705
+ headline: ad.headline,
706
+ description: ad.description || undefined,
707
+ creative_url: ad.creative_url || undefined,
708
+ status: "draft",
709
+ metrics: {},
710
+ });
711
+ }
712
+ }
713
+
714
+ return getCampaign(cloned.id)!;
715
+ }
716
+
717
+ // 7. Budget remaining (reuses checkBudgetStatus but provides a focused view)
718
+ export interface BudgetRemaining {
719
+ campaign_id: string;
720
+ campaign_name: string;
721
+ budget_daily: number;
722
+ budget_total: number;
723
+ spend: number;
724
+ daily_remaining: number;
725
+ total_remaining: number;
726
+ days_remaining_at_daily_rate: number;
727
+ }
728
+
729
+ export function getBudgetRemaining(id: string): BudgetRemaining | null {
730
+ const campaign = getCampaign(id);
731
+ if (!campaign) return null;
732
+
733
+ const now = new Date();
734
+ let daysActive = 1;
735
+ if (campaign.start_date) {
736
+ const start = new Date(campaign.start_date);
737
+ const diffMs = now.getTime() - start.getTime();
738
+ daysActive = Math.max(1, Math.ceil(diffMs / (1000 * 60 * 60 * 24)));
739
+ }
740
+
741
+ const avgDailySpend = campaign.spend / daysActive;
742
+ const dailyRemaining = Math.max(0, campaign.budget_daily - avgDailySpend);
743
+ const totalRemaining = campaign.budget_total > 0
744
+ ? Math.max(0, campaign.budget_total - campaign.spend)
745
+ : 0;
746
+ const daysRemainingAtRate = avgDailySpend > 0 && campaign.budget_total > 0
747
+ ? Math.floor(totalRemaining / avgDailySpend)
748
+ : 0;
749
+
750
+ return {
751
+ campaign_id: id,
752
+ campaign_name: campaign.name,
753
+ budget_daily: campaign.budget_daily,
754
+ budget_total: campaign.budget_total,
755
+ spend: campaign.spend,
756
+ daily_remaining: Math.round(dailyRemaining * 100) / 100,
757
+ total_remaining: Math.round(totalRemaining * 100) / 100,
758
+ days_remaining_at_daily_rate: daysRemainingAtRate,
759
+ };
760
+ }
761
+
762
+ // 8. Ad group stats — aggregate metrics for ads in a group
763
+ export interface AdGroupStats {
764
+ ad_group_id: string;
765
+ ad_group_name: string;
766
+ campaign_id: string;
767
+ total_ads: number;
768
+ active_ads: number;
769
+ metrics: Record<string, number>;
770
+ }
771
+
772
+ export function getAdGroupStats(adGroupId: string): AdGroupStats | null {
773
+ const adGroup = getAdGroup(adGroupId);
774
+ if (!adGroup) return null;
775
+
776
+ const ads = listAds(adGroupId);
777
+ const activeAds = ads.filter((a) => a.status === "active").length;
778
+
779
+ // Aggregate numeric metrics from all ads
780
+ const aggregated: Record<string, number> = {};
781
+ for (const ad of ads) {
782
+ for (const [key, value] of Object.entries(ad.metrics)) {
783
+ if (typeof value === "number") {
784
+ aggregated[key] = (aggregated[key] || 0) + value;
785
+ }
786
+ }
787
+ }
788
+
789
+ return {
790
+ ad_group_id: adGroupId,
791
+ ad_group_name: adGroup.name,
792
+ campaign_id: adGroup.campaign_id,
793
+ total_ads: ads.length,
794
+ active_ads: activeAds,
795
+ metrics: aggregated,
796
+ };
797
+ }