Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
654 changes: 622 additions & 32 deletions src/API/package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/API/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.2.0",
"sharp": "^0.35.3",
"typeorm": "^0.3.7",
"uuid": "^8.3.2",
"winston": "^3.17.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class ConvertSpeciesImagesToBytea1785228239230 implements MigrationInterface {
name = 'ConvertSpeciesImagesToBytea1785228239230'

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "species_information" DROP COLUMN "speciesImage"`);
await queryRunner.query(`ALTER TABLE "species_information" ADD "speciesImage" bytea`);
await queryRunner.query(`ALTER TABLE "species_information" DROP COLUMN "previewImage"`);
await queryRunner.query(`ALTER TABLE "species_information" ADD "previewImage" bytea`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "species_information" DROP COLUMN "previewImage"`);
await queryRunner.query(`ALTER TABLE "species_information" ADD "previewImage" character varying`);
await queryRunner.query(`ALTER TABLE "species_information" DROP COLUMN "speciesImage"`);
await queryRunner.query(`ALTER TABLE "species_information" ADD "speciesImage" character varying`);
}

}
1 change: 1 addition & 0 deletions src/API/src/db/shared/reference.resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ describe('Reference resolver', () => {
startId: NaN,
endId: 100,
textFilter: 'filter',
filterField: 'author',
});

expect(referenceService.findReferences).toHaveBeenCalledWith(
Expand Down
37 changes: 37 additions & 0 deletions src/API/src/db/shared/reference.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Resolver,
ObjectType,
ArgsType,
Int,
} from '@nestjs/graphql';
import { ReferenceService } from './reference.service';
import { UseGuards } from '@nestjs/common';
Expand Down Expand Up @@ -48,6 +49,11 @@ export class CreateReferenceInput {
v_data: boolean;
}

// Same shape as create — an update always resends the full form,
// matching how the frontend's updateSourceQuery builds its payload.
@InputType()
export class UpdateReferenceInput extends CreateReferenceInput {}

@ObjectType()
class PaginatedReferenceData extends PaginatedResponse(Reference) {}

Expand Down Expand Up @@ -81,6 +87,14 @@ export class GetReferenceDataArgs {
@Field(stringTypeResolver, { nullable: true, defaultValue: '' })
@Min(0)
textFilter: string;

// Which column textFilter searches against — matches whichever option
// the user picked in the field dropdown on the frontend. Defaults to
// the previous hardcoded behavior so any existing caller that doesn't
// pass this still works unchanged.
@Field(stringTypeResolver, { nullable: true, defaultValue: 'article_title' })
@Min(0)
filterField: string;
}

