feat(17-01): move TenderEmailConfig ownership from tenant to user

Alert-Postfach gehoert jetzt dem einzelnen Nutzer (userId @unique) statt
dem Mandanten (D-01) — ein zweiter Kollege desselben Mandanten kann sein
eigenes Postfach anbinden. tenantId bleibt denormalisiert (SMTP-Aufloesung,
Herkunftsmarkierung), wird auf create UND update mitgeschrieben.

- Handgeschriebene Migration (prisma migrate dev verweigert die
  nicht-interaktive Shell): befuellt Bestandszeilen mit dem aeltesten
  aktiven Administrator ihres Mandanten, entfernt verwaiste Zeilen ohne
  Administrator, ersetzt die tenantId-Eindeutigkeit durch userId.
  Lokal getestet (0 Bestandszeilen lokal und auf alpha — Zaehlung im
  Task-1-Checkpoint), Index-Ergebnis verifiziert.
- TenderEmailConfigService.getConfigForApi/saveConfig auf userId als
  Schluessel umgestellt; saveConfig nimmt {userId, tenantId}.
- TendersController: email-config-Routen von @Roles(ADMIN,SUPER_ADMIN)
  auf @UseModule('tender-radar') umgestellt (Postfach ist jetzt
  Nutzereinstellung); Route-Reihenfolge vor @Get(':id') unveraendert.
- Neue Seite /modules/tender-radar/my-sources ("Meine Quellen") mit dem
  unveraenderten EmailAlertConfigForm; Hinweistext benennt D-05 (Tender
  bleibt plattform-global — nur wer Quellen einspeist aendert sich).
- tenders.controller.spec.ts an neue Service-Signatur angepasst (Rule 3,
  nicht im Plan gelistet, aber zum Kompilieren/Bestehen erforderlich).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 11:19:55 +02:00
parent 42a2c7703f
commit 05b1d293d8
9 changed files with 363 additions and 115 deletions
+44 -10
View File
@@ -104,13 +104,17 @@ function makeFakeRssFeedService() {
/**
* Fake TenderEmailConfigService for controller-level wiring tests (Plan
* 14-03, D-06/D-07/CONFIG-02). Default stubs echo/return null; individual
* tests override via `.mockResolvedValueOnce`/reassigning the mock.
* 14-03, D-06/D-07/CONFIG-02; per-user ownership since Phase 17, Plan 01,
* D-01). Default stubs echo/return null; individual tests override via
* `.mockResolvedValueOnce`/reassigning the mock.
*/
function makeFakeEmailConfigService() {
return {
getConfigForApi: vi.fn(async (_tenantId: string) => null as any),
saveConfig: vi.fn(async (_tenantId: string, dto: any) => ({ id: 'ec-1', ...dto })),
getConfigForApi: vi.fn(async (_userId: string) => null as any),
saveConfig: vi.fn(async (_ctx: { userId: string; tenantId: string }, dto: any) => ({
id: 'ec-1',
...dto,
})),
};
}
@@ -959,12 +963,13 @@ describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', (
});
});
describe('TendersController — email-config (Plan 14-03, per-tenant, D-06/D-07/T-14-03-05)', () => {
it('GET /email-config resolves tenantId from the auth context and delegates to tenderEmailConfig.getConfigForApi(tenantId)', async () => {
describe('TendersController — email-config (Plan 14-03, per-user since Phase 17 Plan 01 D-01, T-14-03-05/T-17-01)', () => {
it('GET /email-config resolves userId from the auth context and delegates to tenderEmailConfig.getConfigForApi(userId)', async () => {
const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const emailConfigService = makeFakeEmailConfigService();
emailConfigService.getConfigForApi.mockResolvedValueOnce({
userId: 'u1',
tenantId: 'tenant1',
protocol: 'imap',
hasPassword: true,
@@ -981,11 +986,16 @@ describe('TendersController — email-config (Plan 14-03, per-tenant, D-06/D-07/
const result = await controller.getEmailConfig(makeFakeRequest('u1', 'tenant1'));
expect(emailConfigService.getConfigForApi).toHaveBeenCalledWith('tenant1');
expect(result).toEqual({ tenantId: 'tenant1', protocol: 'imap', hasPassword: true });
expect(emailConfigService.getConfigForApi).toHaveBeenCalledWith('u1');
expect(result).toEqual({
userId: 'u1',
tenantId: 'tenant1',
protocol: 'imap',
hasPassword: true,
});
});
it('PUT /email-config delegates to tenderEmailConfig.saveConfig with tenantId from the auth context, never the body', async () => {
it('PUT /email-config delegates to tenderEmailConfig.saveConfig with {userId, tenantId} from the auth context, never the body', async () => {
const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const emailConfigService = makeFakeEmailConfigService();
@@ -1002,7 +1012,31 @@ describe('TendersController — email-config (Plan 14-03, per-tenant, D-06/D-07/
const dto = { protocol: 'imap', encryption: 'ssl-tls', host: 'imap.example.test' } as any;
await controller.saveEmailConfig(dto, makeFakeRequest('u1', 'tenant1'));
expect(emailConfigService.saveConfig).toHaveBeenCalledWith('tenant1', dto);
expect(emailConfigService.saveConfig).toHaveBeenCalledWith(
{ userId: 'u1', tenantId: 'tenant1' },
dto,
);
});
it('two different users of the same tenant each resolve their own userId — never the other user\'s (T-17-01, IDOR)', async () => {
const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const emailConfigService = makeFakeEmailConfigService();
const controller = new TendersController(
prisma as any,
scheduler,
makeFakeTriageService() as any,
makeFakeSavedSearchService() as any,
makeFakeNotificationPrefService() as any,
makeFakeRssFeedService() as any,
emailConfigService as any,
);
await controller.getEmailConfig(makeFakeRequest('user-a', 'tenant1'));
await controller.getEmailConfig(makeFakeRequest('user-b', 'tenant1'));
expect(emailConfigService.getConfigForApi).toHaveBeenNthCalledWith(1, 'user-a');
expect(emailConfigService.getConfigForApi).toHaveBeenNthCalledWith(2, 'user-b');
});
});