feat(11-06): TenderSavedSearch model + CRUD service (FILTER-06)
GREEN phase — adds TenderSavedSearch (userId+tenantId scoped, filters
Json, @@unique([userId,name])), the migration (applied to local dev DB),
and TenderSavedSearchService following the FavoritesService/
TenderTriageService pattern: manual where:{userId} scoping (no
forTenant()/RLS), ownership check before update/remove, P2002 unique
conflicts translated to ConflictException.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
-- CreateTable
|
||||||
|
-- FILTER-06 (D-08/D-11) — per-user Suchprofil (benannte Filterkombination).
|
||||||
|
-- Scoping-Muster wie FavoriteLink/TenderTriage (T-08-06, Pitfall 4):
|
||||||
|
-- userId-Scoping im Service, KEIN forTenant()/RLS. tenantId wird
|
||||||
|
-- zusätzlich mitgeführt (spätere Tenant-Isolation), ist aber nicht das
|
||||||
|
-- Scoping-Feld. KEIN Tender-FK (Pitfall 6) — filters speichert nur
|
||||||
|
-- Kriterien, nicht Tender-Ids, daher unkritisch bei der Retention.
|
||||||
|
CREATE TABLE IF NOT EXISTS "TenderSavedSearch" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"filters" JSONB NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "TenderSavedSearch_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
-- Verhindert zwei Profile gleichen Namens pro Nutzer (T-11-14).
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "TenderSavedSearch_userId_name_key" ON "TenderSavedSearch"("userId", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX IF NOT EXISTS "TenderSavedSearch_userId_idx" ON "TenderSavedSearch"("userId");
|
||||||
@@ -316,6 +316,26 @@ model TenderTriage {
|
|||||||
@@index([tenderId])
|
@@index([tenderId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FILTER-06 — per-user Suchprofil (D-08/D-11). Scoping-Muster wie
|
||||||
|
// FavoriteLink/TenderTriage (T-08-06, Pitfall 4): userId-Scoping im
|
||||||
|
// Service, KEIN forTenant()/RLS. tenantId wird zusätzlich mitgeführt
|
||||||
|
// (spätere Tenant-Isolation), ist aber NICHT das Scoping-Feld. KEIN
|
||||||
|
// Tender-FK (Pitfall 6) — ein Profil speichert nur die Filterkriterien
|
||||||
|
// (deckungsgleich mit den URL-searchParams, Plan 11-06), nicht Tender-Ids,
|
||||||
|
// daher unkritisch bei der 90-Tage-Retention.
|
||||||
|
model TenderSavedSearch {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
tenantId String
|
||||||
|
name String
|
||||||
|
filters Json // serialisierte Filterkombination (q, plz, bundesland, region, cpv, deadlineFrom/To, openOnly, valueMin/Max, includeNullValue, sort, favOnly)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([userId, name]) // keine zwei Profile gleichen Namens pro Nutzer
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
// Singleton-per-source admin poll config (INGEST-06 foundation).
|
// Singleton-per-source admin poll config (INGEST-06 foundation).
|
||||||
model TenderSourcePollConfig {
|
model TenderSourcePollConfig {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { IsNotEmpty, IsObject, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body DTO for POST /modules/tender-radar/saved-searches (FILTER-06).
|
||||||
|
*
|
||||||
|
* Security (T-11-14 / V4 — IDOR): deliberately has NO userId or tenantId
|
||||||
|
* field — both are always derived server-side from the auth context
|
||||||
|
* (FavoritesController.extractContext pattern) in TendersController, never
|
||||||
|
* trusted from the request body.
|
||||||
|
*
|
||||||
|
* `filters` is validated only as a plain object (T-11-17 / V5) — its shape
|
||||||
|
* mirrors the FilterPanel's URL-searchParams contract on the frontend, but
|
||||||
|
* the backend does not re-validate individual filter keys here; Prisma
|
||||||
|
* stores it as parametrized JSONB (no string interpolation, V5).
|
||||||
|
*/
|
||||||
|
export class CreateSavedSearchDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(100)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
filters!: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body DTO for PATCH /modules/tender-radar/saved-searches/:searchId.
|
||||||
|
* Both fields optional — only provided fields are updated (FavoritesService
|
||||||
|
* UpdateFavoriteDto pattern).
|
||||||
|
*/
|
||||||
|
export class UpdateSavedSearchDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(100)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
filters?: Record<string, unknown>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreateSavedSearchDto, UpdateSavedSearchDto } from './dto/saved-search.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for managing per-user Tender saved searches (Suchprofile,
|
||||||
|
* FILTER-06, D-08/D-11).
|
||||||
|
*
|
||||||
|
* Access control (T-11-14 / V4 — IDOR): every query is scoped by userId,
|
||||||
|
* exactly the FavoritesService/TenderTriageService convention (T-08-06) —
|
||||||
|
* NOT forTenant()/RLS (Pitfall 4). userId must always be derived from the
|
||||||
|
* caller's auth context (controller), never accepted as a body/query
|
||||||
|
* parameter here.
|
||||||
|
*
|
||||||
|
* @@unique([userId, name]) (T-11-14): a second profile with the same name
|
||||||
|
* for the same user is rejected by Postgres (P2002) — this service
|
||||||
|
* translates that into a 409 ConflictException so the frontend can show a
|
||||||
|
* meaningful "Name bereits vergeben" message instead of a raw 500.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class TenderSavedSearchService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all saved searches for a user, ordered by name asc. Scoped
|
||||||
|
* strictly by userId (V4/IDOR) — a foreign userId sees nothing.
|
||||||
|
*/
|
||||||
|
async list(userId: string) {
|
||||||
|
return this.prisma.tenderSavedSearch.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new saved search. Throws ConflictException (409) if this
|
||||||
|
* user already has a profile with the same name
|
||||||
|
* (@@unique([userId,name])) — the same name IS allowed across different
|
||||||
|
* users, since the uniqueness is scoped per-user.
|
||||||
|
*/
|
||||||
|
async create(userId: string, tenantId: string, dto: CreateSavedSearchDto) {
|
||||||
|
try {
|
||||||
|
return await this.prisma.tenderSavedSearch.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
tenantId,
|
||||||
|
name: dto.name,
|
||||||
|
filters: dto.filters,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'P2002') {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Ein Suchprofil mit diesem Namen existiert bereits.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing saved search (rename and/or filters change).
|
||||||
|
* Verifies userId ownership before applying changes (T-11-14) — throws
|
||||||
|
* NotFoundException for BOTH a missing row and a foreign-owned row
|
||||||
|
* (FavoritesService pattern: never distinguishes the two, to avoid
|
||||||
|
* leaking whether another user's profile exists).
|
||||||
|
*/
|
||||||
|
async update(id: string, userId: string, dto: UpdateSavedSearchDto) {
|
||||||
|
const existing = await this.prisma.tenderSavedSearch.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing || existing.userId !== userId) {
|
||||||
|
throw new NotFoundException('Suchprofil nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
if (dto.name !== undefined) data.name = dto.name;
|
||||||
|
if (dto.filters !== undefined) data.filters = dto.filters;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.prisma.tenderSavedSearch.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'P2002') {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Ein Suchprofil mit diesem Namen existiert bereits.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a saved search. Verifies userId ownership before deleting
|
||||||
|
* (T-11-14) — same missing-vs-foreign NotFoundException collapse as
|
||||||
|
* update().
|
||||||
|
*/
|
||||||
|
async remove(id: string, userId: string) {
|
||||||
|
const existing = await this.prisma.tenderSavedSearch.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing || existing.userId !== userId) {
|
||||||
|
throw new NotFoundException('Suchprofil nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.tenderSavedSearch.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user