Skip to content

Commit 10d0063

Browse files
committed
Merge branch 'main' into staging
2 parents 6459ff9 + b8f301d commit 10d0063

13 files changed

Lines changed: 474 additions & 63 deletions

File tree

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import { db, event, graphqlQuery, validateShortId } 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+
export const exportAuditTable = async () => {
11+
const auditId = (event.queryStringParameters as any).id;
12+
const contentType = (event.queryStringParameters as any).contentType || "all";
13+
const sortBy = (event.queryStringParameters as any).sortBy || "created_at";
14+
const sortOrder = (event.queryStringParameters as any).sortOrder || "desc";
15+
16+
const tagsParam = (event.queryStringParameters as any).tags || null;
17+
const categoriesParam =
18+
(event.queryStringParameters as any).categories || null;
19+
const statusParam = (event.queryStringParameters as any).status || null;
20+
21+
const tagFilters = tagsParam ? tagsParam.split(",").filter(Boolean) : [];
22+
const typeFilters = categoriesParam
23+
? categoriesParam.split(",").filter(Boolean)
24+
: [];
25+
26+
const searchString = (event.queryStringParameters as any).searchString || "";
27+
28+
await db.connect();
29+
const audit = (
30+
await db.query({
31+
text: `SELECT * FROM "audits" WHERE "id" = $1`,
32+
values: [auditId],
33+
})
34+
).rows?.[0];
35+
await db.clean();
36+
37+
const whereConditions: any[] = [];
38+
39+
if (tagFilters.length > 0) {
40+
whereConditions.push({
41+
blocker_messages: {
42+
message: {
43+
message_tags: { tag: { id: { _in: tagFilters } } },
44+
},
45+
},
46+
});
47+
}
48+
49+
if (typeFilters.length > 0) {
50+
whereConditions.push({
51+
blocker_messages: {
52+
message: { category: { _in: typeFilters } },
53+
},
54+
});
55+
}
56+
57+
if (statusParam) {
58+
if (statusParam === "active") {
59+
whereConditions.push({
60+
blocker_messages: {
61+
blocker: {
62+
_not: {
63+
ignored_blocker: { blocker_id: { _is_null: false } },
64+
},
65+
},
66+
},
67+
});
68+
} else if (statusParam === "ignored") {
69+
whereConditions.push({
70+
blocker_messages: {
71+
blocker: {
72+
ignored_blocker: { id: { _is_null: false } },
73+
},
74+
},
75+
});
76+
}
77+
}
78+
79+
if (searchString !== "") {
80+
if (validateShortId(searchString)) {
81+
whereConditions.push({ short_id: { _eq: searchString } });
82+
} else {
83+
whereConditions.push({
84+
url: { url: { _ilike: `%${searchString}%` } },
85+
});
86+
}
87+
}
88+
89+
const whereClause =
90+
whereConditions.length > 0 ? { _and: whereConditions } : {};
91+
92+
const orderByClause =
93+
sortBy === "url"
94+
? { url: { url: sortOrder } }
95+
: { created_at: sortOrder };
96+
97+
// Find the latest scan id for this audit so we can paginate blockers directly
98+
const scanQuery = {
99+
query: `query ($audit_id: uuid!) {
100+
audits_by_pk(id: $audit_id) {
101+
scans(order_by: {created_at: desc}, limit: 1) {
102+
id
103+
}
104+
}
105+
}`,
106+
variables: { audit_id: auditId },
107+
};
108+
const scanResp = await graphqlQuery(scanQuery);
109+
const latestScanId = scanResp.audits_by_pk?.scans?.[0]?.id;
110+
111+
if (!latestScanId) {
112+
return {
113+
statusCode: 200,
114+
headers: {
115+
"content-type": "text/csv; charset=utf-8",
116+
"content-disposition": `attachment; filename="blockers-${auditId}-${new Date().toISOString().split("T")[0]}.csv"`,
117+
},
118+
body: "Type,URL,Issue,Code,Tags,Categories,Status,ID\n",
119+
};
120+
}
121+
122+
// Pull all blockers in batches to avoid Hasura row limits
123+
const ignoredSetQuery = {
124+
query: `query ($audit_id: uuid!) {
125+
ignored_blockers(where: {audit_id: {_eq: $audit_id}}) {
126+
blocker_id
127+
}
128+
}`,
129+
variables: { audit_id: auditId },
130+
};
131+
const ignoredResp = await graphqlQuery(ignoredSetQuery);
132+
const ignoredSet = new Set<string>(
133+
(ignoredResp.ignored_blockers || []).map((ib: any) => ib.blocker_id)
134+
);
135+
136+
const scopedWhere = {
137+
_and: [{ scan_id: { _eq: latestScanId } }, ...whereConditions],
138+
};
139+
140+
const allBlockers: any[] = [];
141+
let offset = 0;
142+
while (true) {
143+
const batchQuery = {
144+
query: `query ($limit: Int!, $offset: Int!, $where: blockers_bool_exp!, $order_by: [blockers_order_by!]) {
145+
blockers(where: $where, limit: $limit, offset: $offset, order_by: $order_by) {
146+
id
147+
short_id
148+
created_at
149+
content
150+
url_id
151+
url { url type }
152+
blocker_messages {
153+
id
154+
message {
155+
id
156+
content
157+
category
158+
message_tags { tag { id content } }
159+
}
160+
}
161+
}
162+
}`,
163+
variables: {
164+
limit: BATCH_SIZE,
165+
offset,
166+
where: scopedWhere,
167+
order_by: [orderByClause],
168+
},
169+
};
170+
const batchResp = await graphqlQuery(batchQuery);
171+
const batch = batchResp.blockers || [];
172+
allBlockers.push(...batch);
173+
if (batch.length < BATCH_SIZE) break;
174+
offset += BATCH_SIZE;
175+
}
176+
177+
let formattedBlockers = allBlockers.map((blocker) => {
178+
const tags = blocker.blocker_messages.flatMap(
179+
(bm: any) =>
180+
bm.message.message_tags?.map((mt: any) => mt.tag).filter(Boolean) || []
181+
);
182+
const uniqueTags = Array.from(
183+
new Map(tags.map((tag: any) => [tag.id, tag])).values()
184+
) as any[];
185+
const categories = Array.from(
186+
new Set(blocker.blocker_messages.map((bm: any) => bm.message.category))
187+
);
188+
const messages = blocker.blocker_messages.map(
189+
(bm: any) => `[${bm.message.category}] ${bm.message.content}`
190+
);
191+
return {
192+
id: blocker.id,
193+
short_id: blocker.short_id,
194+
url: blocker.url?.url || "Unknown URL",
195+
type: blocker.url?.type || "unknown",
196+
content: blocker.content,
197+
messages,
198+
tags: uniqueTags,
199+
categories,
200+
};
201+
});
202+
203+
if (
204+
contentType.toLowerCase() === "html" ||
205+
contentType.toLowerCase() === "pdf"
206+
) {
207+
formattedBlockers = formattedBlockers.filter(
208+
(b) => b.type.toLowerCase() === contentType.toLowerCase()
209+
);
210+
}
211+
212+
const headers = [
213+
"Type",
214+
"URL",
215+
"Issue",
216+
"Code",
217+
"Tags",
218+
"Categories",
219+
"Status",
220+
"ID",
221+
];
222+
const rows = formattedBlockers.map((b) =>
223+
[
224+
b.type,
225+
b.url,
226+
b.messages?.[0] || "",
227+
b.content || "",
228+
b.tags.map((t: any) => t.content).join("; "),
229+
b.categories.join("; "),
230+
ignoredSet.has(b.id) ? "Ignored" : "Active",
231+
b.short_id || "",
232+
]
233+
.map(csvEscape)
234+
.join(",")
235+
);
236+
237+
const csv = [headers.join(","), ...rows].join("\n");
238+
const datePart = new Date().toISOString().split("T")[0];
239+
const filename = `blockers-${audit?.name ? audit.name.replace(/[^a-z0-9-_]/gi, "_") + "-" : ""}${auditId}-${datePart}.csv`;
240+
241+
return {
242+
statusCode: 200,
243+
headers: {
244+
"content-type": "text/csv; charset=utf-8",
245+
"content-disposition": `attachment; filename="${filename}"`,
246+
},
247+
body: csv,
248+
};
249+
};

