feat: add lead sync frontend clients

This commit is contained in:
Matthew Meszaros
2026-06-04 11:50:09 +02:00
parent 6a4c370152
commit 7fe63a98eb
17 changed files with 341 additions and 0 deletions
@@ -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<LeadSyncSource> {
return await Request<LeadSyncSource>({
method: "POST",
url: "/lead-sync/sources",
data: input,
authorization: true,
});
}
@@ -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<void> {
await Request<void>({
method: "DELETE",
url: `/lead-sync/sources/${id}`,
authorization: true,
});
}
@@ -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<LeadSyncConnection> {
return await Request<LeadSyncConnection>({
method: "GET",
url: "/lead-sync/google/connection",
authorization: true,
});
}
@@ -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<LeadSyncSource> {
return await Request<LeadSyncSource>({
method: "GET",
url: `/lead-sync/sources/${id}`,
authorization: true,
});
}
@@ -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<SheetMeta> {
return await Request<SheetMeta>({
method: "POST",
url: "/lead-sync/google/spreadsheet",
data: input,
authorization: true,
});
}
@@ -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,
});
}
@@ -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<ImportPreview> {
return await Request<ImportPreview>({
method: "POST",
url: "/lead-sync/google/preview",
data: input,
authorization: true,
});
}
@@ -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<LeadSyncResult> {
return await Request<LeadSyncResult>({
method: "POST",
url: `/lead-sync/sources/${id}/sync`,
authorization: true,
});
}
@@ -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<LeadSyncSource> {
return await Request<LeadSyncSource>({
method: "PATCH",
url: `/lead-sync/sources/${id}`,
data: input,
authorization: true,
});
}
@@ -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"] });
},
});
}
@@ -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"] });
},
});
}
@@ -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,
});
}
@@ -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,
});
}
@@ -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 });
}
@@ -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"] });
},
});
}
@@ -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"] });
},
});
}
@@ -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;
}