Skip to content

Commit 0537a8a

Browse files
committed
species fix and sourcelist
1 parent ae76ebd commit 0537a8a

36 files changed

Lines changed: 1760 additions & 578 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { MigrationInterface, QueryRunner } from "typeorm";
2+
3+
export class ConvertSpeciesImagesToBytea1785228239230 implements MigrationInterface {
4+
name = 'ConvertSpeciesImagesToBytea1785228239230'
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(`ALTER TABLE "species_information" DROP COLUMN "speciesImage"`);
8+
await queryRunner.query(`ALTER TABLE "species_information" ADD "speciesImage" bytea`);
9+
await queryRunner.query(`ALTER TABLE "species_information" DROP COLUMN "previewImage"`);
10+
await queryRunner.query(`ALTER TABLE "species_information" ADD "previewImage" bytea`);
11+
}
12+
13+
public async down(queryRunner: QueryRunner): Promise<void> {
14+
await queryRunner.query(`ALTER TABLE "species_information" DROP COLUMN "previewImage"`);
15+
await queryRunner.query(`ALTER TABLE "species_information" ADD "previewImage" character varying`);
16+
await queryRunner.query(`ALTER TABLE "species_information" DROP COLUMN "speciesImage"`);
17+
await queryRunner.query(`ALTER TABLE "species_information" ADD "speciesImage" character varying`);
18+
}
19+
20+
}

src/API/src/db/shared/reference.resolver.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
Resolver,
99
ObjectType,
1010
ArgsType,
11+
Int,
1112
} from '@nestjs/graphql';
1213
import { ReferenceService } from './reference.service';
1314
import { UseGuards } from '@nestjs/common';
@@ -48,6 +49,11 @@ export class CreateReferenceInput {
4849
v_data: boolean;
4950
}
5051

52+
// Same shape as create — an update always resends the full form,
53+
// matching how the frontend's updateSourceQuery builds its payload.
54+
@InputType()
55+
export class UpdateReferenceInput extends CreateReferenceInput {}
56+
5157
@ObjectType()
5258
class PaginatedReferenceData extends PaginatedResponse(Reference) {}
5359

@@ -81,6 +87,14 @@ export class GetReferenceDataArgs {
8187
@Field(stringTypeResolver, { nullable: true, defaultValue: '' })
8288
@Min(0)
8389
textFilter: string;
90+
91+
// Which column textFilter searches against — matches whichever option
92+
// the user picked in the field dropdown on the frontend. Defaults to
93+
// the previous hardcoded behavior so any existing caller that doesn't
94+
// pass this still works unchanged.
95+
@Field(stringTypeResolver, { nullable: true, defaultValue: 'article_title' })
96+
@Min(0)
97+
filterField: string;
8498
}
8599

