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:
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user