Skip to content

Commit f177424

Browse files
committed
feat(PDF Link Export): added new endpoint, refactored CSV export UI
1 parent e7d6326 commit f177424

4 files changed

Lines changed: 350 additions & 38 deletions

File tree

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { event, graphqlQuery } from "#src/utils";
2+
3+
const BATCH_SIZE = 1000;
4+
5+
const csvEscape = (val: any) => {
6+
const str = val === null || val === undefined ? "" : String(val);
7+
return `"${str.replace(/"/g, '""')}"`;
8+
};
9+
10+
const extractHref = (content: string): string => {
11+
const match = content?.match(/href=["']([^"']+)["']/i);
12+
return match ? match[1] : "";
13+
};
14+
15+
export const exportAuditTablePdfSourceLinks = async () => {
16+
const auditId = (event.queryStringParameters as any).id;
17+
18+
const scanQuery = {
19+
query: `query ($audit_id: uuid!) {
20+
audits_by_pk(id: $audit_id) {
21+
name
22+
scans(order_by: {created_at: desc}, limit: 1) {
23+
id
24+
}
25+
}
26+
}`,
27+
variables: { audit_id: auditId },
28+
};
29+
const scanResp = await graphqlQuery(scanQuery);
30+
const auditName = scanResp.audits_by_pk?.name;
31+
const latestScanId = scanResp.audits_by_pk?.scans?.[0]?.id;
32+
33+
const datePart = new Date().toISOString().split("T")[0];
34+
const safeName = auditName ? auditName.replace(/[^a-z0-9-_]/gi, "_") + "-" : "";
35+
const filename = `pdf-links-${safeName}${auditId}-${datePart}.csv`;
36+
37+
const emptyResponse = {
38+
statusCode: 200,
39+
headers: {
40+
"content-type": "text/csv; charset=utf-8",
41+
"content-disposition": `attachment; filename="${filename}"`,
42+
},
43+
body: "Source URL,PDF Link\n",
44+
};
45+
46+
if (!latestScanId) return emptyResponse;
47+
48+
const scopedWhere = {
49+
_and: [
50+
{ scan_id: { _eq: latestScanId } },
51+
{ url: { type: { _eq: "html" } } },
52+
{ blocker_messages: { message: { category: { _eq: "pdf-link" } } } },
53+
],
54+
};
55+
56+
const rows: string[] = [];
57+
let offset = 0;
58+
while (true) {
59+
const batchQuery = {
60+
query: `query ($limit: Int!, $offset: Int!, $where: blockers_bool_exp!) {
61+
blockers(where: $where, limit: $limit, offset: $offset) {
62+
content
63+
url { url }
64+
}
65+
}`,
66+
variables: { limit: BATCH_SIZE, offset, where: scopedWhere },
67+
};
68+
const batchResp = await graphqlQuery(batchQuery);
69+
const batch: any[] = batchResp.blockers || [];
70+
71+
for (const blocker of batch) {
72+
const sourceUrl = blocker.url?.url || "";
73+
const pdfLink = extractHref(blocker.content);
74+
rows.push([csvEscape(sourceUrl), csvEscape(pdfLink)].join(","));
75+
}
76+
77+
if (batch.length < BATCH_SIZE) break;
78+
offset += BATCH_SIZE;
79+
}
80+
81+
const csv = ["Source URL,PDF Link", ...rows].join("\n");
82+
83+
return {
84+
statusCode: 200,
85+
headers: {
86+
"content-type": "text/csv; charset=utf-8",
87+
"content-disposition": `attachment; filename="${filename}"`,
88+
},
89+
body: csv,
90+
};
91+
};

apps/backend/routes/auth/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export * from './updateAudit'
1111
export * from './getAuditChart'
1212
export * from './getAuditTable'
1313
export * from './exportAuditTable'
14+
export * from './exportAuditTablePdfSourceLinks'
1415
export * from './getLogs'
1516
export * from './inviteUser'
1617
export * from './getAuditSummary'

apps/frontend/src/components/BlockersTable.module.scss

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,114 @@
11
@use "../global-styles/variables.module.scss";
22
@use "../global-styles/fonts.scss";
33