apps/backend/routes/auth/getAuditTable.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@ export const getAuditTable = async () => {
6262
});
6363
}
6464

65+
// Content type filtering (html/pdf)
66+
if (contentType.toLowerCase() === "html" || contentType.toLowerCase() === "pdf") {
67+
whereConditions.push({
68+
url: { type: { _eq: contentType.toLowerCase() } },
69+
});
70+
}
71+
6572
// Status filtering ('ignore' field true/false)
6673
if (statusParam) {
6774
if (statusParam === "active") {
@@ -303,16 +310,6 @@ export const getAuditTable = async () => {
303310
};
304311
});
305312

306-
// filter for content type. We need to do it last because the type field is set by URL, not blocker
307-
if (
308-
contentType.toLowerCase() === "html" ||
309-
contentType.toLowerCase() === "pdf"
310-
) {
311-
formattedBlockers = formattedBlockers.filter((blocker) => {
312-
return blocker.type.toLowerCase() === contentType.toLowerCase();
313-
});
314-
}
315-
316313
return {
317314
statusCode: 200,
318315
headers: { "content-type": "application/json" },

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 './deleteAudit'
1111
export * from './updateAudit'
1212
export * from './getAuditChart'
1313
export * from './getAuditTable'
14+
export * from './exportAuditTable'
1415
export * from './getLogs'
1516
export * from './inviteUser'
1617
export * from './getAuditSummary'
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { exportAuditTable as exportAuditTableAuth } from "../auth";
2+
3+
export const exportAuditTable = async () => {
4+
return await exportAuditTableAuth();
5+
};

apps/backend/routes/public/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ export * from './checkIfUserExists'
44
export * from './scanWebhook'
55
export * from './getAuditChart'
66
export * from './getAuditTable'
7+
export * from './exportAuditTable'
78
export * from './getAuditSummary'

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

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

4+
@keyframes spin {
5+
from { transform: rotate(0deg); }
6+
to { transform: rotate(360deg); }
7+
}
8+
9+
.spinning {
10+
animation: spin 1s linear infinite;
11+
color: variables.$green;
12+
}
13+
414
.AuditsTable {
515
margin-top:variables.$spacing;
616
margin-bottom:calc(variables.$spacing*2);

apps/frontend/src/components/AuditsTable.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { StyledLabeledInput } from "./StyledLabeledInput";
1717
import { formatId } from "../utils";
1818
import { Link } from "react-router-dom";
1919
import { FaArrowDown, FaArrowUp } from "react-icons/fa";
20+
import { GrPowerCycle } from "react-icons/gr";
2021
import React from "react";
2122

2223
interface Audit {
@@ -57,6 +58,22 @@ export const AuditsTable = ({ audits, isLoading }: auditsTableProps) => {
5758
}
5859
const columns = useMemo<ColumnDef<Audit>[]>(
5960
() => [
61+
{
62+
accessorFn: (row) => (row.scans[0]?.status === "processing" ? 1 : 0),
63+
id: "status",
64+
header: "Status",
65+
sortingFn: "basic",
66+
cell: ({ row }) => {
67+
if (row.original.scans[0]?.status === "processing") {
68+
return (
69+
<span role="img" aria-label="Processing">
70+
<GrPowerCycle className={styles.spinning} />
71+
</span>
72+
);
73+
}
74+
return null;
75+
},
76+
},
6077
{
6178
accessorKey: "name",
6279
header: "Name",

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,20 @@
55
.table-top-buttons {
66
display: flex;
77
gap: 8px;
8-
justify-content: flex-end;
8+
justify-content: space-between;
9+
align-items: center;
10+
}
11+
.total-blockers {
12+
font-weight: bold;
13+
font-size: 1rem;
14+
}
15+
.total-blockers-count {
16+
font-size: 1.25rem;
17+
}
18+
.table-top-actions {
19+
display: flex;
20+
gap: 8px;
21+
align-items: center;
922
}
1023
.blocker-code {
1124
//color: variables.$white;

0 commit comments

Comments
 (0)