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 - z A - Z 0 - 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 ;
0 commit comments