diff --git a/web/src/lib/api/client/app/leadsync/createSource.ts b/web/src/lib/api/client/app/leadsync/createSource.ts new file mode 100644 index 000000000..9e1b274f8 --- /dev/null +++ b/web/src/lib/api/client/app/leadsync/createSource.ts @@ -0,0 +1,19 @@ +import type { + CreateLeadSyncSource, + LeadSyncSource, +} from "@/lib/api/models/app/leadsync/LeadSync"; +import Request from "../../Request"; + +// Saves a new on-demand source. The backend validates the connection is the +// org's google_sheets connection, dedup is valid, and column_mapping maps a +// column to 'email'. +export default async function createSource( + input: CreateLeadSyncSource, +): Promise { + return await Request({ + method: "POST", + url: "/lead-sync/sources", + data: input, + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/leadsync/deleteSource.ts b/web/src/lib/api/client/app/leadsync/deleteSource.ts new file mode 100644 index 000000000..d4e576837 --- /dev/null +++ b/web/src/lib/api/client/app/leadsync/deleteSource.ts @@ -0,0 +1,10 @@ +import Request from "../../Request"; + +// Deletes a saved source (org-scoped). Backend returns 204 No Content. +export default async function deleteSource(id: string): Promise { + await Request({ + method: "DELETE", + url: `/lead-sync/sources/${id}`, + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/leadsync/getGoogleConnection.ts b/web/src/lib/api/client/app/leadsync/getGoogleConnection.ts new file mode 100644 index 000000000..609af68d5 --- /dev/null +++ b/web/src/lib/api/client/app/leadsync/getGoogleConnection.ts @@ -0,0 +1,12 @@ +import type { LeadSyncConnection } from "@/lib/api/models/app/leadsync/LeadSync"; +import Request from "../../Request"; + +// Reports whether the org has a connected (hidden) google_sheets OAuth +// connection usable for lead-sync. Drives "Connect Google" vs the sheet picker. +export default async function getGoogleConnection(): Promise { + return await Request({ + method: "GET", + url: "/lead-sync/google/connection", + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/leadsync/getSource.ts b/web/src/lib/api/client/app/leadsync/getSource.ts new file mode 100644 index 000000000..3006ffb4f --- /dev/null +++ b/web/src/lib/api/client/app/leadsync/getSource.ts @@ -0,0 +1,11 @@ +import type { LeadSyncSource } from "@/lib/api/models/app/leadsync/LeadSync"; +import Request from "../../Request"; + +// Returns one saved source (org-scoped). +export default async function getSource(id: string): Promise { + return await Request({ + method: "GET", + url: `/lead-sync/sources/${id}`, + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/leadsync/getSpreadsheet.ts b/web/src/lib/api/client/app/leadsync/getSpreadsheet.ts new file mode 100644 index 000000000..b2d3fdab2 --- /dev/null +++ b/web/src/lib/api/client/app/leadsync/getSpreadsheet.ts @@ -0,0 +1,16 @@ +import type { SheetMeta } from "@/lib/api/models/app/leadsync/LeadSync"; +import Request from "../../Request"; + +// Returns a spreadsheet's title + tabs (read via the connection's refreshed +// Google token) so the UI can render a tab picker. +export default async function getSpreadsheet(input: { + connection_id: string; + sheet_id: string; +}): Promise { + return await Request({ + method: "POST", + url: "/lead-sync/google/spreadsheet", + data: input, + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/leadsync/listSources.ts b/web/src/lib/api/client/app/leadsync/listSources.ts new file mode 100644 index 000000000..58ce00f6f --- /dev/null +++ b/web/src/lib/api/client/app/leadsync/listSources.ts @@ -0,0 +1,14 @@ +import type { LeadSyncSource } from "@/lib/api/models/app/leadsync/LeadSync"; +import Request from "../../Request"; + +// Lists this org's saved sync sources, optionally filtered to one campaign +// (powers both the global Contacts > Sync sources area and the per-campaign +// "Connect a Google Sheet" list). +export default async function listSources(campaignId?: string): Promise<{ data: LeadSyncSource[] }> { + return await Request<{ data: LeadSyncSource[] }>({ + method: "GET", + url: "/lead-sync/sources", + params: campaignId ? { campaign_id: campaignId } : undefined, + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/leadsync/previewSheet.ts b/web/src/lib/api/client/app/leadsync/previewSheet.ts new file mode 100644 index 000000000..3e11ff92b --- /dev/null +++ b/web/src/lib/api/client/app/leadsync/previewSheet.ts @@ -0,0 +1,18 @@ +import type { ImportPreview } from "@/lib/api/client/app/contacts/importContacts"; +import Request from "../../Request"; + +// Reads the first ~21 rows of a tab and returns the exact contacts +// ImportPreview JSON shape, so the frontend reuses its contact-import column +// mapper verbatim. +export default async function previewSheet(input: { + connection_id: string; + sheet_id: string; + tab_title: string; +}): Promise { + return await Request({ + method: "POST", + url: "/lead-sync/google/preview", + data: input, + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/leadsync/syncSource.ts b/web/src/lib/api/client/app/leadsync/syncSource.ts new file mode 100644 index 000000000..9788571cd --- /dev/null +++ b/web/src/lib/api/client/app/leadsync/syncSource.ts @@ -0,0 +1,13 @@ +import type { LeadSyncResult } from "@/lib/api/models/app/leadsync/LeadSync"; +import Request from "../../Request"; + +// "Sync now": reads the full sheet tab, encodes rows as CSV in memory, and runs +// the existing contact ImportCommit (upsert by email, attach categories + +// target campaign). Naturally idempotent (email upsert), so no Idempotency-Key. +export default async function syncSource(id: string): Promise { + return await Request({ + method: "POST", + url: `/lead-sync/sources/${id}/sync`, + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/leadsync/updateSource.ts b/web/src/lib/api/client/app/leadsync/updateSource.ts new file mode 100644 index 000000000..3e1e42e95 --- /dev/null +++ b/web/src/lib/api/client/app/leadsync/updateSource.ts @@ -0,0 +1,19 @@ +import type { + LeadSyncSource, + UpdateLeadSyncSource, +} from "@/lib/api/models/app/leadsync/LeadSync"; +import Request from "../../Request"; + +// Edits a saved source; omitted fields are left unchanged. clear_campaign:true +// detaches the target campaign. +export default async function updateSource( + id: string, + input: UpdateLeadSyncSource, +): Promise { + return await Request({ + method: "PATCH", + url: `/lead-sync/sources/${id}`, + data: input, + authorization: true, + }); +} diff --git a/web/src/lib/api/hooks/app/leadsync/useCreateLeadSyncSource.ts b/web/src/lib/api/hooks/app/leadsync/useCreateLeadSyncSource.ts new file mode 100644 index 000000000..2be73c8fa --- /dev/null +++ b/web/src/lib/api/hooks/app/leadsync/useCreateLeadSyncSource.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import createSource from "@/lib/api/client/app/leadsync/createSource"; + +// Saves a new sync source and refreshes every source list. +export default function useCreateLeadSyncSource() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: createSource, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["lead-sync", "sources"] }); + }, + }); +} diff --git a/web/src/lib/api/hooks/app/leadsync/useDeleteLeadSyncSource.ts b/web/src/lib/api/hooks/app/leadsync/useDeleteLeadSyncSource.ts new file mode 100644 index 000000000..a181e982f --- /dev/null +++ b/web/src/lib/api/hooks/app/leadsync/useDeleteLeadSyncSource.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import deleteSource from "@/lib/api/client/app/leadsync/deleteSource"; + +// Deletes a saved source and refreshes every source list. +export default function useDeleteLeadSyncSource() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: deleteSource, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["lead-sync", "sources"] }); + }, + }); +} diff --git a/web/src/lib/api/hooks/app/leadsync/useGoogleConnection.ts b/web/src/lib/api/hooks/app/leadsync/useGoogleConnection.ts new file mode 100644 index 000000000..ea969a6a3 --- /dev/null +++ b/web/src/lib/api/hooks/app/leadsync/useGoogleConnection.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query"; +import getGoogleConnection from "@/lib/api/client/app/leadsync/getGoogleConnection"; + +// Reports whether the org has a connected google_sheets OAuth connection +// usable for lead-sync. Drives the "Connect Google Sheets" vs sheet-picker UI. +export default function useGoogleConnection() { + return useQuery({ + queryKey: ["lead-sync", "google", "connection"], + queryFn: getGoogleConnection, + staleTime: 10_000, + }); +} diff --git a/web/src/lib/api/hooks/app/leadsync/useLeadSyncSources.ts b/web/src/lib/api/hooks/app/leadsync/useLeadSyncSources.ts new file mode 100644 index 000000000..9f0037135 --- /dev/null +++ b/web/src/lib/api/hooks/app/leadsync/useLeadSyncSources.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query"; +import listSources from "@/lib/api/client/app/leadsync/listSources"; + +// Lists saved sync sources, optionally filtered to one campaign. Used by both +// the global Contacts > Sync sources area and the per-campaign list. +export default function useLeadSyncSources(campaignId?: string) { + return useQuery({ + queryKey: ["lead-sync", "sources", campaignId ?? null], + queryFn: () => listSources(campaignId), + staleTime: 10_000, + }); +} diff --git a/web/src/lib/api/hooks/app/leadsync/useSheetMeta.ts b/web/src/lib/api/hooks/app/leadsync/useSheetMeta.ts new file mode 100644 index 000000000..682956d8e --- /dev/null +++ b/web/src/lib/api/hooks/app/leadsync/useSheetMeta.ts @@ -0,0 +1,13 @@ +import { useMutation } from "@tanstack/react-query"; +import getSpreadsheet from "@/lib/api/client/app/leadsync/getSpreadsheet"; +import previewSheet from "@/lib/api/client/app/leadsync/previewSheet"; + +// Fetching spreadsheet metadata and previewing a tab are user-triggered +// actions inside the wizard (not background queries), so they're mutations. +export function useGetSpreadsheet() { + return useMutation({ mutationFn: getSpreadsheet }); +} + +export function usePreviewSheet() { + return useMutation({ mutationFn: previewSheet }); +} diff --git a/web/src/lib/api/hooks/app/leadsync/useSyncLeadSyncSource.ts b/web/src/lib/api/hooks/app/leadsync/useSyncLeadSyncSource.ts new file mode 100644 index 000000000..6b663ad02 --- /dev/null +++ b/web/src/lib/api/hooks/app/leadsync/useSyncLeadSyncSource.ts @@ -0,0 +1,15 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import syncSource from "@/lib/api/client/app/leadsync/syncSource"; + +// "Sync now". On success, refreshes the source list (so last_synced_at / +// last_result / status update) and contacts (new/updated rows landed). +export default function useSyncLeadSyncSource() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: syncSource, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["lead-sync", "sources"] }); + qc.invalidateQueries({ queryKey: ["contacts"] }); + }, + }); +} diff --git a/web/src/lib/api/hooks/app/leadsync/useUpdateLeadSyncSource.ts b/web/src/lib/api/hooks/app/leadsync/useUpdateLeadSyncSource.ts new file mode 100644 index 000000000..7ac3a6730 --- /dev/null +++ b/web/src/lib/api/hooks/app/leadsync/useUpdateLeadSyncSource.ts @@ -0,0 +1,15 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import updateSource from "@/lib/api/client/app/leadsync/updateSource"; +import type { UpdateLeadSyncSource } from "@/lib/api/models/app/leadsync/LeadSync"; + +// Edits a saved source and refreshes every source list. +export default function useUpdateLeadSyncSource() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: { id: string; body: UpdateLeadSyncSource }) => + updateSource(input.id, input.body), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["lead-sync", "sources"] }); + }, + }); +} diff --git a/web/src/lib/api/models/app/leadsync/LeadSync.ts b/web/src/lib/api/models/app/leadsync/LeadSync.ts new file mode 100644 index 000000000..a73722486 --- /dev/null +++ b/web/src/lib/api/models/app/leadsync/LeadSync.ts @@ -0,0 +1,116 @@ +// Mirror of the backend's models/lead_sync.go shapes. Field names match the +// JSON contract exactly. Lead-sync is an on-demand Google Sheet → contacts +// importer: a saved "sync source" the user re-runs via a "Sync now" button. +// +// We deliberately REUSE the contact-import column-mapping + result types from +// the import wizard rather than redeclaring them, so the same TargetPicker / +// MapStep mapper and result rendering work verbatim against /google/preview +// and /sources/:id/sync responses. + +import type { + ImportColumnMapping, + ImportDedupStrategy, + ImportPreview, + ImportResult, +} from "@/lib/api/client/app/contacts/importContacts"; + +// Re-export the reused contact-import types under lead-sync names so callers +// can import everything lead-sync from one module. +export type { + ImportColumnMapping, + ImportDedupStrategy, + ImportPreview, + ImportResult, +}; + +// LeadSyncStatus mirrors models.LeadSyncStatus. The synchronous SyncNow only +// ever lands on idle/error; "syncing" exists for completeness/future async use. +export type LeadSyncStatus = "idle" | "syncing" | "error"; + +// A saved on-demand Google-Sheet → contacts source. +export interface LeadSyncSource { + id: string; + organization_id: string; + created_by_user_id: string; + provider: string; + connection_id: string; + sheet_id: string; + sheet_title?: string; + tab_title?: string; + a1_range?: string; + has_header: boolean; + column_mapping: ImportColumnMapping[]; + dedup: ImportDedupStrategy; + target_campaign_id?: string; + category_ids: string[]; + subscribed_default: boolean; + label?: string; + status: LeadSyncStatus; + last_synced_at?: string; + last_result?: ImportResult; + last_error?: string; + created_at: string; + updated_at: string; +} + +// Result of POST /sources/:id/sync. +export interface LeadSyncResult { + source_id: string; + result: ImportResult; +} + +// Reports whether the org has a connected (hidden) google_sheets OAuth +// connection usable for lead-sync. +export interface LeadSyncConnection { + connected: boolean; + connection: { + id: string; + external_account_name: string; + status: string; + } | null; +} + +// One tab of a spreadsheet, from /google/spreadsheet. +export interface SheetTab { + title: string; + index: number; +} + +// Spreadsheet metadata returned by /google/spreadsheet — drives the tab picker. +export interface SheetMeta { + sheet_id: string; + title: string; + tabs: SheetTab[]; +} + +// Request body for creating a new source. +export interface CreateLeadSyncSource { + connection_id: string; + sheet_id: string; + sheet_title: string; + tab_title: string; + has_header: boolean; + column_mapping: ImportColumnMapping[]; + dedup: ImportDedupStrategy; + target_campaign_id?: string; + category_ids: string[]; + subscribed_default?: boolean; + label: string; +} + +// Request body for PATCH — all fields optional; omitted ⇒ unchanged. +// `clear_campaign: true` detaches the target campaign (a nil pointer alone +// can't express "clear"). +export interface UpdateLeadSyncSource { + sheet_id?: string; + sheet_title?: string; + tab_title?: string; + has_header?: boolean; + column_mapping?: ImportColumnMapping[]; + dedup?: ImportDedupStrategy; + target_campaign_id?: string; + clear_campaign?: boolean; + category_ids?: string[]; + subscribed_default?: boolean; + label?: string; +}