test(17-02): D-06 ownerTenantId tagging for RSS ingestion + seed idempotency
- RssAdapter.fetchTenders tags every record from a feed with a tenantId with the same D-13 ownerTenantId origin marking email-alert records carry since Phase 14; platform-wide feeds (no tenantId) stay unmarked. The pure parseFeed mapping is untouched — tagging happens in the fan-out loop that knows which row a batch came from - Extracted the service.bund.de seed out of TendersModule.onModuleInit into seedServiceBundRssFeed() (tenders.seed.ts, same pattern as the existing seedTendersModule), so the find-then-create idempotency added in Task 1 is unit-tested directly instead of only via a Nest bootstrap - New rss-feed-migration-sql.spec.ts: text-only check of the Task 1 migration file (nullable columns, dropped/created indexes, no existing-row mutation, correct ordering) - Files modified: apps/api/src/tenders/adapters/rss.adapter.ts, apps/api/src/tenders/tenders.module.ts, apps/api/src/tenders/tenders.seed.ts, apps/api/src/tenders/adapters/rss.adapter.spec.ts, apps/api/src/tenders/tenders.seed.spec.ts, apps/api/src/tenders/rss-feed-migration-sql.spec.ts
This commit is contained in:
@@ -306,6 +306,82 @@ describe('RssAdapter', () => {
|
|||||||
|
|
||||||
expect(records).toEqual([]); // oversized feed skipped, catch-per-feed (D-01)
|
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)', () => {
|
it('never imports or uses axios (native fetch is the sole HTTP client convention)', () => {
|
||||||
|
|||||||
@@ -50,6 +50,15 @@ import type { TenderSourceAdapter } from './tender-source-adapter.interface';
|
|||||||
* filter — RSS has no day-batch concept; it is polled every scheduler
|
* filter — RSS has no day-batch concept; it is polled every scheduler
|
||||||
* tick via `pollGranularity: 'tick'` (D-15, wired in
|
* tick via `pollGranularity: 'tick'` (D-15, wired in
|
||||||
* `tender-ingestion.service.ts`), not gated by the day-cursor at all.
|
* `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;
|
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). */
|
/** 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) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal fan-out over every active admin-managed feed (D-14/D-08,
|
* Internal fan-out over every active feed — platform-wide and personal
|
||||||
* global — no tenantId). Catch-per-feed (D-01): a single feed that fails
|
* alike (D-14/D-08/D-02). Catch-per-feed (D-01): a single feed that
|
||||||
* to fetch/parse is skipped (logger.warn), never aborting the rest.
|
* 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<RawTenderRecord[]> {
|
async fetchTenders(_dayCursor: string): Promise<RawTenderRecord[]> {
|
||||||
const feeds = await this.prisma.tenderRssFeedSource.findMany({
|
const feeds = await this.prisma.tenderRssFeedSource.findMany({
|
||||||
@@ -85,7 +100,13 @@ export class RssAdapter implements TenderSourceAdapter {
|
|||||||
for (const feed of feeds) {
|
for (const feed of feeds) {
|
||||||
try {
|
try {
|
||||||
const xml = await this.fetchFeedXml(feed.url);
|
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) {
|
} catch (error) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`RSS feed '${feed.label}' (${feed.url}) fetch failed, skipping this feed for this tick: ${(error as Error).message}`,
|
`RSS feed '${feed.label}' (${feed.url}) fetch failed, skipping this feed for this tick: ${(error as Error).message}`,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,7 +10,7 @@ import { EmailAlertAdapter } from './adapters/email-alert.adapter';
|
|||||||
import { NetServerAdapter } from './adapters/netserver.adapter';
|
import { NetServerAdapter } from './adapters/netserver.adapter';
|
||||||
import { RssAdapter } from './adapters/rss.adapter';
|
import { RssAdapter } from './adapters/rss.adapter';
|
||||||
import { SourceRegistry } from './source-registry';
|
import { SourceRegistry } from './source-registry';
|
||||||
import { seedTendersModule } from './tenders.seed';
|
import { seedServiceBundRssFeed, seedTendersModule } from './tenders.seed';
|
||||||
import { TenderDedupService } from './tender-dedup.service';
|
import { TenderDedupService } from './tender-dedup.service';
|
||||||
import { TenderDigestScheduler } from './tender-digest.scheduler';
|
import { TenderDigestScheduler } from './tender-digest.scheduler';
|
||||||
import { TenderEmailConfigService } from './tender-email-config.service';
|
import { TenderEmailConfigService } from './tender-email-config.service';
|
||||||
@@ -278,37 +278,13 @@ export class TendersModule implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Phase 14, Plan 02 (D-14, RESEARCH.md Open Question 3): seed a
|
// Phase 17, Plan 02 (Task 3): the actual find-then-create logic (and
|
||||||
// single default-active service.bund.de feed row — the one genuinely
|
// why "upsert on url" stopped working, D-02) lives in
|
||||||
// national/global RSS source. subreport-elvis has no single
|
// seedServiceBundRssFeed (tenders.seed.ts) now — extracted the same
|
||||||
// canonical URL (per-municipality instances, RESEARCH.md Pitfall 2)
|
// way seedTendersModule already is, so it is a plain testable
|
||||||
// — deliberately ZERO subreport-elvis rows seeded; admins/users add
|
// function and not only reachable via a full Nest bootstrap
|
||||||
// the municipality feeds relevant to them via the RSS-Feeds UI
|
// (idempotency proven in tenders.seed.spec.ts).
|
||||||
// (Plan 14-02 Task 3).
|
await seedServiceBundRssFeed(this.prisma);
|
||||||
//
|
|
||||||
// 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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { seedTendersModule } from './tenders.seed';
|
import { SERVICE_BUND_RSS_URL, seedServiceBundRssFeed, seedTendersModule } from './tenders.seed';
|
||||||
|
|
||||||
describe('seedTendersModule', () => {
|
describe('seedTendersModule', () => {
|
||||||
it('calls moduleRegistryService.seedModule once with tender-radar slug', async () => {
|
it('calls moduleRegistryService.seedModule once with tender-radar slug', async () => {
|
||||||
@@ -16,3 +16,83 @@ describe('seedTendersModule', () => {
|
|||||||
expect(arg.description).toHaveProperty('en');
|
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<string, any>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||||
|
import type { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seeds the tender-radar module into the module registry.
|
* Seeds the tender-radar module into the module registry.
|
||||||
@@ -24,3 +25,45 @@ export async function seedTendersModule(
|
|||||||
isSystem: true,
|
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<void> {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user