Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bin/cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}

Expand Down
16 changes: 9 additions & 7 deletions src/app/map/[id]/components/table/MapTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Expand All @@ -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");
Expand Down Expand Up @@ -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;
}

Expand Down
12 changes: 11 additions & 1 deletion src/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The import path is being changed to use PascalCase 'Invite', but the actual file is still named 'invite.tsx' (lowercase). This import will fail on case-sensitive file systems. Either the file needs to be renamed from 'invite.tsx' to 'Invite.tsx', or the import should remain as '@/server/emails/invite'. Based on the codebase convention where other email component files use PascalCase names (ForgotPassword.tsx, TaggingComplete.tsx), the file should be renamed.

Copilot uses AI. Check for mistakes.

export default function Invite({ token }: { token: string }) {
const baseUrl = getAbsoluteUrl();
Expand Down
File renamed without changes.
50 changes: 50 additions & 0 deletions src/server/emails/TaggingComplete.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<EmailLayout>
<Preview>Tagging complete for {dataSourceName}</Preview>
<Container className="mx-auto my-[40px] max-w-[465px] rounded border border-[#eaeaea] border-solid p-[20px]">
<Section className="mt-[32px]">
<Img
alt="Mapped logo"
src={`${baseUrl}logo.png`}
width="40"
height="40"
className="mx-auto my-0"
/>
</Section>

<Text className="text-[14px] text-black font-sans leading-[24px]">
Hello,
</Text>

<Text className="text-[14px] text-black font-sans leading-[24px]">
The tagging job for data source <strong>{dataSourceName}</strong> with
view <strong>{viewName}</strong> has completed successfully.
</Text>

<Text className="text-[14px] text-black font-sans leading-[24px]">
All records have been tagged accordingly.
</Text>
</Container>
</EmailLayout>
);
}
51 changes: 51 additions & 0 deletions src/server/emails/TaggingFailed.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<EmailLayout>
<Preview>Tagging failed for {dataSourceName}</Preview>
<Container className="mx-auto my-[40px] max-w-[465px] rounded border border-[#eaeaea] border-solid p-[20px]">
<Section className="mt-[32px]">
<Img
alt="Mapped logo"
src={`${baseUrl}logo.png`}
width="40"
height="40"
className="mx-auto my-0"
/>
</Section>
<Text className="text-[14px] text-black font-sans leading-[24px]">
Hello,
</Text>
<Text className="text-[14px] text-black font-sans leading-[24px]">
The tagging job for data source <strong>{dataSourceName}</strong> with
view <strong>{viewName}</strong> has failed.
</Text>
<Text className="text-[14px] text-black font-sans leading-[24px]">
Reason: {reason}
</Text>
</Container>
</EmailLayout>
);
}
70 changes: 57 additions & 13 deletions src/server/jobs/tagDataSource.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,70 @@
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,
} from "@/server/repositories/DataRecord";
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<boolean> => {
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);
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The userEmail parameter is converted to a string without validating that it's a properly formatted email address. If an invalid email is passed, it could result in email sending failures. Consider adding email format validation similar to other email-handling code in the codebase (e.g., using z.string().email() validation).

Copilot uses AI. Check for mistakes.

const dataSource = await findDataSourceById(dataSourceId);
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the data source is not found, the email is sent with the dataSourceId as the dataSourceName parameter. This will display a UUID in the email instead of a user-friendly name. Consider using a placeholder like "Unknown Data Source" or formatting the ID more clearly as "Data Source (ID: ...)" to improve the user experience.

Copilot uses AI. Check for mistakes.
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);
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the data source is not found, the function passes dataSourceId (a UUID) as the dataSourceName parameter and viewId (a UUID) as the viewName parameter. This will result in the user receiving an email with technical IDs instead of human-readable names. Consider passing more user-friendly values or having a fallback message in the email template for unknown entities.

Suggested change
await sendFailureEmail(userEmail, dataSourceId, viewId, reason);
await sendFailureEmail(
userEmail,
"Unknown data source",
"Unknown view",
reason,
);

Copilot uses AI. Check for mistakes.
return false;
}

const view = await findMapViewById(viewId);
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the view is not found, the email is sent with the viewId as the viewName parameter. This will display a UUID in the email instead of a user-friendly name. Consider using a placeholder like "Unknown View" or formatting the ID more clearly as "View (ID: ...)" to improve the user experience.

Copilot uses AI. Check for mistakes.
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);
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the view is not found, the function passes viewId (a UUID) as the viewName parameter. This will result in the user receiving an email with a technical ID instead of a human-readable name. Consider passing a more user-friendly value or having a fallback message in the email template for unknown views.

Suggested change
await sendFailureEmail(userEmail, dataSource.name, viewId, reason);
await sendFailureEmail(userEmail, dataSource.name, "Unknown view", reason);

Copilot uses AI. Check for mistakes.
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;
}

Expand All @@ -46,9 +78,9 @@ const tagDataSource = async (args: object | null): Promise<boolean> => {

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);
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If sendFailureEmail throws an exception, this failure path will also fail. Consider wrapping the email operation in a try-catch block to ensure that failures to send email notifications don't prevent proper logging and return of the job failure status.

Copilot uses AI. Check for mistakes.
return false;
}

Expand Down Expand Up @@ -90,15 +122,27 @@ const tagDataSource = async (args: object | null): Promise<boolean> => {
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);
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If sendFailureEmail throws an exception here, it will mask the original error. Consider wrapping this email operation in a try-catch block and logging the email sending error separately, while still re-throwing the original error to maintain proper job failure handling.

Suggested change
await sendFailureEmail(userEmail, dataSource.name, view.name, reason);
try {
await sendFailureEmail(userEmail, dataSource.name, view.name, reason);
} catch (emailError) {
logger.error("Failed to send failure email for tagDataSource job", {
error: emailError,
});
}

Copilot uses AI. Check for mistakes.
throw error;
}

return false;
};

export default tagDataSource;
2 changes: 1 addition & 1 deletion src/server/trpc/routers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/server/trpc/routers/invitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/server/trpc/routers/mapView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}),
Expand Down