@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.
- package/microservices/microservice-social/package.json +2 -1
- package/microservices/microservice-social/src/cli/index.ts +906 -12
- package/microservices/microservice-social/src/db/migrations.ts +72 -0
- package/microservices/microservice-social/src/db/social.ts +33 -3
- package/microservices/microservice-social/src/lib/audience.ts +353 -0
- package/microservices/microservice-social/src/lib/content-ai.ts +278 -0
- package/microservices/microservice-social/src/lib/media.ts +311 -0
- package/microservices/microservice-social/src/lib/mentions.ts +434 -0
- package/microservices/microservice-social/src/lib/metrics-sync.ts +264 -0
- package/microservices/microservice-social/src/lib/publisher.ts +377 -0
- package/microservices/microservice-social/src/lib/scheduler.ts +229 -0
- package/microservices/microservice-social/src/lib/sentiment.ts +256 -0
- package/microservices/microservice-social/src/lib/threads.ts +291 -0
- package/microservices/microservice-social/src/mcp/index.ts +776 -6
- package/microservices/microservice-social/src/server/index.ts +441 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|