Skip to content

Commit

Permalink
feat: Download bulk results as CSV (#479)
Browse files Browse the repository at this point in the history
* Better error

* Make csv download work
  • Loading branch information
amaury1093 committed Dec 28, 2023
1 parent 43445eb commit 3d679a0
Show file tree
Hide file tree
Showing 7 changed files with 240 additions and 94 deletions.
139 changes: 139 additions & 0 deletions src/app/api/v1/bulk/[jobId]/download/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { supabaseAdmin } from "@/util/supabaseServer";
import { sentryException } from "@/util/sentry";
import { checkUserInDB, isEarlyResponse } from "@/util/api";
import { Database } from "@/supabase/database.types";
import { CheckEmailOutput } from "@reacherhq/api";
import { components } from "@reacherhq/api/lib/types";
import { NextRequest } from "next/server";

type ArrayElement<ArrayType extends readonly unknown[]> =
ArrayType extends readonly (infer ElementType)[] ? ElementType : never;
type SmtpD = components["schemas"]["SmtpDetails"];
type MiscD = components["schemas"]["MiscDetails"];
type MxD = components["schemas"]["MxDetails"];
type ReacherError = components["schemas"]["Error"];

export const GET = async (
req: NextRequest,
{
params,
}: {
params: { jobId: string };
}
): Promise<Response> => {
// TODO Remove this once we allow Bulk.
if (process.env.VERCEL_ENV === "production") {
return Response.json(
{ error: "Not available in production" },
{ status: 403 }
);
}

try {
const { user } = await checkUserInDB(req);
if (!user) {
return Response.json(
{
error: "User not found",
},
{
status: 401,
}
);
}

const { jobId } = params;

const res = await supabaseAdmin
.rpc<
ArrayElement<
Database["public"]["Functions"]["get_bulk_results"]["Returns"]
>
>("get_bulk_results", { id_param: jobId })
.select("*");
if (res.error) {
return Response.json(
{
error: res.error.message,
},
res
);
}

const rows = res.data.map((row) => {
const result = row.result as CheckEmailOutput;
result.misc;
return {
reacher_email_id: row.id,
email: row.email,
is_reachable: row.is_reachable,
// Smtp
["smtp.can_connect_smtp"]: (result.smtp as SmtpD)
?.can_connect_smtp,
["smtp.has_full_inbox"]: (result.smtp as SmtpD)?.has_full_inbox,
["smtp.is_catch_all"]: (result.smtp as SmtpD)?.is_catch_all,
["smtp.is_deliverable"]: (result.smtp as SmtpD)?.is_deliverable,
["smtp.is_disabled"]: (result.smtp as SmtpD).is_disabled,
["smtp.error"]: formatCsvError(result.smtp),
// Misc
["misc.is_disposable"]: (result.misc as MiscD)?.is_disposable,
["misc.is_role_account"]: (result.misc as MiscD)
?.is_role_account,
["misc.gravatar_url"]: (result.misc as MiscD)?.gravatar_url,
["misc.error"]: formatCsvError(result.misc),
// Mx
["mx.accepts_mail"]: (result.mx as MxD)?.accepts_mail,
["mx.records"]: (result.mx as MxD)?.records,
["mx.error"]: formatCsvError(result.mx),
// Syntax
["syntax.is_valid_syntax"]: result.syntax.is_valid_syntax,
["syntax.domain"]: result.syntax.domain,
["syntax.username"]: result.syntax.username,
};
});

// Convert to CSV
const header = Object.keys(rows[0]);
const csv = [
header.join(","), // header row first
...rows.map((row) =>
header
.map((fieldName) =>
JSON.stringify(row[fieldName as keyof typeof row])
)
.join(",")
),
].join("\r\n");

return new Response(csv, {
headers: {
"Content-Type": "text/csv",
"Content-Disposition": `attachment; filename="bulkjob_${jobId}_results.csv"`,
},
});
} catch (err) {
if (isEarlyResponse(err)) {
return err.response;
}

sentryException(err as Error);
return Response.json(
{
error: (err as Error).message,
},
{
status: 500,
}
);
}
};

function formatCsvError(err: unknown): string | null {
if ((err as ReacherError)?.type && (err as ReacherError)?.message) {
return `${(err as ReacherError).type}: ${
(err as ReacherError).message
}`;
}

return null;
}
57 changes: 0 additions & 57 deletions src/app/api/v1/bulk/[jobId]/route.ts

This file was deleted.

32 changes: 23 additions & 9 deletions src/components/Dashboard/BulkHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ export function alertError(
}

export function BulkHistory(): React.ReactElement {
const [bulkJobs, setBulkJobs] = useState<Tables<"bulk_jobs_info">[]>([]);
const [bulkJobs, setBulkJobs] = useState<
Tables<"bulk_jobs_info">[] | undefined
>();
const router = useRouter();
const d = dictionary(router.locale).dashboard.get_started_bulk.history;

Expand Down Expand Up @@ -54,21 +56,33 @@ export function BulkHistory(): React.ReactElement {
);
};

const renderDownloadCsv: TableColumnRender<
Tables<"bulk_jobs_info">
> = () => {
const renderDownloadCsv: TableColumnRender<Tables<"bulk_jobs_info">> = (
_value,
rowData
) => {
return (
<Button className="m-auto" auto icon={<Download />}>
{d.button_download}
</Button>
<div className="m-auto">
{rowData.verified === rowData.number_of_emails ? (
<a
href={`/api/v1/bulk/${rowData.bulk_job_id}/download`}
download={`bulkjob_${rowData.bulk_job_id}_results.csv`}
>
<Button className="m-auto" auto icon={<Download />}>
{d.button_download}
</Button>
</a>
) : (
<em>{d.table.not_available}</em>
)}
</div>
);
};

return (
<Card>
<Text h3>{d.title}</Text>
<Text h3>{bulkJobs === undefined ? d.title_loading : d.title}</Text>
<Spacer />
<Table data={bulkJobs}>
<Table data={bulkJobs || []}>
<Table.Column prop="bulk_job_id" label={d.table.job_id} />
<Table.Column
prop="verified"
Expand Down
78 changes: 54 additions & 24 deletions src/components/Dashboard/GetStartedBulk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,7 @@ import CheckInCircleFill from "@geist-ui/react-icons/checkInCircleFill";
import Upload from "@geist-ui/react-icons/upload";
import FileText from "@geist-ui/react-icons/fileText";
import { BulkHistory } from "./BulkHistory";

export function alertError(
e: string,
d: ReturnType<typeof dictionary>["dashboard"]["get_started_bulk"]
) {
alert(d.error.unexpected.replace("%s", e));
}
import XCircleFill from "@geist-ui/react-icons/xCircleFill";

export function GetStartedBulk(): React.ReactElement {
const { user, userDetails } = useUser();
Expand All @@ -43,32 +37,44 @@ export function GetStartedBulk(): React.ReactElement {
return;
}
if (acceptedFiles.length > 1) {
alertError(
d.error.one_file.replace(
"%s",
acceptedFiles.length.toString()
),
d
setUpload(
<Error
error={d.error.one_file.replace(
"%s",
acceptedFiles.length.toString()
)}
d={d}
/>
);
return;
}
const file = acceptedFiles[0];
setUpload(<Analyzing d={d} file={file} />);
const reader = new FileReader();
reader.onabort = () => {
alertError(d.error.aborted, d);
setUpload(<Error error={d.error.aborted} d={d} />);
};
reader.onerror = () => {
alertError(d.error.invalid_file, d);
setUpload(<Error error={d.error.invalid_file} d={d} />);
};
reader.onload = () => {
// Do whatever you want with the file contents
const binaryStr = reader.result?.toString();
if (typeof binaryStr !== "string") {
alertError(d.error.empty, d);
setUpload(<Error error={d.error.empty} d={d} />);
return;
}
const lines = binaryStr
.split("\n")
.map((s) => s.trim())
.filter((x) => !!x)
.map((l) => l.split(",")[0]);

if (!lines.length) {
setUpload(<Error error={d.error.no_emails} d={d} />);
return;
}
const lines = binaryStr.split("\n").map((l) => l.split(",")[0]);

// Optionally remove 1st line with headers.
if (
!lines[0].includes("@") ||
Expand All @@ -78,7 +84,7 @@ export function GetStartedBulk(): React.ReactElement {
}

if (!lines.length) {
alertError(d.error.no_emails, d);
setUpload(<Error error={d.error.no_emails} d={d} />);
return;
}

Expand All @@ -105,9 +111,13 @@ export function GetStartedBulk(): React.ReactElement {
return;
}
if (!userDetails) {
alertError(
`userDetails is undefined for user ${user?.id || "undefined"}`,
d
setUpload(
<Error
error={`userDetails is undefined for user ${
user?.id || "undefined"
}`}
d={d}
/>
);
return;
}
Expand All @@ -126,7 +136,7 @@ export function GetStartedBulk(): React.ReactElement {
})
.catch((err: Error) => {
sentryException(err);
alertError(err.message, d);
setUpload(<Error error={err.message} d={d} />);
})
.finally(() => {
setLoading(false);
Expand All @@ -145,10 +155,12 @@ export function GetStartedBulk(): React.ReactElement {
shadow={isDragActive}
width="400px"
>
<div {...getRootProps()}>
<Spacer h={2} />
<Card.Body {...getRootProps()}>
<input {...getInputProps()} />
{upload}
</div>
</Card.Body>

{!!emails.length && (
<div className="text-center">
<Spacer />
Expand All @@ -170,6 +182,7 @@ export function GetStartedBulk(): React.ReactElement {
</Button>
</div>
)}
<Spacer h={2} />
</Card>
<Spacer h={3} />
</Card>
Expand Down Expand Up @@ -263,3 +276,20 @@ function Uploaded({
</>
);
}

function Error({
error,
d,
}: {
error: string;
d: ReturnType<typeof dictionary>["dashboard"]["get_started_bulk"];
}) {
return (
<>
<XCircleFill color="red" />
<h4>{d.error.title}</h4>
<p>{error}</p>
<Button>{d.error.try_again}</Button>
</>
);
}
Loading

0 comments on commit 3d679a0

Please sign in to comment.