1- import { Injectable } from '@nestjs/common' ;
1+ import { Injectable , NotFoundException } from '@nestjs/common' ;
22import { InjectRepository } from '@nestjs/typeorm' ;
33import { Reference } from './entities/reference.entity' ;
4- import { Occurrence } from '../occurrence/entities/occurrence.entity' ;
5- import { Dataset } from './entities/dataset.entity' ;
64import { Repository } from 'typeorm' ;
75
6+ // Only these columns are ever allowed as a dynamic filter target — this
7+ // whitelist exists specifically to prevent filterField (which ultimately
8+ // comes from user input via the frontend dropdown) from being used to
9+ // inject an arbitrary column/expression into the raw SQL string below.
10+ const ALLOWED_FILTER_FIELDS = [ 'article_title' , 'author' , 'journal_title' ] ;
11+
812@Injectable ( )
913export class ReferenceService {
1014 constructor (
@@ -16,6 +20,10 @@ export class ReferenceService {
1620 return this . referenceRepository . findOne ( { where : { id : id } } ) ;
1721 }
1822
23+ findOneByNumId ( num_id : number ) : Promise < Reference > {
24+ return this . referenceRepository . findOne ( { where : { num_id } } ) ;
25+ }
26+
1927 findAll ( ) : Promise < Reference [ ] > {
2028 return this . referenceRepository . find ( ) ;
2129 }
@@ -27,6 +35,20 @@ export class ReferenceService {
2735 return this . referenceRepository . save ( reference ) ;
2836 }
2937
38+ async update (
39+ num_id : number ,
40+ updates : Partial < Reference > ,
41+ ) : Promise < Reference > {
42+ const existing = await this . findOneByNumId ( num_id ) ;
43+ if ( ! existing ) {
44+ throw new NotFoundException (
45+ `Reference with num_id ${ num_id } not found` ,
46+ ) ;
47+ }
48+ const merged = this . referenceRepository . merge ( existing , updates ) ;
49+ return this . referenceRepository . save ( merged ) ;
50+ }
51+
3052 async findReferences (
3153 take : number ,
3254 skip : number ,
@@ -35,97 +57,47 @@ export class ReferenceService {
3557 startId : number ,
3658 endId : number ,
3759 textFilter : string ,
60+ filterField : string = 'article_title' ,
3861 ) : Promise < { items : Reference [ ] ; total : number } > {
3962 const nonStringCols = [ 'num_id' , 'year' , 'published' , 'v_data' ] ;
63+ const orderByString = nonStringCols . includes ( orderBy )
64+ ? `reference.${ orderBy } `
65+ : `LOWER(reference.${ orderBy } )` ;
4066
41- // Build filter conditions that will be applied to both queries
42- const filterParams : Record < string , any > = { status : 'Approved' } ;
43-
44- if ( startId && ! isNaN ( startId ) ) {
45- filterParams . startId = startId ;
46- }
47- if ( endId && ! isNaN ( endId ) ) {
48- filterParams . endId = endId ;
49- }
50- if ( textFilter ) {
51- filterParams . textFilter = `%${ textFilter . toLocaleLowerCase ( ) } %` ;
52- }
53-
54- // ============================================
55- // QUERY 1: Get paginated items
56- // ============================================
57- let itemsQuery = this . referenceRepository
58- . createQueryBuilder ( 'reference' )
59- . innerJoin ( Occurrence , 'occ' , 'occ.referenceId = reference.id' )
60- . innerJoin ( Dataset , 'ds' , 'ds.id = occ.datasetId' )
61- . andWhere ( 'ds.status = :status' , filterParams )
62- . distinct ( true ) ;
67+ // Guard against an unexpected/invalid filterField value reaching the
68+ // raw query string below — falls back to the original hardcoded
69+ // column if the requested one isn't in the allowed list.
70+ const safeFilterField = ALLOWED_FILTER_FIELDS . includes ( filterField )
71+ ? filterField
72+ : 'article_title' ;
6373
64- // Apply filters to items query
65- if ( startId && ! isNaN ( startId ) ) {
66- itemsQuery = itemsQuery . andWhere (
67- 'reference.num_id >= :startId' ,
68- filterParams ,
69- ) ;
70- }
71- if ( endId && ! isNaN ( endId ) ) {
72- itemsQuery = itemsQuery . andWhere (
73- 'reference.num_id <= :endId' ,
74- filterParams ,
75- ) ;
76- }
77- if ( textFilter ) {
78- itemsQuery = itemsQuery . andWhere (
79- 'LOWER(reference.article_title) LIKE :textFilter' ,
80- filterParams ,
81- ) ;
82- }
83-
84- // Apply ordering
85- if ( nonStringCols . includes ( orderBy ) ) {
86- itemsQuery = itemsQuery . addOrderBy ( `"reference"."${ orderBy } "` , order ) ;
87- } else {
88- const lowerAlias = `lower_${ orderBy } ` ;
89- itemsQuery = itemsQuery . addSelect (
90- `LOWER("reference"."${ orderBy } ")` ,
91- lowerAlias ,
92- ) ;
93- itemsQuery = itemsQuery . addOrderBy ( lowerAlias , order ) ;
94- }
74+ let query = this . referenceRepository . createQueryBuilder ( 'reference' ) ;
9575
96- const items = await itemsQuery . skip ( skip ) . take ( take ) . getMany ( ) ;
97-
98- // ============================================
99- // QUERY 2: Get DISTINCT count
100- // ============================================
101- let countQuery = this . referenceRepository
102- . createQueryBuilder ( 'reference' )
103- . select ( 'COUNT(DISTINCT reference.id)' , 'count' )
104- . innerJoin ( Occurrence , 'occ2' , 'occ2.referenceId = reference.id' )
105- . innerJoin ( Dataset , 'ds2' , 'ds2.id = occ2.datasetId' )
106- . andWhere ( 'ds2.status = :status' , { status : 'Approved' } ) ;
107-
108- // Apply same filters to count query
10976 if ( startId && ! isNaN ( startId ) ) {
110- countQuery = countQuery . andWhere ( 'reference. num_id >= :startId' , {
77+ query = query . andWhere ( '" reference"." num_id" >= :startId' , {
11178 startId,
11279 } ) ;
11380 }
11481 if ( endId && ! isNaN ( endId ) ) {
115- countQuery = countQuery . andWhere ( 'reference.num_id <= :endId' , { endId } ) ;
82+ query = query . andWhere ( '"reference"."num_id" <= :endId' , {
83+ endId,
84+ } ) ;
11685 }
11786 if ( textFilter ) {
118- countQuery = countQuery . andWhere (
119- ' LOWER(reference.article_title ) LIKE :textFilter' ,
87+ query = query . andWhere (
88+ ` LOWER(" reference"." ${ safeFilterField } " ) LIKE :textFilter` ,
12089 {
12190 textFilter : `%${ textFilter . toLocaleLowerCase ( ) } %` ,
12291 } ,
12392 ) ;
12493 }
12594
126- const countResult = await countQuery . getRawOne ( ) ;
127- const total = parseInt ( countResult . count , 10 ) || 0 ;
95+ const [ items , total ] = await query
96+ . orderBy ( orderByString , order )
97+ . skip ( skip )
98+ . take ( take )
99+ . getManyAndCount ( ) ;
128100
129101 return { items, total } ;
130102 }
131- }
103+ }
0 commit comments