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,52 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { randomBytes, createHash } from "crypto";
3
+ import { getXClientCredentials } from "@/lib/platforms/x/auth";
4
+ import { savePkceState } from "@/lib/platforms/x/pkce-store";
5
+
6
+ // Free tier: CRM engagement + messaging (requires "Read and write and Direct message" in X Developer Portal)
7
+ 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";
8
+ // Basic+ tier: adds contact management scopes (follows, lists, mute, block)
9
+ 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";
10
+
11
+ /**
12
+ * GET /api/platforms/x/auth
13
+ * Generate PKCE challenge and return the X OAuth 2.0 authorization URL.
14
+ * Pass ?extended=true to request Basic+ tier scopes (follows.read/write).
15
+ */
16
+ export async function GET(req: NextRequest) {
17
+ try {
18
+ const { clientId } = getXClientCredentials();
19
+ const extended = req.nextUrl.searchParams.get("extended") === "true";
20
+
21
+ // Determine redirect URI from the request origin
22
+ const origin = req.headers.get("origin") || req.nextUrl.origin;
23
+ const redirectUri = `${origin}/api/platforms/x/callback`;
24
+
25
+ // Generate PKCE code verifier and challenge
26
+ const codeVerifier = randomBytes(32).toString("base64url");
27
+ const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
28
+
29
+ // Generate state parameter for CSRF protection
30
+ const state = randomBytes(16).toString("hex");
31
+
32
+ // Store PKCE state with tier metadata (in-memory, 10-min TTL)
33
+ savePkceState(state, codeVerifier, extended);
34
+
35
+ const params = new URLSearchParams({
36
+ response_type: "code",
37
+ client_id: clientId,
38
+ redirect_uri: redirectUri,
39
+ scope: extended ? EXTENDED_SCOPES : FREE_SCOPES,
40
+ state,
41
+ code_challenge: codeChallenge,
42
+ code_challenge_method: "S256",
43
+ });
44
+
45
+ const authUrl = `https://x.com/i/oauth2/authorize?${params.toString()}`;
46
+
47
+ return NextResponse.json({ authUrl, state });
48
+ } catch (error) {
49
+ const message = error instanceof Error ? error.message : "Failed to generate auth URL";
50
+ return NextResponse.json({ error: message }, { status: 500 });
51
+ }
52
+ }
@@ -0,0 +1,79 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import {
3
+ hasSession,
4
+ loadSession,
5
+ clearSession,
6
+ setupSession,
7
+ validateSession,
8
+ } from "@/lib/browser/session";
9
+
10
+ /**
11
+ * POST /api/platforms/x/browser-session
12
+ * Manage browser session for X.
13
+ * Body: { action: "setup" | "validate" }
14
+ */
15
+ export async function POST(req: NextRequest) {
16
+ try {
17
+ const body = await req.json().catch(() => ({}));
18
+ const action = body.action || "setup";
19
+
20
+ switch (action) {
21
+ case "setup": {
22
+ // Launch headed browser for manual login
23
+ const session = await setupSession("x");
24
+ return NextResponse.json({
25
+ status: "session_created",
26
+ createdAt: session.createdAt,
27
+ });
28
+ }
29
+
30
+ case "validate": {
31
+ const isValid = await validateSession("x");
32
+ return NextResponse.json({
33
+ status: isValid ? "valid" : "invalid",
34
+ isValid,
35
+ });
36
+ }
37
+
38
+ default:
39
+ return NextResponse.json(
40
+ { error: `Unknown action: ${action}` },
41
+ { status: 400 }
42
+ );
43
+ }
44
+ } catch (error) {
45
+ const message =
46
+ error instanceof Error ? error.message : "Browser session operation failed";
47
+ return NextResponse.json({ error: message }, { status: 500 });
48
+ }
49
+ }
50
+
51
+ /**
52
+ * GET /api/platforms/x/browser-session
53
+ * Check browser session status.
54
+ */
55
+ export async function GET() {
56
+ const exists = hasSession("x");
57
+
58
+ if (!exists) {
59
+ return NextResponse.json({
60
+ hasSession: false,
61
+ });
62
+ }
63
+
64
+ const session = loadSession("x");
65
+ return NextResponse.json({
66
+ hasSession: true,
67
+ lastValidatedAt: session?.lastValidatedAt ?? null,
68
+ createdAt: session?.createdAt ?? null,
69
+ });
70
+ }
71
+
72
+ /**
73
+ * DELETE /api/platforms/x/browser-session
74
+ * Clear stored browser session.
75
+ */
76
+ export async function DELETE() {
77
+ clearSession("x");
78
+ return NextResponse.json({ status: "cleared" });
79
+ }
@@ -0,0 +1,130 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { getXClientCredentials, saveXCredentials } from "@/lib/platforms/x/auth";
3
+ import { getPkceState } from "@/lib/platforms/x/pkce-store";
4
+ import { encrypt } from "@/lib/auth/crypto";
5
+ import {
6
+ createPlatformAccount,
7
+ getPlatformAccountByPlatform,
8
+ updatePlatformAccount,
9
+ } from "@/lib/db/queries/platform-accounts";
10
+ import type { PlatformCredentials } from "@/lib/platforms/adapter";
11
+
12
+ /**
13
+ * GET /api/platforms/x/callback?code=...&state=...
14
+ * OAuth 2.0 callback — exchanges code for tokens, fetches user profile, stores account.
15
+ */
16
+ export async function GET(req: NextRequest) {
17
+ const { searchParams } = new URL(req.url);
18
+ const code = searchParams.get("code");
19
+ const state = searchParams.get("state");
20
+ const error = searchParams.get("error");
21
+
22
+ // Handle OAuth denial
23
+ if (error) {
24
+ return NextResponse.redirect(
25
+ new URL(`/dashboard/settings?error=${encodeURIComponent(error)}`, req.url)
26
+ );
27
+ }
28
+
29
+ if (!code || !state) {
30
+ return NextResponse.redirect(
31
+ new URL("/dashboard/settings?error=missing_params", req.url)
32
+ );
33
+ }
34
+
35
+ // Validate PKCE state
36
+ const pkceEntry = getPkceState(state);
37
+ if (!pkceEntry) {
38
+ return NextResponse.redirect(
39
+ new URL("/dashboard/settings?error=invalid_state", req.url)
40
+ );
41
+ }
42
+ const { codeVerifier } = pkceEntry;
43
+
44
+ try {
45
+ const { clientId, clientSecret } = getXClientCredentials();
46
+ const origin = req.nextUrl.origin;
47
+ const redirectUri = `${origin}/api/platforms/x/callback`;
48
+
49
+ // Exchange authorization code for tokens
50
+ const tokenRes = await fetch("https://api.x.com/2/oauth2/token", {
51
+ method: "POST",
52
+ headers: {
53
+ "Content-Type": "application/x-www-form-urlencoded",
54
+ Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`,
55
+ },
56
+ body: new URLSearchParams({
57
+ grant_type: "authorization_code",
58
+ code,
59
+ redirect_uri: redirectUri,
60
+ code_verifier: codeVerifier,
61
+ }),
62
+ });
63
+
64
+ if (!tokenRes.ok) {
65
+ const errText = await tokenRes.text();
66
+ console.error("Token exchange failed:", errText);
67
+ return NextResponse.redirect(
68
+ new URL("/dashboard/settings?error=token_exchange_failed", req.url)
69
+ );
70
+ }
71
+
72
+ const tokenData = await tokenRes.json();
73
+ // X returns the actually-granted scopes in the token response
74
+ const grantedScopes: string = tokenData.scope ?? "";
75
+ const creds: PlatformCredentials = {
76
+ accessToken: tokenData.access_token,
77
+ refreshToken: tokenData.refresh_token,
78
+ expiresAt: Math.floor(Date.now() / 1000) + tokenData.expires_in,
79
+ grantedScopes,
80
+ };
81
+
82
+ // Fetch user profile from X API
83
+ const profileRes = await fetch(
84
+ "https://api.x.com/2/users/me?user.fields=name,username,description,location,url,profile_image_url,public_metrics",
85
+ {
86
+ headers: { Authorization: `Bearer ${creds.accessToken}` },
87
+ }
88
+ );
89
+
90
+ if (!profileRes.ok) {
91
+ const errText = await profileRes.text();
92
+ console.error("Profile fetch failed:", errText);
93
+ return NextResponse.redirect(
94
+ new URL("/dashboard/settings?error=profile_fetch_failed", req.url)
95
+ );
96
+ }
97
+
98
+ const profileData = await profileRes.json();
99
+ const xUser = profileData.data;
100
+
101
+ // Upsert platform account
102
+ const existing = getPlatformAccountByPlatform("x");
103
+ if (existing) {
104
+ updatePlatformAccount(existing.id, {
105
+ displayName: `@${xUser.username}`,
106
+ credentialsEncrypted: encrypt(JSON.stringify(creds)),
107
+ status: "active",
108
+ });
109
+ } else {
110
+ const account = createPlatformAccount({
111
+ platform: "x",
112
+ displayName: `@${xUser.username}`,
113
+ authType: "oauth",
114
+ credentialsEncrypted: encrypt(JSON.stringify(creds)),
115
+ status: "active",
116
+ });
117
+ // Save credentials after creation (already encrypted in createPlatformAccount call above)
118
+ void account; // credentials already stored inline
119
+ }
120
+
121
+ return NextResponse.redirect(
122
+ new URL("/dashboard/settings?connected=x", req.url)
123
+ );
124
+ } catch (err) {
125
+ console.error("OAuth callback error:", err);
126
+ return NextResponse.redirect(
127
+ new URL("/dashboard/settings?error=callback_failed", req.url)
128
+ );
129
+ }
130
+ }
@@ -0,0 +1,247 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { z } from "zod";
3
+ import { nanoid } from "nanoid";
4
+ import { getPlatformAccountByPlatform } from "@/lib/db/queries/platform-accounts";
5
+ import { createContentItem, updateContentItem, getContentItem, getThreadItems } from "@/lib/db/queries/content";
6
+ import {
7
+ getAuthenticatedUser,
8
+ postTweet,
9
+ postThread,
10
+ RateLimitError,
11
+ TierRestrictedError,
12
+ } from "@/lib/platforms/x/client";
13
+
14
+ const composeSchema = z.object({
15
+ tweets: z.array(z.string().min(1).max(280)).min(1).max(25),
16
+ saveAsDraft: z.boolean().optional(),
17
+ draftId: z.string().optional(),
18
+ });
19
+
20
+ /**
21
+ * POST /api/platforms/x/compose
22
+ * Compose and publish tweets/threads, or save as drafts.
23
+ */
24
+ export async function POST(req: NextRequest) {
25
+ try {
26
+ const account = getPlatformAccountByPlatform("x");
27
+ if (!account) {
28
+ return NextResponse.json({ error: "No X account connected" }, { status: 400 });
29
+ }
30
+
31
+ if (account.status === "needs_reauth") {
32
+ return NextResponse.json({ error: "X account needs re-authentication" }, { status: 401 });
33
+ }
34
+
35
+ const body = await req.json();
36
+ const { tweets, saveAsDraft, draftId } = composeSchema.parse(body);
37
+
38
+ const isThread = tweets.length > 1;
39
+ const threadId = draftId
40
+ ? getExistingThreadId(draftId)
41
+ : (isThread ? nanoid() : null);
42
+
43
+ // --- Save as draft ---
44
+ if (saveAsDraft) {
45
+ const items = saveDraftItems(tweets, threadId, draftId, account.id);
46
+ return NextResponse.json({ success: true, draft: true, items });
47
+ }
48
+
49
+ // --- Publish ---
50
+ if (isThread) {
51
+ const result = await postThread(account.id, tweets);
52
+ const me = await getAuthenticatedUser(account.id);
53
+
54
+ // Create content items for each posted tweet
55
+ const items = result.posted.map((tweet, i) => {
56
+ const item = createContentItem(
57
+ {
58
+ body: tweets[i],
59
+ contentType: i === 0 ? "thread" : "reply",
60
+ status: "published",
61
+ origin: "authored",
62
+ direction: "outbound",
63
+ platformAccountId: account.id,
64
+ threadId: threadId!,
65
+ parentItemId: i > 0 ? undefined : undefined, // linked via threadId
66
+ },
67
+ {
68
+ platformAccountId: account.id,
69
+ platformPostId: tweet.id,
70
+ platformUrl: `https://x.com/${me.username}/status/${tweet.id}`,
71
+ publishedAt: Math.floor(Date.now() / 1000),
72
+ status: "published",
73
+ engagementSnapshot: JSON.stringify({
74
+ likes: 0,
75
+ replies: 0,
76
+ retweets: 0,
77
+ quotes: 0,
78
+ }),
79
+ }
80
+ );
81
+ return item;
82
+ });
83
+
84
+ // Clean up draft items if publishing from a draft
85
+ if (draftId) {
86
+ cleanupDraftItems(draftId);
87
+ }
88
+
89
+ if (result.error) {
90
+ return NextResponse.json({
91
+ success: true,
92
+ partial: true,
93
+ error: result.error,
94
+ published: items.length,
95
+ total: tweets.length,
96
+ items,
97
+ });
98
+ }
99
+
100
+ return NextResponse.json({ success: true, items });
101
+ }
102
+
103
+ // Single tweet
104
+ const tweet = await postTweet(account.id, tweets[0]);
105
+ const me = await getAuthenticatedUser(account.id);
106
+
107
+ const item = createContentItem(
108
+ {
109
+ body: tweets[0],
110
+ contentType: "post",
111
+ status: "published",
112
+ origin: "authored",
113
+ direction: "outbound",
114
+ platformAccountId: account.id,
115
+ },
116
+ {
117
+ platformAccountId: account.id,
118
+ platformPostId: tweet.id,
119
+ platformUrl: `https://x.com/${me.username}/status/${tweet.id}`,
120
+ publishedAt: Math.floor(Date.now() / 1000),
121
+ status: "published",
122
+ engagementSnapshot: JSON.stringify({
123
+ likes: 0,
124
+ replies: 0,
125
+ retweets: 0,
126
+ quotes: 0,
127
+ }),
128
+ }
129
+ );
130
+
131
+ // Clean up draft if publishing from one
132
+ if (draftId) {
133
+ cleanupDraftItems(draftId);
134
+ }
135
+
136
+ return NextResponse.json({ success: true, items: [item] });
137
+ } catch (error) {
138
+ if (error instanceof z.ZodError) {
139
+ return NextResponse.json({ error: error.errors }, { status: 400 });
140
+ }
141
+ if (error instanceof RateLimitError) {
142
+ return NextResponse.json(
143
+ { error: `Rate limited. Try again in ${error.retryAfter} seconds.`, retryAfter: error.retryAfter },
144
+ { status: 429 }
145
+ );
146
+ }
147
+ if (error instanceof TierRestrictedError) {
148
+ return NextResponse.json(
149
+ { error: "Posting requires a higher X API tier.", code: "TIER_RESTRICTED" },
150
+ { status: 403 }
151
+ );
152
+ }
153
+ const message = error instanceof Error ? error.message : "Compose failed";
154
+ return NextResponse.json({ error: message }, { status: 500 });
155
+ }
156
+ }
157
+
158
+ /** Get existing threadId from a draft's first content item. */
159
+ function getExistingThreadId(draftId: string): string | null {
160
+ const item = getContentItem(draftId);
161
+ return item?.threadId ?? null;
162
+ }
163
+
164
+ /** Save tweets as draft content items. If draftId provided, update existing draft. */
165
+ function saveDraftItems(
166
+ tweets: string[],
167
+ threadId: string | null,
168
+ draftId: string | undefined,
169
+ accountId: string
170
+ ) {
171
+ const isThread = tweets.length > 1;
172
+
173
+ // If updating existing draft, update the first item and handle additions/removals
174
+ if (draftId) {
175
+ const existing = getContentItem(draftId);
176
+ if (existing) {
177
+ const existingThreadItems = existing.threadId
178
+ ? getThreadItems(existing.threadId)
179
+ : [existing];
180
+
181
+ // Update existing items with new text, create new items if needed
182
+ const updatedItems = [];
183
+ for (let i = 0; i < tweets.length; i++) {
184
+ if (i < existingThreadItems.length) {
185
+ // Update existing item
186
+ const updated = updateContentItem(existingThreadItems[i].id, {
187
+ body: tweets[i],
188
+ contentType: isThread ? (i === 0 ? "thread" : "reply") : "post",
189
+ });
190
+ if (updated) updatedItems.push(updated);
191
+ } else {
192
+ // Create new item for additional tweets
193
+ const item = createContentItem({
194
+ body: tweets[i],
195
+ contentType: isThread ? "reply" : "post",
196
+ status: "draft",
197
+ origin: "authored",
198
+ direction: "outbound",
199
+ platformAccountId: accountId,
200
+ threadId: threadId,
201
+ });
202
+ updatedItems.push(item);
203
+ }
204
+ }
205
+
206
+ // Delete excess items if the new draft has fewer tweets
207
+ for (let i = tweets.length; i < existingThreadItems.length; i++) {
208
+ updateContentItem(existingThreadItems[i].id, { status: "draft" });
209
+ // We can't delete easily, so just leave extras — they'll be cleaned up on publish
210
+ }
211
+
212
+ return updatedItems;
213
+ }
214
+ }
215
+
216
+ // Create new draft items
217
+ const items = tweets.map((text, i) => {
218
+ return createContentItem({
219
+ body: text,
220
+ contentType: isThread ? (i === 0 ? "thread" : "reply") : "post",
221
+ status: "draft",
222
+ origin: "authored",
223
+ direction: "outbound",
224
+ platformAccountId: accountId,
225
+ threadId: isThread ? threadId : null,
226
+ });
227
+ });
228
+
229
+ return items;
230
+ }
231
+
232
+ /** Remove draft items after successful publish. */
233
+ function cleanupDraftItems(draftId: string) {
234
+ const item = getContentItem(draftId);
235
+ if (!item) return;
236
+
237
+ if (item.threadId) {
238
+ const threadItems = getThreadItems(item.threadId);
239
+ for (const ti of threadItems) {
240
+ if (ti.status === "draft") {
241
+ updateContentItem(ti.id, { status: "published" });
242
+ }
243
+ }
244
+ } else {
245
+ updateContentItem(draftId, { status: "published" });
246
+ }
247
+ }
@@ -0,0 +1,113 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { z } from "zod";
3
+ import { getPlatformAccountByPlatform } from "@/lib/db/queries/platform-accounts";
4
+ import { createEngagement } from "@/lib/db/queries/engagements";
5
+ import {
6
+ getAuthenticatedUser,
7
+ likeTweet,
8
+ unlikeTweet,
9
+ retweet,
10
+ unretweet,
11
+ replyToTweet,
12
+ RateLimitError,
13
+ TierRestrictedError,
14
+ } from "@/lib/platforms/x/client";
15
+
16
+ const engageSchema = z.object({
17
+ action: z.enum(["like", "unlike", "retweet", "unretweet", "reply"]),
18
+ tweetId: z.string().min(1),
19
+ contentPostId: z.string().optional(),
20
+ text: z.string().optional(),
21
+ });
22
+
23
+ /**
24
+ * POST /api/platforms/x/engage
25
+ * Perform an engagement action (like, retweet, reply) on a tweet.
26
+ */
27
+ export async function POST(req: NextRequest) {
28
+ try {
29
+ const account = getPlatformAccountByPlatform("x");
30
+ if (!account) {
31
+ return NextResponse.json({ error: "No X account connected" }, { status: 400 });
32
+ }
33
+
34
+ if (account.status === "needs_reauth") {
35
+ return NextResponse.json({ error: "X account needs re-authentication" }, { status: 401 });
36
+ }
37
+
38
+ const body = await req.json();
39
+ const { action, tweetId, contentPostId, text } = engageSchema.parse(body);
40
+
41
+ if (action === "reply" && !text) {
42
+ return NextResponse.json({ error: "Reply text is required" }, { status: 400 });
43
+ }
44
+
45
+ // Get the authenticated user's platform ID (needed for like/retweet endpoints)
46
+ const me = await getAuthenticatedUser(account.id);
47
+
48
+ let result: unknown;
49
+
50
+ switch (action) {
51
+ case "like":
52
+ result = await likeTweet(account.id, me.id, tweetId);
53
+ break;
54
+ case "unlike":
55
+ result = await unlikeTweet(account.id, me.id, tweetId);
56
+ break;
57
+ case "retweet":
58
+ result = await retweet(account.id, me.id, tweetId);
59
+ break;
60
+ case "unretweet":
61
+ result = await unretweet(account.id, me.id, tweetId);
62
+ break;
63
+ case "reply":
64
+ result = await replyToTweet(account.id, tweetId, text!);
65
+ break;
66
+ }
67
+
68
+ // Record the engagement in the database
69
+ const engagementTypeMap = {
70
+ like: "like",
71
+ unlike: "like",
72
+ retweet: "retweet",
73
+ unretweet: "retweet",
74
+ reply: "reply",
75
+ } as const;
76
+
77
+ createEngagement({
78
+ contactId: null,
79
+ platformAccountId: account.id,
80
+ engagementType: engagementTypeMap[action],
81
+ direction: "outbound",
82
+ content: action === "reply" ? text ?? null : null,
83
+ templateId: null,
84
+ workflowRunId: null,
85
+ contentPostId: contentPostId ?? null,
86
+ platform: "x",
87
+ platformEngagementId: null,
88
+ threadId: null,
89
+ source: "manual",
90
+ platformData: JSON.stringify({ action, tweetId, result }),
91
+ });
92
+
93
+ return NextResponse.json({ success: true, action, result });
94
+ } catch (error) {
95
+ if (error instanceof z.ZodError) {
96
+ return NextResponse.json({ error: error.errors }, { status: 400 });
97
+ }
98
+ if (error instanceof RateLimitError) {
99
+ return NextResponse.json(
100
+ { error: `Rate limited. Try again in ${error.retryAfter} seconds.`, retryAfter: error.retryAfter },
101
+ { status: 429 }
102
+ );
103
+ }
104
+ if (error instanceof TierRestrictedError) {
105
+ return NextResponse.json(
106
+ { error: "This action requires a higher X API tier.", code: "TIER_RESTRICTED" },
107
+ { status: 403 }
108
+ );
109
+ }
110
+ const message = error instanceof Error ? error.message : "Engagement action failed";
111
+ return NextResponse.json({ error: message }, { status: 500 });
112
+ }
113
+ }
@@ -0,0 +1,79 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { getPlatformAccountByPlatform } from "@/lib/db/queries/platform-accounts";
3
+ import { listSyncCursors } from "@/lib/db/queries/sync";
4
+ import { hasSession } from "@/lib/browser/session";
5
+ import { syncXProfiles } from "@/lib/platforms/sync-x-profiles";
6
+ import { runSyncWorkflow } from "@/lib/workflows/run-sync-workflow";
7
+
8
+ /**
9
+ * POST /api/platforms/x/enrich
10
+ * Trigger browser-based profile enrichment for X contacts.
11
+ * Body: { contactIds?: string[], maxProfiles?: number }
12
+ */
13
+ export async function POST(req: NextRequest) {
14
+ try {
15
+ const account = getPlatformAccountByPlatform("x");
16
+ if (!account) {
17
+ return NextResponse.json(
18
+ { error: "No X platform account found" },
19
+ { status: 400 }
20
+ );
21
+ }
22
+
23
+ if (!hasSession("x")) {
24
+ return NextResponse.json(
25
+ { error: "No browser session configured. Set up in Settings." },
26
+ { status: 400 }
27
+ );
28
+ }
29
+
30
+ const body = await req.json().catch(() => ({}));
31
+ const maxProfiles = body.maxProfiles ?? 15;
32
+
33
+ const { workflowRun, syncResult } = await runSyncWorkflow({
34
+ workflowType: "enrich",
35
+ syncSubType: "x_enrich",
36
+ platformAccountId: account.id,
37
+ syncFunction: () =>
38
+ syncXProfiles(account.id, {
39
+ contactIds: body.contactIds,
40
+ maxProfiles,
41
+ }),
42
+ });
43
+
44
+ return NextResponse.json({ success: true, result: syncResult, workflowRunId: workflowRun.id });
45
+ } catch (error) {
46
+ const message = error instanceof Error ? error.message : "Enrichment failed";
47
+ return NextResponse.json({ error: message }, { status: 500 });
48
+ }
49
+ }
50
+
51
+ /**
52
+ * GET /api/platforms/x/enrich
53
+ * Get enrichment sync status.
54
+ */
55
+ export async function GET() {
56
+ const account = getPlatformAccountByPlatform("x");
57
+ if (!account) {
58
+ return NextResponse.json({ configured: false });
59
+ }
60
+
61
+ const hasBrowserSession = hasSession("x");
62
+
63
+ // Find the x_profiles sync cursor if it exists
64
+ const cursors = listSyncCursors(account.id);
65
+ const enrichCursor = cursors.find((c) => c.dataType === "x_profiles");
66
+
67
+ return NextResponse.json({
68
+ configured: true,
69
+ hasBrowserSession,
70
+ enrichment: enrichCursor
71
+ ? {
72
+ status: enrichCursor.syncStatus,
73
+ totalEnriched: enrichCursor.totalItemsSynced,
74
+ lastEnrichedAt: enrichCursor.lastSyncCompletedAt,
75
+ lastError: enrichCursor.lastError,
76
+ }
77
+ : null,
78
+ });
79
+ }