86100
@Resolver(() => Reference)
@@ -103,6 +117,7 @@ export class ReferenceResolver {
103117
startId,
104118
endId,
105119
textFilter,
120+
filterField,
106121
}: GetReferenceDataArgs,
107122
) {
108123
const { items, total } = await this.referenceService.findReferences(
@@ -113,6 +128,7 @@ export class ReferenceResolver {
113128
startId,
114129
endId,
115130
textFilter,
131+
filterField,
116132
);
117133
return Object.assign(new PaginatedReferenceData(), {
118134
items,
@@ -141,4 +157,25 @@ export class ReferenceResolver {
141157
};
142158
return this.referenceService.save(newRef);
143159
}
144-
}
160+
161+
@UseGuards(GqlAuthGuard, RolesGuard)
162+
@Roles(Role.Uploader, Role.Editor)
163+
@Mutation(() => Reference)
164+
async updateReference(
165+
@Args('num_id', { type: () => Int }) num_id: number,
166+
@Args({ name: 'input', type: () => UpdateReferenceInput, nullable: false })
167+
input: UpdateReferenceInput,
168+
) {
169+
const updates: Partial<Reference> = {
170+
author: decodeURIComponent(input.author),
171+
article_title: decodeURIComponent(input.article_title),
172+
journal_title: decodeURIComponent(input.journal_title),
173+
citation: decodeURIComponent(input.citation),
174+
year: input.year,
175+
published: input.published,
176+
report_type: decodeURIComponent(input.report_type),
177+
v_data: input.v_data,
178+
};
179+
return this.referenceService.update(num_id, updates);
180+
}
181+
}
Lines changed: 48 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1-
import { Injectable } from '@nestjs/common';
1+
import { Injectable, NotFoundException } from '@nestjs/common';
22
import { InjectRepository } from '@nestjs/typeorm';
33
import { Reference } from './entities/reference.entity';
4-
import { Occurrence } from '../occurrence/entities/occurrence.entity';
5-
import { Dataset } from './entities/dataset.entity';
64
import { 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()
913
export 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+
}

src/API/src/db/speciesInformation/entities/speciesInformation.entity.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,20 @@ export class SpeciesInformation extends BaseEntity {
1717
@Field({ nullable: false })
1818
description: string;
1919

20-
@Column('varchar', { nullable: false })
21-
@Field({ nullable: false })
22-
speciesImage: string;
20+
// Original, full-size JPEG, stored directly as raw bytes in Postgres.
21+
// No @Field here — exposed to GraphQL as a base64 string via a
22+
// @ResolveField in speciesInformation.resolver.ts, since GraphQL has
23+
// no native binary type. Only ever needed for downloading —
24+
// still never fetched by the list query (see allSpeciesInformation's
25+
// select list in the service, unchanged).
26+
@Column('bytea', { nullable: true })
27+
speciesImage: Buffer;
28+
29+
// Small WebP version, generated automatically whenever a new
30+
// speciesImage is uploaded. This is what gets displayed everywhere.
31+
// Same base64-via-resolver treatment as speciesImage above.
32+
@Column('bytea', { nullable: true })
33+
previewImage: Buffer;
2334

2435
@Column('varchar', { nullable: false })
2536
@Field({ nullable: false })
@@ -32,4 +43,4 @@ export class SpeciesInformation extends BaseEntity {
3243
@Column('varchar', { nullable: true })
3344
@Field({ nullable: true })
3445
link: string;
35-
}
46+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import {
2+
Controller,
3+
Post,
4+
Get,
5+
Param,
6+
Res,
7+
UploadedFile,
8+
UseInterceptors,
9+
BadRequestException,
10+
NotFoundException,
11+
} from '@nestjs/common';
12+
import { FileInterceptor } from '@nestjs/platform-express';
13+
import { Response } from 'express';
14+
import { SpeciesInformationService } from './speciesInformation.service';
15+
16+
// CHANGED: TypeScript kept resolving sharp's type declarations as
17+
// non-callable in this project's config, regardless of import style
18+
// (`import sharp from`, `import * as sharp from`, and
19+
// `import sharp = require(...)` all failed the same way). Plain
20+
// require() sidesteps that entirely — sharp becomes typed as `any`
21+
// and isn't type-checked at all, only used at runtime.
22+
// eslint-disable-next-line @typescript-eslint/no-var-requires
23+
const sharp = require('sharp');
24+
25+
@Controller('species-information')
26+
export class SpeciesInformationController {
27+
constructor(
28+
private readonly speciesInformationService: SpeciesInformationService,
29+
) {}
30+
31+
// Processes a newly-selected species image. No external storage upload
32+
// happens here anymore — this validates the file, generates a smaller
33+
// WebP preview, and returns both as base64 strings. The frontend holds
34+
// these in local state and sends them along with the rest of the form
35+
// in the createEditSpeciesInformation mutation, where they get decoded
36+
// to raw bytes and saved directly into the species_information table's
37+
// speciesImage / previewImage columns.
38+
@Post('upload-image')
39+
@UseInterceptors(FileInterceptor('file'))
40+
async uploadImage(@UploadedFile() file: Express.Multer.File) {
41+
if (!file) {
42+
throw new BadRequestException('No file provided');
43+
}
44+
45+
// Only JPEG is accepted, since that's the documented format and
46+
// the only one we're set up to convert to WebP here.
47+
if (file.mimetype !== 'image/jpeg') {
48+
throw new BadRequestException('Only JPEG images are supported');
49+
}
50+
51+
// Build a smaller WebP version in memory.
52+
// - resize() caps the width so the preview is genuinely lighter
53+
// - withoutEnlargement stops small images being blown up
54+
// - webp({ quality: 80 }) is a solid size/quality balance
55+
const previewBuffer = await sharp(file.buffer)
56+
.resize({ width: 800, withoutEnlargement: true })
57+
.webp({ quality: 80 })
58+
.toBuffer();
59+
60+
return {
61+
imageBase64: file.buffer.toString('base64'),
62+
previewBase64: previewBuffer.toString('base64'),
63+
};
64+
}
65+
66+
// Download route for the list page's "Download" button. Reads the raw
67+
// bytes straight from Postgres and sends them back as a file attachment
68+
// — no redirect anywhere, since there's no external file to redirect to.
69+
@Get(':id/download-image')
70+
async downloadImage(@Param('id') id: string, @Res() res: Response) {
71+
const species =
72+
await this.speciesInformationService.getSpeciesImageForDownload(id);
73+
74+
if (!species || !species.speciesImage) {
75+
throw new NotFoundException('Image not found');
76+
}
77+
78+
res.set({
79+
'Content-Type': 'image/jpeg',
80+
'Content-Disposition': `attachment; filename="${species.name || 'species'}.jpeg"`,
81+
});
82+
return res.send(species.speciesImage);
83+
}
84+
}

0 commit comments

Comments
 (0)