feat(13-06): getTender include sources[] (SCHEMA-03 read surface)

GET /modules/tender-radar/:id now includes the TenderSource relation
(sourcePortal, sourceUrl, sourceNoticeId) so a cross-source-deduped
tender's detail response carries links to all its source portals, not
just the single primary sourceUrl column. Route order unchanged (:id
stays after all static routes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-23 08:55:02 +02:00
parent 1c523c039c
commit 166194fa04
2 changed files with 61 additions and 1 deletions
@@ -24,6 +24,18 @@ function makeFakePrisma() {
title: 'Beispielausschreibung', title: 'Beispielausschreibung',
status: 'active', status: 'active',
sourcePortal: 'doe-opendata', sourcePortal: 'doe-opendata',
sources: [
{
sourcePortal: 'doe-opendata',
sourceUrl: 'https://doe.example/t1',
sourceNoticeId: 'doe-1',
},
{
sourcePortal: 'cosinex-dtvp',
sourceUrl: 'https://dtvp.example/t1',
sourceNoticeId: 'dtvp-1',
},
],
}); });
const configs = new Map<string, any>(); const configs = new Map<string, any>();
configs.set('doe-opendata', { configs.set('doe-opendata', {
@@ -147,6 +159,40 @@ describe('TendersController — global read (not tenant-scoped)', () => {
expect(callArgs.where).toEqual({ id: 't1' }); expect(callArgs.where).toEqual({ id: 't1' });
}); });
it('GET /:id includes the sources relation so the response carries all TenderSource links (13-06, SCHEMA-03)', async () => {
const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const triageService = makeFakeTriageService();
const controller = new TendersController(
prisma as any,
scheduler,
triageService as any,
makeFakeSavedSearchService() as any,
makeFakeNotificationPrefService() as any,
);
const result = await controller.getTender('t1');
const callArgs = prisma.tender.findUnique.mock.calls[0][0];
expect(callArgs.include).toEqual({
sources: {
select: { sourcePortal: true, sourceUrl: true, sourceNoticeId: true },
},
});
expect(result.sources).toEqual([
{
sourcePortal: 'doe-opendata',
sourceUrl: 'https://doe.example/t1',
sourceNoticeId: 'doe-1',
},
{
sourcePortal: 'cosinex-dtvp',
sourceUrl: 'https://dtvp.example/t1',
sourceNoticeId: 'dtvp-1',
},
]);
});
it('GET /:id throws NotFoundException for a missing id', async () => { it('GET /:id throws NotFoundException for a missing id', async () => {
const prisma = makeFakePrisma(); const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
+15 -1
View File
@@ -343,11 +343,25 @@ export class TendersController {
* GET /modules/tender-radar/:id — single tender detail. * GET /modules/tender-radar/:id — single tender detail.
* Gated by @UseModule('tender-radar'); NOT scoped by the tenant's id * Gated by @UseModule('tender-radar'); NOT scoped by the tenant's id
* (global row). * (global row).
*
* D-03/SCHEMA-03 (13-06): includes the `sources` relation (TenderSource
* rows) so a cross-source-deduplicated tender surfaces links to ALL of
* its source portals, not just the single primary `sourceUrl` column.
* `select` is scoped to the three display fields the frontend needs —
* no `id`/`createdAt` leak, same minimal-surface convention as
* `getCoverage`'s groupBy projection.
*/ */
@Get(':id') @Get(':id')
@UseModule('tender-radar') @UseModule('tender-radar')
async getTender(@Param('id') id: string) { async getTender(@Param('id') id: string) {
const tender = await this.prisma.tender.findUnique({ where: { id } }); const tender = await this.prisma.tender.findUnique({
where: { id },
include: {
sources: {
select: { sourcePortal: true, sourceUrl: true, sourceNoticeId: true },
},
},
});
if (!tender) { if (!tender) {
throw new NotFoundException('Tender not found'); throw new NotFoundException('Tender not found');
} }