4+
// Export dropdown trigger — sits inside .BlockersTable DOM, scoped normally
5+
.export-trigger {
6+
background: transparent;
7+
border: 0;
8+
color: variables.$red;
9+
opacity: 0.6;
10+
cursor: pointer;
11+
display: flex;
12+
align-items: center;
13+
gap: 2px;
14+
padding: 0;
15+
16+
svg {
17+
min-width: 14px;
18+
min-height: 14px;
19+
}
20+
21+
&:hover:not(:disabled) {
22+
opacity: 1;
23+
}
24+
&[data-state="open"] {
25+
opacity: 1;
26+
}
27+
&:disabled {
28+
cursor: default;
29+
opacity: 0.35;
30+
}
31+
}
32+
33+
.export-trigger-caret {
34+
font-size: 10px;
35+
margin-top: 1px;
36+
}
37+
38+
.export-trigger-spinner {
39+
display: inline-block;
40+
width: 14px;
41+
height: 14px;
42+
border: 2px solid currentColor;
43+
border-top-color: transparent;
44+
border-radius: 50%;
45+
animation: export-spin 0.8s linear infinite;
46+
}
47+
48+
@keyframes export-spin {
49+
to { transform: rotate(360deg); }
50+
}
51+
52+
// Export dropdown content — renders in a portal, so NOT nested inside .BlockersTable
53+
.export-dropdown-content {
54+
min-width: 280px;
55+
background: variables.$white;
56+
border: 1px solid variables.$gray;
57+
border-radius: calc(variables.$spacing / 2);
58+
box-shadow: variables.$shadow-small;
59+
padding: calc(variables.$spacing / 2);
60+
z-index: 50;
61+
animation: dropdown-fade-in 0.1s ease-out;
62+
}
63+
64+
@keyframes dropdown-fade-in {
65+
from { opacity: 0; transform: translateY(-4px); }
66+
to { opacity: 1; transform: translateY(0); }
67+
}
68+
69+
.export-dropdown-item {
70+
display: flex;
71+
align-items: flex-start;
72+
gap: variables.$spacing;
73+
padding: variables.$spacing calc(variables.$spacing * 1.5);
74+
border-radius: calc(variables.$spacing / 2);
75+
cursor: pointer;
76+
outline: none;
77+
78+
svg {
79+
margin-top: 3px;
80+
flex-shrink: 0;
81+
color: variables.$red;
82+
}
83+
84+
&[data-highlighted] {
85+
background: variables.$gray;
86+
}
87+
&[data-disabled] {
88+
opacity: 0.45;
89+
cursor: default;
90+
}
91+
}
92+
93+
.export-dropdown-item-label {
94+
@include fonts.font-size-normal;
95+
font-weight: bold;
96+
color: variables.$black;
97+
}
98+
99+
.export-dropdown-item-desc {
100+
@include fonts.font-size-small;
101+
color: variables.$black;
102+
opacity: 0.65;
103+
margin-top: 1px;
104+
}
105+
106+
.export-dropdown-separator {
107+
height: 1px;
108+
background: variables.$gray;
109+
margin: calc(variables.$spacing / 2) 0;
110+
}
111+
4112
.BlockersTable {
5113
.table-top-buttons {
6114
display: flex;
@@ -130,4 +238,34 @@
130238
.tooltip-rondel {
131239
background: variables.$dark-border;
132240
}
241+
242+
.export-trigger {
243+
color: variables.$yellow;
244+
}
245+
246+
.export-dropdown-content {
247+
background: variables.$dark-surface;
248+
border-color: variables.$dark-border;
249+
}
250+
251+
.export-dropdown-item {
252+
svg {
253+
color: variables.$yellow;
254+
}
255+
&[data-highlighted] {
256+
background: variables.$dark-border;
257+
}
258+
}
259+
260+
.export-dropdown-item-label {
261+
color: variables.$paper;
262+
}
263+
264+
.export-dropdown-item-desc {
265+
color: variables.$paper;
266+
}
267+
268+
.export-dropdown-separator {
269+
background: variables.$dark-border;
270+
}
133271
}

0 commit comments

Comments
 (0)