feat(17-02): delete protection and 20-feed cap for personal RSS feeds

- remove(id, {userId, isAdmin}) replaces remove(id): single conditional
  deleteMany (id AND (owned-by-caller OR admin-on-platform-feed)) — no
  TOCTOU window, ownership check lives in the DB condition. Deletes
  nothing -> NotFoundException (never Forbidden, no existence leak)
- createForUser rejects a caller's 21st personal feed with a clear
  German message (T-17-10); platform-wide feeds are not counted
- DELETE /rss-feeds/:feedId moves from @Roles(ADMIN,SUPER_ADMIN) to
  @UseModule('tender-radar') — ownership check does the gating now
- Tests use a Prisma double that actually evaluates the where condition
  (not a double that always "succeeds") for both deleteMany and count

- Files modified: apps/api/src/tenders/tender-rss-feed.service.ts, apps/api/src/tenders/tenders.controller.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:41:55 +02:00
parent adb72f611f
commit 96161556db
4 changed files with 324 additions and 34 deletions
+19 -7
View File
@@ -302,15 +302,27 @@ export class TendersController {
}
/**
* DELETE /modules/tender-radar/rss-feeds/:feedId — remove a global RSS
* feed. Uses `:feedId` (not `:id`) so this route can never be confused
* with the Tender `:id` route below (Pitfall 5, same convention as
* `saved-searches/:searchId`).
* DELETE /modules/tender-radar/rss-feeds/:feedId — remove a feed.
* Ownership is enforced entirely inside the service's single conditional
* `deleteMany` (T-17-07): the caller may delete their own feed, or — if
* ADMIN/SUPER_ADMIN — a platform-wide feed. Anything else (someone
* else's personal feed, or a non-admin targeting a platform-wide feed)
* surfaces as `NotFoundException`, never `ForbiddenException` — the
* response never confirms whether a foreign id exists. Phase 17: replaced
* `@Roles(ADMIN, SUPER_ADMIN)` with `@UseModule('tender-radar')` — every
* module user may reach this route now, the ownership check does the
* rest.
*
* Uses `:feedId` (not `:id`) so this route can never be confused with
* the Tender `:id` route below (Pitfall 5, same convention as
* `saved-searches/:searchId`). Route position UNCHANGED.
*/
@Delete('rss-feeds/:feedId')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async removeRssFeed(@Param('feedId') feedId: string) {
return this.tenderRssFeedSource.remove(feedId);
@UseModule('tender-radar')
async removeRssFeed(@Param('feedId') feedId: string, @Req() req: Request) {
const { userId, role } = this.extractTriageContext(req);
const isAdmin = role === Role.ADMIN || role === Role.SUPER_ADMIN;
return this.tenderRssFeedSource.remove(feedId, { userId, isAdmin });
}
// ─── E-Mail-Alerts config (ModuleGuard-gated, PER-USER, Phase 17 D-01) ─────