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:
2026-08-12 11:37:56 +02:00
parent 71dcb302a3
commit adb72f611f
8 changed files with 346 additions and 91 deletions
+65 -22
View File
@@ -70,6 +70,17 @@ const DOE_SOURCE_TYPE = 'doe-opendata';
* apply the D-13 OR[global, mine] visibility filter for PRIVATE
* (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.
*
* 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')
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/
* tenantId are ALWAYS read from the authenticated request context, never
* from a client-supplied body/query field (T-11-10 / V4 — IDOR).
* tenantId/role are ALWAYS read from the authenticated request context,
* 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) {
const userId = (req as any).user?.id;
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
const role = (req as any).user?.role;
if (!tenantId) {
throw new ForbiddenException('No tenant context');
@@ -109,7 +127,7 @@ export class TendersController {
throw new ForbiddenException('No user context');
}
return { userId, tenantId };
return { userId, tenantId, role };
}
/**
@@ -227,35 +245,60 @@ export class TendersController {
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
* (global, no tenantId — D-08). `@Roles`-guarded: this is a
* platform-admin action, not a per-tenant module feature, same stance as
* `source-config` above.
* GET /modules/tender-radar/rss-feeds — every platform-wide feed plus the
* requesting user's own personal feeds (D-02). Phase 17: replaced
* `@Roles(ADMIN, SUPER_ADMIN)` with `@UseModule('tender-radar')` — every
* 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
* as `source-config`/`coverage`/`triage`/... above (Pitfall 5): NestJS
* matches routes in declaration order, so a `@Get(':id')` placed first
* would capture "rss-feeds" as an id and shadow this handler.
* as `source-config`/`coverage`/`triage`/... above (Pitfall 5). Route
* position is UNCHANGED from before Phase 17 — no new route was added,
* only the guard and the handler body.
*/
@Get('rss-feeds')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async listRssFeeds() {
return this.tenderRssFeedSource.list();
@UseModule('tender-radar')
async listRssFeeds(@Req() req: Request) {
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.
* The save-time hostname/SSRF guard (D-14, T-14-02-01) lives in
* `TenderRssFeedSourceService.create()` — a denylisted/private-host URL
* surfaces as a 400 (BadRequestException) here, unchanged.
* POST /modules/tender-radar/rss-feeds — add a feed. `dto.scope` is a
* WISH, never trusted by itself: `scope: 'platform'` additionally
* requires the caller to hold ADMIN/SUPER_ADMIN (T-17-08), checked here
* 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')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async createRssFeed(@Body() dto: TenderRssFeedDto) {
return this.tenderRssFeedSource.create(dto);
@UseModule('tender-radar')
async createRssFeed(@Body() dto: TenderRssFeedDto, @Req() req: Request) {
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);
}
/**