@hasna/microservices 0.0.7 → 0.0.8

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.
@@ -85,4 +85,76 @@ export const MIGRATIONS: MigrationEntry[] = [
85
85
  CREATE INDEX IF NOT EXISTS idx_posts_recurrence ON posts(recurrence);
86
86
  `,
87
87
  },
88
+ {
89
+ id: 3,
90
+ name: "add_mentions",
91
+ sql: `
92
+ CREATE TABLE IF NOT EXISTS mentions (
93
+ id TEXT PRIMARY KEY,
94
+ account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
95
+ platform TEXT NOT NULL,
96
+ author TEXT,
97
+ author_handle TEXT,
98
+ content TEXT,
99
+ type TEXT CHECK (type IN ('mention', 'reply', 'quote', 'dm')),
100
+ platform_post_id TEXT,
101
+ sentiment TEXT,
102
+ read INTEGER NOT NULL DEFAULT 0,
103
+ created_at TEXT,
104
+ fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
105
+ );
106
+
107
+ CREATE INDEX IF NOT EXISTS idx_mentions_account ON mentions(account_id);
108
+ CREATE INDEX IF NOT EXISTS idx_mentions_read ON mentions(read);
109
+ CREATE INDEX IF NOT EXISTS idx_mentions_type ON mentions(type);
110
+ `,
111
+ },
112
+ {
113
+ id: 4,
114
+ name: "add_last_metrics_sync",
115
+ sql: `
116
+ ALTER TABLE posts ADD COLUMN last_metrics_sync TEXT;
117
+ CREATE INDEX IF NOT EXISTS idx_posts_last_metrics_sync ON posts(last_metrics_sync);
118
+ `,
119
+ },
120
+ {
121
+ id: 5,
122
+ name: "add_thread_support",
123
+ sql: `
124
+ ALTER TABLE posts ADD COLUMN thread_id TEXT;
125
+ ALTER TABLE posts ADD COLUMN thread_position INTEGER;
126
+ CREATE INDEX IF NOT EXISTS idx_posts_thread ON posts(thread_id);
127
+ `,
128
+ },
129
+ {
130
+ id: 6,
131
+ name: "add_followers_and_audience_snapshots",
132
+ sql: `
133
+ CREATE TABLE IF NOT EXISTS followers (
134
+ id TEXT PRIMARY KEY,
135
+ account_id TEXT NOT NULL,
136
+ platform_user_id TEXT,
137
+ username TEXT,
138
+ display_name TEXT,
139
+ follower_count INTEGER DEFAULT 0,
140
+ following INTEGER DEFAULT 1,
141
+ followed_at TEXT,
142
+ unfollowed_at TEXT,
143
+ metadata TEXT DEFAULT '{}',
144
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
145
+ );
146
+
147
+ CREATE TABLE IF NOT EXISTS audience_snapshots (
148
+ id TEXT PRIMARY KEY,
149
+ account_id TEXT NOT NULL,
150
+ follower_count INTEGER DEFAULT 0,
151
+ following_count INTEGER DEFAULT 0,
152
+ snapshot_at TEXT NOT NULL DEFAULT (datetime('now'))
153
+ );
154
+
155
+ CREATE INDEX IF NOT EXISTS idx_followers_account ON followers(account_id);
156
+ CREATE INDEX IF NOT EXISTS idx_followers_following ON followers(following);
157
+ CREATE INDEX IF NOT EXISTS idx_snapshots_account ON audience_snapshots(account_id);
158
+ `,
159
+ },
88
160
  ];
@@ -99,6 +99,8 @@ export interface Post {
99
99
  engagement: Engagement;
100
100
  tags: string[];
101
101
  recurrence: Recurrence | null;
102
+ thread_id: string | null;
103
+ thread_position: number | null;
102
104
  created_at: string;
103
105
  updated_at: string;
104
106
  }
@@ -115,6 +117,8 @@ interface PostRow {
115
117
  engagement: string;
116
118
  tags: string;
117
119
  recurrence: string | null;
120
+ thread_id: string | null;
121
+ thread_position: number | null;
118
122
  created_at: string;
119
123
  updated_at: string;
120
124
  }
@@ -127,6 +131,8 @@ function rowToPost(row: PostRow): Post {
127
131
  engagement: JSON.parse(row.engagement || "{}"),
128
132
  tags: JSON.parse(row.tags || "[]"),
129
133
  recurrence: (row.recurrence as Recurrence) || null,
134
+ thread_id: row.thread_id || null,
135
+ thread_position: row.thread_position ?? null,
130
136
  };
131
137
  }
132
138
 
@@ -299,6 +305,8 @@ export interface CreatePostInput {
299
305
  scheduled_at?: string;
300
306
  tags?: string[];
301
307
  recurrence?: Recurrence;
308
+ thread_id?: string;
309
+ thread_position?: number;
302
310
  }
303
311
 
304
312
  export function createPost(input: CreatePostInput): Post {
@@ -308,8 +316,8 @@ export function createPost(input: CreatePostInput): Post {
308
316
  const tags = JSON.stringify(input.tags || []);
309
317
 
310
318
  db.prepare(
311
- `INSERT INTO posts (id, account_id, content, media_urls, status, scheduled_at, tags, recurrence)
312
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
319
+ `INSERT INTO posts (id, account_id, content, media_urls, status, scheduled_at, tags, recurrence, thread_id, thread_position)
320
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
313
321
  ).run(
314
322
  id,
315
323
  input.account_id,
@@ -318,7 +326,9 @@ export function createPost(input: CreatePostInput): Post {
318
326
  input.status || "draft",
319
327
  input.scheduled_at || null,
320
328
  tags,
321
- input.recurrence || null
329
+ input.recurrence || null,
330
+ input.thread_id || null,
331
+ input.thread_position ?? null
322
332
  );
323
333
 
324
334
  return getPost(id)!;
@@ -462,6 +472,26 @@ export function countPosts(): number {
462
472
  return row.count;
463
473
  }
464
474
 
475
+ /**
476
+ * Get all posts in a thread, ordered by thread_position
477
+ */
478
+ export function getThreadPosts(threadId: string): Post[] {
479
+ const db = getDatabase();
480
+ const rows = db.prepare(
481
+ "SELECT * FROM posts WHERE thread_id = ? ORDER BY thread_position ASC"
482
+ ).all(threadId) as PostRow[];
483
+ return rows.map(rowToPost);
484
+ }
485
+
486
+ /**
487
+ * Delete all posts in a thread
488
+ */
489
+ export function deleteThreadPosts(threadId: string): number {
490
+ const db = getDatabase();
491
+ const result = db.prepare("DELETE FROM posts WHERE thread_id = ?").run(threadId);
492
+ return result.changes;
493
+ }
494
+
465
495
  /**
466
496
  * Schedule a post — sets status to 'scheduled' and sets scheduled_at
467
497
  */
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Follower sync and audience insights
3
+ */
4
+
5
+ import { getDatabase } from "../db/database.js";
6
+
7
+ // ---- Types ----
8
+
9
+ export interface Follower {
10
+ id: string;
11
+ account_id: string;
12
+ platform_user_id: string | null;
13
+ username: string | null;
14
+ display_name: string | null;
15
+ follower_count: number;
16
+ following: boolean;
17
+ followed_at: string | null;
18
+ unfollowed_at: string | null;
19
+ metadata: Record<string, unknown>;
20
+ created_at: string;
21
+ }
22
+
23
+ interface FollowerRow {
24
+ id: string;
25
+ account_id: string;
26
+ platform_user_id: string | null;
27
+ username: string | null;
28
+ display_name: string | null;
29
+ follower_count: number;
30
+ following: number;
31
+ followed_at: string | null;
32
+ unfollowed_at: string | null;
33
+ metadata: string;
34
+ created_at: string;
35
+ }
36
+
37
+ function rowToFollower(row: FollowerRow): Follower {
38
+ return {
39
+ ...row,
40
+ following: row.following === 1,
41
+ metadata: JSON.parse(row.metadata || "{}"),
42
+ };
43
+ }
44
+
45
+ export interface AudienceSnapshot {
46
+ id: string;
47
+ account_id: string;
48
+ follower_count: number;
49
+ following_count: number;
50
+ snapshot_at: string;
51
+ }
52
+
53
+ export interface CreateFollowerInput {
54
+ account_id: string;
55
+ platform_user_id?: string;
56
+ username?: string;
57
+ display_name?: string;
58
+ follower_count?: number;
59
+ following?: boolean;
60
+ followed_at?: string;
61
+ metadata?: Record<string, unknown>;
62
+ }
63
+
64
+ export interface ListFollowersFilters {
65
+ following?: boolean;
66
+ search?: string;
67
+ limit?: number;
68
+ offset?: number;
69
+ }
70
+
71
+ export interface UpdateFollowerInput {
72
+ username?: string;
73
+ display_name?: string;
74
+ follower_count?: number;
75
+ following?: boolean;
76
+ unfollowed_at?: string;
77
+ metadata?: Record<string, unknown>;
78
+ }
79
+
80
+ export interface AudienceInsights {
81
+ total_followers: number;
82
+ growth_rate_7d: number;
83
+ growth_rate_30d: number;
84
+ new_followers_7d: number;
85
+ lost_followers_7d: number;
86
+ top_followers: Follower[];
87
+ }
88
+
89
+ export interface GrowthPoint {
90
+ date: string;
91
+ count: number;
92
+ }
93
+
94
+ // ---- Followers CRUD ----
95
+
96
+ export function createFollower(input: CreateFollowerInput): Follower {
97
+ const db = getDatabase();
98
+ const id = crypto.randomUUID();
99
+ const metadata = JSON.stringify(input.metadata || {});
100
+
101
+ db.prepare(
102
+ `INSERT INTO followers (id, account_id, platform_user_id, username, display_name, follower_count, following, followed_at, metadata)
103
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
104
+ ).run(
105
+ id,
106
+ input.account_id,
107
+ input.platform_user_id || null,
108
+ input.username || null,
109
+ input.display_name || null,
110
+ input.follower_count ?? 0,
111
+ input.following !== false ? 1 : 0,
112
+ input.followed_at || null,
113
+ metadata
114
+ );
115
+
116
+ return getFollower(id)!;
117
+ }
118
+
119
+ export function getFollower(id: string): Follower | null {
120
+ const db = getDatabase();
121
+ const row = db.prepare("SELECT * FROM followers WHERE id = ?").get(id) as FollowerRow | null;
122
+ return row ? rowToFollower(row) : null;
123
+ }
124
+
125
+ export function listFollowers(accountId: string, filters: ListFollowersFilters = {}): Follower[] {
126
+ const db = getDatabase();
127
+ const conditions: string[] = ["account_id = ?"];
128
+ const params: unknown[] = [accountId];
129
+
130
+ if (filters.following !== undefined) {
131
+ conditions.push("following = ?");
132
+ params.push(filters.following ? 1 : 0);
133
+ }
134
+
135
+ if (filters.search) {
136
+ conditions.push("(username LIKE ? OR display_name LIKE ?)");
137
+ params.push(`%${filters.search}%`, `%${filters.search}%`);
138
+ }
139
+
140
+ let sql = `SELECT * FROM followers WHERE ${conditions.join(" AND ")} ORDER BY follower_count DESC`;
141
+
142
+ if (filters.limit) {
143
+ sql += " LIMIT ?";
144
+ params.push(filters.limit);
145
+ }
146
+ if (filters.offset) {
147
+ sql += " OFFSET ?";
148
+ params.push(filters.offset);
149
+ }
150
+
151
+ const rows = db.prepare(sql).all(...params) as FollowerRow[];
152
+ return rows.map(rowToFollower);
153
+ }
154
+
155
+ export function updateFollower(id: string, input: UpdateFollowerInput): Follower | null {
156
+ const db = getDatabase();
157
+ const existing = getFollower(id);
158
+ if (!existing) return null;
159
+
160
+ const sets: string[] = [];
161
+ const params: unknown[] = [];
162
+
163
+ if (input.username !== undefined) {
164
+ sets.push("username = ?");
165
+ params.push(input.username);
166
+ }
167
+ if (input.display_name !== undefined) {
168
+ sets.push("display_name = ?");
169
+ params.push(input.display_name);
170
+ }
171
+ if (input.follower_count !== undefined) {
172
+ sets.push("follower_count = ?");
173
+ params.push(input.follower_count);
174
+ }
175
+ if (input.following !== undefined) {
176
+ sets.push("following = ?");
177
+ params.push(input.following ? 1 : 0);
178
+ }
179
+ if (input.unfollowed_at !== undefined) {
180
+ sets.push("unfollowed_at = ?");
181
+ params.push(input.unfollowed_at);
182
+ }
183
+ if (input.metadata !== undefined) {
184
+ sets.push("metadata = ?");
185
+ params.push(JSON.stringify(input.metadata));
186
+ }
187
+
188
+ if (sets.length === 0) return existing;
189
+
190
+ params.push(id);
191
+ db.prepare(`UPDATE followers SET ${sets.join(", ")} WHERE id = ?`).run(...params);
192
+
193
+ return getFollower(id);
194
+ }
195
+
196
+ export function removeFollower(id: string): boolean {
197
+ const db = getDatabase();
198
+ const result = db.prepare("DELETE FROM followers WHERE id = ?").run(id);
199
+ return result.changes > 0;
200
+ }
201
+
202
+ // ---- Sync ----
203
+
204
+ export interface SyncResult {
205
+ synced: number;
206
+ new_followers: number;
207
+ unfollowed: number;
208
+ message: string;
209
+ }
210
+
211
+ /**
212
+ * Stub that would call the platform API for follower list, upsert into DB.
213
+ * In production this would call X/Instagram/LinkedIn APIs.
214
+ */
215
+ export function syncFollowers(accountId: string): SyncResult {
216
+ // In a real implementation, this would:
217
+ // 1. Call the platform API to get the current follower list
218
+ // 2. Upsert new followers into the DB
219
+ // 3. Mark unfollowed users (following=0, set unfollowed_at)
220
+ // 4. Update follower metadata (display_name, follower_count)
221
+
222
+ const db = getDatabase();
223
+ const currentFollowers = db.prepare(
224
+ "SELECT COUNT(*) as count FROM followers WHERE account_id = ? AND following = 1"
225
+ ).get(accountId) as { count: number };
226
+
227
+ return {
228
+ synced: currentFollowers.count,
229
+ new_followers: 0,
230
+ unfollowed: 0,
231
+ message: "Sync stub — connect platform API credentials to enable live sync.",
232
+ };
233
+ }
234
+
235
+ // ---- Snapshots ----
236
+
237
+ export function createSnapshot(
238
+ accountId: string,
239
+ followerCount: number,
240
+ followingCount: number
241
+ ): AudienceSnapshot {
242
+ const db = getDatabase();
243
+ const id = crypto.randomUUID();
244
+
245
+ db.prepare(
246
+ `INSERT INTO audience_snapshots (id, account_id, follower_count, following_count)
247
+ VALUES (?, ?, ?, ?)`
248
+ ).run(id, accountId, followerCount, followingCount);
249
+
250
+ return db.prepare("SELECT * FROM audience_snapshots WHERE id = ?").get(id) as AudienceSnapshot;
251
+ }
252
+
253
+ /**
254
+ * Creates a daily snapshot from current follower counts in the DB.
255
+ */
256
+ export function trackGrowth(accountId: string): AudienceSnapshot {
257
+ const db = getDatabase();
258
+
259
+ const followerCount = (
260
+ db.prepare(
261
+ "SELECT COUNT(*) as count FROM followers WHERE account_id = ? AND following = 1"
262
+ ).get(accountId) as { count: number }
263
+ ).count;
264
+
265
+ const followingCount = (
266
+ db.prepare(
267
+ "SELECT COUNT(*) as count FROM followers WHERE account_id = ? AND following = 0"
268
+ ).get(accountId) as { count: number }
269
+ ).count;
270
+
271
+ return createSnapshot(accountId, followerCount, followingCount);
272
+ }
273
+
274
+ // ---- Insights ----
275
+
276
+ export function getAudienceInsights(accountId: string): AudienceInsights {
277
+ const db = getDatabase();
278
+
279
+ // Total current followers
280
+ const total_followers = (
281
+ db.prepare(
282
+ "SELECT COUNT(*) as count FROM followers WHERE account_id = ? AND following = 1"
283
+ ).get(accountId) as { count: number }
284
+ ).count;
285
+
286
+ // New followers in last 7 days
287
+ const new_followers_7d = (
288
+ db.prepare(
289
+ "SELECT COUNT(*) as count FROM followers WHERE account_id = ? AND following = 1 AND created_at >= datetime('now', '-7 days')"
290
+ ).get(accountId) as { count: number }
291
+ ).count;
292
+
293
+ // Lost followers in last 7 days
294
+ const lost_followers_7d = (
295
+ db.prepare(
296
+ "SELECT COUNT(*) as count FROM followers WHERE account_id = ? AND following = 0 AND unfollowed_at >= datetime('now', '-7 days')"
297
+ ).get(accountId) as { count: number }
298
+ ).count;
299
+
300
+ // Growth rates from snapshots
301
+ const growth_rate_7d = calculateGrowthRate(accountId, 7);
302
+ const growth_rate_30d = calculateGrowthRate(accountId, 30);
303
+
304
+ // Top followers by their follower count
305
+ const top_followers = listFollowers(accountId, { following: true, limit: 10 });
306
+
307
+ return {
308
+ total_followers,
309
+ growth_rate_7d,
310
+ growth_rate_30d,
311
+ new_followers_7d,
312
+ lost_followers_7d,
313
+ top_followers,
314
+ };
315
+ }
316
+
317
+ function calculateGrowthRate(accountId: string, days: number): number {
318
+ const db = getDatabase();
319
+
320
+ const latest = db.prepare(
321
+ "SELECT follower_count FROM audience_snapshots WHERE account_id = ? ORDER BY snapshot_at DESC LIMIT 1"
322
+ ).get(accountId) as { follower_count: number } | null;
323
+
324
+ const older = db.prepare(
325
+ `SELECT follower_count FROM audience_snapshots WHERE account_id = ? AND snapshot_at <= datetime('now', '-${days} days') ORDER BY snapshot_at DESC LIMIT 1`
326
+ ).get(accountId) as { follower_count: number } | null;
327
+
328
+ if (!latest || !older || older.follower_count === 0) return 0;
329
+
330
+ return Math.round(((latest.follower_count - older.follower_count) / older.follower_count) * 10000) / 100;
331
+ }
332
+
333
+ // ---- Growth Chart ----
334
+
335
+ export function getFollowerGrowthChart(accountId: string, days: number = 30): GrowthPoint[] {
336
+ const db = getDatabase();
337
+
338
+ const rows = db.prepare(
339
+ `SELECT date(snapshot_at) as date, MAX(follower_count) as count
340
+ FROM audience_snapshots
341
+ WHERE account_id = ? AND snapshot_at >= datetime('now', '-${days} days')
342
+ GROUP BY date(snapshot_at)
343
+ ORDER BY date ASC`
344
+ ).all(accountId) as GrowthPoint[];
345
+
346
+ return rows;
347
+ }
348
+
349
+ // ---- Top Followers ----
350
+
351
+ export function getTopFollowers(accountId: string, limit: number = 10): Follower[] {
352
+ return listFollowers(accountId, { following: true, limit });
353
+ }