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
91 changes: 91 additions & 0 deletions apps/backend/routes/auth/exportAuditTablePdfSourceLinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { event, graphqlQuery } from "#src/utils";

const BATCH_SIZE = 1000;

const csvEscape = (val: any) => {
const str = val === null || val === undefined ? "" : String(val);
return `"${str.replace(/"/g, '""')}"`;
};

const extractHref = (content: string): string => {
const match = content?.match(/href=["']([^"']+)["']/i);
return match ? match[1] : "";
};

export const exportAuditTablePdfSourceLinks = async () => {
const auditId = (event.queryStringParameters as any).id;

const scanQuery = {
query: `query ($audit_id: uuid!) {
audits_by_pk(id: $audit_id) {
name
scans(order_by: {created_at: desc}, limit: 1) {
id
}
}
}`,
variables: { audit_id: auditId },
};
const scanResp = await graphqlQuery(scanQuery);
const auditName = scanResp.audits_by_pk?.name;
const latestScanId = scanResp.audits_by_pk?.scans?.[0]?.id;

const datePart = new Date().toISOString().split("T")[0];
const safeName = auditName ? auditName.replace(/[^a-z0-9-_]/gi, "_") + "-" : "";
const filename = `pdf-links-${safeName}${auditId}-${datePart}.csv`;

const emptyResponse = {
statusCode: 200,
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": `attachment; filename="${filename}"`,
},
body: "Source URL,PDF Link\n",
};

if (!latestScanId) return emptyResponse;

const scopedWhere = {
_and: [
{ scan_id: { _eq: latestScanId } },
{ url: { type: { _eq: "html" } } },
{ blocker_messages: { message: { category: { _eq: "pdf-link" } } } },
],
};

const rows: string[] = [];
let offset = 0;
while (true) {
const batchQuery = {
query: `query ($limit: Int!, $offset: Int!, $where: blockers_bool_exp!) {
blockers(where: $where, limit: $limit, offset: $offset) {
content
url { url }
}
}`,
variables: { limit: BATCH_SIZE, offset, where: scopedWhere },
};
const batchResp = await graphqlQuery(batchQuery);
const batch: any[] = batchResp.blockers || [];

for (const blocker of batch) {
const sourceUrl = blocker.url?.url || "";
const pdfLink = extractHref(blocker.content);
rows.push([csvEscape(sourceUrl), csvEscape(pdfLink)].join(","));
}

if (batch.length < BATCH_SIZE) break;
offset += BATCH_SIZE;
}

const csv = ["Source URL,PDF Link", ...rows].join("\n");

