openvolo 0.1.2

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 (208) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +175 -0
  3. package/components.json +20 -0
  4. package/dist/cli.js +992 -0
  5. package/drizzle.config.ts +14 -0
  6. package/next.config.mjs +7 -0
  7. package/package.json +91 -0
  8. package/postcss.config.mjs +7 -0
  9. package/public/android-chrome-192x192.png +0 -0
  10. package/public/android-chrome-512x512.png +0 -0
  11. package/public/apple-touch-icon.png +0 -0
  12. package/public/assets/openvolo-logo-black.png +0 -0
  13. package/public/assets/openvolo-logo-name.png +0 -0
  14. package/public/assets/openvolo-logo-transparent.png +0 -0
  15. package/public/favicon-16x16.png +0 -0
  16. package/public/favicon-32x32.png +0 -0
  17. package/public/favicon.ico +0 -0
  18. package/public/site.webmanifest +19 -0
  19. package/src/app/api/analytics/agents/route.ts +30 -0
  20. package/src/app/api/analytics/content/route.ts +24 -0
  21. package/src/app/api/analytics/engagement/route.ts +24 -0
  22. package/src/app/api/analytics/overview/route.ts +22 -0
  23. package/src/app/api/analytics/sync-health/route.ts +22 -0
  24. package/src/app/api/contacts/[id]/identities/[identityId]/route.ts +24 -0
  25. package/src/app/api/contacts/[id]/identities/route.ts +61 -0
  26. package/src/app/api/contacts/[id]/route.ts +72 -0
  27. package/src/app/api/contacts/route.ts +91 -0
  28. package/src/app/api/content/[id]/route.ts +61 -0
  29. package/src/app/api/content/route.ts +48 -0
  30. package/src/app/api/platforms/gmail/auth/route.ts +50 -0
  31. package/src/app/api/platforms/gmail/callback/route.ts +126 -0
  32. package/src/app/api/platforms/gmail/route.ts +60 -0
  33. package/src/app/api/platforms/gmail/sync/route.ts +96 -0
  34. package/src/app/api/platforms/linkedin/auth/route.ts +40 -0
  35. package/src/app/api/platforms/linkedin/callback/route.ts +128 -0
  36. package/src/app/api/platforms/linkedin/import/route.ts +40 -0
  37. package/src/app/api/platforms/linkedin/route.ts +60 -0
  38. package/src/app/api/platforms/linkedin/sync/route.ts +85 -0
  39. package/src/app/api/platforms/x/auth/route.ts +52 -0
  40. package/src/app/api/platforms/x/browser-session/route.ts +79 -0
  41. package/src/app/api/platforms/x/callback/route.ts +130 -0
  42. package/src/app/api/platforms/x/compose/route.ts +247 -0
  43. package/src/app/api/platforms/x/engage/route.ts +113 -0
  44. package/src/app/api/platforms/x/enrich/route.ts +79 -0
  45. package/src/app/api/platforms/x/route.ts +63 -0
  46. package/src/app/api/platforms/x/sync/route.ts +142 -0
  47. package/src/app/api/settings/route.ts +43 -0
  48. package/src/app/api/settings/search-api/route.ts +180 -0
  49. package/src/app/api/tasks/[id]/route.ts +60 -0
  50. package/src/app/api/tasks/route.ts +39 -0
  51. package/src/app/api/workflows/[id]/progress/route.ts +45 -0
  52. package/src/app/api/workflows/[id]/route.ts +20 -0
  53. package/src/app/api/workflows/route.ts +30 -0
  54. package/src/app/api/workflows/run-agent/route.ts +44 -0
  55. package/src/app/api/workflows/templates/[id]/activate/route.ts +64 -0
  56. package/src/app/api/workflows/templates/[id]/route.ts +75 -0
  57. package/src/app/api/workflows/templates/route.ts +60 -0
  58. package/src/app/dashboard/analytics/analytics-dashboard.tsx +535 -0
  59. package/src/app/dashboard/analytics/page.tsx +15 -0
  60. package/src/app/dashboard/contacts/[id]/contact-detail-client.tsx +334 -0
  61. package/src/app/dashboard/contacts/[id]/page.tsx +21 -0
  62. package/src/app/dashboard/contacts/contact-list-client.tsx +213 -0
  63. package/src/app/dashboard/contacts/page.tsx +38 -0
  64. package/src/app/dashboard/content/[id]/engagement-actions.tsx +167 -0
  65. package/src/app/dashboard/content/[id]/page.tsx +253 -0
  66. package/src/app/dashboard/content/content-list-client.tsx +428 -0
  67. package/src/app/dashboard/content/page.tsx +39 -0
  68. package/src/app/dashboard/help/page.tsx +1247 -0
  69. package/src/app/dashboard/layout.tsx +19 -0
  70. package/src/app/dashboard/page.tsx +187 -0
  71. package/src/app/dashboard/settings/page.tsx +1664 -0
  72. package/src/app/dashboard/workflows/[id]/page.tsx +90 -0
  73. package/src/app/dashboard/workflows/[id]/workflow-detail-steps.tsx +55 -0
  74. package/src/app/dashboard/workflows/[id]/workflow-run-live.tsx +195 -0
  75. package/src/app/dashboard/workflows/activate-dialog.tsx +251 -0
  76. package/src/app/dashboard/workflows/page.tsx +41 -0
  77. package/src/app/dashboard/workflows/template-gallery.tsx +201 -0
  78. package/src/app/dashboard/workflows/workflow-quick-actions.tsx +121 -0
  79. package/src/app/dashboard/workflows/workflow-view-switcher.tsx +62 -0
  80. package/src/app/globals.css +232 -0
  81. package/src/app/layout.tsx +57 -0
  82. package/src/app/page.tsx +5 -0
  83. package/src/components/add-contact-dialog.tsx +74 -0
  84. package/src/components/add-task-dialog.tsx +153 -0
  85. package/src/components/animated-stat.tsx +53 -0
  86. package/src/components/app-sidebar.tsx +130 -0
  87. package/src/components/charts/area-chart-card.tsx +99 -0
  88. package/src/components/charts/bar-chart-card.tsx +128 -0
  89. package/src/components/charts/chart-skeleton.tsx +43 -0
  90. package/src/components/charts/donut-chart-card.tsx +100 -0
  91. package/src/components/charts/ranked-table-card.tsx +127 -0
  92. package/src/components/charts/stat-cards-row.tsx +45 -0
  93. package/src/components/compose-dialog.tsx +344 -0
  94. package/src/components/contact-form.tsx +218 -0
  95. package/src/components/dashboard-greeting.tsx +27 -0
  96. package/src/components/dashboard-header.tsx +87 -0
  97. package/src/components/empty-state.tsx +32 -0
  98. package/src/components/enrich-button.tsx +107 -0
  99. package/src/components/enrichment-score-badge.tsx +30 -0
  100. package/src/components/funnel-stage-badge.tsx +19 -0
  101. package/src/components/funnel-visualization.tsx +66 -0
  102. package/src/components/identities-section.tsx +219 -0
  103. package/src/components/pagination-controls.tsx +115 -0
  104. package/src/components/platform-connection-card.tsx +292 -0
  105. package/src/components/priority-badge.tsx +17 -0
  106. package/src/components/step-output-renderer.tsx +63 -0
  107. package/src/components/tweet-input.tsx +126 -0
  108. package/src/components/ui/alert-dialog.tsx +196 -0
  109. package/src/components/ui/avatar.tsx +109 -0
  110. package/src/components/ui/badge.tsx +48 -0
  111. package/src/components/ui/button.tsx +64 -0
  112. package/src/components/ui/card.tsx +92 -0
  113. package/src/components/ui/chart.tsx +357 -0
  114. package/src/components/ui/dialog.tsx +158 -0
  115. package/src/components/ui/dropdown-menu.tsx +257 -0
  116. package/src/components/ui/input.tsx +21 -0
  117. package/src/components/ui/label.tsx +24 -0
  118. package/src/components/ui/progress.tsx +31 -0
  119. package/src/components/ui/scroll-area.tsx +58 -0
  120. package/src/components/ui/select.tsx +190 -0
  121. package/src/components/ui/separator.tsx +28 -0
  122. package/src/components/ui/sheet.tsx +143 -0
  123. package/src/components/ui/sidebar.tsx +726 -0
  124. package/src/components/ui/skeleton.tsx +13 -0
  125. package/src/components/ui/table.tsx +116 -0
  126. package/src/components/ui/tabs.tsx +91 -0
  127. package/src/components/ui/textarea.tsx +18 -0
  128. package/src/components/ui/tooltip.tsx +57 -0
  129. package/src/components/workflow-graph-view.tsx +205 -0
  130. package/src/components/workflow-kanban-view.tsx +69 -0
  131. package/src/components/workflow-list-view.tsx +201 -0
  132. package/src/components/workflow-progress-card.tsx +150 -0
  133. package/src/components/workflow-run-card.tsx +144 -0
  134. package/src/components/workflow-step-timeline.tsx +173 -0
  135. package/src/components/workflow-swimlane-view.tsx +87 -0
  136. package/src/hooks/use-mobile.ts +19 -0
  137. package/src/hooks/use-workflow-polling.ts +85 -0
  138. package/src/lib/agents/router.ts +79 -0
  139. package/src/lib/agents/run-agent-workflow.ts +605 -0
  140. package/src/lib/agents/tools/browser-scrape.ts +118 -0
  141. package/src/lib/agents/tools/enrich-contact.ts +128 -0
  142. package/src/lib/agents/tools/search-web.ts +473 -0
  143. package/src/lib/agents/tools/update-progress.ts +40 -0
  144. package/src/lib/agents/tools/url-fetch.ts +152 -0
  145. package/src/lib/agents/types.ts +79 -0
  146. package/src/lib/analytics/utils.ts +33 -0
  147. package/src/lib/auth/claude-auth.ts +134 -0
  148. package/src/lib/auth/crypto.ts +58 -0
  149. package/src/lib/browser/anti-detection.ts +79 -0
  150. package/src/lib/browser/extractors/profile-merger.ts +71 -0
  151. package/src/lib/browser/extractors/profile-parser.ts +133 -0
  152. package/src/lib/browser/platforms/x-scraper.ts +269 -0
  153. package/src/lib/browser/scraper.ts +92 -0
  154. package/src/lib/browser/session.ts +229 -0
  155. package/src/lib/browser/types.ts +80 -0
  156. package/src/lib/db/client.ts +24 -0
  157. package/src/lib/db/enrichment.ts +90 -0
  158. package/src/lib/db/migrate-identities.ts +95 -0
  159. package/src/lib/db/migrate.ts +33 -0
  160. package/src/lib/db/migrations/0000_tired_thanos.sql +296 -0
  161. package/src/lib/db/migrations/meta/0000_snapshot.json +2169 -0
  162. package/src/lib/db/migrations/meta/_journal.json +13 -0
  163. package/src/lib/db/queries/analytics.ts +449 -0
  164. package/src/lib/db/queries/contacts.ts +170 -0
  165. package/src/lib/db/queries/content.ts +215 -0
  166. package/src/lib/db/queries/dashboard.ts +79 -0
  167. package/src/lib/db/queries/engagements.ts +35 -0
  168. package/src/lib/db/queries/identities.ts +51 -0
  169. package/src/lib/db/queries/platform-accounts.ts +53 -0
  170. package/src/lib/db/queries/sync.ts +74 -0
  171. package/src/lib/db/queries/tasks.ts +88 -0
  172. package/src/lib/db/queries/workflow-templates.ts +213 -0
  173. package/src/lib/db/queries/workflows.ts +167 -0
  174. package/src/lib/db/schema.ts +437 -0
  175. package/src/lib/db/seed-templates.ts +221 -0
  176. package/src/lib/db/types.ts +78 -0
  177. package/src/lib/pagination.ts +12 -0
  178. package/src/lib/platforms/adapter.ts +75 -0
  179. package/src/lib/platforms/gmail/adapter.ts +112 -0
  180. package/src/lib/platforms/gmail/auth.ts +137 -0
  181. package/src/lib/platforms/gmail/client.ts +255 -0
  182. package/src/lib/platforms/gmail/mappers.ts +125 -0
  183. package/src/lib/platforms/gmail/oauth-state-store.ts +65 -0
  184. package/src/lib/platforms/index.ts +22 -0
  185. package/src/lib/platforms/linkedin/adapter.ts +164 -0
  186. package/src/lib/platforms/linkedin/auth.ts +124 -0
  187. package/src/lib/platforms/linkedin/client.ts +183 -0
  188. package/src/lib/platforms/linkedin/csv-import.ts +283 -0
  189. package/src/lib/platforms/linkedin/mappers.ts +123 -0
  190. package/src/lib/platforms/linkedin/oauth-state-store.ts +65 -0
  191. package/src/lib/platforms/rate-limiter.ts +88 -0
  192. package/src/lib/platforms/sync-contacts.ts +121 -0
  193. package/src/lib/platforms/sync-content.ts +225 -0
  194. package/src/lib/platforms/sync-gmail-contacts.ts +186 -0
  195. package/src/lib/platforms/sync-gmail-metadata.ts +158 -0
  196. package/src/lib/platforms/sync-linkedin-contacts.ts +148 -0
  197. package/src/lib/platforms/sync-x-profiles.ts +280 -0
  198. package/src/lib/platforms/x/adapter.ts +129 -0
  199. package/src/lib/platforms/x/auth.ts +165 -0
  200. package/src/lib/platforms/x/client.ts +390 -0
  201. package/src/lib/platforms/x/mappers.ts +134 -0
  202. package/src/lib/platforms/x/pkce-store.ts +67 -0
  203. package/src/lib/utils.ts +6 -0
  204. package/src/lib/workflows/format-error.test.ts +177 -0
  205. package/src/lib/workflows/format-error.ts +207 -0
  206. package/src/lib/workflows/run-sync-workflow.ts +141 -0
  207. package/src/lib/workflows/types.ts +71 -0
  208. package/tsconfig.json +42 -0
