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,225 @@
1
+ import { nanoid } from "nanoid";
2
+ import { db } from "@/lib/db/client";
3
+ import { contentItems, contentPosts, engagementMetrics } from "@/lib/db/schema";
4
+ import { eq, and } from "drizzle-orm";
5
+ import { getContentPostByPlatformId } from "@/lib/db/queries/content";
6
+ import { getSyncCursor, updateSyncCursor } from "@/lib/db/queries/sync";
7
+ import { updatePlatformAccount } from "@/lib/db/queries/platform-accounts";
8
+ import { getAuthenticatedUser, getUserTweets, getUserMentions } from "@/lib/platforms/x/client";
9
+ import { mapXTweetToContentItem, mapXTweetToContentPost, extractTweetMetrics } from "@/lib/platforms/x/mappers";
10
+ import type { XTweet } from "@/lib/platforms/x/client";
11
+ import type { SyncResult } from "@/lib/platforms/adapter";
12
+
13
+ interface ContentSyncResult extends SyncResult {
14
+ /** Total items processed (including skipped). */
15
+ total: number;
16
+ }
17
+
18
+ /**
19
+ * Sync tweets from the authenticated user's timeline.
20
+ * Deduplicates by platformPostId on the content_posts table.
21
+ * Uses sync_cursors for pagination state.
22
+ */
23
+ export async function syncTweetsFromX(
24
+ accountId: string,
25
+ opts?: { maxPages?: number }
26
+ ): Promise<ContentSyncResult> {
27
+ const result: ContentSyncResult = { added: 0, updated: 0, skipped: 0, errors: [], total: 0 };
28
+ const maxPages = opts?.maxPages ?? 5;
29
+
30
+ const cursor = getSyncCursor(accountId, "tweets");
31
+ updateSyncCursor(cursor.id, {
32
+ syncStatus: "syncing",
33
+ lastSyncStartedAt: Math.floor(Date.now() / 1000),
34
+ });
35
+
36
+ try {
37
+ const me = await getAuthenticatedUser(accountId);
38
+ let paginationToken: string | undefined = cursor.cursor ?? undefined;
39
+ let page = 0;
40
+
41
+ while (page < maxPages) {
42
+ const res = await getUserTweets(accountId, me.id, {
43
+ maxResults: 10,
44
+ paginationToken,
45
+ });
46
+
47
+ const tweets = Array.isArray(res.data) ? res.data : [];
48
+ if (tweets.length === 0) break;
49
+
50
+ for (const tweet of tweets) {
51
+ try {
52
+ const wasAdded = processTweet(tweet, accountId, "authored");
53
+ if (wasAdded) {
54
+ result.added++;
55
+ } else {
56
+ result.skipped++;
57
+ }
58
+ result.total++;
59
+ } catch (err) {
60
+ result.errors.push(
61
+ `Failed to process tweet ${tweet.id}: ${err instanceof Error ? err.message : String(err)}`
62
+ );
63
+ }
64
+ }
65
+
66
+ // Update cursor with pagination token
67
+ paginationToken = res.meta?.next_token ?? undefined;
68
+ updateSyncCursor(cursor.id, {
69
+ cursor: paginationToken ?? null,
70
+ totalItemsSynced: cursor.totalItemsSynced + result.added,
71
+ syncProgress: JSON.stringify({
72
+ current: result.total,
73
+ message: `Imported ${result.added} tweets`,
74
+ }),
75
+ });
76
+
77
+ if (!paginationToken) break;
78
+ page++;
79
+ }
80
+
81
+ updateSyncCursor(cursor.id, {
82
+ syncStatus: "completed",
83
+ lastSyncCompletedAt: Math.floor(Date.now() / 1000),
84
+ totalItemsSynced: cursor.totalItemsSynced + result.added,
85
+ });
86
+ } catch (err) {
87
+ const errorMessage = err instanceof Error ? err.message : String(err);
88
+ result.errors.push(`Tweet sync failed: ${errorMessage}`);
89
+ updateSyncCursor(cursor.id, {
90
+ syncStatus: result.added > 0 ? "completed" : "failed",
91
+ lastSyncCompletedAt: Math.floor(Date.now() / 1000),
92
+ totalItemsSynced: cursor.totalItemsSynced + result.added,
93
+ lastError: errorMessage,
94
+ });
95
+ }
96
+
97
+ // Update account last synced time
98
+ updatePlatformAccount(accountId, {
99
+ lastSyncedAt: Math.floor(Date.now() / 1000),
100
+ });
101
+
102
+ return result;
103
+ }
104
+
105
+ /**
106
+ * Sync mentions of the authenticated user.
107
+ * Mentions are treated as "received" content.
108
+ */
109
+ export async function syncMentionsFromX(
110
+ accountId: string,
111
+ opts?: { maxPages?: number }
112
+ ): Promise<ContentSyncResult> {
113
+ const result: ContentSyncResult = { added: 0, updated: 0, skipped: 0, errors: [], total: 0 };
114
+ const maxPages = opts?.maxPages ?? 5;
115
+
116
+ const cursor = getSyncCursor(accountId, "mentions");
117
+ updateSyncCursor(cursor.id, {
118
+ syncStatus: "syncing",
119
+ lastSyncStartedAt: Math.floor(Date.now() / 1000),
120
+ });
121
+
122
+ try {
123
+ const me = await getAuthenticatedUser(accountId);
124
+ let paginationToken: string | undefined = cursor.cursor ?? undefined;
125
+ let page = 0;
126
+
127
+ while (page < maxPages) {
128
+ const res = await getUserMentions(accountId, me.id, {
129
+ maxResults: 10,
130
+ paginationToken,
131
+ });
132
+
133
+ const mentions = Array.isArray(res.data) ? res.data : [];
134
+ if (mentions.length === 0) break;
135
+
136
+ for (const mention of mentions) {
137
+ try {
138
+ const wasAdded = processTweet(mention, accountId, "received");
139
+ if (wasAdded) {
140
+ result.added++;
141
+ } else {
142
+ result.skipped++;
143
+ }
144
+ result.total++;
145
+ } catch (err) {
146
+ result.errors.push(
147
+ `Failed to process mention ${mention.id}: ${err instanceof Error ? err.message : String(err)}`
148
+ );
149
+ }
150
+ }
151
+
152
+ paginationToken = res.meta?.next_token ?? undefined;
153
+ updateSyncCursor(cursor.id, {
154
+ cursor: paginationToken ?? null,
155
+ totalItemsSynced: cursor.totalItemsSynced + result.added,
156
+ syncProgress: JSON.stringify({
157
+ current: result.total,
158
+ message: `Imported ${result.added} mentions`,
159
+ }),
160
+ });
161
+
162
+ if (!paginationToken) break;
163
+ page++;
164
+ }
165
+
166
+ updateSyncCursor(cursor.id, {
167
+ syncStatus: "completed",
168
+ lastSyncCompletedAt: Math.floor(Date.now() / 1000),
169
+ totalItemsSynced: cursor.totalItemsSynced + result.added,
170
+ });
171
+ } catch (err) {
172
+ const errorMessage = err instanceof Error ? err.message : String(err);
173
+ result.errors.push(`Mention sync failed: ${errorMessage}`);
174
+ updateSyncCursor(cursor.id, {
175
+ syncStatus: result.added > 0 ? "completed" : "failed",
176
+ lastSyncCompletedAt: Math.floor(Date.now() / 1000),
177
+ totalItemsSynced: cursor.totalItemsSynced + result.added,
178
+ lastError: errorMessage,
179
+ });
180
+ }
181
+
182
+ updatePlatformAccount(accountId, {
183
+ lastSyncedAt: Math.floor(Date.now() / 1000),
184
+ });
185
+
186
+ return result;
187
+ }
188
+
189
+ /**
190
+ * Process a single tweet/mention — create content_item + content_post + metrics.
191
+ * Returns true if added, false if skipped (already exists).
192
+ */
193
+ function processTweet(
194
+ tweet: XTweet,
195
+ accountId: string,
196
+ origin: "authored" | "received"
197
+ ): boolean {
198
+ // Dedup check: does this tweet already exist?
199
+ const existingPost = getContentPostByPlatformId(tweet.id, accountId);
200
+ if (existingPost) return false;
201
+
202
+ // Create content item
203
+ const itemData = mapXTweetToContentItem(tweet, accountId, origin);
204
+ const itemId = nanoid();
205
+ db.insert(contentItems).values({ ...itemData, id: itemId }).run();
206
+
207
+ // Create content post (published instance)
208
+ const postData = mapXTweetToContentPost(tweet, accountId);
209
+ const postId = nanoid();
210
+ db.insert(contentPosts)
211
+ .values({ ...postData, id: postId, contentItemId: itemId })
212
+ .run();
213
+
214
+ // Store engagement metrics snapshot
215
+ const metrics = extractTweetMetrics(tweet);
216
+ db.insert(engagementMetrics)
217
+ .values({
218
+ id: nanoid(),
219
+ contentPostId: postId,
220
+ ...metrics,
221
+ })
222
+ .run();
223
+
224
+ return true;
225
+ }
@@ -0,0 +1,186 @@
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
+ mapGooglePersonToContact,
10
+ mapGooglePersonToIdentity,
11
+ } from "@/lib/platforms/gmail/mappers";
12
+ import { getGoogleContacts } from "@/lib/platforms/gmail/client";
13
+ import type { GooglePerson } from "@/lib/platforms/gmail/client";
14
+ import type { SyncResult } from "@/lib/platforms/adapter";
15
+
16
+ /**
17
+ * Sync contacts from Google People API into OpenVolo.
18
+ * Uses token-based pagination (pageToken, not offset-based like LinkedIn).
19
+ * 2-tier dedup: gmail identity match → email match → create new.
20
+ */
21
+ export async function syncContactsFromGmail(
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 * 100 = 1000 contacts max
27
+
28
+ // Get or create sync cursor for tracking
29
+ const cursor = getSyncCursor(accountId, "google_contacts");
30
+ updateSyncCursor(cursor.id, {
31
+ syncStatus: "syncing",
32
+ lastSyncStartedAt: Math.floor(Date.now() / 1000),
33
+ });
34
+
35
+ try {
36
+ let pageToken: string | undefined = undefined;
37
+ let page = 0;
38
+
39
+ while (page < maxPages) {
40
+ const res = await getGoogleContacts(accountId, { pageToken, pageSize: 100 });
41
+ const people = res.connections ?? [];
42
+ if (people.length === 0) break;
43
+
44
+ // Process each person
45
+ for (const person of people) {
46
+ try {
47
+ processGooglePerson(person, result);
48
+ } catch (err) {
49
+ const name = person.names?.[0]?.displayName ?? person.resourceName;
50
+ result.errors.push(
51
+ `Failed to process ${name}: ${err instanceof Error ? err.message : String(err)}`
52
+ );
53
+ }
54
+ }
55
+
56
+ // Update cursor progress
57
+ updateSyncCursor(cursor.id, {
58
+ totalItemsSynced: (cursor.totalItemsSynced ?? 0) + people.length,
59
+ cursor: res.nextPageToken ?? null,
60
+ });
61
+
62
+ // Check if there are more pages
63
+ if (!res.nextPageToken) break;
64
+ pageToken = res.nextPageToken;
65
+ page++;
66
+ }
67
+
68
+ // Update sync timestamps — always set lastSyncCompletedAt (partial sync pattern)
69
+ const now = Math.floor(Date.now() / 1000);
70
+ updateSyncCursor(cursor.id, {
71
+ syncStatus: "completed",
72
+ lastSyncCompletedAt: now,
73
+ });
74
+
75
+ updatePlatformAccount(accountId, {
76
+ lastSyncedAt: now,
77
+ });
78
+ } catch (err) {
79
+ result.errors.push(
80
+ `Sync failed: ${err instanceof Error ? err.message : String(err)}`
81
+ );
82
+
83
+ // Always set lastSyncCompletedAt even on error (partial sync pattern)
84
+ updateSyncCursor(cursor.id, {
85
+ syncStatus: "failed",
86
+ lastSyncCompletedAt: Math.floor(Date.now() / 1000),
87
+ lastError: err instanceof Error ? err.message : String(err),
88
+ });
89
+ }
90
+
91
+ return result;
92
+ }
93
+
94
+ /** Process a single Google person — create or update contact + identity with cross-platform dedup. */
95
+ function processGooglePerson(person: GooglePerson, result: SyncResult): void {
96
+ const resourceId = person.resourceName.replace(/^people\//, "");
97
+
98
+ // 1. Check for existing identity by (platform="gmail", platformUserId)
99
+ const existingIdentity = db
100
+ .select()
101
+ .from(contactIdentities)
102
+ .where(
103
+ and(
104
+ eq(contactIdentities.platform, "gmail"),
105
+ eq(contactIdentities.platformUserId, resourceId)
106
+ )
107
+ )
108
+ .get();
109
+
110
+ if (existingIdentity) {
111
+ // Update existing contact with latest Google data
112
+ const contactData = mapGooglePersonToContact(person);
113
+ updateContact(existingIdentity.contactId, {
114
+ firstName: contactData.firstName,
115
+ lastName: contactData.lastName,
116
+ email: contactData.email,
117
+ phone: contactData.phone,
118
+ company: contactData.company,
119
+ title: contactData.title,
120
+ location: contactData.location,
121
+ bio: contactData.bio,
122
+ photoUrl: contactData.photoUrl,
123
+ avatarUrl: contactData.avatarUrl,
124
+ website: contactData.website,
125
+ });
126
+
127
+ // Update identity with latest platform data
128
+ const identityData = mapGooglePersonToIdentity(person, existingIdentity.contactId);
129
+ db.update(contactIdentities)
130
+ .set({
131
+ platformHandle: identityData.platformHandle,
132
+ platformData: identityData.platformData,
133
+ lastSyncedAt: Math.floor(Date.now() / 1000),
134
+ updatedAt: Math.floor(Date.now() / 1000),
135
+ })
136
+ .where(eq(contactIdentities.id, existingIdentity.id))
137
+ .run();
138
+
139
+ recalcEnrichment(existingIdentity.contactId);
140
+ result.updated++;
141
+ return;
142
+ }
143
+
144
+ // 2. Cross-platform dedup: check contacts.email matching any of the person's emails
145
+ const contactData = mapGooglePersonToContact(person);
146
+ const personEmails = (person.emailAddresses ?? [])
147
+ .map((e) => e.value)
148
+ .filter((v): v is string => !!v);
149
+
150
+ for (const email of personEmails) {
151
+ const emailMatch = db
152
+ .select()
153
+ .from(contacts)
154
+ .where(eq(contacts.email, email))
155
+ .get();
156
+
157
+ if (emailMatch) {
158
+ // Found existing contact by email — add Gmail identity to it
159
+ updateContact(emailMatch.id, {
160
+ firstName: contactData.firstName ?? emailMatch.firstName,
161
+ lastName: contactData.lastName ?? emailMatch.lastName,
162
+ phone: contactData.phone ?? emailMatch.phone,
163
+ company: contactData.company ?? emailMatch.company,
164
+ title: contactData.title ?? emailMatch.title,
165
+ location: contactData.location ?? emailMatch.location,
166
+ bio: contactData.bio ?? emailMatch.bio,
167
+ photoUrl: contactData.photoUrl ?? emailMatch.photoUrl,
168
+ avatarUrl: contactData.avatarUrl ?? emailMatch.avatarUrl,
169
+ website: contactData.website ?? emailMatch.website,
170
+ });
171
+
172
+ const identityData = mapGooglePersonToIdentity(person, emailMatch.id);
173
+ createIdentity(identityData);
174
+ recalcEnrichment(emailMatch.id);
175
+ result.updated++;
176
+ return;
177
+ }
178
+ }
179
+
180
+ // 3. Create new contact
181
+ const contact = createContact(contactData);
182
+ const identityData = mapGooglePersonToIdentity(person, contact.id);
183
+ createIdentity(identityData);
184
+ recalcEnrichment(contact.id);
185
+ result.added++;
186
+ }
@@ -0,0 +1,158 @@
1
+ import { eq, and, isNotNull } 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 { updatePlatformAccount } from "@/lib/db/queries/platform-accounts";
6
+ import { getSyncCursor, updateSyncCursor } from "@/lib/db/queries/sync";
7
+ import {
8
+ getGmailMessagesByContact,
9
+ getGmailMessageMetadata,
10
+ } from "@/lib/platforms/gmail/client";
11
+ import type { SyncResult } from "@/lib/platforms/adapter";
12
+
13
+ /**
14
+ * Sync Gmail metadata for contacts that have email addresses.
15
+ * Queries Gmail for per-contact message frequency and last interaction date.
16
+ * Stores in contacts.metadata JSON (merged, not replaced).
17
+ */
18
+ export async function syncGmailMetadata(
19
+ accountId: string,
20
+ opts?: { maxContacts?: number }
21
+ ): Promise<SyncResult> {
22
+ const result: SyncResult = { added: 0, updated: 0, skipped: 0, errors: [] };
23
+ const maxContacts = opts?.maxContacts ?? 50; // Rate limit safety
24
+
25
+ // Get or create sync cursor for tracking
26
+ const cursor = getSyncCursor(accountId, "gmail_metadata");
27
+ updateSyncCursor(cursor.id, {
28
+ syncStatus: "syncing",
29
+ lastSyncStartedAt: Math.floor(Date.now() / 1000),
30
+ });
31
+
32
+ try {
33
+ // Find contacts with email addresses
34
+ const contactsWithEmail = db
35
+ .select()
36
+ .from(contacts)
37
+ .where(isNotNull(contacts.email))
38
+ .limit(maxContacts)
39
+ .all();
40
+
41
+ if (contactsWithEmail.length === 0) {
42
+ updateSyncCursor(cursor.id, {
43
+ syncStatus: "completed",
44
+ lastSyncCompletedAt: Math.floor(Date.now() / 1000),
45
+ });
46
+ return result;
47
+ }
48
+
49
+ // Calculate 30-day window for frequency counting
50
+ const now = Math.floor(Date.now() / 1000);
51
+ const thirtyDaysAgo = new Date(now * 1000);
52
+ thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
53
+ const afterDate = `${thirtyDaysAgo.getFullYear()}/${String(thirtyDaysAgo.getMonth() + 1).padStart(2, "0")}/${String(thirtyDaysAgo.getDate()).padStart(2, "0")}`;
54
+
55
+ let processed = 0;
56
+
57
+ for (const contact of contactsWithEmail) {
58
+ if (!contact.email) continue;
59
+
60
+ try {
61
+ // Get most recent messages for last interaction date
62
+ const recentMessages = await getGmailMessagesByContact(
63
+ accountId,
64
+ contact.email,
65
+ { maxResults: 1 }
66
+ );
67
+
68
+ let lastMessageAt: number | null = null;
69
+
70
+ if (recentMessages.messages && recentMessages.messages.length > 0) {
71
+ // Get metadata of the most recent message
72
+ const latestMsg = await getGmailMessageMetadata(
73
+ accountId,
74
+ recentMessages.messages[0].id
75
+ );
76
+ if (latestMsg.internalDate) {
77
+ lastMessageAt = Math.floor(parseInt(latestMsg.internalDate) / 1000);
78
+ }
79
+ }
80
+
81
+ // Get sent messages in the last 30 days
82
+ const sentMessages = await getGmailMessagesByContact(
83
+ accountId,
84
+ contact.email,
85
+ {
86
+ maxResults: 100,
87
+ query: `to:${contact.email} after:${afterDate}`,
88
+ }
89
+ );
90
+ const sent30d = sentMessages.messages?.length ?? 0;
91
+
92
+ // Get received messages in the last 30 days
93
+ const receivedMessages = await getGmailMessagesByContact(
94
+ accountId,
95
+ contact.email,
96
+ {
97
+ maxResults: 100,
98
+ query: `from:${contact.email} after:${afterDate}`,
99
+ }
100
+ );
101
+ const received30d = receivedMessages.messages?.length ?? 0;
102
+
103
+ // Merge metadata (preserve existing, add messageFrequency)
104
+ const existingMetadata = contact.metadata ? JSON.parse(contact.metadata) : {};
105
+ const updatedMetadata = {
106
+ ...existingMetadata,
107
+ messageFrequency: {
108
+ sent30d,
109
+ received30d,
110
+ lastMessageAt,
111
+ },
112
+ };
113
+
114
+ // Update contact
115
+ const updates: Record<string, unknown> = {
116
+ metadata: JSON.stringify(updatedMetadata),
117
+ };
118
+
119
+ // Update lastInteractionAt if we found a more recent message
120
+ if (lastMessageAt && (!contact.lastInteractionAt || lastMessageAt > contact.lastInteractionAt)) {
121
+ updates.lastInteractionAt = lastMessageAt;
122
+ }
123
+
124
+ updateContact(contact.id, updates);
125
+ result.updated++;
126
+ processed++;
127
+ } catch (err) {
128
+ result.errors.push(
129
+ `Failed to sync metadata for ${contact.name} (${contact.email}): ${err instanceof Error ? err.message : String(err)}`
130
+ );
131
+ }
132
+ }
133
+
134
+ // Update sync timestamps
135
+ updateSyncCursor(cursor.id, {
136
+ syncStatus: "completed",
137
+ totalItemsSynced: (cursor.totalItemsSynced ?? 0) + processed,
138
+ lastSyncCompletedAt: now,
139
+ });
140
+
141
+ updatePlatformAccount(accountId, {
142
+ lastSyncedAt: now,
143
+ });
144
+ } catch (err) {
145
+ result.errors.push(
146
+ `Metadata sync failed: ${err instanceof Error ? err.message : String(err)}`
147
+ );
148
+
149
+ // Always set lastSyncCompletedAt even on error (partial sync pattern)
150
+ updateSyncCursor(cursor.id, {
151
+ syncStatus: "failed",
152
+ lastSyncCompletedAt: Math.floor(Date.now() / 1000),
153
+ lastError: err instanceof Error ? err.message : String(err),
154
+ });
155
+ }
156
+
157
+ return result;
158
+ }