diff --git a/package-lock.json b/package-lock.json index 83f01130..0067e67e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "lodash": "4.17.21", "moment": "2.29.4", "npm-audit-resolver": "3.0.0-RC.0", + "papaparse": "5.4.1", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "5.3.4", @@ -52,6 +53,7 @@ "@types/jest": "29.5.4", "@types/jwt-decode": "2.2.1", "@types/lodash": "4.14.198", + "@types/papaparse": "5.3.8", "@types/react": "18.2.21", "@types/react-dom": "18.2.7", "@types/react-router-dom": "5.3.3", @@ -3539,11 +3541,20 @@ "dev": true }, "node_modules/@types/node": { - "version": "18.15.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.11.tgz", - "integrity": "sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==", + "version": "18.17.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.17.15.tgz", + "integrity": "sha512-2yrWpBk32tvV/JAd3HNHWuZn/VDN1P+72hWirHnvsvTGSqbANi+kSeuQR9yAHnbvaBvHDsoTdXV0Fe+iRtHLKA==", "dev": true }, + "node_modules/@types/papaparse": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.8.tgz", + "integrity": "sha512-ArKIEOOWULbhi53wkAiRy1ze4wvrTfhpAj7Yfzva+EkmX2sV8PpFB+xqzJfzXNzK4me95FJH9QZt5NXFVGzOoQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -8517,6 +8528,11 @@ "node": ">=6" } }, + "node_modules/papaparse": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz", + "integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw==" + }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", diff --git a/package.json b/package.json index b356e060..0fa7d546 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "lodash": "4.17.21", "moment": "2.29.4", "npm-audit-resolver": "3.0.0-RC.0", + "papaparse": "5.4.1", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "5.3.4", @@ -61,6 +62,7 @@ "@types/jest": "29.5.4", "@types/jwt-decode": "2.2.1", "@types/lodash": "4.14.198", + "@types/papaparse": "5.3.8", "@types/react": "18.2.21", "@types/react-dom": "18.2.7", "@types/react-router-dom": "5.3.3", diff --git a/src/graphql/analytics-overview.generated.tsx b/src/graphql/analytics-overview.generated.tsx index b3745fd8..5690eb78 100644 --- a/src/graphql/analytics-overview.generated.tsx +++ b/src/graphql/analytics-overview.generated.tsx @@ -37,7 +37,9 @@ export type CampaignWithEngagementsFragment = { endAt: any; pacingIndex?: number | null; format: Types.CampaignFormat; - adSets: Array<{ conversions?: Array<{ type: string }> | null }>; + adSets: Array<{ + conversions?: Array<{ type: string; extractExternalId: boolean }> | null; + }>; engagements?: Array<{ creativeinstanceid: string; createdat: any; @@ -78,7 +80,9 @@ export type AnalyticOverviewQuery = { endAt: any; pacingIndex?: number | null; format: Types.CampaignFormat; - adSets: Array<{ conversions?: Array<{ type: string }> | null }>; + adSets: Array<{ + conversions?: Array<{ type: string; extractExternalId: boolean }> | null; + }>; engagements?: Array<{ creativeinstanceid: string; createdat: any; @@ -158,6 +162,7 @@ export const CampaignWithEngagementsFragmentDoc = gql` adSets { conversions { type + extractExternalId } } engagements { diff --git a/src/graphql/analytics-overview.graphql b/src/graphql/analytics-overview.graphql index 45df71b0..45190b1a 100644 --- a/src/graphql/analytics-overview.graphql +++ b/src/graphql/analytics-overview.graphql @@ -36,6 +36,7 @@ fragment CampaignWithEngagements on Campaign { adSets { conversions { type + extractExternalId } } engagements { diff --git a/src/user/analytics/analyticsOverview/components/ReportUtils.tsx b/src/user/analytics/analyticsOverview/components/ReportUtils.tsx index 8f3d1327..be0fba72 100644 --- a/src/user/analytics/analyticsOverview/components/ReportUtils.tsx +++ b/src/user/analytics/analyticsOverview/components/ReportUtils.tsx @@ -1,17 +1,23 @@ import { Box } from "@mui/material"; -import { LoadingButton } from "@mui/lab"; -import SaveIcon from "@mui/icons-material/Save"; -import { useState } from "react"; -import { downloadCSV } from "../lib/csv.library"; import { DateRangePicker } from "components/Date/DateRangePicker"; -import { useUser } from "auth/hooks/queries/useUser"; import { DashboardButton } from "components/Button/DashboardButton"; import { CampaignFormat } from "graphql/types"; +import _ from "lodash"; +import { ReportMenu } from "user/reporting/ReportMenu"; interface DownloaderProps { startDate: Date | undefined; endDate: Date; - campaign: { id: string; name: string; format?: CampaignFormat }; + campaign: { + id: string; + name: string; + format?: CampaignFormat; + adSets?: + | { + conversions?: { type: string; extractExternalId: boolean }[] | null; + }[] + | null; + }; onSetDate: (val: Date, type: "start" | "end") => void; } @@ -21,8 +27,11 @@ export default function ReportUtils({ campaign, onSetDate, }: DownloaderProps) { - const [downloadingCSV, setDownloadingCSV] = useState(false); - const { userId } = useUser(); + const conversions = _.flatMap(campaign.adSets ?? [], "conversions"); + const maybeHasVerifiedConversions = _.some( + conversions ?? [], + (c) => c.extractExternalId, + ); return ( {startDate && ( onSetDate(d, "start")} onToChange={(d) => onSetDate(d, "end")} - > + /> )} - } - disabled={campaign.format === CampaignFormat.NtpSi} - onClick={() => - downloadCSV( - campaign.id, - campaign.name, - userId ?? "", - false, - setDownloadingCSV, - ) - } - > - Download Report - + {campaign.format !== CampaignFormat.NtpSi && ( + + )} ); diff --git a/src/user/analytics/analyticsOverview/lib/csv.library.ts b/src/user/analytics/analyticsOverview/lib/csv.library.ts deleted file mode 100644 index eac8adeb..00000000 --- a/src/user/analytics/analyticsOverview/lib/csv.library.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Dispatch } from "react"; -import axios from "axios"; - -export const downloadCSV = async ( - campaignId: string, - campaignName: string, - userId: string, - includeCountry: boolean, - setDownloadingCSV: Dispatch, -) => { - setDownloadingCSV(true); - - try { - const response = await axios( - `${ - import.meta.env.REACT_APP_SERVER_ADDRESS - }/report/campaign/csv/${campaignId}`, - { - withCredentials: true, - headers: { - "-x-user": userId, - "Content-Type": "text/csv", - }, - }, - ); - - const file = new Blob([response.data], { - type: "text/csv", - endings: "transparent", - }); - const fileURL = URL.createObjectURL(file); - const link = document.createElement("a"); - link.href = fileURL; - link.setAttribute("download", `${campaignName}.csv`); - document.body.appendChild(link); - link.click(); - } finally { - setDownloadingCSV(false); - } -}; diff --git a/src/user/analytics/analyticsOverview/reports/campaign/EngagementsOverview.tsx b/src/user/analytics/analyticsOverview/reports/campaign/EngagementsOverview.tsx index cc01ad52..987ba58d 100644 --- a/src/user/analytics/analyticsOverview/reports/campaign/EngagementsOverview.tsx +++ b/src/user/analytics/analyticsOverview/reports/campaign/EngagementsOverview.tsx @@ -82,7 +82,7 @@ export function EngagementsOverview({ const options = prepareChart(metrics, processedData); return ( - + { + const [dialogue, setDialogue] = useState(false); + const [isError, setIsError] = useState(false); + const set = (s: string) => + document.getElementById("private-key")?.setAttribute("value", s); + const { download, loading, error } = useDownloadCSV({ + onComplete() { + setAnchorEl(null); + setDialogue(false); + }, + onError() { + setIsError(true); + setDialogue(false); + }, + }); + + const [anchorEl, setAnchorEl] = useState(null); + const menu = Boolean(anchorEl); + return ( + <> + + + setAnchorEl(null)}> + download(campaignId, false)} + disabled={loading} + > + + + + Performance Report + + , + {hasVerifiedConversions && ( + setDialogue(true)} disabled={loading}> + + + + Verified Conversions Report + + )} + + + setIsError(false)} + anchorOrigin={{ vertical: "top", horizontal: "center" }} + > + setIsError(false)} + severity="error" + sx={{ width: "100%" }} + > + {error} + + + + setDialogue(false)}> + Decrypt Conversion Data? + + + To protect user’s privacy, verified Ad conversion data is + encrypted so that the identities of converted users remain anonymous + to Brave. You can decrypt the conversion data in the CSV file by + providing your private key here. If no key is provided, you will + receive the encrypted conversion data. Your private key will never + be sent to or stored on any Brave servers. + + set(e.target.value)} + autoFocus + margin="normal" + label="Private key" + fullWidth + variant="standard" + /> + + {loading && } + + + + + + + + ); +}; diff --git a/src/user/reporting/csv.library.tsx b/src/user/reporting/csv.library.tsx new file mode 100644 index 00000000..d6b55bec --- /dev/null +++ b/src/user/reporting/csv.library.tsx @@ -0,0 +1,131 @@ +import { useCallback, useState } from "react"; +import { buildAdServerEndpoint } from "util/environment"; +import Papa from "papaparse"; +import tweetnacl from "tweetnacl"; + +interface DownloadProps { + onComplete?: () => void; + onError?: () => void; +} + +export function useDownloadCSV(props: DownloadProps = {}) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + + const download = useCallback((campaignId: string, isVac: boolean) => { + setLoading(true); + setError(undefined); + + const baseUrl = `/report/campaign/csv/${campaignId}`; + fetch(buildAdServerEndpoint(isVac ? `${baseUrl}/vac` : baseUrl), { + method: "GET", + mode: "cors", + credentials: "include", + headers: { + "Content-Type": "text/csv", + }, + }) + .then((res) => { + if (res.status !== 200) { + const err = "Unable to download CSV"; + setError(err); + throw new Error(err); + } + + return res.blob(); + }) + .then((blob) => { + const file = new Blob([blob], { + type: "text/csv", + endings: "transparent", + }); + + if (isVac) { + return transformConversionEnvelope(file); + } + + return Promise.resolve(file); + }) + .then((file) => { + const fileURL = URL.createObjectURL(file); + const link = document.createElement("a"); + link.href = fileURL; + link.setAttribute("download", `${campaignId}.csv`); + document.body.appendChild(link); + link.click(); + if (props.onComplete) { + props.onComplete(); + } + }) + .catch((e) => { + setError(e.message); + }) + .finally(() => { + setLoading(false); + }); + }, []); + + return { download, loading, error }; +} + +type Envelope = { ciphertext: string; epk: string; nonce: string }; +async function transformConversionEnvelope(blob: Blob): Promise { + const ui8a = (s?: string | null) => + s ? Uint8Array.from(atob(s), (c) => c.charCodeAt(0)) : new Uint8Array(); + const privateKey = ui8a( + document.getElementById("private-key")?.getAttribute("value"), + ); + if (privateKey.byteLength === 0) { + return Promise.resolve(blob); + } + + const td = new TextDecoder(); + return await new Promise((resolve, reject) => { + const fileReader = new FileReader(); + fileReader.onload = () => { + const text = fileReader.result as string | null; + if (text === null) { + reject(new Error("No file Result")); + return; + } + + try { + Papa.parse(text, { + header: true, + escapeChar: "\\", + transform(value: string, field: string) { + if (field.includes("Conversion")) { + const { ciphertext, nonce, epk }: Envelope = JSON.parse(value); + const res = tweetnacl.box.open( + ui8a(ciphertext), + ui8a(nonce), + ui8a(epk), + privateKey, + ); + return res + ? td.decode(res.filter((v) => v !== 0x00)) + : "Data not valid for this private key"; + } + + return value; + }, + complete(results) { + const newCSV = Papa.unparse(results.data, { + skipEmptyLines: "greedy", + }); + privateKey.fill(0); + resolve(new Blob([newCSV])); + }, + error(error: Error) { + reject(error); + }, + }); + } catch (e) { + console.error(e); + reject(new Error("Unable to decrypt conversion data")); + } + }; + + fileReader.readAsText(blob); + }); +} diff --git a/src/user/settings/NewKeyPairModal.tsx b/src/user/settings/NewKeyPairModal.tsx index 17a51bb7..283fefb6 100644 --- a/src/user/settings/NewKeyPairModal.tsx +++ b/src/user/settings/NewKeyPairModal.tsx @@ -8,7 +8,7 @@ import { Typography, } from "@mui/material"; import { useUpdateAdvertiserMutation } from "graphql/advertiser.generated"; -import * as tweetnacl from "tweetnacl"; +import tweetnacl from "tweetnacl"; import { useRef, useState } from "react"; import { IAdvertiser } from "auth/context/auth.interface"; import { CardContainer } from "components/Card/CardContainer"; diff --git a/src/user/views/user/CampaignReportView.tsx b/src/user/views/user/CampaignReportView.tsx index a6487c62..670d8454 100644 --- a/src/user/views/user/CampaignReportView.tsx +++ b/src/user/views/user/CampaignReportView.tsx @@ -67,6 +67,7 @@ export function CampaignReportView() { id: params.campaignId, name: campaign?.name ?? "", format: campaign?.format, + adSets: campaign?.adSets, }} onSetDate={setDateRange} />