release: 2026-08-01
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
data/
|
||||
# Exclude local/dev scripts, but KEEP the build-critical ones: the Docker build runs
|
||||
# `npm run build` → prebuild → scripts/mirror-ee.js (open-core mirror; no-ops without ee/).
|
||||
# strip-mirrored-pages.js is kept alongside it for consistency. See docs/OPEN_CORE.md.
|
||||
scripts/*
|
||||
!scripts/mirror-ee.js
|
||||
!scripts/strip-mirrored-pages.js
|
||||
mcp/
|
||||
playwright-browsers/
|
||||
.env*
|
||||
npm-debug.log*
|
||||
.deploy/
|
||||
.publish/
|
||||
docs/
|
||||
CLAUDE.md
|
||||
@@ -0,0 +1,20 @@
|
||||
# Copy this file to .env.local and fill in your values
|
||||
|
||||
# NextAuth — generate secret with: openssl rand -base64 32
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
NEXTAUTH_SECRET=
|
||||
|
||||
# Password to log in to the Linki UI
|
||||
AUTH_PASSWORD=
|
||||
|
||||
# Internal secret for server-to-server API calls (e.g. the MCP server calling Linki's own
|
||||
# /api/* routes over loopback). Generate with: openssl rand -base64 32
|
||||
# Never sent to a browser or exposed externally — only Node processes on this host see it.
|
||||
INTERNAL_API_SECRET=
|
||||
|
||||
# SQLite database path (optional — defaults to ./linki.db in cwd)
|
||||
# When running via Docker, this is set automatically to /data/linki.db
|
||||
LINKI_DB_PATH=
|
||||
|
||||
# Set to "false" to show the browser window during automation (useful for debugging)
|
||||
HEADLESS=true
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/mcp/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files — ignore real secrets, but allow the example template
|
||||
.env
|
||||
.env.local
|
||||
.env.development
|
||||
.env.production
|
||||
credentials.json
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# SQLite database — contains personal data (LinkedIn cookies, lead lists)
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# Runtime log file
|
||||
log.txt
|
||||
|
||||
# Temporary / personal dev scripts — not useful to others
|
||||
scripts/tmp/
|
||||
|
||||
# Playwright browser cache (downloaded automatically on first run)
|
||||
/playwright-browsers
|
||||
|
||||
# Docker volume mount — contains the SQLite DB when running via docker-compose
|
||||
/data
|
||||
|
||||
# open-core: premium pages are mirrored from ee/pages/ into pages/ by
|
||||
# scripts/mirror-ee.js at predev/prebuild. The script writes a generated pages/.gitignore
|
||||
# (read natively by git) that ignores the mirrored copies + manifest, so they are never
|
||||
# tracked. Source of truth is ee/pages/**. NOTE: ee/ itself IS tracked in the private repo
|
||||
# and is stripped only at publish time (.publish/), never via gitignore. See docs/OPEN_CORE.md.
|
||||
|
||||
# credentials
|
||||
# One-off handoff doc (Opsily GHCR access instructions) — keep local, not tracked
|
||||
docs/OPSILY_GHCR_ACCESS.md
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
# Base image PINNED to a digest. The browser fingerprint LinkedIn sees is derived
|
||||
# from the Chromium build below, which depends on this base. A floating tag would
|
||||
# let a rebuild silently pull a newer Debian + newer Chromium, changing the
|
||||
# fingerprint and triggering a LinkedIn forced-logout (root cause of the Jun 2026
|
||||
# incident). Bump this digest only deliberately — it requires re-authenticating.
|
||||
FROM node:22-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2
|
||||
|
||||
# Chromium PINNED to an exact version for a stable browser fingerprint.
|
||||
# Do NOT unpin. Any Chromium change must be a conscious decision — it changes the
|
||||
# fingerprint and will log the LinkedIn session out (Jun 2026 incident, §15).
|
||||
#
|
||||
# We fetch Chromium from snapshot.debian.org (a frozen, immutable Debian archive)
|
||||
# instead of the live mirror. The live mirror only keeps the newest 1-2 Chromium
|
||||
# builds, so it ROTATED 149 out (and the current live build, 150.0.7871.46, is a
|
||||
# broken build that SIGTRAPs on launch — do not use it). The snapshot archive keeps
|
||||
# every version forever, so this pin builds reproducibly regardless of the live
|
||||
# mirror. Snapshot is served over http:// on purpose (its TLS cert isn't in the
|
||||
# base image's CA bundle; apt would silently fall back to the live mirror otherwise).
|
||||
# All three chromium packages are pinned together — chromium depends on
|
||||
# chromium-common/chromium-sandbox at the exact same version or apt errors.
|
||||
RUN echo "deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/20260620T000000Z/ bookworm main" > /etc/apt/sources.list \
|
||||
&& echo "deb [check-valid-until=no] http://snapshot.debian.org/archive/debian-security/20260620T000000Z/ bookworm-security main" >> /etc/apt/sources.list \
|
||||
&& echo "Acquire::Check-Valid-Until false;" > /etc/apt/apt.conf.d/99snapshot \
|
||||
&& apt-get -o Acquire::Retries=3 update && apt-get install -y --no-install-recommends \
|
||||
chromium=149.0.7827.155-1~deb12u1 \
|
||||
chromium-common=149.0.7827.155-1~deb12u1 \
|
||||
chromium-sandbox=149.0.7827.155-1~deb12u1 \
|
||||
libatk-bridge2.0-0 \
|
||||
libatk1.0-0 \
|
||||
libcups2 \
|
||||
libdbus-1-3 \
|
||||
libdrm2 \
|
||||
libgbm1 \
|
||||
libgtk-3-0 \
|
||||
libnss3 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxfixes3 \
|
||||
libxkbcommon0 \
|
||||
libxrandr2 \
|
||||
xdg-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Tell Playwright to use the system Chromium instead of downloading its own
|
||||
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG APP_VERSION=dev
|
||||
ENV APP_VERSION=$APP_VERSION
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Data directory — mount a volume here to persist the SQLite DB
|
||||
RUN mkdir -p /data && chown node:node /data
|
||||
ENV LINKI_DB_PATH=/data/linki.db
|
||||
|
||||
USER node
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "start"]
|
||||
@@ -0,0 +1,50 @@
|
||||
Linki Sustainable Use License
|
||||
|
||||
Copyright (c) 2025 Mohammad Al Jumaa
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to use,
|
||||
copy, modify, merge, publish, and distribute the Software, subject to the
|
||||
following conditions:
|
||||
|
||||
---
|
||||
|
||||
## Permitted Uses
|
||||
|
||||
You may use the Software for any purpose, including commercial purposes, provided
|
||||
that you are not offering it as a managed hosting service to third parties.
|
||||
|
||||
Specifically, you may:
|
||||
|
||||
- Run the Software on your own infrastructure (local machine or VPS) for personal
|
||||
or internal business use.
|
||||
- Modify the Software and contribute changes back to the project.
|
||||
- Use the Software to conduct outreach and automation for your own business.
|
||||
- Offer consulting, support, or integration services that involve the Software,
|
||||
as long as the Software itself is not the product being sold.
|
||||
|
||||
## Prohibited Uses
|
||||
|
||||
You may NOT:
|
||||
|
||||
- Offer the Software as a hosted or managed service to third parties — meaning
|
||||
you cannot provide access to a running instance of the Software to users who
|
||||
did not set it up themselves, in exchange for payment or free of charge.
|
||||
- Sublicense the Software for managed hosting purposes.
|
||||
|
||||
**Exception:** Opsily (opsily.com) is the sole authorized managed hosting
|
||||
provider for Linki. This exception is non-transferable.
|
||||
|
||||
---
|
||||
|
||||
## Conditions
|
||||
|
||||
Redistributions of source code must retain this license file in full.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,205 @@
|
||||
<p align="center">
|
||||
<img src="public/logo_linki.png" alt="Linki" width="56" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">Linki</h1>
|
||||
<p align="center">Open-source AI SDR for B2B outreach. LinkedIn sequences, cold email, and lead enrichment — self-hosted, no per-seat pricing.</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://opsily.com/hosting/linki?utm_source=github&utm_medium=readme&utm_campaign=linki">
|
||||
<img src="public/deploy-with-opsily.svg" alt="Deploy with Opsily" height="36" />
|
||||
</a>
|
||||
|
||||
<a href="https://discord.gg/8VPeFDJMn">
|
||||
<img src="https://img.shields.io/badge/Discord-Join%20our%20community-5865F2?logo=discord&logoColor=white" alt="Join our Discord" height="36" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<strong>▶ Full demo | </strong>
|
||||
<a href="https://youtu.be/S6n4RHULq3E">https://youtu.be/S6n4RHULq3E</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://youtu.be/S6n4RHULq3E">
|
||||
<img src="https://img.youtube.com/vi/S6n4RHULq3E/maxresdefault.jpg" alt="Click to watch the full demo on YouTube" width="720" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## What is Linki
|
||||
|
||||
Linki is an open-source AI SDR built for B2B founders and sales teams who want full control over their outreach. You build multichannel campaigns — LinkedIn sequences, cold email, or both — enrich your leads, and run everything on your own server. Your data never leaves your machine.
|
||||
|
||||
No SaaS middleman. No per-seat pricing. No black box.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### 📬 Multichannel Campaigns
|
||||
|
||||
- **LinkedIn + email in one campaign**: run LinkedIn actions (visit, connect, message) and email actions in parallel within a single campaign sequence
|
||||
- **Flexible step builder**: chain visit → connect → delay → message → cold email in any order, with configurable delays between steps
|
||||
- **Per-lead state tracking**: see exactly where every lead is across both channels, with a live pipeline view broken down by step and status
|
||||
- **A/B template pools**: assign multiple message templates to a step and rotate them automatically
|
||||
|
||||
### 🔐 Server-Side LinkedIn Login
|
||||
|
||||
- **Headless server-side authentication**: log in to LinkedIn directly on your server — no cookie-pasting — so the session is born under the exact browser fingerprint the automation runs with
|
||||
- **Handles LinkedIn's real challenges**: email/SMS codes **and** mobile-app device approval, plus LinkedIn's dynamic React login fields
|
||||
- **Longer, more stable sessions**: a pinned browser fingerprint keeps sessions alive far longer, enabling more complex and more frequent LinkedIn activity without forced logouts
|
||||
- **Captures the full session**: including the httpOnly Sales Navigator seat cookie that cookie-paste can't reach — so Sales Nav import and enrichment just work
|
||||
|
||||
### 🔍 Data & Enrichment
|
||||
|
||||
- **Sales Navigator import**: paste a list URL and Linki pulls in all leads with name, title, company, location, seniority, and LinkedIn URL
|
||||
- **CSV import**: bring in leads from anywhere else — a downloadable template covers LinkedIn URL, Sales Nav URL, email, and every contact field; each row just needs a LinkedIn URL and/or an email, so LinkedIn-only, email-only, and mixed lists all work
|
||||
- **Batched & scheduled imports**: large lists split across days automatically under a global daily cap, with human-like pacing so imports never look like a bot burst
|
||||
- **Apollo.io enrichment**: connect your Apollo API key and enrich any list with verified email addresses, company data, and seniority in one click
|
||||
- **Sales Nav profile enrichment**: pull richer profile data (headline, positions) for better targeting, gathered at runner time to stay under the radar
|
||||
- **Company model**: enriched company records (description, headcount, industry, location) linked from contacts; never duplicated across leads
|
||||
- **Contact detail pages**: full profile view with outreach history, enrichment status, and all campaign activity per contact
|
||||
|
||||
### 📥 Unified Inbox
|
||||
|
||||
- **Aggregated reply feed**: all email replies from active campaigns surface in one inbox regardless of which email account received them
|
||||
- **Email + LinkedIn reply detection**: the runner passively monitors both email and LinkedIn conversations and flags contacts who replied
|
||||
- **Reply filtering**: only shows contacts who actually replied; noise-free by design
|
||||
- **Inline reply composer**: read the full email thread and reply without leaving Linki
|
||||
|
||||
### ⚡ Reliability & Safety
|
||||
|
||||
- **Pinned browser fingerprint**: Chromium and its base image are version-pinned so a rebuild never changes the fingerprint LinkedIn sees — the single biggest cause of forced logouts, eliminated
|
||||
- **63% improvement in connection reliability**: rewritten LinkedIn automation with smarter DOM targeting, clipboard-based message delivery, and graceful handling of LinkedIn's UI variants
|
||||
- **Human-like import behavior**: lead list imports use randomized delays and pacing patterns to avoid triggering LinkedIn's bot detection
|
||||
- **Email account ramp-up**: gradually increase sending volume on new email accounts to build sender reputation safely
|
||||
- **Multiple accounts**: connect as many SMTP/IMAP email accounts and LinkedIn accounts as you need, each with its own daily limits
|
||||
- **Daily limits & auto-reschedule**: set max connections and messages per day; when a limit is hit the runner reschedules work for the next day automatically instead of stopping the campaign
|
||||
|
||||
### 📊 Analytics
|
||||
|
||||
- **Campaign pipeline view**: funnel breakdown by step with prospect counts per stage; click any step to drill into the exact contacts at that point
|
||||
- **Stats bar**: live counts for total prospects, in progress, completed, failed/skipped, connections sent, accepted, and messages sent
|
||||
- **Acceptance rate**: tracks connection request → acceptance ratio per campaign
|
||||
- **Dashboard overview**: cross-campaign summary of active runs, total contacts, recent activity
|
||||
|
||||
---
|
||||
|
||||
## What's new
|
||||
|
||||
- **CSV import** — bring your own leads from any source (a prior export, a website scrape, another tool). One template covers everything: LinkedIn URL, Sales Navigator URL, email, and every contact field — each row just needs a LinkedIn URL and/or an email
|
||||
- **Generic inboxes supported** — role-based addresses (`info@`, `contact@`, `sales@…`) import as regular contacts, ready for email-only campaigns
|
||||
- **Phone number field** — track a contact's phone alongside email and LinkedIn, editable inline on the contact page or via CSV/API
|
||||
- **Bulk contact deletion** — permanently delete contacts (and their run history) from the Contacts page, with a confirmation step
|
||||
- **Server-side headless LinkedIn login** — logs in on your server (email/SMS code **or** mobile-app approval), captures the full session incl. the Sales Navigator seat cookie, and unlocks longer, more frequent, more complex LinkedIn sessions
|
||||
- **Pinned browser fingerprint** — Chromium + base image are version-pinned so rebuilds never trigger a forced logout
|
||||
- **Batched & scheduled imports** — big Sales Nav lists split across days under a global daily cap with human-like pacing
|
||||
- **Better reply sync** — reply detection for **both** email and LinkedIn, with accurate accepted-connection sync via LinkedIn's own APIs
|
||||
- **Lead enrichment built in** — Apollo.io + Sales Nav profile enrichment, one-click on any list
|
||||
|
||||
---
|
||||
|
||||
## Hosting options
|
||||
|
||||
### One-click on Opsily (recommended)
|
||||
|
||||
[Opsily](https://opsily.com/hosting/linki?utm_source=github&utm_medium=readme&utm_campaign=linki) is the easiest way to run Linki. Create a server, deploy Linki from the app store, and you get a live URL in under a minute: no terminal required.
|
||||
|
||||
[](https://opsily.com/hosting/linki?utm_source=github&utm_medium=readme&utm_campaign=linki)
|
||||
|
||||
### Self-host with Docker
|
||||
|
||||
**1. Create your environment file**
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
Fill in `.env.local`:
|
||||
|
||||
```env
|
||||
# Public URL of the app (e.g. https://linki.yourdomain.com or http://localhost:3456)
|
||||
NEXTAUTH_URL=http://localhost:3456
|
||||
|
||||
# Random secret: generate with: openssl rand -base64 32
|
||||
NEXTAUTH_SECRET=your_random_secret_here
|
||||
|
||||
# Password to log in to the Linki UI
|
||||
AUTH_PASSWORD=your_password_here
|
||||
```
|
||||
|
||||
**2. Start the container**
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Or pull the image directly:
|
||||
|
||||
```bash
|
||||
docker run -d -p 3456:3000 \
|
||||
-e NEXTAUTH_URL=http://localhost:3456 \
|
||||
-e NEXTAUTH_SECRET=your_random_secret_here \
|
||||
-e AUTH_PASSWORD=your_password_here \
|
||||
-v $(pwd)/data:/data \
|
||||
moaljumaa/linki:latest
|
||||
```
|
||||
|
||||
Linki is now running at `http://localhost:3456`. The SQLite database is persisted in `./data/linki.db` on your host machine.
|
||||
|
||||
### Self-host manually (Node.js)
|
||||
|
||||
Requires Node.js 22+.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Add a LinkedIn account
|
||||
|
||||
Go to **Settings → LinkedIn** and add your account. Set conservative daily limits to start (recommended: 20 connections/day, 30 messages/day).
|
||||
|
||||
### 2. Authenticate LinkedIn
|
||||
|
||||
Click **Authenticate** and use **Server login** — Linki logs in on the server and walks you through LinkedIn's verification (email/SMS code or a tap in the LinkedIn mobile app). This captures a full, long-lived session, including the Sales Navigator seat needed for imports.
|
||||
|
||||
### 3. Add email accounts (optional)
|
||||
|
||||
Go to **Settings → Email** and add your SMTP/IMAP accounts. You can add as many as you need. Enable ramp-up on new accounts to build sender reputation gradually.
|
||||
|
||||
### 4. Connect Apollo (optional)
|
||||
|
||||
Go to **Settings → Integrations** and add your Apollo API key. Once connected, open any lead list and click **Enrich** to pull in verified emails and company data.
|
||||
|
||||
### 5. Import a lead list
|
||||
|
||||
Go to **Lists → New list** and paste a LinkedIn Sales Navigator list URL. Linki imports all leads with human-like pacing to avoid detection.
|
||||
|
||||
> **Note:** A LinkedIn Sales Navigator subscription is required to import leads.
|
||||
|
||||
### 6. Build and launch a campaign
|
||||
|
||||
Go to **Workflows → New workflow**. Add your steps — LinkedIn actions, email steps, delays — write your messages (or use templates and A/B pools), then create a run and launch.
|
||||
|
||||
---
|
||||
|
||||
## Managed edition
|
||||
|
||||
Everything above is open-source and runs standalone. If you'd rather not manage servers — or want the AI features on top — the fully-managed edition on **[Opsily](https://opsily.com/hosting/linki?utm_source=github&utm_medium=readme&utm_campaign=linki)** adds an **AI agent that writes every message per-lead** (any model via OpenRouter), a **hosted MCP endpoint** to drive Linki from Claude / Cursor, **automatic AI reply handling**, and **Sales Navigator InMail**. [Deploy in a minute →](https://opsily.com/hosting/linki?utm_source=github&utm_medium=readme&utm_campaign=linki)
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Linki is source-available under the [Linki Sustainable Use License](LICENSE).
|
||||
|
||||
**You can:** use it personally, use it for your business, self-host it on your own VPS or laptop, modify it, contribute to it.
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ReactNode, useCallback } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import Sidebar from "./Sidebar";
|
||||
import TourGate from "@/components/onboarding/TourGate";
|
||||
|
||||
const NO_LAYOUT_PATHS = ["/login"];
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const handleCollapse = useCallback((_c: boolean) => {}, []);
|
||||
|
||||
if (NO_LAYOUT_PATHS.includes(router.pathname)) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen overflow-hidden bg-base-100 flex">
|
||||
<TourGate />
|
||||
<Sidebar onCollapse={handleCollapse} />
|
||||
<main className="ml-13 flex-1 p-6 overflow-y-auto transition-[margin] duration-200">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
RiLayoutGridLine,
|
||||
RiFlowChart,
|
||||
RiFileList3Line,
|
||||
RiLogoutBoxLine,
|
||||
RiUserSettingsLine,
|
||||
RiArrowUpCircleLine,
|
||||
RiBuildingLine,
|
||||
RiContactsLine,
|
||||
RiInboxLine,
|
||||
RiMailCheckLine,
|
||||
RiCheckboxCircleLine,
|
||||
RiQuestionLine,
|
||||
RiCompassLine,
|
||||
RiPlayCircleLine,
|
||||
} from "react-icons/ri";
|
||||
import { pathToTourPage, replayPageTour } from "@/lib/tour";
|
||||
|
||||
const LEARNING_PLAYLIST_URL = "https://www.youtube.com/playlist?list=PLBf6xNJOmsIQ";
|
||||
|
||||
const mainNav = [
|
||||
{ href: "/", label: "Dashboard", icon: RiLayoutGridLine, color: "#5aa2ff", tour: "nav-dashboard" },
|
||||
{ href: "/lists", label: "Lists", icon: RiFileList3Line, color: "#32d583", tour: "nav-lists" },
|
||||
{ href: "/contacts", label: "Contacts", icon: RiContactsLine, color: "#34d399", tour: "nav-contacts" },
|
||||
{ href: "/companies", label: "Companies", icon: RiBuildingLine, color: "#a78bfa", tour: "nav-companies" },
|
||||
{ href: "/workflows", label: "Campaigns", icon: RiFlowChart, color: "#f4b740", tour: "nav-workflows" },
|
||||
{ href: "/inbox", label: "Inbox", icon: RiInboxLine, color: "#38bdf8", tour: "nav-inbox" },
|
||||
{ href: "/email-health", label: "Email Health", icon: RiMailCheckLine, color: "#f4b740", tour: "nav-email-health" },
|
||||
];
|
||||
|
||||
// Nav items that only exist in the commercial build (ee/). Filtered out when premium absent.
|
||||
const premiumNav = [
|
||||
{ href: "/todos", label: "Todos", icon: RiCheckboxCircleLine, color: "#fb923c", tour: "nav-todos" },
|
||||
];
|
||||
|
||||
export const SIDEBAR_WIDTH_EXPANDED = 52;
|
||||
export const SIDEBAR_WIDTH_COLLAPSED = 52;
|
||||
|
||||
export default function Sidebar({ onCollapse }: { onCollapse?: (collapsed: boolean) => void }) {
|
||||
const router = useRouter();
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const [latestVersion, setLatestVersion] = useState<string | null>(null);
|
||||
const [currentVersion, setCurrentVersion] = useState<string | null>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const helpRef = useRef<HTMLDivElement>(null);
|
||||
const tourPage = pathToTourPage(router.pathname);
|
||||
// Open-core: hide premium-only nav (Todos) in the public build.
|
||||
const [hasPremium, setHasPremium] = useState(true);
|
||||
const nav = hasPremium ? [...mainNav, ...premiumNav] : mainNav;
|
||||
|
||||
useEffect(() => {
|
||||
if (!helpOpen) return;
|
||||
function onClick(e: MouseEvent) {
|
||||
if (helpRef.current && !helpRef.current.contains(e.target as Node)) setHelpOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", onClick);
|
||||
return () => document.removeEventListener("mousedown", onClick);
|
||||
}, [helpOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
onCollapse?.(false);
|
||||
}, [onCollapse]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/premium-status").then((r) => r.ok ? r.json() : null)
|
||||
.then((d) => { if (d) setHasPremium(!!d.hasPremium); }).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/system/update")
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
setCurrentVersion(d.current ?? null);
|
||||
if (d.updateAvailable) {
|
||||
setUpdateAvailable(true);
|
||||
setLatestVersion(d.latest);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
function isActive(href: string) {
|
||||
if (href === "/") return router.pathname === "/";
|
||||
if (href === "/settings") {
|
||||
return ["/settings", "/accounts"].some((p) => router.pathname.startsWith(p));
|
||||
}
|
||||
return router.pathname.startsWith(href);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="fixed top-0 left-0 h-screen w-13 z-20 flex"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
{/* Icon rail — always visible */}
|
||||
<div className="w-13 shrink-0 bg-base-200 border-r border-base-300/40 flex flex-col h-full">
|
||||
{/* Logo */}
|
||||
<div className="shrink-0 h-13 flex items-center justify-center border-b border-base-300/40">
|
||||
<Image src="/logo_linki.png" alt="Linki" width={22} height={22} className="rounded-md opacity-80" />
|
||||
</div>
|
||||
|
||||
{/* Main nav icons */}
|
||||
<nav className="flex-1 py-3 flex flex-col gap-0.5 px-1.5 overflow-hidden">
|
||||
{nav.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
data-tour={item.tour}
|
||||
title={!hovered ? item.label : undefined}
|
||||
className={`flex items-center justify-center h-9 rounded-lg transition-colors ${
|
||||
active
|
||||
? "text-base-content"
|
||||
: "text-base-content/40 hover:text-base-content/70 hover:bg-base-300/40"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="w-6 h-6 rounded-md flex items-center justify-center"
|
||||
style={{ background: active ? `${item.color}22` : "transparent" }}
|
||||
>
|
||||
<item.icon size={14} style={{ color: active ? item.color : "currentColor" }} />
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Update dot */}
|
||||
{updateAvailable && (
|
||||
<div className="flex justify-center mb-2" title={`v${latestVersion} available`}>
|
||||
<RiArrowUpCircleLine size={15} className="text-warning" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom icons */}
|
||||
<div className="pb-3 border-t border-base-300/40 pt-3 flex flex-col gap-0.5 px-1.5">
|
||||
<div className="relative" ref={helpRef}>
|
||||
<button
|
||||
onClick={() => setHelpOpen((v) => !v)}
|
||||
title={!hovered ? "Help" : undefined}
|
||||
className={`flex items-center justify-center h-9 rounded-lg transition-colors w-full ${
|
||||
helpOpen ? "text-base-content bg-base-300/40" : "text-base-content/40 hover:text-base-content/70 hover:bg-base-300/40"
|
||||
}`}
|
||||
>
|
||||
<span className="w-6 h-6 rounded-md flex items-center justify-center">
|
||||
<RiQuestionLine size={14} />
|
||||
</span>
|
||||
</button>
|
||||
{helpOpen && (
|
||||
<div
|
||||
className="absolute left-11 bottom-0 w-48 bg-base-200 border border-base-300/50 rounded-xl shadow-xl py-1.5 flex flex-col z-30"
|
||||
>
|
||||
{tourPage && (
|
||||
<button
|
||||
onClick={() => { replayPageTour(tourPage); setHelpOpen(false); }}
|
||||
className="flex items-center gap-2.5 px-3 py-2 text-sm text-base-content/70 hover:text-base-content hover:bg-base-300/40 transition-colors text-left"
|
||||
>
|
||||
<RiCompassLine size={14} className="text-base-content/40 shrink-0" />
|
||||
Replay this page's tour
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href={LEARNING_PLAYLIST_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => setHelpOpen(false)}
|
||||
className="flex items-center gap-2.5 px-3 py-2 text-sm text-base-content/70 hover:text-base-content hover:bg-base-300/40 transition-colors"
|
||||
>
|
||||
<RiPlayCircleLine size={14} className="text-base-content/40 shrink-0" />
|
||||
Learning resources
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
const active = isActive("/settings");
|
||||
return (
|
||||
<Link
|
||||
href="/settings"
|
||||
data-tour="nav-settings"
|
||||
title={!hovered ? "Settings" : undefined}
|
||||
className={`flex items-center justify-center h-9 rounded-lg transition-colors ${
|
||||
active
|
||||
? "text-base-content"
|
||||
: "text-base-content/40 hover:text-base-content/70 hover:bg-base-300/40"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="w-6 h-6 rounded-md flex items-center justify-center"
|
||||
style={{ background: active ? "#a0a0a022" : "transparent" }}
|
||||
>
|
||||
<RiUserSettingsLine size={14} style={{ color: active ? "#a0a0a0" : "currentColor" }} />
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})()}
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||
title={!hovered ? "Sign out" : undefined}
|
||||
className="flex items-center justify-center h-9 rounded-lg text-base-content/40 hover:text-error/70 hover:bg-error/5 transition-colors"
|
||||
>
|
||||
<span className="w-6 h-6 rounded-md flex items-center justify-center">
|
||||
<RiLogoutBoxLine size={14} />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hover label panel — overlay, slides in */}
|
||||
<div
|
||||
className={`absolute left-13 top-0 h-full w-44 bg-base-200 border-r border-base-300/40 shadow-xl flex flex-col transition-all duration-150 ${
|
||||
hovered ? "opacity-100 translate-x-0 pointer-events-auto" : "opacity-0 -translate-x-2 pointer-events-none"
|
||||
}`}
|
||||
style={{ borderRadius: "0 12px 12px 0" }}
|
||||
>
|
||||
{/* Logo area */}
|
||||
<div className="shrink-0 h-13 flex items-center px-4 border-b border-base-300/40">
|
||||
<span className="text-base-content font-semibold text-sm tracking-wide">Linki</span>
|
||||
</div>
|
||||
|
||||
{/* Nav labels */}
|
||||
<nav className="flex-1 py-3 flex flex-col gap-0.5 px-2 overflow-hidden">
|
||||
{nav.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`relative flex items-center gap-2.5 px-3 h-9 rounded-lg text-sm transition-colors ${
|
||||
active
|
||||
? "bg-base-300 text-base-content"
|
||||
: "text-base-content/50 hover:text-base-content/80 hover:bg-base-300/40"
|
||||
}`}
|
||||
>
|
||||
{active && (
|
||||
<span
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 rounded-r-full"
|
||||
style={{ background: item.color }}
|
||||
/>
|
||||
)}
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Update banner */}
|
||||
{updateAvailable && (
|
||||
<div className="mx-2 mb-2 px-3 py-2 rounded-lg bg-warning/10 border border-warning/20">
|
||||
<div className="flex items-center gap-1.5 text-warning text-xs font-medium mb-0.5">
|
||||
<RiArrowUpCircleLine size={13} />
|
||||
Update available
|
||||
</div>
|
||||
<p className="text-warning/70 text-[11px] leading-snug">
|
||||
v{latestVersion} is out.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings + signout labels */}
|
||||
<div className="pb-3 border-t border-base-300/40 pt-3 flex flex-col gap-0.5 px-2">
|
||||
{(() => {
|
||||
const active = isActive("/settings");
|
||||
return (
|
||||
<Link
|
||||
href="/settings"
|
||||
className={`relative flex items-center gap-2.5 px-3 h-9 rounded-lg text-sm transition-colors ${
|
||||
active
|
||||
? "bg-base-300 text-base-content"
|
||||
: "text-base-content/50 hover:text-base-content/80 hover:bg-base-300/40"
|
||||
}`}
|
||||
>
|
||||
{active && (
|
||||
<span
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 rounded-r-full"
|
||||
style={{ background: "#a0a0a0" }}
|
||||
/>
|
||||
)}
|
||||
Settings
|
||||
</Link>
|
||||
);
|
||||
})()}
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||
className="flex items-center gap-2.5 px-3 h-9 rounded-lg text-sm text-base-content/50 hover:text-error/80 hover:bg-error/5 transition-colors w-full text-left"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Version + branding */}
|
||||
<div className="px-4 py-3 border-t border-base-300/40">
|
||||
{currentVersion && (
|
||||
<p className="text-[10px] text-base-content/25 mb-0.5">v{currentVersion}</p>
|
||||
)}
|
||||
<a
|
||||
href="https://opsily.com?utm_source=linki&utm_medium=app&utm_campaign=sidebar"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[10px] text-base-content/25 hover:text-base-content/50 transition-colors"
|
||||
>
|
||||
Built by opsily.com
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { startPageTour, getSeenTours, pathToTourPage, type TourPage } from "@/lib/tour";
|
||||
|
||||
// Mounted once in Layout. Auto-starts the tour for the current page the first time
|
||||
// the user lands on it (tracked server-side via /api/tour), one page at a time.
|
||||
export default function TourGate() {
|
||||
const router = useRouter();
|
||||
const [seen, setSeen] = useState<Set<TourPage> | null>(null);
|
||||
const startedRef = useRef<Set<TourPage>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
getSeenTours().then(setSeen);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!seen) return;
|
||||
const page = pathToTourPage(router.pathname);
|
||||
if (!page || seen.has(page) || startedRef.current.has(page)) return;
|
||||
// Let the page finish its own data fetch / render before spotlighting elements.
|
||||
// Guarded at fire-time (not schedule-time) so React StrictMode's dev double-effect
|
||||
// (which would otherwise clear this timeout via the first run's cleanup) can't
|
||||
// silently swallow the only scheduled start.
|
||||
const t = setTimeout(() => {
|
||||
if (startedRef.current.has(page)) return;
|
||||
startedRef.current.add(page);
|
||||
startPageTour(page);
|
||||
}, 600);
|
||||
return () => clearTimeout(t);
|
||||
}, [seen, router.pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { RiAddLine, RiCloseLine, RiFilter3Line } from "react-icons/ri";
|
||||
|
||||
// ─── Field definitions ───────────────────────────────────────────────────────
|
||||
|
||||
export type FieldType = "enum" | "presence" | "boolean" | "number" | "text";
|
||||
|
||||
export interface FieldDef {
|
||||
key: string;
|
||||
label: string;
|
||||
type: FieldType;
|
||||
options?: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export const FILTER_FIELDS: FieldDef[] = [
|
||||
{
|
||||
key: "connection_status",
|
||||
label: "Connection",
|
||||
type: "enum",
|
||||
options: [
|
||||
{ value: "not_contacted", label: "Not contacted" },
|
||||
{ value: "request_sent", label: "Request sent" },
|
||||
{ value: "connected", label: "Connected (1st)" },
|
||||
{ value: "messaged", label: "Messaged" },
|
||||
{ value: "replied", label: "Replied" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "seniority",
|
||||
label: "Seniority",
|
||||
type: "enum",
|
||||
options: [
|
||||
{ value: "owner", label: "Owner" },
|
||||
{ value: "founder", label: "Founder" },
|
||||
{ value: "c_suite", label: "C-Suite" },
|
||||
{ value: "partner", label: "Partner" },
|
||||
{ value: "vp", label: "VP" },
|
||||
{ value: "head", label: "Head" },
|
||||
{ value: "director", label: "Director" },
|
||||
{ value: "manager", label: "Manager" },
|
||||
{ value: "senior", label: "Senior" },
|
||||
{ value: "entry", label: "Entry" },
|
||||
{ value: "intern", label: "Intern" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "email_status",
|
||||
label: "Email status",
|
||||
type: "enum",
|
||||
options: [
|
||||
{ value: "verified", label: "Verified" },
|
||||
{ value: "unverified", label: "Unverified" },
|
||||
{ value: "invalid", label: "Invalid" },
|
||||
{ value: "catchall", label: "Catch-all" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "degree",
|
||||
label: "Degree",
|
||||
type: "enum",
|
||||
options: [
|
||||
{ value: "1", label: "1st" },
|
||||
{ value: "2", label: "2nd" },
|
||||
{ value: "3", label: "3rd" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "email",
|
||||
label: "Email",
|
||||
type: "presence",
|
||||
},
|
||||
{
|
||||
key: "apollo_enriched_at",
|
||||
label: "Apollo enriched",
|
||||
type: "presence",
|
||||
},
|
||||
{
|
||||
key: "connection_requested_at",
|
||||
label: "Connection request",
|
||||
type: "presence",
|
||||
},
|
||||
{
|
||||
key: "connected_at",
|
||||
label: "Connected at",
|
||||
type: "presence",
|
||||
},
|
||||
{
|
||||
key: "message_sent_at",
|
||||
label: "Message sent",
|
||||
type: "presence",
|
||||
},
|
||||
{
|
||||
key: "last_replied_at",
|
||||
label: "Replied",
|
||||
type: "presence",
|
||||
},
|
||||
{
|
||||
key: "open_link",
|
||||
label: "Open link",
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
key: "email_domain_catchall",
|
||||
label: "Catch-all domain",
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
key: "company_size",
|
||||
label: "Company size",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
key: "tenure_months",
|
||||
label: "Tenure (months)",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
key: "country",
|
||||
label: "Country",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "company_industry",
|
||||
label: "Industry",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "company",
|
||||
label: "Company",
|
||||
type: "text",
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Filter model ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type FilterOp =
|
||||
| "is" | "is_not" // enum / text
|
||||
| "contains" // text
|
||||
| "is_set" | "is_not_set" // presence
|
||||
| "is_true" | "is_false" // boolean
|
||||
| "gt" | "lt"; // number
|
||||
|
||||
export interface ActiveFilter {
|
||||
id: string;
|
||||
field: string;
|
||||
op: FilterOp;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
function opLabel(op: FilterOp): string {
|
||||
switch (op) {
|
||||
case "is": return "is";
|
||||
case "is_not": return "is not";
|
||||
case "contains": return "contains";
|
||||
case "is_set": return "is set";
|
||||
case "is_not_set": return "is not set";
|
||||
case "is_true": return "is true";
|
||||
case "is_false": return "is false";
|
||||
case "gt": return ">";
|
||||
case "lt": return "<";
|
||||
}
|
||||
}
|
||||
|
||||
function opsForField(field: FieldDef): FilterOp[] {
|
||||
switch (field.type) {
|
||||
case "enum": return ["is", "is_not"];
|
||||
case "presence": return ["is_set", "is_not_set"];
|
||||
case "boolean": return ["is_true", "is_false"];
|
||||
case "number": return ["gt", "lt"];
|
||||
case "text": return ["contains", "is", "is_not"];
|
||||
}
|
||||
}
|
||||
|
||||
function defaultOp(field: FieldDef): FilterOp {
|
||||
return opsForField(field)[0];
|
||||
}
|
||||
|
||||
// ─── Chip ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function FilterChip({
|
||||
filter,
|
||||
onRemove,
|
||||
onChange,
|
||||
}: {
|
||||
filter: ActiveFilter;
|
||||
onRemove: () => void;
|
||||
onChange: (updated: ActiveFilter) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const fieldDef = FILTER_FIELDS.find((f) => f.key === filter.field)!;
|
||||
|
||||
useEffect(() => {
|
||||
function handler(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
}
|
||||
if (open) document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [open]);
|
||||
|
||||
const ops = opsForField(fieldDef);
|
||||
const needsValue = !["is_set", "is_not_set", "is_true", "is_false"].includes(filter.op);
|
||||
|
||||
// Chip label
|
||||
const valueLabel =
|
||||
fieldDef.options?.find((o) => o.value === filter.value)?.label ?? filter.value ?? "";
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`inline-flex items-center gap-1.5 h-7 pl-2.5 pr-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
open
|
||||
? "bg-primary/15 border-primary/40 text-primary"
|
||||
: "bg-base-200 border-base-300/60 text-base-content/70 hover:border-base-300 hover:text-base-content"
|
||||
}`}
|
||||
>
|
||||
<span className="text-base-content/50">{fieldDef.label}</span>
|
||||
<span className="text-base-content/30 mx-0.5">·</span>
|
||||
<span className="text-base-content/70">{opLabel(filter.op)}</span>
|
||||
{needsValue && filter.value && (
|
||||
<>
|
||||
<span className="text-base-content/30 mx-0.5">·</span>
|
||||
<span className="font-semibold text-base-content">{valueLabel}</span>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
||||
className="ml-1 w-4 h-4 rounded flex items-center justify-center text-base-content/30 hover:text-base-content/70 hover:bg-base-300/60 transition-colors"
|
||||
>
|
||||
<RiCloseLine size={11} />
|
||||
</button>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute top-full left-0 mt-1.5 z-50 bg-base-200 border border-base-300/60 rounded-xl shadow-xl shadow-black/30 p-3 flex flex-col gap-2.5 min-w-55">
|
||||
{/* Field label */}
|
||||
<div className="text-xs font-semibold text-base-content/40 uppercase tracking-wider px-0.5">
|
||||
{fieldDef.label}
|
||||
</div>
|
||||
|
||||
{/* Operator */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ops.map((op) => (
|
||||
<button
|
||||
key={op}
|
||||
onClick={() => onChange({ ...filter, op, value: undefined })}
|
||||
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
|
||||
filter.op === op
|
||||
? "bg-primary/20 text-primary border border-primary/30"
|
||||
: "bg-base-300/50 text-base-content/50 border border-transparent hover:text-base-content hover:bg-base-300"
|
||||
}`}
|
||||
>
|
||||
{opLabel(op)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Value input */}
|
||||
{needsValue && (
|
||||
<div>
|
||||
{fieldDef.type === "enum" && fieldDef.options ? (
|
||||
<div className="flex flex-col gap-0.5 max-h-48 overflow-y-auto">
|
||||
{fieldDef.options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => { onChange({ ...filter, value: opt.value }); setOpen(false); }}
|
||||
className={`flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-xs text-left transition-colors ${
|
||||
filter.value === opt.value
|
||||
? "bg-primary/15 text-primary font-medium"
|
||||
: "text-base-content/70 hover:bg-base-300/60 hover:text-base-content"
|
||||
}`}
|
||||
>
|
||||
{filter.value === opt.value && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary shrink-0" />
|
||||
)}
|
||||
{filter.value !== opt.value && (
|
||||
<span className="w-1.5 h-1.5 shrink-0" />
|
||||
)}
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
autoFocus
|
||||
type={fieldDef.type === "number" ? "number" : "text"}
|
||||
placeholder="Value…"
|
||||
value={filter.value ?? ""}
|
||||
onChange={(e) => onChange({ ...filter, value: e.target.value })}
|
||||
onKeyDown={(e) => e.key === "Enter" && setOpen(false)}
|
||||
className="w-full bg-base-300/50 border border-base-300/80 rounded-lg px-2.5 py-1.5 text-xs text-base-content placeholder:text-base-content/30 focus:outline-none focus:border-primary/50"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Add filter dropdown ───────────────────────────────────────────────────────
|
||||
|
||||
function AddFilterButton({ onAdd, fields }: { onAdd: (field: string) => void; fields: FieldDef[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handler(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
}
|
||||
}
|
||||
if (open) document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [open]);
|
||||
|
||||
const filtered = fields.filter((f) =>
|
||||
f.label.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-lg text-xs font-medium text-base-content/40 border border-dashed border-base-300/60 hover:text-base-content/70 hover:border-base-300 hover:bg-base-200/50 transition-colors"
|
||||
>
|
||||
<RiAddLine size={12} />
|
||||
Add filter
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute top-full left-0 mt-1.5 z-50 bg-base-200 border border-base-300/60 rounded-xl shadow-xl shadow-black/30 w-52 overflow-hidden">
|
||||
<div className="p-2 border-b border-base-300/50">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="Search fields…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-base-300/50 border border-base-300/60 rounded-lg px-2.5 py-1.5 text-xs text-base-content placeholder:text-base-content/30 focus:outline-none focus:border-primary/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-60 overflow-y-auto p-1.5 flex flex-col gap-0.5">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-xs text-base-content/30 px-2 py-3 text-center">No fields found</div>
|
||||
) : (
|
||||
filtered.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
onClick={() => { onAdd(f.key); setOpen(false); setSearch(""); }}
|
||||
className="flex items-center gap-2 w-full px-2.5 py-1.5 rounded-lg text-xs text-left text-base-content/60 hover:text-base-content hover:bg-base-300/60 transition-colors"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-base-content/20 shrink-0" />
|
||||
{f.label}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── FilterBar ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface FilterBarProps {
|
||||
filters: ActiveFilter[];
|
||||
onChange: (filters: ActiveFilter[]) => void;
|
||||
fieldSubset?: string[];
|
||||
}
|
||||
|
||||
export default function FilterBar({ filters, onChange, fieldSubset }: FilterBarProps) {
|
||||
const visibleFields = fieldSubset
|
||||
? FILTER_FIELDS.filter((f) => fieldSubset.includes(f.key))
|
||||
: FILTER_FIELDS;
|
||||
|
||||
function addFilter(fieldKey: string) {
|
||||
const fieldDef = FILTER_FIELDS.find((f) => f.key === fieldKey)!;
|
||||
const newFilter: ActiveFilter = {
|
||||
id: Math.random().toString(36).slice(2),
|
||||
field: fieldKey,
|
||||
op: defaultOp(fieldDef),
|
||||
};
|
||||
onChange([...filters, newFilter]);
|
||||
}
|
||||
|
||||
function updateFilter(id: string, updated: ActiveFilter) {
|
||||
onChange(filters.map((f) => (f.id === id ? updated : f)));
|
||||
}
|
||||
|
||||
function removeFilter(id: string) {
|
||||
onChange(filters.filter((f) => f.id !== id));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{filters.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-base-content/30 pr-0.5">
|
||||
<RiFilter3Line size={11} />
|
||||
</span>
|
||||
)}
|
||||
{filters.map((f) => (
|
||||
<FilterChip
|
||||
key={f.id}
|
||||
filter={f}
|
||||
onRemove={() => removeFilter(f.id)}
|
||||
onChange={(updated) => updateFilter(f.id, updated)}
|
||||
/>
|
||||
))}
|
||||
<AddFilterButton onAdd={addFilter} fields={visibleFields} />
|
||||
{filters.length > 0 && (
|
||||
<button
|
||||
onClick={() => onChange([])}
|
||||
className="text-xs text-base-content/30 hover:text-base-content/60 transition-colors px-1 h-7"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Client-side filter logic (for list detail page) ─────────────────────────
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function applyFiltersClient<T>(items: T[], filters: ActiveFilter[]): T[] {
|
||||
if (filters.length === 0) return items;
|
||||
return items.filter((item) => filters.every((f) => matchesFilter(item as Record<string, unknown>, f)));
|
||||
}
|
||||
|
||||
function matchesFilter(item: Record<string, unknown>, filter: ActiveFilter): boolean {
|
||||
// connection_status is a derived field — handle specially
|
||||
if (filter.field === "connection_status") {
|
||||
const degree = item.degree as number | null;
|
||||
const crAt = item.connection_requested_at as string | null;
|
||||
const msgAt = item.message_sent_at as string | null;
|
||||
const repAt = item.last_replied_at as string | null;
|
||||
|
||||
let status: string;
|
||||
if (repAt) status = "replied";
|
||||
else if (msgAt) status = "messaged";
|
||||
else if (degree === 1) status = "connected";
|
||||
else if (crAt) status = "request_sent";
|
||||
else status = "not_contacted";
|
||||
|
||||
if (filter.op === "is") return status === filter.value;
|
||||
if (filter.op === "is_not") return status !== filter.value;
|
||||
return true;
|
||||
}
|
||||
|
||||
const raw = item[filter.field];
|
||||
|
||||
switch (filter.op) {
|
||||
case "is_set": return raw !== null && raw !== undefined && raw !== "";
|
||||
case "is_not_set": return raw === null || raw === undefined || raw === "";
|
||||
case "is_true": return raw === 1 || raw === true || raw === "1";
|
||||
case "is_false": return raw === 0 || raw === false || raw === "0" || raw === null || raw === undefined;
|
||||
case "is": return String(raw ?? "").toLowerCase() === String(filter.value ?? "").toLowerCase();
|
||||
case "is_not": return String(raw ?? "").toLowerCase() !== String(filter.value ?? "").toLowerCase();
|
||||
case "contains": return String(raw ?? "").toLowerCase().includes(String(filter.value ?? "").toLowerCase());
|
||||
case "gt": return Number(raw) > Number(filter.value ?? 0);
|
||||
case "lt": return Number(raw) < Number(filter.value ?? 0);
|
||||
default: return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Server-side filter → query params (for contacts page) ───────────────────
|
||||
|
||||
export function filtersToParams(filters: ActiveFilter[]): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
filters.forEach((f, i) => {
|
||||
params.set(`f[${i}][field]`, f.field);
|
||||
params.set(`f[${i}][op]`, f.op);
|
||||
if (f.value !== undefined) params.set(`f[${i}][value]`, f.value);
|
||||
});
|
||||
return params;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState } from "react";
|
||||
import { RiSearchLine, RiArrowDownSLine, RiArrowRightSLine } from "react-icons/ri";
|
||||
|
||||
export interface OrModel { id: string; name: string; provider: string; }
|
||||
|
||||
const PROVIDER_DISPLAY: Record<string, string> = {
|
||||
google: "Google",
|
||||
anthropic: "Anthropic",
|
||||
openai: "OpenAI",
|
||||
mistral: "Mistral",
|
||||
qwen: "Qwen (Alibaba)",
|
||||
alibaba: "Alibaba",
|
||||
};
|
||||
|
||||
const PROVIDER_ORDER = ["google", "anthropic", "openai", "mistral", "qwen", "alibaba"];
|
||||
|
||||
interface ModelPickerProps {
|
||||
models: OrModel[];
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function ModelPicker({ models, value, onChange, placeholder = "Select a model…" }: ModelPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [collapsedProviders, setCollapsedProviders] = useState<Set<string>>(new Set());
|
||||
|
||||
const isSearching = search.trim().length > 0;
|
||||
|
||||
const filtered = models.filter(m =>
|
||||
!isSearching || m.name.toLowerCase().includes(search.toLowerCase()) || m.id.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const byProvider: Record<string, OrModel[]> = {};
|
||||
for (const m of filtered) {
|
||||
(byProvider[m.provider] ??= []).push(m);
|
||||
}
|
||||
|
||||
const providerOrder = [
|
||||
...PROVIDER_ORDER.filter(p => byProvider[p]),
|
||||
...Object.keys(byProvider).filter(p => !PROVIDER_ORDER.includes(p)).sort(),
|
||||
];
|
||||
|
||||
const selectedModel = models.find(m => m.id === value);
|
||||
|
||||
function toggleProvider(p: string) {
|
||||
setCollapsedProviders(prev => {
|
||||
const n = new Set(prev);
|
||||
n.has(p) ? n.delete(p) : n.add(p);
|
||||
return n;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setOpen(!open); setSearch(""); }}
|
||||
className="w-full flex items-center justify-between px-3 py-2 rounded-lg bg-base-300/50 border border-base-300/50 text-sm text-left hover:border-base-300 transition-colors"
|
||||
>
|
||||
<span className={value ? "text-base-content" : "text-base-content/30"}>
|
||||
{selectedModel ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-base-content/40 text-xs">{PROVIDER_DISPLAY[selectedModel.provider] ?? selectedModel.provider}</span>
|
||||
<span>{selectedModel.name}</span>
|
||||
</span>
|
||||
) : placeholder}
|
||||
</span>
|
||||
<RiSearchLine size={13} className="text-base-content/30 shrink-0" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => { setOpen(false); setSearch(""); }} />
|
||||
<div className="absolute z-50 top-full mt-1 left-0 right-0 bg-base-300 border border-base-300/80 rounded-xl shadow-xl overflow-hidden">
|
||||
<div className="p-2 border-b border-base-300/50">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="Search models…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-base-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none placeholder:text-base-content/30"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{models.length === 0 ? (
|
||||
<p className="px-3 py-4 text-sm text-base-content/30 text-center">No OpenRouter key configured</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="px-3 py-4 text-sm text-base-content/30 text-center">No models match</p>
|
||||
) : providerOrder.map(provider => {
|
||||
const providerModels = byProvider[provider];
|
||||
const isCollapsed = !isSearching && collapsedProviders.has(provider);
|
||||
const displayName = PROVIDER_DISPLAY[provider] ?? provider.charAt(0).toUpperCase() + provider.slice(1);
|
||||
return (
|
||||
<div key={provider}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !isSearching && toggleProvider(provider)}
|
||||
className={`w-full flex items-center justify-between px-3 py-1.5 sticky top-0 bg-base-300/90 backdrop-blur-sm border-b border-base-300/30 ${isSearching ? "cursor-default" : "hover:bg-base-300 cursor-pointer"}`}
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-wider text-base-content/40 font-semibold">{displayName}</span>
|
||||
{!isSearching && (
|
||||
<span className="text-base-content/30">
|
||||
{isCollapsed ? <RiArrowRightSLine size={13} /> : <RiArrowDownSLine size={13} />}
|
||||
</span>
|
||||
)}
|
||||
{isSearching && (
|
||||
<span className="text-[10px] text-base-content/25">{providerModels.length}</span>
|
||||
)}
|
||||
</button>
|
||||
{!isCollapsed && providerModels.map(m => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => { onChange(m.id); setOpen(false); setSearch(""); }}
|
||||
className={`w-full text-left px-3 py-2 text-sm transition-colors hover:bg-base-200 ${value === m.id ? "text-primary font-medium" : "text-base-content/80"}`}
|
||||
>
|
||||
{m.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
services:
|
||||
linki:
|
||||
image: moaljumaa/linki:latest
|
||||
# To build from source instead: comment out image above and uncomment below
|
||||
# build: .
|
||||
ports:
|
||||
- "127.0.0.1:${PORT:-3456}:3000"
|
||||
volumes:
|
||||
# Persist SQLite database outside the container
|
||||
- ./data:/data
|
||||
env_file:
|
||||
- .env.local
|
||||
environment:
|
||||
# Runtime — always set by compose, do not put in .env.local
|
||||
- NODE_ENV=production
|
||||
- LINKI_DB_PATH=/data/linki.db
|
||||
|
||||
# --- copy the block below into .env.local and fill in values ---
|
||||
#
|
||||
# Public URL of the app (used by NextAuth for redirects)
|
||||
# NEXTAUTH_URL=
|
||||
#
|
||||
# Random secret for NextAuth session signing
|
||||
# Generate with: openssl rand -base64 32
|
||||
# NEXTAUTH_SECRET=
|
||||
#
|
||||
# Password to log in to the Linki UI
|
||||
# AUTH_PASSWORD=
|
||||
#
|
||||
# Set to "false" to show the browser during automation (debug only)
|
||||
# HEADLESS=true
|
||||
|
||||
restart: unless-stopped
|
||||
# Caps Linki's CPU so a busy runner can't starve other containers on a
|
||||
# small box. Raise/remove this if you have cores to spare.
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "1.5"
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,11 @@
|
||||
export async function register() {
|
||||
// Only run on the Node.js server runtime, not in the browser/edge
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
try {
|
||||
const { ensureGlobalRunnerStarted } = await import("@/lib/linkedin/runner");
|
||||
ensureGlobalRunnerStarted();
|
||||
} catch (err) {
|
||||
console.error("[instrumentation] Failed to start runner:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
const APOLLO_BASE = "https://api.apollo.io/api/v1";
|
||||
|
||||
export interface ApolloMatchResult {
|
||||
apollo_id: string;
|
||||
linkedin_url: string | null;
|
||||
headline: string | null;
|
||||
seniority: string | null;
|
||||
functions: string[] | null;
|
||||
departments: string[] | null;
|
||||
email: string | null;
|
||||
email_status: string | null;
|
||||
email_domain_catchall: boolean;
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
time_zone: string | null;
|
||||
positions_json: string | null;
|
||||
organization: {
|
||||
name: string | null;
|
||||
domain: string | null;
|
||||
industry: string | null;
|
||||
estimated_num_employees: number | null;
|
||||
short_description: string | null;
|
||||
location: string | null;
|
||||
linkedin_url: string | null;
|
||||
website_url: string | null;
|
||||
founded_year: number | null;
|
||||
logo_url: string | null;
|
||||
phone: string | null;
|
||||
annual_revenue_printed: string | null;
|
||||
technology_names: string[] | null;
|
||||
keywords: string[] | null;
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export async function matchPerson(
|
||||
linkedinUrl: string,
|
||||
apiKey: string
|
||||
): Promise<ApolloMatchResult | null> {
|
||||
const res = await fetch(`${APOLLO_BASE}/people/match`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Api-Key": apiKey },
|
||||
body: JSON.stringify({ linkedin_url: linkedinUrl, reveal_personal_emails: false }),
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
|
||||
const data = await res.json();
|
||||
const p = data.person;
|
||||
if (!p || !p.id) return null;
|
||||
|
||||
const org = p.organization ?? null;
|
||||
const location =
|
||||
org
|
||||
? [org.city, org.country].filter(Boolean).join(", ") || null
|
||||
: null;
|
||||
|
||||
// Map employment_history to positions_json format (same shape as Sales Nav enrichment)
|
||||
let positions_json: string | null = null;
|
||||
if (Array.isArray(p.employment_history) && p.employment_history.length > 0) {
|
||||
const positions = p.employment_history.map((e: Record<string, unknown>) => ({
|
||||
title: e.title ?? "",
|
||||
companyName: e.organization_name ?? "",
|
||||
current: e.current === true,
|
||||
startDate: e.start_date ?? undefined,
|
||||
endDate: e.end_date ?? undefined,
|
||||
}));
|
||||
positions_json = JSON.stringify(positions);
|
||||
}
|
||||
|
||||
return {
|
||||
apollo_id: p.id,
|
||||
linkedin_url: p.linkedin_url ?? null,
|
||||
headline: p.headline ?? null,
|
||||
seniority: p.seniority ?? null,
|
||||
functions: Array.isArray(p.functions) && p.functions.length > 0 ? p.functions : null,
|
||||
departments: Array.isArray(p.departments) && p.departments.length > 0 ? p.departments : null,
|
||||
email: p.email ?? null,
|
||||
email_status: p.email_status ?? null,
|
||||
email_domain_catchall: p.email_domain_catchall === true,
|
||||
city: p.city ?? null,
|
||||
country: p.country ?? null,
|
||||
time_zone: p.time_zone ?? null,
|
||||
positions_json,
|
||||
organization: org
|
||||
? {
|
||||
name: org.name ?? null,
|
||||
domain: org.primary_domain ?? null,
|
||||
industry: org.industry ?? null,
|
||||
estimated_num_employees: org.estimated_num_employees ?? null,
|
||||
short_description: org.short_description ?? null,
|
||||
location,
|
||||
linkedin_url: org.linkedin_url ?? null,
|
||||
website_url: org.website_url ?? null,
|
||||
founded_year: org.founded_year ?? null,
|
||||
logo_url: org.logo_url ?? null,
|
||||
phone: org.phone ?? org.sanitized_phone ?? null,
|
||||
annual_revenue_printed: org.annual_revenue_printed ?? null,
|
||||
technology_names: Array.isArray(org.technology_names) && org.technology_names.length > 0 ? org.technology_names : null,
|
||||
keywords: Array.isArray(org.keywords) && org.keywords.length > 0 ? org.keywords : null,
|
||||
city: org.city ?? null,
|
||||
country: org.country ?? null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { getToken } from "next-auth/jwt";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
const INTERNAL_HEADER = "x-internal-secret";
|
||||
|
||||
/**
|
||||
* Shared request-auth check used by proxy.ts to gate /api/*.
|
||||
*
|
||||
* Accepts either:
|
||||
* - a NextAuth session cookie (browser app) — verified via getToken().
|
||||
* - the internal service secret (server-to-server loopback calls, e.g. the MCP server's
|
||||
* tool handlers calling Linki's own /api/* routes — see ee/mcp/server.ts). This secret
|
||||
* never leaves the host: it's not sent to a browser and never crosses the public ngrok/
|
||||
* reverse-proxy path, only Node processes on 127.0.0.1 exchange it.
|
||||
*
|
||||
* Routes with their own independent auth (Authorization: Bearer, e.g. /api/mcp) are
|
||||
* excluded from the gate entirely in proxy.ts rather than special-cased here.
|
||||
*/
|
||||
export async function isAuthenticated(req: NextRequest): Promise<boolean> {
|
||||
if (await hasValidInternalSecret(req)) return true;
|
||||
|
||||
const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET });
|
||||
return token !== null;
|
||||
}
|
||||
|
||||
async function hasValidInternalSecret(req: NextRequest): Promise<boolean> {
|
||||
const expected = process.env.INTERNAL_API_SECRET;
|
||||
if (!expected) return false;
|
||||
|
||||
const provided = req.headers.get(INTERNAL_HEADER);
|
||||
if (!provided) return false;
|
||||
|
||||
return timingSafeEqual(provided, expected);
|
||||
}
|
||||
|
||||
// Constant-time string comparison via Web Crypto (available on both the Edge and Node.js
|
||||
// runtimes) so an internal-secret guess can't be timed byte-by-byte.
|
||||
async function timingSafeEqual(a: string, b: string): Promise<boolean> {
|
||||
const enc = new TextEncoder();
|
||||
const [digestA, digestB] = await Promise.all([
|
||||
crypto.subtle.digest("SHA-256", enc.encode(a)),
|
||||
crypto.subtle.digest("SHA-256", enc.encode(b)),
|
||||
]);
|
||||
const bytesA = new Uint8Array(digestA);
|
||||
const bytesB = new Uint8Array(digestB);
|
||||
let diff = 0;
|
||||
for (let i = 0; i < bytesA.length; i++) diff |= bytesA[i] ^ bytesB[i];
|
||||
return diff === 0;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "crypto";
|
||||
|
||||
// Encrypts secrets at rest (LinkedIn session cookies, email passwords, API keys) so a
|
||||
// leaked/copied linki.db doesn't hand those out in plaintext. Key is derived from
|
||||
// NEXTAUTH_SECRET (already a required, real secret) via HKDF — no new env var needed.
|
||||
//
|
||||
// Format: "v1:<iv-base64>:<authTag-base64>:<ciphertext-base64>". decryptSecret() passes
|
||||
// any value NOT in this format straight through unchanged, so pre-migration plaintext
|
||||
// rows keep working — see lib/db.ts's encryptLegacySecretsMigration.
|
||||
|
||||
const VERSION = "v1";
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
|
||||
function deriveKey(): Buffer {
|
||||
const secret = process.env.NEXTAUTH_SECRET;
|
||||
if (!secret) throw new Error("NEXTAUTH_SECRET must be set to encrypt/decrypt stored secrets");
|
||||
return Buffer.from(hkdfSync("sha256", secret, "", "linki-secret-encryption", 32));
|
||||
}
|
||||
|
||||
export function encryptSecret(plaintext: string): string {
|
||||
const key = deriveKey();
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return [VERSION, iv.toString("base64"), authTag.toString("base64"), ciphertext.toString("base64")].join(":");
|
||||
}
|
||||
|
||||
export function decryptSecret(value: string | null): string | null {
|
||||
if (value === null) return null;
|
||||
if (!isEncrypted(value)) return value; // not-yet-migrated plaintext — pass through
|
||||
|
||||
const [, ivB64, authTagB64, dataB64] = value.split(":");
|
||||
const key = deriveKey();
|
||||
const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(ivB64, "base64"));
|
||||
decipher.setAuthTag(Buffer.from(authTagB64, "base64"));
|
||||
const plaintext = Buffer.concat([decipher.update(Buffer.from(dataB64, "base64")), decipher.final()]);
|
||||
return plaintext.toString("utf8");
|
||||
}
|
||||
|
||||
export function isEncrypted(value: string): boolean {
|
||||
return value.startsWith(`${VERSION}:`);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import Papa from "papaparse";
|
||||
import type DatabaseType from "better-sqlite3";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
type DB = DatabaseType.Database;
|
||||
|
||||
// User-fillable target fields — everything else on `targets` (URNs, JSON blobs,
|
||||
// enrichment timestamps, apollo/automation internals) is system-owned and not
|
||||
// importable. Mirrors the PATCH /api/targets/[id] editable set.
|
||||
const EDITABLE_FIELDS = [
|
||||
"first_name", "last_name", "title", "company", "location",
|
||||
"city", "country", "phone", "headline", "summary", "notes",
|
||||
] as const;
|
||||
type EditableField = (typeof EDITABLE_FIELDS)[number];
|
||||
|
||||
// One template covers every case: a pure LinkedIn list, a pure email list (incl.
|
||||
// generic inboxes like info@company.com), or an export that already has both
|
||||
// (e.g. a list exported from another tool). Each row just needs linkedin_url
|
||||
// and/or email filled in — whatever the contact actually has.
|
||||
const TEMPLATE_COLUMNS = ["linkedin_url", "sales_nav_url", "email", ...EDITABLE_FIELDS] as const;
|
||||
|
||||
const SAMPLE_VALUES: Record<EditableField, string> = {
|
||||
first_name: "Jane",
|
||||
last_name: "Doe",
|
||||
title: "Head of Marketing",
|
||||
company: "Acme Inc",
|
||||
location: "Berlin, Germany",
|
||||
city: "Berlin",
|
||||
country: "Germany",
|
||||
phone: "+49 30 1234567",
|
||||
headline: "Head of Marketing @ Acme Inc",
|
||||
summary: "10+ years in B2B SaaS marketing.",
|
||||
notes: "Met at SaaStr 2026",
|
||||
};
|
||||
|
||||
export function buildCsvTemplate(): string {
|
||||
const sample = TEMPLATE_COLUMNS.map((c) =>
|
||||
c === "linkedin_url" ? "https://www.linkedin.com/in/example-profile/" :
|
||||
c === "sales_nav_url" ? "" :
|
||||
c === "email" ? "jane@acme.com" :
|
||||
SAMPLE_VALUES[c as EditableField]
|
||||
);
|
||||
return Papa.unparse({ fields: [...TEMPLATE_COLUMNS], data: [sample] });
|
||||
}
|
||||
|
||||
export interface CsvImportResult {
|
||||
imported: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
function normalizeLinkedinUrl(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed || !trimmed.includes("linkedin.com/in/")) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
interface ParsedRow {
|
||||
linkedin_url: string | null;
|
||||
sales_nav_url: string | null;
|
||||
email: string | null;
|
||||
full_name: string | null;
|
||||
fields: Record<EditableField, string | null>;
|
||||
}
|
||||
|
||||
function get(row: Record<string, string>, key: string): string | null {
|
||||
const v = row[key];
|
||||
const t = typeof v === "string" ? v.trim() : "";
|
||||
return t.length > 0 ? t : null;
|
||||
}
|
||||
|
||||
export function importCsv(db: DB, listId: string, csvText: string): CsvImportResult {
|
||||
const parsed = Papa.parse<Record<string, string>>(csvText, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
transformHeader: (h) => h.trim().toLowerCase().replace(/\s+/g, "_"),
|
||||
});
|
||||
|
||||
const errors: string[] = [];
|
||||
let imported = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
const rows: ParsedRow[] = [];
|
||||
parsed.data.forEach((raw, idx) => {
|
||||
const rowNum = idx + 2; // header is row 1
|
||||
const fields = Object.fromEntries(EDITABLE_FIELDS.map((f) => [f, get(raw, f)])) as Record<EditableField, string | null>;
|
||||
const full_name = [fields.first_name, fields.last_name].filter(Boolean).join(" ") || null;
|
||||
|
||||
const rawUrl = get(raw, "linkedin_url");
|
||||
const linkedin_url = rawUrl ? normalizeLinkedinUrl(rawUrl) : null;
|
||||
if (rawUrl && !linkedin_url) { errors.push(`Row ${rowNum}: "${rawUrl}" is not a valid linkedin.com/in/ URL`); return; }
|
||||
|
||||
const sales_nav_url = get(raw, "sales_nav_url");
|
||||
|
||||
const rawEmail = get(raw, "email");
|
||||
let email: string | null = null;
|
||||
if (rawEmail) {
|
||||
if (!EMAIL_RE.test(rawEmail)) { errors.push(`Row ${rowNum}: "${rawEmail}" is not a valid email`); return; }
|
||||
email = rawEmail.toLowerCase();
|
||||
}
|
||||
|
||||
if (!linkedin_url && !email) {
|
||||
errors.push(`Row ${rowNum}: needs at least a linkedin_url or an email`);
|
||||
return;
|
||||
}
|
||||
|
||||
rows.push({ linkedin_url, sales_nav_url, email, full_name, fields });
|
||||
});
|
||||
|
||||
const editableCols = [...EDITABLE_FIELDS, "full_name", "sales_nav_url"] as const;
|
||||
|
||||
const insertByLinkedin = db.prepare(`
|
||||
INSERT INTO targets (id, linkedin_url, email, sales_nav_url, full_name, ${EDITABLE_FIELDS.join(", ")})
|
||||
VALUES (?, ?, ?, ?, ?, ${EDITABLE_FIELDS.map(() => "?").join(", ")})
|
||||
ON CONFLICT(linkedin_url) DO UPDATE SET
|
||||
email = COALESCE(excluded.email, targets.email),
|
||||
${editableCols.map((c) => `${c} = COALESCE(excluded.${c}, targets.${c})`).join(",\n ")}
|
||||
`);
|
||||
const findByLinkedin = db.prepare("SELECT id FROM targets WHERE linkedin_url = ?");
|
||||
const findByEmail = db.prepare("SELECT id FROM targets WHERE email = ? LIMIT 1");
|
||||
const insertByEmail = db.prepare(`
|
||||
INSERT INTO targets (id, email, full_name, ${EDITABLE_FIELDS.join(", ")}, sales_nav_url)
|
||||
VALUES (?, ?, ?, ${EDITABLE_FIELDS.map(() => "?").join(", ")}, ?)
|
||||
`);
|
||||
const updateByEmail = db.prepare(`
|
||||
UPDATE targets SET
|
||||
${editableCols.map((c) => `${c} = COALESCE(?, ${c})`).join(",\n ")}
|
||||
WHERE id = ?
|
||||
`);
|
||||
const linkToList = db.prepare("INSERT OR IGNORE INTO list_targets (list_id, target_id) VALUES (?, ?)");
|
||||
|
||||
db.transaction(() => {
|
||||
for (const row of rows) {
|
||||
let targetId: string;
|
||||
let isNew: boolean;
|
||||
const fieldValues = EDITABLE_FIELDS.map((f) => row.fields[f]);
|
||||
|
||||
if (row.linkedin_url) {
|
||||
const existing = findByLinkedin.get(row.linkedin_url) as { id: string } | undefined;
|
||||
isNew = !existing;
|
||||
targetId = existing?.id ?? randomUUID();
|
||||
insertByLinkedin.run(targetId, row.linkedin_url, row.email, row.sales_nav_url, row.full_name, ...fieldValues);
|
||||
} else {
|
||||
const existing = findByEmail.get(row.email) as { id: string } | undefined;
|
||||
isNew = !existing;
|
||||
if (existing) {
|
||||
targetId = existing.id;
|
||||
updateByEmail.run(...fieldValues, row.full_name, row.sales_nav_url, targetId);
|
||||
} else {
|
||||
targetId = randomUUID();
|
||||
insertByEmail.run(targetId, row.email, row.full_name, ...fieldValues, row.sales_nav_url);
|
||||
}
|
||||
}
|
||||
|
||||
const linkResult = linkToList.run(listId, targetId);
|
||||
if (linkResult.changes > 0) {
|
||||
if (isNew) imported++; else updated++;
|
||||
} else {
|
||||
skipped++; // already in this list, no changes
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return { imported, updated, skipped, errors };
|
||||
}
|
||||
@@ -0,0 +1,883 @@
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
import { scheduleUpdateCheck } from "@/lib/update-check";
|
||||
import { encryptSecret, isEncrypted } from "@/lib/crypto";
|
||||
|
||||
const DB_PATH = process.env.LINKI_DB_PATH || path.join(process.cwd(), "linki.db");
|
||||
|
||||
let db: Database.Database;
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (!db) {
|
||||
db = new Database(DB_PATH);
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
initDb(db);
|
||||
runMigrations(db);
|
||||
scheduleUpdateCheck();
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
function runParallelTracksMigration(db: Database.Database) {
|
||||
// This backfill reads the legacy run_profiles.state column. If that column no longer
|
||||
// exists, dropDeprecatedRunProfileColumns has already run (a prior startup) and this
|
||||
// migration is moot — skip, otherwise the SELECT rp.state below throws "no such column".
|
||||
// (Fresh DBs hit this: state is created, dropped on one startup, then this guard would
|
||||
// otherwise re-enter on the next because no email-tracked steps exist yet.)
|
||||
try {
|
||||
const rpCols = db.prepare("PRAGMA table_info(run_profiles)").all() as { name: string }[];
|
||||
if (!rpCols.some(c => c.name === "state")) return;
|
||||
} catch { return; }
|
||||
|
||||
// Idempotent: skip if run_profile_tracks already has rows or workflow_steps already has email-tracked rows
|
||||
try {
|
||||
const alreadyRun = (db.prepare("SELECT COUNT(*) as c FROM run_profile_tracks").get() as { c: number }).c > 0
|
||||
|| (db.prepare("SELECT COUNT(*) as c FROM workflow_steps WHERE track = 'email'").get() as { c: number }).c > 0;
|
||||
if (alreadyRun) return;
|
||||
} catch { return; }
|
||||
|
||||
db.transaction(() => {
|
||||
// 1. Assign email step_type rows to the email track
|
||||
db.exec("UPDATE workflow_steps SET track = 'email' WHERE step_type = 'email'");
|
||||
// Also assign delay steps to the email track if the next non-delay step after them is an email step.
|
||||
// Without this, delays between email steps default to 'linkedin' and create a ghost linkedin track.
|
||||
db.exec(`
|
||||
UPDATE workflow_steps SET track = 'email'
|
||||
WHERE step_type = 'delay'
|
||||
AND (
|
||||
SELECT step_type FROM workflow_steps ws2
|
||||
WHERE ws2.workflow_id = workflow_steps.workflow_id
|
||||
AND ws2.step_order > workflow_steps.step_order
|
||||
AND ws2.step_type != 'delay'
|
||||
ORDER BY ws2.step_order ASC
|
||||
LIMIT 1
|
||||
) = 'email'
|
||||
`);
|
||||
|
||||
// 2. Re-number step_order densely within each (workflow_id, track), preserving original order
|
||||
const stepGroups = db.prepare(
|
||||
"SELECT id, workflow_id, track, step_order FROM workflow_steps ORDER BY workflow_id, track, step_order"
|
||||
).all() as Array<{ id: string; workflow_id: string; track: string; step_order: number }>;
|
||||
|
||||
// Group by (workflow_id, track) and assign dense 1-based order
|
||||
const grouped = new Map<string, Array<{ id: string; step_order: number }>>();
|
||||
for (const row of stepGroups) {
|
||||
const key = `${row.workflow_id}|${row.track}`;
|
||||
if (!grouped.has(key)) grouped.set(key, []);
|
||||
grouped.get(key)!.push({ id: row.id, step_order: row.step_order });
|
||||
}
|
||||
const updateStep = db.prepare("UPDATE workflow_steps SET step_order = ? WHERE id = ?");
|
||||
for (const steps of grouped.values()) {
|
||||
steps.sort((a, b) => a.step_order - b.step_order);
|
||||
steps.forEach((s, i) => updateStep.run(i + 1, s.id));
|
||||
}
|
||||
|
||||
// 3. Backfill run_profile_tracks from existing run_profiles
|
||||
// Only backfill if there are run_profiles rows to process
|
||||
const allProfiles = db.prepare(
|
||||
`SELECT rp.id, rp.run_id, rp.state, rp.current_step, rp.next_step_at,
|
||||
rp.error_message, rp.last_email_subject, rp.last_email_body, rp.last_linkedin_message,
|
||||
r.workflow_id
|
||||
FROM run_profiles rp
|
||||
JOIN runs r ON r.id = rp.run_id`
|
||||
).all() as Array<{
|
||||
id: string; run_id: string; state: string; current_step: number;
|
||||
next_step_at: string | null; error_message: string | null;
|
||||
last_email_subject: string | null; last_email_body: string | null;
|
||||
last_linkedin_message: string | null; workflow_id: string;
|
||||
}>;
|
||||
|
||||
if (allProfiles.length === 0) return;
|
||||
|
||||
// Load all workflow steps grouped by workflow_id, preserving their original ordering
|
||||
// We need the ORIGINAL step_order to map legacy current_step (0-based flat index) to tracks.
|
||||
// After the re-numbering above, step_order is now per-track. We stored the original order as the
|
||||
// sort key inside stepGroups above. Rebuild a per-workflow flat list from the original ordering.
|
||||
const workflowStepsOrig = db.prepare(
|
||||
"SELECT id, workflow_id, track, step_order FROM workflow_steps ORDER BY workflow_id, step_order"
|
||||
).all() as Array<{ id: string; workflow_id: string; track: string; step_order: number }>;
|
||||
|
||||
// For each workflow, build the flat list of steps in their original order (by new step_order within track, then track order linkedin < email)
|
||||
// BUT: we need the ORIGINAL flat order before re-numbering. Since we already re-numbered, we have to reconstruct.
|
||||
// Approach: the original flat step_order was cross-track. The re-numbered step_order is per-track and starts at 1.
|
||||
// We stored the original step_order in stepGroups (before modification). Use that.
|
||||
const origStepOrderMap = new Map<string, number>(); // step id → original flat step_order
|
||||
for (const row of stepGroups) {
|
||||
origStepOrderMap.set(row.id, row.step_order);
|
||||
}
|
||||
|
||||
// Per workflow: flat list of steps sorted by original step_order
|
||||
const workflowFlatSteps = new Map<string, Array<{ id: string; track: string; orig_order: number }>>();
|
||||
for (const step of workflowStepsOrig) {
|
||||
if (!workflowFlatSteps.has(step.workflow_id)) workflowFlatSteps.set(step.workflow_id, []);
|
||||
workflowFlatSteps.get(step.workflow_id)!.push({
|
||||
id: step.id,
|
||||
track: step.track,
|
||||
orig_order: origStepOrderMap.get(step.id) ?? step.step_order,
|
||||
});
|
||||
}
|
||||
for (const steps of workflowFlatSteps.values()) {
|
||||
steps.sort((a, b) => a.orig_order - b.orig_order);
|
||||
}
|
||||
|
||||
// Per workflow: which tracks exist
|
||||
const workflowTracks = new Map<string, Set<string>>();
|
||||
for (const step of workflowStepsOrig) {
|
||||
if (!workflowTracks.has(step.workflow_id)) workflowTracks.set(step.workflow_id, new Set());
|
||||
workflowTracks.get(step.workflow_id)!.add(step.track);
|
||||
}
|
||||
|
||||
const insertTrack = db.prepare(`
|
||||
INSERT OR IGNORE INTO run_profile_tracks
|
||||
(id, run_profile_id, track, state, current_step, last_step_at, next_step_at,
|
||||
error_message, last_email_subject, last_email_body, last_linkedin_message)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const rp of allProfiles) {
|
||||
const flatSteps = workflowFlatSteps.get(rp.workflow_id) ?? [];
|
||||
const tracks = workflowTracks.get(rp.workflow_id) ?? new Set(["linkedin"]);
|
||||
const terminalStates = new Set(["completed", "failed", "skipped"]);
|
||||
|
||||
if (terminalStates.has(rp.state)) {
|
||||
// Terminal profile — mark all tracks with the same terminal state
|
||||
for (const track of tracks) {
|
||||
insertTrack.run(
|
||||
randomUUID(), rp.id, track, rp.state, 0,
|
||||
null, rp.error_message ?? null, null, null, null
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Active profile — compute per-track completed step count
|
||||
// legacy current_step is 0-based index into the flat step list = steps already completed
|
||||
const completedCount = rp.current_step;
|
||||
const completedSteps = flatSteps.slice(0, completedCount);
|
||||
|
||||
const trackCompletedCount = new Map<string, number>();
|
||||
for (const track of tracks) trackCompletedCount.set(track, 0);
|
||||
for (const s of completedSteps) {
|
||||
trackCompletedCount.set(s.track, (trackCompletedCount.get(s.track) ?? 0) + 1);
|
||||
}
|
||||
|
||||
// Which track owned the step we were waiting on (index = completedCount)?
|
||||
const currentFlatStep = flatSteps[completedCount];
|
||||
const waitingTrack = currentFlatStep?.track ?? null;
|
||||
|
||||
for (const track of tracks) {
|
||||
const trackCurrentStep = trackCompletedCount.get(track) ?? 0;
|
||||
// next_step_at: only carry over to the track that was waiting; other track starts immediately
|
||||
const nextStepAt = track === waitingTrack ? rp.next_step_at : null;
|
||||
const lastEmailSubject = track === "email" ? rp.last_email_subject : null;
|
||||
const lastEmailBody = track === "email" ? rp.last_email_body : null;
|
||||
const lastLinkedinMessage = track === "linkedin" ? rp.last_linkedin_message : null;
|
||||
|
||||
insertTrack.run(
|
||||
randomUUID(), rp.id, track, rp.state, trackCurrentStep,
|
||||
nextStepAt, rp.error_message ?? null,
|
||||
lastEmailSubject, lastEmailBody, lastLinkedinMessage
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function dropDeprecatedRunProfileColumns(db: Database.Database) {
|
||||
// Idempotent: check if state column still exists on run_profiles
|
||||
try {
|
||||
const tableInfo = db.prepare("PRAGMA table_info(run_profiles)").all() as { name: string }[];
|
||||
const hasState = tableInfo.some(c => c.name === "state");
|
||||
if (!hasState) return; // already dropped
|
||||
} catch { return; }
|
||||
|
||||
// SQLite requires a table rebuild to drop columns
|
||||
try {
|
||||
db.exec(`
|
||||
PRAGMA foreign_keys = OFF;
|
||||
CREATE TABLE run_profiles_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
run_id TEXT REFERENCES runs(id) ON DELETE CASCADE,
|
||||
target_id TEXT REFERENCES targets(id),
|
||||
email_account_id TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(run_id, target_id)
|
||||
);
|
||||
INSERT INTO run_profiles_new (id, run_id, target_id, email_account_id, created_at)
|
||||
SELECT id, run_id, target_id, email_account_id, created_at FROM run_profiles;
|
||||
DROP TABLE run_profiles;
|
||||
ALTER TABLE run_profiles_new RENAME TO run_profiles;
|
||||
PRAGMA foreign_keys = ON;
|
||||
`);
|
||||
} catch { /* ignore — may already be done */ }
|
||||
}
|
||||
|
||||
function runMigrations(db: Database.Database) {
|
||||
// Add columns introduced after initial schema — safe to run on existing DBs
|
||||
const migrations = [
|
||||
"ALTER TABLE targets ADD COLUMN degree INTEGER",
|
||||
"ALTER TABLE targets ADD COLUMN connection_requested_at TEXT",
|
||||
"ALTER TABLE targets ADD COLUMN connected_at TEXT",
|
||||
"ALTER TABLE targets ADD COLUMN message_sent_at TEXT",
|
||||
"ALTER TABLE targets ADD COLUMN last_replied_at TEXT",
|
||||
"ALTER TABLE targets ADD COLUMN linkedin_member_urn TEXT",
|
||||
"ALTER TABLE targets ADD COLUMN sales_nav_url TEXT",
|
||||
"ALTER TABLE lists ADD COLUMN sales_nav_url TEXT",
|
||||
"ALTER TABLE accounts ADD COLUMN inbox_synced_at TEXT",
|
||||
"ALTER TABLE accounts ADD COLUMN active_hours_start INTEGER DEFAULT 9",
|
||||
"ALTER TABLE accounts ADD COLUMN active_hours_end INTEGER DEFAULT 18",
|
||||
"ALTER TABLE accounts ADD COLUMN timezone TEXT DEFAULT 'UTC'",
|
||||
"ALTER TABLE accounts ADD COLUMN working_days TEXT DEFAULT '1,2,3,4,5'",
|
||||
"ALTER TABLE workflow_steps ADD COLUMN connect_note TEXT",
|
||||
"ALTER TABLE workflow_steps ADD COLUMN message_body TEXT",
|
||||
"ALTER TABLE targets ADD COLUMN headline TEXT",
|
||||
"ALTER TABLE targets ADD COLUMN summary TEXT",
|
||||
"ALTER TABLE accounts ADD COLUMN accepted_sync_at TEXT",
|
||||
// Messaging identity (fsd_profile URN, "urn:li:fsd_profile:ACoAA...") — the
|
||||
// form the messaging GraphQL API returns. It is NOT convertible to the
|
||||
// numeric urn:li:member we store elsewhere, so we capture it on first reply
|
||||
// (matched by name) and then join by it directly on every later sync.
|
||||
"ALTER TABLE targets ADD COLUMN messaging_urn TEXT",
|
||||
"CREATE INDEX IF NOT EXISTS idx_targets_messaging_urn ON targets(messaging_urn)",
|
||||
// Boundary for the incremental connections sync: the createdAt of the newest
|
||||
// connection seen last run. NULL = never synced (first run does a full pass).
|
||||
"ALTER TABLE accounts ADD COLUMN connections_synced_through_ms INTEGER",
|
||||
"ALTER TABLE accounts ADD COLUMN li_connections INTEGER",
|
||||
"ALTER TABLE accounts ADD COLUMN li_pending INTEGER",
|
||||
"ALTER TABLE accounts ADD COLUMN li_profile_views INTEGER",
|
||||
"ALTER TABLE accounts ADD COLUMN li_stats_synced_at TEXT",
|
||||
`CREATE TABLE IF NOT EXISTS workflow_step_templates (
|
||||
step_id TEXT REFERENCES workflow_steps(id) ON DELETE CASCADE,
|
||||
template_id TEXT REFERENCES templates(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (step_id, template_id)
|
||||
)`,
|
||||
// Extended lead data from salesApiLeadSearch
|
||||
"ALTER TABLE targets ADD COLUMN object_urn TEXT",
|
||||
"ALTER TABLE targets ADD COLUMN open_link INTEGER DEFAULT 0",
|
||||
"ALTER TABLE targets ADD COLUMN company_industry TEXT",
|
||||
"ALTER TABLE targets ADD COLUMN company_location TEXT",
|
||||
"ALTER TABLE targets ADD COLUMN tenure_months INTEGER",
|
||||
"ALTER TABLE targets ADD COLUMN spotlight_badges TEXT",
|
||||
// Profile enrichment — populated by visiting their Sales Nav profile page
|
||||
"ALTER TABLE targets ADD COLUMN positions_json TEXT",
|
||||
"ALTER TABLE targets ADD COLUMN skills_json TEXT",
|
||||
"ALTER TABLE targets ADD COLUMN enriched_profile_at TEXT",
|
||||
// Email outreach fields
|
||||
"ALTER TABLE targets ADD COLUMN email TEXT",
|
||||
"ALTER TABLE targets ADD COLUMN email_replied_at TEXT",
|
||||
"ALTER TABLE targets ADD COLUMN company_id TEXT",
|
||||
// Email account on runs (nullable — only needed when workflow has email steps)
|
||||
"ALTER TABLE runs ADD COLUMN email_account_id TEXT REFERENCES email_accounts(id)",
|
||||
// Workflow step email fields
|
||||
"ALTER TABLE workflow_steps ADD COLUMN email_subject TEXT",
|
||||
"ALTER TABLE workflow_steps ADD COLUMN email_body TEXT",
|
||||
// Apollo enrichment fields
|
||||
"ALTER TABLE targets ADD COLUMN apollo_id TEXT",
|
||||
"ALTER TABLE targets ADD COLUMN seniority TEXT",
|
||||
"ALTER TABLE targets ADD COLUMN apollo_functions TEXT",
|
||||
"ALTER TABLE targets ADD COLUMN company_description TEXT",
|
||||
"ALTER TABLE targets ADD COLUMN company_size INTEGER",
|
||||
"ALTER TABLE targets ADD COLUMN apollo_enriched_at TEXT",
|
||||
"ALTER TABLE targets ADD COLUMN email_status TEXT",
|
||||
// Manual fields
|
||||
"ALTER TABLE targets ADD COLUMN notes TEXT",
|
||||
// Apollo extra person fields
|
||||
"ALTER TABLE targets ADD COLUMN city TEXT",
|
||||
"ALTER TABLE targets ADD COLUMN country TEXT",
|
||||
"ALTER TABLE targets ADD COLUMN time_zone TEXT",
|
||||
"ALTER TABLE targets ADD COLUMN apollo_departments TEXT",
|
||||
// Apollo extra company fields on companies table
|
||||
"ALTER TABLE companies ADD COLUMN founded_year INTEGER",
|
||||
"ALTER TABLE companies ADD COLUMN logo_url TEXT",
|
||||
"ALTER TABLE companies ADD COLUMN phone TEXT",
|
||||
"ALTER TABLE companies ADD COLUMN annual_revenue TEXT",
|
||||
"ALTER TABLE companies ADD COLUMN technology_names TEXT",
|
||||
"ALTER TABLE companies ADD COLUMN keywords TEXT",
|
||||
"ALTER TABLE companies ADD COLUMN city TEXT",
|
||||
"ALTER TABLE companies ADD COLUMN country TEXT",
|
||||
// Email signature
|
||||
"ALTER TABLE email_accounts ADD COLUMN signature TEXT",
|
||||
// Reply-To override — if set, outgoing emails include Reply-To header
|
||||
"ALTER TABLE email_accounts ADD COLUMN reply_to TEXT",
|
||||
// Ramp-up: start slow and increase sending volume over time
|
||||
"ALTER TABLE email_accounts ADD COLUMN ramp_up_enabled INTEGER DEFAULT 1",
|
||||
"ALTER TABLE email_accounts ADD COLUMN ramp_start_date TEXT",
|
||||
// Company description and employee count moved from targets to companies
|
||||
"ALTER TABLE companies ADD COLUMN description TEXT",
|
||||
"ALTER TABLE companies ADD COLUMN employee_count INTEGER",
|
||||
// AI agent columns on workflow steps
|
||||
"ALTER TABLE workflow_steps ADD COLUMN ai_enabled INTEGER DEFAULT 0",
|
||||
"ALTER TABLE workflow_steps ADD COLUMN ai_model TEXT",
|
||||
"ALTER TABLE workflow_steps ADD COLUMN ai_prompt TEXT",
|
||||
"ALTER TABLE workflow_steps ADD COLUMN ai_max_words INTEGER",
|
||||
// Email step position — which followup number (1 = cold email, 2 = first followup, etc.)
|
||||
"ALTER TABLE workflow_steps ADD COLUMN email_position INTEGER DEFAULT 1",
|
||||
// Agent default model (stored on agent_config)
|
||||
"ALTER TABLE agent_config ADD COLUMN default_model TEXT",
|
||||
// Email threading — store sent email message-id for reply threading (future use)
|
||||
"ALTER TABLE run_profiles ADD COLUMN last_email_subject TEXT",
|
||||
"ALTER TABLE run_profiles ADD COLUMN last_email_body TEXT",
|
||||
// LinkedIn message follow-up tracking
|
||||
"ALTER TABLE workflow_steps ADD COLUMN message_position INTEGER DEFAULT 1",
|
||||
"ALTER TABLE run_profiles ADD COLUMN last_linkedin_message TEXT",
|
||||
// Language for AI-generated content per step
|
||||
"ALTER TABLE workflow_steps ADD COLUMN ai_language TEXT DEFAULT 'English'",
|
||||
// Campaign-level prompt — per-workflow AI context (USP, persona, tone for this campaign)
|
||||
"ALTER TABLE workflows ADD COLUMN prompt TEXT",
|
||||
// Email domain invalid flag on companies — set when a bounce is detected for any contact at this company
|
||||
"ALTER TABLE companies ADD COLUMN email_domain_invalid INTEGER DEFAULT 0",
|
||||
// Apollo extra person fields
|
||||
"ALTER TABLE targets ADD COLUMN email_domain_catchall INTEGER DEFAULT 0",
|
||||
// Per-profile email account assignment (multi-account routing)
|
||||
"ALTER TABLE run_profiles ADD COLUMN email_account_id TEXT",
|
||||
// Backfill: copy email_account_id from runs → run_profiles for existing records
|
||||
`UPDATE run_profiles SET email_account_id = (SELECT email_account_id FROM runs WHERE runs.id = run_profiles.run_id) WHERE email_account_id IS NULL`,
|
||||
// Import job progress tracking
|
||||
`CREATE TABLE IF NOT EXISTS list_imports (
|
||||
id TEXT PRIMARY KEY,
|
||||
list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
phase TEXT,
|
||||
page INTEGER DEFAULT 0,
|
||||
total_pages INTEGER DEFAULT 0,
|
||||
count INTEGER DEFAULT 0,
|
||||
total INTEGER DEFAULT 0,
|
||||
imported INTEGER DEFAULT 0,
|
||||
skipped INTEGER DEFAULT 0,
|
||||
error TEXT,
|
||||
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
finished_at TEXT
|
||||
)`,
|
||||
// Parallel tracks: add track column to workflow_steps
|
||||
"ALTER TABLE workflow_steps ADD COLUMN track TEXT NOT NULL DEFAULT 'linkedin' CHECK(track IN ('linkedin', 'email'))",
|
||||
// Parallel tracks: create run_profile_tracks table
|
||||
`CREATE TABLE IF NOT EXISTS run_profile_tracks (
|
||||
id TEXT PRIMARY KEY,
|
||||
run_profile_id TEXT NOT NULL REFERENCES run_profiles(id) ON DELETE CASCADE,
|
||||
track TEXT NOT NULL CHECK(track IN ('linkedin', 'email')),
|
||||
state TEXT NOT NULL DEFAULT 'pending' CHECK(state IN ('pending', 'in_progress', 'completed', 'failed', 'skipped')),
|
||||
current_step INTEGER NOT NULL DEFAULT 0,
|
||||
last_step_at TEXT,
|
||||
next_step_at TEXT,
|
||||
error_message TEXT,
|
||||
last_email_subject TEXT,
|
||||
last_email_body TEXT,
|
||||
last_linkedin_message TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(run_profile_id, track)
|
||||
)`,
|
||||
"CREATE INDEX IF NOT EXISTS idx_run_profile_tracks_run_profile_id ON run_profile_tracks(run_profile_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_run_profile_tracks_state_next ON run_profile_tracks(state, next_step_at)",
|
||||
// Drop deprecated columns from run_profiles — all consumers now read from run_profile_tracks
|
||||
// SQLite does not support DROP COLUMN directly before 3.35; handled via table rebuild below
|
||||
// Separate IMAP credentials for custom mail providers where SMTP ≠ IMAP auth
|
||||
"ALTER TABLE email_accounts ADD COLUMN imap_username TEXT",
|
||||
"ALTER TABLE email_accounts ADD COLUMN imap_password TEXT",
|
||||
"ALTER TABLE workflows ADD COLUMN is_archived INTEGER NOT NULL DEFAULT 0",
|
||||
// Per-step signature override for email steps (null = use email account default)
|
||||
"ALTER TABLE workflow_steps ADD COLUMN email_signature TEXT",
|
||||
// CRM: todos per contact
|
||||
`CREATE TABLE IF NOT EXISTS todos (
|
||||
id TEXT PRIMARY KEY,
|
||||
target_id TEXT NOT NULL REFERENCES targets(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
due_date TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open', 'done')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`,
|
||||
"CREATE INDEX IF NOT EXISTS idx_todos_target_id ON todos(target_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_todos_status ON todos(status)",
|
||||
"ALTER TABLE todos ADD COLUMN description TEXT",
|
||||
// CRM: activity log per contact
|
||||
`CREATE TABLE IF NOT EXISTS activity_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
target_id TEXT NOT NULL REFERENCES targets(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL DEFAULT 'note' CHECK(type IN ('call', 'email', 'meeting', 'note', 'other')),
|
||||
body TEXT NOT NULL,
|
||||
logged_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`,
|
||||
"CREATE INDEX IF NOT EXISTS idx_activity_logs_target_id ON activity_logs(target_id)",
|
||||
// Reply classifier: captured email replies + classifier verdict + dispatcher result
|
||||
`CREATE TABLE IF NOT EXISTS email_replies (
|
||||
id TEXT PRIMARY KEY,
|
||||
target_id TEXT NOT NULL REFERENCES targets(id) ON DELETE CASCADE,
|
||||
run_id TEXT REFERENCES runs(id) ON DELETE SET NULL,
|
||||
from_email TEXT NOT NULL,
|
||||
subject TEXT,
|
||||
body_text TEXT NOT NULL,
|
||||
received_at TEXT NOT NULL,
|
||||
classified_at TEXT,
|
||||
classification_json TEXT,
|
||||
classification_error TEXT,
|
||||
dispatched_at TEXT,
|
||||
dispatch_result_json TEXT,
|
||||
manually_edited INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`,
|
||||
"CREATE INDEX IF NOT EXISTS idx_email_replies_target_id ON email_replies(target_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_email_replies_dispatched_at ON email_replies(dispatched_at)",
|
||||
// Reply classifier verdict stamped on the target — enables reply-rate-by-kind metrics
|
||||
"ALTER TABLE targets ADD COLUMN reply_kind TEXT",
|
||||
"ALTER TABLE targets ADD COLUMN inmail_sent_at TEXT",
|
||||
"ALTER TABLE targets ADD COLUMN posts_json TEXT", // recent posts from visit_profile
|
||||
"ALTER TABLE targets ADD COLUMN posts_scraped_at TEXT",
|
||||
// One-shot OOO reply context for the AI follow-up writer — set by the dispatcher,
|
||||
// read + cleared by the runner on the next email send. Distinct from last_email_body
|
||||
// (which holds the last email WE sent, used for follow-up threading).
|
||||
"ALTER TABLE run_profile_tracks ADD COLUMN pending_reply_context TEXT",
|
||||
// Removed the in-app chat agent (replaced by the hosted MCP endpoint at /api/mcp) — drop its tables.
|
||||
"DROP TABLE IF EXISTS chat_messages",
|
||||
"DROP TABLE IF EXISTS chat_sessions",
|
||||
// Batched/scheduled imports — split large lists across days under a daily cap.
|
||||
"ALTER TABLE list_imports ADD COLUMN account_id TEXT",
|
||||
"ALTER TABLE list_imports ADD COLUMN sales_nav_url TEXT",
|
||||
"ALTER TABLE list_imports ADD COLUMN scheduled_for TEXT", // 'YYYY-MM-DD'; NULL = run now
|
||||
"ALTER TABLE list_imports ADD COLUMN start_page INTEGER DEFAULT 1",
|
||||
"ALTER TABLE list_imports ADD COLUMN cap INTEGER", // max contacts for this batch
|
||||
"ALTER TABLE list_imports ADD COLUMN cancel_requested INTEGER DEFAULT 0",
|
||||
"ALTER TABLE list_imports ADD COLUMN batch_index INTEGER DEFAULT 1",
|
||||
"ALTER TABLE list_imports ADD COLUMN enrich INTEGER DEFAULT 0",
|
||||
"CREATE INDEX IF NOT EXISTS idx_list_imports_scheduled ON list_imports(status, scheduled_for)",
|
||||
// Simple app-wide key/value settings (e.g. daily_import_cap)
|
||||
`CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`,
|
||||
// Sales Nav InMail gets its own daily budget — separate from daily_message_limit,
|
||||
// since InMail (non-connections) and regular messages (connections) were being
|
||||
// gated off the same counter, starving one whenever the other was busy.
|
||||
"ALTER TABLE accounts ADD COLUMN daily_inmail_limit INTEGER DEFAULT 15",
|
||||
// Sales Nav search: persistent cache of resolved filter values (typeahead
|
||||
// ids). Dedup on (filter_type, id) — LinkedIn ids are stable (e.g. Berlin's
|
||||
// geoUrn never changes), so once resolved a value is reused forever with no
|
||||
// further live typeahead calls. Lives here (not ee/) per the open-core rule:
|
||||
// all migrations stay in lib/db.ts. Consumed only by the ee/ search feature.
|
||||
`CREATE TABLE IF NOT EXISTS search_filter_cache (
|
||||
id TEXT NOT NULL,
|
||||
filter_type TEXT NOT NULL,
|
||||
display_value TEXT NOT NULL,
|
||||
headline TEXT,
|
||||
query TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (filter_type, id)
|
||||
)`,
|
||||
"CREATE INDEX IF NOT EXISTS idx_sfc_type_query ON search_filter_cache(filter_type, query)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_sfc_type_display ON search_filter_cache(filter_type, display_value)",
|
||||
// CSV import: lists can be flagged as linkedin- or email-only, so the runner/UI
|
||||
// can warn before enrolling a purpose-mismatched list into a campaign.
|
||||
"ALTER TABLE lists ADD COLUMN purpose TEXT",
|
||||
// Manual/CSV-only field — no automation reads or writes this, reference data only.
|
||||
"ALTER TABLE targets ADD COLUMN phone TEXT",
|
||||
];
|
||||
for (const sql of migrations) {
|
||||
try { db.exec(sql); } catch { /* column already exists */ }
|
||||
}
|
||||
|
||||
// Parallel tracks: assign email steps to email track, re-number step_order, backfill run_profile_tracks
|
||||
runParallelTracksMigration(db);
|
||||
// Drop deprecated run_profiles columns (state, current_step, etc.) — consumers now read track-runs
|
||||
dropDeprecatedRunProfileColumns(db);
|
||||
|
||||
// Migrate workflow_steps CHECK constraint to allow 'delay' and 'email' step_types
|
||||
try {
|
||||
const tableInfo = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='workflow_steps'").get() as { sql: string } | undefined;
|
||||
if (tableInfo && (!tableInfo.sql.includes("'delay'") || !tableInfo.sql.includes("'email'"))) {
|
||||
db.exec(`
|
||||
PRAGMA foreign_keys = OFF;
|
||||
CREATE TABLE workflow_steps_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
workflow_id TEXT REFERENCES workflows(id) ON DELETE CASCADE,
|
||||
step_order INTEGER NOT NULL,
|
||||
step_type TEXT NOT NULL CHECK(step_type IN ('visit', 'connect', 'message', 'delay', 'email')),
|
||||
template_id TEXT REFERENCES templates(id),
|
||||
delay_seconds INTEGER DEFAULT 0,
|
||||
connect_note TEXT,
|
||||
message_body TEXT,
|
||||
email_subject TEXT,
|
||||
email_body TEXT,
|
||||
enabled INTEGER DEFAULT 1
|
||||
);
|
||||
INSERT INTO workflow_steps_new
|
||||
SELECT id, workflow_id, step_order, step_type, template_id, delay_seconds,
|
||||
connect_note, message_body,
|
||||
NULL, NULL,
|
||||
enabled
|
||||
FROM workflow_steps;
|
||||
DROP TABLE workflow_steps;
|
||||
ALTER TABLE workflow_steps_new RENAME TO workflow_steps;
|
||||
PRAGMA foreign_keys = ON;
|
||||
`);
|
||||
}
|
||||
} catch { /* migration already done */ }
|
||||
|
||||
// Allow the 'sales_inmail' step_type (Sales Navigator InMail). Rebuilds the
|
||||
// table preserving EVERY current column (the historical rebuild above only
|
||||
// copied the original columns — do NOT reuse it). InMail reuses message_body
|
||||
// for the body and email_subject for the required subject.
|
||||
try {
|
||||
const ti = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='workflow_steps'").get() as { sql: string } | undefined;
|
||||
if (ti && !ti.sql.includes("'sales_inmail'")) {
|
||||
const cols = (db.prepare("PRAGMA table_info(workflow_steps)").all() as Array<{ name: string }>).map((c) => c.name);
|
||||
const colList = cols.join(", ");
|
||||
db.exec(`
|
||||
PRAGMA foreign_keys = OFF;
|
||||
CREATE TABLE workflow_steps_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
workflow_id TEXT REFERENCES workflows(id) ON DELETE CASCADE,
|
||||
step_order INTEGER NOT NULL,
|
||||
step_type TEXT NOT NULL CHECK(step_type IN ('visit', 'connect', 'message', 'sales_inmail', 'delay', 'email')),
|
||||
template_id TEXT REFERENCES templates(id),
|
||||
delay_seconds INTEGER DEFAULT 0,
|
||||
connect_note TEXT,
|
||||
message_body TEXT,
|
||||
email_subject TEXT,
|
||||
email_body TEXT,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
ai_enabled INTEGER DEFAULT 0,
|
||||
ai_model TEXT,
|
||||
ai_prompt TEXT,
|
||||
ai_max_words INTEGER,
|
||||
email_position INTEGER DEFAULT 1,
|
||||
message_position INTEGER DEFAULT 1,
|
||||
ai_language TEXT DEFAULT 'English',
|
||||
track TEXT NOT NULL DEFAULT 'linkedin' CHECK(track IN ('linkedin', 'email')),
|
||||
email_signature TEXT
|
||||
);
|
||||
INSERT INTO workflow_steps_new (${colList}) SELECT ${colList} FROM workflow_steps;
|
||||
DROP TABLE workflow_steps;
|
||||
ALTER TABLE workflow_steps_new RENAME TO workflow_steps;
|
||||
PRAGMA foreign_keys = ON;
|
||||
`);
|
||||
}
|
||||
} catch { /* migration already done */ }
|
||||
|
||||
// CSV import: allow email-only targets (no LinkedIn URL). targets.linkedin_url was
|
||||
// NOT NULL UNIQUE from the base schema — rebuild to make it nullable (still UNIQUE,
|
||||
// SQLite allows multiple NULLs under UNIQUE) preserving EVERY current column, same
|
||||
// pattern as the sales_inmail rebuild above (do not reuse the older historical rebuilds
|
||||
// that only copied the original columns).
|
||||
try {
|
||||
const ti = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='targets'").get() as { sql: string } | undefined;
|
||||
if (ti && ti.sql.includes("linkedin_url TEXT NOT NULL")) {
|
||||
const cols = (db.prepare("PRAGMA table_info(targets)").all() as Array<{ name: string; type: string; notnull: number; dflt_value: string | null }>);
|
||||
const colDefs = cols.map((c) => {
|
||||
if (c.name === "linkedin_url") return "linkedin_url TEXT UNIQUE";
|
||||
if (c.name === "id") return "id TEXT PRIMARY KEY";
|
||||
const notnull = c.notnull ? " NOT NULL" : "";
|
||||
// Non-literal defaults (e.g. datetime('now')) must be parenthesized in SQLite DDL.
|
||||
const isLiteral = c.dflt_value === null || /^-?\d+(\.\d+)?$/.test(c.dflt_value) || /^'.*'$/.test(c.dflt_value);
|
||||
const dflt = c.dflt_value !== null ? ` DEFAULT ${isLiteral ? c.dflt_value : `(${c.dflt_value})`}` : "";
|
||||
return `${c.name} ${c.type}${notnull}${dflt}`;
|
||||
});
|
||||
const colList = cols.map((c) => c.name).join(", ");
|
||||
db.exec(`
|
||||
PRAGMA foreign_keys = OFF;
|
||||
CREATE TABLE targets_new (
|
||||
${colDefs.join(",\n ")}
|
||||
);
|
||||
INSERT INTO targets_new (${colList}) SELECT ${colList} FROM targets;
|
||||
DROP TABLE targets;
|
||||
ALTER TABLE targets_new RENAME TO targets;
|
||||
PRAGMA foreign_keys = ON;
|
||||
`);
|
||||
}
|
||||
} catch { /* migration already done */ }
|
||||
|
||||
// Backfill: move company_description and company_size from targets into companies
|
||||
try {
|
||||
db.exec(`
|
||||
UPDATE companies
|
||||
SET
|
||||
description = COALESCE(description, (
|
||||
SELECT t.company_description FROM targets t
|
||||
WHERE t.company_id = companies.id AND t.company_description IS NOT NULL
|
||||
LIMIT 1
|
||||
)),
|
||||
employee_count = COALESCE(employee_count, (
|
||||
SELECT t.company_size FROM targets t
|
||||
WHERE t.company_id = companies.id AND t.company_size IS NOT NULL
|
||||
LIMIT 1
|
||||
))
|
||||
WHERE description IS NULL OR employee_count IS NULL
|
||||
`);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Backfill: for old records where linkedin_url is a Sales Nav URL, move it to sales_nav_url
|
||||
try {
|
||||
db.exec(`
|
||||
UPDATE targets
|
||||
SET sales_nav_url = linkedin_url
|
||||
WHERE linkedin_url LIKE '%/sales/lead/%' AND (sales_nav_url IS NULL OR sales_nav_url = '')
|
||||
`);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Create unique index on run_profiles if not already present (idempotent)
|
||||
try {
|
||||
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_run_profiles_unique ON run_profiles(run_id, target_id);");
|
||||
} catch { /* ignore */ }
|
||||
|
||||
encryptLegacySecretsMigration(db);
|
||||
}
|
||||
|
||||
// One-time (per-row) migration: encrypt any plaintext accounts.cookies_json,
|
||||
// email_accounts.password/imap_password, integrations.api_key left over from before
|
||||
// encryption-at-rest was added. isEncrypted() lets this run safely on every boot — already
|
||||
//-encrypted rows (real "v1:" ciphertext) are skipped, so this is idempotent and cheap once
|
||||
// migrated. See lib/crypto.ts for the format and lib/premium.ts-style boundary notes.
|
||||
function encryptLegacySecretsMigration(db: Database.Database) {
|
||||
const accounts = db.prepare("SELECT id, cookies_json FROM accounts WHERE cookies_json IS NOT NULL").all() as
|
||||
{ id: string; cookies_json: string }[];
|
||||
for (const row of accounts) {
|
||||
if (isEncrypted(row.cookies_json)) continue;
|
||||
db.prepare("UPDATE accounts SET cookies_json = ? WHERE id = ?").run(encryptSecret(row.cookies_json), row.id);
|
||||
}
|
||||
|
||||
const emailAccounts = db
|
||||
.prepare("SELECT id, password, imap_password FROM email_accounts")
|
||||
.all() as { id: string; password: string; imap_password: string | null }[];
|
||||
for (const row of emailAccounts) {
|
||||
const needsPassword = !isEncrypted(row.password);
|
||||
const needsImapPassword = row.imap_password !== null && !isEncrypted(row.imap_password);
|
||||
if (!needsPassword && !needsImapPassword) continue;
|
||||
db.prepare("UPDATE email_accounts SET password = ?, imap_password = ? WHERE id = ?").run(
|
||||
needsPassword ? encryptSecret(row.password) : row.password,
|
||||
needsImapPassword ? encryptSecret(row.imap_password!) : row.imap_password,
|
||||
row.id
|
||||
);
|
||||
}
|
||||
|
||||
const integrations = db.prepare("SELECT key, api_key FROM integrations WHERE api_key IS NOT NULL").all() as
|
||||
{ key: string; api_key: string }[];
|
||||
for (const row of integrations) {
|
||||
if (isEncrypted(row.api_key)) continue;
|
||||
db.prepare("UPDATE integrations SET api_key = ? WHERE key = ?").run(encryptSecret(row.api_key), row.key);
|
||||
}
|
||||
}
|
||||
|
||||
function initDb(db: Database.Database) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
cookies_json TEXT,
|
||||
is_authenticated INTEGER DEFAULT 0,
|
||||
daily_connection_limit INTEGER DEFAULT 20,
|
||||
daily_message_limit INTEGER DEFAULT 50,
|
||||
daily_inmail_limit INTEGER DEFAULT 15,
|
||||
active_hours_start INTEGER DEFAULT 9,
|
||||
active_hours_end INTEGER DEFAULT 18,
|
||||
timezone TEXT DEFAULT 'UTC',
|
||||
working_days TEXT DEFAULT '1,2,3,4,5',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS targets (
|
||||
id TEXT PRIMARY KEY,
|
||||
linkedin_url TEXT UNIQUE,
|
||||
sales_nav_url TEXT,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
full_name TEXT,
|
||||
title TEXT,
|
||||
company TEXT,
|
||||
location TEXT,
|
||||
profile_image_url TEXT,
|
||||
degree INTEGER,
|
||||
connection_requested_at TEXT,
|
||||
connected_at TEXT,
|
||||
message_sent_at TEXT,
|
||||
last_replied_at TEXT,
|
||||
linkedin_member_urn TEXT,
|
||||
enriched_at TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lists (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
sales_nav_url TEXT,
|
||||
purpose TEXT CHECK(purpose IN ('linkedin', 'email')),
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS list_targets (
|
||||
list_id TEXT REFERENCES lists(id) ON DELETE CASCADE,
|
||||
target_id TEXT REFERENCES targets(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (list_id, target_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS templates (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workflows (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workflow_steps (
|
||||
id TEXT PRIMARY KEY,
|
||||
workflow_id TEXT REFERENCES workflows(id) ON DELETE CASCADE,
|
||||
step_order INTEGER NOT NULL,
|
||||
step_type TEXT NOT NULL CHECK(step_type IN ('visit', 'connect', 'message', 'delay')),
|
||||
template_id TEXT REFERENCES templates(id),
|
||||
delay_seconds INTEGER DEFAULT 0,
|
||||
connect_note TEXT,
|
||||
message_body TEXT,
|
||||
enabled INTEGER DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workflow_step_templates (
|
||||
step_id TEXT REFERENCES workflow_steps(id) ON DELETE CASCADE,
|
||||
template_id TEXT REFERENCES templates(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (step_id, template_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
workflow_id TEXT REFERENCES workflows(id),
|
||||
list_id TEXT REFERENCES lists(id),
|
||||
account_id TEXT REFERENCES accounts(id),
|
||||
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'running', 'paused', 'completed', 'failed')),
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
started_at TEXT,
|
||||
completed_at TEXT,
|
||||
runner_pid INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS run_profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
run_id TEXT REFERENCES runs(id) ON DELETE CASCADE,
|
||||
target_id TEXT REFERENCES targets(id),
|
||||
state TEXT DEFAULT 'pending' CHECK(state IN ('pending', 'in_progress', 'completed', 'failed', 'skipped')),
|
||||
current_step INTEGER DEFAULT 0,
|
||||
last_step_at TEXT,
|
||||
next_step_at TEXT,
|
||||
error_message TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(run_id, target_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
run_id TEXT REFERENCES runs(id) ON DELETE CASCADE,
|
||||
target_id TEXT REFERENCES targets(id),
|
||||
level TEXT DEFAULT 'info' CHECK(level IN ('info', 'warn', 'error')),
|
||||
message TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS companies (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
domain TEXT,
|
||||
industry TEXT,
|
||||
location TEXT,
|
||||
linkedin_url TEXT,
|
||||
website TEXT,
|
||||
notes TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS integrations (
|
||||
key TEXT PRIMARY KEY,
|
||||
api_key TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_config (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
system_prompt TEXT,
|
||||
user_prompt TEXT,
|
||||
email_examples TEXT,
|
||||
linkedin_examples TEXT,
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
run_id TEXT,
|
||||
target_id TEXT,
|
||||
step_id TEXT,
|
||||
model TEXT,
|
||||
input_tokens INTEGER,
|
||||
output_tokens INTEGER,
|
||||
cost_usd REAL,
|
||||
prompt TEXT,
|
||||
generated_text TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Hosted MCP OAuth 2.1 server: clients (DCR), single-use auth codes, access/refresh tokens.
|
||||
-- Tokens/codes are stored only as sha256 hashes. Reuses NEXTAUTH user identity.
|
||||
CREATE TABLE IF NOT EXISTS oauth_clients (
|
||||
client_id TEXT PRIMARY KEY,
|
||||
client_name TEXT,
|
||||
redirect_uris TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_auth_codes (
|
||||
code_hash TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
code_challenge TEXT NOT NULL,
|
||||
scope TEXT,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
access_hash TEXT NOT NULL UNIQUE,
|
||||
refresh_hash TEXT UNIQUE,
|
||||
client_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
scope TEXT,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS email_accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
from_email TEXT NOT NULL,
|
||||
from_name TEXT,
|
||||
smtp_host TEXT NOT NULL,
|
||||
smtp_port INTEGER DEFAULT 587,
|
||||
smtp_secure INTEGER DEFAULT 0,
|
||||
imap_host TEXT,
|
||||
imap_port INTEGER DEFAULT 993,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
daily_email_limit INTEGER DEFAULT 50,
|
||||
active_hours_start INTEGER DEFAULT 9,
|
||||
active_hours_end INTEGER DEFAULT 18,
|
||||
timezone TEXT DEFAULT 'UTC',
|
||||
working_days TEXT DEFAULT '1,2,3,4,5',
|
||||
is_verified INTEGER DEFAULT 0,
|
||||
inbox_synced_at TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare module 'imap-simple';
|
||||
@@ -0,0 +1,367 @@
|
||||
import Imap from "imap";
|
||||
import { simpleParser } from "mailparser";
|
||||
import { randomUUID } from "crypto";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { premium } from "@/lib/premium";
|
||||
import { decryptSecret } from "@/lib/crypto";
|
||||
|
||||
const IMAP_POLL_INTERVAL_MS = 2 * 60 * 60 * 1000; // 2 hours
|
||||
// Jul 2026 incident: all email accounts became "due" in the same tick and synced
|
||||
// back-to-back (one 20-account burst ran 3 minutes straight, one account alone took
|
||||
// 85s), which lined up with NocoDB/n8n healthcheck failures on the shared host. A
|
||||
// deterministic per-account offset spreads accounts across a window so they drift
|
||||
// apart over successive cycles instead of staying clustered together forever.
|
||||
const IMAP_POLL_JITTER_MS = 30 * 60 * 1000; // 30 min
|
||||
|
||||
function accountJitterMs(accountId: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < accountId.length; i++) h = (h * 31 + accountId.charCodeAt(i)) >>> 0;
|
||||
return h % IMAP_POLL_JITTER_MS;
|
||||
}
|
||||
|
||||
const BOUNCE_SENDER_PATTERNS = [
|
||||
/mailer-daemon@/i,
|
||||
/postmaster@/i,
|
||||
/mail-delivery-subsystem@/i,
|
||||
/delivery-status@/i,
|
||||
/amazonses\.com$/i,
|
||||
];
|
||||
|
||||
function extractEmails(text: string): string[] {
|
||||
return [...text.matchAll(/[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/g)].map(m => m[0].toLowerCase());
|
||||
}
|
||||
|
||||
function isBounce(fromEmail: string): boolean {
|
||||
return BOUNCE_SENDER_PATTERNS.some(p => p.test(fromEmail));
|
||||
}
|
||||
|
||||
function parseHeaderValue(raw: string, field: string): string {
|
||||
const regex = new RegExp(`^${field}:[ \\t]*(.+?)(?=\\r?\\n[^\\s]|$)`, "im");
|
||||
const m = raw.match(regex);
|
||||
if (!m) return "";
|
||||
return m[1].replace(/\r?\n[\t ]+/g, " ").trim();
|
||||
}
|
||||
|
||||
interface EmailAccount {
|
||||
id: string;
|
||||
imap_host: string | null;
|
||||
imap_port: number | null;
|
||||
username: string;
|
||||
password: string;
|
||||
imap_username: string | null;
|
||||
imap_password: string | null;
|
||||
inbox_synced_at: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the body + headers for a given UID and inserts a row into
|
||||
* `email_replies` (idempotent — skips if a row with the same target_id +
|
||||
* received_at already exists). Best-effort: errors are swallowed by the caller.
|
||||
*/
|
||||
export function captureReplyBody(
|
||||
imap: Imap,
|
||||
db: ReturnType<typeof getDb>,
|
||||
targetId: string,
|
||||
fromEmail: string,
|
||||
uid: number,
|
||||
): Promise<string | null> {
|
||||
return new Promise<string | null>((resolve) => {
|
||||
// Fetch the full raw RFC822 message — mailparser handles MIME multipart,
|
||||
// base64 / quoted-printable transfer encodings, and charset decoding. The
|
||||
// old HEADER+TEXT regex approach stored raw base64 / =XX escapes for the
|
||||
// common German auto-reply formats, feeding the classifier garbage.
|
||||
const fetch = imap.fetch(uid, { bodies: [""], struct: false });
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
fetch.on("message", (msg) => {
|
||||
msg.on("body", (stream) => {
|
||||
stream.on("data", (c: Buffer) => chunks.push(c));
|
||||
});
|
||||
});
|
||||
|
||||
fetch.once("error", () => resolve(null));
|
||||
fetch.once("end", () => {
|
||||
void (async () => {
|
||||
try {
|
||||
const raw = Buffer.concat(chunks);
|
||||
const parsed = await simpleParser(raw);
|
||||
|
||||
const subject = parsed.subject ?? null;
|
||||
const parsedFrom =
|
||||
parsed.from?.value?.[0]?.address?.toLowerCase().trim() || fromEmail.toLowerCase();
|
||||
const receivedAt = parsed.date ? parsed.date.toISOString() : new Date().toISOString();
|
||||
|
||||
// Prefer decoded plain text; fall back to stripping the HTML part.
|
||||
const rawText =
|
||||
parsed.text ??
|
||||
(parsed.html ? parsed.html.replace(/<[^>]+>/g, " ") : "");
|
||||
const bodyText = rawText
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.replace(/[ \t]{2,}/g, " ")
|
||||
.trim()
|
||||
.slice(0, 16_000);
|
||||
|
||||
if (!bodyText) { resolve(null); return; }
|
||||
|
||||
// Dedup: skip if we already have a row for this target with the same received_at
|
||||
const existing = db.prepare(
|
||||
"SELECT id FROM email_replies WHERE target_id = ? AND received_at = ?"
|
||||
).get(targetId, receivedAt) as { id: string } | undefined;
|
||||
|
||||
if (existing) { resolve(null); return; }
|
||||
|
||||
// Look up the most recent active run for this target — the dispatcher needs
|
||||
// it to find the email track to reschedule / enroll a substitute into.
|
||||
const runRow = db.prepare(
|
||||
`SELECT r.id FROM runs r
|
||||
JOIN run_profiles rp ON rp.run_id = r.id
|
||||
WHERE rp.target_id = ? AND r.status IN ('running', 'paused')
|
||||
ORDER BY r.created_at DESC LIMIT 1`
|
||||
).get(targetId) as { id: string } | undefined;
|
||||
|
||||
const replyId = randomUUID();
|
||||
db.prepare(
|
||||
`INSERT INTO email_replies (id, target_id, run_id, from_email, subject, body_text, received_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(replyId, targetId, runRow?.id ?? null, parsedFrom, subject, bodyText, receivedAt);
|
||||
resolve(replyId);
|
||||
} catch (err) {
|
||||
console.warn(`[email-inbox] captureReplyBody parse/insert failed:`, err);
|
||||
resolve(null);
|
||||
}
|
||||
})();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function shouldSyncEmailInbox(emailAccountId: string): boolean {
|
||||
const db = getDb();
|
||||
const account = db
|
||||
.prepare("SELECT inbox_synced_at FROM email_accounts WHERE id = ?")
|
||||
.get(emailAccountId) as { inbox_synced_at: string | null } | undefined;
|
||||
if (!account?.inbox_synced_at) return true;
|
||||
const dueAfterMs = IMAP_POLL_INTERVAL_MS + accountJitterMs(emailAccountId);
|
||||
return Date.now() - new Date(account.inbox_synced_at).getTime() >= dueAfterMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens one IMAP connection, then for each lead that has been emailed but
|
||||
* not yet replied, runs a server-side FROM search. Only touches the mailbox
|
||||
* index — never downloads message bodies for reply detection.
|
||||
*
|
||||
* Also scans the last 50 messages for bounces (mailer-daemon etc.).
|
||||
*/
|
||||
export async function syncEmailInbox(emailAccountId: string): Promise<{ replies: number; bounces: number }> {
|
||||
const db = getDb();
|
||||
|
||||
const account = db
|
||||
.prepare("SELECT id, imap_host, imap_port, username, password, imap_username, imap_password, inbox_synced_at FROM email_accounts WHERE id = ?")
|
||||
.get(emailAccountId) as EmailAccount | undefined;
|
||||
|
||||
if (!account?.imap_host) {
|
||||
console.warn(`[email-inbox] Account ${emailAccountId} has no IMAP config — skipping`);
|
||||
return { replies: 0, bounces: 0 };
|
||||
}
|
||||
|
||||
// Leads that were emailed via this account and haven't replied yet
|
||||
const pendingTargets = db.prepare(`
|
||||
SELECT DISTINCT t.id, t.email
|
||||
FROM targets t
|
||||
JOIN run_profiles rp ON rp.target_id = t.id
|
||||
JOIN run_profile_tracks rt ON rt.run_profile_id = rp.id
|
||||
WHERE t.email IS NOT NULL
|
||||
AND t.email_replied_at IS NULL
|
||||
AND t.email_status != 'invalid'
|
||||
AND rt.track = 'email'
|
||||
AND rt.state NOT IN ('pending')
|
||||
AND rp.email_account_id = ?
|
||||
`).all(emailAccountId) as { id: string; email: string }[];
|
||||
|
||||
if (pendingTargets.length === 0) {
|
||||
db.prepare("UPDATE email_accounts SET inbox_synced_at = datetime('now') WHERE id = ?").run(emailAccountId);
|
||||
return { replies: 0, bounces: 0 };
|
||||
}
|
||||
|
||||
console.log(`[email-inbox] Checking ${pendingTargets.length} leads via IMAP FROM search`);
|
||||
|
||||
const imapUser = account.imap_username ?? account.username;
|
||||
const imapPass = decryptSecret(account.imap_password) ?? decryptSecret(account.password)!;
|
||||
|
||||
let replies = 0;
|
||||
let bounces = 0;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const imap = new Imap({
|
||||
host: account.imap_host!,
|
||||
port: account.imap_port ?? 993,
|
||||
tls: true,
|
||||
tlsOptions: { rejectUnauthorized: false },
|
||||
user: imapUser,
|
||||
password: imapPass,
|
||||
authTimeout: 10_000,
|
||||
connTimeout: 12_000,
|
||||
});
|
||||
|
||||
const done = () => {
|
||||
try { imap.end(); } catch { /* ignore */ }
|
||||
resolve();
|
||||
};
|
||||
|
||||
imap.once("error", (err: Error) => {
|
||||
console.warn(`[email-inbox] IMAP error for account ${emailAccountId}:`, err.message);
|
||||
done();
|
||||
});
|
||||
|
||||
imap.once("ready", () => {
|
||||
imap.openBox("INBOX", true, async (err, box) => {
|
||||
if (err || !box) { console.warn("[email-inbox] openBox failed:", err?.message); done(); return; }
|
||||
|
||||
// ── Reply detection: one FROM search per lead ──────────────────────────
|
||||
// Run sequentially — the imap library serialises commands over one TCP connection
|
||||
for (const target of pendingTargets) {
|
||||
await new Promise<void>((resSearch) => {
|
||||
imap.search([["FROM", target.email]], async (searchErr, uids) => {
|
||||
if (searchErr) { resSearch(); return; }
|
||||
if (uids.length > 0) {
|
||||
console.log(`[email-inbox] Reply detected for ${target.email} (target ${target.id})`);
|
||||
replies++;
|
||||
|
||||
// Capture the body, then let the classifier+dispatcher decide the action.
|
||||
// We no longer eagerly stamp email_replied_at here — the dispatcher does it
|
||||
// for skip-buckets (call_task / human_reply / not_interested) but leaves it
|
||||
// unset for OOO follow-ups so the runner keeps the contact enrolled.
|
||||
// On any failure the contact stays enrolled (safe fallback, plan §3.2).
|
||||
try {
|
||||
const latestUid = uids[uids.length - 1];
|
||||
const replyId = await captureReplyBody(imap, db, target.id, target.email, latestUid);
|
||||
// The reply is always STORED (open-core). AI classification + auto-followup
|
||||
// is a premium feature — skipped cleanly when ee/ is absent.
|
||||
if (replyId && premium?.replies) {
|
||||
await premium.replies.classifyAndDispatch(replyId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[email-inbox] Failed to capture/dispatch reply for ${target.email}:`, err);
|
||||
}
|
||||
}
|
||||
resSearch();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Bounce detection: scan last 50 messages for mailer-daemon ─────────
|
||||
if (box.messages.total > 0) {
|
||||
const total = box.messages.total;
|
||||
const start = Math.max(1, total - 49);
|
||||
const range = `${start}:${total}`;
|
||||
|
||||
await new Promise<void>((resFetch) => {
|
||||
const fetch = imap.seq.fetch(range, {
|
||||
bodies: ["HEADER.FIELDS (FROM TO)", "TEXT"],
|
||||
struct: false,
|
||||
});
|
||||
|
||||
type RawMsg = { header: string; body: string };
|
||||
const msgs: RawMsg[] = [];
|
||||
|
||||
fetch.on("message", (msg) => {
|
||||
const entry: RawMsg = { header: "", body: "" };
|
||||
msg.on("body", (stream, info) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (c: Buffer) => chunks.push(c));
|
||||
stream.once("end", () => {
|
||||
const text = Buffer.concat(chunks).toString();
|
||||
if (info.which.startsWith("HEADER")) entry.header = text;
|
||||
else entry.body = text.slice(0, 3000);
|
||||
});
|
||||
});
|
||||
msg.once("end", () => msgs.push(entry));
|
||||
});
|
||||
|
||||
fetch.once("error", () => resFetch());
|
||||
fetch.once("end", () => {
|
||||
for (const msg of msgs) {
|
||||
const fromRaw = parseHeaderValue(msg.header, "From");
|
||||
const toRaw = parseHeaderValue(msg.header, "To");
|
||||
|
||||
const emailMatch = fromRaw.match(/<([^>]+)>/) ?? fromRaw.match(/([^\s]+@[^\s]+)/);
|
||||
const fromEmail = emailMatch?.[1]?.toLowerCase().trim();
|
||||
if (!fromEmail || !isBounce(fromEmail)) continue;
|
||||
|
||||
// Also extract Final-Recipient from DSN bodies (SES bounce format)
|
||||
const finalRecipient = msg.body.match(/Final-Recipient:\s*rfc822;\s*([^\s\r\n]+)/i)?.[1] ?? "";
|
||||
const candidates = extractEmails(msg.body + " " + toRaw + " " + finalRecipient);
|
||||
for (const candidate of candidates) {
|
||||
if (BOUNCE_SENDER_PATTERNS.some(p => p.test(candidate))) continue;
|
||||
|
||||
const target = db
|
||||
.prepare("SELECT id, email_status, company_id FROM targets WHERE lower(email) = ?")
|
||||
.get(candidate) as { id: string; email_status: string | null; company_id: string | null } | undefined;
|
||||
|
||||
if (!target || target.email_status === "invalid") continue;
|
||||
|
||||
const note = `Email bounced on ${new Date().toISOString().slice(0, 10)} — marked invalid`;
|
||||
db.prepare(`
|
||||
UPDATE targets SET email_status = 'invalid',
|
||||
notes = CASE WHEN notes IS NULL OR notes = '' THEN ? ELSE notes || char(10) || ? END
|
||||
WHERE id = ?
|
||||
`).run(note, note, target.id);
|
||||
|
||||
db.prepare(`
|
||||
UPDATE run_profile_tracks SET state = 'skipped', error_message = 'Email bounced — invalid address'
|
||||
WHERE run_profile_id IN (SELECT id FROM run_profiles WHERE target_id = ?)
|
||||
AND state IN ('pending', 'in_progress')
|
||||
`).run(target.id);
|
||||
|
||||
if (target.company_id) {
|
||||
const companyNote = `Email domain flagged invalid — bounce for ${candidate} on ${new Date().toISOString().slice(0, 10)}`;
|
||||
db.prepare(`
|
||||
UPDATE companies SET email_domain_invalid = 1,
|
||||
notes = CASE WHEN notes IS NULL OR notes = '' THEN ? ELSE notes || char(10) || ? END
|
||||
WHERE id = ?
|
||||
`).run(companyNote, companyNote, target.company_id);
|
||||
|
||||
const siblings = db.prepare(`
|
||||
SELECT id FROM targets WHERE company_id = ? AND id != ? AND email IS NOT NULL AND email_status != 'invalid'
|
||||
`).all(target.company_id, target.id) as { id: string }[];
|
||||
|
||||
for (const sibling of siblings) {
|
||||
const sibNote = `Email bounced on ${new Date().toISOString().slice(0, 10)} — marked invalid (domain flagged via company)`;
|
||||
db.prepare(`
|
||||
UPDATE targets SET email_status = 'invalid',
|
||||
notes = CASE WHEN notes IS NULL OR notes = '' THEN ? ELSE notes || char(10) || ? END
|
||||
WHERE id = ?
|
||||
`).run(sibNote, sibNote, sibling.id);
|
||||
db.prepare(`
|
||||
UPDATE run_profile_tracks SET state = 'skipped', error_message = 'Email domain invalid — company flagged'
|
||||
WHERE run_profile_id IN (SELECT id FROM run_profiles WHERE target_id = ?)
|
||||
AND state IN ('pending', 'in_progress')
|
||||
`).run(sibling.id);
|
||||
}
|
||||
|
||||
if (siblings.length > 0) {
|
||||
console.log(`[email-inbox] Company ${target.company_id} flagged — ${siblings.length} sibling(s) marked invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[email-inbox] Bounce for ${candidate} (target ${target.id}) — marked invalid`);
|
||||
bounces++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
resFetch();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
imap.connect();
|
||||
});
|
||||
|
||||
db.prepare("UPDATE email_accounts SET inbox_synced_at = datetime('now') WHERE id = ?").run(emailAccountId);
|
||||
return { replies, bounces };
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import Imap from "imap";
|
||||
|
||||
export interface EmailAccount {
|
||||
id: string;
|
||||
from_email: string;
|
||||
from_name: string | null;
|
||||
reply_to?: string | null;
|
||||
smtp_host: string;
|
||||
smtp_port: number;
|
||||
smtp_secure: number; // 0 = STARTTLS, 1 = SSL
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export async function sendEmail(
|
||||
account: EmailAccount,
|
||||
to: string,
|
||||
subject: string,
|
||||
body: string
|
||||
): Promise<void> {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: account.smtp_host,
|
||||
port: account.smtp_port,
|
||||
secure: account.smtp_secure === 1,
|
||||
auth: {
|
||||
user: account.username,
|
||||
pass: account.password,
|
||||
},
|
||||
// Allow self-signed certs (common in some corp SMTP setups)
|
||||
tls: { rejectUnauthorized: false },
|
||||
});
|
||||
|
||||
const from = account.from_name
|
||||
? `"${account.from_name}" <${account.from_email}>`
|
||||
: account.from_email;
|
||||
|
||||
await transporter.sendMail({
|
||||
from, to, subject, text: body,
|
||||
...(account.reply_to ? { replyTo: account.reply_to } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies SMTP connectivity — used by the test-connection endpoint.
|
||||
* Returns null on success, error message string on failure.
|
||||
*/
|
||||
export async function testSmtpConnection(account: Omit<EmailAccount, "id">): Promise<string | null> {
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: account.smtp_host,
|
||||
port: account.smtp_port,
|
||||
secure: account.smtp_secure === 1,
|
||||
auth: { user: account.username, pass: account.password },
|
||||
tls: { rejectUnauthorized: false },
|
||||
connectionTimeout: 10_000,
|
||||
greetingTimeout: 10_000,
|
||||
});
|
||||
await transporter.verify();
|
||||
return null;
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ImapTestAccount {
|
||||
imap_host: string;
|
||||
imap_port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
imap_username: string | null;
|
||||
imap_password: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies IMAP connectivity — connects, authenticates, then disconnects.
|
||||
* Returns null on success, error message string on failure.
|
||||
*/
|
||||
export async function testImapConnection(account: ImapTestAccount): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const imap = new Imap({
|
||||
host: account.imap_host,
|
||||
port: account.imap_port,
|
||||
tls: true,
|
||||
tlsOptions: { rejectUnauthorized: false },
|
||||
user: account.imap_username ?? account.username,
|
||||
password: account.imap_password ?? account.password,
|
||||
authTimeout: 10_000,
|
||||
connTimeout: 12_000,
|
||||
});
|
||||
|
||||
imap.once("ready", () => {
|
||||
try { imap.end(); } catch { /* ignore */ }
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
imap.once("error", (err: Error) => {
|
||||
resolve(err.message ?? String(err));
|
||||
});
|
||||
|
||||
imap.connect();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import type DatabaseType from "better-sqlite3";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
type DB = DatabaseType.Database;
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
export const DEFAULT_DAILY_CAP = 1500;
|
||||
|
||||
export interface ImportRow {
|
||||
id: string;
|
||||
list_id: string;
|
||||
account_id: string | null;
|
||||
sales_nav_url: string | null;
|
||||
status: string;
|
||||
phase: string | null;
|
||||
page: number;
|
||||
total_pages: number;
|
||||
count: number;
|
||||
total: number;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
error: string | null;
|
||||
scheduled_for: string | null;
|
||||
start_page: number;
|
||||
cap: number | null;
|
||||
cancel_requested: number;
|
||||
batch_index: number;
|
||||
enrich: number;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
// ─── settings ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function getDailyImportCap(db: DB = getDb()): number {
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'daily_import_cap'").get() as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
const n = row ? parseInt(row.value, 10) : NaN;
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_DAILY_CAP;
|
||||
}
|
||||
|
||||
export function setDailyImportCap(db: DB, n: number): void {
|
||||
const v = String(Math.max(1, Math.floor(n)));
|
||||
db.prepare(
|
||||
`INSERT INTO app_settings (key, value, updated_at) VALUES ('daily_import_cap', ?, datetime('now'))
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`
|
||||
).run(v);
|
||||
}
|
||||
|
||||
// ─── quota ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Contacts imported across ALL lists today (the global daily budget). */
|
||||
export function importedToday(db: DB): number {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT COALESCE(SUM(imported), 0) c FROM list_imports
|
||||
WHERE status IN ('done', 'running') AND date(COALESCE(finished_at, started_at)) = date('now')`
|
||||
)
|
||||
.get() as { c: number };
|
||||
return row.c;
|
||||
}
|
||||
|
||||
function todayStr(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
function addDaysStr(base: string, days: number): string {
|
||||
const d = new Date(base + "T00:00:00Z");
|
||||
d.setUTCDate(d.getUTCDate() + days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// ─── public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Queue an import for a list. Creates the first batch as 'scheduled' for today;
|
||||
* the runner's scheduler picks it up (one import at a time). Large lists are
|
||||
* split across days under the daily cap by runBatch chaining continuations.
|
||||
*/
|
||||
export function startImport(
|
||||
db: DB,
|
||||
opts: { listId: string; accountId: string; salesNavUrl: string; enrich?: boolean }
|
||||
): { importId: string } {
|
||||
cancelImportsForList(db, opts.listId); // supersede any prior import for this list
|
||||
const importId = randomUUID();
|
||||
db.prepare(
|
||||
`INSERT INTO list_imports
|
||||
(id, list_id, account_id, sales_nav_url, status, scheduled_for, start_page, batch_index, enrich, started_at)
|
||||
VALUES (?, ?, ?, ?, 'scheduled', ?, 1, 1, ?, datetime('now'))`
|
||||
).run(importId, opts.listId, opts.accountId, opts.salesNavUrl, todayStr(), opts.enrich ? 1 : 0);
|
||||
return { importId };
|
||||
}
|
||||
|
||||
export function cancelImportsForList(db: DB, listId: string): void {
|
||||
db.prepare(
|
||||
`UPDATE list_imports
|
||||
SET cancel_requested = 1,
|
||||
status = CASE WHEN status = 'scheduled' THEN 'canceled' ELSE status END,
|
||||
finished_at = CASE WHEN status = 'scheduled' THEN datetime('now') ELSE finished_at END
|
||||
WHERE list_id = ? AND status IN ('scheduled', 'running')`
|
||||
).run(listId);
|
||||
}
|
||||
|
||||
export function cancelImport(db: DB, importId: string): void {
|
||||
db.prepare(
|
||||
`UPDATE list_imports
|
||||
SET cancel_requested = 1,
|
||||
status = CASE WHEN status = 'scheduled' THEN 'canceled' ELSE status END,
|
||||
finished_at = CASE WHEN status = 'scheduled' THEN datetime('now') ELSE finished_at END
|
||||
WHERE id = ?`
|
||||
).run(importId);
|
||||
}
|
||||
|
||||
// ─── scheduler + executor ────────────────────────────────────────────────────
|
||||
|
||||
let importRunning = false;
|
||||
|
||||
/** Runner hook (called each tick): start the next due batch if none is running. */
|
||||
export async function processScheduledImports(db: DB): Promise<void> {
|
||||
if (importRunning) return;
|
||||
const due = db
|
||||
.prepare(
|
||||
`SELECT * FROM list_imports
|
||||
WHERE status = 'scheduled' AND cancel_requested = 0
|
||||
AND (scheduled_for IS NULL OR scheduled_for <= date('now'))
|
||||
ORDER BY scheduled_for ASC, batch_index ASC LIMIT 1`
|
||||
)
|
||||
.get() as ImportRow | undefined;
|
||||
if (!due) return;
|
||||
|
||||
importRunning = true;
|
||||
db.prepare("UPDATE list_imports SET status = 'running', started_at = datetime('now') WHERE id = ?").run(due.id);
|
||||
runBatch(due.id)
|
||||
.catch((e) => console.error("[import] batch crashed:", e))
|
||||
.finally(() => { importRunning = false; });
|
||||
}
|
||||
|
||||
async function runBatch(importId: string): Promise<void> {
|
||||
const db = getDb();
|
||||
const job = db.prepare("SELECT * FROM list_imports WHERE id = ?").get(importId) as ImportRow | undefined;
|
||||
if (!job || !job.account_id || !job.sales_nav_url) return;
|
||||
|
||||
// List deleted out from under us?
|
||||
const list = db.prepare("SELECT id FROM lists WHERE id = ?").get(job.list_id);
|
||||
if (!list) {
|
||||
db.prepare("UPDATE list_imports SET status = 'canceled', finished_at = datetime('now') WHERE id = ?").run(importId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Today's remaining budget → max whole pages this run
|
||||
const cap = getDailyImportCap(db);
|
||||
const remaining = cap - importedToday(db);
|
||||
const maxPages = Math.floor(remaining / PAGE_SIZE);
|
||||
if (maxPages < 1) {
|
||||
db.prepare("UPDATE list_imports SET status = 'scheduled', scheduled_for = ? WHERE id = ?").run(
|
||||
addDaysStr(todayStr(), 1),
|
||||
importId
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[import] batch ${importId} (b${job.batch_index}) start_page=${job.start_page} maxPages=${maxPages} cap=${cap}`);
|
||||
const { getSessionContext } = await import("@/lib/linkedin/session");
|
||||
const { scrapeNavigatorUrl } = await import("@/lib/linkedin/scraper");
|
||||
|
||||
const updateProgress = db.prepare(
|
||||
"UPDATE list_imports SET phase = ?, page = ?, total_pages = ?, count = ?, total = ? WHERE id = ?"
|
||||
);
|
||||
const isCanceled = () => {
|
||||
const r = db.prepare("SELECT cancel_requested FROM list_imports WHERE id = ?").get(importId) as
|
||||
| { cancel_requested: number }
|
||||
| undefined;
|
||||
return !r || r.cancel_requested === 1; // row deleted (list cascade) or explicit cancel
|
||||
};
|
||||
|
||||
try {
|
||||
const ctx = await getSessionContext(job.account_id);
|
||||
const { profiles, lastPage, knownTotal, exhausted } = await scrapeNavigatorUrl(ctx, job.sales_nav_url, {
|
||||
startPage: job.start_page,
|
||||
maxPages,
|
||||
onProgress: (p) => updateProgress.run(p.phase, p.page ?? 0, p.totalPages ?? 0, p.count, p.total, importId),
|
||||
isCanceled,
|
||||
});
|
||||
|
||||
if (isCanceled()) {
|
||||
if (db.prepare("SELECT id FROM list_imports WHERE id = ?").get(importId)) {
|
||||
db.prepare("UPDATE list_imports SET status = 'canceled', finished_at = datetime('now') WHERE id = ?").run(importId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { imported, skipped } = insertProfiles(db, job.list_id, profiles);
|
||||
console.log(`[import] batch ${importId} inserted ${imported} new, skipped ${skipped} (lastPage=${lastPage}, exhausted=${exhausted})`);
|
||||
|
||||
db.prepare(
|
||||
`UPDATE list_imports
|
||||
SET status = 'done', imported = ?, skipped = ?, count = ?, total = ?, page = ?, total_pages = ?, finished_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
).run(imported, skipped, profiles.length, knownTotal, lastPage, Math.ceil(knownTotal / PAGE_SIZE), importId);
|
||||
|
||||
// More of the list left → chain the remainder to the next day
|
||||
if (!exhausted) {
|
||||
db.prepare(
|
||||
`INSERT INTO list_imports
|
||||
(id, list_id, account_id, sales_nav_url, status, scheduled_for, start_page, batch_index, enrich, total, total_pages, started_at)
|
||||
VALUES (?, ?, ?, ?, 'scheduled', ?, ?, ?, ?, ?, ?, datetime('now'))`
|
||||
).run(
|
||||
randomUUID(),
|
||||
job.list_id,
|
||||
job.account_id,
|
||||
job.sales_nav_url,
|
||||
addDaysStr(todayStr(), 1),
|
||||
lastPage + 1,
|
||||
job.batch_index + 1,
|
||||
job.enrich,
|
||||
knownTotal,
|
||||
Math.ceil(knownTotal / PAGE_SIZE)
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("[import] FAILED:", message);
|
||||
if (db.prepare("SELECT id FROM list_imports WHERE id = ?").get(importId)) {
|
||||
db.prepare("UPDATE list_imports SET status = 'error', error = ?, finished_at = datetime('now') WHERE id = ?").run(
|
||||
message,
|
||||
importId
|
||||
);
|
||||
}
|
||||
// A "no data intercepted / re-authentication" failure means the session died.
|
||||
if (/re-authentication|No data intercepted/i.test(message) && job.account_id) {
|
||||
try {
|
||||
const { markNeedsReauth } = await import("@/lib/linkedin/session");
|
||||
await markNeedsReauth(job.account_id);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function insertProfiles(db: DB, listId: string, profiles: any[]): { imported: number; skipped: number } {
|
||||
const insertTarget = db.prepare(
|
||||
`INSERT INTO targets (
|
||||
id, linkedin_url, sales_nav_url, first_name, last_name, full_name,
|
||||
title, company, location, degree,
|
||||
object_urn, summary, open_link, company_industry, company_location,
|
||||
tenure_months, spotlight_badges
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(linkedin_url) DO UPDATE SET
|
||||
sales_nav_url = excluded.sales_nav_url,
|
||||
first_name = excluded.first_name,
|
||||
last_name = excluded.last_name,
|
||||
full_name = excluded.full_name,
|
||||
title = excluded.title,
|
||||
company = excluded.company,
|
||||
location = excluded.location,
|
||||
degree = excluded.degree,
|
||||
object_urn = excluded.object_urn,
|
||||
summary = excluded.summary,
|
||||
open_link = excluded.open_link,
|
||||
company_industry = excluded.company_industry,
|
||||
company_location = excluded.company_location,
|
||||
tenure_months = excluded.tenure_months,
|
||||
spotlight_badges = excluded.spotlight_badges`
|
||||
);
|
||||
const insertLink = db.prepare("INSERT OR IGNORE INTO list_targets (list_id, target_id) VALUES (?, ?)");
|
||||
const findTarget = db.prepare("SELECT id FROM targets WHERE linkedin_url = ?");
|
||||
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
db.transaction(() => {
|
||||
for (const p of profiles) {
|
||||
const url = p.linkedinUrl ?? p.salesNavUrl;
|
||||
insertTarget.run(
|
||||
randomUUID(), url, p.salesNavUrl,
|
||||
p.firstName, p.lastName, p.fullName,
|
||||
p.title, p.company, p.location, p.degree,
|
||||
p.objectUrn, p.summary, p.openLink ? 1 : 0,
|
||||
p.companyIndustry, p.companyLocation,
|
||||
p.tenureMonths, p.spotlightBadges
|
||||
);
|
||||
const target = findTarget.get(url) as { id: string };
|
||||
const result = insertLink.run(listId, target.id);
|
||||
if (result.changes > 0) imported++;
|
||||
else skipped++;
|
||||
}
|
||||
})();
|
||||
return { imported, skipped };
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Page } from "playwright";
|
||||
|
||||
export class WeeklyLimitError extends Error {}
|
||||
export class AlreadyConnectedError extends Error {}
|
||||
export class PendingInviteError extends Error {}
|
||||
|
||||
/**
|
||||
* Sends a LinkedIn connection request without a note.
|
||||
* Navigates to the profile page and clicks the Connect button.
|
||||
* Throws WeeklyLimitError if the weekly limit popup appears.
|
||||
* Throws AlreadyConnectedError / PendingInviteError if already in that state.
|
||||
*/
|
||||
export async function sendConnectionRequest(page: Page, linkedinUrl: string): Promise<void> {
|
||||
await page.goto(linkedinUrl, { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
await page.waitForTimeout(2000 + Math.random() * 1000);
|
||||
|
||||
// Already connected? Primary signal: presence of the profile's "Message" link
|
||||
// (only shown to 1st-degree connections) — an href attribute, not a CSS class
|
||||
// or translated text, so it survives LinkedIn's class-name hashing and non-
|
||||
// English UI languages. Falls back to the old text-scrape for accounts/layouts
|
||||
// where that link isn't found as a plain <a href>.
|
||||
//
|
||||
// MUST be scoped to the visited person's own intro/top card — a page-wide
|
||||
// search also matches "Message" links / "1st" badges belonging to OTHER
|
||||
// people rendered elsewhere on the page (sidebar modules like "People also
|
||||
// viewed"). For a non-connected target this false-positived AlreadyConnected,
|
||||
// which skipped the real invite AND (via the message step that follows) sent
|
||||
// a message to a random unrelated 1st-degree connection instead of the
|
||||
// target — see CLAUDE.md / memory for the Jul 2026 incident. The top card is
|
||||
// identified structurally as the section containing the page's own <h1> name
|
||||
// heading, robust to class-name hashing. Mirrors the same fix in visit.ts.
|
||||
const topCard = page.locator("main section").filter({ has: page.locator("h1") }).first();
|
||||
const hasMessageLink = await topCard.locator('a[href*="/messaging/compose"]').first().count() > 0;
|
||||
if (hasMessageLink) throw new AlreadyConnectedError("Already connected");
|
||||
const pageText = await topCard.innerText().catch(() => "");
|
||||
if (/\b1st\b/.test(pageText)) throw new AlreadyConnectedError("Already connected");
|
||||
|
||||
// Pending?
|
||||
if (/\bPending\b/.test(pageText)) throw new PendingInviteError("Invitation already pending");
|
||||
const pendingBtn = page.locator('button[aria-label*="Pending"]:visible');
|
||||
if (await pendingBtn.count() > 0) throw new PendingInviteError("Invitation already pending");
|
||||
|
||||
// Case 1: Direct Connect link (primary CTA) — navigate to its href directly.
|
||||
// Clicking fails because the Sales Nav overlay SVG intercepts pointer events.
|
||||
const directConnect = page.locator('a[aria-label*="Invite"][aria-label*="to connect"]:visible, a[href*="custom-invite"]:visible').first();
|
||||
if (await directConnect.count() > 0) {
|
||||
const href = await directConnect.getAttribute("href");
|
||||
if (!href) throw new Error("Connect link has no href");
|
||||
const inviteUrl = href.startsWith("http") ? href : `https://www.linkedin.com${href}`;
|
||||
await page.goto(inviteUrl, { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
await page.waitForTimeout(1000);
|
||||
} else {
|
||||
// Case 2: Connect is inside the "..." More menu
|
||||
// LinkedIn has two "More" buttons on page: [0] = nav bar, [1] = profile card
|
||||
const moreBtn = page.locator('button[aria-label="More"]:visible').nth(1);
|
||||
await moreBtn.click();
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// Check for Pending in the menu — means invite was already sent
|
||||
const pendingMenuItem = page.locator('[role="menuitem"]:has-text("Pending"):visible');
|
||||
if (await pendingMenuItem.count() > 0) throw new PendingInviteError("Invitation already pending (found in More menu)");
|
||||
|
||||
const connectOption = page.locator('[role="menuitem"]:has-text("Connect"):visible');
|
||||
if (await connectOption.count() === 0) throw new Error("Connect option not found in More menu");
|
||||
await connectOption.first().click();
|
||||
}
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Click "Send without a note" / "Send now"
|
||||
const sendBtn = page.locator(
|
||||
'button:has-text("Send now"), button[aria-label*="Send without"], button[aria-label*="Send invitation"]:not([aria-label*="note"])'
|
||||
);
|
||||
if (await sendBtn.count() > 0) {
|
||||
await sendBtn.first().click({ force: true });
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
|
||||
// Check for weekly limit popup
|
||||
const limitPopup = page.locator('div[class*="ip-fuse-limit-alert__warning"]');
|
||||
if (await limitPopup.count() > 0) throw new WeeklyLimitError("Weekly connection limit reached");
|
||||
|
||||
// Check for error toast
|
||||
const errorToast = page.locator('div[data-test-artdeco-toast-item-type="error"]:visible');
|
||||
if (await errorToast.count() > 0) {
|
||||
const msg = await errorToast.innerText();
|
||||
throw new Error(`Connection error: ${msg.trim()}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Profile enrichment via Sales Navigator profile page intercept.
|
||||
*
|
||||
* Strategy: navigate to the lead's sales_nav_url. Sales Nav automatically
|
||||
* fires a salesApiProfiles call that returns the full profile — headline,
|
||||
* all positions with descriptions, degree, flagshipProfileUrl, summary.
|
||||
* We intercept that response instead of making extra API calls.
|
||||
*
|
||||
* Weight: ~5-8s per profile (one real browser page load). Run fire-and-forget
|
||||
* after import — do not await in API routes.
|
||||
*/
|
||||
import type { BrowserContext } from "playwright";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
interface EnrichedPosition {
|
||||
title: string;
|
||||
companyName: string;
|
||||
current: boolean;
|
||||
startedOn?: { year?: number; month?: number };
|
||||
endedOn?: { year?: number; month?: number };
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface SalesNavProfileData {
|
||||
entityUrn?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
fullName?: string;
|
||||
headline?: string;
|
||||
summary?: string;
|
||||
location?: string;
|
||||
degree?: number;
|
||||
flagshipProfileUrl?: string;
|
||||
objectUrn?: string;
|
||||
positions?: EnrichedPosition[];
|
||||
skills?: Array<{ name?: string }>;
|
||||
}
|
||||
|
||||
interface InterceptedResponse {
|
||||
data?: SalesNavProfileData;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export async function enrichProfile(
|
||||
ctx: BrowserContext,
|
||||
target: { id: string; sales_nav_url: string; full_name: string }
|
||||
): Promise<boolean> {
|
||||
const db = getDb();
|
||||
const page = await ctx.newPage();
|
||||
|
||||
try {
|
||||
// Box it so the async response listener can mutate it (TS can't narrow across async callbacks)
|
||||
const box: { data: SalesNavProfileData | null } = { data: null };
|
||||
|
||||
// Intercept the salesApiProfiles call that Sales Nav fires automatically on profile load.
|
||||
// Two calls fire per page load:
|
||||
// 1st — flat object with entityUrn, headline, positions, flagshipProfileUrl (what we want)
|
||||
// 2nd — flat object with educations, skills, numOfConnections (supplementary)
|
||||
// Both are flat — no .data wrapper. Accept the first one that has entityUrn.
|
||||
page.on("response", async (resp) => {
|
||||
if (box.data) return;
|
||||
if (!resp.url().includes("salesApiProfiles")) return;
|
||||
if (resp.status() !== 200) return;
|
||||
try {
|
||||
const json = await resp.json() as Record<string, unknown>;
|
||||
if ("entityUrn" in json && "firstName" in json) {
|
||||
box.data = json as unknown as SalesNavProfileData;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
await page.goto(target.sales_nav_url, { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
await page.waitForTimeout(8000);
|
||||
|
||||
const intercepted = box.data;
|
||||
if (!intercepted) {
|
||||
console.log(`[enrich] No profile data intercepted for ${target.full_name} — skipping`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract positions — include all, not just current
|
||||
const positions: EnrichedPosition[] = (intercepted.positions ?? []).map((p) => ({
|
||||
title: p.title ?? "",
|
||||
companyName: p.companyName ?? "",
|
||||
current: p.current ?? false,
|
||||
startedOn: p.startedOn,
|
||||
endedOn: p.endedOn,
|
||||
description: p.description ?? undefined,
|
||||
}));
|
||||
|
||||
// Skills come from a separate decoration call — if not in intercept, skip for now
|
||||
// (the page-load intercept does not include skills; they'd need a second fetch)
|
||||
const skills: string[] = (intercepted.skills ?? [])
|
||||
.map((s) => s.name)
|
||||
.filter((n): n is string => !!n);
|
||||
|
||||
db.prepare(`
|
||||
UPDATE targets SET
|
||||
headline = COALESCE(?, headline),
|
||||
summary = COALESCE(?, summary),
|
||||
positions_json = ?,
|
||||
skills_json = CASE WHEN ? IS NOT NULL THEN ? ELSE skills_json END,
|
||||
enriched_profile_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
intercepted.headline ?? null,
|
||||
intercepted.summary ?? null,
|
||||
positions.length > 0 ? JSON.stringify(positions) : null,
|
||||
skills.length > 0 ? "1" : null,
|
||||
skills.length > 0 ? JSON.stringify(skills) : null,
|
||||
target.id
|
||||
);
|
||||
|
||||
console.log(`[enrich] ${target.full_name} — ${positions.length} positions, ${skills.length} skills, headline: ${!!intercepted.headline}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[enrich] Error enriching ${target.full_name}:`, err instanceof Error ? err.message : err);
|
||||
return false;
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function enrichList(
|
||||
ctx: BrowserContext,
|
||||
listId: string,
|
||||
delayMs = 2000,
|
||||
onProgress?: (count: number, total: number) => void
|
||||
): Promise<{ enriched: number; failed: number }> {
|
||||
const db = getDb();
|
||||
|
||||
// Only targets with a sales_nav_url that haven't been enriched yet
|
||||
const targets = db.prepare(`
|
||||
SELECT t.id, t.sales_nav_url, t.full_name
|
||||
FROM targets t
|
||||
JOIN list_targets lt ON lt.target_id = t.id
|
||||
WHERE lt.list_id = ?
|
||||
AND t.sales_nav_url IS NOT NULL
|
||||
AND t.enriched_profile_at IS NULL
|
||||
ORDER BY t.created_at ASC
|
||||
`).all(listId) as Array<{ id: string; sales_nav_url: string; full_name: string }>;
|
||||
|
||||
const total = targets.length;
|
||||
console.log(`[enrich] Starting enrichment for ${total} profiles in list ${listId}`);
|
||||
|
||||
let enriched = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
const target = targets[i];
|
||||
console.log(`[enrich] ${i + 1}/${total} — ${target.full_name}`);
|
||||
const ok = await enrichProfile(ctx, target);
|
||||
if (ok) enriched++; else failed++;
|
||||
console.log(`[enrich] ${i + 1}/${total} done (${enriched} ok, ${failed} failed)`);
|
||||
onProgress?.(i + 1, total);
|
||||
if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
|
||||
}
|
||||
|
||||
console.log(`[enrich] Done — ${enriched} enriched, ${failed} failed`);
|
||||
return { enriched, failed };
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Page } from "playwright";
|
||||
|
||||
export interface LinkedInStats {
|
||||
connections: number;
|
||||
pending: number;
|
||||
profile_views: number;
|
||||
}
|
||||
|
||||
function parseNum(str: string): number {
|
||||
return parseInt(str.replace(/[^0-9]/g, ""), 10) || 0;
|
||||
}
|
||||
|
||||
export async function scrapeLinkedInStats(page: Page): Promise<LinkedInStats> {
|
||||
// ── Connections ───────────────────────────────────────────
|
||||
await page.goto("https://www.linkedin.com/mynetwork/invite-connect/connections/", {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 30000,
|
||||
});
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
const connectionsTexts = await page.evaluate(() =>
|
||||
[...document.querySelectorAll("h1,h2,h3,span,p")]
|
||||
.map(el => ((el as HTMLElement).innerText ?? "").trim())
|
||||
.filter(t => /\d.*connection/i.test(t))
|
||||
);
|
||||
const connections = parseNum(connectionsTexts[0] ?? "0");
|
||||
|
||||
// ── Pending sent ──────────────────────────────────────────
|
||||
await page.goto("https://www.linkedin.com/mynetwork/invitation-manager/sent/", {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 30000,
|
||||
});
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
const pendingTexts = await page.evaluate(() =>
|
||||
[...document.querySelectorAll("button,a,span,h1,h2,h3")]
|
||||
.map(el => ((el as HTMLElement).innerText ?? "").trim())
|
||||
.filter(t => /People\s*\(\d+\)/i.test(t))
|
||||
);
|
||||
const pendingMatch = (pendingTexts[0] ?? "").match(/\((\d+)\)/);
|
||||
const pending = pendingMatch ? parseInt(pendingMatch[1], 10) : 0;
|
||||
|
||||
// ── Profile views ─────────────────────────────────────────
|
||||
await page.goto("https://www.linkedin.com/analytics/profile-views/", {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 30000,
|
||||
});
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const profileViewsTexts = await page.evaluate(() =>
|
||||
[...document.querySelectorAll("*")]
|
||||
.map(el => ((el as HTMLElement).innerText ?? "").trim())
|
||||
.filter(t => /Profile viewers/i.test(t) && t.length < 100)
|
||||
);
|
||||
// The block looks like "185\n\nProfile viewers\n\n30% previous week"
|
||||
const pvBlock = profileViewsTexts[0] ?? "";
|
||||
const profile_views = parseNum(pvBlock.split("\n")[0]);
|
||||
|
||||
return { connections, pending, profile_views };
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { Page } from "playwright";
|
||||
import { visitProfile } from "./visit";
|
||||
|
||||
export class NotConnectedError extends Error {}
|
||||
|
||||
export interface SendMessageResult {
|
||||
messagingUrn: string | null;
|
||||
isFirstDegree: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message to a LinkedIn 1st-degree connection.
|
||||
*
|
||||
* Self-contained URN resolution — does NOT depend on a prior 'visit' workflow
|
||||
* step. If messagingUrn is already cached, it's used directly (no extra page
|
||||
* load). Otherwise this does its own live profile check (the same top-card-
|
||||
* scoped logic as the 'visit' step, see visit.ts) to fetch a fresh URN and
|
||||
* verify the target is still actually connected, immediately before sending.
|
||||
* Only if that live check finds no URN despite confirming 1st-degree does it
|
||||
* fall back to the connections-only name-search typeahead — a rare last
|
||||
* resort now, not the default path for every contact that lacks a cached URN.
|
||||
*
|
||||
* Throws NotConnectedError if the live check finds the target is NOT 1st-
|
||||
* degree, instead of guessing via typeahead search (which can silently hit
|
||||
* an unrelated connection with a similar/truncated name — see CLAUDE.md /
|
||||
* memory for the Jul 2026 incident this replaced).
|
||||
*
|
||||
* Returns the resolved { messagingUrn, isFirstDegree } so the caller can
|
||||
* persist it to the target record, same as the 'visit' step does.
|
||||
*/
|
||||
export async function sendMessage(
|
||||
page: Page,
|
||||
fullName: string,
|
||||
text: string,
|
||||
linkedinUrl: string,
|
||||
messagingUrn?: string | null
|
||||
): Promise<SendMessageResult> {
|
||||
if (messagingUrn) {
|
||||
const opened = await openComposeByUrn(page, messagingUrn);
|
||||
if (opened) {
|
||||
await sendFromComposeBox(page, text);
|
||||
return { messagingUrn, isFirstDegree: true };
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = await visitProfile(page, linkedinUrl);
|
||||
if (resolved.messagingUrn) {
|
||||
const opened = await openComposeByUrn(page, resolved.messagingUrn);
|
||||
if (opened) {
|
||||
await sendFromComposeBox(page, text);
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
if (!resolved.isFirstDegree) {
|
||||
throw new NotConnectedError(`${fullName} is not a 1st-degree connection — refusing to message`);
|
||||
}
|
||||
|
||||
// Connected, but no message link could be resolved live (unusual layout) —
|
||||
// last-resort fallback to name search.
|
||||
await sendMessageViaTypeahead(page, fullName, text);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async function openComposeByUrn(page: Page, messagingUrn: string): Promise<boolean> {
|
||||
try {
|
||||
const recipientId = messagingUrn.split(":").pop();
|
||||
const composeUrl = `https://www.linkedin.com/messaging/compose/?profileUrn=${encodeURIComponent(messagingUrn)}&recipient=${recipientId}`;
|
||||
await page.goto(composeUrl, { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
await page.waitForTimeout(1500 + Math.random() * 1000);
|
||||
const msgInput = page.locator("div.msg-form__contenteditable").first();
|
||||
await msgInput.waitFor({ timeout: 8000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessageViaTypeahead(page: Page, fullName: string, text: string): Promise<void> {
|
||||
await page.goto("https://www.linkedin.com/messaging/thread/new/", {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 30000,
|
||||
});
|
||||
await page.waitForTimeout(1500 + Math.random() * 1000);
|
||||
|
||||
// Search for recipient by name
|
||||
const searchField = page.locator("input.msg-connections-typeahead__search-field").first();
|
||||
await searchField.waitFor({ timeout: 10000 });
|
||||
await searchField.click();
|
||||
await searchField.type(fullName, { delay: 60 + Math.random() * 40 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Select first result — but verify it's actually the intended person first.
|
||||
// This search only returns 1st-degree connections; if the real target isn't
|
||||
// connected, LinkedIn will still happily return a same/similar-named
|
||||
// connection as the top result, and we'd silently message a stranger.
|
||||
const firstResult = page.locator('div[class*="msg-connections-typeahead__search-result-row"]').first();
|
||||
await firstResult.waitFor({ timeout: 8000 });
|
||||
const resultText = (await firstResult.innerText().catch(() => "")).trim();
|
||||
if (!resultNameMatches(resultText, fullName)) {
|
||||
throw new Error(
|
||||
`Typeahead search for "${fullName}" returned a non-matching result ("${resultText.replace(/\s+/g, " ")}") — refusing to send to avoid messaging the wrong person`
|
||||
);
|
||||
}
|
||||
await firstResult.click({ delay: 100 });
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
await sendFromComposeBox(page, text);
|
||||
}
|
||||
|
||||
function resultNameMatches(resultText: string, fullName: string): boolean {
|
||||
const normalize = (s: string) =>
|
||||
s.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[^a-z\s]/g, " ").replace(/\s+/g, " ").trim();
|
||||
const target = normalize(fullName);
|
||||
if (!target) return false;
|
||||
return normalize(resultText).includes(target);
|
||||
}
|
||||
|
||||
async function sendFromComposeBox(page: Page, text: string): Promise<void> {
|
||||
// Paste message into compose area
|
||||
const msgInput = page.locator("div.msg-form__contenteditable").first();
|
||||
await msgInput.waitFor({ timeout: 8000 });
|
||||
await msgInput.click();
|
||||
try {
|
||||
await page.evaluate((t) => navigator.clipboard.writeText(t), text);
|
||||
await page.waitForTimeout(300);
|
||||
await msgInput.press("Control+V");
|
||||
} catch {
|
||||
// Clipboard blocked in headless — fall back to keyboard typing
|
||||
await msgInput.pressSequentially(text, { delay: 20 });
|
||||
}
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Send
|
||||
const sendBtn = page.locator("button.msg-form__send-button:visible").first();
|
||||
await sendBtn.waitFor({ timeout: 5000 });
|
||||
await sendBtn.click({ delay: 100 });
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Page } from "playwright";
|
||||
|
||||
/**
|
||||
* Scrapes the full list of vanity names from the sent invitations page.
|
||||
* Returns a Set of lowercase vanity names that are STILL pending on LinkedIn.
|
||||
*
|
||||
* Strategy: navigate to invitation-manager/sent/, scroll the #workspace container
|
||||
* until the count stabilises, then extract all /in/ links.
|
||||
*
|
||||
* Works headless — the scrollable element is <main id="workspace">, not window.
|
||||
*/
|
||||
export async function scrapePendingInvitationVanityNames(page: Page): Promise<Set<string>> {
|
||||
await page.goto("https://www.linkedin.com/mynetwork/invitation-manager/sent/", {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 30000,
|
||||
});
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Scroll #workspace until link count stabilises
|
||||
let lastCount = 0;
|
||||
let stableRounds = 0;
|
||||
|
||||
for (let round = 0; round < 40; round++) {
|
||||
await page.evaluate(() => {
|
||||
const workspace = document.getElementById("workspace");
|
||||
if (workspace) {
|
||||
workspace.scrollTop = workspace.scrollHeight;
|
||||
} else {
|
||||
// Fallback: scroll window + body
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
document.documentElement.scrollTop = document.documentElement.scrollHeight;
|
||||
}
|
||||
});
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
const currentCount = await page.evaluate(() =>
|
||||
document.querySelectorAll("a[href*='/in/']").length
|
||||
);
|
||||
|
||||
if (currentCount === lastCount) {
|
||||
stableRounds++;
|
||||
if (stableRounds >= 3) break;
|
||||
} else {
|
||||
stableRounds = 0;
|
||||
lastCount = currentCount;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract unique vanity names from all /in/ links
|
||||
const vanityNames = await page.evaluate(() => {
|
||||
const seen = new Set<string>();
|
||||
for (const link of document.querySelectorAll<HTMLAnchorElement>("a[href*='/in/']")) {
|
||||
const href = link.getAttribute("href") ?? "";
|
||||
const match = href.match(/\/in\/([^/?#]+)/);
|
||||
if (match) seen.add(match[1].toLowerCase());
|
||||
}
|
||||
return [...seen];
|
||||
});
|
||||
|
||||
return new Set(vanityNames);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Live profile scrape → clean structured JSON for an LLM.
|
||||
*
|
||||
* Two confirmed sources (probed against prod, Jun 2026 — see docs §18):
|
||||
* 1. Sales Nav `salesApiProfiles` intercept (navigate the lead's sales_nav_url):
|
||||
* two flat responses give headline, summary, positions, educations, skills,
|
||||
* languages, connections, contactInfo, inmailRestriction, memberBadges, degree.
|
||||
* 2. Voyager `feed/updates?q=memberShareFeed` on the /in/ publicId:
|
||||
* the 10 most recent authored posts (text + media type + engagement counts)
|
||||
* plus the comments left on those posts. (q=memberComments → 400, not used.)
|
||||
*
|
||||
* Read-only. ~10-12s per profile (one Sales Nav page load + one Voyager fetch).
|
||||
*/
|
||||
import type { BrowserContext } from "playwright";
|
||||
|
||||
export interface ProfilePost {
|
||||
activityUrn: string;
|
||||
text: string;
|
||||
postedAt: string | null; // ISO, when derivable from the activity id
|
||||
mediaType: "video" | "image" | "article" | "text";
|
||||
reactions: number;
|
||||
comments: number;
|
||||
shares: number;
|
||||
}
|
||||
|
||||
export interface ProfileScrape {
|
||||
full_name: string | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
headline: string | null;
|
||||
summary: string | null;
|
||||
location: string | null;
|
||||
degree: number | null; // network distance (1/2/3)
|
||||
linkedin_url: string | null; // real /in/ URL
|
||||
num_connections: number | null;
|
||||
num_shared_connections: number | null;
|
||||
is_premium: boolean;
|
||||
is_open_profile: boolean; // openLink — can be messaged free (note: API often under-reports)
|
||||
can_inmail: boolean; // inmailRestriction === NO_RESTRICTION
|
||||
pending_invitation: boolean;
|
||||
websites: string[];
|
||||
positions: Array<{
|
||||
title: string;
|
||||
company: string;
|
||||
current: boolean;
|
||||
started: { year?: number; month?: number } | null;
|
||||
ended: { year?: number; month?: number } | null;
|
||||
description: string | null;
|
||||
}>;
|
||||
educations: Array<{ school: string | null; degree: string | null; field: string | null }>;
|
||||
skills: string[];
|
||||
languages: string[];
|
||||
recent_posts: ProfilePost[]; // up to 10, most recent first
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function textOf(v: unknown): string | null {
|
||||
if (typeof v === "string") return v;
|
||||
if (v && typeof v === "object" && "text" in v && typeof (v as { text: unknown }).text === "string") {
|
||||
return (v as { text: string }).text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// LinkedIn activity/ugcPost ids encode the creation epoch-ms in the high 41 bits
|
||||
// (the low 22 bits are a sequence). Shift right 22 = divide by 2^22, via float
|
||||
// math to avoid BigInt (tsconfig targets < ES2020).
|
||||
const SHIFT_22 = 4194304; // 2^22
|
||||
function timeFromActivityUrn(urn: string): string | null {
|
||||
const m = urn.match(/(\d{18,})/);
|
||||
if (!m) return null;
|
||||
const ms = Math.floor(Number(m[1]) / SHIFT_22);
|
||||
return ms > 1_000_000_000_000 ? new Date(ms).toISOString() : null;
|
||||
}
|
||||
|
||||
interface SalesPosition {
|
||||
title?: string; companyName?: string; current?: boolean;
|
||||
startedOn?: { year?: number; month?: number };
|
||||
endedOn?: { year?: number; month?: number };
|
||||
description?: string;
|
||||
}
|
||||
interface SalesProfileFlat {
|
||||
entityUrn?: string; firstName?: string; lastName?: string; fullName?: string;
|
||||
headline?: string; summary?: string; location?: string; degree?: number;
|
||||
flagshipProfileUrl?: string; pendingInvitation?: boolean;
|
||||
positions?: SalesPosition[];
|
||||
contactInfo?: { websites?: Array<{ url?: string }> };
|
||||
educations?: Array<{ schoolName?: string; degreeName?: string; fieldOfStudy?: string }>;
|
||||
skills?: Array<{ name?: string }>;
|
||||
languages?: Array<{ name?: string }>;
|
||||
numOfConnections?: number; numOfSharedConnections?: number;
|
||||
inmailRestriction?: string;
|
||||
memberBadges?: { premium?: boolean; openLink?: boolean };
|
||||
}
|
||||
|
||||
// ─── Sales Nav career ─────────────────────────────────────────────────────────
|
||||
|
||||
async function scrapeSalesCareer(
|
||||
ctx: BrowserContext,
|
||||
salesNavUrl: string
|
||||
): Promise<Partial<ProfileScrape>> {
|
||||
const page = await ctx.newPage();
|
||||
const responses: SalesProfileFlat[] = [];
|
||||
page.on("response", async (resp) => {
|
||||
if (!resp.url().includes("salesApiProfiles") || resp.status() !== 200) return;
|
||||
try {
|
||||
const j = (await resp.json()) as Record<string, unknown>;
|
||||
// Only the two flat profile responses (have entityUrn); ignore the data/included one.
|
||||
if ("entityUrn" in j && ("firstName" in j || "educations" in j || "skills" in j)) {
|
||||
responses.push(j as unknown as SalesProfileFlat);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(salesNavUrl, { waitUntil: "domcontentloaded", timeout: 35000 });
|
||||
await page.waitForTimeout(9000);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
|
||||
// Merge the (typically two) flat responses — first carries the core profile,
|
||||
// second carries educations/skills/languages/connections/inmailRestriction.
|
||||
const core = responses.find((r) => r.firstName || r.positions) ?? {};
|
||||
const extra = responses.find((r) => r.educations || r.skills || r.numOfConnections != null) ?? {};
|
||||
|
||||
const positions = (core.positions ?? []).map((p) => ({
|
||||
title: p.title ?? "",
|
||||
company: p.companyName ?? "",
|
||||
current: p.current ?? false,
|
||||
started: p.startedOn ?? null,
|
||||
ended: p.endedOn ?? null,
|
||||
description: p.description?.trim() ? p.description : null,
|
||||
}));
|
||||
|
||||
return {
|
||||
full_name: core.fullName ?? null,
|
||||
first_name: core.firstName ?? null,
|
||||
last_name: core.lastName ?? null,
|
||||
headline: core.headline ?? null,
|
||||
summary: core.summary ?? null,
|
||||
location: core.location ?? null,
|
||||
degree: core.degree ?? null,
|
||||
linkedin_url: core.flagshipProfileUrl ?? null,
|
||||
pending_invitation: core.pendingInvitation ?? false,
|
||||
websites: (core.contactInfo?.websites ?? []).map((w) => w.url).filter((u): u is string => !!u),
|
||||
positions,
|
||||
num_connections: extra.numOfConnections ?? null,
|
||||
num_shared_connections: extra.numOfSharedConnections ?? null,
|
||||
is_premium: extra.memberBadges?.premium ?? false,
|
||||
is_open_profile: extra.memberBadges?.openLink ?? false,
|
||||
can_inmail: (extra.inmailRestriction ?? core.inmailRestriction) === "NO_RESTRICTION",
|
||||
educations: (extra.educations ?? []).map((e) => ({
|
||||
school: e.schoolName ?? null, degree: e.degreeName ?? null, field: e.fieldOfStudy ?? null,
|
||||
})),
|
||||
skills: (extra.skills ?? []).map((s) => s.name).filter((n): n is string => !!n),
|
||||
languages: (extra.languages ?? []).map((l) => l.name).filter((n): n is string => !!n),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Voyager posts ────────────────────────────────────────────────────────────
|
||||
|
||||
function publicIdFromUrl(url: string | null | undefined): string | null {
|
||||
const m = url && url.match(/\/in\/([^/?#]+)/);
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
}
|
||||
|
||||
async function scrapePosts(
|
||||
ctx: BrowserContext,
|
||||
publicId: string
|
||||
): Promise<ProfilePost[]> {
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
await page.goto("https://www.linkedin.com/feed/", { waitUntil: "domcontentloaded", timeout: 25000 });
|
||||
const cookies = await ctx.cookies();
|
||||
const csrf = (cookies.find((c) => c.name === "JSESSIONID")?.value || "").replace(/"/g, "");
|
||||
if (!csrf) return [];
|
||||
|
||||
const url =
|
||||
`https://www.linkedin.com/voyager/api/feed/updates` +
|
||||
`?profileId=${encodeURIComponent(publicId)}&q=memberShareFeed&count=10&start=0`;
|
||||
const raw = await page.evaluate(
|
||||
async ({ url, csrf }: { url: string; csrf: string }) => {
|
||||
const r = await fetch(url, {
|
||||
headers: {
|
||||
accept: "application/vnd.linkedin.normalized+json+2.1",
|
||||
"csrf-token": csrf,
|
||||
"x-restli-protocol-version": "2.0.0",
|
||||
},
|
||||
credentials: "include",
|
||||
});
|
||||
return r.status === 200 ? r.text() : "";
|
||||
},
|
||||
{ url, csrf }
|
||||
);
|
||||
if (!raw) return [];
|
||||
|
||||
const j = JSON.parse(raw) as { included?: Array<Record<string, unknown>> };
|
||||
const inc = j.included ?? [];
|
||||
|
||||
// Engagement counts keyed by the post's ugcPost/share urn.
|
||||
const countsByUrn = new Map<string, { reactions: number; comments: number; shares: number }>();
|
||||
for (const x of inc) {
|
||||
if (!String(x["$type"]).endsWith("SocialActivityCounts")) continue;
|
||||
const urn = String(x.urn ?? "");
|
||||
if (!urn.includes("ugcPost") && !urn.includes("activity")) continue;
|
||||
countsByUrn.set(urn, {
|
||||
reactions: Number(x.numLikes ?? 0),
|
||||
comments: Number(x.numComments ?? 0),
|
||||
shares: Number(x.numShares ?? 0),
|
||||
});
|
||||
}
|
||||
|
||||
const posts: ProfilePost[] = [];
|
||||
for (const x of inc) {
|
||||
if (!String(x["$type"]).endsWith("UpdateV2")) continue;
|
||||
const commentary = x.commentary as { text?: unknown } | undefined;
|
||||
const text = commentary ? textOf(commentary.text) : null;
|
||||
if (!text) continue;
|
||||
const meta = x.updateMetadata as { urn?: string } | undefined;
|
||||
const activityUrn = meta?.urn ?? "";
|
||||
|
||||
// Find the matching counts (the SocialActivityCounts urn references the backing ugcPost).
|
||||
let counts = { reactions: 0, comments: 0, shares: 0 };
|
||||
for (const [urn, c] of countsByUrn) {
|
||||
const idActivity = activityUrn.match(/(\d{18,})/)?.[1];
|
||||
if (idActivity && urn.includes(idActivity)) { counts = c; break; }
|
||||
}
|
||||
|
||||
const content = x.content as Record<string, unknown> | undefined;
|
||||
const ctype = content ? String(Object.keys(content)[0] ?? "").toLowerCase() : "";
|
||||
const mediaType: ProfilePost["mediaType"] =
|
||||
ctype.includes("video") ? "video" :
|
||||
ctype.includes("image") ? "image" :
|
||||
ctype.includes("article") ? "article" : "text";
|
||||
|
||||
posts.push({
|
||||
activityUrn,
|
||||
text,
|
||||
postedAt: timeFromActivityUrn(activityUrn),
|
||||
mediaType,
|
||||
reactions: counts.reactions,
|
||||
comments: counts.comments,
|
||||
shares: counts.shares,
|
||||
});
|
||||
}
|
||||
|
||||
// Most recent first (by encoded time when available).
|
||||
posts.sort((a, b) => (b.postedAt ?? "").localeCompare(a.postedAt ?? ""));
|
||||
return posts.slice(0, 10);
|
||||
} catch {
|
||||
return [];
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── public ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function scrapeProfile(
|
||||
ctx: BrowserContext,
|
||||
target: { sales_nav_url: string | null; linkedin_url: string | null }
|
||||
): Promise<ProfileScrape> {
|
||||
if (!target.sales_nav_url) {
|
||||
throw new Error("Contact has no sales_nav_url — cannot scrape the Sales Nav profile.");
|
||||
}
|
||||
|
||||
const career = await scrapeSalesCareer(ctx, target.sales_nav_url);
|
||||
|
||||
// Prefer the flagship /in/ URL from the career scrape; fall back to the stored one.
|
||||
const inUrl = career.linkedin_url ?? target.linkedin_url ?? null;
|
||||
const publicId = publicIdFromUrl(inUrl);
|
||||
const recent_posts = publicId ? await scrapePosts(ctx, publicId) : [];
|
||||
|
||||
return {
|
||||
full_name: career.full_name ?? null,
|
||||
first_name: career.first_name ?? null,
|
||||
last_name: career.last_name ?? null,
|
||||
headline: career.headline ?? null,
|
||||
summary: career.summary ?? null,
|
||||
location: career.location ?? null,
|
||||
degree: career.degree ?? null,
|
||||
linkedin_url: inUrl,
|
||||
num_connections: career.num_connections ?? null,
|
||||
num_shared_connections: career.num_shared_connections ?? null,
|
||||
is_premium: career.is_premium ?? false,
|
||||
is_open_profile: career.is_open_profile ?? false,
|
||||
can_inmail: career.can_inmail ?? false,
|
||||
pending_invitation: career.pending_invitation ?? false,
|
||||
websites: career.websites ?? [],
|
||||
positions: career.positions ?? [],
|
||||
educations: career.educations ?? [],
|
||||
skills: career.skills ?? [],
|
||||
languages: career.languages ?? [],
|
||||
recent_posts,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type DatabaseType from "better-sqlite3";
|
||||
|
||||
type DB = DatabaseType.Database;
|
||||
export interface ResolvedAccount { id: string; email: string }
|
||||
|
||||
/**
|
||||
* Resolve which authenticated LinkedIn account to act through for a contact.
|
||||
* Order: explicit id → the contact's most recent run assignment → the sole
|
||||
* authenticated account. Only ever returns an `is_authenticated = 1` account
|
||||
* so callers never drive a dead session.
|
||||
*/
|
||||
export function resolveLinkedInAccount(db: DB, targetId: string, explicitId?: string): ResolvedAccount | null {
|
||||
const byId = (aid: string) =>
|
||||
db.prepare("SELECT id, email FROM accounts WHERE id = ? AND is_authenticated = 1").get(aid) as
|
||||
| ResolvedAccount
|
||||
| undefined;
|
||||
|
||||
if (explicitId) return byId(explicitId) ?? null;
|
||||
|
||||
const assigned = db.prepare(`
|
||||
SELECT r.account_id FROM run_profiles rp
|
||||
JOIN runs r ON r.id = rp.run_id
|
||||
WHERE rp.target_id = ?
|
||||
ORDER BY rp.created_at DESC LIMIT 1
|
||||
`).get(targetId) as { account_id: string } | undefined;
|
||||
if (assigned?.account_id) {
|
||||
const a = byId(assigned.account_id);
|
||||
if (a) return a;
|
||||
}
|
||||
|
||||
const all = db.prepare("SELECT id, email FROM accounts WHERE is_authenticated = 1").all() as ResolvedAccount[];
|
||||
return all.length === 1 ? all[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an authenticated LinkedIn account when there's no target/contact to
|
||||
* anchor the lookup to (e.g. Sales Navigator search, which runs before any
|
||||
* lead exists locally). Order: explicit id → the sole authenticated account.
|
||||
*/
|
||||
export function resolveAnyAuthenticatedAccount(db: DB, explicitId?: string): ResolvedAccount | null {
|
||||
const byId = (aid: string) =>
|
||||
db.prepare("SELECT id, email FROM accounts WHERE id = ? AND is_authenticated = 1").get(aid) as
|
||||
| ResolvedAccount
|
||||
| undefined;
|
||||
|
||||
if (explicitId) return byId(explicitId) ?? null;
|
||||
|
||||
const all = db.prepare("SELECT id, email FROM accounts WHERE is_authenticated = 1").all() as ResolvedAccount[];
|
||||
return all.length === 1 ? all[0] : null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* Sales Navigator list scraper using the internal Sales API.
|
||||
* Uses Playwright's browser context request (inherits browser TLS fingerprint).
|
||||
*
|
||||
* Response format discovery (from capture scripts):
|
||||
* - ctx.request returns normalized JSON: { data: { metadata: { totalDisplayCount: "106" } }, included: [...] }
|
||||
* - Profiles are in `included` filtered by entityUrn containing "salesProfile"
|
||||
* - Browser-intercepted first page returns flat: { elements: [...], paging: { total } }
|
||||
* - Query format: parentheses/commas unencoded, colons in URN encoded as %3A
|
||||
*
|
||||
* IMPORTANT — vanityName is gone from salesApiPeopleSearch (dropped by LinkedIn ~March 2026):
|
||||
* The list API no longer returns vanityName. To get the real /in/ URL we call
|
||||
* salesApiProfiles per batch of 25 after scraping, using flagshipProfileUrl.
|
||||
* See docs/linkedin-api-learnings.md for the full investigation.
|
||||
*/
|
||||
import type { BrowserContext, Page } from "playwright";
|
||||
|
||||
export interface ScrapedProfile {
|
||||
salesNavUrn: string;
|
||||
salesNavUrl: string;
|
||||
linkedinUrl: string | null; // regular /in/ URL
|
||||
fullName: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
title: string | null;
|
||||
company: string | null;
|
||||
location: string | null;
|
||||
degree: number | null;
|
||||
// Extended fields
|
||||
objectUrn: string | null; // urn:li:member:XXXX — stable LinkedIn member ID
|
||||
summary: string | null; // About / bio text
|
||||
openLink: boolean; // can message without connecting
|
||||
companyIndustry: string | null;
|
||||
companyLocation: string | null; // company HQ location
|
||||
tenureMonths: number | null; // months in current role
|
||||
spotlightBadges: string | null; // JSON array of badge displayValues
|
||||
}
|
||||
|
||||
interface SalesProfile {
|
||||
entityUrn: string;
|
||||
objectUrn?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
fullName?: string;
|
||||
vanityName?: string;
|
||||
geoRegion?: string;
|
||||
degree?: number;
|
||||
summary?: string;
|
||||
openLink?: boolean;
|
||||
premium?: boolean;
|
||||
pendingInvitation?: boolean;
|
||||
currentPositions?: Array<{
|
||||
title?: string;
|
||||
companyName?: string;
|
||||
companyUrn?: string;
|
||||
current?: boolean;
|
||||
description?: string;
|
||||
startedOn?: { year?: number; month?: number };
|
||||
tenureAtPosition?: { numYears?: number; numMonths?: number };
|
||||
tenureAtCompany?: { numYears?: number; numMonths?: number };
|
||||
companyUrnResolutionResult?: {
|
||||
name?: string;
|
||||
industry?: string;
|
||||
location?: string;
|
||||
entityUrn?: string;
|
||||
};
|
||||
}>;
|
||||
leadAssociatedAccount?: { name?: string } | null;
|
||||
spotlightBadges?: Array<{ displayValue?: string; id?: string }>;
|
||||
}
|
||||
|
||||
// Flat response intercepted from browser
|
||||
interface FlatResponse {
|
||||
elements?: SalesProfile[];
|
||||
paging?: { total: number; count: number; start: number };
|
||||
}
|
||||
|
||||
// salesApiProfiles single-profile response
|
||||
interface ProfileDetailResponse {
|
||||
entityUrn?: string;
|
||||
flagshipProfileUrl?: string;
|
||||
fullName?: string;
|
||||
}
|
||||
|
||||
function extractListId(url: string): string | null {
|
||||
const match = url.match(/\/sales\/lists\/people\/(\d+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function extractSavedSearchId(url: string): string | null {
|
||||
const match = url.match(/[?&]savedSearchId=(\d+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function urnToSalesNavUrl(urn: string): string {
|
||||
const match = urn.match(/\(([^)]+)\)/);
|
||||
if (!match) return "";
|
||||
return `https://www.linkedin.com/sales/lead/${match[1]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse profileId, authType, authToken out of a salesProfile URN.
|
||||
* urn:li:fs_salesProfile:(ACwAAEIY-4YB25mKP6R5AKfkFjhO9isSbvsVlag,NAME_SEARCH,22wq)
|
||||
*/
|
||||
function parseUrn(entityUrn: string): { profileId: string; authType: string; authToken: string } | null {
|
||||
const match = entityUrn.match(/\(([^,]+),([^,]+),([^)]+)\)/);
|
||||
if (!match) return null;
|
||||
return { profileId: match[1], authType: match[2], authToken: match[3] };
|
||||
}
|
||||
|
||||
function profileToResult(el: SalesProfile, linkedinUrl: string | null): ScrapedProfile {
|
||||
const currentPos = el.currentPositions?.find((p) => p.current) ?? el.currentPositions?.[0];
|
||||
const company = currentPos?.companyUrnResolutionResult ?? null;
|
||||
|
||||
// Tenure in months at current position
|
||||
let tenureMonths: number | null = null;
|
||||
const t = currentPos?.tenureAtPosition;
|
||||
if (t) tenureMonths = (t.numYears ?? 0) * 12 + (t.numMonths ?? 0);
|
||||
|
||||
// Spotlight badge labels as a compact JSON array e.g. ["Changed jobs", "Mentioned in news"]
|
||||
const badges = (el.spotlightBadges ?? [])
|
||||
.map((b) => b.displayValue)
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
return {
|
||||
salesNavUrn: el.entityUrn,
|
||||
salesNavUrl: urnToSalesNavUrl(el.entityUrn),
|
||||
linkedinUrl,
|
||||
fullName: el.fullName ?? null,
|
||||
firstName: el.firstName ?? null,
|
||||
lastName: el.lastName ?? null,
|
||||
title: currentPos?.title ?? null,
|
||||
company: el.leadAssociatedAccount?.name ?? company?.name ?? currentPos?.companyName ?? null,
|
||||
location: el.geoRegion ?? null,
|
||||
degree: el.degree ?? null,
|
||||
objectUrn: el.objectUrn ?? null,
|
||||
summary: el.summary ?? null,
|
||||
openLink: el.openLink ?? false,
|
||||
companyIndustry: company?.industry ?? null,
|
||||
companyLocation: company?.location ?? null,
|
||||
tenureMonths,
|
||||
spotlightBadges: badges.length > 0 ? JSON.stringify(badges) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich profiles with their real /in/ URL via salesApiProfiles (flagshipProfileUrl).
|
||||
*
|
||||
* Uses page.evaluate fetch with JSESSIONID csrf-token. On 429 (rate limit),
|
||||
* backs off for 30s and retries. Delay between calls: 2s normally, 30s after 429.
|
||||
*
|
||||
* For 100 profiles at 2s/call = ~3.5 minutes. Happens once at import time.
|
||||
*/
|
||||
async function enrichWithFlagshipUrls(
|
||||
page: Page,
|
||||
profiles: SalesProfile[],
|
||||
onProgress?: (p: ImportProgress) => void
|
||||
): Promise<Map<string, string>> {
|
||||
const urlMap = new Map<string, string>(); // entityUrn → flagshipProfileUrl
|
||||
|
||||
const cookies = await page.context().cookies("https://www.linkedin.com");
|
||||
const jsessionid = cookies.find(c => c.name === "JSESSIONID")?.value?.replace(/"/g, "") ?? "";
|
||||
if (!jsessionid) {
|
||||
console.log("[scraper] JSESSIONID not found — skipping flagshipProfileUrl enrichment");
|
||||
return urlMap;
|
||||
}
|
||||
|
||||
let done = 0;
|
||||
for (const el of profiles) {
|
||||
const parts = parseUrn(el.entityUrn);
|
||||
if (!parts) continue;
|
||||
|
||||
const { profileId, authType, authToken } = parts;
|
||||
const apiUrl = `https://www.linkedin.com/sales-api/salesApiProfiles/(profileId:${profileId},authType:${authType},authToken:${authToken})?decoration=%28entityUrn%2CflagshipProfileUrl%29`;
|
||||
|
||||
let attempts = 0;
|
||||
while (attempts < 3) {
|
||||
const evaluatePromise = page.evaluate(async ({ url, csrfToken }: { url: string; csrfToken: string }) => {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 12000);
|
||||
const resp = await fetch(url, {
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"csrf-token": csrfToken,
|
||||
"x-restli-protocol-version": "2.0.0",
|
||||
"accept": "application/json",
|
||||
},
|
||||
});
|
||||
clearTimeout(timer);
|
||||
return { status: resp.status, body: await resp.text() };
|
||||
} catch (e: unknown) {
|
||||
return { status: -1, body: (e as Error).message };
|
||||
}
|
||||
}, { url: apiUrl, csrfToken: jsessionid });
|
||||
|
||||
const timeoutPromise = new Promise<{ status: number; body: string }>((resolve) =>
|
||||
setTimeout(() => resolve({ status: -1, body: 'timeout' }), 15000)
|
||||
);
|
||||
const result = await Promise.race([evaluatePromise, timeoutPromise]);
|
||||
|
||||
if (result.status === 200) {
|
||||
try {
|
||||
const data = JSON.parse(result.body) as ProfileDetailResponse;
|
||||
if (data.flagshipProfileUrl) {
|
||||
const normalized = data.flagshipProfileUrl.endsWith("/")
|
||||
? data.flagshipProfileUrl
|
||||
: data.flagshipProfileUrl + "/";
|
||||
urlMap.set(el.entityUrn, normalized);
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
break;
|
||||
} else if (result.status === 429) {
|
||||
attempts++;
|
||||
console.log(`[scraper] 429 rate limit — waiting 30s before retry (attempt ${attempts}/3)`);
|
||||
await page.waitForTimeout(30000);
|
||||
} else {
|
||||
const reason = result.body === 'timeout' ? 'timeout' : result.status;
|
||||
console.log(`[scraper] enrichment ${reason} for ${el.fullName ?? el.entityUrn.substring(0, 30)} — skipping`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 2s between calls — slow enough to stay under rate limits
|
||||
await page.waitForTimeout(2000);
|
||||
done++;
|
||||
console.log(`[scraper] flagship URLs: ${done}/${profiles.length} resolved`);
|
||||
onProgress?.({ phase: 'enriching', count: done, total: profiles.length });
|
||||
}
|
||||
|
||||
console.log(`[scraper] enrichment done: ${urlMap.size}/${profiles.length} profiles resolved`);
|
||||
return urlMap;
|
||||
}
|
||||
|
||||
export interface ImportProgress {
|
||||
phase: 'scraping' | 'enriching' | 'visiting';
|
||||
page?: number;
|
||||
totalPages?: number;
|
||||
count: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ScrapeOptions {
|
||||
/** 1-based page to begin this window at (for batched imports). Default 1. */
|
||||
startPage?: number;
|
||||
/** Max pages to fetch in THIS window, starting at startPage. Default 50. */
|
||||
maxPages?: number;
|
||||
onProgress?: (p: ImportProgress) => void;
|
||||
/** Polled between pages; return true to stop early (cancel / deleted list). */
|
||||
isCanceled?: () => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface WindowedScrapeResult {
|
||||
profiles: ScrapedProfile[];
|
||||
/** Last page actually fetched in this window. */
|
||||
lastPage: number;
|
||||
/** Total profiles available in the whole search. */
|
||||
knownTotal: number;
|
||||
/** True if this window reached the end of the search (nothing left to batch). */
|
||||
exhausted: boolean;
|
||||
}
|
||||
|
||||
export async function scrapeNavigatorList(
|
||||
ctx: BrowserContext,
|
||||
salesNavUrl: string,
|
||||
opts: ScrapeOptions = {}
|
||||
): Promise<WindowedScrapeResult> {
|
||||
const { startPage = 1, maxPages = 50, onProgress, isCanceled } = opts;
|
||||
const listId = extractListId(salesNavUrl);
|
||||
if (!listId) throw new Error(`Invalid Sales Navigator URL: ${salesNavUrl}`);
|
||||
|
||||
const allElements: SalesProfile[] = [];
|
||||
const seen = new Set<string>();
|
||||
const PAGE_SIZE = 25;
|
||||
const buildUrl = (n: number) =>
|
||||
`https://www.linkedin.com/sales/lists/people/${listId}?${n > 1 ? `page=${n}&` : ""}sortCriteria=CREATED_TIME&sortOrder=DESCENDING`;
|
||||
|
||||
const page = await ctx.newPage();
|
||||
let knownTotal = 0;
|
||||
let intercepted: FlatResponse | null = null;
|
||||
|
||||
const waitForIntercept = async (url: string, waitMs: number): Promise<FlatResponse | null> => {
|
||||
intercepted = null;
|
||||
page.removeAllListeners("response");
|
||||
page.on("response", async (response) => {
|
||||
if (intercepted) return;
|
||||
if (response.url().includes("salesApiPeopleSearch") && response.status() === 200) {
|
||||
try { intercepted = await response.json() as FlatResponse; } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
await page.waitForTimeout(waitMs);
|
||||
return intercepted;
|
||||
};
|
||||
|
||||
// First page of the window
|
||||
const firstData = await waitForIntercept(buildUrl(startPage), 15000);
|
||||
if (!firstData) {
|
||||
const finalUrl = page.url();
|
||||
console.error(`[scraper] no intercept after 15s. Final URL: ${finalUrl}`);
|
||||
await page.close();
|
||||
throw new Error("No data intercepted from Sales Nav — session may need re-authentication");
|
||||
}
|
||||
|
||||
knownTotal = firstData.paging?.total ?? 0;
|
||||
for (const el of firstData.elements ?? []) {
|
||||
if (!el.entityUrn || seen.has(el.entityUrn)) continue;
|
||||
seen.add(el.entityUrn);
|
||||
allElements.push(el);
|
||||
}
|
||||
console.log(`[scraper] page ${startPage}: ${allElements.length} elements, total=${knownTotal}`);
|
||||
|
||||
const totalPages = Math.ceil(knownTotal / PAGE_SIZE);
|
||||
const endPage = Math.min(totalPages, startPage + maxPages - 1);
|
||||
let lastPage = startPage;
|
||||
onProgress?.({ phase: 'scraping', page: startPage, totalPages: endPage, count: allElements.length, total: knownTotal });
|
||||
|
||||
for (let pageNum = startPage + 1; pageNum <= endPage; pageNum++) {
|
||||
if (isCanceled && (await isCanceled())) break;
|
||||
const delayMs = 60000 + Math.random() * 60000;
|
||||
console.log(`[scraper] waiting ${Math.round(delayMs / 1000)}s before page ${pageNum}...`);
|
||||
await page.waitForTimeout(delayMs);
|
||||
if (isCanceled && (await isCanceled())) break;
|
||||
|
||||
let pageData = await waitForIntercept(buildUrl(pageNum), 15000);
|
||||
if (!pageData || (pageData.elements?.length ?? 0) === 0) {
|
||||
console.log(`[scraper] page ${pageNum} empty on first try — retrying with 15s wait`);
|
||||
pageData = await waitForIntercept(buildUrl(pageNum), 15000);
|
||||
}
|
||||
if (pageData) {
|
||||
for (const el of pageData.elements ?? []) {
|
||||
if (!el.entityUrn || seen.has(el.entityUrn)) continue;
|
||||
seen.add(el.entityUrn);
|
||||
allElements.push(el);
|
||||
}
|
||||
}
|
||||
lastPage = pageNum;
|
||||
console.log(`[scraper] page ${pageNum}/${endPage}: ${allElements.length} (total ${knownTotal})`);
|
||||
onProgress?.({ phase: 'scraping', page: pageNum, totalPages: endPage, count: allElements.length, total: knownTotal });
|
||||
}
|
||||
|
||||
await page.close();
|
||||
return {
|
||||
profiles: allElements.map(el => profileToResult(el, null)),
|
||||
lastPage,
|
||||
knownTotal,
|
||||
exhausted: lastPage >= totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
export async function scrapeSavedSearch(
|
||||
ctx: BrowserContext,
|
||||
savedSearchUrl: string,
|
||||
opts: ScrapeOptions = {}
|
||||
): Promise<WindowedScrapeResult> {
|
||||
const { startPage = 1, maxPages = 50, onProgress, isCanceled } = opts;
|
||||
const savedSearchId = extractSavedSearchId(savedSearchUrl);
|
||||
if (!savedSearchId) throw new Error(`Invalid Sales Navigator saved search URL: ${savedSearchUrl}`);
|
||||
|
||||
const allElements: SalesProfile[] = [];
|
||||
const seen = new Set<string>();
|
||||
const PAGE_SIZE = 25;
|
||||
const buildUrl = (n: number) =>
|
||||
`https://www.linkedin.com/sales/search/people?savedSearchId=${savedSearchId}${n > 1 ? `&page=${n}` : ""}`;
|
||||
|
||||
const page = await ctx.newPage();
|
||||
let knownTotal = 0;
|
||||
|
||||
const waitForIntercept = async (url: string, waitMs: number): Promise<FlatResponse | null> => {
|
||||
let intercepted: FlatResponse | null = null;
|
||||
page.removeAllListeners("response");
|
||||
page.on("response", async (response) => {
|
||||
if (intercepted) return;
|
||||
if (response.url().includes("salesApiLeadSearch") && response.status() === 200) {
|
||||
try { intercepted = await response.json() as FlatResponse; } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
await page.waitForTimeout(waitMs);
|
||||
return intercepted;
|
||||
};
|
||||
|
||||
// First page of the window
|
||||
const firstData = await waitForIntercept(buildUrl(startPage), 15000);
|
||||
if (!firstData) {
|
||||
await page.close();
|
||||
throw new Error("No data intercepted from saved search — session may need re-authentication");
|
||||
}
|
||||
|
||||
knownTotal = firstData.paging?.total ?? 0;
|
||||
for (const el of firstData.elements ?? []) {
|
||||
if (!el.entityUrn || seen.has(el.entityUrn)) continue;
|
||||
seen.add(el.entityUrn);
|
||||
allElements.push(el);
|
||||
}
|
||||
console.log(`[scraper:saved-search] page ${startPage}: ${allElements.length} elements, total=${knownTotal}`);
|
||||
|
||||
const totalPages = Math.ceil(knownTotal / PAGE_SIZE);
|
||||
const endPage = Math.min(totalPages, startPage + maxPages - 1);
|
||||
let lastPage = startPage;
|
||||
onProgress?.({ phase: 'scraping', page: startPage, totalPages: endPage, count: allElements.length, total: knownTotal });
|
||||
|
||||
for (let pageNum = startPage + 1; pageNum <= endPage; pageNum++) {
|
||||
if (isCanceled && (await isCanceled())) break;
|
||||
const delayMs = 60000 + Math.random() * 60000;
|
||||
console.log(`[scraper:saved-search] waiting ${Math.round(delayMs / 1000)}s before page ${pageNum}...`);
|
||||
await page.waitForTimeout(delayMs);
|
||||
if (isCanceled && (await isCanceled())) break;
|
||||
|
||||
let pageData = await waitForIntercept(buildUrl(pageNum), 15000);
|
||||
if (!pageData || (pageData.elements?.length ?? 0) === 0) {
|
||||
console.log(`[scraper:saved-search] page ${pageNum} empty on first try — retrying with 15s wait`);
|
||||
pageData = await waitForIntercept(buildUrl(pageNum), 15000);
|
||||
}
|
||||
if (pageData) {
|
||||
for (const el of pageData.elements ?? []) {
|
||||
if (!el.entityUrn || seen.has(el.entityUrn)) continue;
|
||||
seen.add(el.entityUrn);
|
||||
allElements.push(el);
|
||||
}
|
||||
}
|
||||
lastPage = pageNum;
|
||||
console.log(`[scraper:saved-search] page ${pageNum}/${endPage}: ${allElements.length} (total ${knownTotal})`);
|
||||
onProgress?.({ phase: 'scraping', page: pageNum, totalPages: endPage, count: allElements.length, total: knownTotal });
|
||||
}
|
||||
|
||||
await page.close();
|
||||
return {
|
||||
profiles: allElements.map(el => profileToResult(el, null)),
|
||||
lastPage,
|
||||
knownTotal,
|
||||
exhausted: lastPage >= totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatcher — accepts either a lead list URL or a saved search URL.
|
||||
* Callers don't need to know which type they're dealing with.
|
||||
*/
|
||||
export async function scrapeNavigatorUrl(
|
||||
ctx: BrowserContext,
|
||||
url: string,
|
||||
opts: ScrapeOptions = {}
|
||||
): Promise<WindowedScrapeResult> {
|
||||
if (extractSavedSearchId(url)) {
|
||||
return scrapeSavedSearch(ctx, url, opts);
|
||||
}
|
||||
if (extractListId(url)) {
|
||||
return scrapeNavigatorList(ctx, url, opts);
|
||||
}
|
||||
throw new Error(`Unrecognized Sales Navigator URL. Expected a list URL (/sales/lists/people/...) or saved search URL (?savedSearchId=...)`);
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
import { chromium } from "playwright-extra";
|
||||
import type { Browser, BrowserContext, Page } from "playwright";
|
||||
import StealthPlugin from "puppeteer-extra-plugin-stealth";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { encryptSecret, decryptSecret } from "@/lib/crypto";
|
||||
|
||||
chromium.use(StealthPlugin());
|
||||
|
||||
let browser: Browser | null = null;
|
||||
const contexts: Map<string, BrowserContext> = new Map();
|
||||
|
||||
const HEADLESS = process.env.HEADLESS !== "false";
|
||||
const CHROMIUM_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
|
||||
|
||||
const LAUNCH_ARGS = [
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
];
|
||||
|
||||
/**
|
||||
* Shared browser-context fingerprint. Login and runtime MUST use the identical
|
||||
* options so the LinkedIn session is BORN under the exact fingerprint it will
|
||||
* later be used with — a mismatch (or a drift) triggers a forced re-auth.
|
||||
*/
|
||||
function contextOptions(storageState?: object) {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
storageState: storageState as any,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
locale: "en-US",
|
||||
timezoneId: "America/New_York",
|
||||
permissions: ["clipboard-read", "clipboard-write"] as ("clipboard-read" | "clipboard-write")[],
|
||||
};
|
||||
}
|
||||
|
||||
async function getBrowser(headless = HEADLESS): Promise<Browser> {
|
||||
// B1: if the cached browser is disconnected, CLOSE it before relaunching.
|
||||
// Without this, a dead-but-not-reaped chromium process tree is orphaned on
|
||||
// every relaunch (the leak behind the Jun 2026 zombie pile-up).
|
||||
if (browser && !browser.isConnected()) {
|
||||
try { await browser.close(); } catch { /* already gone */ }
|
||||
browser = null;
|
||||
}
|
||||
if (!browser) {
|
||||
browser = await chromium.launch({
|
||||
headless,
|
||||
executablePath: CHROMIUM_PATH,
|
||||
args: LAUNCH_ARGS,
|
||||
});
|
||||
}
|
||||
return browser;
|
||||
}
|
||||
|
||||
async function getOrCreateContext(accountId: string): Promise<BrowserContext> {
|
||||
const db = getDb();
|
||||
const account = db.prepare("SELECT * FROM accounts WHERE id = ?").get(accountId) as
|
||||
| { cookies_json: string | null; email: string }
|
||||
| undefined;
|
||||
|
||||
if (!account) throw new Error(`Account ${accountId} not found`);
|
||||
|
||||
if (!contexts.has(accountId)) {
|
||||
const b = await getBrowser();
|
||||
|
||||
let storageState: object | undefined;
|
||||
if (account.cookies_json) {
|
||||
try {
|
||||
storageState = JSON.parse(decryptSecret(account.cookies_json)!);
|
||||
} catch {
|
||||
// Invalid storage state — will need re-auth
|
||||
}
|
||||
}
|
||||
|
||||
const ctx = await b.newContext(contextOptions(storageState));
|
||||
|
||||
// Auto-evict from map when context closes for any reason (crash, session expiry, etc.)
|
||||
ctx.on("close", () => { if (contexts.get(accountId) === ctx) contexts.delete(accountId); });
|
||||
|
||||
contexts.set(accountId, ctx);
|
||||
}
|
||||
|
||||
return contexts.get(accountId)!;
|
||||
}
|
||||
|
||||
/** Returns the BrowserContext for an account (for API calls via ctx.request) */
|
||||
export async function getSessionContext(accountId: string): Promise<BrowserContext> {
|
||||
try {
|
||||
return await getOrCreateContext(accountId);
|
||||
} catch {
|
||||
// First attempt failed — evict and retry once with a fresh context
|
||||
contexts.delete(accountId);
|
||||
return getOrCreateContext(accountId);
|
||||
}
|
||||
}
|
||||
|
||||
// B6 (Jul 2026 CPU-spike incident): closing a Playwright page doesn't mean the
|
||||
// underlying Chromium renderer OS process has actually exited — under CPU
|
||||
// contention on the 2-vCPU prod box, teardown was observed lagging 60-90s
|
||||
// behind page.close(). If the runner loop (or a concurrent MCP/API call)
|
||||
// opens its next page immediately after, two renderer processes end up alive
|
||||
// at once, which is enough to peg both cores and starve sibling containers'
|
||||
// healthchecks (NocoDB/Chatwoot flapping). Fix: serialize ALL page opens
|
||||
// app-wide through one queue, and hold the queue for a teardown buffer after
|
||||
// each page.close() before letting the next one through. PAGE_MAX_HOLD_MS is
|
||||
// a safety valve so a caller that forgets to close its page can't wedge the
|
||||
// whole app's browser access forever.
|
||||
const PAGE_TEARDOWN_GAP_MS = 3000;
|
||||
const PAGE_MAX_HOLD_MS = 120_000;
|
||||
let pageQueueTail: Promise<void> = Promise.resolve();
|
||||
|
||||
/** Returns a new Page from the account's browser context */
|
||||
export async function getSessionPage(accountId: string): Promise<Page> {
|
||||
let releaseTurn!: () => void;
|
||||
const myTurn = new Promise<void>(r => { releaseTurn = r; });
|
||||
const previousTail = pageQueueTail;
|
||||
pageQueueTail = myTurn;
|
||||
await previousTail;
|
||||
|
||||
let released = false;
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
releaseTurn();
|
||||
};
|
||||
const safetyTimer = setTimeout(release, PAGE_MAX_HOLD_MS);
|
||||
|
||||
let page: Page;
|
||||
try {
|
||||
const ctx = await getOrCreateContext(accountId);
|
||||
try {
|
||||
page = await ctx.newPage();
|
||||
} catch {
|
||||
// B2: context was dead — CLOSE it before recreating so its underlying
|
||||
// browser process isn't left orphaned, then retry once with a fresh one.
|
||||
try { await ctx.close(); } catch { /* already gone */ }
|
||||
contexts.delete(accountId);
|
||||
const freshCtx = await getOrCreateContext(accountId);
|
||||
page = await freshCtx.newPage();
|
||||
}
|
||||
} catch (err) {
|
||||
clearTimeout(safetyTimer);
|
||||
release();
|
||||
throw err;
|
||||
}
|
||||
|
||||
const originalClose = page.close.bind(page);
|
||||
page.close = (async (options?: Parameters<Page["close"]>[0]) => {
|
||||
try {
|
||||
return await originalClose(options);
|
||||
} finally {
|
||||
clearTimeout(safetyTimer);
|
||||
setTimeout(release, PAGE_TEARDOWN_GAP_MS);
|
||||
}
|
||||
}) as Page["close"];
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
export async function saveSessionState(accountId: string): Promise<void> {
|
||||
const ctx = contexts.get(accountId);
|
||||
if (!ctx) return;
|
||||
const db = getDb();
|
||||
const state = await ctx.storageState();
|
||||
db.prepare("UPDATE accounts SET cookies_json = ?, is_authenticated = 1 WHERE id = ?").run(
|
||||
encryptSecret(JSON.stringify(state)),
|
||||
accountId
|
||||
);
|
||||
}
|
||||
|
||||
export async function closeSession(accountId: string): Promise<void> {
|
||||
const ctx = contexts.get(accountId);
|
||||
if (ctx) {
|
||||
await ctx.close();
|
||||
contexts.delete(accountId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* B4: flag an account as logged out / needing re-auth. Clears is_authenticated
|
||||
* so the runner stops working a dead session (no more 30s-timeout fail-loop),
|
||||
* and drops the live context. The user re-authenticates from Settings.
|
||||
*/
|
||||
export async function markNeedsReauth(accountId: string): Promise<void> {
|
||||
const db = getDb();
|
||||
db.prepare("UPDATE accounts SET is_authenticated = 0 WHERE id = ?").run(accountId);
|
||||
try { await closeSession(accountId); } catch { /* ignore */ }
|
||||
console.warn(`[session] account ${accountId} flagged needs-reauth (session logged out)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a visible browser, navigates to LinkedIn login, and waits for the user
|
||||
* to complete login manually. Returns when the user reaches /feed.
|
||||
* Saves the full storage state to DB and marks account as authenticated.
|
||||
*/
|
||||
export async function authenticateAccount(accountId: string): Promise<void> {
|
||||
const db = getDb();
|
||||
const account = db.prepare("SELECT * FROM accounts WHERE id = ?").get(accountId) as
|
||||
| { email: string }
|
||||
| undefined;
|
||||
if (!account) throw new Error(`Account ${accountId} not found`);
|
||||
|
||||
// Close any existing context for this account — start fresh
|
||||
await closeSession(accountId);
|
||||
|
||||
// Always launch a VISIBLE browser for manual login
|
||||
const visibleBrowser = await chromium.launch({
|
||||
headless: false,
|
||||
executablePath: CHROMIUM_PATH,
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
const ctx = await visibleBrowser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
locale: "en-US",
|
||||
timezoneId: "America/New_York",
|
||||
});
|
||||
|
||||
const page = await ctx.newPage();
|
||||
await page.goto("https://www.linkedin.com/login");
|
||||
|
||||
// Pre-fill email to save the user a step
|
||||
try {
|
||||
await page.waitForSelector("input#username", { timeout: 5000 });
|
||||
await page.fill("input#username", account.email);
|
||||
} catch {
|
||||
// Input not found — page may have redirected already
|
||||
}
|
||||
|
||||
// Wait up to 3 minutes for the user to complete login and reach /feed
|
||||
await page.waitForURL("**/feed/**", { timeout: 180_000 });
|
||||
|
||||
// Save full storage state (cookies + localStorage) to DB
|
||||
const state = await ctx.storageState();
|
||||
db.prepare("UPDATE accounts SET cookies_json = ?, is_authenticated = 1 WHERE id = ?").run(
|
||||
encryptSecret(JSON.stringify(state)),
|
||||
accountId
|
||||
);
|
||||
|
||||
await ctx.close();
|
||||
} finally {
|
||||
await visibleBrowser.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Server-side headless login ───────────────────────────────────────────────
|
||||
// Logs in directly on the server (no screen) so the session is born under the
|
||||
// SAME pinned Chromium fingerprint the runner uses, and so ALL cookies — incl.
|
||||
// httpOnly ones like li_ep_auth_context (the Sales Nav seat cookie that
|
||||
// document.cookie cannot read) — are captured. A login from a datacenter IP
|
||||
// almost always triggers an email/SMS PIN, so it's a two-step flow: start
|
||||
// (email+password) → maybe a challenge → verify (code).
|
||||
|
||||
export type LoginResult =
|
||||
| { status: "authenticated" }
|
||||
| { status: "challenge"; kind: "otp" | "app" | "captcha" | "unknown"; message: string }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
type PendingLogin = { ctx: BrowserContext; page: Page; createdAt: number };
|
||||
const pendingLogins: Map<string, PendingLogin> = new Map();
|
||||
const PENDING_TTL_MS = 10 * 60_000;
|
||||
|
||||
// PIN/verification-code input. Named ids first, then type-based fallbacks for
|
||||
// LinkedIn's React checkpoint pages (which use dynamic ids). Safe: the login
|
||||
// page itself has no tel/one-time-code input to misfire on.
|
||||
const PIN_SELECTOR =
|
||||
"input[name='pin'], #input__email_verification_pin, input[autocomplete='one-time-code']:visible, input[type='tel']:visible";
|
||||
|
||||
async function clearPendingLogin(accountId: string): Promise<void> {
|
||||
const p = pendingLogins.get(accountId);
|
||||
if (p) {
|
||||
pendingLogins.delete(accountId);
|
||||
try { await p.ctx.close(); } catch { /* already gone */ }
|
||||
}
|
||||
}
|
||||
|
||||
function sweepPendingLogins(): void {
|
||||
const now = Date.now();
|
||||
for (const [id, p] of pendingLogins) {
|
||||
if (now - p.createdAt > PENDING_TTL_MS) void clearPendingLogin(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm the freshly-authenticated session by loading Sales Navigator once, THEN
|
||||
* persist. The bare login POST lands on /feed and only mints the ~11 core
|
||||
* LinkedIn cookies — it does NOT yet include the Sales Nav SEAT cookie
|
||||
* (li_ep_auth_context) nor the secondary auth tokens (li_a, liap) and
|
||||
* localStorage. Those are only issued once the browser actually enters Sales
|
||||
* Navigator. Without the seat cookie every Sales Nav API call returns nothing
|
||||
* ("no intercept after 15s" → import fails → account wrongly flagged
|
||||
* needs-reauth). So we navigate to /sales/ and wait for it to settle before
|
||||
* calling storageState(), capturing the FULL session the runner needs.
|
||||
* Best-effort: if the account has no Sales Nav seat the nav simply doesn't add
|
||||
* the seat cookie — the rest of the (regular-LinkedIn) session is still saved.
|
||||
*/
|
||||
async function persistLogin(accountId: string, ctx: BrowserContext, page?: Page): Promise<void> {
|
||||
if (page) {
|
||||
try {
|
||||
await page.goto("https://www.linkedin.com/sales/home", { waitUntil: "domcontentloaded", timeout: 30_000 });
|
||||
// Let Sales Nav's bootstrap requests fire so li_ep_auth_context is set.
|
||||
await page.waitForTimeout(4_000);
|
||||
} catch {
|
||||
// Non-fatal — a missing seat / slow load must not fail the whole login.
|
||||
}
|
||||
}
|
||||
const db = getDb();
|
||||
const state = await ctx.storageState();
|
||||
db.prepare("UPDATE accounts SET cookies_json = ?, is_authenticated = 1 WHERE id = ?").run(
|
||||
encryptSecret(JSON.stringify(state)),
|
||||
accountId
|
||||
);
|
||||
// Drop any stale runtime context so the runner reloads the fresh cookies.
|
||||
await closeSession(accountId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect the page after a login/verify submit and classify the outcome.
|
||||
* LinkedIn varies its challenge per attempt — email/SMS code OR device (app)
|
||||
* approval — so we detect both: a visible code input = otp; a checkpoint page
|
||||
* with no code input and no captcha = device approval.
|
||||
*/
|
||||
async function classifyLoginState(page: Page): Promise<LoginResult> {
|
||||
const start = Date.now();
|
||||
const deadline = start + 20_000;
|
||||
while (Date.now() < deadline) {
|
||||
const url = page.url();
|
||||
if (/\/feed\//.test(url) || /linkedin\.com\/sales\//.test(url)) {
|
||||
return { status: "authenticated" };
|
||||
}
|
||||
|
||||
// Email/SMS PIN entry
|
||||
const pin = page.locator(PIN_SELECTOR).first();
|
||||
if ((await pin.count()) > 0 && (await pin.isVisible().catch(() => false))) {
|
||||
return {
|
||||
status: "challenge",
|
||||
kind: "otp",
|
||||
message: "LinkedIn sent you a verification code (email or SMS). Enter it below.",
|
||||
};
|
||||
}
|
||||
|
||||
if (/checkpoint\/challenge/.test(url)) {
|
||||
// CAPTCHA (Arkose / FunCaptcha) — cannot be solved headlessly
|
||||
const cap = page.locator("iframe[src*='arkoselabs'], iframe[title*='captcha'], #captcha-internal");
|
||||
if ((await cap.count().catch(() => 0)) > 0) {
|
||||
return {
|
||||
status: "challenge",
|
||||
kind: "captcha",
|
||||
message: "LinkedIn requires a CAPTCHA, which can't be solved on the server. Use cookie paste instead.",
|
||||
};
|
||||
}
|
||||
// Device/app approval: a settled checkpoint with no code input and no captcha
|
||||
if (Date.now() - start > 4_000) {
|
||||
return {
|
||||
status: "challenge",
|
||||
kind: "app",
|
||||
message: "LinkedIn sent a sign-in request to your LinkedIn mobile app. Approve it there, then click Continue.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Wrong credentials
|
||||
const wrongPw = await page
|
||||
.getByText(/that.?s not the right password|please enter a valid|couldn.?t find a linkedin account/i)
|
||||
.count()
|
||||
.catch(() => 0);
|
||||
if (wrongPw > 0) return { status: "error", message: "Wrong email or password." };
|
||||
|
||||
await page.waitForTimeout(800);
|
||||
}
|
||||
|
||||
if (/checkpoint/.test(page.url())) {
|
||||
return {
|
||||
status: "challenge",
|
||||
kind: "unknown",
|
||||
message: "LinkedIn presented a security checkpoint. If you got a code enter it; if it's an app request, approve it and click Continue.",
|
||||
};
|
||||
}
|
||||
return { status: "error", message: `Login did not complete. Current page: ${page.url()}` };
|
||||
}
|
||||
|
||||
export async function startHeadlessLogin(
|
||||
accountId: string,
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<LoginResult> {
|
||||
sweepPendingLogins();
|
||||
await clearPendingLogin(accountId);
|
||||
|
||||
const b = await getBrowser(true);
|
||||
const ctx = await b.newContext(contextOptions());
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
await page.goto("https://www.linkedin.com/login", { waitUntil: "domcontentloaded", timeout: 30_000 });
|
||||
// LinkedIn's React login page uses dynamic ids — target by type+visibility
|
||||
// (old #username/#password kept as a fallback for the legacy layout).
|
||||
const emailInput = page.locator("input#username, input[type='email']:visible").first();
|
||||
await emailInput.waitFor({ state: "visible", timeout: 20_000 });
|
||||
await emailInput.fill(email);
|
||||
const passwordInput = page.locator("input#password, input[type='password']:visible").first();
|
||||
await passwordInput.fill(password);
|
||||
await Promise.all([
|
||||
page.waitForLoadState("domcontentloaded").catch(() => {}),
|
||||
passwordInput.press("Enter"),
|
||||
]);
|
||||
|
||||
const result = await classifyLoginState(page);
|
||||
console.log(`[login] start account=${accountId} -> ${result.status}${"kind" in result ? "/" + result.kind : ""} url=${page.url()}`);
|
||||
if (result.status === "authenticated") {
|
||||
await persistLogin(accountId, ctx, page);
|
||||
await ctx.close();
|
||||
return result;
|
||||
}
|
||||
if (result.status === "challenge" && result.kind !== "captcha") {
|
||||
pendingLogins.set(accountId, { ctx, page, createdAt: Date.now() });
|
||||
return result;
|
||||
}
|
||||
await ctx.close();
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.log(`[login] start account=${accountId} ERROR ${(e as Error).message} url=${page.url()}`);
|
||||
try { await ctx.close(); } catch { /* ignore */ }
|
||||
return { status: "error", message: (e as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitLoginChallenge(accountId: string, code: string): Promise<LoginResult> {
|
||||
const p = pendingLogins.get(accountId);
|
||||
if (!p) return { status: "error", message: "No login in progress (it may have timed out — start again)." };
|
||||
|
||||
const { ctx, page } = p;
|
||||
try {
|
||||
const pin = page.locator(PIN_SELECTOR).first();
|
||||
await pin.waitFor({ state: "visible", timeout: 15_000 });
|
||||
await pin.fill(code);
|
||||
await Promise.all([
|
||||
page.waitForLoadState("domcontentloaded").catch(() => {}),
|
||||
pin.press("Enter"),
|
||||
]);
|
||||
|
||||
const result = await classifyLoginState(page);
|
||||
console.log(`[login] verify account=${accountId} -> ${result.status}${"kind" in result ? "/" + result.kind : ""} url=${page.url()}`);
|
||||
if (result.status === "authenticated") {
|
||||
await persistLogin(accountId, ctx, page);
|
||||
await clearPendingLogin(accountId);
|
||||
return result;
|
||||
}
|
||||
if (result.status === "challenge" && result.kind !== "captcha") {
|
||||
p.createdAt = Date.now(); // keep the session alive for another step
|
||||
return result;
|
||||
}
|
||||
await clearPendingLogin(accountId);
|
||||
return result.status === "error"
|
||||
? result
|
||||
: { status: "error", message: "Code rejected or login failed." };
|
||||
} catch (e) {
|
||||
await clearPendingLogin(accountId);
|
||||
return { status: "error", message: (e as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a device/app-approval challenge to clear. Called after the user
|
||||
* approves the sign-in in their LinkedIn mobile app — the checkpoint page then
|
||||
* auto-advances to the feed. Also dismisses a possible "remember this browser?"
|
||||
* interstitial. If still pending, returns the challenge so the user can retry.
|
||||
*/
|
||||
export async function awaitLoginApproval(accountId: string): Promise<LoginResult> {
|
||||
const p = pendingLogins.get(accountId);
|
||||
if (!p) return { status: "error", message: "No login in progress (it may have timed out — start again)." };
|
||||
|
||||
const { ctx, page } = p;
|
||||
p.createdAt = Date.now();
|
||||
try {
|
||||
const reachedFeed = await page
|
||||
.waitForURL(/\/feed\/|linkedin\.com\/sales\//, { timeout: 50_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (!reachedFeed) {
|
||||
// Possible post-approval interstitial (e.g. "remember this browser?")
|
||||
const btn = page
|
||||
.locator("button[type=submit]:visible, button:has-text('Yes'):visible, button:has-text('Ja'):visible")
|
||||
.first();
|
||||
if ((await btn.count().catch(() => 0)) > 0) {
|
||||
await btn.click().catch(() => {});
|
||||
await page.waitForURL(/\/feed\/|linkedin\.com\/sales\//, { timeout: 20_000 }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const result = await classifyLoginState(page);
|
||||
console.log(`[login] await account=${accountId} -> ${result.status}${"kind" in result ? "/" + result.kind : ""} url=${page.url()}`);
|
||||
if (result.status === "authenticated") {
|
||||
await persistLogin(accountId, ctx, page);
|
||||
await clearPendingLogin(accountId);
|
||||
return result;
|
||||
}
|
||||
if (result.status === "challenge" && result.kind !== "captcha") {
|
||||
p.createdAt = Date.now();
|
||||
return result;
|
||||
}
|
||||
await clearPendingLogin(accountId);
|
||||
return result;
|
||||
} catch (e) {
|
||||
await clearPendingLogin(accountId);
|
||||
return { status: "error", message: (e as Error).message };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import type { Page } from "playwright";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { getSessionPage, saveSessionState, markNeedsReauth } from "@/lib/linkedin/session";
|
||||
|
||||
/**
|
||||
* Accepted-connection sync via the authoritative Voyager connections API.
|
||||
*
|
||||
* Replaces the old absence-inference (scroll the SENT list, treat a vanished
|
||||
* invite as "accepted"). That was measurably wrong: of 1,600 contacts it had
|
||||
* marked degree=1, only 325 were genuinely connected — 1,275 were phantoms
|
||||
* (invites that expired/withdrew/were ignored, not accepted). See docs §19.
|
||||
*
|
||||
* Sources — all proved against prod (Jul 2026), NO scrolling:
|
||||
* - DATA: GET /voyager/api/relationships/dash/connections
|
||||
* ?decorationId=…ConnectionListWithProfile-16&q=search
|
||||
* &sortType=RECENTLY_ADDED&start=N&count=100
|
||||
* → data["*elements"] (newest-first) + included[]: Connection { createdAt(ms),
|
||||
* connectedMember } and Profile { publicIdentifier (vanity), entityUrn }.
|
||||
* The API paginates to the end (30 pages for ~2947) in ~60s. It does NOT
|
||||
* expose a grand total.
|
||||
* - TOTAL (checksum): the connections PAGE header "<N> connections" <p>. Read
|
||||
* once per full pass via a single navigation (NO scroll). A full pass is
|
||||
* "verified complete" only when unique-pulled == declared total (matched ±0
|
||||
* in prod). Presence in the list = 100% proof of a 1st-degree connection.
|
||||
*
|
||||
* Behaviour:
|
||||
* - First run (boundary NULL) → FULL pass: page to the end, stamp degree=1 +
|
||||
* connected_at from createdAt for every matched contact. If the pass is
|
||||
* checksum-verified complete, ALSO un-mark phantom degree=1 contacts whose
|
||||
* vanity is absent from the authoritative list (resets degree/connected_at).
|
||||
* - Later runs → incremental: page from start=0 and STOP at the first
|
||||
* connection older than the stored boundary (minus a 24h overlap margin).
|
||||
* Incremental/incomplete passes are ADD-ONLY — they never un-mark (a partial
|
||||
* pull must not wipe real accepts). Un-marking happens ONLY on a verified
|
||||
* full pass.
|
||||
* - Reuses the runner's shared browser (getSessionPage) — never a 2nd browser.
|
||||
*/
|
||||
|
||||
const ACCEPTED_SYNC_INTERVAL_MS = 8 * 60 * 60 * 1000; // 8h — 3x per day
|
||||
const PAGE_SIZE = 100;
|
||||
const MAX_PAGES = 60; // safety cap (60 * 100 = 6000)
|
||||
const OVERLAP_MARGIN_MS = 24 * 60 * 60 * 1000; // re-check a day of overlap (idempotent)
|
||||
const DECORATION = "com.linkedin.voyager.dash.deco.web.mynetwork.ConnectionListWithProfile-16";
|
||||
|
||||
export function shouldSyncAccepted(accountId: string): boolean {
|
||||
const db = getDb();
|
||||
const row = db.prepare("SELECT accepted_sync_at FROM accounts WHERE id = ?").get(accountId) as
|
||||
| { accepted_sync_at: string | null }
|
||||
| undefined;
|
||||
if (!row?.accepted_sync_at) return true;
|
||||
return Date.now() - new Date(row.accepted_sync_at).getTime() >= ACCEPTED_SYNC_INTERVAL_MS;
|
||||
}
|
||||
|
||||
interface ApiConnection {
|
||||
vanity: string | null;
|
||||
createdAt: number; // epoch ms
|
||||
}
|
||||
|
||||
export async function syncAcceptedConnections(accountId: string): Promise<number> {
|
||||
const db = getDb();
|
||||
const page = await getSessionPage(accountId);
|
||||
let stamped = 0;
|
||||
|
||||
try {
|
||||
const boundaryRow = db.prepare("SELECT connections_synced_through_ms FROM accounts WHERE id = ?").get(accountId) as
|
||||
| { connections_synced_through_ms: number | null }
|
||||
| undefined;
|
||||
const boundary = boundaryRow?.connections_synced_through_ms ?? null;
|
||||
const isFullPass = boundary === null;
|
||||
const stopBefore = boundary === null ? null : boundary - OVERLAP_MARGIN_MS;
|
||||
|
||||
// Read the declared total from the connections PAGE header (single nav, NO
|
||||
// scroll). This is also our login-wall check and the completeness checksum.
|
||||
await page.goto("https://www.linkedin.com/mynetwork/invite-connect/connections/", {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 35000,
|
||||
});
|
||||
await page.waitForTimeout(3500 + Math.random() * 1500);
|
||||
if (/\/login|\/authwall|\/checkpoint|\/uas\//.test(page.url())) {
|
||||
console.warn(`[sync-accepted] Session looks logged out (${page.url()}) — skipping`);
|
||||
return 0;
|
||||
}
|
||||
const declaredTotal = await page.evaluate(() => {
|
||||
const m = document.body.innerText.match(/([\d.,]+)\s+connections?/i);
|
||||
return m ? parseInt(m[1].replace(/[.,]/g, ""), 10) : null;
|
||||
});
|
||||
|
||||
const findByVanity = db.prepare(
|
||||
`SELECT id, full_name, connected_at, degree FROM targets
|
||||
WHERE linkedin_url LIKE ? AND connection_requested_at IS NOT NULL`
|
||||
);
|
||||
const stampAccepted = db.prepare(
|
||||
"UPDATE targets SET degree = 1, connected_at = COALESCE(connected_at, ?) WHERE id = ?"
|
||||
);
|
||||
|
||||
const seenVanities = new Set<string>(); // full-pass phantom check
|
||||
let uniquePulled = 0;
|
||||
let newestSeen: number | null = null;
|
||||
let reachedBoundary = false;
|
||||
|
||||
for (let pageIdx = 0; pageIdx < MAX_PAGES; pageIdx++) {
|
||||
const conns = await fetchConnectionsPage(page, pageIdx * PAGE_SIZE, PAGE_SIZE);
|
||||
if (conns === null) {
|
||||
console.warn(`[sync-accepted] connections API failed at start=${pageIdx * PAGE_SIZE} — stopping`);
|
||||
break;
|
||||
}
|
||||
if (conns.length === 0) break; // end of list
|
||||
|
||||
for (const c of conns) {
|
||||
uniquePulled++;
|
||||
if (c.vanity) seenVanities.add(c.vanity);
|
||||
if (newestSeen === null || c.createdAt > newestSeen) newestSeen = c.createdAt;
|
||||
|
||||
// Incremental early-exit (list is newest-first).
|
||||
if (stopBefore !== null && c.createdAt < stopBefore) {
|
||||
reachedBoundary = true;
|
||||
break;
|
||||
}
|
||||
if (!c.vanity) continue;
|
||||
|
||||
for (const m of findByVanity.all(`%/in/${c.vanity}/%`) as Array<{
|
||||
id: string; full_name: string | null; connected_at: string | null; degree: number | null;
|
||||
}>) {
|
||||
if (m.degree === 1 && m.connected_at) continue; // already correct
|
||||
stampAccepted.run(msToSqlite(c.createdAt), m.id);
|
||||
console.log(`[sync-accepted] Accepted: ${m.full_name ?? c.vanity}`);
|
||||
stamped++;
|
||||
}
|
||||
}
|
||||
|
||||
if (reachedBoundary) break;
|
||||
await page.waitForTimeout(900 + Math.random() * 700); // gentle, API-only
|
||||
}
|
||||
|
||||
// Completeness checksum: a full pass is trustworthy only if what we pulled
|
||||
// matches LinkedIn's own declared total (±5 slack for live churn).
|
||||
const verifiedComplete =
|
||||
isFullPass && declaredTotal !== null && Math.abs(uniquePulled - declaredTotal) <= 5;
|
||||
|
||||
// Correction: ONLY on a verified-complete full pass, un-mark phantom
|
||||
// degree=1 contacts (present nowhere in the authoritative list). Never on an
|
||||
// incremental/incomplete pass — a partial pull must not wipe real accepts.
|
||||
let unmarked = 0;
|
||||
if (verifiedComplete) {
|
||||
const deg1 = db.prepare(
|
||||
"SELECT id, full_name, linkedin_url FROM targets WHERE degree = 1 AND linkedin_url LIKE '%/in/%'"
|
||||
).all() as Array<{ id: string; full_name: string | null; linkedin_url: string }>;
|
||||
const unmark = db.prepare("UPDATE targets SET degree = NULL, connected_at = NULL WHERE id = ?");
|
||||
const tx = db.transaction((rows: typeof deg1) => {
|
||||
for (const t of rows) {
|
||||
const mm = t.linkedin_url.match(/\/in\/([^/?#]+)/);
|
||||
const v = mm ? decodeURIComponent(mm[1]).toLowerCase() : null;
|
||||
if (!v || !seenVanities.has(v)) {
|
||||
unmark.run(t.id);
|
||||
unmarked++;
|
||||
}
|
||||
}
|
||||
});
|
||||
tx(deg1);
|
||||
console.log(`[sync-accepted] Verified full pass (pulled ${uniquePulled} == declared ${declaredTotal}). Un-marked ${unmarked} phantom degree=1.`);
|
||||
} else if (isFullPass) {
|
||||
console.warn(`[sync-accepted] Full pass NOT verified complete (pulled ${uniquePulled}, declared ${declaredTotal}) — add-only, no un-marking.`);
|
||||
}
|
||||
|
||||
// Advance the boundary to the newest connection seen this run.
|
||||
if (newestSeen !== null) {
|
||||
db.prepare("UPDATE accounts SET connections_synced_through_ms = ? WHERE id = ?").run(newestSeen, accountId);
|
||||
}
|
||||
// Store the declared total for visibility (Settings shows LinkedIn's count).
|
||||
if (declaredTotal !== null) {
|
||||
db.prepare("UPDATE accounts SET li_connections = ? WHERE id = ?").run(declaredTotal, accountId);
|
||||
}
|
||||
console.log(`[sync-accepted] Stamped ${stamped} accepted, un-marked ${unmarked} phantom (boundary=${newestSeen}).`);
|
||||
} finally {
|
||||
// B5 safety: only persist the session if still on a valid page.
|
||||
let url = "";
|
||||
try { url = page.url(); } catch { /* gone */ }
|
||||
try { await page.close(); } catch { /* ignore */ }
|
||||
if (/\/login|\/authwall|\/checkpoint|\/uas\//.test(url)) {
|
||||
console.warn(`[sync-accepted] Ended on a wall (${url}) — not persisting; flagging re-auth`);
|
||||
try { await markNeedsReauth(accountId); } catch { /* ignore */ }
|
||||
} else {
|
||||
try { await saveSessionState(accountId); } catch { /* ignore */ }
|
||||
}
|
||||
db.prepare("UPDATE accounts SET accepted_sync_at = datetime('now') WHERE id = ?").run(accountId);
|
||||
}
|
||||
|
||||
return stamped;
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function msToSqlite(ms: number): string {
|
||||
return new Date(ms).toISOString().replace("T", " ").slice(0, 19);
|
||||
}
|
||||
|
||||
async function fetchConnectionsPage(page: Page, start: number, count: number): Promise<ApiConnection[] | null> {
|
||||
return page.evaluate(
|
||||
async ({ start, count, decoration }): Promise<ApiConnection[] | null> => {
|
||||
const cookies = document.cookie.split("; ").reduce((a: Record<string, string>, c) => {
|
||||
const i = c.indexOf("=");
|
||||
if (i > 0) a[c.slice(0, i)] = c.slice(i + 1);
|
||||
return a;
|
||||
}, {});
|
||||
const csrf = (cookies["JSESSIONID"] || "").replace(/"/g, "");
|
||||
const url =
|
||||
`https://www.linkedin.com/voyager/api/relationships/dash/connections` +
|
||||
`?decorationId=${decoration}&count=${count}&q=search&sortType=RECENTLY_ADDED&start=${start}`;
|
||||
let json: {
|
||||
included?: Array<{
|
||||
$type?: string;
|
||||
entityUrn?: string;
|
||||
createdAt?: number;
|
||||
connectedMember?: string;
|
||||
publicIdentifier?: string;
|
||||
}>;
|
||||
};
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
headers: {
|
||||
"csrf-token": csrf,
|
||||
"accept": "application/vnd.linkedin.normalized+json+2.1",
|
||||
"x-restli-protocol-version": "2.0.0",
|
||||
"x-li-lang": "en_US",
|
||||
},
|
||||
credentials: "include",
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
json = await r.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const included = json.included || [];
|
||||
const vanityByUrn: Record<string, string> = {};
|
||||
for (const x of included) {
|
||||
if ((x.$type || "").includes("identity.profile.Profile") && x.entityUrn && x.publicIdentifier) {
|
||||
vanityByUrn[x.entityUrn] = x.publicIdentifier;
|
||||
}
|
||||
}
|
||||
const out: ApiConnection[] = [];
|
||||
for (const x of included) {
|
||||
if ((x.$type || "").includes("relationships.Connection") && typeof x.createdAt === "number") {
|
||||
const memberUrn = x.connectedMember || null;
|
||||
out.push({
|
||||
createdAt: x.createdAt,
|
||||
vanity: memberUrn ? vanityByUrn[memberUrn] ?? null : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => b.createdAt - a.createdAt);
|
||||
return out;
|
||||
},
|
||||
{ start, count, decoration: DECORATION }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Page } from "playwright";
|
||||
|
||||
/**
|
||||
* Visits a LinkedIn profile page. This registers as a profile view on LinkedIn.
|
||||
* Navigates and waits, then reports whether the page shows a 1st-degree badge —
|
||||
* lets the runner backfill degree=1 for contacts that were already connected
|
||||
* before Linki ever sent them a connection request (e.g. manually added leads).
|
||||
*
|
||||
* Primary-degree signal: presence of the profile's primary "Message" link
|
||||
* (a[href*="/messaging/compose"]) — only shown to 1st-degree connections, reads
|
||||
* an href attribute rather than a CSS class or translated text, so it survives
|
||||
* both LinkedIn's periodic class-name hashing and non-English UI languages.
|
||||
*
|
||||
* MUST be scoped to the visited person's own intro/top card — a page-wide
|
||||
* search also matches "Message" links belonging to OTHER people rendered
|
||||
* elsewhere on the page (sidebar modules like "People also viewed" / suggested
|
||||
* connections). For a non-connected target, THEIR page has no such link, but
|
||||
* the sidebar still does — a page-wide `.first()` silently grabbed a random
|
||||
* unrelated 1st-degree connection's link, wrongly marked the target degree=1,
|
||||
* and stored that stranger's messaging URN, causing messages to go to the
|
||||
* wrong person entirely (incident: Jul 2026, see CLAUDE.md/memory). The top
|
||||
* card is identified structurally as the section containing the page's own
|
||||
* <h1> name heading, which is robust to LinkedIn's class-name hashing.
|
||||
*
|
||||
* Falls back to the old text-scrape (/\b1st\b/ within that same top card)
|
||||
* when the link isn't found, in case the current account's profile layout
|
||||
* doesn't render it as a plain link (e.g. buried behind a click/menu).
|
||||
*
|
||||
* The same link's href carries the messaging URN (urn:li:fsd_profile:ACoAA...)
|
||||
* needed to message this person directly later without a name-search typeahead
|
||||
* — see lib/linkedin/message.ts. Returned as messagingUrn when found.
|
||||
*/
|
||||
export async function visitProfile(page: Page, linkedinUrl: string): Promise<{ isFirstDegree: boolean; messagingUrn: string | null }> {
|
||||
await page.goto(linkedinUrl, { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
await page.waitForTimeout(3000 + Math.random() * 2000);
|
||||
|
||||
const topCard = page.locator("main section").filter({ has: page.locator("h1") }).first();
|
||||
|
||||
const messageLink = topCard.locator('a[href*="/messaging/compose"]').first();
|
||||
const messageHref = (await messageLink.count()) > 0 ? await messageLink.getAttribute("href").catch(() => null) : null;
|
||||
const urnMatch = messageHref?.match(/profileUrn=([^&]+)/);
|
||||
const messagingUrn = urnMatch ? decodeURIComponent(urnMatch[1]) : null;
|
||||
|
||||
if (messageHref) return { isFirstDegree: true, messagingUrn };
|
||||
|
||||
const pageText = await topCard.innerText().catch(() => "");
|
||||
return { isFirstDegree: /\b1st\b/.test(pageText), messagingUrn: null };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// lib/premium.ts — the ONE bridge from open-core code to commercial (ee/) features.
|
||||
//
|
||||
// This file lives in open-core and is present in BOTH the private and public builds.
|
||||
// It loads ee/ if the folder exists, otherwise degrades to `null`. Open-core code must
|
||||
// call premium features ONLY through the `premium` object exported here — never with a
|
||||
// direct `import ... from "@/ee/..."` anywhere else. That one rule is what makes the
|
||||
// public build (ee/ stripped) compile and run with premium cleanly absent.
|
||||
//
|
||||
// Full strategy + rules: docs/OPEN_CORE.md
|
||||
//
|
||||
// IMPORTANT: the surface type below is declared HERE, in open-core, on purpose. If we
|
||||
// imported the type from @/ee, the public build (where @/ee does not exist) would fail
|
||||
// to typecheck. ee/index.ts must SATISFY this type instead. Extend this as features land.
|
||||
|
||||
// Minimal structural shapes for what OPEN-CORE consumes from premium features. These are
|
||||
// declared here (not imported from @/ee) so the public build typechecks with ee/ stripped.
|
||||
// They are intentionally loose at the boundary — ee/ owns the rich domain types and must
|
||||
// SATISFY these. Only add a field here when open-core actually reads it. Kept deliberately
|
||||
// permissive (no index signatures) so the richer ee/ return types remain assignable.
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export interface AiSurface {
|
||||
// Returns at least { default_model } — ee's AgentConfig is a superset.
|
||||
getAgentConfig(): { default_model: string | null };
|
||||
// Bundle is passed straight back into the writers; open-core never inspects its innards.
|
||||
getContactWithCompany(targetId: string): { contact: any; company: any } | null;
|
||||
// Writers take a large param object (ee owns its full type); open-core builds it inline.
|
||||
writeEmail(params: any): Promise<{ subject: string; body: string }>;
|
||||
writeLinkedInMessage(params: any): Promise<{ body: string }>;
|
||||
writeSalesInMail(params: any): Promise<{ subject: string; body: string }>;
|
||||
}
|
||||
|
||||
export interface RepliesSurface {
|
||||
// AI classify + auto-followup for one stored reply (email or LinkedIn).
|
||||
classifyAndDispatch(replyId: string): Promise<void>;
|
||||
// LinkedIn inbox sync (reply detection), driven by the runner loop.
|
||||
shouldSyncInbox(accountId: string): boolean;
|
||||
syncAccountInbox(accountId: string): Promise<number>;
|
||||
}
|
||||
|
||||
export interface InMailSurface {
|
||||
// Sends a Sales Nav InMail via a live browser page (playwright Page type is open-core).
|
||||
sendInMail(page: any, salesNavUrl: string, subject: string, body: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PremiumSurface {
|
||||
ai?: AiSurface;
|
||||
replies?: RepliesSurface;
|
||||
inmail?: InMailSurface;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function loadPremium(): PremiumSurface | null {
|
||||
try {
|
||||
// Loaded by real path. The ee/ folder is imported IN PLACE (not copied) so its own
|
||||
// relative imports resolve normally. In the public build this require throws → null.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const mod = require("@/ee");
|
||||
return (mod?.default ?? mod) as PremiumSurface;
|
||||
} catch {
|
||||
return null; // public build: ee/ has been stripped
|
||||
}
|
||||
}
|
||||
|
||||
export const premium: PremiumSurface | null = loadPremium();
|
||||
|
||||
/** True in the private/commercial build, false in the public open-source build. */
|
||||
export const hasPremium: boolean = premium !== null;
|
||||
@@ -0,0 +1,53 @@
|
||||
// Minimal shape both a real NextApiRequest and NextAuth's authorize() RequestInternal
|
||||
// satisfy — socket is optional since RequestInternal doesn't expose one.
|
||||
type IpSource = {
|
||||
headers?: { [key: string]: string | string[] | undefined };
|
||||
socket?: { remoteAddress?: string };
|
||||
};
|
||||
|
||||
// In-memory sliding-window rate limiter, per IP. No Redis/external service (self-hosted,
|
||||
// single process — see CLAUDE.md). Resets on server restart; acceptable for a single-user
|
||||
// tool defending against brute force on login/signup, not a distributed-attack mitigation.
|
||||
const hits = new Map<string, number[]>();
|
||||
|
||||
// Periodically drop stale keys so this doesn't grow unbounded over a long-running process.
|
||||
const SWEEP_INTERVAL_MS = 10 * 60 * 1000;
|
||||
let lastSweep = Date.now();
|
||||
|
||||
export function isRateLimited(req: IpSource, key: string, limit: number, windowMs: number): boolean {
|
||||
const ip = clientIp(req);
|
||||
const mapKey = `${key}:${ip}`;
|
||||
const now = Date.now();
|
||||
|
||||
sweepIfDue(now, windowMs);
|
||||
|
||||
const timestamps = (hits.get(mapKey) ?? []).filter(t => now - t < windowMs);
|
||||
if (timestamps.length >= limit) {
|
||||
hits.set(mapKey, timestamps);
|
||||
return true;
|
||||
}
|
||||
|
||||
timestamps.push(now);
|
||||
hits.set(mapKey, timestamps);
|
||||
return false;
|
||||
}
|
||||
|
||||
function sweepIfDue(now: number, windowMs: number) {
|
||||
if (now - lastSweep < SWEEP_INTERVAL_MS) return;
|
||||
lastSweep = now;
|
||||
for (const [mapKey, timestamps] of hits) {
|
||||
const fresh = timestamps.filter(t => now - t < windowMs);
|
||||
if (fresh.length === 0) hits.delete(mapKey);
|
||||
else hits.set(mapKey, fresh);
|
||||
}
|
||||
}
|
||||
|
||||
function clientIp(req: IpSource): string {
|
||||
const xRealIp = req.headers?.["x-real-ip"];
|
||||
if (typeof xRealIp === "string" && xRealIp) return xRealIp;
|
||||
|
||||
const xForwardedFor = req.headers?.["x-forwarded-for"];
|
||||
if (typeof xForwardedFor === "string" && xForwardedFor) return xForwardedFor.split(",")[0].trim();
|
||||
|
||||
return req.socket?.remoteAddress || "unknown";
|
||||
}
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
// lib/tour.ts — product tour engine (driver.js). Open-core: available in both builds.
|
||||
// Each page gets its own short, independent tour keyed by a "page" id. A tour is shown
|
||||
// once automatically (tracked server-side via /api/tour) and can be replayed manually
|
||||
// from Settings → General ("Restart tour"), which never touches the "seen" state.
|
||||
"use client";
|
||||
|
||||
import { driver, type DriveStep } from "driver.js";
|
||||
import "driver.js/dist/driver.css";
|
||||
|
||||
export type TourPage =
|
||||
| "dashboard"
|
||||
| "lists"
|
||||
| "contacts"
|
||||
| "companies"
|
||||
| "workflows"
|
||||
| "inbox"
|
||||
| "settings";
|
||||
|
||||
// Steps whose `element` selector isn't in the DOM are silently skipped by driver.js —
|
||||
// relied on here so a step referencing a premium-only anchor (Todos nav, InMail, MCP)
|
||||
// just no-ops cleanly in the open-core build instead of needing its own guard.
|
||||
const TOURS: Record<TourPage, DriveStep[]> = {
|
||||
dashboard: [
|
||||
{
|
||||
element: '[data-tour="nav-dashboard"]',
|
||||
popover: {
|
||||
title: "Welcome to Linki",
|
||||
description: "This is your outreach dashboard. Everything in Linki runs on one background process at a human pace — nothing fires the instant you click a button, so don't expect instant numbers here either.",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="dashboard-filters"]',
|
||||
popover: {
|
||||
title: "LinkedIn and email, side by side",
|
||||
description: "The two channels are tracked completely separately — a campaign can run both at once, and this page reports them independently rather than blended.",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="dashboard-funnel"]',
|
||||
popover: {
|
||||
title: "Your outreach funnel",
|
||||
description: "See exactly where leads drop off — from total targets down to replies. This is the \"is my campaign actually working\" view.",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="dashboard-chart"]',
|
||||
popover: {
|
||||
title: "Activity over time",
|
||||
description: "Visits, connections, messages and emails per day. Click a series label to toggle it off, or change the day range to reshape the whole page.",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="nav-settings"]',
|
||||
popover: {
|
||||
title: "First: connect a LinkedIn account",
|
||||
description: "Nothing else works until an account is authenticated in Settings → LinkedIn — that's the one true prerequisite before importing or running anything.",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="nav-lists"]',
|
||||
popover: {
|
||||
title: "Then: import a list",
|
||||
description: "Paste a Sales Navigator search URL — that's how leads get into Linki.",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="nav-workflows"]',
|
||||
popover: {
|
||||
title: "Then: build a campaign",
|
||||
description: "Chain steps like visit, connect, message and email into a sequence, pick the list and account, and launch.",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
lists: [
|
||||
{
|
||||
element: '[data-tour="lists-new"]',
|
||||
popover: {
|
||||
title: "Create a list",
|
||||
description: "Paste a LinkedIn Sales Navigator search URL to scrape leads automatically.",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="lists-new"]',
|
||||
popover: {
|
||||
title: "Imports run in the background",
|
||||
description: "A Sales Navigator import doesn't happen instantly — it's queued and scraped in small batches with human-like delays, specifically to avoid LinkedIn flagging the account.",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="lists-jobs"]',
|
||||
popover: {
|
||||
title: "Import jobs",
|
||||
description: "There's a shared daily import cap across every list (configurable in Settings → General) — a large list automatically spills into the next day. Track progress and cancel batches here.",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="lists-new"]',
|
||||
popover: {
|
||||
title: "A fresh import is intentionally thin",
|
||||
description: "Newly imported leads only have basic data (name, title, company). Full profile and email enrichment happen later, automatically, right before a campaign actually needs them — you don't need to enrich everything up front.",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
contacts: [
|
||||
{
|
||||
element: '[data-tour="nav-contacts"]',
|
||||
popover: {
|
||||
title: "Every lead, in one place",
|
||||
description: "This is the single, deduplicated view of everyone you've ever imported — the same person can belong to several lists, and shows up here just once regardless.",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="contacts-filters"]',
|
||||
popover: {
|
||||
title: "Search and filter",
|
||||
description: "Filter by list, title, connection status, seniority, or email verification to build a targeted sub-audience without re-importing anything.",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="contacts-select"]',
|
||||
popover: {
|
||||
title: "Bulk-select, then add to a list",
|
||||
description: "Select multiple contacts and add them all to a list at once — an easy way to turn a filtered view (e.g. everyone who replied) into a new targeted list. Contacts already in that list are skipped.",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
companies: [
|
||||
{
|
||||
element: '[data-tour="nav-companies"]',
|
||||
popover: {
|
||||
title: "Companies",
|
||||
description: "Enriched organization records — industry, size, tech stack, revenue — that populate automatically as contacts get enriched. You can also add or edit one by hand.",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
workflows: [
|
||||
{
|
||||
element: '[data-tour="workflows-new"]',
|
||||
popover: {
|
||||
title: "Build a campaign",
|
||||
description: "A campaign chains steps — visit, connect, message, email — into a repeatable sequence, then runs it against a list of contacts through one LinkedIn account.",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="workflows-new"]',
|
||||
popover: {
|
||||
title: "LinkedIn and email run in parallel",
|
||||
description: "LinkedIn steps and email steps are two independent tracks for the same contact. A reply on either channel stops that person's outreach on both — you don't need to manage them separately.",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="workflows-new"]',
|
||||
popover: {
|
||||
title: "A LinkedIn account is always required",
|
||||
description: "Even an email-only campaign needs one authenticated LinkedIn account selected — set that up first in Settings if you haven't.",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="workflows-new"]',
|
||||
popover: {
|
||||
title: "One active run at a time",
|
||||
description: "A campaign can only have one run going at once. To reach more people later, use \"Add contacts\" on the running campaign instead of starting a second run.",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="workflows-new"]',
|
||||
popover: {
|
||||
title: "It runs at a human pace",
|
||||
description: "Launching doesn't message everyone instantly — the runner works through the list gradually in the background, respecting daily limits and working hours per account.",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
inbox: [
|
||||
{
|
||||
element: '[data-tour="nav-inbox"]',
|
||||
popover: {
|
||||
title: "Every reply, one place",
|
||||
description: "Replies from LinkedIn and email land here automatically — no need to check either inbox by hand. A reply also automatically stops that contact's outreach.",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="nav-inbox"]',
|
||||
popover: {
|
||||
title: "Not every reply means interest",
|
||||
description: "Reply counts can be misleadingly high — many are auto-responders (out-of-office, \"contact my colleague\") rather than genuine interest. Open one to see the full thread and judge for yourself.",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
settings: [
|
||||
{
|
||||
element: '[data-tour="settings-tab-linkedin"]',
|
||||
popover: {
|
||||
title: "Connect a LinkedIn account first",
|
||||
description: "An account must be authenticated here before it can be used in any campaign — this is the single most important setup step in Linki.",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="settings-tab-email"]',
|
||||
popover: {
|
||||
title: "Email accounts, with ramp-up",
|
||||
description: "Configure SMTP/IMAP for email steps. A new account can ramp up sending volume gradually instead of blasting at full volume from day one, which helps it avoid getting flagged as spam.",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="settings-tab-integrations"]',
|
||||
popover: {
|
||||
title: "Apollo enrichment",
|
||||
description: "Add an Apollo API key here to enrich leads with verified emails, seniority, and company data.",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="settings-tab-general"]',
|
||||
popover: {
|
||||
title: "Connect an AI agent",
|
||||
description: "The General tab has a copyable MCP URL to hook up Claude Code, Cursor, or any MCP-compatible agent to manage Linki on your behalf — plus the daily import cap and this tour picker.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function markSeen(page: TourPage) {
|
||||
fetch("/api/tour", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ page }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function buildAndRun(page: TourPage, persist: boolean) {
|
||||
const steps = TOURS[page];
|
||||
if (!steps || steps.length === 0) return;
|
||||
|
||||
const d = driver({
|
||||
showProgress: true,
|
||||
overlayColor: "#000",
|
||||
overlayOpacity: 0.65,
|
||||
stagePadding: 6,
|
||||
stageRadius: 8,
|
||||
popoverOffset: 12,
|
||||
allowClose: true,
|
||||
popoverClass: "linki-tour-popover",
|
||||
nextBtnText: "Next",
|
||||
prevBtnText: "Back",
|
||||
doneBtnText: "Done",
|
||||
steps,
|
||||
onDestroyed: () => {
|
||||
if (persist) markSeen(page);
|
||||
},
|
||||
});
|
||||
|
||||
d.drive();
|
||||
}
|
||||
|
||||
export function startPageTour(page: TourPage) {
|
||||
buildAndRun(page, true);
|
||||
}
|
||||
|
||||
export function replayPageTour(page: TourPage) {
|
||||
buildAndRun(page, false);
|
||||
}
|
||||
|
||||
export async function getSeenTours(): Promise<Set<TourPage>> {
|
||||
try {
|
||||
const res = await fetch("/api/tour");
|
||||
if (!res.ok) return new Set();
|
||||
const data = await res.json();
|
||||
return new Set((data.seen ?? []) as TourPage[]);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export const ALL_TOUR_PAGES: TourPage[] = ["dashboard", "lists", "contacts", "companies", "workflows", "inbox", "settings"];
|
||||
|
||||
export const TOUR_PAGE_LABELS: Record<TourPage, string> = {
|
||||
dashboard: "Dashboard",
|
||||
lists: "Lists",
|
||||
contacts: "Contacts",
|
||||
companies: "Companies",
|
||||
workflows: "Campaigns",
|
||||
inbox: "Inbox",
|
||||
settings: "Settings",
|
||||
};
|
||||
|
||||
// Maps a pathname to its tour page id, or null if the current page has no tour
|
||||
// (e.g. a detail route like /lists/[id] — the index page's tour covers the entry point).
|
||||
export function pathToTourPage(pathname: string): TourPage | null {
|
||||
if (pathname === "/") return "dashboard";
|
||||
if (pathname === "/lists") return "lists";
|
||||
if (pathname === "/contacts") return "contacts";
|
||||
if (pathname === "/companies") return "companies";
|
||||
if (pathname === "/workflows") return "workflows";
|
||||
if (pathname === "/inbox") return "inbox";
|
||||
if (pathname === "/settings") return "settings";
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Update checker — polls Docker Hub tags to detect if a newer version of the
|
||||
* image has been published. Runs once on startup, then every 12 hours.
|
||||
*
|
||||
* State is kept in memory (reset on restart, which is fine — it re-checks immediately).
|
||||
*/
|
||||
|
||||
const DOCKER_HUB_TAGS_URL =
|
||||
"https://hub.docker.com/v2/repositories/moaljumaa/linki/tags?page_size=50&ordering=last_updated";
|
||||
|
||||
const POLL_INTERVAL_MS = 12 * 60 * 60 * 1000; // 12 hours
|
||||
|
||||
export interface UpdateState {
|
||||
current: string;
|
||||
latest: string | null;
|
||||
updateAvailable: boolean;
|
||||
checkedAt: string | null;
|
||||
}
|
||||
|
||||
const state: UpdateState = {
|
||||
current: process.env.APP_VERSION ?? "dev",
|
||||
latest: null,
|
||||
updateAvailable: false,
|
||||
checkedAt: null,
|
||||
};
|
||||
|
||||
export function getUpdateState(): UpdateState {
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
/** Parse semver string like "1.2.3" → [1, 2, 3]. Returns null if not semver. */
|
||||
function parseSemver(v: string): [number, number, number] | null {
|
||||
const m = v.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
||||
if (!m) return null;
|
||||
return [parseInt(m[1]), parseInt(m[2]), parseInt(m[3])];
|
||||
}
|
||||
|
||||
/** Returns true if b is strictly greater than a */
|
||||
function isNewer(a: string, b: string): boolean {
|
||||
const pa = parseSemver(a);
|
||||
const pb = parseSemver(b);
|
||||
if (!pa || !pb) return false;
|
||||
if (pb[0] !== pa[0]) return pb[0] > pa[0];
|
||||
if (pb[1] !== pa[1]) return pb[1] > pa[1];
|
||||
return pb[2] > pa[2];
|
||||
}
|
||||
|
||||
async function checkForUpdate() {
|
||||
try {
|
||||
const res = await fetch(DOCKER_HUB_TAGS_URL);
|
||||
if (!res.ok) return;
|
||||
|
||||
const data = await res.json() as { results?: { name: string }[] };
|
||||
const tags: string[] = (data.results ?? [])
|
||||
.map((t) => t.name)
|
||||
.filter((n) => parseSemver(n) !== null);
|
||||
|
||||
if (tags.length === 0) return;
|
||||
|
||||
// Find highest semver tag
|
||||
const latest = tags.reduce((best, tag) => {
|
||||
return isNewer(best, tag) ? tag : best;
|
||||
}, tags[0]);
|
||||
|
||||
state.latest = latest;
|
||||
state.updateAvailable = state.current !== "dev" && isNewer(state.current, latest);
|
||||
state.checkedAt = new Date().toISOString();
|
||||
|
||||
if (state.updateAvailable) {
|
||||
console.log(`[update-check] New version available: ${latest} (running ${state.current})`);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — silently ignore network errors
|
||||
}
|
||||
}
|
||||
|
||||
const g = global as typeof global & { __updateCheckScheduled?: boolean };
|
||||
|
||||
export function scheduleUpdateCheck() {
|
||||
if (g.__updateCheckScheduled) return;
|
||||
g.__updateCheckScheduled = true;
|
||||
|
||||
// Run immediately on startup (non-blocking)
|
||||
checkForUpdate();
|
||||
|
||||
// Then every 12 hours
|
||||
setInterval(checkForUpdate, POLL_INTERVAL_MS);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { NextConfig } from "next";
|
||||
import { existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
// Open-core: the hosted MCP + its OAuth routes are a commercial (ee/) feature. When ee/
|
||||
// is stripped (public build), those routes don't exist, so the well-known rewrites below
|
||||
// must NOT be emitted — otherwise they'd point at 404s. Gate them on ee/ being present.
|
||||
const hasEE = existsSync(join(__dirname, "ee"));
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
serverExternalPackages: ["better-sqlite3", "playwright", "playwright-extra", "puppeteer-extra-plugin-stealth"],
|
||||
|
||||
// OAuth discovery for the hosted MCP server must live at /.well-known/* (RFC 8414 / 9728).
|
||||
// Map those well-known paths to the pages-router API routes that serve the metadata.
|
||||
// Present only in the commercial build (see hasEE above).
|
||||
async rewrites() {
|
||||
if (!hasEE) return [];
|
||||
return [
|
||||
{
|
||||
source: "/.well-known/oauth-authorization-server",
|
||||
destination: "/api/oauth/metadata-authorization-server",
|
||||
},
|
||||
{
|
||||
source: "/.well-known/oauth-protected-resource",
|
||||
destination: "/api/oauth/metadata-protected-resource",
|
||||
},
|
||||
{
|
||||
// Clients probe the resource-scoped variant too.
|
||||
source: "/.well-known/oauth-protected-resource/api/mcp",
|
||||
destination: "/api/oauth/metadata-protected-resource",
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+10659
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "linki",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"predev": "node scripts/mirror-ee.js",
|
||||
"dev": "next dev",
|
||||
"prebuild": "node scripts/mirror-ee.js",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"mirror:ee": "node scripts/mirror-ee.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"daisyui": "^5.5.19",
|
||||
"driver.js": "^1.6.0",
|
||||
"imap-simple": "^5.1.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"mailparser": "^3.9.8",
|
||||
"next": "16.1.6",
|
||||
"next-auth": "^4.24.13",
|
||||
"nodemailer": "^7.0.13",
|
||||
"openai": "^6.34.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"playwright": "^1.58.2",
|
||||
"playwright-extra": "^4.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/imap-simple": "^4.2.10",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^20",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import "@/styles/globals.css";
|
||||
import type { AppProps } from "next/app";
|
||||
import { SessionProvider, useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
import Layout from "@/components/layout/Layout";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
const PUBLIC_PATHS = ["/login"];
|
||||
|
||||
function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "loading") return;
|
||||
if (!session && !PUBLIC_PATHS.includes(router.pathname)) {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [session, status, router]);
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="min-h-screen bg-base-100 flex items-center justify-center">
|
||||
<span className="loading loading-spinner loading-sm text-base-content/40" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session && !PUBLIC_PATHS.includes(router.pathname)) return null;
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default function App({ Component, pageProps: { session, ...pageProps } }: AppProps) {
|
||||
return (
|
||||
<SessionProvider session={session}>
|
||||
<AuthGuard>
|
||||
<Layout>
|
||||
<Component {...pageProps} />
|
||||
<Toaster theme="dark" position="bottom-right" />
|
||||
</Layout>
|
||||
</AuthGuard>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Html, Head, Main, NextScript } from "next/document";
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html lang="en" data-theme="notion">
|
||||
<Head>
|
||||
<link rel="icon" type="image/x-icon" href="/logo_linki.ico" />
|
||||
<link rel="icon" type="image/png" href="/logo_linki.png" />
|
||||
<link rel="apple-touch-icon" href="/logo_linki.png" />
|
||||
</Head>
|
||||
<body className="min-h-screen bg-base-100 text-base-content antialiased">
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { encryptSecret } from "@/lib/crypto";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") return res.status(405).end();
|
||||
|
||||
const db = getDb();
|
||||
const id = req.query.id as string;
|
||||
|
||||
const account = db.prepare("SELECT * FROM accounts WHERE id = ?").get(id);
|
||||
if (!account) return res.status(404).json({ error: "Account not found" });
|
||||
|
||||
const { li_at, document_cookie } = req.body as { li_at?: string; document_cookie?: string };
|
||||
if (!li_at) return res.status(400).json({ error: "li_at cookie is required" });
|
||||
|
||||
// Parse document.cookie string into cookie objects
|
||||
const extraCookies: { name: string; value: string; domain: string; path: string }[] = [];
|
||||
if (document_cookie) {
|
||||
for (const part of document_cookie.split(";")) {
|
||||
const eqIdx = part.indexOf("=");
|
||||
if (eqIdx === -1) continue;
|
||||
const name = part.slice(0, eqIdx).trim();
|
||||
const value = part.slice(eqIdx + 1).trim();
|
||||
if (name && value) {
|
||||
extraCookies.push({ name, value, domain: ".linkedin.com", path: "/" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build Playwright-compatible storageState
|
||||
const storageState = {
|
||||
cookies: [
|
||||
{ name: "li_at", value: li_at.trim(), domain: ".linkedin.com", path: "/", httpOnly: true, secure: true, sameSite: "None" as const },
|
||||
...extraCookies.filter((c) => c.name !== "li_at"),
|
||||
],
|
||||
origins: [],
|
||||
};
|
||||
|
||||
db.prepare("UPDATE accounts SET cookies_json = ?, is_authenticated = 1 WHERE id = ?").run(
|
||||
encryptSecret(JSON.stringify(storageState)),
|
||||
id
|
||||
);
|
||||
|
||||
// Evict the cached browser context so next import uses the new cookies
|
||||
const { closeSession } = await import("@/lib/linkedin/session");
|
||||
await closeSession(id);
|
||||
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
// Excludes cookies_json — the frontend never uses the raw session blob, only
|
||||
// is_authenticated, so there's no reason to ship it (even encrypted) to the client.
|
||||
const ACCOUNT_COLUMNS = `id, name, email, is_authenticated, daily_connection_limit, daily_message_limit, daily_inmail_limit,
|
||||
active_hours_start, active_hours_end, timezone, working_days, created_at,
|
||||
inbox_synced_at, accepted_sync_at, li_connections, li_pending, li_profile_views,
|
||||
li_stats_synced_at, connections_synced_through_ms`;
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const db = getDb();
|
||||
const id = req.query.id as string;
|
||||
|
||||
if (req.method === "GET") {
|
||||
const account = db.prepare(`SELECT ${ACCOUNT_COLUMNS} FROM accounts WHERE id = ?`).get(id);
|
||||
if (!account) return res.status(404).json({ error: "Not found" });
|
||||
return res.json(account);
|
||||
}
|
||||
|
||||
if (req.method === "PUT") {
|
||||
const { name, email, daily_connection_limit, daily_message_limit, daily_inmail_limit, active_hours_start, active_hours_end, timezone, working_days } = req.body;
|
||||
db.prepare(
|
||||
`UPDATE accounts SET
|
||||
name = COALESCE(?, name),
|
||||
email = COALESCE(?, email),
|
||||
daily_connection_limit = COALESCE(?, daily_connection_limit),
|
||||
daily_message_limit = COALESCE(?, daily_message_limit),
|
||||
daily_inmail_limit = COALESCE(?, daily_inmail_limit),
|
||||
active_hours_start = COALESCE(?, active_hours_start),
|
||||
active_hours_end = COALESCE(?, active_hours_end),
|
||||
timezone = COALESCE(?, timezone),
|
||||
working_days = COALESCE(?, working_days)
|
||||
WHERE id = ?`
|
||||
).run(name, email, daily_connection_limit, daily_message_limit, daily_inmail_limit, active_hours_start, active_hours_end, timezone, working_days, id);
|
||||
return res.json(db.prepare(`SELECT ${ACCOUNT_COLUMNS} FROM accounts WHERE id = ?`).get(id));
|
||||
}
|
||||
|
||||
if (req.method === "DELETE") {
|
||||
db.prepare("DELETE FROM accounts WHERE id = ?").run(id);
|
||||
return res.status(204).end();
|
||||
}
|
||||
|
||||
res.setHeader("Allow", ["GET", "PUT", "DELETE"]);
|
||||
res.status(405).end();
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { getSessionPage, saveSessionState } from "@/lib/linkedin/session";
|
||||
import { scrapeLinkedInStats } from "@/lib/linkedin/li-stats";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") return res.status(405).end();
|
||||
|
||||
const accountId = req.query.id as string;
|
||||
const db = getDb();
|
||||
const account = db.prepare("SELECT id, is_authenticated FROM accounts WHERE id = ?").get(accountId) as
|
||||
| { id: string; is_authenticated: number }
|
||||
| undefined;
|
||||
|
||||
if (!account) return res.status(404).json({ error: "Account not found" });
|
||||
if (!account.is_authenticated) return res.status(400).json({ error: "Account not authenticated" });
|
||||
|
||||
let page;
|
||||
try {
|
||||
page = await getSessionPage(accountId);
|
||||
const stats = await scrapeLinkedInStats(page);
|
||||
await saveSessionState(accountId);
|
||||
db.prepare(`
|
||||
UPDATE accounts SET
|
||||
li_connections = ?, li_pending = ?, li_profile_views = ?,
|
||||
li_stats_synced_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(stats.connections, stats.pending, stats.profile_views, accountId);
|
||||
return res.json(stats);
|
||||
} catch (err) {
|
||||
console.error("[li-stats]", err);
|
||||
return res.status(500).json({ error: err instanceof Error ? err.message : "Scrape failed" });
|
||||
} finally {
|
||||
await page?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { startHeadlessLogin, submitLoginChallenge, awaitLoginApproval } from "@/lib/linkedin/session";
|
||||
|
||||
/**
|
||||
* Server-side headless LinkedIn login.
|
||||
* POST { step: "start", email, password } → begins login (returns authenticated | challenge | error)
|
||||
* POST { step: "verify", code } → submits the email/SMS verification code
|
||||
* POST { step: "await" } → waits for a device/app approval to clear
|
||||
*
|
||||
* The session is born under the same pinned Chromium fingerprint the runner uses
|
||||
* and captures all cookies (incl. httpOnly li_ep_auth_context).
|
||||
*/
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") return res.status(405).end();
|
||||
|
||||
const db = getDb();
|
||||
const id = req.query.id as string;
|
||||
const account = db.prepare("SELECT * FROM accounts WHERE id = ?").get(id) as
|
||||
| { email: string }
|
||||
| undefined;
|
||||
if (!account) return res.status(404).json({ error: "Account not found" });
|
||||
|
||||
const { step } = req.body as { step?: string };
|
||||
|
||||
try {
|
||||
if (step === "verify") {
|
||||
const { code } = req.body as { code?: string };
|
||||
if (!code?.trim()) return res.status(400).json({ error: "code is required" });
|
||||
return res.json(await submitLoginChallenge(id, code.trim()));
|
||||
}
|
||||
|
||||
if (step === "await") {
|
||||
return res.json(await awaitLoginApproval(id));
|
||||
}
|
||||
|
||||
const { email, password } = req.body as { email?: string; password?: string };
|
||||
const loginEmail = (email || account.email || "").trim();
|
||||
if (!loginEmail || !password) {
|
||||
return res.status(400).json({ error: "email and password are required" });
|
||||
}
|
||||
return res.json(await startHeadlessLogin(id, loginEmail, password));
|
||||
} catch (e) {
|
||||
return res.status(500).json({ error: (e as Error).message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { getSessionPage, saveSessionState } from "@/lib/linkedin/session";
|
||||
import { scrapePendingInvitationVanityNames } from "@/lib/linkedin/pending-invitations";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") return res.status(405).end();
|
||||
|
||||
const accountId = req.query.id as string;
|
||||
const db = getDb();
|
||||
|
||||
const account = db.prepare("SELECT id, is_authenticated FROM accounts WHERE id = ?").get(accountId) as
|
||||
| { id: string; is_authenticated: number }
|
||||
| undefined;
|
||||
|
||||
if (!account) return res.status(404).json({ error: "Account not found" });
|
||||
if (!account.is_authenticated) return res.status(400).json({ error: "Account not authenticated" });
|
||||
|
||||
let page;
|
||||
try {
|
||||
page = await getSessionPage(accountId);
|
||||
const stillPending = await scrapePendingInvitationVanityNames(page);
|
||||
await saveSessionState(accountId);
|
||||
|
||||
// Find all targets that we sent a request to but haven't marked as connected
|
||||
const waiting = db.prepare(`
|
||||
SELECT id, linkedin_url, full_name
|
||||
FROM targets
|
||||
WHERE connection_requested_at IS NOT NULL
|
||||
AND (degree IS NULL OR degree != 1)
|
||||
AND connected_at IS NULL
|
||||
`).all() as { id: string; linkedin_url: string; full_name: string | null }[];
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const accepted: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
const markAccepted = db.prepare(
|
||||
"UPDATE targets SET degree = 1, connected_at = ? WHERE id = ?"
|
||||
);
|
||||
|
||||
db.transaction(() => {
|
||||
for (const target of waiting) {
|
||||
const match = target.linkedin_url?.match(/\/in\/([^/?#]+)/);
|
||||
if (!match) continue; // no /in/ URL — can't check
|
||||
const vanity = match[1].toLowerCase();
|
||||
|
||||
if (!stillPending.has(vanity)) {
|
||||
// Not in the pending list anymore → accepted (or expired, but treat as accepted)
|
||||
markAccepted.run(now, target.id);
|
||||
accepted.push(target.full_name ?? vanity);
|
||||
} else {
|
||||
skipped.push(target.full_name ?? vanity);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return res.json({
|
||||
pending_on_linkedin: stillPending.size,
|
||||
waiting_in_db: waiting.length,
|
||||
newly_accepted: accepted.length,
|
||||
still_pending: skipped.length,
|
||||
accepted_names: accepted,
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error("[sync-accepted]", err);
|
||||
return res.status(500).json({ error: err instanceof Error ? err.message : "Sync failed" });
|
||||
} finally {
|
||||
await page?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const db = getDb();
|
||||
|
||||
// Excludes cookies_json — the frontend never uses the raw session blob, only
|
||||
// is_authenticated, so there's no reason to ship it (even encrypted) to the client.
|
||||
const ACCOUNT_COLUMNS = `a.id, a.name, a.email, a.is_authenticated, a.daily_connection_limit, a.daily_message_limit, a.daily_inmail_limit,
|
||||
a.active_hours_start, a.active_hours_end, a.timezone, a.working_days, a.created_at,
|
||||
a.inbox_synced_at, a.accepted_sync_at, a.li_connections, a.li_pending, a.li_profile_views,
|
||||
a.li_stats_synced_at, a.connections_synced_through_ms`;
|
||||
|
||||
if (req.method === "GET") {
|
||||
const accounts = db.prepare(`
|
||||
SELECT ${ACCOUNT_COLUMNS},
|
||||
(SELECT COUNT(*) FROM runs r WHERE r.account_id = a.id AND r.status IN ('running', 'paused')) AS active_run_count
|
||||
FROM accounts a ORDER BY a.created_at DESC
|
||||
`).all();
|
||||
return res.json(accounts);
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
const { name, email, daily_connection_limit = 20, daily_message_limit = 50, daily_inmail_limit = 15 } = req.body;
|
||||
if (!name || !email) return res.status(400).json({ error: "name and email required" });
|
||||
try {
|
||||
const id = randomUUID();
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO accounts (id, name, email, daily_connection_limit, daily_message_limit, daily_inmail_limit) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.run(id, name, email, daily_connection_limit, daily_message_limit, daily_inmail_limit);
|
||||
const account = db.prepare(`SELECT ${ACCOUNT_COLUMNS} FROM accounts a WHERE a.id = ?`).get(id);
|
||||
return res.status(201).json(account);
|
||||
} catch {
|
||||
return res.status(409).json({ error: "Email already exists" });
|
||||
}
|
||||
}
|
||||
|
||||
res.setHeader("Allow", ["GET", "POST"]);
|
||||
res.status(405).end();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import NextAuth, { NextAuthOptions } from "next-auth";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { isRateLimited } from "@/lib/rate-limit";
|
||||
|
||||
type UserRow = { id: string; email: string; password_hash: string };
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: "Credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials, req) {
|
||||
if (!credentials?.email || !credentials?.password) return null;
|
||||
|
||||
// Throttle login attempts per IP — this is the password brute-force surface.
|
||||
if (isRateLimited(req, "login", 10, 15 * 60 * 1000)) {
|
||||
throw new Error("Too many attempts. Try again later.");
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const user = db
|
||||
.prepare("SELECT id, email, password_hash FROM users WHERE email = ?")
|
||||
.get(credentials.email) as UserRow | undefined;
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const valid = await bcrypt.compare(credentials.password, user.password_hash);
|
||||
if (!valid) return null;
|
||||
|
||||
return { id: user.id, email: user.email };
|
||||
},
|
||||
}),
|
||||
],
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
},
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
},
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
};
|
||||
|
||||
export default NextAuth(authOptions);
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "./[...nextauth]";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") return res.status(405).end();
|
||||
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
if (!session?.user?.email) return res.status(401).json({ error: "Not authenticated" });
|
||||
|
||||
const { currentPassword, newPassword } = req.body as {
|
||||
currentPassword?: string;
|
||||
newPassword?: string;
|
||||
};
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
return res.status(400).json({ error: "Current and new password are required." });
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
return res.status(400).json({ error: "New password must be at least 8 characters." });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const user = db
|
||||
.prepare("SELECT id, password_hash FROM users WHERE email = ?")
|
||||
.get(session.user.email) as { id: string; password_hash: string } | undefined;
|
||||
|
||||
if (!user) return res.status(404).json({ error: "User not found." });
|
||||
|
||||
const valid = await bcrypt.compare(currentPassword, user.password_hash);
|
||||
if (!valid) return res.status(400).json({ error: "Current password is incorrect." });
|
||||
|
||||
const hash = await bcrypt.hash(newPassword, 10);
|
||||
db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(hash, user.id);
|
||||
|
||||
return res.status(200).json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { randomUUID } from "crypto";
|
||||
import { isRateLimited } from "@/lib/rate-limit";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") return res.status(405).end();
|
||||
|
||||
// Invite code + password are both guessable secrets — throttle attempts per IP.
|
||||
if (isRateLimited(req, "signup", 10, 15 * 60 * 1000)) {
|
||||
return res.status(429).json({ error: "Too many attempts. Try again later." });
|
||||
}
|
||||
|
||||
const { email, password, inviteCode } = req.body as {
|
||||
email?: string;
|
||||
password?: string;
|
||||
inviteCode?: string;
|
||||
};
|
||||
|
||||
if (!email || !password || !inviteCode) {
|
||||
return res.status(400).json({ error: "Email, password, and invite code are required." });
|
||||
}
|
||||
|
||||
const authPassword = process.env.AUTH_PASSWORD;
|
||||
if (!authPassword) {
|
||||
return res.status(500).json({ error: "AUTH_PASSWORD is not configured on this server." });
|
||||
}
|
||||
|
||||
if (inviteCode !== authPassword) {
|
||||
return res.status(403).json({ error: "Invalid invite code." });
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return res.status(400).json({ error: "Password must be at least 8 characters." });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(email);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: "An account with this email already exists." });
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
db.prepare("INSERT INTO users (id, email, password_hash) VALUES (?, ?, ?)").run(randomUUID(), email, hash);
|
||||
|
||||
return res.status(201).json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const db = getDb();
|
||||
const id = req.query.id as string;
|
||||
|
||||
if (req.method === "GET") {
|
||||
const company = db.prepare("SELECT * FROM companies WHERE id = ?").get(id);
|
||||
if (!company) return res.status(404).json({ error: "not found" });
|
||||
const contacts = db
|
||||
.prepare("SELECT id, full_name, title, email, linkedin_url FROM targets WHERE company_id = ? ORDER BY full_name")
|
||||
.all(id);
|
||||
return res.json({ ...company as object, contacts });
|
||||
}
|
||||
|
||||
if (req.method === "PUT") {
|
||||
const { name, domain, industry, location, linkedin_url, website, notes } = req.body;
|
||||
db.prepare(`
|
||||
UPDATE companies SET
|
||||
name = COALESCE(?, name),
|
||||
domain = ?,
|
||||
industry = ?,
|
||||
location = ?,
|
||||
linkedin_url = ?,
|
||||
website = ?,
|
||||
notes = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
name ?? null, domain ?? null, industry ?? null,
|
||||
location ?? null, linkedin_url ?? null, website ?? null, notes ?? null,
|
||||
id
|
||||
);
|
||||
return res.json(db.prepare("SELECT * FROM companies WHERE id = ?").get(id));
|
||||
}
|
||||
|
||||
if (req.method === "DELETE") {
|
||||
// Unlink contacts first, then delete company
|
||||
db.prepare("UPDATE targets SET company_id = NULL WHERE company_id = ?").run(id);
|
||||
db.prepare("DELETE FROM companies WHERE id = ?").run(id);
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
|
||||
res.status(405).end();
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const db = getDb();
|
||||
|
||||
if (req.method === "GET") {
|
||||
// Paging + light default fields — the full enrichment blobs (description, keywords,
|
||||
// technology_names, etc.) are heavy; only return them when full=1 is requested.
|
||||
const full = req.query.full === "1" || req.query.full === "true";
|
||||
const search = (req.query.search as string | undefined)?.trim();
|
||||
// Page by default (trimmed fields). full=1 with no explicit limit returns the whole set
|
||||
// (the companies UI relies on this for its own client-side search/filter).
|
||||
const explicitPaging = req.query.limit !== undefined || req.query.page !== undefined;
|
||||
const hasPaging = explicitPaging || !full;
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 500);
|
||||
const offset = (Number(req.query.page) || 0) * limit;
|
||||
|
||||
const where = search ? "WHERE c.name LIKE ?" : "";
|
||||
const whereArgs: unknown[] = search ? [`%${search}%`] : [];
|
||||
|
||||
const select = full
|
||||
? "c.*"
|
||||
: "c.id, c.name, c.domain, c.industry, c.location, c.website, c.employee_count, c.created_at";
|
||||
|
||||
const total = (db.prepare(`SELECT COUNT(*) as c FROM companies c ${where}`).get(...whereArgs) as { c: number }).c;
|
||||
|
||||
const pageClause = hasPaging ? " LIMIT ? OFFSET ?" : "";
|
||||
const pageArgs = hasPaging ? [limit, offset] : [];
|
||||
|
||||
const companies = db.prepare(`
|
||||
SELECT ${select}, COUNT(t.id) as contact_count
|
||||
FROM companies c
|
||||
LEFT JOIN targets t ON t.company_id = c.id
|
||||
${where}
|
||||
GROUP BY c.id
|
||||
ORDER BY c.name COLLATE NOCASE${pageClause}
|
||||
`).all(...whereArgs, ...pageArgs);
|
||||
|
||||
return res.json({ companies, total });
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
const { name, domain, industry, location, linkedin_url, website, notes } = req.body;
|
||||
if (!name) return res.status(400).json({ error: "name required" });
|
||||
const id = randomUUID();
|
||||
db.prepare(`
|
||||
INSERT INTO companies (id, name, domain, industry, location, linkedin_url, website, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, name, domain ?? null, industry ?? null, location ?? null, linkedin_url ?? null, website ?? null, notes ?? null);
|
||||
return res.status(201).json(db.prepare("SELECT * FROM companies WHERE id = ?").get(id));
|
||||
}
|
||||
|
||||
res.setHeader("Allow", ["GET", "POST"]);
|
||||
res.status(405).end();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "GET") return res.status(405).end();
|
||||
|
||||
try {
|
||||
const db = getDb();
|
||||
const days = Math.min(Math.max(Number(req.query.days) || 7, 7), 90);
|
||||
|
||||
const daily = db.prepare(`
|
||||
SELECT
|
||||
date(created_at) AS day,
|
||||
SUM(cost_usd) AS cost_usd,
|
||||
SUM(input_tokens) AS input_tokens,
|
||||
SUM(output_tokens) AS output_tokens
|
||||
FROM agent_sessions
|
||||
WHERE created_at >= datetime('now', '-${days} days')
|
||||
GROUP BY date(created_at)
|
||||
ORDER BY day ASC
|
||||
`).all() as { day: string; cost_usd: number; input_tokens: number; output_tokens: number }[];
|
||||
|
||||
// Fill missing days with zeros
|
||||
const filled: typeof daily = [];
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - i);
|
||||
const key = d.toISOString().slice(0, 10);
|
||||
const found = daily.find(r => r.day === key);
|
||||
filled.push(found ?? { day: key, cost_usd: 0, input_tokens: 0, output_tokens: 0 });
|
||||
}
|
||||
|
||||
res.json({ daily: filled });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: "Failed to load agent stats" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "GET") return res.status(405).end();
|
||||
|
||||
try {
|
||||
const db = getDb();
|
||||
|
||||
const listId = req.query.list_id as string | undefined;
|
||||
const workflowId = req.query.workflow_id as string | undefined;
|
||||
const days = Math.min(Math.max(Number(req.query.days) || 7, 7), 90);
|
||||
|
||||
// Fetch lists and workflows for filter dropdowns (always unfiltered)
|
||||
const lists = db.prepare("SELECT id, name FROM lists ORDER BY name").all() as { id: string; name: string }[];
|
||||
const workflows = db.prepare("SELECT id, name FROM workflows ORDER BY name").all() as { id: string; name: string }[];
|
||||
|
||||
// Today's summary (always global — not scoped to filter)
|
||||
const today = db.prepare(`
|
||||
SELECT
|
||||
COUNT(CASE WHEN message LIKE 'Visited%' THEN 1 END) AS visits_today,
|
||||
COUNT(CASE WHEN message LIKE 'Connection request sent%' THEN 1 END) AS connections_today,
|
||||
COUNT(CASE WHEN message LIKE 'Message sent%' THEN 1 END) AS messages_today,
|
||||
COUNT(CASE WHEN message LIKE 'InMail sent%' THEN 1 END) AS inmails_today
|
||||
FROM logs
|
||||
WHERE date(created_at) = date('now')
|
||||
`).get() as Record<string, number>;
|
||||
|
||||
if (!workflowId && !listId) {
|
||||
// ── Unfiltered: use targets fields (fast, global) ──────────────────────
|
||||
const ACTIVE = `id IN (SELECT DISTINCT target_id FROM list_targets)`;
|
||||
|
||||
const totals = db.prepare(`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM targets WHERE ${ACTIVE}) AS total_targets,
|
||||
(SELECT COUNT(*) FROM targets WHERE ${ACTIVE} AND connection_requested_at IS NOT NULL) AS connections_requested,
|
||||
(SELECT COUNT(*) FROM targets WHERE ${ACTIVE} AND connected_at IS NOT NULL) AS connected,
|
||||
(SELECT COUNT(*) FROM targets WHERE ${ACTIVE} AND message_sent_at IS NOT NULL) AS messages_sent,
|
||||
(SELECT COUNT(*) FROM targets WHERE ${ACTIVE} AND inmail_sent_at IS NOT NULL) AS inmails_sent,
|
||||
(SELECT COUNT(*) FROM targets WHERE ${ACTIVE} AND last_replied_at IS NOT NULL) AS replies_received,
|
||||
(SELECT COUNT(*) FROM runs WHERE status = 'running') AS active_runs,
|
||||
(SELECT COUNT(*) FROM lists) AS total_lists,
|
||||
(SELECT COUNT(*) FROM workflows) AS total_workflows,
|
||||
(SELECT COUNT(*) FROM logs WHERE message LIKE 'Email sent%') AS emails_sent,
|
||||
(SELECT COUNT(*) FROM targets WHERE ${ACTIVE} AND email_replied_at IS NOT NULL) AS email_replies
|
||||
`).get() as Record<string, number>;
|
||||
|
||||
const activity = db.prepare(`
|
||||
SELECT
|
||||
date(created_at) AS day,
|
||||
COUNT(CASE WHEN message LIKE 'Visited%' THEN 1 END) AS visits,
|
||||
COUNT(CASE WHEN message LIKE 'Connection request sent%' THEN 1 END) AS connections,
|
||||
COUNT(CASE WHEN message LIKE 'Message sent%' THEN 1 END) AS messages,
|
||||
COUNT(CASE WHEN message LIKE 'InMail sent%' THEN 1 END) AS inmails,
|
||||
COUNT(CASE WHEN message LIKE 'Email sent%' THEN 1 END) AS emails
|
||||
FROM logs
|
||||
WHERE created_at >= datetime('now', '-${days} days')
|
||||
GROUP BY date(created_at)
|
||||
ORDER BY day ASC
|
||||
`).all() as { day: string; visits: number; connections: number; messages: number; inmails: number; emails: number }[];
|
||||
|
||||
const filled = fillDays(activity, days);
|
||||
return res.json({ totals, today, activity: filled, lists, workflows });
|
||||
}
|
||||
|
||||
// ── Filtered by workflow or list: use logs as source of truth ─────────────
|
||||
// Build the scoped runs subquery + its single argument
|
||||
let runsSubquery: string;
|
||||
let runsArg: string;
|
||||
if (workflowId) {
|
||||
runsSubquery = `SELECT id FROM runs WHERE workflow_id = ? AND status IN ('running','paused','completed')`;
|
||||
runsArg = workflowId;
|
||||
} else {
|
||||
runsSubquery = `SELECT id FROM runs WHERE list_id = ? AND status IN ('running','paused','completed')`;
|
||||
runsArg = listId!;
|
||||
}
|
||||
|
||||
// Targets in scope = distinct targets that appeared in scoped runs
|
||||
const SCOPED_TARGETS = `SELECT DISTINCT target_id FROM run_profiles WHERE run_id IN (${runsSubquery})`;
|
||||
|
||||
const totals = db.prepare(`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM (${SCOPED_TARGETS})) AS total_targets,
|
||||
|
||||
(SELECT COUNT(DISTINCT target_id) FROM logs
|
||||
WHERE run_id IN (${runsSubquery})
|
||||
AND message LIKE 'Connection request sent%') AS connections_requested,
|
||||
|
||||
(SELECT COUNT(DISTINCT l.target_id) FROM logs l
|
||||
JOIN targets t ON t.id = l.target_id
|
||||
WHERE l.run_id IN (${runsSubquery})
|
||||
AND l.message LIKE 'Connection request sent%'
|
||||
AND t.connected_at IS NOT NULL) AS connected,
|
||||
|
||||
(SELECT COUNT(DISTINCT target_id) FROM logs
|
||||
WHERE run_id IN (${runsSubquery})
|
||||
AND message LIKE 'Message sent%') AS messages_sent,
|
||||
|
||||
(SELECT COUNT(DISTINCT target_id) FROM logs
|
||||
WHERE run_id IN (${runsSubquery})
|
||||
AND message LIKE 'InMail sent%') AS inmails_sent,
|
||||
|
||||
(SELECT COUNT(DISTINCT l.target_id) FROM logs l
|
||||
JOIN targets t ON t.id = l.target_id
|
||||
WHERE l.run_id IN (${runsSubquery})
|
||||
AND (l.message LIKE 'Message sent%' OR l.message LIKE 'InMail sent%')
|
||||
AND t.last_replied_at IS NOT NULL) AS replies_received,
|
||||
|
||||
(SELECT COUNT(*) FROM runs WHERE status = 'running') AS active_runs,
|
||||
(SELECT COUNT(*) FROM lists) AS total_lists,
|
||||
(SELECT COUNT(*) FROM workflows) AS total_workflows,
|
||||
|
||||
(SELECT COUNT(DISTINCT target_id) FROM logs
|
||||
WHERE run_id IN (${runsSubquery})
|
||||
AND message LIKE 'Email sent%') AS emails_sent,
|
||||
|
||||
(SELECT COUNT(DISTINCT l.target_id) FROM logs l
|
||||
JOIN targets t ON t.id = l.target_id
|
||||
WHERE l.run_id IN (${runsSubquery})
|
||||
AND l.message LIKE 'Email sent%'
|
||||
AND t.email_replied_at IS NOT NULL) AS email_replies
|
||||
`).get(
|
||||
runsArg, // SCOPED_TARGETS
|
||||
runsArg, // connections_requested
|
||||
runsArg, // connected
|
||||
runsArg, // messages_sent
|
||||
runsArg, // inmails_sent
|
||||
runsArg, // replies_received
|
||||
runsArg, // emails_sent
|
||||
runsArg, // email_replies
|
||||
) as Record<string, number>;
|
||||
|
||||
const activity = db.prepare(`
|
||||
SELECT
|
||||
date(created_at) AS day,
|
||||
COUNT(CASE WHEN message LIKE 'Visited%' THEN 1 END) AS visits,
|
||||
COUNT(CASE WHEN message LIKE 'Connection request sent%' THEN 1 END) AS connections,
|
||||
COUNT(CASE WHEN message LIKE 'Message sent%' THEN 1 END) AS messages,
|
||||
COUNT(CASE WHEN message LIKE 'InMail sent%' THEN 1 END) AS inmails,
|
||||
COUNT(CASE WHEN message LIKE 'Email sent%' THEN 1 END) AS emails
|
||||
FROM logs
|
||||
WHERE created_at >= datetime('now', '-${days} days')
|
||||
AND run_id IN (${runsSubquery})
|
||||
GROUP BY date(created_at)
|
||||
ORDER BY day ASC
|
||||
`).all(runsArg) as { day: string; visits: number; connections: number; messages: number; inmails: number; emails: number }[];
|
||||
|
||||
const filled = fillDays(activity, days);
|
||||
return res.json({ totals, today, activity: filled, lists, workflows });
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: "Failed to load dashboard stats" });
|
||||
}
|
||||
}
|
||||
|
||||
function fillDays(
|
||||
activity: { day: string; visits: number; connections: number; messages: number; inmails: number; emails: number }[],
|
||||
days: number
|
||||
) {
|
||||
const filled: typeof activity = [];
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - i);
|
||||
const key = d.toISOString().slice(0, 10);
|
||||
const found = activity.find(r => r.day === key);
|
||||
filled.push(found ?? { day: key, visits: 0, connections: 0, messages: 0, inmails: 0, emails: 0 });
|
||||
}
|
||||
return filled;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { encryptSecret } from "@/lib/crypto";
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const db = getDb();
|
||||
const id = req.query.id as string;
|
||||
|
||||
if (req.method === "GET") {
|
||||
const account = db
|
||||
.prepare("SELECT id, name, from_email, from_name, reply_to, smtp_host, smtp_port, smtp_secure, imap_host, imap_port, username, imap_username, daily_email_limit, active_hours_start, active_hours_end, timezone, working_days, is_verified, signature, ramp_up_enabled, ramp_start_date, created_at FROM email_accounts WHERE id = ?")
|
||||
.get(id);
|
||||
if (!account) return res.status(404).json({ error: "not found" });
|
||||
return res.json(account);
|
||||
}
|
||||
|
||||
if (req.method === "PUT") {
|
||||
const {
|
||||
name, from_email, from_name, reply_to,
|
||||
smtp_host, smtp_port, smtp_secure,
|
||||
imap_host, imap_port,
|
||||
username, password,
|
||||
imap_username, imap_password,
|
||||
daily_email_limit,
|
||||
active_hours_start, active_hours_end,
|
||||
timezone, working_days,
|
||||
signature,
|
||||
ramp_up_enabled,
|
||||
ramp_start_date,
|
||||
} = req.body;
|
||||
|
||||
const rampEnabled = ramp_up_enabled != null ? (ramp_up_enabled ? 1 : 0) : null;
|
||||
|
||||
// Build password SET clauses only when provided (don't wipe on partial update)
|
||||
const pwClause = password ? "password = ?," : "";
|
||||
const imapPwClause = imap_password ? "imap_password = ?," : "";
|
||||
|
||||
const pwParams = password ? [encryptSecret(password)] : [];
|
||||
const imapPwParams = imap_password ? [encryptSecret(imap_password)] : [];
|
||||
|
||||
db.prepare(`
|
||||
UPDATE email_accounts SET
|
||||
name = COALESCE(?, name), from_email = COALESCE(?, from_email),
|
||||
from_name = ?, reply_to = ?, smtp_host = COALESCE(?, smtp_host),
|
||||
smtp_port = COALESCE(?, smtp_port), smtp_secure = COALESCE(?, smtp_secure),
|
||||
imap_host = ?, imap_port = COALESCE(?, imap_port),
|
||||
username = COALESCE(?, username), ${pwClause}
|
||||
imap_username = ?, ${imapPwClause}
|
||||
daily_email_limit = COALESCE(?, daily_email_limit),
|
||||
active_hours_start = COALESCE(?, active_hours_start),
|
||||
active_hours_end = COALESCE(?, active_hours_end),
|
||||
timezone = COALESCE(?, timezone), working_days = COALESCE(?, working_days),
|
||||
signature = ?, ramp_up_enabled = COALESCE(?, ramp_up_enabled),
|
||||
ramp_start_date = COALESCE(?, ramp_start_date)
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
name ?? null, from_email ?? null, from_name ?? null, reply_to ?? null,
|
||||
smtp_host ?? null, smtp_port ?? null, smtp_secure ?? null,
|
||||
imap_host ?? null, imap_port ?? null,
|
||||
username ?? null, ...pwParams,
|
||||
imap_username ?? null, ...imapPwParams,
|
||||
daily_email_limit ?? null,
|
||||
active_hours_start ?? null, active_hours_end ?? null,
|
||||
timezone ?? null, working_days ?? null,
|
||||
signature ?? null, rampEnabled, ramp_start_date ?? null,
|
||||
id
|
||||
);
|
||||
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
|
||||
if (req.method === "DELETE") {
|
||||
db.prepare("DELETE FROM email_accounts WHERE id = ?").run(id);
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
|
||||
res.status(405).end();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { sendEmail } from "@/lib/email/sender";
|
||||
import { decryptSecret } from "@/lib/crypto";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
return res.status(405).end();
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const id = req.query.id as string;
|
||||
const { to, subject, body } = req.body as { to?: string; subject?: string; body?: string };
|
||||
|
||||
if (!to) return res.status(400).json({ error: "to is required" });
|
||||
|
||||
const account = db
|
||||
.prepare("SELECT * FROM email_accounts WHERE id = ?")
|
||||
.get(id) as {
|
||||
id: string; from_email: string; from_name: string | null;
|
||||
smtp_host: string; smtp_port: number; smtp_secure: number;
|
||||
username: string; password: string; signature: string | null;
|
||||
} | undefined;
|
||||
|
||||
if (!account) return res.status(404).json({ error: "not found" });
|
||||
|
||||
const fullBody = buildEmailBody(body ?? "", account.signature);
|
||||
|
||||
try {
|
||||
await sendEmail({ ...account, password: decryptSecret(account.password)! }, to, subject ?? "Test email from Linki", fullBody);
|
||||
return res.json({ ok: true });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
function buildEmailBody(body: string, signature: string | null): string {
|
||||
const sig = signature?.trim();
|
||||
if (!sig) return body;
|
||||
return `${body}\n\n--\n${sig}`;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { testSmtpConnection, testImapConnection } from "@/lib/email/sender";
|
||||
import { decryptSecret } from "@/lib/crypto";
|
||||
|
||||
interface AccountRow {
|
||||
from_email: string;
|
||||
from_name: string | null;
|
||||
smtp_host: string;
|
||||
smtp_port: number;
|
||||
smtp_secure: number;
|
||||
imap_host: string | null;
|
||||
imap_port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
imap_username: string | null;
|
||||
imap_password: string | null;
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
return res.status(405).end();
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const id = req.query.id as string;
|
||||
|
||||
const row = db
|
||||
.prepare("SELECT * FROM email_accounts WHERE id = ?")
|
||||
.get(id) as AccountRow | undefined;
|
||||
|
||||
if (!row) return res.status(404).json({ error: "not found" });
|
||||
|
||||
const account: AccountRow = {
|
||||
...row,
|
||||
password: decryptSecret(row.password)!,
|
||||
imap_password: decryptSecret(row.imap_password),
|
||||
};
|
||||
|
||||
const smtpError = await testSmtpConnection(account);
|
||||
|
||||
let imapError: string | null = null;
|
||||
if (account.imap_host) {
|
||||
imapError = await testImapConnection({
|
||||
imap_host: account.imap_host,
|
||||
imap_port: account.imap_port ?? 993,
|
||||
username: account.username,
|
||||
password: account.password,
|
||||
imap_username: account.imap_username,
|
||||
imap_password: account.imap_password,
|
||||
});
|
||||
}
|
||||
|
||||
const ok = !smtpError && !imapError;
|
||||
|
||||
if (ok) {
|
||||
db.prepare("UPDATE email_accounts SET is_verified = 1 WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
return res.status(ok ? 200 : 400).json({
|
||||
ok,
|
||||
smtp: smtpError ? { ok: false, error: smtpError } : { ok: true },
|
||||
imap: account.imap_host
|
||||
? (imapError ? { ok: false, error: imapError } : { ok: true })
|
||||
: null,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { randomUUID } from "crypto";
|
||||
import { encryptSecret } from "@/lib/crypto";
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const db = getDb();
|
||||
|
||||
if (req.method === "GET") {
|
||||
// Never return passwords to the client
|
||||
const accounts = db
|
||||
.prepare(`
|
||||
SELECT ea.id, ea.name, ea.from_email, ea.from_name, ea.reply_to,
|
||||
ea.smtp_host, ea.smtp_port, ea.smtp_secure,
|
||||
ea.imap_host, ea.imap_port, ea.username, ea.imap_username,
|
||||
ea.daily_email_limit, ea.active_hours_start, ea.active_hours_end,
|
||||
ea.timezone, ea.working_days, ea.is_verified, ea.signature,
|
||||
ea.ramp_up_enabled, ea.ramp_start_date, ea.created_at,
|
||||
(SELECT COUNT(DISTINCT rp.run_id) FROM run_profiles rp
|
||||
JOIN runs r ON rp.run_id = r.id
|
||||
WHERE rp.email_account_id = ea.id
|
||||
AND r.status IN ('running', 'paused')) AS active_run_count
|
||||
FROM email_accounts ea ORDER BY ea.created_at DESC
|
||||
`)
|
||||
.all();
|
||||
return res.json(accounts);
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
const {
|
||||
name, from_email, from_name, reply_to,
|
||||
smtp_host, smtp_port = 587, smtp_secure = 0,
|
||||
imap_host, imap_port = 993,
|
||||
username, password,
|
||||
imap_username, imap_password,
|
||||
daily_email_limit = 50,
|
||||
active_hours_start = 9, active_hours_end = 18,
|
||||
timezone = "UTC", working_days = "1,2,3,4,5",
|
||||
signature,
|
||||
ramp_up_enabled = 1,
|
||||
ramp_start_date,
|
||||
} = req.body;
|
||||
|
||||
if (!name || !from_email || !smtp_host || !username || !password) {
|
||||
return res.status(400).json({ error: "name, from_email, smtp_host, username, password required" });
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const resolvedRampStart = ramp_start_date ?? new Date().toISOString().slice(0, 10);
|
||||
db.prepare(`
|
||||
INSERT INTO email_accounts
|
||||
(id, name, from_email, from_name, reply_to, smtp_host, smtp_port, smtp_secure,
|
||||
imap_host, imap_port, username, password,
|
||||
imap_username, imap_password,
|
||||
daily_email_limit, active_hours_start, active_hours_end, timezone, working_days, signature,
|
||||
ramp_up_enabled, ramp_start_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id, name, from_email, from_name ?? null, reply_to ?? null,
|
||||
smtp_host, smtp_port, smtp_secure,
|
||||
imap_host ?? null, imap_port,
|
||||
username, encryptSecret(password),
|
||||
imap_username ?? null, imap_password ? encryptSecret(imap_password) : null,
|
||||
daily_email_limit, active_hours_start, active_hours_end, timezone, working_days,
|
||||
signature ?? null,
|
||||
ramp_up_enabled ? 1 : 0, resolvedRampStart
|
||||
);
|
||||
|
||||
return res.status(201).json({ id });
|
||||
}
|
||||
|
||||
res.setHeader("Allow", ["GET", "POST"]);
|
||||
res.status(405).end();
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "GET") return res.status(405).end();
|
||||
const db = getDb();
|
||||
|
||||
const accounts = db.prepare(`
|
||||
SELECT id, name, from_email, daily_email_limit, ramp_up_enabled, ramp_start_date
|
||||
FROM email_accounts
|
||||
ORDER BY name
|
||||
`).all() as {
|
||||
id: string; name: string; from_email: string;
|
||||
daily_email_limit: number; ramp_up_enabled: number; ramp_start_date: string | null;
|
||||
}[];
|
||||
|
||||
// Sent per account per day for the last 7 days
|
||||
const dailySends = db.prepare(`
|
||||
SELECT rp.email_account_id,
|
||||
date(l.created_at) as day,
|
||||
COUNT(*) as sent
|
||||
FROM logs l
|
||||
JOIN run_profiles rp ON rp.run_id = l.run_id AND rp.target_id = l.target_id
|
||||
WHERE l.message LIKE 'Email sent%'
|
||||
AND l.created_at >= date('now', '-6 days')
|
||||
GROUP BY rp.email_account_id, date(l.created_at)
|
||||
`).all() as { email_account_id: string; day: string; sent: number }[];
|
||||
|
||||
// Last 50 email send log entries across all accounts
|
||||
const recentLogs = db.prepare(`
|
||||
SELECT l.created_at, l.message, rp.email_account_id
|
||||
FROM logs l
|
||||
JOIN run_profiles rp ON rp.run_id = l.run_id AND rp.target_id = l.target_id
|
||||
WHERE l.message LIKE 'Email sent%'
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT 50
|
||||
`).all() as { created_at: string; message: string; email_account_id: string }[];
|
||||
|
||||
// Guard trips today
|
||||
const guardTrips = db.prepare(`
|
||||
SELECT l.created_at, l.message, rp.email_account_id
|
||||
FROM logs l
|
||||
LEFT JOIN run_profiles rp ON rp.run_id = l.run_id AND rp.target_id = l.target_id
|
||||
WHERE (l.message LIKE '%Daily limit%' OR l.message LIKE '%limit guard%')
|
||||
AND date(l.created_at) = date('now')
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT 50
|
||||
`).all() as { created_at: string; message: string; email_account_id: string | null }[];
|
||||
|
||||
function effectiveLimit(a: typeof accounts[0], date: Date) {
|
||||
if (!a.ramp_up_enabled || !a.ramp_start_date) return a.daily_email_limit;
|
||||
const start = new Date(a.ramp_start_date);
|
||||
const daysActive = Math.floor((date.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1;
|
||||
return Math.min(a.daily_email_limit, daysActive * 2);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
// Build last 7 days list (UTC dates)
|
||||
const days: string[] = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = new Date(now);
|
||||
d.setUTCDate(d.getUTCDate() - i);
|
||||
days.push(d.toISOString().slice(0, 10));
|
||||
}
|
||||
|
||||
// Index sends: accountId -> day -> count
|
||||
const sendsIndex: Record<string, Record<string, number>> = {};
|
||||
for (const row of dailySends) {
|
||||
if (!sendsIndex[row.email_account_id]) sendsIndex[row.email_account_id] = {};
|
||||
sendsIndex[row.email_account_id][row.day] = row.sent;
|
||||
}
|
||||
|
||||
const today = now.toISOString().slice(0, 10);
|
||||
|
||||
const result = accounts.map(a => {
|
||||
const sentToday = sendsIndex[a.id]?.[today] ?? 0;
|
||||
const limit = effectiveLimit(a, now);
|
||||
return {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
from_email: a.from_email,
|
||||
daily_email_limit: a.daily_email_limit,
|
||||
ramp_up_enabled: a.ramp_up_enabled,
|
||||
ramp_start_date: a.ramp_start_date,
|
||||
effective_limit_today: limit,
|
||||
sent_today: sentToday,
|
||||
days: days.map(day => ({
|
||||
day,
|
||||
sent: sendsIndex[a.id]?.[day] ?? 0,
|
||||
limit: effectiveLimit(a, new Date(day + "T12:00:00Z")),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ accounts: result, days, recentLogs, guardTrips });
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
type Data = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export default function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<Data>,
|
||||
) {
|
||||
res.status(200).json({ name: "John Doe" });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { cancelImport } from "@/lib/import-jobs";
|
||||
|
||||
/** POST — cancel an import batch. A running batch stops at its next page boundary. */
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
return res.status(405).end();
|
||||
}
|
||||
const db = getDb();
|
||||
const id = req.query.id as string;
|
||||
|
||||
const job = db.prepare("SELECT id, status FROM list_imports WHERE id = ?").get(id) as
|
||||
| { id: string; status: string }
|
||||
| undefined;
|
||||
if (!job) return res.status(404).json({ error: "Import not found" });
|
||||
|
||||
cancelImport(db, id);
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { getDailyImportCap, importedToday } from "@/lib/import-jobs";
|
||||
|
||||
/** GET — all import jobs (active first), with list names, for the Jobs panel. */
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "GET") {
|
||||
res.setHeader("Allow", ["GET"]);
|
||||
return res.status(405).end();
|
||||
}
|
||||
const db = getDb();
|
||||
|
||||
const jobs = db.prepare(`
|
||||
SELECT li.id, li.list_id, l.name AS list_name, li.status, li.phase,
|
||||
li.page, li.total_pages, li.count, li.total, li.imported, li.skipped, li.error,
|
||||
li.scheduled_for, li.start_page, li.batch_index, li.started_at, li.finished_at
|
||||
FROM list_imports li
|
||||
LEFT JOIN lists l ON l.id = li.list_id
|
||||
WHERE li.status IN ('running', 'scheduled')
|
||||
OR li.finished_at >= datetime('now', '-2 days')
|
||||
ORDER BY
|
||||
CASE li.status WHEN 'running' THEN 0 WHEN 'scheduled' THEN 1 ELSE 2 END,
|
||||
li.scheduled_for ASC,
|
||||
li.started_at DESC
|
||||
`).all();
|
||||
|
||||
return res.json({
|
||||
jobs,
|
||||
dailyCap: getDailyImportCap(db),
|
||||
importedToday: importedToday(db),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { randomUUID } from "crypto";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Cancels a scheduled OOO follow-up: clears the email track's next_step_at +
|
||||
* pending_reply_context and stamps the contact's email_replied_at, so the runner
|
||||
* skips them on the next tick (treats the reply as bucket D retroactively).
|
||||
*/
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
return res.status(405).end();
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const replyId = req.query.replyId as string;
|
||||
if (!replyId) return res.status(400).json({ error: "replyId required" });
|
||||
|
||||
const reply = db
|
||||
.prepare("SELECT target_id, run_id FROM email_replies WHERE id = ?")
|
||||
.get(replyId) as { target_id: string; run_id: string | null } | undefined;
|
||||
if (!reply) return res.status(404).json({ error: "reply_not_found" });
|
||||
|
||||
if (reply.run_id) {
|
||||
db.prepare(
|
||||
`UPDATE run_profile_tracks SET state = 'skipped', next_step_at = NULL, pending_reply_context = NULL,
|
||||
error_message = 'Follow-up cancelled from inbox'
|
||||
WHERE track = 'email' AND state IN ('pending', 'in_progress')
|
||||
AND run_profile_id IN (
|
||||
SELECT id FROM run_profiles WHERE run_id = ? AND target_id = ?
|
||||
)`,
|
||||
).run(reply.run_id, reply.target_id);
|
||||
}
|
||||
|
||||
db.prepare("UPDATE targets SET email_replied_at = datetime('now') WHERE id = ?").run(reply.target_id);
|
||||
db.prepare(
|
||||
"INSERT INTO activity_logs (id, target_id, type, body) VALUES (?, ?, 'email', 'Scheduled follow-up cancelled from inbox.')",
|
||||
).run(randomUUID(), reply.target_id);
|
||||
|
||||
db.prepare(
|
||||
"UPDATE email_replies SET dispatch_result_json = ? WHERE id = ?",
|
||||
).run(JSON.stringify({ kind: "cancelled", notes: "Follow-up cancelled from inbox" }), replyId);
|
||||
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
export interface InboxReply {
|
||||
id: string;
|
||||
full_name: string | null;
|
||||
linkedin_url: string | null;
|
||||
email: string | null;
|
||||
headline: string | null;
|
||||
company: string | null;
|
||||
channel: "email" | "linkedin" | "both";
|
||||
replied_at: string;
|
||||
email_replied_at: string | null;
|
||||
last_replied_at: string | null;
|
||||
// run context
|
||||
run_id: string | null;
|
||||
workflow_id: string | null;
|
||||
workflow_name: string | null;
|
||||
// email account context
|
||||
email_account_id: string | null;
|
||||
email_account_name: string | null;
|
||||
email_account_from: string | null;
|
||||
// classifier / dispatcher context (from the most recent email_replies row)
|
||||
reply_id: string | null;
|
||||
reply_kind: string | null;
|
||||
reply_summary: string | null;
|
||||
reply_body: string | null;
|
||||
classified_at: string | null;
|
||||
classification_error: string | null;
|
||||
dispatched_at: string | null;
|
||||
dispatch_result_json: string | null;
|
||||
manually_edited: number;
|
||||
}
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "GET") {
|
||||
res.setHeader("Allow", ["GET"]);
|
||||
return res.status(405).end();
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const channel = req.query.channel as string | undefined; // "email" | "linkedin" | undefined
|
||||
|
||||
// A target shows in the inbox if it has a reply stamp OR a captured email_replies row.
|
||||
// OOO-followup replies intentionally leave email_replied_at NULL, so we must include
|
||||
// the email_replies source to keep scheduled follow-ups visible.
|
||||
let channelFilter =
|
||||
"AND (t.email_replied_at IS NOT NULL OR t.last_replied_at IS NOT NULL OR er.id IS NOT NULL)";
|
||||
if (channel === "email") channelFilter = "AND (t.email_replied_at IS NOT NULL OR er.id IS NOT NULL)";
|
||||
if (channel === "linkedin") channelFilter = "AND t.last_replied_at IS NOT NULL";
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
t.id,
|
||||
t.full_name,
|
||||
t.linkedin_url,
|
||||
t.email,
|
||||
t.headline,
|
||||
t.company,
|
||||
t.email_replied_at,
|
||||
t.last_replied_at,
|
||||
CASE
|
||||
WHEN (t.email_replied_at IS NOT NULL OR er.id IS NOT NULL) AND t.last_replied_at IS NOT NULL THEN 'both'
|
||||
WHEN t.email_replied_at IS NOT NULL OR er.id IS NOT NULL THEN 'email'
|
||||
ELSE 'linkedin'
|
||||
END AS channel,
|
||||
MAX(
|
||||
COALESCE(t.email_replied_at, ''),
|
||||
COALESCE(t.last_replied_at, ''),
|
||||
COALESCE(er.received_at, '')
|
||||
) AS replied_at,
|
||||
r.id AS run_id,
|
||||
r.workflow_id,
|
||||
w.name AS workflow_name,
|
||||
ea.id AS email_account_id,
|
||||
ea.name AS email_account_name,
|
||||
ea.from_email AS email_account_from,
|
||||
er.id AS reply_id,
|
||||
er.classification_json AS classification_json,
|
||||
er.body_text AS reply_body,
|
||||
er.classified_at,
|
||||
er.classification_error,
|
||||
er.dispatched_at,
|
||||
er.dispatch_result_json,
|
||||
COALESCE(er.manually_edited, 0) AS manually_edited
|
||||
FROM targets t
|
||||
LEFT JOIN run_profiles rp ON rp.target_id = t.id
|
||||
LEFT JOIN runs r ON r.id = rp.run_id AND r.status IN ('running', 'paused', 'completed')
|
||||
LEFT JOIN workflows w ON w.id = r.workflow_id
|
||||
LEFT JOIN email_accounts ea ON ea.id = rp.email_account_id
|
||||
LEFT JOIN (
|
||||
SELECT er1.* FROM email_replies er1
|
||||
WHERE er1.received_at = (
|
||||
SELECT MAX(er2.received_at) FROM email_replies er2 WHERE er2.target_id = er1.target_id
|
||||
)
|
||||
) er ON er.target_id = t.id
|
||||
WHERE 1=1
|
||||
${channelFilter}
|
||||
GROUP BY t.id
|
||||
ORDER BY replied_at DESC
|
||||
`).all() as Array<InboxReply & { classification_json: string | null }>;
|
||||
|
||||
const replies: InboxReply[] = rows.map((row) => {
|
||||
let reply_kind: string | null = null;
|
||||
let reply_summary: string | null = null;
|
||||
if (row.classification_json) {
|
||||
try {
|
||||
const cls = JSON.parse(row.classification_json) as { kind?: string; summary?: string };
|
||||
reply_kind = cls.kind ?? null;
|
||||
reply_summary = cls.summary ?? null;
|
||||
} catch { /* malformed — leave null */ }
|
||||
}
|
||||
const { classification_json: _omit, ...rest } = row;
|
||||
void _omit;
|
||||
return { ...rest, reply_kind, reply_summary };
|
||||
});
|
||||
|
||||
return res.json({ replies });
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { sendEmail } from "@/lib/email/sender";
|
||||
import { decryptSecret } from "@/lib/crypto";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
return res.status(405).end();
|
||||
}
|
||||
|
||||
const { emailAccountId, to, subject, body } = req.body as {
|
||||
emailAccountId?: string;
|
||||
to?: string;
|
||||
subject?: string;
|
||||
body?: string;
|
||||
};
|
||||
|
||||
if (!emailAccountId || !to || !subject || !body) {
|
||||
return res.status(400).json({ error: "emailAccountId, to, subject, and body are required" });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const account = db.prepare(
|
||||
"SELECT id, from_email, from_name, reply_to, smtp_host, smtp_port, smtp_secure, username, password FROM email_accounts WHERE id = ?"
|
||||
).get(emailAccountId) as {
|
||||
id: string; from_email: string; from_name: string | null; reply_to: string | null;
|
||||
smtp_host: string; smtp_port: number; smtp_secure: number; username: string; password: string;
|
||||
} | undefined;
|
||||
|
||||
if (!account) return res.status(404).json({ error: "Email account not found" });
|
||||
|
||||
try {
|
||||
await sendEmail({ ...account, password: decryptSecret(account.password)! }, to, subject, body);
|
||||
return res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error("[inbox/reply] send failed:", err);
|
||||
return res.status(500).json({ error: err instanceof Error ? err.message : "Send failed" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import Imap from "imap";
|
||||
import { simpleParser } from "mailparser";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { decryptSecret } from "@/lib/crypto";
|
||||
|
||||
export interface EmailMessage {
|
||||
uid: number;
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
date: string;
|
||||
text: string;
|
||||
html: string | null;
|
||||
messageId: string | null;
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "GET") {
|
||||
res.setHeader("Allow", ["GET"]);
|
||||
return res.status(405).end();
|
||||
}
|
||||
|
||||
const { targetId, emailAccountId } = req.query as { targetId?: string; emailAccountId?: string };
|
||||
if (!targetId || !emailAccountId) return res.status(400).json({ error: "targetId and emailAccountId required" });
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const target = db.prepare("SELECT email FROM targets WHERE id = ?").get(targetId) as { email: string | null } | undefined;
|
||||
if (!target?.email) return res.status(404).json({ error: "Target has no email" });
|
||||
|
||||
const account = db.prepare(
|
||||
"SELECT imap_host, imap_port, username, password, imap_username, imap_password FROM email_accounts WHERE id = ?"
|
||||
).get(emailAccountId) as {
|
||||
imap_host: string | null; imap_port: number | null;
|
||||
username: string; password: string;
|
||||
imap_username: string | null; imap_password: string | null;
|
||||
} | undefined;
|
||||
|
||||
if (!account?.imap_host) return res.status(404).json({ error: "Email account not found or missing IMAP config" });
|
||||
|
||||
const imapUser = account.imap_username ?? account.username;
|
||||
const imapPass = decryptSecret(account.imap_password) ?? decryptSecret(account.password)!;
|
||||
const targetEmail = target.email.toLowerCase();
|
||||
|
||||
try {
|
||||
const messages = await fetchThread(
|
||||
{ host: account.imap_host, port: account.imap_port ?? 993, user: imapUser, password: imapPass },
|
||||
targetEmail
|
||||
);
|
||||
return res.json({ messages });
|
||||
} catch (err) {
|
||||
console.error("[inbox/thread] IMAP error:", err);
|
||||
return res.status(500).json({ error: "IMAP fetch failed" });
|
||||
}
|
||||
}
|
||||
|
||||
interface ImapConfig { host: string; port: number; user: string; password: string; }
|
||||
|
||||
async function fetchThread(cfg: ImapConfig, contactEmail: string): Promise<EmailMessage[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const imap = new Imap({
|
||||
host: cfg.host,
|
||||
port: cfg.port,
|
||||
tls: true,
|
||||
tlsOptions: { rejectUnauthorized: false },
|
||||
user: cfg.user,
|
||||
password: cfg.password,
|
||||
authTimeout: 10_000,
|
||||
connTimeout: 12_000,
|
||||
});
|
||||
|
||||
const done = (err?: Error) => {
|
||||
try { imap.end(); } catch { /* ignore */ }
|
||||
if (err) reject(err);
|
||||
};
|
||||
|
||||
const messages: EmailMessage[] = [];
|
||||
|
||||
imap.once("error", (err: Error) => done(err));
|
||||
imap.once("ready", () => {
|
||||
imap.openBox("INBOX", true, (boxErr) => {
|
||||
if (boxErr) { done(boxErr); return; }
|
||||
|
||||
// Search for messages FROM or TO the contact
|
||||
imap.search([["OR", ["FROM", contactEmail], ["TO", contactEmail]]], (searchErr, uids) => {
|
||||
if (searchErr) { done(searchErr); return; }
|
||||
if (uids.length === 0) { resolve([]); done(); return; }
|
||||
|
||||
// Fetch the last 20 matching messages max
|
||||
const toFetch = uids.slice(-20);
|
||||
const fetch = imap.fetch(toFetch, { bodies: "", struct: false });
|
||||
const pending: Promise<void>[] = [];
|
||||
|
||||
fetch.on("message", (msg) => {
|
||||
let uid = 0;
|
||||
msg.on("attributes", (attrs) => { uid = attrs.uid; });
|
||||
|
||||
const p = new Promise<void>((res2) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let bodyEnded = false;
|
||||
|
||||
msg.on("body", (stream) => {
|
||||
stream.on("data", (c: Buffer) => chunks.push(c));
|
||||
stream.once("end", async () => {
|
||||
bodyEnded = true;
|
||||
try {
|
||||
const parsed = await simpleParser(Buffer.concat(chunks));
|
||||
messages.push({
|
||||
uid,
|
||||
from: parsed.from?.text ?? "",
|
||||
to: parsed.to ? (Array.isArray(parsed.to) ? parsed.to.map(a => a.text).join(", ") : parsed.to.text) : "",
|
||||
subject: parsed.subject ?? "(no subject)",
|
||||
date: parsed.date?.toISOString() ?? new Date().toISOString(),
|
||||
text: parsed.text ?? "",
|
||||
html: parsed.html || null,
|
||||
messageId: parsed.messageId ?? null,
|
||||
});
|
||||
} catch { /* skip malformed */ }
|
||||
res2();
|
||||
});
|
||||
});
|
||||
|
||||
// Only use msg.end as fallback if no body was received
|
||||
msg.once("end", () => { if (!bodyEnded) res2(); });
|
||||
});
|
||||
pending.push(p);
|
||||
});
|
||||
|
||||
fetch.once("error", (fetchErr: Error) => done(fetchErr));
|
||||
fetch.once("end", async () => {
|
||||
await Promise.allSettled(pending);
|
||||
messages.sort((a, b) => a.date.localeCompare(b.date));
|
||||
resolve(messages);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
imap.connect();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { encryptSecret, decryptSecret } from "@/lib/crypto";
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const db = getDb();
|
||||
|
||||
if (req.method === "GET") {
|
||||
const rows = db.prepare("SELECT key, api_key, updated_at FROM integrations").all() as {
|
||||
key: string;
|
||||
api_key: string | null;
|
||||
updated_at: string;
|
||||
}[];
|
||||
const masked = rows.map((r) => {
|
||||
const plain = decryptSecret(r.api_key);
|
||||
return {
|
||||
key: r.key,
|
||||
updated_at: r.updated_at,
|
||||
api_key_masked: plain ? "••••••••" + plain.slice(-4) : null,
|
||||
configured: !!plain,
|
||||
};
|
||||
});
|
||||
return res.json(masked);
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
const { key, api_key } = req.body;
|
||||
if (!key) return res.status(400).json({ error: "key required" });
|
||||
if (!api_key) return res.status(400).json({ error: "api_key required" });
|
||||
db.prepare(`
|
||||
INSERT INTO integrations (key, api_key, updated_at)
|
||||
VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(key) DO UPDATE SET api_key = excluded.api_key, updated_at = excluded.updated_at
|
||||
`).run(key, encryptSecret(api_key));
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
|
||||
if (req.method === "DELETE") {
|
||||
const { key } = req.query;
|
||||
if (!key) return res.status(400).json({ error: "key required" });
|
||||
db.prepare("DELETE FROM integrations WHERE key = ?").run(key);
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
|
||||
res.setHeader("Allow", ["GET", "POST", "DELETE"]);
|
||||
res.status(405).end();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
// Add EXISTING contacts to a list by id (membership only — does not create
|
||||
// contacts). Idempotent: already-member ids are skipped. This is the inverse of
|
||||
// remove-members and the way to UNDO a removal (feed back removed_contact_ids).
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") return res.status(405).end();
|
||||
|
||||
const db = getDb();
|
||||
const list_id = req.query.id as string;
|
||||
|
||||
const list = db.prepare("SELECT id FROM lists WHERE id = ?").get(list_id);
|
||||
if (!list) return res.status(404).json({ error: "List not found" });
|
||||
|
||||
const { contact_ids } = req.body as { contact_ids?: string[] };
|
||||
if (!Array.isArray(contact_ids) || contact_ids.length === 0) {
|
||||
return res.status(400).json({ error: "contact_ids[] is required." });
|
||||
}
|
||||
|
||||
// Only add ids that actually exist as contacts (ignore unknown ids rather
|
||||
// than failing the whole batch).
|
||||
const placeholders = contact_ids.map(() => "?").join(", ");
|
||||
const existing = db
|
||||
.prepare(`SELECT id, full_name, title FROM targets WHERE id IN (${placeholders})`)
|
||||
.all(...contact_ids) as Array<{ id: string; full_name: string | null; title: string | null }>;
|
||||
const existingIds = new Set(existing.map((r) => r.id));
|
||||
const unknown = contact_ids.filter((id) => !existingIds.has(id));
|
||||
|
||||
const insert = db.prepare("INSERT OR IGNORE INTO list_targets (list_id, target_id) VALUES (?, ?)");
|
||||
let added = 0;
|
||||
const addTx = db.transaction((ids: string[]) => {
|
||||
for (const id of ids) {
|
||||
const info = insert.run(list_id, id);
|
||||
if (info.changes > 0) added++;
|
||||
}
|
||||
});
|
||||
addTx([...existingIds]);
|
||||
|
||||
const total = (db.prepare("SELECT COUNT(*) as c FROM list_targets WHERE list_id = ?").get(list_id) as { c: number }).c;
|
||||
|
||||
return res.json({
|
||||
added,
|
||||
already_members: existingIds.size - added,
|
||||
unknown_contact_ids: unknown,
|
||||
list_total: total,
|
||||
contacts_added: existing,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
// Title + location breakdown for a list — used to spot irrelevant contacts before cleaning.
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "GET") return res.status(405).end();
|
||||
|
||||
const db = getDb();
|
||||
const list_id = req.query.id as string;
|
||||
|
||||
const list = db.prepare("SELECT id, name FROM lists WHERE id = ?").get(list_id);
|
||||
if (!list) return res.status(404).json({ error: "List not found" });
|
||||
|
||||
const total = (db.prepare("SELECT COUNT(*) as c FROM list_targets WHERE list_id = ?").get(list_id) as { c: number }).c;
|
||||
|
||||
const titles = db.prepare(`
|
||||
SELECT t.title, COUNT(*) as count
|
||||
FROM list_targets lt
|
||||
JOIN targets t ON t.id = lt.target_id
|
||||
WHERE lt.list_id = ? AND t.title IS NOT NULL
|
||||
GROUP BY t.title
|
||||
ORDER BY count DESC
|
||||
`).all(list_id);
|
||||
|
||||
const locations = db.prepare(`
|
||||
SELECT t.location, COUNT(*) as count
|
||||
FROM list_targets lt
|
||||
JOIN targets t ON t.id = lt.target_id
|
||||
WHERE lt.list_id = ? AND t.location IS NOT NULL
|
||||
GROUP BY t.location
|
||||
ORDER BY count DESC
|
||||
`).all(list_id);
|
||||
|
||||
return res.json({ list, total, titles, locations });
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { matchPerson } from "@/lib/apollo";
|
||||
import { randomUUID } from "crypto";
|
||||
import { decryptSecret } from "@/lib/crypto";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
return res.status(405).end();
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const listId = req.query.id as string;
|
||||
|
||||
const list = db.prepare("SELECT id FROM lists WHERE id = ?").get(listId);
|
||||
if (!list) return res.status(404).json({ error: "List not found" });
|
||||
|
||||
const integration = db.prepare("SELECT api_key FROM integrations WHERE key = 'apollo'").get() as
|
||||
| { api_key: string }
|
||||
| undefined;
|
||||
if (!integration?.api_key) {
|
||||
return res.status(400).json({ error: "Apollo integration not configured" });
|
||||
}
|
||||
const apiKey = decryptSecret(integration.api_key)!;
|
||||
|
||||
// Optional: target_ids in body to enrich specific contacts only
|
||||
const { target_ids } = req.body as { target_ids?: string[] };
|
||||
|
||||
let targets: { id: string; linkedin_url: string | null; sales_nav_url: string | null }[];
|
||||
if (target_ids && target_ids.length > 0) {
|
||||
const placeholders = target_ids.map(() => "?").join(",");
|
||||
targets = db.prepare(`
|
||||
SELECT t.id, t.linkedin_url, t.sales_nav_url
|
||||
FROM targets t
|
||||
INNER JOIN list_targets lt ON lt.target_id = t.id
|
||||
WHERE lt.list_id = ? AND t.id IN (${placeholders}) AND t.email IS NULL
|
||||
AND (t.linkedin_url IS NOT NULL OR t.sales_nav_url IS NOT NULL)
|
||||
`).all(listId, ...target_ids) as { id: string; linkedin_url: string | null; sales_nav_url: string | null }[];
|
||||
} else {
|
||||
targets = db.prepare(`
|
||||
SELECT t.id, t.linkedin_url, t.sales_nav_url
|
||||
FROM targets t
|
||||
INNER JOIN list_targets lt ON lt.target_id = t.id
|
||||
WHERE lt.list_id = ? AND t.apollo_enriched_at IS NULL AND t.email IS NULL
|
||||
AND (t.linkedin_url IS NOT NULL OR t.sales_nav_url IS NOT NULL)
|
||||
`).all(listId) as { id: string; linkedin_url: string | null; sales_nav_url: string | null }[];
|
||||
}
|
||||
|
||||
let enriched = 0;
|
||||
let notFound = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const target of targets) {
|
||||
// Prefer real /in/ URL, fall back to sales_nav_url (Apollo accepts both)
|
||||
const urlToUse = (target.linkedin_url && !target.linkedin_url.includes("/sales/lead/"))
|
||||
? target.linkedin_url
|
||||
: target.sales_nav_url;
|
||||
if (!urlToUse) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await matchPerson(urlToUse, apiKey);
|
||||
|
||||
if (!result) {
|
||||
notFound++;
|
||||
// Mark as attempted so we don't retry endlessly
|
||||
db.prepare("UPDATE targets SET apollo_enriched_at = datetime('now') WHERE id = ?").run(target.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Company association via domain
|
||||
let companyId: string | null = null;
|
||||
if (result.organization?.domain) {
|
||||
const domain = result.organization.domain.replace(/^www\./, "").toLowerCase();
|
||||
const existing = db.prepare("SELECT id FROM companies WHERE domain = ?").get(domain) as
|
||||
| { id: string }
|
||||
| undefined;
|
||||
const org = result.organization;
|
||||
if (existing) {
|
||||
companyId = existing.id;
|
||||
// Update company with richer data if we have it
|
||||
db.prepare(`
|
||||
UPDATE companies SET
|
||||
industry = COALESCE(industry, ?),
|
||||
location = COALESCE(location, ?),
|
||||
linkedin_url = COALESCE(linkedin_url, ?),
|
||||
website = COALESCE(website, ?),
|
||||
founded_year = COALESCE(founded_year, ?),
|
||||
logo_url = COALESCE(logo_url, ?),
|
||||
phone = COALESCE(phone, ?),
|
||||
annual_revenue = COALESCE(annual_revenue, ?),
|
||||
technology_names = COALESCE(technology_names, ?),
|
||||
keywords = COALESCE(keywords, ?),
|
||||
city = COALESCE(city, ?),
|
||||
country = COALESCE(country, ?),
|
||||
description = COALESCE(description, ?),
|
||||
employee_count = COALESCE(employee_count, ?)
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
org.industry ?? null,
|
||||
org.location ?? null,
|
||||
org.linkedin_url ?? null,
|
||||
org.website_url ?? null,
|
||||
org.founded_year ?? null,
|
||||
org.logo_url ?? null,
|
||||
org.phone ?? null,
|
||||
org.annual_revenue_printed ?? null,
|
||||
org.technology_names ? JSON.stringify(org.technology_names) : null,
|
||||
org.keywords ? JSON.stringify(org.keywords) : null,
|
||||
org.city ?? null,
|
||||
org.country ?? null,
|
||||
org.short_description ?? null,
|
||||
org.estimated_num_employees ?? null,
|
||||
existing.id
|
||||
);
|
||||
} else {
|
||||
companyId = randomUUID();
|
||||
db.prepare(`
|
||||
INSERT INTO companies (id, name, domain, industry, location, linkedin_url, website, founded_year, logo_url, phone, annual_revenue, technology_names, keywords, city, country, description, employee_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
companyId,
|
||||
org.name ?? "",
|
||||
domain,
|
||||
org.industry ?? null,
|
||||
org.location ?? null,
|
||||
org.linkedin_url ?? null,
|
||||
org.website_url ?? null,
|
||||
org.founded_year ?? null,
|
||||
org.logo_url ?? null,
|
||||
org.phone ?? null,
|
||||
org.annual_revenue_printed ?? null,
|
||||
org.technology_names ? JSON.stringify(org.technology_names) : null,
|
||||
org.keywords ? JSON.stringify(org.keywords) : null,
|
||||
org.city ?? null,
|
||||
org.country ?? null,
|
||||
org.short_description ?? null,
|
||||
org.estimated_num_employees ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Apollo returns a real /in/ URL — always write it, even if target already has a Sales Nav URL
|
||||
const apolloLinkedinUrl = result.linkedin_url?.includes("/in/") ? result.linkedin_url : null;
|
||||
|
||||
db.prepare(`
|
||||
UPDATE targets SET
|
||||
apollo_id = ?,
|
||||
seniority = ?,
|
||||
apollo_functions = ?,
|
||||
apollo_departments = ?,
|
||||
email = COALESCE(email, ?),
|
||||
email_status = COALESCE(email_status, ?),
|
||||
email_domain_catchall = ?,
|
||||
city = COALESCE(city, ?),
|
||||
country = COALESCE(country, ?),
|
||||
time_zone = COALESCE(time_zone, ?),
|
||||
headline = COALESCE(headline, ?),
|
||||
positions_json = COALESCE(positions_json, ?),
|
||||
company_id = COALESCE(company_id, ?),
|
||||
linkedin_url = COALESCE(?, linkedin_url),
|
||||
company_size = COALESCE(company_size, ?),
|
||||
apollo_enriched_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
result.apollo_id,
|
||||
result.seniority ?? null,
|
||||
result.functions ? JSON.stringify(result.functions) : null,
|
||||
result.departments ? JSON.stringify(result.departments) : null,
|
||||
result.email ?? null,
|
||||
result.email_status ?? null,
|
||||
result.email_domain_catchall ? 1 : 0,
|
||||
result.city ?? null,
|
||||
result.country ?? null,
|
||||
result.time_zone ?? null,
|
||||
result.headline ?? null,
|
||||
result.positions_json ?? null,
|
||||
companyId,
|
||||
apolloLinkedinUrl,
|
||||
result.organization?.estimated_num_employees ?? null,
|
||||
target.id
|
||||
);
|
||||
|
||||
enriched++;
|
||||
} catch {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ enriched, notFound, skipped, total: targets.length });
|
||||
}
|
||||
|
||||
export const config = {
|
||||
api: { responseLimit: false },
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
// GET /api/lists/[id]/conflicts
|
||||
// Returns how many prospects in this list are already active in a running/paused workflow
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "GET") {
|
||||
res.setHeader("Allow", ["GET"]);
|
||||
return res.status(405).end();
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const listId = req.query.id as string;
|
||||
|
||||
const total = (db.prepare(
|
||||
"SELECT COUNT(*) as c FROM list_targets WHERE list_id = ?"
|
||||
).get(listId) as { c: number }).c;
|
||||
|
||||
const blocked = (db.prepare(
|
||||
`SELECT COUNT(DISTINCT lt.target_id) as blocked
|
||||
FROM list_targets lt
|
||||
WHERE lt.list_id = ?
|
||||
AND lt.target_id IN (
|
||||
SELECT rp.target_id FROM run_profiles rp
|
||||
JOIN runs r ON r.id = rp.run_id
|
||||
WHERE r.status IN ('running', 'paused')
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM run_profile_tracks rt
|
||||
WHERE rt.run_profile_id = rp.id AND rt.state NOT IN ('completed', 'failed', 'skipped')
|
||||
)
|
||||
)`
|
||||
).get(listId) as { blocked: number }).blocked;
|
||||
|
||||
return res.json({ total, blocked });
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { buildCsvTemplate } from "@/lib/csv-import";
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "GET") {
|
||||
res.setHeader("Allow", ["GET"]);
|
||||
return res.status(405).end();
|
||||
}
|
||||
|
||||
const csv = buildCsvTemplate();
|
||||
res.setHeader("Content-Type", "text/csv");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="linki-import-template.csv"`);
|
||||
res.status(200).send(csv);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
return res.status(405).end();
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const listId = req.query.id as string;
|
||||
|
||||
const list = db.prepare("SELECT * FROM lists WHERE id = ?").get(listId);
|
||||
if (!list) return res.status(404).json({ error: "List not found" });
|
||||
|
||||
const { account_id } = req.body;
|
||||
if (!account_id) return res.status(400).json({ error: "account_id required" });
|
||||
|
||||
const account = db.prepare("SELECT * FROM accounts WHERE id = ?").get(account_id) as
|
||||
| { cookies_json: string | null; is_authenticated: number }
|
||||
| undefined;
|
||||
if (!account) return res.status(400).json({ error: "Account not found" });
|
||||
if (!account.is_authenticated || !account.cookies_json) {
|
||||
return res.status(400).json({ error: "Account not authenticated" });
|
||||
}
|
||||
|
||||
const pending = db.prepare(`
|
||||
SELECT COUNT(*) as c FROM targets t
|
||||
JOIN list_targets lt ON lt.target_id = t.id
|
||||
WHERE lt.list_id = ? AND t.sales_nav_url IS NOT NULL AND t.enriched_profile_at IS NULL
|
||||
`).get(listId) as { c: number };
|
||||
|
||||
// Respond immediately — enrichment runs in background
|
||||
res.json({ started: true, profiles: pending.c });
|
||||
|
||||
// Fire and forget — do not await
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
const { getSessionContext } = await import("@/lib/linkedin/session");
|
||||
const { enrichList } = await import("@/lib/linkedin/enrich");
|
||||
const ctx = await getSessionContext(account_id);
|
||||
await enrichList(ctx, listId);
|
||||
} catch (err) {
|
||||
console.error("[enrich] background enrichment failed:", err instanceof Error ? err.message : err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const config = {
|
||||
api: { responseLimit: false },
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { importCsv } from "@/lib/csv-import";
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
return res.status(405).end();
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const listId = req.query.id as string;
|
||||
|
||||
const list = db.prepare("SELECT id FROM lists WHERE id = ?").get(listId) as { id: string } | undefined;
|
||||
if (!list) return res.status(404).json({ error: "List not found" });
|
||||
|
||||
const { csv } = req.body as { csv?: string };
|
||||
if (!csv || typeof csv !== "string" || !csv.trim()) {
|
||||
return res.status(400).json({ error: "csv content is required" });
|
||||
}
|
||||
|
||||
const result = importCsv(db, listId, csv);
|
||||
res.json(result);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { getDailyImportCap } from "@/lib/import-jobs";
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "GET") {
|
||||
res.setHeader("Allow", ["GET"]);
|
||||
return res.status(405).end();
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const listId = req.query.id as string;
|
||||
const dailyCap = getDailyImportCap(db);
|
||||
|
||||
// All batches for this list, newest first
|
||||
const batches = db.prepare(`
|
||||
SELECT id, status, phase, page, total_pages, count, total, imported, skipped, error,
|
||||
scheduled_for, start_page, batch_index, started_at, finished_at
|
||||
FROM list_imports
|
||||
WHERE list_id = ?
|
||||
ORDER BY batch_index ASC, started_at DESC
|
||||
`).all(listId) as Array<{
|
||||
id: string; status: string; phase: string | null; page: number; total_pages: number;
|
||||
count: number; total: number; imported: number; skipped: number; error: string | null;
|
||||
scheduled_for: string | null; start_page: number; batch_index: number;
|
||||
started_at: string; finished_at: string | null;
|
||||
}>;
|
||||
|
||||
if (batches.length === 0) return res.json({ status: "idle", dailyCap });
|
||||
|
||||
// The "current" batch = running > scheduled > most recent — drives the old single-job UI
|
||||
const current =
|
||||
batches.find((b) => b.status === "running") ??
|
||||
batches.find((b) => b.status === "scheduled") ??
|
||||
batches[batches.length - 1];
|
||||
|
||||
const total = batches.reduce((m, b) => Math.max(m, b.total || 0), 0);
|
||||
const importedSoFar = batches.reduce((s, b) => s + (b.imported || 0), 0);
|
||||
const remaining = total > 0 ? Math.max(0, total - importedSoFar) : 0;
|
||||
|
||||
return res.json({
|
||||
...current,
|
||||
dailyCap,
|
||||
batches,
|
||||
// Plan summary: is this list being split across days?
|
||||
plan: { total, importedSoFar, remaining, exceedsCap: total > dailyCap, dailyCap },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { startImport, getDailyImportCap } from "@/lib/import-jobs";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
return res.status(405).end();
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const listId = req.query.id as string;
|
||||
|
||||
const list = db.prepare("SELECT * FROM lists WHERE id = ?").get(listId);
|
||||
if (!list) return res.status(404).json({ error: "List not found" });
|
||||
|
||||
const { sales_nav_url, account_id, enrich } = req.body as {
|
||||
sales_nav_url?: string;
|
||||
account_id?: string;
|
||||
enrich?: boolean;
|
||||
};
|
||||
if (!sales_nav_url) return res.status(400).json({ error: "sales_nav_url required" });
|
||||
if (!account_id) return res.status(400).json({ error: "account_id required" });
|
||||
|
||||
const account = db.prepare("SELECT * FROM accounts WHERE id = ?").get(account_id) as
|
||||
| { cookies_json: string | null; is_authenticated: number }
|
||||
| undefined;
|
||||
if (!account) return res.status(400).json({ error: "Account not found" });
|
||||
if (!account.is_authenticated || !account.cookies_json) {
|
||||
return res.status(400).json({ error: "Account not authenticated. Please authenticate first." });
|
||||
}
|
||||
|
||||
// Already an active import for this list?
|
||||
const existing = db
|
||||
.prepare("SELECT id FROM list_imports WHERE list_id = ? AND status IN ('running','scheduled')")
|
||||
.get(listId);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: "An import is already queued or running for this list" });
|
||||
}
|
||||
|
||||
// Save the Sales Nav URL on the list for reference
|
||||
db.prepare("UPDATE lists SET sales_nav_url = ? WHERE id = ?").run(sales_nav_url, listId);
|
||||
|
||||
// Queue the first batch — the runner scheduler picks it up (one import at a time)
|
||||
const { importId } = startImport(db, { listId, accountId: account_id, salesNavUrl: sales_nav_url, enrich });
|
||||
|
||||
res.json({ started: true, importId, dailyCap: getDailyImportCap(db) });
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const db = getDb();
|
||||
const id = req.query.id as string;
|
||||
|
||||
if (req.method === "GET") {
|
||||
const list = db.prepare("SELECT * FROM lists WHERE id = ?").get(id);
|
||||
if (!list) return res.status(404).json({ error: "Not found" });
|
||||
const targets = db
|
||||
.prepare(
|
||||
`SELECT t.* FROM targets t
|
||||
JOIN list_targets lt ON lt.target_id = t.id
|
||||
WHERE lt.list_id = ?
|
||||
ORDER BY t.created_at DESC`
|
||||
)
|
||||
.all(id);
|
||||
return res.json({ ...list, targets });
|
||||
}
|
||||
|
||||
if (req.method === "PUT") {
|
||||
const { name, description } = req.body;
|
||||
db.prepare(
|
||||
"UPDATE lists SET name = COALESCE(?, name), description = COALESCE(?, description) WHERE id = ?"
|
||||
).run(name, description, id);
|
||||
return res.json(db.prepare("SELECT * FROM lists WHERE id = ?").get(id));
|
||||
}
|
||||
|
||||
if (req.method === "DELETE") {
|
||||
db.prepare("DELETE FROM runs WHERE list_id = ?").run(id);
|
||||
db.prepare("DELETE FROM lists WHERE id = ?").run(id);
|
||||
return res.status(204).end();
|
||||
}
|
||||
|
||||
res.setHeader("Allow", ["GET", "PUT", "DELETE"]);
|
||||
res.status(405).end();
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
// POST /api/lists/[id]/move-targets
|
||||
// body: { target_ids: string[], destination_list_id: string }
|
||||
// Moves targets from source list to destination list (removes from source, adds to destination)
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
return res.status(405).end();
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const sourceListId = req.query.id as string;
|
||||
const { target_ids, destination_list_id } = req.body as {
|
||||
target_ids: string[];
|
||||
destination_list_id: string;
|
||||
};
|
||||
|
||||
if (!Array.isArray(target_ids) || target_ids.length === 0)
|
||||
return res.status(400).json({ error: "target_ids must be a non-empty array" });
|
||||
if (!destination_list_id)
|
||||
return res.status(400).json({ error: "destination_list_id required" });
|
||||
if (destination_list_id === sourceListId)
|
||||
return res.status(400).json({ error: "Source and destination list are the same" });
|
||||
|
||||
const dest = db.prepare("SELECT id FROM lists WHERE id = ?").get(destination_list_id);
|
||||
if (!dest) return res.status(404).json({ error: "Destination list not found" });
|
||||
|
||||
const placeholders = target_ids.map(() => "?").join(",");
|
||||
|
||||
db.transaction(() => {
|
||||
db.prepare(
|
||||
`DELETE FROM list_targets WHERE list_id = ? AND target_id IN (${placeholders})`
|
||||
).run(sourceListId, ...target_ids);
|
||||
|
||||
for (const tid of target_ids) {
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO list_targets (list_id, target_id) VALUES (?, ?)`
|
||||
).run(destination_list_id, tid);
|
||||
}
|
||||
})();
|
||||
|
||||
return res.json({ moved: target_ids.length });
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
// Remove contacts from a list (membership only — never deletes the contact). Filters are OR'd:
|
||||
// titles (exact), title_patterns (LIKE %p%), exclude_location_substrings (LIKE %l%). dry_run previews.
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") return res.status(405).end();
|
||||
|
||||
const db = getDb();
|
||||
const list_id = req.query.id as string;
|
||||
|
||||
const list = db.prepare("SELECT id FROM lists WHERE id = ?").get(list_id);
|
||||
if (!list) return res.status(404).json({ error: "List not found" });
|
||||
|
||||
const { contact_ids, titles, title_patterns, exclude_location_substrings, dry_run = true } = req.body as {
|
||||
contact_ids?: string[];
|
||||
titles?: string[];
|
||||
title_patterns?: string[];
|
||||
exclude_location_substrings?: string[];
|
||||
dry_run?: boolean;
|
||||
};
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [list_id];
|
||||
|
||||
// Precise, ID-scoped removal — safest path (no title collisions). When
|
||||
// contact_ids is given it is OR'd in like any other filter, so a curated set
|
||||
// (built manually or by an LLM pass) removes exactly those contacts.
|
||||
if (contact_ids && contact_ids.length > 0) {
|
||||
const placeholders = contact_ids.map(() => "?").join(", ");
|
||||
conditions.push(`t.id IN (${placeholders})`);
|
||||
params.push(...contact_ids);
|
||||
}
|
||||
if (titles && titles.length > 0) {
|
||||
const placeholders = titles.map(() => "?").join(", ");
|
||||
conditions.push(`t.title IN (${placeholders})`);
|
||||
params.push(...titles);
|
||||
}
|
||||
if (title_patterns && title_patterns.length > 0) {
|
||||
const patternClauses = title_patterns.map(() => "t.title LIKE ?").join(" OR ");
|
||||
conditions.push(`(${patternClauses})`);
|
||||
for (const p of title_patterns) params.push(`%${p}%`);
|
||||
}
|
||||
if (exclude_location_substrings && exclude_location_substrings.length > 0) {
|
||||
const locClauses = exclude_location_substrings.map(() => "t.location LIKE ?").join(" OR ");
|
||||
conditions.push(`(${locClauses})`);
|
||||
for (const l of exclude_location_substrings) params.push(`%${l}%`);
|
||||
}
|
||||
|
||||
if (conditions.length === 0) {
|
||||
return res.status(400).json({ error: "No filters provided — nothing to remove." });
|
||||
}
|
||||
|
||||
const whereFilter = conditions.join(" OR ");
|
||||
|
||||
const preview = db.prepare(`
|
||||
SELECT t.id, t.full_name, t.title, t.location
|
||||
FROM list_targets lt
|
||||
JOIN targets t ON t.id = lt.target_id
|
||||
WHERE lt.list_id = ? AND (${whereFilter})
|
||||
ORDER BY t.title
|
||||
`).all(...params);
|
||||
|
||||
if (dry_run) {
|
||||
return res.json({ dry_run: true, would_remove: preview.length, contacts: preview });
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
DELETE FROM list_targets
|
||||
WHERE list_id = ?
|
||||
AND target_id IN (
|
||||
SELECT t.id FROM list_targets lt
|
||||
JOIN targets t ON t.id = lt.target_id
|
||||
WHERE lt.list_id = ? AND (${whereFilter})
|
||||
)
|
||||
`).run(list_id, list_id, ...params.slice(1));
|
||||
|
||||
const remaining = (db.prepare("SELECT COUNT(*) as c FROM list_targets WHERE list_id = ?").get(list_id) as { c: number }).c;
|
||||
|
||||
// Surface the exact removed IDs so a removal is trivially reversible:
|
||||
// feed removed_contact_ids straight into add_to_list to undo this batch.
|
||||
const removedIds = (preview as Array<{ id: string }>).map((c) => c.id);
|
||||
|
||||
return res.json({
|
||||
dry_run: false,
|
||||
removed: preview.length,
|
||||
remaining,
|
||||
contacts_removed: preview,
|
||||
removed_contact_ids: removedIds,
|
||||
undo_hint: "To undo, call add_to_list with these removed_contact_ids.",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
// POST /api/lists/[id]/sync-status body: { account_id: number }
|
||||
// Re-fetches the Sales Nav list and updates degree for non-connected targets.
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
return res.status(405).end();
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const listId = req.query.id as string;
|
||||
|
||||
const list = db.prepare("SELECT * FROM lists WHERE id = ?").get(listId) as
|
||||
| { sales_nav_url?: string }
|
||||
| undefined;
|
||||
if (!list) return res.status(404).json({ error: "List not found" });
|
||||
if (!list.sales_nav_url) return res.status(400).json({ error: "No Sales Navigator URL saved for this list — run an import first" });
|
||||
|
||||
const { account_id } = req.body as { account_id: string };
|
||||
if (!account_id) return res.status(400).json({ error: "account_id required" });
|
||||
|
||||
const account = db.prepare("SELECT * FROM accounts WHERE id = ?").get(account_id) as
|
||||
| { cookies_json: string | null; is_authenticated: number }
|
||||
| undefined;
|
||||
if (!account?.is_authenticated) return res.status(400).json({ error: "Account not authenticated" });
|
||||
|
||||
const { getSessionContext } = await import("@/lib/linkedin/session");
|
||||
const { scrapeNavigatorList } = await import("@/lib/linkedin/scraper");
|
||||
|
||||
try {
|
||||
const ctx = await getSessionContext(account_id);
|
||||
const { profiles } = await scrapeNavigatorList(ctx, list.sales_nav_url, { maxPages: 300 });
|
||||
|
||||
const updateDegree = db.prepare("UPDATE targets SET degree = ? WHERE linkedin_url = ?");
|
||||
const markConnected = db.prepare(
|
||||
`UPDATE targets SET degree = ?, connected_at = CASE
|
||||
WHEN (degree IS NULL OR degree != 1) AND connected_at IS NULL THEN datetime('now')
|
||||
ELSE connected_at
|
||||
END
|
||||
WHERE linkedin_url = ?`
|
||||
);
|
||||
|
||||
let updated = 0;
|
||||
db.transaction(() => {
|
||||
for (const p of profiles) {
|
||||
if (p.degree === 1) {
|
||||
markConnected.run(p.degree, p.salesNavUrl);
|
||||
} else {
|
||||
updateDegree.run(p.degree, p.salesNavUrl);
|
||||
}
|
||||
updated++;
|
||||
}
|
||||
})();
|
||||
|
||||
return res.json({ updated, total: profiles.length });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return res.status(500).json({ error: message });
|
||||
}
|
||||
}
|
||||
|
||||
export const config = {
|
||||
api: { responseLimit: false },
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
// DELETE /api/lists/[id]/targets body: { target_ids: number[] }
|
||||
// Removes targets from the list (list_targets rows only, does not delete the target itself)
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "DELETE") {
|
||||
res.setHeader("Allow", ["DELETE"]);
|
||||
return res.status(405).end();
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const listId = req.query.id as string;
|
||||
const { target_ids } = req.body as { target_ids: string[] };
|
||||
|
||||
if (!Array.isArray(target_ids) || target_ids.length === 0) {
|
||||
return res.status(400).json({ error: "target_ids must be a non-empty array" });
|
||||
}
|
||||
|
||||
const placeholders = target_ids.map(() => "?").join(",");
|
||||
const result = db
|
||||
.prepare(`DELETE FROM list_targets WHERE list_id = ? AND target_id IN (${placeholders})`)
|
||||
.run(listId, ...target_ids);
|
||||
|
||||
return res.json({ removed: result.changes });
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const db = getDb();
|
||||
|
||||
if (req.method === "GET") {
|
||||
const lists = db
|
||||
.prepare(
|
||||
`SELECT l.*, COUNT(lt.target_id) as target_count
|
||||
FROM lists l
|
||||
LEFT JOIN list_targets lt ON lt.list_id = l.id
|
||||
GROUP BY l.id
|
||||
ORDER BY l.created_at DESC`
|
||||
)
|
||||
.all();
|
||||
return res.json(lists);
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
const { name, description } = req.body;
|
||||
if (!name) return res.status(400).json({ error: "name required" });
|
||||
const id = randomUUID();
|
||||
db
|
||||
.prepare("INSERT INTO lists (id, name, description) VALUES (?, ?, ?)")
|
||||
.run(id, name, description ?? null);
|
||||
return res.status(201).json(db.prepare("SELECT * FROM lists WHERE id = ?").get(id));
|
||||
}
|
||||
|
||||
res.setHeader("Allow", ["GET", "POST"]);
|
||||
res.status(405).end();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// GET /api/premium-status — open-core endpoint the UI uses to decide whether to render
|
||||
// premium features or an "Upgrade to Premium" affordance. Returns { hasPremium } which is
|
||||
// true in the commercial build (ee/ present) and false in the public open-source build.
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { hasPremium } from "@/lib/premium";
|
||||
|
||||
export default function handler(_req: NextApiRequest, res: NextApiResponse) {
|
||||
res.status(200).json({ hasPremium });
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
return res.status(405).end();
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const runId = req.query.id as string;
|
||||
const { target_ids } = req.body as { target_ids?: string[] };
|
||||
|
||||
if (!Array.isArray(target_ids) || target_ids.length === 0) {
|
||||
return res.status(400).json({ error: "target_ids required" });
|
||||
}
|
||||
|
||||
const run = db
|
||||
.prepare("SELECT id, workflow_id FROM runs WHERE id = ?")
|
||||
.get(runId) as { id: string; workflow_id: string } | undefined;
|
||||
if (!run) return res.status(404).json({ error: "run_not_found" });
|
||||
|
||||
// Tracks defined on this workflow
|
||||
const workflowTracks = [...new Set(
|
||||
(db.prepare("SELECT DISTINCT track FROM workflow_steps WHERE workflow_id = ?").all(run.workflow_id) as { track: string }[]).map((r) => r.track)
|
||||
)];
|
||||
if (workflowTracks.length === 0) workflowTracks.push("linkedin");
|
||||
|
||||
// Existing email-account pool for this run (used as round-robin pool for new enrollments)
|
||||
const emailAccountPool: string[] = (db
|
||||
.prepare(
|
||||
`SELECT DISTINCT email_account_id FROM run_profiles
|
||||
WHERE run_id = ? AND email_account_id IS NOT NULL`
|
||||
)
|
||||
.all(runId) as Array<{ email_account_id: string }>).map((r) => r.email_account_id);
|
||||
|
||||
// Dedup: already enrolled in this workflow at all
|
||||
const alreadyEnrolled = new Set(
|
||||
(db
|
||||
.prepare(
|
||||
`SELECT DISTINCT rp.target_id FROM run_profiles rp
|
||||
JOIN runs r ON r.id = rp.run_id
|
||||
WHERE r.workflow_id = ?`
|
||||
)
|
||||
.all(run.workflow_id) as { target_id: string }[]).map((r) => r.target_id)
|
||||
);
|
||||
|
||||
// Active elsewhere (in some other running/paused run with an in-progress track)
|
||||
const activeElsewhere = new Set(
|
||||
(db
|
||||
.prepare(
|
||||
`SELECT DISTINCT rp.target_id FROM run_profiles rp
|
||||
JOIN runs r ON r.id = rp.run_id
|
||||
WHERE r.status IN ('running', 'paused')
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM run_profile_tracks rt
|
||||
WHERE rt.run_profile_id = rp.id AND rt.state NOT IN ('completed', 'failed', 'skipped')
|
||||
)`
|
||||
)
|
||||
.all() as { target_id: string }[]).map((r) => r.target_id)
|
||||
);
|
||||
|
||||
let skipped_already_enrolled = 0;
|
||||
let skipped_active_elsewhere = 0;
|
||||
const eligible: string[] = [];
|
||||
for (const tid of target_ids) {
|
||||
if (alreadyEnrolled.has(tid)) { skipped_already_enrolled++; continue; }
|
||||
if (activeElsewhere.has(tid)) { skipped_active_elsewhere++; continue; }
|
||||
eligible.push(tid);
|
||||
}
|
||||
|
||||
if (eligible.length === 0) {
|
||||
return res.json({ enrolled: 0, skipped_already_enrolled, skipped_active_elsewhere });
|
||||
}
|
||||
|
||||
// Assign email accounts: company-grouped round-robin (same as run creation)
|
||||
const emailAssignment = new Map<string, string | null>();
|
||||
if (emailAccountPool.length > 0) {
|
||||
const placeholders = eligible.map(() => "?").join(",");
|
||||
const companyRows = db
|
||||
.prepare(`SELECT id, company_id FROM targets WHERE id IN (${placeholders})`)
|
||||
.all(...eligible) as { id: string; company_id: string | null }[];
|
||||
const companyAccountMap = new Map<string, string>();
|
||||
let cursor = 0;
|
||||
for (const row of companyRows) {
|
||||
if (row.company_id) {
|
||||
if (!companyAccountMap.has(row.company_id)) {
|
||||
companyAccountMap.set(row.company_id, emailAccountPool[cursor % emailAccountPool.length]);
|
||||
cursor++;
|
||||
}
|
||||
emailAssignment.set(row.id, companyAccountMap.get(row.company_id)!);
|
||||
} else {
|
||||
emailAssignment.set(row.id, emailAccountPool[cursor % emailAccountPool.length]);
|
||||
cursor++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const insertProfile = db.prepare(
|
||||
"INSERT INTO run_profiles (id, run_id, target_id, email_account_id) VALUES (?, ?, ?, ?)"
|
||||
);
|
||||
const insertTrack = db.prepare(
|
||||
"INSERT INTO run_profile_tracks (id, run_profile_id, track, state, current_step) VALUES (?, ?, ?, 'pending', 0)"
|
||||
);
|
||||
const insertMany = db.transaction((ids: string[]) => {
|
||||
for (const tid of ids) {
|
||||
const assignedEmailAccountId = emailAssignment.get(tid) ?? null;
|
||||
const rpId = randomUUID();
|
||||
insertProfile.run(rpId, runId, tid, assignedEmailAccountId);
|
||||
for (const track of workflowTracks) {
|
||||
if (track === "email" && !assignedEmailAccountId) continue;
|
||||
insertTrack.run(randomUUID(), rpId, track);
|
||||
}
|
||||
}
|
||||
});
|
||||
insertMany(eligible);
|
||||
|
||||
return res.json({
|
||||
enrolled: eligible.length,
|
||||
skipped_already_enrolled,
|
||||
skipped_active_elsewhere,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const db = getDb();
|
||||
const id = req.query.id as string;
|
||||
|
||||
if (req.method === "GET") {
|
||||
const run = db
|
||||
.prepare(
|
||||
`SELECT r.*,
|
||||
w.name as workflow_name,
|
||||
l.name as list_name,
|
||||
a.name as account_name
|
||||
FROM runs r
|
||||
LEFT JOIN workflows w ON w.id = r.workflow_id
|
||||
LEFT JOIN lists l ON l.id = r.list_id
|
||||
LEFT JOIN accounts a ON a.id = r.account_id
|
||||
WHERE r.id = ?`
|
||||
)
|
||||
.get(id);
|
||||
if (!run) return res.status(404).json({ error: "not found" });
|
||||
|
||||
// Paging + optional target_id filter — a run can hold hundreds of profiles, so callers
|
||||
// (incl. the MCP) can page or pull a single contact instead of the whole set.
|
||||
const targetId = req.query.target_id as string | undefined;
|
||||
const hasPaging = req.query.limit !== undefined || req.query.page !== undefined;
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 500);
|
||||
const offset = (Number(req.query.page) || 0) * limit;
|
||||
|
||||
const profileWhere = targetId ? "rp.run_id = ? AND rp.target_id = ?" : "rp.run_id = ?";
|
||||
const profileArgs: unknown[] = targetId ? [id, targetId] : [id];
|
||||
const profilesTotal = (db.prepare(
|
||||
`SELECT COUNT(*) as c FROM run_profiles rp WHERE ${profileWhere}`
|
||||
).get(...profileArgs) as { c: number }).c;
|
||||
|
||||
const pageClause = (targetId || hasPaging) ? " LIMIT ? OFFSET ?" : "";
|
||||
const pageArgs = (targetId || hasPaging) ? [limit, offset] : [];
|
||||
|
||||
const profiles = db
|
||||
.prepare(
|
||||
`SELECT rp.id, rp.run_id, rp.target_id, rp.email_account_id, rp.created_at,
|
||||
COALESCE(rt_li.state, 'pending') as state,
|
||||
COALESCE(rt_li.current_step, 0) as current_step,
|
||||
rt_li.next_step_at, rt_li.error_message,
|
||||
rt_email.state as email_state,
|
||||
rt_email.current_step as email_current_step,
|
||||
t.full_name, t.linkedin_url, t.title, t.company
|
||||
FROM run_profiles rp
|
||||
LEFT JOIN targets t ON t.id = rp.target_id
|
||||
LEFT JOIN run_profile_tracks rt_li ON rt_li.run_profile_id = rp.id AND rt_li.track = 'linkedin'
|
||||
LEFT JOIN run_profile_tracks rt_email ON rt_email.run_profile_id = rp.id AND rt_email.track = 'email'
|
||||
WHERE ${profileWhere}
|
||||
ORDER BY rp.id${pageClause}`
|
||||
)
|
||||
.all(...profileArgs, ...pageArgs);
|
||||
|
||||
const logs = db
|
||||
.prepare(
|
||||
`SELECT lg.*, t.full_name as target_name
|
||||
FROM logs lg
|
||||
LEFT JOIN targets t ON t.id = lg.target_id
|
||||
WHERE lg.run_id = ?
|
||||
ORDER BY lg.created_at DESC
|
||||
LIMIT 100`
|
||||
)
|
||||
.all(id);
|
||||
|
||||
return res.json({ ...run as object, profiles, profiles_total: profilesTotal, logs });
|
||||
}
|
||||
|
||||
if (req.method === "PATCH") {
|
||||
const { status } = req.body;
|
||||
if (!status) return res.status(400).json({ error: "status required" });
|
||||
db.prepare("UPDATE runs SET status = ? WHERE id = ?").run(status, id);
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
|
||||
if (req.method === "DELETE") {
|
||||
db.prepare("DELETE FROM runs WHERE id = ?").run(id);
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
|
||||
res.status(405).end();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") return res.status(405).end();
|
||||
|
||||
const db = getDb();
|
||||
const runId = req.query.id as string;
|
||||
const { target_ids } = req.body as { target_ids: string[] };
|
||||
|
||||
if (!target_ids?.length) return res.status(400).json({ error: "target_ids required" });
|
||||
|
||||
const placeholders = target_ids.map(() => "?").join(",");
|
||||
const result = db
|
||||
.prepare(
|
||||
`DELETE FROM run_profiles
|
||||
WHERE run_id = ? AND target_id IN (${placeholders})`
|
||||
)
|
||||
.run(runId, ...target_ids);
|
||||
|
||||
return res.json({ ok: true, removed: result.changes });
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") return res.status(405).end();
|
||||
|
||||
const db = getDb();
|
||||
const runId = req.query.id as string;
|
||||
const { target_ids } = req.body as { target_ids: string[] };
|
||||
|
||||
if (!target_ids?.length) return res.status(400).json({ error: "target_ids required" });
|
||||
|
||||
const placeholders = target_ids.map(() => "?").join(",");
|
||||
// Retry: reset all failed track-runs for these profiles back to in_progress
|
||||
const rpRows = db.prepare(
|
||||
`SELECT id FROM run_profiles WHERE run_id = ? AND target_id IN (${placeholders})`
|
||||
).all(runId, ...target_ids) as { id: string }[];
|
||||
|
||||
let retried = 0;
|
||||
for (const rp of rpRows) {
|
||||
const r = db.prepare(
|
||||
`UPDATE run_profile_tracks SET state = 'in_progress', error_message = NULL, next_step_at = NULL
|
||||
WHERE run_profile_id = ? AND state = 'failed'`
|
||||
).run(rp.id);
|
||||
retried += r.changes;
|
||||
}
|
||||
|
||||
return res.json({ ok: true, retried });
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { ensureGlobalRunnerStarted } from "@/lib/linkedin/runner";
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") return res.status(405).end();
|
||||
|
||||
const db = getDb();
|
||||
const id = req.query.id as string;
|
||||
|
||||
const run = db.prepare("SELECT status FROM runs WHERE id = ?").get(id) as { status: string } | undefined;
|
||||
if (!run) return res.status(404).json({ error: "Run not found" });
|
||||
if (run.status === "running") return res.status(400).json({ error: "Run already running" });
|
||||
|
||||
db.prepare(
|
||||
"UPDATE runs SET status = 'running', started_at = COALESCE(started_at, datetime('now')) WHERE id = ?"
|
||||
).run(id);
|
||||
|
||||
ensureGlobalRunnerStarted();
|
||||
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") return res.status(405).end();
|
||||
|
||||
const db = getDb();
|
||||
const runId = req.query.id as string;
|
||||
const { target_id } = req.body as { target_id: string };
|
||||
|
||||
if (!target_id) return res.status(400).json({ error: "target_id required" });
|
||||
|
||||
// Unenroll: mark all track-runs for this profile as skipped
|
||||
const rp = db.prepare(
|
||||
"SELECT id FROM run_profiles WHERE run_id = ? AND target_id = ?"
|
||||
).get(runId, target_id) as { id: string } | undefined;
|
||||
|
||||
if (!rp) return res.status(404).json({ error: "Profile not found" });
|
||||
|
||||
const result = db.prepare(
|
||||
`UPDATE run_profile_tracks SET state = 'skipped', error_message = 'Manually unenrolled'
|
||||
WHERE run_profile_id = ? AND state IN ('pending', 'in_progress')`
|
||||
).run(rp.id);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: "Profile not found or already completed/skipped" });
|
||||
}
|
||||
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const db = getDb();
|
||||
|
||||
if (req.method === "GET") {
|
||||
const runs = db
|
||||
.prepare(
|
||||
`SELECT r.*,
|
||||
w.name as workflow_name,
|
||||
l.name as list_name,
|
||||
a.name as account_name,
|
||||
COUNT(DISTINCT rp.id) as total_profiles,
|
||||
COUNT(DISTINCT CASE WHEN NOT EXISTS (
|
||||
SELECT 1 FROM run_profile_tracks rt2
|
||||
WHERE rt2.run_profile_id = rp.id AND rt2.state NOT IN ('completed', 'failed', 'skipped')
|
||||
) AND EXISTS (
|
||||
SELECT 1 FROM run_profile_tracks rt3
|
||||
WHERE rt3.run_profile_id = rp.id AND rt3.state = 'completed'
|
||||
) THEN rp.id END) as completed_profiles
|
||||
FROM runs r
|
||||
LEFT JOIN workflows w ON w.id = r.workflow_id
|
||||
LEFT JOIN lists l ON l.id = r.list_id
|
||||
LEFT JOIN accounts a ON a.id = r.account_id
|
||||
LEFT JOIN run_profiles rp ON rp.run_id = r.id
|
||||
GROUP BY r.id
|
||||
ORDER BY r.created_at DESC`
|
||||
)
|
||||
.all();
|
||||
return res.json(runs);
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
const { workflow_id, list_id, account_id, email_account_id, email_account_ids, target_ids } = req.body;
|
||||
if (!workflow_id || !list_id || !account_id)
|
||||
return res.status(400).json({ error: "workflow_id, list_id, account_id required" });
|
||||
|
||||
// Normalise email account list — prefer the new array, fall back to legacy single-id
|
||||
const emailAccountPool: string[] = Array.isArray(email_account_ids) && email_account_ids.length > 0
|
||||
? email_account_ids
|
||||
: (email_account_id ? [email_account_id] : []);
|
||||
|
||||
// Check 1: only one active run per workflow
|
||||
const activeRun = db.prepare(
|
||||
"SELECT id FROM runs WHERE workflow_id = ? AND status IN ('running', 'paused') LIMIT 1"
|
||||
).get(workflow_id) as { id: string } | undefined;
|
||||
if (activeRun) {
|
||||
return res.status(400).json({
|
||||
error: "workflow_already_active",
|
||||
message: "This workflow is already running. Stop or pause it before enrolling a new list.",
|
||||
});
|
||||
}
|
||||
|
||||
const runId = randomUUID();
|
||||
// For backwards compat, store first email account on the run row (may be null for no-email campaigns)
|
||||
db
|
||||
.prepare("INSERT INTO runs (id, workflow_id, list_id, account_id, email_account_id) VALUES (?, ?, ?, ?, ?)")
|
||||
.run(runId, workflow_id, list_id, account_id, emailAccountPool[0] ?? null);
|
||||
|
||||
// Create run_profiles — either for selected targets or all targets in the list
|
||||
const candidates: { target_id: string }[] = Array.isArray(target_ids) && target_ids.length > 0
|
||||
? (target_ids as string[]).map((id) => ({ target_id: id }))
|
||||
: db.prepare("SELECT target_id FROM list_targets WHERE list_id = ?").all(list_id) as { target_id: string }[];
|
||||
|
||||
// Exclude targets already enrolled in any run of this workflow
|
||||
const alreadyEnrolled = new Set(
|
||||
(db.prepare(
|
||||
`SELECT DISTINCT rp.target_id FROM run_profiles rp
|
||||
JOIN runs r ON r.id = rp.run_id
|
||||
WHERE r.workflow_id = ?`
|
||||
).all(workflow_id) as { target_id: string }[]).map((r) => r.target_id)
|
||||
);
|
||||
|
||||
// Exclude targets currently active in any other running/paused workflow
|
||||
const activeElsewhere = new Set(
|
||||
(db.prepare(
|
||||
`SELECT DISTINCT rp.target_id FROM run_profiles rp
|
||||
JOIN runs r ON r.id = rp.run_id
|
||||
WHERE r.status IN ('running', 'paused')
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM run_profile_tracks rt
|
||||
WHERE rt.run_profile_id = rp.id AND rt.state NOT IN ('completed', 'failed', 'skipped')
|
||||
)`
|
||||
).all() as { target_id: string }[]).map((r) => r.target_id)
|
||||
);
|
||||
|
||||
const targets = candidates.filter((t) => !alreadyEnrolled.has(t.target_id) && !activeElsewhere.has(t.target_id));
|
||||
|
||||
if (targets.length === 0) {
|
||||
// Clean up the run we just created since there's nothing to enroll
|
||||
db.prepare("DELETE FROM runs WHERE id = ?").run(runId);
|
||||
return res.status(400).json({
|
||||
error: "all_already_enrolled",
|
||||
message: "All selected contacts are already enrolled in this workflow.",
|
||||
});
|
||||
}
|
||||
|
||||
// Assign email accounts: company-grouped round-robin
|
||||
// All targets at the same company get the same sender; companies cycle through the pool
|
||||
let emailAssignment: Map<string, string | null> = new Map();
|
||||
if (emailAccountPool.length > 0) {
|
||||
// Load company_id for each candidate target
|
||||
const targetIds = targets.map(t => t.target_id);
|
||||
const placeholders = targetIds.map(() => "?").join(",");
|
||||
const companyRows = db.prepare(
|
||||
`SELECT id, company_id FROM targets WHERE id IN (${placeholders})`
|
||||
).all(...targetIds) as { id: string; company_id: string | null }[];
|
||||
|
||||
const companyAccountMap = new Map<string, string>(); // company_id → email_account_id
|
||||
let poolCursor = 0;
|
||||
|
||||
for (const row of companyRows) {
|
||||
if (row.company_id) {
|
||||
if (!companyAccountMap.has(row.company_id)) {
|
||||
companyAccountMap.set(row.company_id, emailAccountPool[poolCursor % emailAccountPool.length]);
|
||||
poolCursor++;
|
||||
}
|
||||
emailAssignment.set(row.id, companyAccountMap.get(row.company_id)!);
|
||||
} else {
|
||||
// No company — assign individually round-robin
|
||||
emailAssignment.set(row.id, emailAccountPool[poolCursor % emailAccountPool.length]);
|
||||
poolCursor++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which tracks this workflow has steps for
|
||||
const workflowTracks = [...new Set(
|
||||
(db.prepare("SELECT DISTINCT track FROM workflow_steps WHERE workflow_id = ?").all(workflow_id) as { track: string }[]).map(r => r.track)
|
||||
)];
|
||||
// If no track column exists yet (old DB), default to linkedin-only
|
||||
if (workflowTracks.length === 0) workflowTracks.push("linkedin");
|
||||
|
||||
const insertProfile = db.prepare(
|
||||
"INSERT INTO run_profiles (id, run_id, target_id, email_account_id) VALUES (?, ?, ?, ?)"
|
||||
);
|
||||
const insertTrack = db.prepare(
|
||||
"INSERT INTO run_profile_tracks (id, run_profile_id, track, state, current_step) VALUES (?, ?, ?, 'pending', 0)"
|
||||
);
|
||||
const insertMany = db.transaction((ts: { target_id: string }[]) => {
|
||||
for (const t of ts) {
|
||||
const assignedEmailAccountId = emailAssignment.get(t.target_id) ?? null;
|
||||
const rpId = randomUUID();
|
||||
insertProfile.run(rpId, runId, t.target_id, assignedEmailAccountId);
|
||||
for (const track of workflowTracks) {
|
||||
// Skip email track if no email account is configured on this run
|
||||
if (track === "email" && !assignedEmailAccountId) continue;
|
||||
insertTrack.run(randomUUID(), rpId, track);
|
||||
}
|
||||
}
|
||||
});
|
||||
insertMany(targets);
|
||||
|
||||
return res.status(201).json({ id: runId });
|
||||
}
|
||||
|
||||
res.status(405).end();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { getDailyImportCap, setDailyImportCap, DEFAULT_DAILY_CAP } from "@/lib/import-jobs";
|
||||
|
||||
/** GET → { cap }, PUT { cap } → set the global daily import cap. */
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const db = getDb();
|
||||
|
||||
if (req.method === "GET") {
|
||||
return res.json({ cap: getDailyImportCap(db), default: DEFAULT_DAILY_CAP });
|
||||
}
|
||||
|
||||
if (req.method === "PUT") {
|
||||
const { cap } = req.body as { cap?: number };
|
||||
const n = Number(cap);
|
||||
if (!Number.isFinite(n) || n < 1) return res.status(400).json({ error: "cap must be a positive number" });
|
||||
setDailyImportCap(db, n);
|
||||
return res.json({ cap: getDailyImportCap(db) });
|
||||
}
|
||||
|
||||
res.setHeader("Allow", ["GET", "PUT"]);
|
||||
return res.status(405).end();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getUpdateState } from "@/lib/update-check";
|
||||
|
||||
export default function handler(_req: NextApiRequest, res: NextApiResponse) {
|
||||
res.json(getUpdateState());
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const db = getDb();
|
||||
const id = req.query.id as string;
|
||||
|
||||
if (req.method === "GET") {
|
||||
const target = db.prepare("SELECT * FROM targets WHERE id = ?").get(id);
|
||||
if (!target) return res.status(404).json({ error: "Not found" });
|
||||
|
||||
const company = db.prepare("SELECT * FROM companies WHERE id = (SELECT company_id FROM targets WHERE id = ?)").get(id);
|
||||
const lists = db.prepare(`
|
||||
SELECT l.id, l.name FROM lists l
|
||||
INNER JOIN list_targets lt ON lt.list_id = l.id
|
||||
WHERE lt.target_id = ?
|
||||
ORDER BY l.name COLLATE NOCASE
|
||||
`).all(id);
|
||||
|
||||
return res.json({ ...target as object, company: company ?? null, lists });
|
||||
}
|
||||
|
||||
if (req.method === "PATCH") {
|
||||
const target = db.prepare("SELECT id FROM targets WHERE id = ?").get(id);
|
||||
if (!target) return res.status(404).json({ error: "Not found" });
|
||||
|
||||
// Editable contact fields (CRM hygiene). Anything else is owned by enrichment/automation.
|
||||
const EDITABLE = [
|
||||
"first_name", "last_name", "full_name", "title", "company", "location",
|
||||
"email", "phone", "headline", "summary", "notes",
|
||||
] as const;
|
||||
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const fields: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
for (const col of EDITABLE) {
|
||||
if (body[col] !== undefined) {
|
||||
const v = body[col];
|
||||
fields.push(`${col} = ?`);
|
||||
params.push(typeof v === "string" ? (v.trim() || null) : v);
|
||||
}
|
||||
}
|
||||
if (fields.length === 0) return res.status(400).json({ error: "No editable fields provided" });
|
||||
params.push(id);
|
||||
db.prepare(`UPDATE targets SET ${fields.join(", ")} WHERE id = ?`).run(...params);
|
||||
|
||||
return res.json(db.prepare("SELECT * FROM targets WHERE id = ?").get(id));
|
||||
}
|
||||
|
||||
if (req.method === "DELETE") {
|
||||
const target = db.prepare("SELECT id FROM targets WHERE id = ?").get(id);
|
||||
if (!target) return res.status(404).json({ error: "Not found" });
|
||||
// Some references (run_profiles, logs) have no ON DELETE CASCADE — clear them first so the
|
||||
// FK constraint doesn't block the delete. run_profile_tracks cascade off run_profiles.
|
||||
db.transaction(() => {
|
||||
db.prepare("DELETE FROM run_profiles WHERE target_id = ?").run(id);
|
||||
db.prepare("DELETE FROM logs WHERE target_id = ?").run(id);
|
||||
db.prepare("DELETE FROM targets WHERE id = ?").run(id);
|
||||
})();
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
|
||||
res.setHeader("Allow", ["GET", "PATCH", "DELETE"]);
|
||||
return res.status(405).end();
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import Imap from "imap";
|
||||
import { simpleParser } from "mailparser";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { sendEmail, type EmailAccount } from "@/lib/email/sender";
|
||||
import { decryptSecret } from "@/lib/crypto";
|
||||
|
||||
// Email conversation for one contact, account-resolved automatically so callers (incl. the MCP)
|
||||
// never need to know the email_account_id.
|
||||
// GET → the IMAP thread (sent + received) with this contact.
|
||||
// POST → send an email to this contact as the Linki sending identity (subject optional → reply).
|
||||
//
|
||||
// Account resolution order: explicit email_account_id → the contact's most recent run_profile
|
||||
// assignment → the single configured account if there's exactly one.
|
||||
|
||||
type AccountRow = {
|
||||
id: string; from_email: string; from_name: string | null; reply_to: string | null;
|
||||
smtp_host: string; smtp_port: number; smtp_secure: number;
|
||||
imap_host: string | null; imap_port: number | null;
|
||||
username: string; password: string;
|
||||
imap_username: string | null; imap_password: string | null;
|
||||
};
|
||||
|
||||
function resolveAccount(db: ReturnType<typeof getDb>, targetId: string, explicitId?: string): AccountRow | null {
|
||||
const cols = `id, from_email, from_name, reply_to, smtp_host, smtp_port, smtp_secure,
|
||||
imap_host, imap_port, username, password, imap_username, imap_password`;
|
||||
if (explicitId) {
|
||||
return (db.prepare(`SELECT ${cols} FROM email_accounts WHERE id = ?`).get(explicitId) as AccountRow) ?? null;
|
||||
}
|
||||
const assigned = db.prepare(`
|
||||
SELECT rp.email_account_id FROM run_profiles rp
|
||||
WHERE rp.target_id = ? AND rp.email_account_id IS NOT NULL
|
||||
ORDER BY rp.created_at DESC LIMIT 1
|
||||
`).get(targetId) as { email_account_id: string } | undefined;
|
||||
if (assigned?.email_account_id) {
|
||||
return (db.prepare(`SELECT ${cols} FROM email_accounts WHERE id = ?`).get(assigned.email_account_id) as AccountRow) ?? null;
|
||||
}
|
||||
const all = db.prepare(`SELECT ${cols} FROM email_accounts`).all() as AccountRow[];
|
||||
return all.length === 1 ? all[0] : null;
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const db = getDb();
|
||||
const targetId = req.query.id as string;
|
||||
|
||||
const target = db.prepare("SELECT id, email FROM targets WHERE id = ?").get(targetId) as
|
||||
| { id: string; email: string | null } | undefined;
|
||||
if (!target) return res.status(404).json({ error: "Contact not found" });
|
||||
if (!target.email) return res.status(400).json({ error: "Contact has no email address" });
|
||||
|
||||
// ── Read thread ──
|
||||
if (req.method === "GET") {
|
||||
const account = resolveAccount(db, targetId, req.query.email_account_id as string | undefined);
|
||||
if (!account?.imap_host) {
|
||||
return res.status(400).json({ error: "No email account with IMAP config could be resolved for this contact" });
|
||||
}
|
||||
const cfg = {
|
||||
host: account.imap_host,
|
||||
port: account.imap_port ?? 993,
|
||||
user: account.imap_username ?? account.username,
|
||||
password: decryptSecret(account.imap_password) ?? decryptSecret(account.password)!,
|
||||
};
|
||||
// Transient IMAP failures (dropped connection, auth timeout) are common — retry once
|
||||
// before surfacing a structured error rather than an opaque 500.
|
||||
let lastErr: unknown = null;
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
try {
|
||||
const messages = await fetchThread(cfg, target.email.toLowerCase());
|
||||
return res.json({ email_account_id: account.id, contact_email: target.email, messages });
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
console.warn(`[targets/emails] IMAP attempt ${attempt} failed:`, err instanceof Error ? err.message : err);
|
||||
if (attempt < 2) await new Promise((r) => setTimeout(r, 800));
|
||||
}
|
||||
}
|
||||
return res.status(502).json({
|
||||
error: "IMAP fetch failed",
|
||||
detail: lastErr instanceof Error ? lastErr.message : String(lastErr),
|
||||
email_account_id: account.id,
|
||||
mailbox: "INBOX",
|
||||
contact_email: target.email,
|
||||
hint: "Transient IMAP error after 1 retry. For reliable reply retrieval use get_replies, which reads stored replies and never hits IMAP live.",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Send / reply ──
|
||||
if (req.method === "POST") {
|
||||
const { subject, body, email_account_id } = req.body as {
|
||||
subject?: string; body?: string; email_account_id?: string;
|
||||
};
|
||||
if (!body?.trim()) return res.status(400).json({ error: "body is required" });
|
||||
|
||||
const account = resolveAccount(db, targetId, email_account_id);
|
||||
if (!account) return res.status(400).json({ error: "No email account could be resolved for this contact" });
|
||||
|
||||
// No subject → treat as a reply: reuse the last subject in the thread (Re: …) or a sane default.
|
||||
let finalSubject = subject?.trim();
|
||||
if (!finalSubject) {
|
||||
const lastReply = db.prepare(
|
||||
"SELECT subject FROM email_replies WHERE target_id = ? AND subject IS NOT NULL ORDER BY received_at DESC LIMIT 1"
|
||||
).get(targetId) as { subject: string } | undefined;
|
||||
finalSubject = lastReply?.subject
|
||||
? (/^re:/i.test(lastReply.subject) ? lastReply.subject : `Re: ${lastReply.subject}`)
|
||||
: "Re:";
|
||||
}
|
||||
|
||||
try {
|
||||
await sendEmail(
|
||||
{ ...account, password: decryptSecret(account.password)! } as EmailAccount,
|
||||
target.email, finalSubject, body
|
||||
);
|
||||
return res.json({ ok: true, email_account_id: account.id, to: target.email, subject: finalSubject });
|
||||
} catch (err) {
|
||||
console.error("[targets/emails] send failed:", err);
|
||||
return res.status(500).json({ error: err instanceof Error ? err.message : "Send failed" });
|
||||
}
|
||||
}
|
||||
|
||||
res.setHeader("Allow", ["GET", "POST"]);
|
||||
return res.status(405).end();
|
||||
}
|
||||
|
||||
interface ImapConfig { host: string; port: number; user: string; password: string; }
|
||||
interface EmailMessage {
|
||||
uid: number; from: string; to: string; subject: string; date: string;
|
||||
text: string; messageId: string | null;
|
||||
}
|
||||
|
||||
async function fetchThread(cfg: ImapConfig, contactEmail: string): Promise<EmailMessage[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const imap = new Imap({
|
||||
host: cfg.host, port: cfg.port, tls: true,
|
||||
tlsOptions: { rejectUnauthorized: false },
|
||||
user: cfg.user, password: cfg.password,
|
||||
authTimeout: 10_000, connTimeout: 12_000,
|
||||
});
|
||||
const done = (err?: Error) => { try { imap.end(); } catch { /* ignore */ } if (err) reject(err); };
|
||||
const messages: EmailMessage[] = [];
|
||||
|
||||
imap.once("error", (err: Error) => done(err));
|
||||
imap.once("ready", () => {
|
||||
imap.openBox("INBOX", true, (boxErr) => {
|
||||
if (boxErr) { done(boxErr); return; }
|
||||
imap.search([["OR", ["FROM", contactEmail], ["TO", contactEmail]]], (searchErr, uids) => {
|
||||
if (searchErr) { done(searchErr); return; }
|
||||
if (uids.length === 0) { resolve([]); done(); return; }
|
||||
const toFetch = uids.slice(-20);
|
||||
const fetch = imap.fetch(toFetch, { bodies: "", struct: false });
|
||||
const pending: Promise<void>[] = [];
|
||||
|
||||
fetch.on("message", (msg) => {
|
||||
let uid = 0;
|
||||
msg.on("attributes", (attrs) => { uid = attrs.uid; });
|
||||
const p = new Promise<void>((res2) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let bodyEnded = false;
|
||||
msg.on("body", (stream) => {
|
||||
stream.on("data", (c: Buffer) => chunks.push(c));
|
||||
stream.once("end", async () => {
|
||||
bodyEnded = true;
|
||||
try {
|
||||
const parsed = await simpleParser(Buffer.concat(chunks));
|
||||
messages.push({
|
||||
uid,
|
||||
from: parsed.from?.text ?? "",
|
||||
to: parsed.to ? (Array.isArray(parsed.to) ? parsed.to.map(a => a.text).join(", ") : parsed.to.text) : "",
|
||||
subject: parsed.subject ?? "(no subject)",
|
||||
date: parsed.date?.toISOString() ?? new Date().toISOString(),
|
||||
text: parsed.text ?? "",
|
||||
messageId: parsed.messageId ?? null,
|
||||
});
|
||||
} catch { /* skip malformed */ }
|
||||
res2();
|
||||
});
|
||||
});
|
||||
msg.once("end", () => { if (!bodyEnded) res2(); });
|
||||
});
|
||||
pending.push(p);
|
||||
});
|
||||
|
||||
fetch.once("error", (fetchErr: Error) => done(fetchErr));
|
||||
fetch.once("end", async () => {
|
||||
await Promise.allSettled(pending);
|
||||
messages.sort((a, b) => a.date.localeCompare(b.date));
|
||||
resolve(messages);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
imap.connect();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { getSessionContext } from "@/lib/linkedin/session";
|
||||
import { scrapeProfile } from "@/lib/linkedin/profile-scrape";
|
||||
import { resolveLinkedInAccount } from "@/lib/linkedin/resolve-account";
|
||||
|
||||
// POST /api/targets/[id]/profile-scrape
|
||||
// Live-scrapes a lead's LinkedIn profile (Sales Nav career + recent posts) and
|
||||
// returns clean structured JSON. Read-only on LinkedIn's side; does not persist.
|
||||
// Account auto-resolved (explicit account_id → contact's last run → sole account).
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") return res.status(405).json({ error: "Method not allowed" });
|
||||
|
||||
const db = getDb();
|
||||
const id = req.query.id as string;
|
||||
const target = db.prepare(
|
||||
"SELECT id, full_name, linkedin_url, sales_nav_url FROM targets WHERE id = ?"
|
||||
).get(id) as { id: string; full_name: string | null; linkedin_url: string | null; sales_nav_url: string | null } | undefined;
|
||||
|
||||
if (!target) return res.status(404).json({ error: "Contact not found" });
|
||||
if (!target.sales_nav_url) {
|
||||
return res.status(400).json({ error: "Contact has no Sales Navigator URL — re-import the list to capture it." });
|
||||
}
|
||||
|
||||
const account = resolveLinkedInAccount(db, id, req.body?.account_id);
|
||||
if (!account) return res.status(400).json({ error: "No authenticated LinkedIn account could be resolved." });
|
||||
|
||||
try {
|
||||
const ctx = await getSessionContext(account.id);
|
||||
const profile = await scrapeProfile(ctx, target);
|
||||
|
||||
// Persist what we scraped so it isn't thrown away: posts + the career fields
|
||||
// we already have columns for (headline/summary/positions). Cheap and lets the
|
||||
// runner's AI writer reuse it without re-scraping.
|
||||
db.prepare(`
|
||||
UPDATE targets SET
|
||||
posts_json = CASE WHEN ? IS NOT NULL THEN ? ELSE posts_json END,
|
||||
posts_scraped_at = datetime('now'),
|
||||
headline = COALESCE(?, headline),
|
||||
summary = COALESCE(?, summary),
|
||||
positions_json = CASE WHEN ? IS NOT NULL THEN ? ELSE positions_json END
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
profile.recent_posts.length ? "1" : null,
|
||||
profile.recent_posts.length ? JSON.stringify(profile.recent_posts) : null,
|
||||
profile.headline,
|
||||
profile.summary,
|
||||
profile.positions.length ? "1" : null,
|
||||
profile.positions.length ? JSON.stringify(profile.positions) : null,
|
||||
id
|
||||
);
|
||||
|
||||
return res.json({ contact_id: id, account_id: account.id, profile });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// A dead session surfaces as "No data intercepted" / re-auth — flag it so the runner stops.
|
||||
if (/re-authentication|No data intercepted|login|checkpoint/i.test(message)) {
|
||||
try {
|
||||
const { markNeedsReauth } = await import("@/lib/linkedin/session");
|
||||
await markNeedsReauth(account.id);
|
||||
} catch { /* ignore */ }
|
||||
return res.status(502).json({ error: `LinkedIn session needs re-authentication: ${message}` });
|
||||
}
|
||||
return res.status(500).json({ error: message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
// Which runs/campaigns a contact is enrolled in, with per-track (linkedin/email) state + step.
|
||||
// Answers "is this person in a campaign, and where are they in it?" without scanning a whole run.
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "GET") return res.status(405).end();
|
||||
|
||||
const db = getDb();
|
||||
const targetId = req.query.id as string;
|
||||
|
||||
const target = db.prepare("SELECT id FROM targets WHERE id = ?").get(targetId);
|
||||
if (!target) return res.status(404).json({ error: "Not found" });
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
rp.id AS run_profile_id,
|
||||
r.id AS run_id,
|
||||
r.status AS run_status,
|
||||
r.workflow_id,
|
||||
w.name AS workflow_name,
|
||||
l.id AS list_id,
|
||||
l.name AS list_name,
|
||||
rp.email_account_id,
|
||||
rt.track,
|
||||
rt.state,
|
||||
rt.current_step,
|
||||
rt.next_step_at,
|
||||
rt.error_message
|
||||
FROM run_profiles rp
|
||||
JOIN runs r ON r.id = rp.run_id
|
||||
LEFT JOIN workflows w ON w.id = r.workflow_id
|
||||
LEFT JOIN lists l ON l.id = r.list_id
|
||||
LEFT JOIN run_profile_tracks rt ON rt.run_profile_id = rp.id
|
||||
WHERE rp.target_id = ?
|
||||
ORDER BY r.created_at DESC, rt.track
|
||||
`).all(targetId) as Array<Record<string, unknown>>;
|
||||
|
||||
// Group tracks under their run.
|
||||
const byRun = new Map<string, Record<string, unknown>>();
|
||||
for (const row of rows) {
|
||||
const rid = row.run_id as string;
|
||||
if (!byRun.has(rid)) {
|
||||
byRun.set(rid, {
|
||||
run_id: rid,
|
||||
run_profile_id: row.run_profile_id,
|
||||
run_status: row.run_status,
|
||||
workflow_id: row.workflow_id,
|
||||
workflow_name: row.workflow_name,
|
||||
list_id: row.list_id,
|
||||
list_name: row.list_name,
|
||||
email_account_id: row.email_account_id,
|
||||
tracks: [],
|
||||
});
|
||||
}
|
||||
if (row.track) {
|
||||
(byRun.get(rid)!.tracks as unknown[]).push({
|
||||
track: row.track,
|
||||
state: row.state,
|
||||
current_step: row.current_step,
|
||||
next_step_at: row.next_step_at,
|
||||
error_message: row.error_message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ runs: Array.from(byRun.values()) });
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user