From 32ffae7820b9d26b8f4fbf817ad5ae208503185d Mon Sep 17 00:00:00 2001 From: Joaquim d'Souza Date: Thu, 19 Feb 2026 12:02:49 +0100 Subject: [PATCH 1/2] feat: re-enable tagging records in the user's CRM and send email when complete --- bin/cmd.ts | 4 +- .../map/[id]/components/table/MapTable.tsx | 16 ++--- src/features.ts | 12 +++- ...forgot-password.tsx => ForgotPassword.tsx} | 2 +- src/server/emails/{invite.tsx => Invite.tsx} | 2 +- src/server/emails/{layout.tsx => Layout.tsx} | 0 src/server/emails/TaggingComplete.tsx | 50 +++++++++++++++ src/server/emails/TaggingFailed.tsx | 51 +++++++++++++++ src/server/jobs/tagDataSource.ts | 62 +++++++++++++++---- src/server/trpc/routers/auth.ts | 2 +- src/server/trpc/routers/invitation.ts | 2 +- src/server/trpc/routers/mapView.ts | 1 + 12 files changed, 177 insertions(+), 27 deletions(-) rename src/server/emails/{forgot-password.tsx => ForgotPassword.tsx} (97%) rename src/server/emails/{invite.tsx => Invite.tsx} (98%) rename src/server/emails/{layout.tsx => Layout.tsx} (100%) create mode 100644 src/server/emails/TaggingComplete.tsx create mode 100644 src/server/emails/TaggingFailed.tsx diff --git a/bin/cmd.ts b/bin/cmd.ts index e87de8ae..66ba2feb 100755 --- a/bin/cmd.ts +++ b/bin/cmd.ts @@ -5,7 +5,7 @@ import importAreaSet from "@/server/commands/importAreaSet"; import importPostcodes from "@/server/commands/importPostcodes"; import regeocode from "@/server/commands/regeocode"; import removeDevWebhooks from "@/server/commands/removeDevWebhooks"; -import Invite from "@/server/emails/invite"; +import Invite from "@/server/emails/Invite"; import enrichDataSource from "@/server/jobs/enrichDataSource"; import importDataSource from "@/server/jobs/importDataSource"; import { createInvitation } from "@/server/repositories/Invitation"; @@ -140,7 +140,7 @@ program const orgs = await findOrganisationsByUserId(user.id); if (!orgs.length) { - logger.warning(`No organisation found for user ${user.email}`); + logger.warn(`No organisation found for user ${user.email}`); continue; } diff --git a/src/app/map/[id]/components/table/MapTable.tsx b/src/app/map/[id]/components/table/MapTable.tsx index dab7445f..2e2d252b 100644 --- a/src/app/map/[id]/components/table/MapTable.tsx +++ b/src/app/map/[id]/components/table/MapTable.tsx @@ -7,6 +7,7 @@ import { useDataSources } from "@/app/map/[id]/hooks/useDataSources"; import { useInspector } from "@/app/map/[id]/hooks/useInspector"; import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; import { useTable } from "@/app/map/[id]/hooks/useTable"; +import { DataSourceFeatures } from "@/features"; import { useFeatureFlagEnabled } from "@/hooks"; import { DataSourceTypeLabels } from "@/labels"; import { FilterType } from "@/server/models/MapView"; @@ -30,7 +31,6 @@ export default function MapTable() { const { view, updateView } = useMapViews(); const { getDataSourceById } = useDataSources(); const { focusedRecord, setFocusedRecord } = useInspector(); - const enableSyncToCRM = useFeatureFlagEnabled("sync-to-crm"); const [lookingUpPage, setLookingUpPage] = useState(false); const { @@ -53,7 +53,9 @@ export default function MapTable() { const { mutate: tagRecords } = useMutation( trpc.mapView.tagRecordsWithViewName.mutationOptions({ onSuccess: () => { - toast.success("Tagging records in the background"); + toast.success( + "Tagging records in the background - we will email you when the process completes.", + ); }, onError: () => { toast.error("Failed to tag records with view name"); @@ -136,13 +138,13 @@ export default function MapTable() { view, ]); - if (!selectedDataSourceId || !view) { - return null; - } - const dataSource = getDataSourceById(selectedDataSourceId); + const enableSyncToCRM = + useFeatureFlagEnabled("sync-to-crm") && + dataSource && + DataSourceFeatures[dataSource.config.type].syncToCrm; - if (!dataSource) { + if (!dataSource || !view) { return null; } diff --git a/src/features.ts b/src/features.ts index bff7fa9e..db9cf88c 100644 --- a/src/features.ts +++ b/src/features.ts @@ -2,31 +2,41 @@ import { DataSourceType } from "./server/models/DataSource"; export const DataSourceFeatures: Record< DataSourceType, - { autoEnrich: boolean; autoImport: boolean; enrichment: boolean } + { + autoEnrich: boolean; + autoImport: boolean; + enrichment: boolean; + syncToCrm: boolean; + } > = { [DataSourceType.ActionNetwork]: { autoEnrich: true, autoImport: true, enrichment: false, + syncToCrm: true, }, [DataSourceType.Airtable]: { autoEnrich: true, autoImport: true, enrichment: false, + syncToCrm: true, }, [DataSourceType.CSV]: { autoEnrich: false, autoImport: false, enrichment: false, + syncToCrm: false, }, [DataSourceType.GoogleSheets]: { autoEnrich: true, autoImport: true, enrichment: false, + syncToCrm: true, }, [DataSourceType.Mailchimp]: { autoEnrich: true, autoImport: true, enrichment: false, + syncToCrm: true, }, }; diff --git a/src/server/emails/forgot-password.tsx b/src/server/emails/ForgotPassword.tsx similarity index 97% rename from src/server/emails/forgot-password.tsx rename to src/server/emails/ForgotPassword.tsx index b26fa81a..7a7811a6 100644 --- a/src/server/emails/forgot-password.tsx +++ b/src/server/emails/ForgotPassword.tsx @@ -9,7 +9,7 @@ import { } from "@react-email/components"; import * as React from "react"; import { getAbsoluteUrl } from "@/utils/appUrl"; -import { EmailLayout } from "./layout"; +import { EmailLayout } from "./Layout"; export default function ForgotPassword({ token = "123" }: { token: string }) { const baseUrl = getAbsoluteUrl(); diff --git a/src/server/emails/invite.tsx b/src/server/emails/Invite.tsx similarity index 98% rename from src/server/emails/invite.tsx rename to src/server/emails/Invite.tsx index 4d23a7ec..e0ed1e1c 100644 --- a/src/server/emails/invite.tsx +++ b/src/server/emails/Invite.tsx @@ -9,7 +9,7 @@ import { } from "@react-email/components"; import * as React from "react"; import { getAbsoluteUrl } from "@/utils/appUrl"; -import { EmailLayout } from "./layout"; +import { EmailLayout } from "./Layout"; export default function Invite({ token }: { token: string }) { const baseUrl = getAbsoluteUrl(); diff --git a/src/server/emails/layout.tsx b/src/server/emails/Layout.tsx similarity index 100% rename from src/server/emails/layout.tsx rename to src/server/emails/Layout.tsx diff --git a/src/server/emails/TaggingComplete.tsx b/src/server/emails/TaggingComplete.tsx new file mode 100644 index 00000000..eff6a5fb --- /dev/null +++ b/src/server/emails/TaggingComplete.tsx @@ -0,0 +1,50 @@ +import { + Container, + Img, + Preview, + Section, + Text, +} from "@react-email/components"; +import * as React from "react"; +import { getAbsoluteUrl } from "@/utils/appUrl"; +import { EmailLayout } from "./Layout"; + +export default function TaggingComplete({ + dataSourceName, + viewName, +}: { + dataSourceName: string; + viewName: string; +}) { + const baseUrl = getAbsoluteUrl(); + + return ( + + Tagging complete for {dataSourceName} + +
+ Mapped logo +
+ + + Hello, + + + + The tagging job for data source {dataSourceName} with + view {viewName} has completed successfully. + + + + All records have been tagged accordingly. + +
+
+ ); +} diff --git a/src/server/emails/TaggingFailed.tsx b/src/server/emails/TaggingFailed.tsx new file mode 100644 index 00000000..fe63952d --- /dev/null +++ b/src/server/emails/TaggingFailed.tsx @@ -0,0 +1,51 @@ +import { + Container, + Img, + Preview, + Section, + Text, +} from "@react-email/components"; +import * as React from "react"; + +import { getAbsoluteUrl } from "@/utils/appUrl"; + +import { EmailLayout } from "./Layout"; + +export default function TaggingFailed({ + dataSourceName, + viewName, + reason, +}: { + dataSourceName: string; + viewName: string; + reason: string; +}) { + const baseUrl = getAbsoluteUrl(); + + return ( + + Tagging failed for {dataSourceName} + +
+ Mapped logo +
+ + Hello, + + + The tagging job for data source {dataSourceName} with + view {viewName} has failed. + + + Reason: {reason} + +
+
+ ); +} diff --git a/src/server/jobs/tagDataSource.ts b/src/server/jobs/tagDataSource.ts index b4fe8d37..b094b38e 100644 --- a/src/server/jobs/tagDataSource.ts +++ b/src/server/jobs/tagDataSource.ts @@ -1,5 +1,7 @@ import { DATA_SOURCE_JOB_BATCH_SIZE } from "@/constants"; import { getDataSourceAdaptor } from "@/server/adaptors"; +import TaggingComplete from "@/server/emails/TaggingComplete"; +import TaggingFailed from "@/server/emails/TaggingFailed"; import { countDataRecordsForDataSource, streamDataRecordsByDataSource, @@ -7,32 +9,58 @@ import { import { findDataSourceById } from "@/server/repositories/DataSource"; import { findMapViewById } from "@/server/repositories/MapView"; import logger from "@/server/services/logger"; +import { sendEmail } from "@/server/services/mailer"; import { batchAsync } from "@/server/utils"; import { findMapById } from "../repositories/Map"; import type { TaggedRecord } from "@/types"; +const sendFailureEmail = async ( + userEmail: string, + dataSourceName: string, + viewName: string, + reason: string, +) => { + await sendEmail( + userEmail, + "Tagging failed", + TaggingFailed({ dataSourceName, viewName, reason }), + ); +}; + const tagDataSource = async (args: object | null): Promise => { - if (!args || !("dataSourceId" in args) || !("viewId" in args)) { + if ( + !args || + !("dataSourceId" in args) || + !("viewId" in args) || + !("userEmail" in args) + ) { return false; } const dataSourceId = String(args.dataSourceId); const viewId = String(args.viewId); + const userEmail = String(args.userEmail); const dataSource = await findDataSourceById(dataSourceId); if (!dataSource) { - logger.info(`Data source ${dataSourceId} not found.`); + const reason = `Failed to tag data source: ${dataSourceId} not found.`; + logger.warn(reason); + await sendFailureEmail(userEmail, dataSourceId, viewId, reason); return false; } const view = await findMapViewById(viewId); if (!view) { - logger.info(`View ${viewId} not found.`); + const reason = `View ${viewId} not found.`; + logger.warn(`Failed to tag data source ${dataSourceId}: ${reason}`); + await sendFailureEmail(userEmail, dataSource.name, viewId, reason); return false; } const map = await findMapById(view.mapId); if (!map) { - logger.info(`Map ${view.mapId} not found.`); + const reason = `Map ${view.mapId} not found.`; + logger.warn(`Failed to tag data source ${dataSourceId}: ${reason}`); + await sendFailureEmail(userEmail, dataSource.name, view.name, reason); return false; } @@ -46,9 +74,9 @@ const tagDataSource = async (args: object | null): Promise => { const adaptor = getDataSourceAdaptor(dataSource); if (!adaptor) { - logger.error( - `Could not get data source adaptor for source ${dataSourceId}, type ${dataSource.config.type}`, - ); + const reason = `Could not get data source adaptor for source ${dataSourceId}, type ${dataSource.config.type}`; + logger.error(reason); + await sendFailureEmail(userEmail, dataSource.name, view.name, reason); return false; } @@ -90,15 +118,23 @@ const tagDataSource = async (args: object | null): Promise => { logger.info( `Tagged data source ${dataSourceId} with view ${view.name} (${view.id})`, ); + + await sendEmail( + userEmail, + "Tagging complete", + TaggingComplete({ + dataSourceName: dataSource.name, + viewName: view.name, + }), + ); + return true; } catch (error) { - logger.error( - `Failed to tag records for ${dataSource.config.type} ${dataSourceId}`, - { error }, - ); + const reason = `Failed to tag records for ${dataSource.config.type} ${dataSourceId}`; + logger.error(reason, { error }); + await sendFailureEmail(userEmail, dataSource.name, view.name, reason); + throw error; } - - return false; }; export default tagDataSource; diff --git a/src/server/trpc/routers/auth.ts b/src/server/trpc/routers/auth.ts index 220e01c1..0d654402 100644 --- a/src/server/trpc/routers/auth.ts +++ b/src/server/trpc/routers/auth.ts @@ -5,7 +5,7 @@ import { NoResultError } from "kysely"; import { cookies } from "next/headers"; import z from "zod"; import ensureOrganisationMap from "@/server/commands/ensureOrganisationMap"; -import ForgotPassword from "@/server/emails/forgot-password"; +import ForgotPassword from "@/server/emails/ForgotPassword"; import { findAndUseInvitation, updateInvitation, diff --git a/src/server/trpc/routers/invitation.ts b/src/server/trpc/routers/invitation.ts index 71aefda8..1db9c1bd 100644 --- a/src/server/trpc/routers/invitation.ts +++ b/src/server/trpc/routers/invitation.ts @@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server"; import { SignJWT } from "jose"; import z from "zod"; import ensureOrganisationMap from "@/server/commands/ensureOrganisationMap"; -import Invite from "@/server/emails/invite"; +import Invite from "@/server/emails/Invite"; import { createInvitation, listPendingInvitations, diff --git a/src/server/trpc/routers/mapView.ts b/src/server/trpc/routers/mapView.ts index b8a1149a..44f8bad9 100644 --- a/src/server/trpc/routers/mapView.ts +++ b/src/server/trpc/routers/mapView.ts @@ -16,6 +16,7 @@ export const mapViewRouter = router({ await enqueue("tagDataSource", ctx.dataSource.id, { dataSourceId: ctx.dataSource.id, viewId: input.viewId, + userEmail: ctx.user.email, }); return true; }), From e647d413c2e6c7a861069f16da8c96b02ed0971b Mon Sep 17 00:00:00 2001 From: Joaquim d'Souza Date: Thu, 19 Feb 2026 12:26:02 +0100 Subject: [PATCH 2/2] fix: wrap sendEmail calls in try/catch in tagDataSource job --- src/server/jobs/tagDataSource.ts | 34 ++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/server/jobs/tagDataSource.ts b/src/server/jobs/tagDataSource.ts index b094b38e..b69d3e0c 100644 --- a/src/server/jobs/tagDataSource.ts +++ b/src/server/jobs/tagDataSource.ts @@ -20,11 +20,15 @@ const sendFailureEmail = async ( viewName: string, reason: string, ) => { - await sendEmail( - userEmail, - "Tagging failed", - TaggingFailed({ dataSourceName, viewName, reason }), - ); + try { + await sendEmail( + userEmail, + "Tagging failed", + TaggingFailed({ dataSourceName, viewName, reason }), + ); + } catch (error) { + logger.error("Failed to send tagging failure email", { error }); + } }; const tagDataSource = async (args: object | null): Promise => { @@ -119,14 +123,18 @@ const tagDataSource = async (args: object | null): Promise => { `Tagged data source ${dataSourceId} with view ${view.name} (${view.id})`, ); - await sendEmail( - userEmail, - "Tagging complete", - TaggingComplete({ - dataSourceName: dataSource.name, - viewName: view.name, - }), - ); + try { + await sendEmail( + userEmail, + "Tagging complete", + TaggingComplete({ + dataSourceName: dataSource.name, + viewName: view.name, + }), + ); + } catch (error) { + logger.error("Failed to send tagging success email", { error }); + } return true; } catch (error) {