diff --git a/ApiCore-Backend/src/apiaries/apiaries.controller.ts b/ApiCore-Backend/src/apiaries/apiaries.controller.ts index 5f4beb3..5cb38af 100644 --- a/ApiCore-Backend/src/apiaries/apiaries.controller.ts +++ b/ApiCore-Backend/src/apiaries/apiaries.controller.ts @@ -1,6 +1,14 @@ -import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + Query, +} from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { Role } from '../../generated/prisma'; +import { ApiaryStatus, Role } from '../../generated/prisma'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; @@ -15,8 +23,11 @@ export class ApiariesController { constructor(private readonly apiariesService: ApiariesService) {} @Get() - findAll() { - return this.apiariesService.findAll(); + findAll( + @Query('status') status?: ApiaryStatus, + @Query('location') location?: string, + ) { + return this.apiariesService.findAll({ status, location }); } @Get(':id') diff --git a/ApiCore-Backend/src/apiaries/apiaries.service.ts b/ApiCore-Backend/src/apiaries/apiaries.service.ts index 0980b2e..8d9d9c1 100644 --- a/ApiCore-Backend/src/apiaries/apiaries.service.ts +++ b/ApiCore-Backend/src/apiaries/apiaries.service.ts @@ -1,10 +1,15 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; -import { Apiary } from '../../generated/prisma'; +import { Apiary, ApiaryStatus } from '../../generated/prisma'; import { CreateApiaryDto } from './dto/create-apiary.dto'; import { UpdateApiaryDto } from './dto/update-apiary.dto'; +export interface ApiaryFilter { + status?: ApiaryStatus; + location?: string; +} + @Injectable() export class ApiariesService { constructor( @@ -12,8 +17,16 @@ export class ApiariesService { private readonly auditLog: AuditLogService, ) {} - async findAll(): Promise { - return this.prisma.apiary.findMany({ orderBy: { name: 'asc' } }); + async findAll(filter: ApiaryFilter = {}): Promise { + return this.prisma.apiary.findMany({ + where: { + status: filter.status, + location: filter.location + ? { contains: filter.location, mode: 'insensitive' } + : undefined, + }, + orderBy: { name: 'asc' }, + }); } async findById(id: string): Promise { diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 1b3e4c6..2946459 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -19,6 +19,7 @@ import { ExtractionsModule } from './extractions/extractions.module'; import { HoneyBatchesModule } from './honey-batches/honey-batches.module'; import { StorageModule } from './storage/storage.module'; import { DashboardModule } from './dashboard/dashboard.module'; +import { SearchModule } from './search/search.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { RolesGuard } from './auth/guards/roles.guard'; @@ -43,6 +44,7 @@ import { RolesGuard } from './auth/guards/roles.guard'; HoneyBatchesModule, StorageModule, DashboardModule, + SearchModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/src/colonies/colonies.controller.ts b/ApiCore-Backend/src/colonies/colonies.controller.ts index f9f0915..8e2a560 100644 --- a/ApiCore-Backend/src/colonies/colonies.controller.ts +++ b/ApiCore-Backend/src/colonies/colonies.controller.ts @@ -1,6 +1,14 @@ -import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + Query, +} from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { Role } from '../../generated/prisma'; +import { ColonyStatus, Role } from '../../generated/prisma'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; @@ -18,8 +26,11 @@ export class ColoniesController { constructor(private readonly coloniesService: ColoniesService) {} @Get() - findAll() { - return this.coloniesService.findAll(); + findAll( + @Query('status') status?: ColonyStatus, + @Query('apiaryId') apiaryId?: string, + ) { + return this.coloniesService.findAll({ status, apiaryId }); } @Get(':id') diff --git a/ApiCore-Backend/src/colonies/colonies.service.ts b/ApiCore-Backend/src/colonies/colonies.service.ts index 60ffcbf..47671e4 100644 --- a/ApiCore-Backend/src/colonies/colonies.service.ts +++ b/ApiCore-Backend/src/colonies/colonies.service.ts @@ -5,13 +5,18 @@ import { } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; -import { Colony } from '../../generated/prisma'; +import { Colony, ColonyStatus } from '../../generated/prisma'; import { CreateColonyDto } from './dto/create-colony.dto'; import { UpdateColonyDto } from './dto/update-colony.dto'; import { MoveColonyDto } from './dto/move-colony.dto'; import { SplitColonyDto } from './dto/split-colony.dto'; import { MergeColonyDto } from './dto/merge-colony.dto'; +export interface ColonyFilter { + status?: ColonyStatus; + apiaryId?: string; +} + @Injectable() export class ColoniesService { constructor( @@ -19,8 +24,11 @@ export class ColoniesService { private readonly auditLog: AuditLogService, ) {} - async findAll(): Promise { - return this.prisma.colony.findMany({ orderBy: { code: 'asc' } }); + async findAll(filter: ColonyFilter = {}): Promise { + return this.prisma.colony.findMany({ + where: { status: filter.status, apiaryId: filter.apiaryId }, + orderBy: { code: 'asc' }, + }); } async findById(id: string): Promise { diff --git a/ApiCore-Backend/src/honey-batches/honey-batches.controller.ts b/ApiCore-Backend/src/honey-batches/honey-batches.controller.ts index a5b07ae..ea100b6 100644 --- a/ApiCore-Backend/src/honey-batches/honey-batches.controller.ts +++ b/ApiCore-Backend/src/honey-batches/honey-batches.controller.ts @@ -1,6 +1,6 @@ -import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { Role } from '../../generated/prisma'; +import { HoneyBatchStatus, Role } from '../../generated/prisma'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; @@ -15,8 +15,8 @@ export class HoneyBatchesController { constructor(private readonly honeyBatchesService: HoneyBatchesService) {} @Get() - findAll() { - return this.honeyBatchesService.findAll(); + findAll(@Query('status') status?: HoneyBatchStatus) { + return this.honeyBatchesService.findAll({ status }); } @Get(':id') diff --git a/ApiCore-Backend/src/honey-batches/honey-batches.service.ts b/ApiCore-Backend/src/honey-batches/honey-batches.service.ts index 58b4343..38fb946 100644 --- a/ApiCore-Backend/src/honey-batches/honey-batches.service.ts +++ b/ApiCore-Backend/src/honey-batches/honey-batches.service.ts @@ -1,6 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { HoneyBatchStatus } from '../../generated/prisma'; import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto'; import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto'; @@ -17,6 +18,10 @@ const honeyBatchInclude = { }, } as const; +export interface HoneyBatchFilter { + status?: HoneyBatchStatus; +} + @Injectable() export class HoneyBatchesService { constructor( @@ -24,8 +29,9 @@ export class HoneyBatchesService { private readonly auditLog: AuditLogService, ) {} - async findAll() { + async findAll(filter: HoneyBatchFilter = {}) { return this.prisma.honeyBatch.findMany({ + where: { status: filter.status }, include: honeyBatchInclude, orderBy: { createdAt: 'desc' }, }); diff --git a/ApiCore-Backend/src/queens/queens.controller.ts b/ApiCore-Backend/src/queens/queens.controller.ts index 3b3f2b2..b599bbf 100644 --- a/ApiCore-Backend/src/queens/queens.controller.ts +++ b/ApiCore-Backend/src/queens/queens.controller.ts @@ -1,6 +1,14 @@ -import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + Query, +} from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { Role } from '../../generated/prisma'; +import { QueenStatus, Role } from '../../generated/prisma'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; @@ -16,8 +24,8 @@ export class QueensController { constructor(private readonly queensService: QueensService) {} @Get() - findAll() { - return this.queensService.findAll(); + findAll(@Query('status') status?: QueenStatus) { + return this.queensService.findAll({ status }); } @Get(':id') diff --git a/ApiCore-Backend/src/queens/queens.service.ts b/ApiCore-Backend/src/queens/queens.service.ts index e74b615..f7ae457 100644 --- a/ApiCore-Backend/src/queens/queens.service.ts +++ b/ApiCore-Backend/src/queens/queens.service.ts @@ -1,11 +1,15 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; -import { Queen } from '../../generated/prisma'; +import { Queen, QueenStatus } from '../../generated/prisma'; import { CreateQueenDto } from './dto/create-queen.dto'; import { UpdateQueenDto } from './dto/update-queen.dto'; import { AssignColonyDto } from './dto/assign-colony.dto'; +export interface QueenFilter { + status?: QueenStatus; +} + @Injectable() export class QueensService { constructor( @@ -13,8 +17,11 @@ export class QueensService { private readonly auditLog: AuditLogService, ) {} - async findAll(): Promise { - return this.prisma.queen.findMany({ orderBy: { code: 'asc' } }); + async findAll(filter: QueenFilter = {}): Promise { + return this.prisma.queen.findMany({ + where: { status: filter.status }, + orderBy: { code: 'asc' }, + }); } async findById(id: string): Promise { diff --git a/ApiCore-Backend/src/search/search.controller.ts b/ApiCore-Backend/src/search/search.controller.ts new file mode 100644 index 0000000..cd48c28 --- /dev/null +++ b/ApiCore-Backend/src/search/search.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { SearchService } from './search.service'; + +@ApiTags('search') +@ApiBearerAuth() +@Controller('search') +export class SearchController { + constructor(private readonly searchService: SearchService) {} + + @Get() + search(@Query('q') q?: string) { + return this.searchService.search(q ?? ''); + } +} diff --git a/ApiCore-Backend/src/search/search.module.ts b/ApiCore-Backend/src/search/search.module.ts new file mode 100644 index 0000000..238d0e8 --- /dev/null +++ b/ApiCore-Backend/src/search/search.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { SearchController } from './search.controller'; +import { SearchService } from './search.service'; + +@Module({ + controllers: [SearchController], + providers: [SearchService], +}) +export class SearchModule {} diff --git a/ApiCore-Backend/src/search/search.service.spec.ts b/ApiCore-Backend/src/search/search.service.spec.ts new file mode 100644 index 0000000..cf1bf23 --- /dev/null +++ b/ApiCore-Backend/src/search/search.service.spec.ts @@ -0,0 +1,48 @@ +import { SearchService } from './search.service'; +import { PrismaService } from '../prisma/prisma.service'; + +describe('SearchService', () => { + let service: SearchService; + let prisma: { + apiary: { findMany: jest.Mock }; + colony: { findMany: jest.Mock }; + queen: { findMany: jest.Mock }; + task: { findMany: jest.Mock }; + honeyBatch: { findMany: jest.Mock }; + }; + + beforeEach(() => { + prisma = { + apiary: { findMany: jest.fn().mockResolvedValue([]) }, + colony: { findMany: jest.fn().mockResolvedValue([]) }, + queen: { findMany: jest.fn().mockResolvedValue([]) }, + task: { findMany: jest.fn().mockResolvedValue([]) }, + honeyBatch: { findMany: jest.fn().mockResolvedValue([]) }, + }; + service = new SearchService(prisma as unknown as PrismaService); + }); + + it('returns an empty array for a blank query', async () => { + const results = await service.search(' '); + expect(results).toEqual([]); + expect(prisma.apiary.findMany).not.toHaveBeenCalled(); + }); + + it('combines matches from every entity type', async () => { + prisma.colony.findMany.mockResolvedValue([ + { id: 'colony-1', code: 'V-001' }, + ]); + prisma.apiary.findMany.mockResolvedValue([ + { id: 'apiary-1', name: 'Heimstand' }, + ]); + + const results = await service.search('V-001'); + + expect(results).toEqual( + expect.arrayContaining([ + { type: 'Colony', id: 'colony-1', label: 'V-001' }, + { type: 'Apiary', id: 'apiary-1', label: 'Heimstand' }, + ]), + ); + }); +}); diff --git a/ApiCore-Backend/src/search/search.service.ts b/ApiCore-Backend/src/search/search.service.ts new file mode 100644 index 0000000..345d988 --- /dev/null +++ b/ApiCore-Backend/src/search/search.service.ts @@ -0,0 +1,75 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +export interface SearchResult { + type: 'Apiary' | 'Colony' | 'Queen' | 'Task' | 'HoneyBatch'; + id: string; + label: string; +} + +const RESULT_LIMIT = 10; + +@Injectable() +export class SearchService { + constructor(private readonly prisma: PrismaService) {} + + async search(q: string): Promise { + const term = q.trim(); + if (term.length === 0) { + return []; + } + + const [apiaries, colonies, queens, tasks, honeyBatches] = await Promise.all( + [ + this.prisma.apiary.findMany({ + where: { name: { contains: term, mode: 'insensitive' } }, + take: RESULT_LIMIT, + }), + this.prisma.colony.findMany({ + where: { code: { contains: term, mode: 'insensitive' } }, + take: RESULT_LIMIT, + }), + this.prisma.queen.findMany({ + where: { code: { contains: term, mode: 'insensitive' } }, + take: RESULT_LIMIT, + }), + this.prisma.task.findMany({ + where: { title: { contains: term, mode: 'insensitive' } }, + take: RESULT_LIMIT, + }), + this.prisma.honeyBatch.findMany({ + where: { batchNumber: { contains: term, mode: 'insensitive' } }, + take: RESULT_LIMIT, + }), + ], + ); + + return [ + ...apiaries.map((apiary) => ({ + type: 'Apiary' as const, + id: apiary.id, + label: apiary.name, + })), + ...colonies.map((colony) => ({ + type: 'Colony' as const, + id: colony.id, + label: colony.code, + })), + ...queens.map((queen) => ({ + type: 'Queen' as const, + id: queen.id, + label: queen.code, + })), + ...tasks.map((task) => ({ + type: 'Task' as const, + id: task.id, + label: task.title, + })), + ...honeyBatches.map((honeyBatch) => ({ + type: 'HoneyBatch' as const, + id: honeyBatch.id, + label: honeyBatch.batchNumber, + })), + ]; + } +} diff --git a/ApiCore-Backend/test/search.e2e-spec.ts b/ApiCore-Backend/test/search.e2e-spec.ts new file mode 100644 index 0000000..d3f7dd0 --- /dev/null +++ b/ApiCore-Backend/test/search.e2e-spec.ts @@ -0,0 +1,97 @@ +import { INestApplication } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { createTestApp } from './utils/create-test-app'; +import { login } from './utils/api'; + +interface SearchResultBody { + type: string; + id: string; + label: string; +} + +interface ColonyBody { + id: string; + status: string; +} + +describe('Search and filters (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const readonlyEmail = `search-e2e-readonly-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let readonlyId: string; + let readonlyToken: string; + let apiaryId: string; + let colonyId: string; + let dissolvedColonyId: string; + const uniqueCode = `SRCH-${Date.now()}`; + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + + const passwordHash = await bcrypt.hash(password, 12); + const readonly = await prisma.user.create({ + data: { + email: readonlyEmail, + passwordHash, + name: 'Readonly', + role: 'READONLY', + }, + }); + readonlyId = readonly.id; + readonlyToken = (await login(app, readonlyEmail, password)).accessToken; + + const apiary = await prisma.apiary.create({ + data: { name: `Stand ${Date.now()}` }, + }); + apiaryId = apiary.id; + const colony = await prisma.colony.create({ + data: { code: uniqueCode, apiaryId, colonyType: 'PRODUCTION_COLONY' }, + }); + colonyId = colony.id; + const dissolvedColony = await prisma.colony.create({ + data: { + code: `${uniqueCode}-DISSOLVED`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + status: 'DISSOLVED', + }, + }); + dissolvedColonyId = dissolvedColony.id; + }); + + afterAll(async () => { + await prisma.colony.deleteMany({ + where: { id: { in: [colonyId, dissolvedColonyId] } }, + }); + await prisma.apiary.deleteMany({ where: { id: apiaryId } }); + await prisma.refreshToken.deleteMany({ where: { userId: readonlyId } }); + await prisma.auditLog.deleteMany({ where: { userId: readonlyId } }); + await prisma.user.deleteMany({ where: { id: readonlyId } }); + await app.close(); + }); + + it('finds a colony by its code across entity types', async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v1/search?q=${uniqueCode}`) + .set('Authorization', `Bearer ${readonlyToken}`) + .expect(200); + const results = response.body as SearchResultBody[]; + expect( + results.some((entry) => entry.type === 'Colony' && entry.id === colonyId), + ).toBe(true); + }); + + it('filters colonies by status', async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v1/colonies?status=DISSOLVED&apiaryId=${apiaryId}`) + .set('Authorization', `Bearer ${readonlyToken}`) + .expect(200); + const colonies = response.body as ColonyBody[]; + expect(colonies.map((entry) => entry.id)).toEqual([dissolvedColonyId]); + }); +}); diff --git a/TASKS.md b/TASKS.md index 894d4ba..827967a 100644 --- a/TASKS.md +++ b/TASKS.md @@ -121,7 +121,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo - Backend: `SearchModule` (`GET /api/v1/search?q=`), Filter-Query-Parameter in bestehenden List-Endpunkten (Standort, Status, Zeitraum etc., kombinierbar) - Frontend: `/search`, Filterleisten in allen Listen-Views -- [ ] Backend-Modul fertig, getestet, grün, committet +- [x] Backend-Modul fertig, getestet, grün, committet - [ ] Frontend-Views fertig, getestet, grün, committet ### 15. Responsive Smartphone-Oberfläche