diff --git a/apps/api/src/tenders/adapters/rss.adapter.spec.ts b/apps/api/src/tenders/adapters/rss.adapter.spec.ts index e34ea90..8d3ef84 100644 --- a/apps/api/src/tenders/adapters/rss.adapter.spec.ts +++ b/apps/api/src/tenders/adapters/rss.adapter.spec.ts @@ -306,6 +306,82 @@ describe('RssAdapter', () => { expect(records).toEqual([]); // oversized feed skipped, catch-per-feed (D-01) }); + + describe('D-06 ownerTenantId tagging (Phase 17, Plan 02, personal vs platform-wide feeds)', () => { + it('a feed row with a tenantId produces ONLY tagged records', async () => { + const feeds = [ + { + id: 'f1', + url: 'https://personal.invalid/rss.xml', + label: 'personal-feed', + isActive: true, + userId: 'user-a', + tenantId: 'tenant-a', + }, + ]; + const prisma = makeFakePrisma(feeds); + stubFetchResolvingXml({ 'https://personal.invalid/rss.xml': SERVICE_BUND_FIXTURE }); + const adapter = new RssAdapter(prisma); + + const records = await adapter.fetchTenders('2026-07-23'); + + expect(records.length).toBeGreaterThan(0); + expect(records.every((r) => r.ownerTenantId === 'tenant-a')).toBe(true); + }); + + it('a feed row without a tenantId (platform-wide) produces ONLY untagged records', async () => { + const feeds = [ + { + id: 'f1', + url: 'https://platform.invalid/rss.xml', + label: 'platform-feed', + isActive: true, + userId: null, + tenantId: null, + }, + ]; + const prisma = makeFakePrisma(feeds); + stubFetchResolvingXml({ 'https://platform.invalid/rss.xml': SERVICE_BUND_FIXTURE }); + const adapter = new RssAdapter(prisma); + + const records = await adapter.fetchTenders('2026-07-23'); + + expect(records.length).toBeGreaterThan(0); + expect(records.every((r) => r.ownerTenantId === undefined)).toBe(true); + }); + + it('two feeds of different owners are both fetched in one run; the first failing never blocks the second\'s tagged records (D-01)', async () => { + const feeds = [ + { + id: 'f1', + url: 'https://broken-owner.invalid/rss.xml', + label: 'broken-owner-feed', + isActive: true, + userId: 'user-a', + tenantId: 'tenant-a', + }, + { + id: 'f2', + url: 'https://ok-owner.invalid/rss.xml', + label: 'ok-owner-feed', + isActive: true, + userId: 'user-b', + tenantId: 'tenant-b', + }, + ]; + const prisma = makeFakePrisma(feeds); + stubFetchResolvingXml({ + // 'broken-owner.invalid' deliberately absent -> stub resolves 404 -> throws + 'https://ok-owner.invalid/rss.xml': SERVICE_BUND_FIXTURE, + }); + const adapter = new RssAdapter(prisma); + + const records = await adapter.fetchTenders('2026-07-23'); + + expect(records.length).toBeGreaterThan(0); + expect(records.every((r) => r.ownerTenantId === 'tenant-b')).toBe(true); + }); + }); }); it('never imports or uses axios (native fetch is the sole HTTP client convention)', () => { diff --git a/apps/api/src/tenders/adapters/rss.adapter.ts b/apps/api/src/tenders/adapters/rss.adapter.ts index 267b0ef..fddda3b 100644 --- a/apps/api/src/tenders/adapters/rss.adapter.ts +++ b/apps/api/src/tenders/adapters/rss.adapter.ts @@ -50,6 +50,15 @@ import type { TenderSourceAdapter } from './tender-source-adapter.interface'; * filter — RSS has no day-batch concept; it is polled every scheduler * tick via `pollGranularity: 'tick'` (D-15, wired in * `tender-ingestion.service.ts`), not gated by the day-cursor at all. + * + * Phase 17, Plan 02 (D-02/D-06): the fan-out now resolves BOTH + * platform-wide (`userId`/`tenantId` null) and personal (`userId` set) + * feeds in the same sweep — the mechanics above are unchanged. Records + * from a feed that carries a `tenantId` are tagged with the same D-13 + * `ownerTenantId` origin marking `EmailAlertAdapter` records have carried + * since Phase 14, so a personal feed's tenders stay visible only within + * the owner's tenant; records from a platform-wide feed stay unmarked and + * visible to everyone, exactly as before. */ const RSS_FETCH_TIMEOUT_MS = 15_000; /** RSS feeds are normally small; an admin-supplied URL is less trusted than a hardcoded portal (RESEARCH.md Security Domain, DoS mitigation). */ @@ -71,9 +80,15 @@ export class RssAdapter implements TenderSourceAdapter { constructor(private readonly prisma: PrismaService) {} /** - * Internal fan-out over every active admin-managed feed (D-14/D-08, - * global — no tenantId). Catch-per-feed (D-01): a single feed that fails - * to fetch/parse is skipped (logger.warn), never aborting the rest. + * Internal fan-out over every active feed — platform-wide and personal + * alike (D-14/D-08/D-02). Catch-per-feed (D-01): a single feed that + * fails to fetch/parse is skipped (logger.warn), never aborting the + * rest, even when two feeds belong to different owners. + * + * D-06 (17-02-PLAN.md): a feed row with a `tenantId` tags every record + * it produces with that same `ownerTenantId` — the pure `parseFeed` + * mapping below stays feed-row-agnostic; the tagging happens here, in + * the loop that actually knows which row a batch of records came from. */ async fetchTenders(_dayCursor: string): Promise { const feeds = await this.prisma.tenderRssFeedSource.findMany({ @@ -85,7 +100,13 @@ export class RssAdapter implements TenderSourceAdapter { for (const feed of feeds) { try { const xml = await this.fetchFeedXml(feed.url); - records.push(...this.parseFeed(xml, feed.label)); + const feedRecords = this.parseFeed(xml, feed.label); + if (feed.tenantId) { + for (const record of feedRecords) { + record.ownerTenantId = feed.tenantId; + } + } + records.push(...feedRecords); } catch (error) { this.logger.warn( `RSS feed '${feed.label}' (${feed.url}) fetch failed, skipping this feed for this tick: ${(error as Error).message}`, diff --git a/apps/api/src/tenders/rss-feed-migration-sql.spec.ts b/apps/api/src/tenders/rss-feed-migration-sql.spec.ts new file mode 100644 index 0000000..5c497bb --- /dev/null +++ b/apps/api/src/tenders/rss-feed-migration-sql.spec.ts @@ -0,0 +1,73 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * Prüft das hand-geschriebene Migrations-SQL für den Wechsel von + * TenderRssFeedSource.url @unique auf @@unique([userId, url]) (Phase 17, + * Plan 02, D-02) — reiner Textabgleich ohne Datenbank, gleiches Muster wie + * doe-url-migration-sql.spec.ts / email-config-migration-sql.spec.ts. + * + * Anders als bei der TenderEmailConfig-Migration (Plan 17-01) gibt es hier + * KEINEN Bestandsdaten-Umzug: eine bereits vorhandene Zeile bekommt keinen + * Besitzer zugewiesen und bleibt damit plattformweit — genau der + * Ist-Zustand (17-CONTEXT.md, offener Punkt 2). Das ist die zentrale + * Korrektheitsbedingung dieser Migration, nicht eine Zuordnungsreihenfolge. + */ + +const MIGRATIONS_DIR = join(__dirname, '../../prisma/migrations'); + +function readMigrationSql(suffix: string): string { + const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.endsWith(suffix)) + .map((entry) => entry.name); + + if (dirs.length !== 1) { + throw new Error( + `Expected exactly one migration directory ending in "${suffix}", found ${dirs.length}: ${dirs.join(', ')}`, + ); + } + + return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8'); +} + +describe('tender_rss_feed_owner migration.sql', () => { + const sql = readMigrationSql('_tender_rss_feed_owner'); + + it('ergaenzt beide neuen Spalten ohne Pflichtwert', () => { + expect(sql).toMatch(/ADD COLUMN\s+"userId"\s+TEXT(?!\s+NOT NULL)/); + expect(sql).toMatch(/ADD COLUMN\s+"tenantId"\s+TEXT(?!\s+NOT NULL)/); + // Weder Spalte traegt irgendwo in der Datei eine Pflicht-Klausel. + expect(sql).not.toMatch(/ALTER COLUMN\s+"userId"\s+SET NOT NULL/); + expect(sql).not.toMatch(/ALTER COLUMN\s+"tenantId"\s+SET NOT NULL/); + }); + + it('hebt den alten eindeutigen Index auf der Adresse auf', () => { + expect(sql).toContain('DROP INDEX "TenderRssFeedSource_url_key"'); + }); + + it('legt einen neuen eindeutigen Index ueber Besitzer und Adresse an', () => { + expect(sql).toContain( + 'CREATE UNIQUE INDEX "TenderRssFeedSource_userId_url_key" ON "TenderRssFeedSource"("userId", "url")', + ); + }); + + it('legt einen gewoehnlichen Index auf dem Besitzer an', () => { + expect(sql).toContain( + 'CREATE INDEX "TenderRssFeedSource_userId_idx" ON "TenderRssFeedSource"("userId")', + ); + }); + + it('enthaelt keine Anweisung, die Bestandszeilen aendert oder loescht — anders als die TenderEmailConfig-Migration (Plan 17-01) gibt es hier keinen Datenumzug', () => { + expect(sql).not.toMatch(/UPDATE\s+"TenderRssFeedSource"/); + expect(sql).not.toMatch(/DELETE FROM\s+"TenderRssFeedSource"/); + }); + + it('legt die neue Eindeutigkeitsregel erst NACH dem Entfernen der alten an (Reihenfolge)', () => { + const dropIdx = sql.indexOf('DROP INDEX "TenderRssFeedSource_url_key"'); + const createIdx = sql.indexOf('CREATE UNIQUE INDEX "TenderRssFeedSource_userId_url_key"'); + + expect(dropIdx).toBeGreaterThanOrEqual(0); + expect(createIdx).toBeGreaterThan(dropIdx); + }); +}); diff --git a/apps/api/src/tenders/tenders.module.ts b/apps/api/src/tenders/tenders.module.ts index 4c7e521..69b95c0 100644 --- a/apps/api/src/tenders/tenders.module.ts +++ b/apps/api/src/tenders/tenders.module.ts @@ -10,7 +10,7 @@ import { EmailAlertAdapter } from './adapters/email-alert.adapter'; import { NetServerAdapter } from './adapters/netserver.adapter'; import { RssAdapter } from './adapters/rss.adapter'; import { SourceRegistry } from './source-registry'; -import { seedTendersModule } from './tenders.seed'; +import { seedServiceBundRssFeed, seedTendersModule } from './tenders.seed'; import { TenderDedupService } from './tender-dedup.service'; import { TenderDigestScheduler } from './tender-digest.scheduler'; import { TenderEmailConfigService } from './tender-email-config.service'; @@ -278,37 +278,13 @@ export class TendersModule implements OnModuleInit { } try { - // Phase 14, Plan 02 (D-14, RESEARCH.md Open Question 3): seed a - // single default-active service.bund.de feed row — the one genuinely - // national/global RSS source. subreport-elvis has no single - // canonical URL (per-municipality instances, RESEARCH.md Pitfall 2) - // — deliberately ZERO subreport-elvis rows seeded; admins/users add - // the municipality feeds relevant to them via the RSS-Feeds UI - // (Plan 14-02 Task 3). - // - // 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: { - userId: null, - url: 'https://www.service.bund.de/Content/Globals/Functions/RSSFeed/RSSGenerator_Ausschreibungen.xml', - }, - }); - if (!existingServiceBundFeed) { - await this.prisma.tenderRssFeedSource.create({ - data: { - url: 'https://www.service.bund.de/Content/Globals/Functions/RSSFeed/RSSGenerator_Ausschreibungen.xml', - label: 'service-bund', - isActive: true, - }, - }); - } + // Phase 17, Plan 02 (Task 3): the actual find-then-create logic (and + // why "upsert on url" stopped working, D-02) lives in + // seedServiceBundRssFeed (tenders.seed.ts) now — extracted the same + // way seedTendersModule already is, so it is a plain testable + // function and not only reachable via a full Nest bootstrap + // (idempotency proven in tenders.seed.spec.ts). + await seedServiceBundRssFeed(this.prisma); this.logger.log('service.bund.de default RSS feed seeded (active)'); } catch (error) { this.logger.error('Failed to seed service.bund.de RSS feed', error); diff --git a/apps/api/src/tenders/tenders.seed.spec.ts b/apps/api/src/tenders/tenders.seed.spec.ts index 8f3da7e..0dcdc70 100644 --- a/apps/api/src/tenders/tenders.seed.spec.ts +++ b/apps/api/src/tenders/tenders.seed.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { seedTendersModule } from './tenders.seed'; +import { SERVICE_BUND_RSS_URL, seedServiceBundRssFeed, seedTendersModule } from './tenders.seed'; describe('seedTendersModule', () => { it('calls moduleRegistryService.seedModule once with tender-radar slug', async () => { @@ -16,3 +16,83 @@ describe('seedTendersModule', () => { expect(arg.description).toHaveProperty('en'); }); }); + +/** + * seedServiceBundRssFeed.spec — proves the Phase 17, Plan 02 (D-02) fix: + * "upsert on url" stopped compiling once the unique index moved to + * (userId, url) — Prisma's generated compound-unique input type requires a + * plain `string` userId, which a platform-wide row (userId = null) can + * never provide. Uses "find a platform-wide row with this url first, only + * create if none exists" instead. In-memory fake, no live DB connection. + */ +function makeFakePrisma() { + const rows = new Map(); + let seq = 0; + + return { + tenderRssFeedSource: { + findFirst: async ({ where }: any) => { + for (const row of rows.values()) { + if ( + row.userId === where.userId && + row.url === where.url + ) { + return row; + } + } + return null; + }, + create: async ({ data }: any) => { + seq += 1; + const row = { id: `feed-${seq}`, userId: null, tenantId: null, ...data }; + rows.set(row.id, row); + return row; + }, + }, + __rows: rows, + }; +} + +describe('seedServiceBundRssFeed', () => { + it('creates a single active, ownerless service.bund.de row when none exists yet', async () => { + const prisma = makeFakePrisma(); + + await seedServiceBundRssFeed(prisma as any); + + expect(prisma.__rows.size).toBe(1); + const [row] = [...prisma.__rows.values()]; + expect(row.url).toBe(SERVICE_BUND_RSS_URL); + expect(row.label).toBe('service-bund'); + expect(row.isActive).toBe(true); + expect(row.userId).toBeNull(); + }); + + it('running the seed twice in a row (simulating a second app start) leaves exactly one ownerless row (D-02, idempotent)', async () => { + const prisma = makeFakePrisma(); + + await seedServiceBundRssFeed(prisma as any); + await seedServiceBundRssFeed(prisma as any); + + expect(prisma.__rows.size).toBe(1); + const [row] = [...prisma.__rows.values()]; + expect(row.userId).toBeNull(); + }); + + it('does not touch an existing personal feed at the same URL owned by a real user', async () => { + const prisma = makeFakePrisma(); + prisma.__rows.set('personal-1', { + id: 'personal-1', + userId: 'user-a', + tenantId: 'tenant-a', + url: SERVICE_BUND_RSS_URL, + label: 'mein-service-bund-feed', + isActive: true, + }); + + await seedServiceBundRssFeed(prisma as any); + + expect(prisma.__rows.size).toBe(2); + const platformRows = [...prisma.__rows.values()].filter((r: any) => r.userId === null); + expect(platformRows).toHaveLength(1); + }); +}); diff --git a/apps/api/src/tenders/tenders.seed.ts b/apps/api/src/tenders/tenders.seed.ts index 55e305f..6f957d5 100644 --- a/apps/api/src/tenders/tenders.seed.ts +++ b/apps/api/src/tenders/tenders.seed.ts @@ -1,4 +1,5 @@ import { ModuleRegistryService } from '../module-registry/module-registry.service'; +import type { PrismaService } from '../prisma/prisma.service'; /** * Seeds the tender-radar module into the module registry. @@ -24,3 +25,45 @@ export async function seedTendersModule( isSystem: true, }); } + +/** The one genuinely national/global RSS source (RESEARCH.md Open Question 3). */ +export const SERVICE_BUND_RSS_URL = + 'https://www.service.bund.de/Content/Globals/Functions/RSSFeed/RSSGenerator_Ausschreibungen.xml'; + +/** + * Seeds the single default-active service.bund.de RSS feed row — the one + * genuinely national/global RSS source (RESEARCH.md Open Question 3, + * Phase 14, Plan 02, D-14). subreport-elvis has no single canonical URL + * (per-municipality instances, RESEARCH.md Pitfall 2) — deliberately ZERO + * subreport-elvis rows are ever seeded; users add the municipality feeds + * relevant to them via the RSS-Feeds UI (Plan 14-02 Task 3). + * + * Extracted out of `TendersModule.onModuleInit` (Phase 17, Plan 02, Task 3) + * — same "logic lives in a plain testable function, the module's lifecycle + * hook only wraps it in try/catch + logging" pattern as `seedTendersModule` + * above — so the idempotency behavior below can be exercised directly by a + * unit test instead of only through a full Nest bootstrap. + * + * Phase 17, Plan 02 (D-02): "upsert on url" stopped working when the + * unique index moved to `(userId, url)` — 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. This function instead FINDS a + * platform-wide row with this url first, and only creates one if none + * exists — an ordinary equality condition has no such requirement. Still + * idempotent across repeated app starts (proven in tenders.seed.spec.ts). + */ +export async function seedServiceBundRssFeed(prisma: PrismaService): Promise { + const existing = await prisma.tenderRssFeedSource.findFirst({ + where: { userId: null, url: SERVICE_BUND_RSS_URL }, + }); + if (existing) return; + + await prisma.tenderRssFeedSource.create({ + data: { + url: SERVICE_BUND_RSS_URL, + label: 'service-bund', + isActive: true, + }, + }); +}