|
| 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 | +}; |
0 commit comments