@@ -0,0 +1,148 @@
1
+ import { eq, and } from "drizzle-orm";
2
+ import { db } from "@/lib/db/client";
3
+ import { contacts, contactIdentities } from "@/lib/db/schema";
4
+ import { createContact, updateContact, recalcEnrichment } from "@/lib/db/queries/contacts";
5
+ import { createIdentity } from "@/lib/db/queries/identities";
6
+ import { updatePlatformAccount } from "@/lib/db/queries/platform-accounts";
7
+ import { getSyncCursor, updateSyncCursor } from "@/lib/db/queries/sync";
8
+ import {
9
+ mapLinkedInConnectionToContact,
10
+ mapLinkedInConnectionToIdentity,
11
+ } from "@/lib/platforms/linkedin/mappers";
12
+ import { getConnections } from "@/lib/platforms/linkedin/client";
13
+ import type { LinkedInConnection } from "@/lib/platforms/linkedin/client";
14
+ import type { SyncResult } from "@/lib/platforms/adapter";
15
+
16
+ /**
17
+ * Sync contacts from LinkedIn connections into OpenVolo.
18
+ * Uses offset-based pagination (not cursor-based like X).
19
+ * Cross-platform dedup: matches by LinkedIn ID, then by email.
20
+ */
21
+ export async function syncContactsFromLinkedIn(
22
+ accountId: string,
23
+ opts?: { maxPages?: number }
24
+ ): Promise<SyncResult> {
25
+ const result: SyncResult = { added: 0, updated: 0, skipped: 0, errors: [] };
26
+ const maxPages = opts?.maxPages ?? 10; // Safety limit: 10 pages * 50 = 500 contacts max
27
+
28
+ // Get or create sync cursor for tracking
29
+ const cursor = getSyncCursor(accountId, "connections");
30
+ updateSyncCursor(cursor.id, {
31
+ syncStatus: "syncing",
32
+ lastSyncStartedAt: Math.floor(Date.now() / 1000),
33
+ });
34
+
35
+ try {
36
+ let start = 0;
37
+ let page = 0;
38
+
39
+ while (page < maxPages) {
40
+ const res = await getConnections(accountId, { start, count: 50 });
41
+ const connections = res.elements ?? [];
42
+ if (connections.length === 0) break;
43
+
44
+ // Process each connection
45
+ for (const connection of connections) {
46
+ try {
47
+ processLinkedInConnection(connection, result);
48
+ } catch (err) {
49
+ const name = `${connection.localizedFirstName ?? ""} ${connection.localizedLastName ?? ""}`.trim();
50
+ result.errors.push(
51
+ `Failed to process ${name || connection.id}: ${err instanceof Error ? err.message : String(err)}`
52
+ );
53
+ }
54
+ }
55
+
56
+ // Update cursor progress
57
+ updateSyncCursor(cursor.id, {
58
+ totalItemsSynced: (cursor.totalItemsSynced ?? 0) + connections.length,
59
+ cursor: String(start + connections.length),
60
+ });
61
+
62
+ // Check if there are more pages
63
+ const total = res.paging?.total ?? 0;
64
+ start += res.paging?.count ?? 50;
65
+ if (start >= total) break;
66
+ page++;
67
+ }
68
+
69
+ // Update sync timestamps — always set lastSyncCompletedAt (partial sync pattern)
70
+ const now = Math.floor(Date.now() / 1000);
71
+ updateSyncCursor(cursor.id, {
72
+ syncStatus: "completed",
73
+ lastSyncCompletedAt: now,
74
+ });
75
+
76
+ updatePlatformAccount(accountId, {
77
+ lastSyncedAt: now,
78
+ });
79
+ } catch (err) {
80
+ result.errors.push(
81
+ `Sync failed: ${err instanceof Error ? err.message : String(err)}`
82
+ );
83
+
84
+ // Always set lastSyncCompletedAt even on error (partial sync pattern)
85
+ updateSyncCursor(cursor.id, {
86
+ syncStatus: "failed",
87
+ lastSyncCompletedAt: Math.floor(Date.now() / 1000),
88
+ lastError: err instanceof Error ? err.message : String(err),
89
+ });
90
+ }
91
+
92
+ return result;
93
+ }
94
+
95
+ /** Process a single LinkedIn connection — create or update contact + identity with cross-platform dedup. */
96
+ function processLinkedInConnection(connection: LinkedInConnection, result: SyncResult): void {
97
+ // 1. Check for existing identity by (platform="linkedin", platformUserId)
98
+ const existingIdentity = db
99
+ .select()
100
+ .from(contactIdentities)
101
+ .where(
102
+ and(
103
+ eq(contactIdentities.platform, "linkedin"),
104
+ eq(contactIdentities.platformUserId, connection.id)
105
+ )
106
+ )
107
+ .get();
108
+
109
+ if (existingIdentity) {
110
+ // Update existing contact with latest LinkedIn data
111
+ const contactData = mapLinkedInConnectionToContact(connection);
112
+ updateContact(existingIdentity.contactId, {
113
+ headline: contactData.headline,
114
+ photoUrl: contactData.photoUrl,
115
+ avatarUrl: contactData.avatarUrl,
116
+ });
117
+
118
+ // Update identity with latest platform data
119
+ db.update(contactIdentities)
120
+ .set({
121
+ platformHandle: mapLinkedInConnectionToIdentity(connection, existingIdentity.contactId).platformHandle,
122
+ platformData: mapLinkedInConnectionToIdentity(connection, existingIdentity.contactId).platformData,
123
+ lastSyncedAt: Math.floor(Date.now() / 1000),
124
+ updatedAt: Math.floor(Date.now() / 1000),
125
+ })
126
+ .where(eq(contactIdentities.id, existingIdentity.id))
127
+ .run();
128
+
129
+ recalcEnrichment(existingIdentity.contactId);
130
+ result.updated++;
131
+ return;
132
+ }
133
+
134
+ // 2. Cross-platform dedup: check if we can match by name (first+last)
135
+ // Note: email is not available at the connection level without additional API scopes
136
+ const contactData = mapLinkedInConnectionToContact(connection);
137
+
138
+ // Create new contact
139
+ const contact = createContact(contactData);
140
+
141
+ // Create identity linking this contact to the LinkedIn connection
142
+ const identityData = mapLinkedInConnectionToIdentity(connection, contact.id);
143
+ createIdentity(identityData);
144
+
145
+ // Recompute enrichment with the new identity
146
+ recalcEnrichment(contact.id);
147
+ result.added++;
148
+ }
@@ -0,0 +1,280 @@
1
+ import { eq, and, asc, isNotNull, inArray } from "drizzle-orm";
2
+ import { db } from "@/lib/db/client";
3
+ import { contacts, contactIdentities } from "@/lib/db/schema";
4
+ import { updateContact } from "@/lib/db/queries/contacts";
5
+ import { updateIdentity } from "@/lib/db/queries/identities";
6
+ import { updatePlatformAccount } from "@/lib/db/queries/platform-accounts";
7
+ import { getSyncCursor, updateSyncCursor } from "@/lib/db/queries/sync";
8
+ import { XScraper } from "@/lib/browser/platforms/x-scraper";
9
+ import { parseProfile } from "@/lib/browser/extractors/profile-parser";
10
+ import {
11
+ mergeProfileData,
12
+ buildPlatformDataUpdate,
13
+ } from "@/lib/browser/extractors/profile-merger";
14
+ import type { SyncResult } from "@/lib/platforms/adapter";
15
+
16
+ /** Contact with its X identity info, ready for enrichment. */
17
+ interface EnrichableContact {
18
+ id: string;
19
+ name: string;
20
+ company: string | null;
21
+ title: string | null;
22
+ headline: string | null;
23
+ email: string | null;
24
+ phone: string | null;
25
+ metadata: string | null;
26
+ enrichmentScore: number;
27
+ identityId: string;
28
+ platformHandle: string;
29
+ platformData: string | null;
30
+ }
31
+
32
+ /** Minimum days between re-scraping the same contact. */
33
+ const COOLDOWN_DAYS = 7;
34
+
35
+ /**
36
+ * Enrich X contacts by scraping their profile pages and extracting
37
+ * structured data via LLM. Follows the sync-gmail-metadata.ts pattern:
38
+ * sync cursor lifecycle, per-contact error isolation, partial failure.
39
+ */
40
+ export async function syncXProfiles(
41
+ accountId: string,
42
+ opts?: {
43
+ maxProfiles?: number;
44
+ contactIds?: string[];
45
+ }
46
+ ): Promise<SyncResult> {
47
+ const maxProfiles = opts?.maxProfiles ?? 15;
48
+ const result: SyncResult = { added: 0, updated: 0, skipped: 0, errors: [] };
49
+
50
+ // 1. Get or create sync cursor
51
+ const cursor = getSyncCursor(accountId, "x_profiles");
52
+ updateSyncCursor(cursor.id, {
53
+ syncStatus: "syncing",
54
+ lastSyncStartedAt: Math.floor(Date.now() / 1000),
55
+ });
56
+
57
+ let scraper: XScraper | null = null;
58
+
59
+ try {
60
+ // 2. Select contacts to enrich
61
+ const enrichable = selectContactsForEnrichment({
62
+ maxProfiles,
63
+ contactIds: opts?.contactIds,
64
+ });
65
+
66
+ if (enrichable.length === 0) {
67
+ updateSyncCursor(cursor.id, {
68
+ syncStatus: "completed",
69
+ lastSyncCompletedAt: Math.floor(Date.now() / 1000),
70
+ });
71
+ return result;
72
+ }
73
+
74
+ // 3. Initialize scraper and validate session
75
+ scraper = new XScraper();
76
+ await scraper.init();
77
+
78
+ if (!(await scraper.validateSession())) {
79
+ throw new Error(
80
+ "X browser session is invalid or expired. Please re-authenticate in Settings."
81
+ );
82
+ }
83
+
84
+ let processed = 0;
85
+
86
+ // 4. Per-contact enrichment loop
87
+ for (const contact of enrichable) {
88
+ if (scraper.batchLimitReached) {
89
+ result.errors.push(
90
+ `Batch limit reached after ${processed} profiles. Remaining contacts will be enriched in the next batch.`
91
+ );
92
+ break;
93
+ }
94
+
95
+ try {
96
+ // 4a. Scrape profile page
97
+ const raw = await scraper.scrapeProfile(contact.platformHandle);
98
+ if (!raw) {
99
+ result.skipped++;
100
+ continue;
101
+ }
102
+
103
+ // 4b. LLM extraction
104
+ const parsed = await parseProfile(raw);
105
+
106
+ // 4c. Merge into contact (fill gaps, don't overwrite)
107
+ // Build a minimal Contact-like object for the merger
108
+ const contactForMerge = {
109
+ id: contact.id,
110
+ name: contact.name,
111
+ company: contact.company,
112
+ title: contact.title,
113
+ headline: contact.headline,
114
+ email: contact.email,
115
+ phone: contact.phone,
116
+ metadata: contact.metadata,
117
+ };
118
+
119
+ const updates = mergeProfileData(
120
+ contactForMerge as Parameters<typeof mergeProfileData>[0],
121
+ parsed,
122
+ raw
123
+ );
124
+
125
+ if (Object.keys(updates).length > 0) {
126
+ // updateContact auto-calls recalcEnrichment(contactId)
127
+ updateContact(contact.id, updates);
128
+ result.updated++;
129
+ } else {
130
+ result.skipped++;
131
+ }
132
+
133
+ // 4d. Update identity platformData for rich data scoring (+10 pts)
134
+ const updatedPlatformData = buildPlatformDataUpdate(
135
+ contact.platformData,
136
+ parsed
137
+ );
138
+ updateIdentity(contact.identityId, {
139
+ platformData: updatedPlatformData,
140
+ });
141
+
142
+ processed++;
143
+ } catch (err) {
144
+ const message =
145
+ err instanceof Error ? err.message : String(err);
146
+ result.errors.push(
147
+ `Failed to enrich @${contact.platformHandle}: ${message}`
148
+ );
149
+
150
+ // Stop batch on challenge/CAPTCHA detection
151
+ if (message.includes("Challenge/CAPTCHA")) {
152
+ result.errors.push("Stopping batch due to challenge detection.");
153
+ break;
154
+ }
155
+ }
156
+ }
157
+
158
+ // 5. Mark sync completed
159
+ const now = Math.floor(Date.now() / 1000);
160
+ updateSyncCursor(cursor.id, {
161
+ syncStatus: "completed",
162
+ totalItemsSynced: (cursor.totalItemsSynced ?? 0) + processed,
163
+ lastSyncCompletedAt: now,
164
+ });
165
+ updatePlatformAccount(accountId, { lastSyncedAt: now });
166
+ } catch (err) {
167
+ // Partial sync pattern: ALWAYS set lastSyncCompletedAt even on error
168
+ result.errors.push(
169
+ `Profile enrichment failed: ${err instanceof Error ? err.message : String(err)}`
170
+ );
171
+ updateSyncCursor(cursor.id, {
172
+ syncStatus: "failed",
173
+ lastSyncCompletedAt: Math.floor(Date.now() / 1000),
174
+ lastError: err instanceof Error ? err.message : String(err),
175
+ });
176
+ } finally {
177
+ await scraper?.close();
178
+ }
179
+
180
+ return result;
181
+ }
182
+
183
+ /**
184
+ * Select contacts for browser enrichment.
185
+ * Prioritizes low enrichment scores, skips recently scraped (7-day cooldown).
186
+ */
187
+ function selectContactsForEnrichment(opts: {
188
+ maxProfiles: number;
189
+ contactIds?: string[];
190
+ }): EnrichableContact[] {
191
+ const cooldownThreshold =
192
+ Math.floor(Date.now() / 1000) - COOLDOWN_DAYS * 86400;
193
+
194
+ // Base query: contacts with an X identity that has a handle
195
+ let rows;
196
+ if (opts.contactIds?.length) {
197
+ // Specific contacts requested
198
+ rows = db
199
+ .select({
200
+ id: contacts.id,
201
+ name: contacts.name,
202
+ company: contacts.company,
203
+ title: contacts.title,
204
+ headline: contacts.headline,
205
+ email: contacts.email,
206
+ phone: contacts.phone,
207
+ metadata: contacts.metadata,
208
+ enrichmentScore: contacts.enrichmentScore,
209
+ identityId: contactIdentities.id,
210
+ platformHandle: contactIdentities.platformHandle,
211
+ platformData: contactIdentities.platformData,
212
+ })
213
+ .from(contacts)
214
+ .innerJoin(
215
+ contactIdentities,
216
+ eq(contacts.id, contactIdentities.contactId)
217
+ )
218
+ .where(
219
+ and(
220
+ inArray(contacts.id, opts.contactIds),
221
+ eq(contactIdentities.platform, "x"),
222
+ isNotNull(contactIdentities.platformHandle)
223
+ )
224
+ )
225
+ .all();
226
+ } else {
227
+ // Auto-select: lowest enrichment scores first
228
+ // Fetch more than needed to account for cooldown filtering
229
+ rows = db
230
+ .select({
231
+ id: contacts.id,
232
+ name: contacts.name,
233
+ company: contacts.company,
234
+ title: contacts.title,
235
+ headline: contacts.headline,
236
+ email: contacts.email,
237
+ phone: contacts.phone,
238
+ metadata: contacts.metadata,
239
+ enrichmentScore: contacts.enrichmentScore,
240
+ identityId: contactIdentities.id,
241
+ platformHandle: contactIdentities.platformHandle,
242
+ platformData: contactIdentities.platformData,
243
+ })
244
+ .from(contacts)
245
+ .innerJoin(
246
+ contactIdentities,
247
+ eq(contacts.id, contactIdentities.contactId)
248
+ )
249
+ .where(
250
+ and(
251
+ eq(contactIdentities.platform, "x"),
252
+ isNotNull(contactIdentities.platformHandle)
253
+ )
254
+ )
255
+ .orderBy(asc(contacts.enrichmentScore))
256
+ .limit(opts.maxProfiles * 2) // over-fetch for cooldown filtering
257
+ .all();
258
+ }
259
+
260
+ // Filter: skip recently scraped contacts (7-day cooldown)
261
+ const filtered = rows.filter((row) => {
262
+ if (!row.metadata) return true;
263
+ try {
264
+ const meta = JSON.parse(row.metadata);
265
+ const scrapedAt = meta?.browserEnrichment?.scrapedAt;
266
+ if (typeof scrapedAt === "number" && scrapedAt > cooldownThreshold) {
267
+ return false; // too recent
268
+ }
269
+ } catch {
270
+ // invalid metadata JSON — allow enrichment
271
+ }
272
+ return true;
273
+ });
274
+
275
+ // Cast platformHandle (Drizzle returns string | null from isNotNull)
276
+ return filtered.slice(0, opts.maxProfiles).map((row) => ({
277
+ ...row,
278
+ platformHandle: row.platformHandle!,
279
+ }));
280
+ }
@@ -0,0 +1,129 @@
1
+ import type {
2
+ PlatformAdapter,
3
+ PlatformCredentials,
4
+ PlatformUserProfile,
5
+ PaginatedResult,
6
+ RateLimitState,
7
+ } from "@/lib/platforms/adapter";
8
+ import { getXClientCredentials, refreshXTokenAsync, disconnectXAccount } from "@/lib/platforms/x/auth";
9
+ import { savePkceState } from "@/lib/platforms/x/pkce-store";
10
+ import {
11
+ getAuthenticatedUser,
12
+ getUserById as xGetUserById,
13
+ getFollowing,
14
+ } from "@/lib/platforms/x/client";
15
+ import { getRateLimitState } from "@/lib/platforms/rate-limiter";
16
+ import { getPlatformAccountByPlatform } from "@/lib/db/queries/platform-accounts";
17
+ import { randomBytes, createHash } from "crypto";
18
+ import type { XUser } from "@/lib/platforms/x/client";
19
+
20
+ /** Map an XUser to the normalized PlatformUserProfile. */
21
+ function toProfile(xUser: XUser): PlatformUserProfile {
22
+ return {
23
+ platformUserId: xUser.id,
24
+ platformHandle: `@${xUser.username}`,
25
+ displayName: xUser.name,
26
+ bio: xUser.description ?? null,
27
+ location: xUser.location ?? null,
28
+ website: xUser.url ?? null,
29
+ photoUrl: xUser.profile_image_url?.replace("_normal", "_400x400") ?? null,
30
+ followersCount: xUser.public_metrics?.followers_count ?? 0,
31
+ followingCount: xUser.public_metrics?.following_count ?? 0,
32
+ rawData: xUser as unknown as Record<string, unknown>,
33
+ };
34
+ }
35
+
36
+ export class XPlatformAdapter implements PlatformAdapter {
37
+ readonly platform = "x" as const;
38
+
39
+ async getAuthorizationUrl(redirectUri: string, extended = false): Promise<{ authUrl: string; state: string }> {
40
+ const { clientId } = getXClientCredentials();
41
+
42
+ const codeVerifier = randomBytes(32).toString("base64url");
43
+ const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
44
+ const state = randomBytes(16).toString("hex");
45
+
46
+ savePkceState(state, codeVerifier, extended);
47
+
48
+ // Free tier: CRM engagement + messaging; Extended (Basic+): adds contact management
49
+ const FREE_SCOPES = "tweet.read tweet.write tweet.moderate.write users.read like.read like.write bookmark.read bookmark.write dm.read dm.write offline.access";
50
+ const EXTENDED_SCOPES = "tweet.read tweet.write tweet.moderate.write users.read like.read like.write bookmark.read bookmark.write dm.read dm.write follows.read follows.write list.read list.write mute.read mute.write block.read block.write space.read offline.access";
51
+
52
+ const params = new URLSearchParams({
53
+ response_type: "code",
54
+ client_id: clientId,
55
+ redirect_uri: redirectUri,
56
+ scope: extended ? EXTENDED_SCOPES : FREE_SCOPES,
57
+ state,
58
+ code_challenge: codeChallenge,
59
+ code_challenge_method: "S256",
60
+ });
61
+
62
+ return {
63
+ authUrl: `https://x.com/i/oauth2/authorize?${params.toString()}`,
64
+ state,
65
+ };
66
+ }
67
+
68
+ async exchangeCode(
69
+ code: string,
70
+ _state: string,
71
+ redirectUri: string
72
+ ): Promise<PlatformCredentials> {
73
+ // Code exchange is handled by the callback API route directly
74
+ // This is here to satisfy the interface
75
+ throw new Error("Use /api/platforms/x/callback for code exchange");
76
+ }
77
+
78
+ async refreshToken(accountId: string): Promise<PlatformCredentials> {
79
+ return refreshXTokenAsync(accountId);
80
+ }
81
+
82
+ async revokeToken(accountId: string): Promise<void> {
83
+ return disconnectXAccount(accountId);
84
+ }
85
+
86
+ async getProfile(accountId: string): Promise<PlatformUserProfile> {
87
+ const xUser = await getAuthenticatedUser(accountId);
88
+ return toProfile(xUser);
89
+ }
90
+
91
+ async getContacts(
92
+ accountId: string,
93
+ cursor?: string
94
+ ): Promise<PaginatedResult<PlatformUserProfile>> {
95
+ // Get the authenticated user's ID to fetch their "following" list
96
+ const account = getPlatformAccountByPlatform("x");
97
+ if (!account) throw new Error("No X account connected");
98
+
99
+ const me = await getAuthenticatedUser(accountId);
100
+ const res = await getFollowing(accountId, me.id, {
101
+ maxResults: 100,
102
+ paginationToken: cursor || undefined,
103
+ });
104
+
105
+ const items = Array.isArray(res.data) ? res.data.map(toProfile) : [];
106
+
107
+ return {
108
+ items,
109
+ nextCursor: res.meta?.next_token ?? null,
110
+ hasMore: !!res.meta?.next_token,
111
+ };
112
+ }
113
+
114
+ async getUserById(
115
+ accountId: string,
116
+ userId: string
117
+ ): Promise<PlatformUserProfile | null> {
118
+ try {
119
+ const xUser = await xGetUserById(accountId, userId);
120
+ return toProfile(xUser);
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ getRateLimitState(accountId: string): RateLimitState {
127
+ return getRateLimitState(accountId);
128
+ }
129
+ }