feat(17-02): RSS feeds get an owner — platform-wide vs personal (D-02)
- TenderRssFeedSource.userId/tenantId (nullable): null = platform-wide
(admin-managed, includes the existing service.bund.de default),
set = personal feed owned by exactly one user
- Migration replaces url @unique with @@unique([userId, url]) — two
users can now follow the same address independently; existing rows
keep an empty owner (platform-wide, unchanged behavior)
- Service: listForUser/createForUser/createPlatform replace list/create
- Controller: GET/POST /rss-feeds move from @Roles(ADMIN,SUPER_ADMIN) to
@UseModule('tender-radar'); POST with scope:'platform' still requires
ADMIN/SUPER_ADMIN, checked inline (T-17-08)
- tenders.module.ts seed switched from upsert-on-url to find-then-create
(Rule 3, pulled forward from Task 3): the new compound unique index
requires a non-null userId in Prisma's generated type, so a
platform-wide row can no longer be addressed via upsert
- Files modified: apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260812110000_tender_rss_feed_owner/migration.sql, apps/api/src/tenders/tender-rss-feed.service.ts, apps/api/src/tenders/dto/tender-rss-feed.dto.ts, apps/api/src/tenders/tenders.controller.ts, apps/api/src/tenders/tenders.module.ts, apps/api/src/tenders/tender-rss-feed.service.spec.ts, apps/api/src/tenders/tenders.controller.spec.ts
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
-- Phase 17 (D-02): RSS-Feeds bekommen einen Besitzer. Bisher galt
|
||||||
|
-- "TenderRssFeedSource.url" plattformweit eindeutig -- ab dieser Migration
|
||||||
|
-- gibt es zwei Sorten: plattformweite Feeds (userId = NULL, von der
|
||||||
|
-- Administration gepflegt, gilt fuer alle -- dazu gehoert der seit Phase 14
|
||||||
|
-- gesetzte service.bund.de-Feed) und persoenliche Feeds (userId gesetzt,
|
||||||
|
-- gehoeren genau einem Nutzer). Bestandszeilen werden NICHT angefasst --
|
||||||
|
-- sie behalten einen leeren Besitzer und sind damit plattformweit, also
|
||||||
|
-- genau der heutige Zustand (17-CONTEXT.md, offener Punkt 2).
|
||||||
|
--
|
||||||
|
-- tenantId ist -- wie bei TenderEmailConfig aus Plan 17-01 -- ein
|
||||||
|
-- denormalisiertes Feld des Besitzers, leer bei plattformweiten Feeds;
|
||||||
|
-- es traegt spaeter die D-13-Herkunftsmarkierung fuer Ausschreibungen aus
|
||||||
|
-- persoenlichen Feeds (D-06, 17-02-PLAN.md).
|
||||||
|
|
||||||
|
-- 1. Beide Spalten ohne Pflichtwert -- Bestandszeilen bleiben unangetastet
|
||||||
|
-- (leerer Besitzer = plattformweit, entspricht dem Ist-Zustand).
|
||||||
|
ALTER TABLE "TenderRssFeedSource" ADD COLUMN "userId" TEXT;
|
||||||
|
ALTER TABLE "TenderRssFeedSource" ADD COLUMN "tenantId" TEXT;
|
||||||
|
|
||||||
|
-- 2. Alte Eindeutigkeitsregel "eine Adresse plattformweit" aufheben --
|
||||||
|
-- zwei Nutzer sollen dieselbe Adresse unabhaengig voneinander verfolgen
|
||||||
|
-- koennen.
|
||||||
|
DROP INDEX "TenderRssFeedSource_url_key";
|
||||||
|
|
||||||
|
-- 3. Neue Eindeutigkeitsregel: eine Adresse ist je Besitzer eindeutig.
|
||||||
|
-- Leere Werte gelten in PostgreSQL in einer Eindeutigkeitsregel als
|
||||||
|
-- jeweils verschieden -- das deckt daher nur persoenliche Feeds ab,
|
||||||
|
-- nicht doppelte plattformweite Feeds (siehe Begruendung 17-02-PLAN.md
|
||||||
|
-- Objective, T-17-13, bewusst hingenommen).
|
||||||
|
CREATE UNIQUE INDEX "TenderRssFeedSource_userId_url_key" ON "TenderRssFeedSource"("userId", "url");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TenderRssFeedSource_userId_idx" ON "TenderRssFeedSource"("userId");
|
||||||
@@ -546,19 +546,40 @@ model TenderSourcePollConfig {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 14, Plan 02 (INGEST-04, D-14) — admin-managed GLOBAL RSS feed list.
|
// Phase 14, Plan 02 (INGEST-04, D-14) — admin-managed RSS feed list.
|
||||||
// Deliberately NO tenantId (mirrors TenderSourcePollConfig's global/
|
// Feed URLs are RUNTIME admin/user input — unlike the hardcoded
|
||||||
// RLS-exempt stance, D-08: RSS feeds are public and identical for every
|
|
||||||
// tenant). Feed URLs are RUNTIME admin input — unlike the hardcoded
|
|
||||||
// NETSERVER_PORTALS/COSINEX_BASE_URL constants, the code-level
|
// NETSERVER_PORTALS/COSINEX_BASE_URL constants, the code-level
|
||||||
// SourceRegistry denylist gate does NOT cover this data (RESEARCH.md
|
// SourceRegistry denylist gate does NOT cover this data (RESEARCH.md
|
||||||
// Pitfall 3); TenderRssFeedSourceService enforces a SEPARATE save-time
|
// Pitfall 3); TenderRssFeedSourceService enforces a SEPARATE save-time
|
||||||
// hostname/SSRF guard (T-14-02-01) on create/update.
|
// hostname/SSRF guard (T-14-02-01) on create/update.
|
||||||
|
//
|
||||||
|
// Phase 17, Plan 02 (D-02): the list is now two-part. `userId = null` is a
|
||||||
|
// PLATFORM-WIDE feed — admin-managed, active for every tenant (mirrors
|
||||||
|
// TenderSourcePollConfig's global/RLS-exempt stance, D-08); this is the
|
||||||
|
// bucket the service.bund.de default (seeded since Phase 14) lives in, and
|
||||||
|
// stays there unmigrated (17-CONTEXT.md, offener Punkt 2). `userId` set is
|
||||||
|
// a PERSONAL feed owned by exactly one user. `tenantId` is denormalized
|
||||||
|
// from the owner (same role as `TenderEmailConfig.tenantId`, Phase 17 Plan
|
||||||
|
// 01) — null for platform-wide feeds, set for personal feeds so
|
||||||
|
// `RssAdapter` can tag their ingested `Tender` rows with the existing D-13
|
||||||
|
// `ownerTenantId` origin marking (D-06, this plan).
|
||||||
|
//
|
||||||
|
// `@@unique([userId, url])` replaces the old `url @unique`: two different
|
||||||
|
// users may now follow the same address. NULL is distinct per-row in a
|
||||||
|
// PostgreSQL unique index, so this does NOT prevent the same URL being
|
||||||
|
// registered twice platform-wide (both userId NULL) — deliberately
|
||||||
|
// accepted, see 17-02-PLAN.md Objective (T-17-13): cross-source dedup
|
||||||
|
// absorbs the duplicate, only costing one extra fetch.
|
||||||
model TenderRssFeedSource {
|
model TenderRssFeedSource {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
url String @unique
|
url String
|
||||||
label String
|
label String
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
userId String? // null = platform-wide (D-02); set = personal feed owner
|
||||||
|
tenantId String? // denormalized owner's tenant, null for platform-wide feeds (D-06)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([userId, url])
|
||||||
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsUrl,
|
IsUrl,
|
||||||
@@ -8,13 +9,14 @@ import {
|
|||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DTO for admin-managed RSS feed sources (`TenderRssFeedSource`, D-14/D-08).
|
* DTO for RSS feed sources (`TenderRssFeedSource`, D-14/D-08; ownership
|
||||||
|
* split personal/platform-wide since Phase 17, Plan 02, D-02).
|
||||||
*
|
*
|
||||||
* `@IsUrl` here is only a COARSE well-formedness check (http/https,
|
* `@IsUrl` here is only a COARSE well-formedness check (http/https,
|
||||||
* protocol required). The SUBSTANTIVE SSRF/denylist guard — rejecting
|
* protocol required). The SUBSTANTIVE SSRF/denylist guard — rejecting
|
||||||
* DENYLISTED_PORTALS hostnames and private/loopback hosts — is enforced in
|
* DENYLISTED_PORTALS hostnames and private/loopback hosts — is enforced in
|
||||||
* `TenderRssFeedSourceService.assertUrlAllowed()`, NOT here (RESEARCH.md
|
* `TenderRssFeedSourceService.assertUrlAllowed()`, NOT here (RESEARCH.md
|
||||||
* Pitfall 3: an admin-supplied RSS feed URL is runtime data, added long
|
* Pitfall 3: a user-supplied RSS feed URL is runtime data, added long
|
||||||
* after `SourceRegistry.register()`'s DI-boot-time denylist check runs —
|
* after `SourceRegistry.register()`'s DI-boot-time denylist check runs —
|
||||||
* this DTO alone provides zero protection against a feed URL pointing at
|
* this DTO alone provides zero protection against a feed URL pointing at
|
||||||
* vergabe24/aumass or an internal host). `require_tld: false` deliberately
|
* vergabe24/aumass or an internal host). `require_tld: false` deliberately
|
||||||
@@ -38,4 +40,15 @@ export class TenderRssFeedDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A WISH only, never trusted as authorization by itself (D-02): 'platform'
|
||||||
|
* additionally requires the caller to hold ADMIN/SUPER_ADMIN, enforced
|
||||||
|
* server-side in `TendersController.createRssFeed` — never here or in the
|
||||||
|
* service. Default 'personal' so an ordinary module user's POST creates a
|
||||||
|
* feed they own without needing to know this field exists.
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['personal', 'platform'])
|
||||||
|
scope?: 'personal' | 'platform';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,24 +4,40 @@ import { TenderRssFeedSourceService } from './tender-rss-feed.service';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* TenderRssFeedSourceService.spec — proves the save-time hostname/SSRF
|
* TenderRssFeedSourceService.spec — proves the save-time hostname/SSRF
|
||||||
* guard (T-14-02-01, D-14/RESEARCH.md Pitfall 3): a runtime-admin-supplied
|
* guard (T-14-02-01, D-14/RESEARCH.md Pitfall 3): a runtime-user-supplied
|
||||||
* RSS feed URL is NOT covered by the code-level SourceRegistry denylist
|
* RSS feed URL is NOT covered by the code-level SourceRegistry denylist
|
||||||
* gate (that only checks an adapter's statically-declared `portals` array
|
* gate (that only checks an adapter's statically-declared `portals` array
|
||||||
* at DI-boot time) — this service is the separate, independent
|
* at DI-boot time) — this service is the separate, independent
|
||||||
* enforcement point.
|
* enforcement point. Also proves the ownership split introduced in Phase
|
||||||
|
* 17, Plan 02 (D-02): `listForUser` returns platform-wide feeds plus the
|
||||||
|
* caller's own, `createForUser` stamps an owner, `createPlatform` leaves
|
||||||
|
* ownership empty.
|
||||||
*
|
*
|
||||||
* Uses the same hand-rolled prisma-shaped fake convention as
|
* Uses the same hand-rolled prisma-shaped fake convention as
|
||||||
* tender-notification-pref.service.spec.ts / tender-saved-search.service.spec.ts
|
* tender-notification-pref.service.spec.ts / tender-saved-search.service.spec.ts
|
||||||
* (in-memory Map, no live DB connection).
|
* (in-memory Map, no live DB connection). Unlike the pre-Phase-17 fake, this
|
||||||
|
* one EVALUATES the `where` clause (OR/AND/equality) so `listForUser`'s
|
||||||
|
* ownership scoping is actually proven, not just assumed.
|
||||||
*/
|
*/
|
||||||
|
function matchesWhere(row: any, where: any): boolean {
|
||||||
|
if (!where) return true;
|
||||||
|
if ('OR' in where) {
|
||||||
|
return (where.OR as any[]).some((clause) => matchesWhere(row, clause));
|
||||||
|
}
|
||||||
|
if ('AND' in where) {
|
||||||
|
return (where.AND as any[]).every((clause) => matchesWhere(row, clause));
|
||||||
|
}
|
||||||
|
return Object.entries(where).every(([key, value]) => row[key] === value);
|
||||||
|
}
|
||||||
|
|
||||||
function makeFakePrisma() {
|
function makeFakePrisma() {
|
||||||
const rows = new Map<string, any>();
|
const rows = new Map<string, any>();
|
||||||
let seq = 0;
|
let seq = 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tenderRssFeedSource: {
|
tenderRssFeedSource: {
|
||||||
findMany: async ({ orderBy }: any) => {
|
findMany: async ({ where, orderBy }: any = {}) => {
|
||||||
const all = [...rows.values()];
|
let all = [...rows.values()].filter((row) => matchesWhere(row, where));
|
||||||
if (orderBy?.createdAt === 'asc') {
|
if (orderBy?.createdAt === 'asc') {
|
||||||
all.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
all.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||||
}
|
}
|
||||||
@@ -31,6 +47,8 @@ function makeFakePrisma() {
|
|||||||
seq += 1;
|
seq += 1;
|
||||||
const row = {
|
const row = {
|
||||||
id: `feed-${seq}`,
|
id: `feed-${seq}`,
|
||||||
|
userId: null,
|
||||||
|
tenantId: null,
|
||||||
createdAt: new Date(Date.now() + seq),
|
createdAt: new Date(Date.now() + seq),
|
||||||
updatedAt: new Date(Date.now() + seq),
|
updatedAt: new Date(Date.now() + seq),
|
||||||
...data,
|
...data,
|
||||||
@@ -49,13 +67,13 @@ function makeFakePrisma() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('TenderRssFeedSourceService', () => {
|
describe('TenderRssFeedSourceService', () => {
|
||||||
describe('create() — save-time hostname/SSRF guard (T-14-02-01)', () => {
|
describe('createPlatform() — save-time hostname/SSRF guard (T-14-02-01)', () => {
|
||||||
it('rejects a vergabe24.de feed URL (denylisted portal, D-14)', async () => {
|
it('rejects a vergabe24.de feed URL (denylisted portal, D-14)', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const prisma = makeFakePrisma();
|
||||||
const service = new TenderRssFeedSourceService(prisma as any);
|
const service = new TenderRssFeedSourceService(prisma as any);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.create({
|
service.createPlatform({
|
||||||
url: 'https://www.vergabe24.de/rss.xml',
|
url: 'https://www.vergabe24.de/rss.xml',
|
||||||
label: 'vergabe24',
|
label: 'vergabe24',
|
||||||
}),
|
}),
|
||||||
@@ -68,7 +86,7 @@ describe('TenderRssFeedSourceService', () => {
|
|||||||
const service = new TenderRssFeedSourceService(prisma as any);
|
const service = new TenderRssFeedSourceService(prisma as any);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.create({ url: 'https://aumass.de/feed', label: 'aumass' }),
|
service.createPlatform({ url: 'https://aumass.de/feed', label: 'aumass' }),
|
||||||
).rejects.toThrow(BadRequestException);
|
).rejects.toThrow(BadRequestException);
|
||||||
expect(prisma.__rows.size).toBe(0);
|
expect(prisma.__rows.size).toBe(0);
|
||||||
});
|
});
|
||||||
@@ -78,7 +96,7 @@ describe('TenderRssFeedSourceService', () => {
|
|||||||
const service = new TenderRssFeedSourceService(prisma as any);
|
const service = new TenderRssFeedSourceService(prisma as any);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.create({
|
service.createPlatform({
|
||||||
url: 'https://feeds.vergabe24.de/rss.xml',
|
url: 'https://feeds.vergabe24.de/rss.xml',
|
||||||
label: 'vergabe24-sub',
|
label: 'vergabe24-sub',
|
||||||
}),
|
}),
|
||||||
@@ -89,7 +107,7 @@ describe('TenderRssFeedSourceService', () => {
|
|||||||
const prisma = makeFakePrisma();
|
const prisma = makeFakePrisma();
|
||||||
const service = new TenderRssFeedSourceService(prisma as any);
|
const service = new TenderRssFeedSourceService(prisma as any);
|
||||||
|
|
||||||
const created = await service.create({
|
const created = await service.createPlatform({
|
||||||
url: 'https://www.service.bund.de/Content/Globals/Functions/RSSFeed/RSSGenerator_Ausschreibungen.xml',
|
url: 'https://www.service.bund.de/Content/Globals/Functions/RSSFeed/RSSGenerator_Ausschreibungen.xml',
|
||||||
label: 'service-bund',
|
label: 'service-bund',
|
||||||
});
|
});
|
||||||
@@ -106,7 +124,7 @@ describe('TenderRssFeedSourceService', () => {
|
|||||||
const service = new TenderRssFeedSourceService(prisma as any);
|
const service = new TenderRssFeedSourceService(prisma as any);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.create({ url: 'file:///etc/passwd', label: 'local-file' }),
|
service.createPlatform({ url: 'file:///etc/passwd', label: 'local-file' }),
|
||||||
).rejects.toThrow(BadRequestException);
|
).rejects.toThrow(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,7 +133,7 @@ describe('TenderRssFeedSourceService', () => {
|
|||||||
const service = new TenderRssFeedSourceService(prisma as any);
|
const service = new TenderRssFeedSourceService(prisma as any);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.create({ url: 'not-a-url', label: 'broken' }),
|
service.createPlatform({ url: 'not-a-url', label: 'broken' }),
|
||||||
).rejects.toThrow(BadRequestException);
|
).rejects.toThrow(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,7 +142,7 @@ describe('TenderRssFeedSourceService', () => {
|
|||||||
const service = new TenderRssFeedSourceService(prisma as any);
|
const service = new TenderRssFeedSourceService(prisma as any);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.create({
|
service.createPlatform({
|
||||||
url: 'http://127.0.0.1:8080/internal-feed.xml',
|
url: 'http://127.0.0.1:8080/internal-feed.xml',
|
||||||
label: 'internal',
|
label: 'internal',
|
||||||
}),
|
}),
|
||||||
@@ -136,7 +154,7 @@ describe('TenderRssFeedSourceService', () => {
|
|||||||
const service = new TenderRssFeedSourceService(prisma as any);
|
const service = new TenderRssFeedSourceService(prisma as any);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.create({ url: 'http://localhost:3000/feed', label: 'x' }),
|
service.createPlatform({ url: 'http://localhost:3000/feed', label: 'x' }),
|
||||||
).rejects.toThrow(BadRequestException);
|
).rejects.toThrow(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -145,7 +163,7 @@ describe('TenderRssFeedSourceService', () => {
|
|||||||
const service = new TenderRssFeedSourceService(prisma as any);
|
const service = new TenderRssFeedSourceService(prisma as any);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.create({ url: 'http://10.0.0.5/feed', label: 'x' }),
|
service.createPlatform({ url: 'http://10.0.0.5/feed', label: 'x' }),
|
||||||
).rejects.toThrow(BadRequestException);
|
).rejects.toThrow(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -154,7 +172,7 @@ describe('TenderRssFeedSourceService', () => {
|
|||||||
const service = new TenderRssFeedSourceService(prisma as any);
|
const service = new TenderRssFeedSourceService(prisma as any);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.create({ url: 'http://192.168.1.1/feed', label: 'x' }),
|
service.createPlatform({ url: 'http://192.168.1.1/feed', label: 'x' }),
|
||||||
).rejects.toThrow(BadRequestException);
|
).rejects.toThrow(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -163,7 +181,7 @@ describe('TenderRssFeedSourceService', () => {
|
|||||||
const service = new TenderRssFeedSourceService(prisma as any);
|
const service = new TenderRssFeedSourceService(prisma as any);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.create({ url: 'http://[::1]/feed', label: 'x' }),
|
service.createPlatform({ url: 'http://[::1]/feed', label: 'x' }),
|
||||||
).rejects.toThrow(BadRequestException);
|
).rejects.toThrow(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -171,7 +189,7 @@ describe('TenderRssFeedSourceService', () => {
|
|||||||
const prisma = makeFakePrisma();
|
const prisma = makeFakePrisma();
|
||||||
const service = new TenderRssFeedSourceService(prisma as any);
|
const service = new TenderRssFeedSourceService(prisma as any);
|
||||||
|
|
||||||
const created = await service.create({
|
const created = await service.createPlatform({
|
||||||
url: 'https://example-tenders.invalid/rss.xml',
|
url: 'https://example-tenders.invalid/rss.xml',
|
||||||
label: 'inactive-feed',
|
label: 'inactive-feed',
|
||||||
isActive: false,
|
isActive: false,
|
||||||
@@ -181,29 +199,83 @@ describe('TenderRssFeedSourceService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('list()/remove()', () => {
|
describe('listForUser()/createForUser()/remove() — ownership split (Phase 17, Plan 02, D-02)', () => {
|
||||||
it('list() returns created feeds ordered by createdAt asc', async () => {
|
it('listForUser() returns platform-wide feeds ordered by createdAt asc when the caller has none of their own', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const prisma = makeFakePrisma();
|
||||||
const service = new TenderRssFeedSourceService(prisma as any);
|
const service = new TenderRssFeedSourceService(prisma as any);
|
||||||
|
|
||||||
await service.create({
|
await service.createPlatform({
|
||||||
url: 'https://a.example-tenders.invalid/rss.xml',
|
url: 'https://a.example-tenders.invalid/rss.xml',
|
||||||
label: 'a',
|
label: 'a',
|
||||||
});
|
});
|
||||||
await service.create({
|
await service.createPlatform({
|
||||||
url: 'https://b.example-tenders.invalid/rss.xml',
|
url: 'https://b.example-tenders.invalid/rss.xml',
|
||||||
label: 'b',
|
label: 'b',
|
||||||
});
|
});
|
||||||
|
|
||||||
const list = await service.list();
|
const list = await service.listForUser('u-anyone');
|
||||||
expect(list.map((f: any) => f.label)).toEqual(['a', 'b']);
|
expect(list.map((f: any) => f.label)).toEqual(['a', 'b']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('listForUser(userId) includes platform-wide feeds plus this user\'s own personal feeds, never another user\'s', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new TenderRssFeedSourceService(prisma as any);
|
||||||
|
|
||||||
|
await service.createPlatform({
|
||||||
|
url: 'https://platform.example-tenders.invalid/rss.xml',
|
||||||
|
label: 'platform-feed',
|
||||||
|
});
|
||||||
|
await service.createForUser(
|
||||||
|
{ userId: 'user-a', tenantId: 'tenant-a' },
|
||||||
|
{ url: 'https://a-only.example-tenders.invalid/rss.xml', label: 'a-only' },
|
||||||
|
);
|
||||||
|
await service.createForUser(
|
||||||
|
{ userId: 'user-b', tenantId: 'tenant-b' },
|
||||||
|
{ url: 'https://b-only.example-tenders.invalid/rss.xml', label: 'b-only' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const listForA = await service.listForUser('user-a');
|
||||||
|
expect(listForA.map((f: any) => f.label).sort()).toEqual(['a-only', 'platform-feed']);
|
||||||
|
|
||||||
|
const listForB = await service.listForUser('user-b');
|
||||||
|
expect(listForB.map((f: any) => f.label).sort()).toEqual(['b-only', 'platform-feed']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createForUser() stamps the owner\'s userId/tenantId; createPlatform() leaves both null', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new TenderRssFeedSourceService(prisma as any);
|
||||||
|
|
||||||
|
const personal = await service.createForUser(
|
||||||
|
{ userId: 'user-a', tenantId: 'tenant-a' },
|
||||||
|
{ url: 'https://mine.example-tenders.invalid/rss.xml', label: 'mine' },
|
||||||
|
);
|
||||||
|
const platform = await service.createPlatform({
|
||||||
|
url: 'https://platform-only.example-tenders.invalid/rss.xml',
|
||||||
|
label: 'platform-only',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(personal.userId).toBe('user-a');
|
||||||
|
expect(personal.tenantId).toBe('tenant-a');
|
||||||
|
expect(platform.userId).toBeNull();
|
||||||
|
expect(platform.tenantId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('two different users may each register the same URL independently (D-02)', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new TenderRssFeedSourceService(prisma as any);
|
||||||
|
const sameUrl = 'https://shared.example-tenders.invalid/rss.xml';
|
||||||
|
|
||||||
|
await service.createForUser({ userId: 'user-a', tenantId: 'tenant-a' }, { url: sameUrl, label: 'a-copy' });
|
||||||
|
await service.createForUser({ userId: 'user-b', tenantId: 'tenant-b' }, { url: sameUrl, label: 'b-copy' });
|
||||||
|
|
||||||
|
expect(prisma.__rows.size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
it('remove() deletes the feed by id', async () => {
|
it('remove() deletes the feed by id', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const prisma = makeFakePrisma();
|
||||||
const service = new TenderRssFeedSourceService(prisma as any);
|
const service = new TenderRssFeedSourceService(prisma as any);
|
||||||
|
|
||||||
const created = await service.create({
|
const created = await service.createPlatform({
|
||||||
url: 'https://c.example-tenders.invalid/rss.xml',
|
url: 'https://c.example-tenders.invalid/rss.xml',
|
||||||
label: 'c',
|
label: 'c',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,31 +4,71 @@ import type { TenderRssFeedDto } from './dto/tender-rss-feed.dto';
|
|||||||
import { DENYLISTED_PORTALS } from './source-registry';
|
import { DENYLISTED_PORTALS } from './source-registry';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TenderRssFeedSourceService — admin CRUD for the GLOBAL RSS feed list
|
* TenderRssFeedSourceService — CRUD for the RSS feed list
|
||||||
* (`TenderRssFeedSource`, D-14/D-08). No `forTenant()`/RLS — this is
|
* (`TenderRssFeedSource`, D-14/D-08). No `forTenant()`/RLS — ownership is
|
||||||
* platform-wide config, mirroring `TenderSourcePollConfig`'s stance.
|
* expressed via the nullable `userId`/`tenantId` columns, not tenant
|
||||||
|
* middleware (mirrors `TenderEmailConfig`'s per-user stance since Phase
|
||||||
|
* 17, Plan 01).
|
||||||
|
*
|
||||||
|
* Phase 17, Plan 02 (D-02): the feed list is two-part now — platform-wide
|
||||||
|
* feeds (`userId = null`, admin-managed, active for every tenant — this is
|
||||||
|
* where the service.bund.de default seeded since Phase 14 lives) and
|
||||||
|
* personal feeds (`userId` set, owned by exactly one user). Every module
|
||||||
|
* user can create a personal feed via `createForUser`; only ADMIN/
|
||||||
|
* SUPER_ADMIN may create a platform-wide one via `createPlatform` — that
|
||||||
|
* role check happens in the CONTROLLER (T-17-08), not here, mirroring
|
||||||
|
* `saveEmailConfig`'s "auth context resolved by the caller" convention.
|
||||||
*
|
*
|
||||||
* Save-time hostname/SSRF guard (T-14-02-01, RESEARCH.md Pitfall 3): RSS
|
* Save-time hostname/SSRF guard (T-14-02-01, RESEARCH.md Pitfall 3): RSS
|
||||||
* feed URLs are RUNTIME admin input, added long after
|
* feed URLs are RUNTIME user input, added long after
|
||||||
* `SourceRegistry.register()`'s DI-boot-time `DENYLISTED_PORTALS` check
|
* `SourceRegistry.register()`'s DI-boot-time `DENYLISTED_PORTALS` check
|
||||||
* runs — that gate provides ZERO protection here. This service is the
|
* runs — that gate provides ZERO protection here. This service is the
|
||||||
* SEPARATE, independent enforcement point: reject non-http(s) schemes,
|
* SEPARATE, independent enforcement point: reject non-http(s) schemes,
|
||||||
* reject any hostname containing a `DENYLISTED_PORTALS` entry (same
|
* reject any hostname containing a `DENYLISTED_PORTALS` entry (same
|
||||||
* constant as the code-level gate — single source of truth, D-14), and
|
* constant as the code-level gate — single source of truth, D-14), and
|
||||||
* reject private/loopback hosts (SSRF guard, analog to T-10-06). Runs on
|
* reject private/loopback hosts (SSRF guard, analog to T-10-06). Runs on
|
||||||
* BOTH create and update paths.
|
* EVERY write path (T-17-09) — it matters more now that ordinary users,
|
||||||
|
* not just admins, can reach it.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TenderRssFeedSourceService {
|
export class TenderRssFeedSourceService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async list() {
|
/**
|
||||||
|
* Every platform-wide feed (`userId = null`) plus this user's own
|
||||||
|
* personal feeds, oldest first (D-02).
|
||||||
|
*/
|
||||||
|
async listForUser(userId: string) {
|
||||||
return this.prisma.tenderRssFeedSource.findMany({
|
return this.prisma.tenderRssFeedSource.findMany({
|
||||||
|
where: { OR: [{ userId: null }, { userId }] },
|
||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: TenderRssFeedDto) {
|
/** Creates a personal feed owned by `ctx.userId` (D-02). */
|
||||||
|
async createForUser(
|
||||||
|
ctx: { userId: string; tenantId: string },
|
||||||
|
dto: TenderRssFeedDto,
|
||||||
|
) {
|
||||||
|
this.assertUrlAllowed(dto.url);
|
||||||
|
|
||||||
|
return this.prisma.tenderRssFeedSource.create({
|
||||||
|
data: {
|
||||||
|
url: dto.url,
|
||||||
|
label: dto.label,
|
||||||
|
isActive: dto.isActive ?? true,
|
||||||
|
userId: ctx.userId,
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a platform-wide feed (`userId`/`tenantId` stay null). Callers
|
||||||
|
* MUST verify ADMIN/SUPER_ADMIN before calling this — this method itself
|
||||||
|
* enforces no authorization (T-17-08, done in TendersController).
|
||||||
|
*/
|
||||||
|
async createPlatform(dto: TenderRssFeedDto) {
|
||||||
this.assertUrlAllowed(dto.url);
|
this.assertUrlAllowed(dto.url);
|
||||||
|
|
||||||
return this.prisma.tenderRssFeedSource.create({
|
return this.prisma.tenderRssFeedSource.create({
|
||||||
|
|||||||
@@ -89,15 +89,31 @@ function makeFakeRequest(userId = 'u1', tenantId = 'tenant1') {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fake TenderRssFeedSourceService for controller-level wiring tests (Plan
|
* Fake TenderRssFeedSourceService for controller-level wiring tests (Plan
|
||||||
* 14-02, Task 3). Default stubs echo/list-nothing; individual tests
|
* 14-02, Task 3; ownership split since Phase 17, Plan 02, D-02). Default
|
||||||
* override via `.mockResolvedValueOnce`/reassigning the mock — including
|
* stubs echo/list-nothing; individual tests override via
|
||||||
* `create` rejecting with BadRequestException to prove the denylist error
|
* `.mockResolvedValueOnce`/reassigning the mock — including `createForUser`/
|
||||||
* surfaces through the controller unchanged.
|
* `createPlatform` rejecting with BadRequestException to prove the
|
||||||
|
* denylist error surfaces through the controller unchanged.
|
||||||
*/
|
*/
|
||||||
function makeFakeRssFeedService() {
|
function makeFakeRssFeedService() {
|
||||||
return {
|
return {
|
||||||
list: vi.fn(async () => [] as any[]),
|
listForUser: vi.fn(async (_userId: string) => [] as any[]),
|
||||||
create: vi.fn(async (dto: any) => ({ id: 'feed-1', isActive: true, ...dto })),
|
createForUser: vi.fn(
|
||||||
|
async (ctx: { userId: string; tenantId: string }, dto: any) => ({
|
||||||
|
id: 'feed-1',
|
||||||
|
isActive: true,
|
||||||
|
userId: ctx.userId,
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
...dto,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
createPlatform: vi.fn(async (dto: any) => ({
|
||||||
|
id: 'feed-1',
|
||||||
|
isActive: true,
|
||||||
|
userId: null,
|
||||||
|
tenantId: null,
|
||||||
|
...dto,
|
||||||
|
})),
|
||||||
remove: vi.fn(async (_id: string) => ({ success: true })),
|
remove: vi.fn(async (_id: string) => ({ success: true })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -871,13 +887,14 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)',
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', () => {
|
describe('TendersController — RSS-feeds personal + platform-wide (Plan 14-02 D-14/D-08, ownership split Phase 17 Plan 02 D-02)', () => {
|
||||||
it('GET /rss-feeds delegates to tenderRssFeedSource.list()', async () => {
|
it('GET /rss-feeds delegates to tenderRssFeedSource.listForUser(userId) and maps isPlatformWide, stripping userId', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const prisma = makeFakePrisma();
|
||||||
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||||
const rssFeedService = makeFakeRssFeedService();
|
const rssFeedService = makeFakeRssFeedService();
|
||||||
rssFeedService.list.mockResolvedValueOnce([
|
rssFeedService.listForUser.mockResolvedValueOnce([
|
||||||
{ id: 'f1', url: 'https://service.bund.de/rss.xml', label: 'service-bund' },
|
{ id: 'f1', url: 'https://service.bund.de/rss.xml', label: 'service-bund', userId: null },
|
||||||
|
{ id: 'f2', url: 'https://mine.invalid/rss.xml', label: 'mine', userId: 'u1' },
|
||||||
]);
|
]);
|
||||||
const controller = new TendersController(
|
const controller = new TendersController(
|
||||||
prisma as any,
|
prisma as any,
|
||||||
@@ -889,15 +906,17 @@ describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', (
|
|||||||
makeFakeEmailConfigService() as any,
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await controller.listRssFeeds();
|
const result = await controller.listRssFeeds(makeFakeRequest('u1', 'tenant1'));
|
||||||
|
|
||||||
expect(rssFeedService.list).toHaveBeenCalledTimes(1);
|
expect(rssFeedService.listForUser).toHaveBeenCalledWith('u1');
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
{ id: 'f1', url: 'https://service.bund.de/rss.xml', label: 'service-bund' },
|
{ id: 'f1', url: 'https://service.bund.de/rss.xml', label: 'service-bund', isPlatformWide: true },
|
||||||
|
{ id: 'f2', url: 'https://mine.invalid/rss.xml', label: 'mine', isPlatformWide: false },
|
||||||
]);
|
]);
|
||||||
|
expect(result.every((f: any) => !('userId' in f))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /rss-feeds delegates to tenderRssFeedSource.create(dto)', async () => {
|
it('POST /rss-feeds with scope omitted creates a personal feed via createForUser({userId,tenantId}, dto)', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const prisma = makeFakePrisma();
|
||||||
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||||
const rssFeedService = makeFakeRssFeedService();
|
const rssFeedService = makeFakeRssFeedService();
|
||||||
@@ -911,17 +930,18 @@ describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', (
|
|||||||
makeFakeEmailConfigService() as any,
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
const dto = { url: 'https://service.bund.de/rss.xml', label: 'service-bund' } as any;
|
const dto = { url: 'https://mine.invalid/rss.xml', label: 'mine' } as any;
|
||||||
await controller.createRssFeed(dto);
|
await controller.createRssFeed(dto, makeFakeRequest('u1', 'tenant1'));
|
||||||
|
|
||||||
expect(rssFeedService.create).toHaveBeenCalledWith(dto);
|
expect(rssFeedService.createForUser).toHaveBeenCalledWith({ userId: 'u1', tenantId: 'tenant1' }, dto);
|
||||||
|
expect(rssFeedService.createPlatform).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /rss-feeds surfaces a BadRequestException from the service when the URL is denylisted (D-14)', async () => {
|
it('POST /rss-feeds surfaces a BadRequestException from the service when the URL is denylisted (D-14)', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const prisma = makeFakePrisma();
|
||||||
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||||
const rssFeedService = makeFakeRssFeedService();
|
const rssFeedService = makeFakeRssFeedService();
|
||||||
rssFeedService.create.mockRejectedValueOnce(
|
rssFeedService.createForUser.mockRejectedValueOnce(
|
||||||
new BadRequestException("Der Host 'www.vergabe24.de' ist AGB-seitig für automatisierten Zugriff gesperrt"),
|
new BadRequestException("Der Host 'www.vergabe24.de' ist AGB-seitig für automatisierten Zugriff gesperrt"),
|
||||||
);
|
);
|
||||||
const controller = new TendersController(
|
const controller = new TendersController(
|
||||||
@@ -935,10 +955,10 @@ describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', (
|
|||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
controller.createRssFeed({
|
controller.createRssFeed(
|
||||||
url: 'https://www.vergabe24.de/rss.xml',
|
{ url: 'https://www.vergabe24.de/rss.xml', label: 'vergabe24' } as any,
|
||||||
label: 'vergabe24',
|
makeFakeRequest('u1', 'tenant1'),
|
||||||
} as any),
|
),
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,17 @@ const DOE_SOURCE_TYPE = 'doe-opendata';
|
|||||||
* apply the D-13 OR[global, mine] visibility filter for PRIVATE
|
* apply the D-13 OR[global, mine] visibility filter for PRIVATE
|
||||||
* (email-alert) tenders — public tenders (D-03) remain visible to every
|
* (email-alert) tenders — public tenders (D-03) remain visible to every
|
||||||
* tenant exactly as before; that part of D-13 is unaffected by D-01.
|
* tenant exactly as before; that part of D-13 is unaffected by D-01.
|
||||||
|
*
|
||||||
|
* Phase 14, Plan 02 (INGEST-04, D-14) originally made `GET`/`POST`/`DELETE
|
||||||
|
* /rss-feeds` per-handler `@Roles(ADMIN, SUPER_ADMIN)`-guarded, GLOBAL-only.
|
||||||
|
* Phase 17, Plan 02 (D-02) changed this: the feed list is now two-part —
|
||||||
|
* platform-wide feeds (unchanged, admin-only to create/delete) and personal
|
||||||
|
* feeds any module user may create and delete for themselves. `GET`/`POST
|
||||||
|
* /rss-feeds` are `@UseModule('tender-radar')`-gated; `POST`'s
|
||||||
|
* `scope: 'platform'` path re-checks ADMIN/SUPER_ADMIN inline
|
||||||
|
* (`extractTriageContext`'s `role`, T-17-08) since the route itself is no
|
||||||
|
* longer admin-only. `DELETE /rss-feeds/:feedId` ownership enforcement is
|
||||||
|
* described at that handler.
|
||||||
*/
|
*/
|
||||||
@Controller('modules/tender-radar')
|
@Controller('modules/tender-radar')
|
||||||
export class TendersController {
|
export class TendersController {
|
||||||
@@ -93,14 +104,21 @@ export class TendersController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts (userId, tenantId) for the per-user Triage routes — same
|
* Extracts (userId, tenantId, role) for the per-user routes — same
|
||||||
* pattern as FavoritesController.extractContext (T-08-06): userId/
|
* pattern as FavoritesController.extractContext (T-08-06): userId/
|
||||||
* tenantId are ALWAYS read from the authenticated request context, never
|
* tenantId/role are ALWAYS read from the authenticated request context,
|
||||||
* from a client-supplied body/query field (T-11-10 / V4 — IDOR).
|
* never from a client-supplied body/query field (T-11-10 / V4 — IDOR).
|
||||||
|
*
|
||||||
|
* `role` was added in Phase 17, Plan 02 (D-02): `createRssFeed` needs the
|
||||||
|
* caller's role to decide whether a `scope: 'platform'` request is
|
||||||
|
* allowed (T-17-08), read from the SAME place `RolesGuard` reads it
|
||||||
|
* (`roles.guard.ts`) — one single spot in this controller resolves
|
||||||
|
* account data from the request.
|
||||||
*/
|
*/
|
||||||
private extractTriageContext(req: Request) {
|
private extractTriageContext(req: Request) {
|
||||||
const userId = (req as any).user?.id;
|
const userId = (req as any).user?.id;
|
||||||
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
|
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
|
||||||
|
const role = (req as any).user?.role;
|
||||||
|
|
||||||
if (!tenantId) {
|
if (!tenantId) {
|
||||||
throw new ForbiddenException('No tenant context');
|
throw new ForbiddenException('No tenant context');
|
||||||
@@ -109,7 +127,7 @@ export class TendersController {
|
|||||||
throw new ForbiddenException('No user context');
|
throw new ForbiddenException('No user context');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { userId, tenantId };
|
return { userId, tenantId, role };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -227,35 +245,60 @@ export class TendersController {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── RSS-Feeds admin CRUD (Roles-guarded, GLOBAL, D-08/D-14) ───────────────
|
// ─── RSS-Feeds (ModuleGuard-gated, personal + platform-wide, Phase 17 D-02) ─
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /modules/tender-radar/rss-feeds — list every admin-managed RSS feed
|
* GET /modules/tender-radar/rss-feeds — every platform-wide feed plus the
|
||||||
* (global, no tenantId — D-08). `@Roles`-guarded: this is a
|
* requesting user's own personal feeds (D-02). Phase 17: replaced
|
||||||
* platform-admin action, not a per-tenant module feature, same stance as
|
* `@Roles(ADMIN, SUPER_ADMIN)` with `@UseModule('tender-radar')` — every
|
||||||
* `source-config` above.
|
* module user can see (and add) their own feeds now, not just admins.
|
||||||
|
* Each entry gets a derived `isPlatformWide` flag; the raw `userId`
|
||||||
|
* ownership field is stripped from the response (T-17-12) — the UI has
|
||||||
|
* no need for it.
|
||||||
*
|
*
|
||||||
* MUST be declared before `@Get(':id')` below — same route-order pitfall
|
* MUST be declared before `@Get(':id')` below — same route-order pitfall
|
||||||
* as `source-config`/`coverage`/`triage`/... above (Pitfall 5): NestJS
|
* as `source-config`/`coverage`/`triage`/... above (Pitfall 5). Route
|
||||||
* matches routes in declaration order, so a `@Get(':id')` placed first
|
* position is UNCHANGED from before Phase 17 — no new route was added,
|
||||||
* would capture "rss-feeds" as an id and shadow this handler.
|
* only the guard and the handler body.
|
||||||
*/
|
*/
|
||||||
@Get('rss-feeds')
|
@Get('rss-feeds')
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
@UseModule('tender-radar')
|
||||||
async listRssFeeds() {
|
async listRssFeeds(@Req() req: Request) {
|
||||||
return this.tenderRssFeedSource.list();
|
const { userId } = this.extractTriageContext(req);
|
||||||
|
const feeds = await this.tenderRssFeedSource.listForUser(userId);
|
||||||
|
|
||||||
|
return feeds.map(({ userId: ownerUserId, ...rest }) => ({
|
||||||
|
...rest,
|
||||||
|
isPlatformWide: ownerUserId === null,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /modules/tender-radar/rss-feeds — add a new global RSS feed URL.
|
* POST /modules/tender-radar/rss-feeds — add a feed. `dto.scope` is a
|
||||||
* The save-time hostname/SSRF guard (D-14, T-14-02-01) lives in
|
* WISH, never trusted by itself: `scope: 'platform'` additionally
|
||||||
* `TenderRssFeedSourceService.create()` — a denylisted/private-host URL
|
* requires the caller to hold ADMIN/SUPER_ADMIN (T-17-08), checked here
|
||||||
* surfaces as a 400 (BadRequestException) here, unchanged.
|
* against the SAME `role` source `RolesGuard` reads
|
||||||
|
* (`extractTriageContext`); any other/omitted scope creates a personal
|
||||||
|
* feed owned by the caller (D-02). The save-time hostname/SSRF guard
|
||||||
|
* (D-14, T-14-02-01) lives in `TenderRssFeedSourceService` and runs
|
||||||
|
* unchanged on both paths — a denylisted/private-host URL still surfaces
|
||||||
|
* as a 400 (BadRequestException) here.
|
||||||
*/
|
*/
|
||||||
@Post('rss-feeds')
|
@Post('rss-feeds')
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
@UseModule('tender-radar')
|
||||||
async createRssFeed(@Body() dto: TenderRssFeedDto) {
|
async createRssFeed(@Body() dto: TenderRssFeedDto, @Req() req: Request) {
|
||||||
return this.tenderRssFeedSource.create(dto);
|
const { userId, tenantId, role } = this.extractTriageContext(req);
|
||||||
|
|
||||||
|
if (dto.scope === 'platform') {
|
||||||
|
if (role !== Role.ADMIN && role !== Role.SUPER_ADMIN) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Nur Administratoren dürfen plattformweite RSS-Feeds anlegen.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.tenderRssFeedSource.createPlatform(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.tenderRssFeedSource.createForUser({ userId, tenantId }, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -282,20 +282,33 @@ export class TendersModule implements OnModuleInit {
|
|||||||
// single default-active service.bund.de feed row — the one genuinely
|
// single default-active service.bund.de feed row — the one genuinely
|
||||||
// national/global RSS source. subreport-elvis has no single
|
// national/global RSS source. subreport-elvis has no single
|
||||||
// canonical URL (per-municipality instances, RESEARCH.md Pitfall 2)
|
// canonical URL (per-municipality instances, RESEARCH.md Pitfall 2)
|
||||||
// — deliberately ZERO subreport-elvis rows seeded; admins add the
|
// — deliberately ZERO subreport-elvis rows seeded; admins/users add
|
||||||
// municipality feeds relevant to them via the RSS-Feeds admin UI
|
// the municipality feeds relevant to them via the RSS-Feeds UI
|
||||||
// (Plan 14-02 Task 3).
|
// (Plan 14-02 Task 3).
|
||||||
await this.prisma.tenderRssFeedSource.upsert({
|
//
|
||||||
|
// Phase 17, Plan 02 (D-02): "upsert on url" no longer works — the
|
||||||
|
// unique index moved to (userId, url), and Prisma's generated
|
||||||
|
// compound-unique input type REQUIRES userId as a plain `string`
|
||||||
|
// (not `string | null | undefined`), so a platform-wide row
|
||||||
|
// (userId = null) can never be addressed through it. Switched to
|
||||||
|
// "find a platform-wide row with this url first, only create if
|
||||||
|
// none exists" — an ordinary equality condition has no such
|
||||||
|
// requirement. Still idempotent across repeated app starts.
|
||||||
|
const existingServiceBundFeed = await this.prisma.tenderRssFeedSource.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
userId: null,
|
||||||
url: 'https://www.service.bund.de/Content/Globals/Functions/RSSFeed/RSSGenerator_Ausschreibungen.xml',
|
url: 'https://www.service.bund.de/Content/Globals/Functions/RSSFeed/RSSGenerator_Ausschreibungen.xml',
|
||||||
},
|
},
|
||||||
update: {},
|
});
|
||||||
create: {
|
if (!existingServiceBundFeed) {
|
||||||
|
await this.prisma.tenderRssFeedSource.create({
|
||||||
|
data: {
|
||||||
url: 'https://www.service.bund.de/Content/Globals/Functions/RSSFeed/RSSGenerator_Ausschreibungen.xml',
|
url: 'https://www.service.bund.de/Content/Globals/Functions/RSSFeed/RSSGenerator_Ausschreibungen.xml',
|
||||||
label: 'service-bund',
|
label: 'service-bund',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
this.logger.log('service.bund.de default RSS feed seeded (active)');
|
this.logger.log('service.bund.de default RSS feed seeded (active)');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to seed service.bund.de RSS feed', error);
|
this.logger.error('Failed to seed service.bund.de RSS feed', error);
|
||||||
|
|||||||
Reference in New Issue
Block a user