Skip to content

Commit d6cc58e

Browse files
Export functionality working
1 parent 11ce108 commit d6cc58e

9 files changed

Lines changed: 1634 additions & 234 deletions

File tree

api/package-lock.json

Lines changed: 1347 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"chai": "^4.2.0",
5858
"chance": "^1.0.18",
5959
"cross-env": "^5.2.0",
60+
"csv-stringify": "^6.6.0",
6061
"expect": "^24.5.0",
6162
"http-status-codes": "^1.3.1",
6263
"koa": "^2.7.0",
@@ -70,6 +71,7 @@
7071
"nodemon": "^1.18.10",
7172
"nyc": "^13.3.0",
7273
"pg": "^8.8.0",
74+
"pg-cursor": "^2.17.0",
7375
"reflect-metadata": "^0.1.13",
7476
"supertest": "^4.0.0",
7577
"tape": "^4.10.1",
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import * as Koa from 'koa';
2+
import * as Router from 'koa-router';
3+
import { Role } from '../../roles';
4+
import { IAuth } from '../../../models/interfaces/i-auth';
5+
import { authorize } from '../../../services/common/authorize.service';
6+
import databaseConnection from '../../../../app/database/database.connection';
7+
8+
const PgCursor = require('pg-cursor');
9+
const { stringify } = require('csv-stringify'); // ✅ use the streaming API
10+
11+
export const timesheetReportCsv = async (ctx: Koa.Context) => {
12+
const auth = ctx.state.auth as IAuth;
13+
if (!auth?.role?.includes(Role.PSB_Admin)) {
14+
ctx.status = 403;
15+
ctx.body = { message: 'Forbidden' };
16+
return;
17+
}
18+
19+
console.log("API: timesheetReport CSV");
20+
21+
// ---- parse filters
22+
const { startDate, endDate, userIds, projectIds } = ctx.query as Record<string, string | undefined>;
23+
const toUuidArray = (s?: string) => (s ? s.split(',').map(x => x.trim()).filter(Boolean) : undefined);
24+
const userIdArr = toUuidArray(userIds);
25+
const projectIdArr = toUuidArray(projectIds);
26+
27+
const where: string[] = [
28+
`(te."hoursBillable" != 0 OR te."hoursUnBillable" != 0 OR te."expenseAmount" != 0)`
29+
];
30+
const params: any[] = [];
31+
let i = 1;
32+
if (startDate) { where.push(`te."entryDate" >= $${i++}`); params.push(startDate); }
33+
if (endDate) { where.push(`te."entryDate" <= $${i++}`); params.push(endDate); }
34+
if (userIdArr?.length) { where.push(`u."id" = ANY($${i++}::uuid[])`); params.push(userIdArr); }
35+
if (projectIdArr?.length) { where.push(`p."id" = ANY($${i++}::uuid[])`); params.push(projectIdArr); }
36+
37+
const sql = `
38+
SELECT
39+
c."fullName" AS "fullName",
40+
te."entryDate" AS "entryDate",
41+
te."hoursBillable" AS "hoursBillable",
42+
c."hourlyRate" AS "hourlyRate",
43+
te."commentsBillable" AS "commentsBillable",
44+
te."hoursUnBillable" AS "hoursUnBillable",
45+
te."commentsUnBillable" AS "commentsUnBillable",
46+
te."expenseCategory" AS "expenseCategory",
47+
te."expenseAmount" AS "expenseAmount",
48+
te."expenseComment" AS "expenseComment",
49+
CASE WHEN (p."categoryId" = 3 OR p."categoryId" IS NULL) THEN 'True' ELSE 'False' END AS "IsProjectBillable",
50+
mou."name" AS "Mou",
51+
p."projectName" AS "projectName",
52+
pr."rfxName" AS "rfxName"
53+
FROM "timesheet" t
54+
JOIN "timesheet_entry" te ON te."timesheetId" = t."id"
55+
LEFT JOIN "user" u ON u."id" = t."userId"
56+
LEFT JOIN "contact" c ON c."id" = u."contactId"
57+
JOIN "mou" mou ON mou."id" = t."mouId"
58+
JOIN "project" p ON p."id" = t."projectId"
59+
JOIN "project_rfx" pr ON pr."id" = t."projectRfxId"
60+
WHERE ${where.join(' AND ')}
61+
ORDER BY c."fullName", te."entryDate", p."projectName", pr."rfxName"
62+
`;
63+
64+
65+
// ---- Build filename based on dates & users ----
66+
67+
// Normalize dates (YYYY-MM-DD or fallback)
68+
const start = startDate ? startDate.slice(0, 10) : 'ALL-DATES';
69+
const end = endDate ? endDate.slice(0, 10) : 'ALL-DATES';
70+
71+
72+
/*
73+
// Build clean user label
74+
let userLabel = 'ALL-USERS';
75+
76+
if (userIdArr?.length === 1) {
77+
// show one user safely (by id prefix)
78+
userLabel = `USER-${userIdArr[0].substring(0, 8)}`;
79+
}
80+
81+
if (userIdArr?.length > 1) {
82+
userLabel = `USERS-${userIdArr.length}`;
83+
}*/
84+
85+
// Final filename assembly
86+
//let filename = `Timesheets_${start}_to_${end}_${userLabel}.csv`;
87+
let filename = `Timesheets_${start}_to_${end}.csv`;
88+
89+
// Sanitize for filesystem safety
90+
filename = filename.replace(/[^a-zA-Z0-9._-]/g, '-');
91+
92+
// Keep it short just in case (200 is safe everywhere)
93+
filename = filename.slice(0, 180);
94+
console.log(filename);
95+
// Apply to response
96+
ctx.attachment(filename);
97+
ctx.type = 'text/csv; charset=utf-8';
98+
ctx.set('Access-Control-Expose-Headers', 'Content-Disposition');
99+
100+
101+
// We’ll take over the raw socket and pipe CSV into it
102+
ctx.respond = false;
103+
const res = ctx.res;
104+
105+
res.on('finish', () => console.log('CSV stream finished'));
106+
res.on('close', () => console.log('CSV stream closed'));
107+
res.on('error', (e) => console.error('CSV stream error', e));
108+
109+
// Optional BOM so Excel recognizes UTF-8 correctly:
110+
// res.write('\uFEFF');
111+
112+
// Configure the CSV stringifier as a stream with headers
113+
const columns = [
114+
'Name','Entry Date','Billable Hours','Hourly Rate','Comments (Billable)',
115+
'Unbillable Hours','Comments (Unbillable)','Expense Category','Expense Amount',
116+
'Expense Comment','Is Project Billable','MOU','Project','RFX'
117+
];
118+
const stringifier = stringify({
119+
header: true,
120+
columns, // header row will be generated
121+
quoted: true, // quote all fields to be safe with commas/newlines
122+
});
123+
124+
// Pipe CSV directly to the HTTP response with backpressure management
125+
// (csv-stringify handles backpressure; piping is fine)
126+
stringifier.pipe(res);
127+
128+
const fmtDate = (d: any) =>
129+
d instanceof Date ? d.toISOString().slice(0, 10) :
130+
(typeof d === 'string' && /^\d{4}-\d{2}-\d{2}/.test(d)) ? d.slice(0, 10) : d;
131+
132+
const conn = await databaseConnection; // TypeORM Connection (0.2.x)
133+
const queryRunner = conn.createQueryRunner('master');
134+
await queryRunner.connect();
135+
136+
const pgClient: any = (queryRunner as any).databaseConnection;
137+
if (!pgClient) {
138+
await queryRunner.release();
139+
res.statusCode = 500;
140+
res.end('Failed to access underlying pg client from QueryRunner.');
141+
return;
142+
}
143+
144+
const fetchSize = 5000;
145+
const cursor = pgClient.query(new PgCursor(sql, params));
146+
147+
try {
148+
console.log("API: timesheetReport CSV - start cursor read");
149+
while (true) {
150+
const rows: any[] = await new Promise((resolve, reject) =>
151+
cursor.read(fetchSize, (err: any, r: any[]) => (err ? reject(err) : resolve(r)))
152+
);
153+
if (!rows.length) break;
154+
155+
for (const r of rows) {
156+
stringifier.write([
157+
r.fullName,
158+
fmtDate(r.entryDate),
159+
Number(r.hoursBillable || 0),
160+
Number(r.hourlyRate || 0),
161+
r.commentsBillable,
162+
Number(r.hoursUnBillable || 0),
163+
r.commentsUnBillable,
164+
r.expenseCategory,
165+
Number(r.expenseAmount || 0),
166+
r.expenseComment,
167+
r.IsProjectBillable,
168+
r.Mou,
169+
r.projectName,
170+
r.rfxName
171+
]);
172+
}
173+
// Allow event loop to process I/O between batches
174+
await new Promise(r => setImmediate(r));
175+
}
176+
177+
// End CSV stream; this will flush and then end the HTTP response
178+
stringifier.end();
179+
} catch (err) {
180+
console.error("CSV ERR:", err);
181+
try { stringifier.destroy(err as Error); } catch {}
182+
} finally {
183+
await new Promise<void>(r => cursor.close(() => r()));
184+
await queryRunner.release();
185+
}
186+
};
187+
188+
const routerOpts: Router.IRouterOptions = { prefix: '/api/report' };
189+
const router: Router = new (Router as any)(routerOpts);
190+
191+
router.get('/export.csv', authorize, timesheetReportCsv);
192+
193+
export default router;

