Skip to content

Commit 2388c3d

Browse files
Merge pull request #636 from EqualifyEverything/staging
feat(PDF Link Export): added new endpoint, refactored CSV export UI
2 parents e7d6326 + fafe53e commit 2388c3d

9 files changed

Lines changed: 3199 additions & 2433 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/getAuditTable.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ export const getAuditTable = async () => {
178178
created_at
179179
content
180180
url_id
181+
url_text
181182
url {
182183
url
183184
type
@@ -287,7 +288,9 @@ export const getAuditTable = async () => {
287288
short_id: blocker.short_id,
288289
content_hash_id: blocker.content_hash_id,
289290
created_at: blocker.created_at,
290-
url: blocker.url?.url || "Unknown URL",
291+
// Fallback chain: live join → snapshotted url_text (preserved at scan time) → Unknown URL.
292+
// This keeps the URL visible even if the urls row was later deleted (CSV change, manual removal).
293+
url: blocker.url?.url || blocker.url_text || "Unknown URL",
291294
type: blocker.url?.type || "unknown",
292295
url_id: blocker.url_id,
293296
content: blocker.content,

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/backend/routes/public/scanWebhook.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -204,12 +204,15 @@ export const scanWebhook = async () => {
204204
const params = [];
205205
let p = 1;
206206
for (const bd of prepared) {
207-
vals.push(`($${p}, $${p+1}, $${p+2}, $${p+3}, $${p+4}, $${p+5}, $${p+6}, $${p+7})`);
208-
params.push(auditId, JSON.stringify([]), bd.node, bd.contentNormalized, bd.contentHashId, bd.shortId, urlId, effectiveScanId);
209-
p += 8;
207+
vals.push(`($${p}, $${p+1}, $${p+2}, $${p+3}, $${p+4}, $${p+5}, $${p+6}, $${p+7}, $${p+8})`);
208+
// Snapshot the URL string as `url_text` so it survives URL row deletion
209+
// (e.g., CSV format change removing/replacing URLs). Falls back to NULL
210+
// if the webhook payload didn't include the url.
211+
params.push(auditId, JSON.stringify([]), bd.node, bd.contentNormalized, bd.contentHashId, bd.shortId, urlId, effectiveScanId, url ?? null);
212+
p += 9;
210213
}
211214
const result = await db.query({
212-
text: `INSERT INTO "blockers" ("audit_id", "targets", "content", "content_normalized", "content_hash_id", "short_id", "url_id", "scan_id") VALUES ${vals.join(', ')} RETURNING "id"`,
215+
text: `INSERT INTO "blockers" ("audit_id", "targets", "content", "content_normalized", "content_hash_id", "short_id", "url_id", "scan_id", "url_text") VALUES ${vals.join(', ')} RETURNING "id"`,
213216
values: params,
214217
});
215218
blockerIds = result.rows.map((r: any) => r.id);

apps/frontend/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,12 @@
5252
"@types/react": "^19.1.13",
5353
"@types/react-dom": "^19.1.9",
5454
"@types/react-syntax-highlighter": "^15.5.13",
55-
"@vitejs/plugin-react": "^5.0.3",
55+
"@vitejs/plugin-react": "^6.0.3",
5656
"autoprefixer": "^10.4.21",
5757
"sass-embedded": "^1.93.3",
5858
"typescript": "^5.9.2",
59-
"vite": "^7.1.6",
60-
"vite-plugin-pwa": "^1.1.0"
59+
"vite": "^8.1.0",
60+
"vite-plugin-pwa": "^1.3.0"
6161
},
6262
"imports": {
6363
"#src/*": "./src/*"

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)