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..b69d3e0c 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,62 @@ 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, +) => { + 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 => { - 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 +78,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 +122,27 @@ const tagDataSource = async (args: object | null): Promise => { logger.info( `Tagged data source ${dataSourceId} with view ${view.name} (${view.id})`, ); + + 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) { - 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; }),