api/src/app/routes/routes.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import mouController from './client/controllers/mou.controller';
1717
import procurementController from './client/controllers/procurement.controller';
1818
import projectNoteController from './client/controllers/projectNote.controller';
1919
import financeCodesController from './client/controllers/financeCode.controller';
20+
import reportController from './client/controllers/report.controller'
2021

2122
export const appRoutes = [
2223
projectController.routes(),
@@ -37,7 +38,8 @@ export const appRoutes = [
3738
mouController.routes(),
3839
procurementController.routes(),
3940
projectNoteController.routes(),
40-
financeCodesController.routes()
41+
financeCodesController.routes(),
42+
reportController.routes()
4143
];
4244

4345
export const allowedMethods = [
@@ -59,5 +61,6 @@ export const allowedMethods = [
5961
mouController.allowedMethods(),
6062
procurementController.allowedMethods(),
6163
projectNoteController.allowedMethods(),
62-
financeCodesController.allowedMethods()
64+
financeCodesController.allowedMethods(),
65+
reportController.allowedMethods()
6366
];

api/src/app/services/common/authorize.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const commonForAllUsers = [
5151
'GET/api/user/',
5252
'GET/api/MOU/',
5353
'POST/api/intake/',
54+
'GET/api/report/export.csv'
5455
];
5556

5657
const commonForPSBAdminAndUser = [

0 commit comments

Comments
 (0)