return {
statusCode: 200,
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": `attachment; filename="${filename}"`,
},
body: csv,
};
};
5 changes: 4 additions & 1 deletion apps/backend/routes/auth/getAuditTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ export const getAuditTable = async () => {
created_at
content
url_id
url_text
url {
url
type
Expand Down Expand Up @@ -287,7 +288,9 @@ export const getAuditTable = async () => {
short_id: blocker.short_id,
content_hash_id: blocker.content_hash_id,
created_at: blocker.created_at,
url: blocker.url?.url || "Unknown URL",
// Fallback chain: live join → snapshotted url_text (preserved at scan time) → Unknown URL.
// This keeps the URL visible even if the urls row was later deleted (CSV change, manual removal).
url: blocker.url?.url || blocker.url_text || "Unknown URL",
type: blocker.url?.type || "unknown",
url_id: blocker.url_id,
content: blocker.content,
Expand Down
1 change: 1 addition & 0 deletions apps/backend/routes/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export * from './updateAudit'
export * from './getAuditChart'
export * from './getAuditTable'
export * from './exportAuditTable'
export * from './exportAuditTablePdfSourceLinks'
export * from './getLogs'
export * from './inviteUser'
export * from './getAuditSummary'
Expand Down
11 changes: 7 additions & 4 deletions apps/backend/routes/public/scanWebhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,12 +204,15 @@ export const scanWebhook = async () => {
const params = [];
let p = 1;
for (const bd of prepared) {
vals.push(`($${p}, $${p+1}, $${p+2}, $${p+3}, $${p+4}, $${p+5}, $${p+6}, $${p+7})`);
params.push(auditId, JSON.stringify([]), bd.node, bd.contentNormalized, bd.contentHashId, bd.shortId, urlId, effectiveScanId);
p += 8;
vals.push(`($${p}, $${p+1}, $${p+2}, $${p+3}, $${p+4}, $${p+5}, $${p+6}, $${p+7}, $${p+8})`);
// Snapshot the URL string as `url_text` so it survives URL row deletion
// (e.g., CSV format change removing/replacing URLs). Falls back to NULL
// if the webhook payload didn't include the url.
params.push(auditId, JSON.stringify([]), bd.node, bd.contentNormalized, bd.contentHashId, bd.shortId, urlId, effectiveScanId, url ?? null);
p += 9;
}
const result = await db.query({
text: `INSERT INTO "blockers" ("audit_id", "targets", "content", "content_normalized", "content_hash_id", "short_id", "url_id", "scan_id") VALUES ${vals.join(', ')} RETURNING "id"`,
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"`,
values: params,
});
blockerIds = result.rows.map((r: any) => r.id);
Expand Down
6 changes: 3 additions & 3 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,12 @@
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^5.0.3",
"@vitejs/plugin-react": "^6.0.3",
"autoprefixer": "^10.4.21",
"sass-embedded": "^1.93.3",
"typescript": "^5.9.2",
"vite": "^7.1.6",
"vite-plugin-pwa": "^1.1.0"
"vite": "^8.1.0",
"vite-plugin-pwa": "^1.3.0"
},
"imports": {
"#src/*": "./src/*"
Expand Down
138 changes: 138 additions & 0 deletions apps/frontend/src/components/BlockersTable.module.scss
Original file line number Diff line number Diff line change
@@ -1,6 +1,114 @@
@use "../global-styles/variables.module.scss";
@use "../global-styles/fonts.scss";

// Export dropdown trigger — sits inside .BlockersTable DOM, scoped normally
.export-trigger {
background: transparent;
border: 0;
color: variables.$red;
opacity: 0.6;
cursor: pointer;
display: flex;
align-items: center;
gap: 2px;
padding: 0;

svg {
min-width: 14px;
min-height: 14px;
}

&:hover:not(:disabled) {
opacity: 1;
}
&[data-state="open"] {
opacity: 1;
}
&:disabled {
cursor: default;
opacity: 0.35;
}
}

.export-trigger-caret {
font-size: 10px;
margin-top: 1px;
}

.export-trigger-spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: export-spin 0.8s linear infinite;
}

@keyframes export-spin {
to { transform: rotate(360deg); }
}

// Export dropdown content — renders in a portal, so NOT nested inside .BlockersTable
.export-dropdown-content {
min-width: 280px;
background: variables.$white;
border: 1px solid variables.$gray;
border-radius: calc(variables.$spacing / 2);
box-shadow: variables.$shadow-small;
padding: calc(variables.$spacing / 2);
z-index: 50;
animation: dropdown-fade-in 0.1s ease-out;
}

@keyframes dropdown-fade-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}

.export-dropdown-item {
display: flex;
align-items: flex-start;
gap: variables.$spacing;
padding: variables.$spacing calc(variables.$spacing * 1.5);
border-radius: calc(variables.$spacing / 2);
cursor: pointer;
outline: none;

svg {
margin-top: 3px;
flex-shrink: 0;
color: variables.$red;
}

&[data-highlighted] {
background: variables.$gray;
}
&[data-disabled] {
opacity: 0.45;
cursor: default;
}
}

.export-dropdown-item-label {
@include fonts.font-size-normal;
font-weight: bold;
color: variables.$black;
}

.export-dropdown-item-desc {
@include fonts.font-size-small;
color: variables.$black;
opacity: 0.65;
margin-top: 1px;
}

.export-dropdown-separator {
height: 1px;
background: variables.$gray;
margin: calc(variables.$spacing / 2) 0;
}

.BlockersTable {
.table-top-buttons {
display: flex;
Expand Down Expand Up @@ -130,4 +238,34 @@
.tooltip-rondel {
background: variables.$dark-border;
}

.export-trigger {
color: variables.$yellow;
}

.export-dropdown-content {
background: variables.$dark-surface;
border-color: variables.$dark-border;
}

.export-dropdown-item {
svg {
color: variables.$yellow;
}
&[data-highlighted] {
background: variables.$dark-border;
}
}

.export-dropdown-item-label {
color: variables.$paper;
}

.export-dropdown-item-desc {
color: variables.$paper;
}

.export-dropdown-separator {
background: variables.$dark-border;
}
}
Loading
Loading