@Resolver(() => Reference)
Expand All @@ -103,6 +117,7 @@ export class ReferenceResolver {
startId,
endId,
textFilter,
filterField,
}: GetReferenceDataArgs,
) {
const { items, total } = await this.referenceService.findReferences(
Expand All @@ -113,6 +128,7 @@ export class ReferenceResolver {
startId,
endId,
textFilter,
filterField,
);
return Object.assign(new PaginatedReferenceData(), {
items,
Expand Down Expand Up @@ -141,4 +157,25 @@ export class ReferenceResolver {
};
return this.referenceService.save(newRef);
}

@UseGuards(GqlAuthGuard, RolesGuard)
@Roles(Role.Uploader, Role.Editor)
@Mutation(() => Reference)
async updateReference(
@Args('num_id', { type: () => Int }) num_id: number,
@Args({ name: 'input', type: () => UpdateReferenceInput, nullable: false })
input: UpdateReferenceInput,
) {
const updates: Partial<Reference> = {
author: decodeURIComponent(input.author),
article_title: decodeURIComponent(input.article_title),
journal_title: decodeURIComponent(input.journal_title),
citation: decodeURIComponent(input.citation),
year: input.year,
published: input.published,
report_type: decodeURIComponent(input.report_type),
v_data: input.v_data,
};
return this.referenceService.update(num_id, updates);
}
}
120 changes: 45 additions & 75 deletions src/API/src/db/shared/reference.service.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { Injectable } from '@nestjs/common';
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Reference } from './entities/reference.entity';
import { Occurrence } from '../occurrence/entities/occurrence.entity';
import { Dataset } from './entities/dataset.entity';
import { Repository } from 'typeorm';

// Only these columns are ever allowed as a dynamic filter target — this
// whitelist exists specifically to prevent filterField (which ultimately
// comes from user input via the frontend dropdown) from being used to
// inject an arbitrary column/expression into the raw SQL string below.
const ALLOWED_FILTER_FIELDS = ['article_title', 'author', 'journal_title'];

@Injectable()
export class ReferenceService {
constructor(
Expand All @@ -16,6 +20,10 @@ export class ReferenceService {
return this.referenceRepository.findOne({ where: { id: id } });
}

findOneByNumId(num_id: number): Promise<Reference> {
return this.referenceRepository.findOne({ where: { num_id } });
}

findAll(): Promise<Reference[]> {
return this.referenceRepository.find();
}
Expand All @@ -27,6 +35,18 @@ export class ReferenceService {
return this.referenceRepository.save(reference);
}

async update(
num_id: number,
updates: Partial<Reference>,
): Promise<Reference> {
const existing = await this.findOneByNumId(num_id);
if (!existing) {
throw new NotFoundException(`Reference with num_id ${num_id} not found`);
}
const merged = this.referenceRepository.merge(existing, updates);
return this.referenceRepository.save(merged);
}

async findReferences(
take: number,
skip: number,
Expand All @@ -35,96 +55,46 @@ export class ReferenceService {
startId: number,
endId: number,
textFilter: string,
filterField = 'article_title',
): Promise<{ items: Reference[]; total: number }> {
const nonStringCols = ['num_id', 'year', 'published', 'v_data'];
const orderByString = nonStringCols.includes(orderBy)
? `reference.${orderBy}`
: `LOWER(reference.${orderBy})`;

// Build filter conditions that will be applied to both queries
const filterParams: Record<string, any> = { status: 'Approved' };
// Guard against an unexpected/invalid filterField value reaching the
// raw query string below — falls back to the original hardcoded
// column if the requested one isn't in the allowed list.
const safeFilterField = ALLOWED_FILTER_FIELDS.includes(filterField)
? filterField
: 'article_title';

if (startId && !isNaN(startId)) {
filterParams.startId = startId;
}
if (endId && !isNaN(endId)) {
filterParams.endId = endId;
}
if (textFilter) {
filterParams.textFilter = `%${textFilter.toLocaleLowerCase()}%`;
}
let query = this.referenceRepository.createQueryBuilder('reference');

// ============================================
// QUERY 1: Get paginated items
// ============================================
let itemsQuery = this.referenceRepository
.createQueryBuilder('reference')
.innerJoin(Occurrence, 'occ', 'occ.referenceId = reference.id')
.innerJoin(Dataset, 'ds', 'ds.id = occ.datasetId')
.andWhere('ds.status = :status', filterParams)
.distinct(true);

// Apply filters to items query
if (startId && !isNaN(startId)) {
itemsQuery = itemsQuery.andWhere(
'reference.num_id >= :startId',
filterParams,
);
}
if (endId && !isNaN(endId)) {
itemsQuery = itemsQuery.andWhere(
'reference.num_id <= :endId',
filterParams,
);
}
if (textFilter) {
itemsQuery = itemsQuery.andWhere(
'LOWER(reference.article_title) LIKE :textFilter',
filterParams,
);
}

// Apply ordering
if (nonStringCols.includes(orderBy)) {
itemsQuery = itemsQuery.addOrderBy(`"reference"."${orderBy}"`, order);
} else {
const lowerAlias = `lower_${orderBy}`;
itemsQuery = itemsQuery.addSelect(
`LOWER("reference"."${orderBy}")`,
lowerAlias,
);
itemsQuery = itemsQuery.addOrderBy(lowerAlias, order);
}

const items = await itemsQuery.skip(skip).take(take).getMany();

// ============================================
// QUERY 2: Get DISTINCT count
// ============================================
let countQuery = this.referenceRepository
.createQueryBuilder('reference')
.select('COUNT(DISTINCT reference.id)', 'count')
.innerJoin(Occurrence, 'occ2', 'occ2.referenceId = reference.id')
.innerJoin(Dataset, 'ds2', 'ds2.id = occ2.datasetId')
.andWhere('ds2.status = :status', { status: 'Approved' });

// Apply same filters to count query
if (startId && !isNaN(startId)) {
countQuery = countQuery.andWhere('reference.num_id >= :startId', {
query = query.andWhere('"reference"."num_id" >= :startId', {
startId,
});
}
if (endId && !isNaN(endId)) {
countQuery = countQuery.andWhere('reference.num_id <= :endId', { endId });
query = query.andWhere('"reference"."num_id" <= :endId', {
endId,
});
}
if (textFilter) {
countQuery = countQuery.andWhere(
'LOWER(reference.article_title) LIKE :textFilter',
query = query.andWhere(
`LOWER("reference"."${safeFilterField}") LIKE :textFilter`,
{
textFilter: `%${textFilter.toLocaleLowerCase()}%`,
},
);
}

const countResult = await countQuery.getRawOne();
const total = parseInt(countResult.count, 10) || 0;
const [items, total] = await query
.orderBy(orderByString, order)
.skip(skip)
.take(take)
.getManyAndCount();

return { items, total };
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,20 @@ export class SpeciesInformation extends BaseEntity {
@Field({ nullable: false })
description: string;

@Column('varchar', { nullable: false })
@Field({ nullable: false })
speciesImage: string;
// Original, full-size JPEG, stored directly as raw bytes in Postgres.
// No @Field here — exposed to GraphQL as a base64 string via a
// @ResolveField in speciesInformation.resolver.ts, since GraphQL has
// no native binary type. Only ever needed for downloading —
// still never fetched by the list query (see allSpeciesInformation's
// select list in the service, unchanged).
@Column('bytea', { nullable: true })
speciesImage: Buffer;

// Small WebP version, generated automatically whenever a new
// speciesImage is uploaded. This is what gets displayed everywhere.
// Same base64-via-resolver treatment as speciesImage above.
@Column('bytea', { nullable: true })
previewImage: Buffer;

@Column('varchar', { nullable: false })
@Field({ nullable: false })
Expand Down
86 changes: 86 additions & 0 deletions src/API/src/db/speciesInformation/speciesInformation.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {
Controller,
Post,
Get,
Param,
Res,
UploadedFile,
UseInterceptors,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Response } from 'express';
import { SpeciesInformationService } from './speciesInformation.service';

// CHANGED: TypeScript kept resolving sharp's type declarations as
// non-callable in this project's config, regardless of import style
// (`import sharp from`, `import * as sharp from`, and
// `import sharp = require(...)` all failed the same way). Plain
// require() sidesteps that entirely — sharp becomes typed as `any`
// and isn't type-checked at all, only used at runtime.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const sharp = require('sharp');

@Controller('species-information')
export class SpeciesInformationController {
constructor(
private readonly speciesInformationService: SpeciesInformationService,
) {}

// Processes a newly-selected species image. No external storage upload
// happens here anymore — this validates the file, generates a smaller
// WebP preview, and returns both as base64 strings. The frontend holds
// these in local state and sends them along with the rest of the form
// in the createEditSpeciesInformation mutation, where they get decoded
// to raw bytes and saved directly into the species_information table's
// speciesImage / previewImage columns.
@Post('upload-image')
@UseInterceptors(FileInterceptor('file'))
async uploadImage(@UploadedFile() file: Express.Multer.File) {
if (!file) {
throw new BadRequestException('No file provided');
}

// Only JPEG is accepted, since that's the documented format and
// the only one we're set up to convert to WebP here.
if (file.mimetype !== 'image/jpeg') {
throw new BadRequestException('Only JPEG images are supported');
}

// Build a smaller WebP version in memory.
// - resize() caps the width so the preview is genuinely lighter
// - withoutEnlargement stops small images being blown up
// - webp({ quality: 80 }) is a solid size/quality balance
const previewBuffer = await sharp(file.buffer)
.resize({ width: 800, withoutEnlargement: true })
.webp({ quality: 80 })
.toBuffer();

return {
imageBase64: file.buffer.toString('base64'),
previewBase64: previewBuffer.toString('base64'),
};
}

// Download route for the list page's "Download" button. Reads the raw
// bytes straight from Postgres and sends them back as a file attachment
// — no redirect anywhere, since there's no external file to redirect to.
@Get(':id/download-image')
async downloadImage(@Param('id') id: string, @Res() res: Response) {
const species =
await this.speciesInformationService.getSpeciesImageForDownload(id);

if (!species || !species.speciesImage) {
throw new NotFoundException('Image not found');
}

res.set({
'Content-Type': 'image/jpeg',
'Content-Disposition': `attachment; filename="${
species.name || 'species'
}.jpeg"`,
});
return res.send(species.speciesImage);
}
}
Loading
Loading