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:
@@ -7,6 +7,12 @@ import { TenderEmailConfigService } from './tender-email-config.service';
|
||||
* (deterministic reversible encode, NOT real AES) — same convention as
|
||||
* tender-dedup.service.spec.ts: no live DB/crypto dependency, just proving
|
||||
* this service's own encrypt-preserve-empty / safe-select contract.
|
||||
*
|
||||
* Phase 17, Plan 01 (D-01): ownership moved from tenantId to userId — the
|
||||
* fake prisma below is now keyed by userId (matches the real
|
||||
* `where: { userId }` upsert target), and two new cases prove the actual
|
||||
* new capability: two users of the SAME tenant get two independent rows,
|
||||
* and tenantId is written on create (denormalized, D-01).
|
||||
*/
|
||||
|
||||
function makeFakeCrypto() {
|
||||
@@ -24,7 +30,7 @@ function makeFakePrisma() {
|
||||
return {
|
||||
tenderEmailConfig: {
|
||||
findUnique: vi.fn(async ({ where, select }: any) => {
|
||||
const row = configs.get(where.tenantId);
|
||||
const row = configs.get(where.userId);
|
||||
if (!row) return null;
|
||||
if (!select) return row;
|
||||
const out: any = {};
|
||||
@@ -32,9 +38,9 @@ function makeFakePrisma() {
|
||||
return out;
|
||||
}),
|
||||
upsert: vi.fn(async ({ where, update, create, select }: any) => {
|
||||
const existing = configs.get(where.tenantId);
|
||||
const row = existing ? { ...existing, ...update } : { id: 'cfg-1', ...create };
|
||||
configs.set(where.tenantId, row);
|
||||
const existing = configs.get(where.userId);
|
||||
const row = existing ? { ...existing, ...update } : { id: `cfg-${configs.size + 1}`, ...create };
|
||||
configs.set(where.userId, row);
|
||||
if (!select) return row;
|
||||
const out: any = {};
|
||||
for (const k of Object.keys(select)) out[k] = row[k];
|
||||
@@ -46,12 +52,12 @@ function makeFakePrisma() {
|
||||
}
|
||||
|
||||
describe('TenderEmailConfigService', () => {
|
||||
it('getConfigForApi returns null when no config exists for the tenant', async () => {
|
||||
it('getConfigForApi returns null when no config exists for the user', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const crypto = makeFakeCrypto();
|
||||
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||
|
||||
const result = await service.getConfigForApi('tenant-missing');
|
||||
const result = await service.getConfigForApi('user-missing');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@@ -61,18 +67,21 @@ describe('TenderEmailConfigService', () => {
|
||||
const crypto = makeFakeCrypto();
|
||||
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||
|
||||
await service.saveConfig('tenant-a', {
|
||||
protocol: 'imap',
|
||||
encryption: 'ssl-tls',
|
||||
host: 'imap.example.test',
|
||||
port: 993,
|
||||
folder: 'INBOX',
|
||||
username: 'alerts@example.test',
|
||||
password: 'super-secret',
|
||||
isActive: true,
|
||||
} as any);
|
||||
await service.saveConfig(
|
||||
{ userId: 'user-a', tenantId: 'tenant-a' },
|
||||
{
|
||||
protocol: 'imap',
|
||||
encryption: 'ssl-tls',
|
||||
host: 'imap.example.test',
|
||||
port: 993,
|
||||
folder: 'INBOX',
|
||||
username: 'alerts@example.test',
|
||||
password: 'super-secret',
|
||||
isActive: true,
|
||||
} as any,
|
||||
);
|
||||
|
||||
const apiResult = await service.getConfigForApi('tenant-a');
|
||||
const apiResult = await service.getConfigForApi('user-a');
|
||||
|
||||
expect(apiResult).not.toBeNull();
|
||||
expect(apiResult).not.toHaveProperty('password');
|
||||
@@ -86,16 +95,19 @@ describe('TenderEmailConfigService', () => {
|
||||
const crypto = makeFakeCrypto();
|
||||
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||
|
||||
await service.saveConfig('tenant-b', {
|
||||
protocol: 'imap',
|
||||
encryption: 'ssl-tls',
|
||||
host: 'imap.example.test',
|
||||
port: 993,
|
||||
folder: 'INBOX',
|
||||
isActive: false,
|
||||
} as any);
|
||||
await service.saveConfig(
|
||||
{ userId: 'user-b', tenantId: 'tenant-b' },
|
||||
{
|
||||
protocol: 'imap',
|
||||
encryption: 'ssl-tls',
|
||||
host: 'imap.example.test',
|
||||
port: 993,
|
||||
folder: 'INBOX',
|
||||
isActive: false,
|
||||
} as any,
|
||||
);
|
||||
|
||||
const apiResult = await service.getConfigForApi('tenant-b');
|
||||
const apiResult = await service.getConfigForApi('user-b');
|
||||
|
||||
expect(apiResult!.hasPassword).toBe(false);
|
||||
expect(apiResult!.username).toBeNull();
|
||||
@@ -107,21 +119,27 @@ describe('TenderEmailConfigService', () => {
|
||||
const crypto = makeFakeCrypto();
|
||||
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||
|
||||
await service.saveConfig('tenant-c', {
|
||||
protocol: 'imap',
|
||||
encryption: 'ssl-tls',
|
||||
username: 'old@example.test',
|
||||
password: 'original-secret',
|
||||
} as any);
|
||||
await service.saveConfig(
|
||||
{ userId: 'user-c', tenantId: 'tenant-c' },
|
||||
{
|
||||
protocol: 'imap',
|
||||
encryption: 'ssl-tls',
|
||||
username: 'old@example.test',
|
||||
password: 'original-secret',
|
||||
} as any,
|
||||
);
|
||||
|
||||
// Re-save with a new username, password left blank (T-07-12 UI convention)
|
||||
await service.saveConfig('tenant-c', {
|
||||
protocol: 'imap',
|
||||
encryption: 'ssl-tls',
|
||||
username: 'new@example.test',
|
||||
} as any);
|
||||
await service.saveConfig(
|
||||
{ userId: 'user-c', tenantId: 'tenant-c' },
|
||||
{
|
||||
protocol: 'imap',
|
||||
encryption: 'ssl-tls',
|
||||
username: 'new@example.test',
|
||||
} as any,
|
||||
);
|
||||
|
||||
const raw = prisma.__store.get('tenant-c');
|
||||
const raw = prisma.__store.get('user-c');
|
||||
const decrypted = JSON.parse(crypto.decrypt(raw.encryptedInboxCreds));
|
||||
expect(decrypted.username).toBe('new@example.test');
|
||||
expect(decrypted.password).toBe('original-secret');
|
||||
@@ -132,20 +150,26 @@ describe('TenderEmailConfigService', () => {
|
||||
const crypto = makeFakeCrypto();
|
||||
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||
|
||||
await service.saveConfig('tenant-d', {
|
||||
protocol: 'imap',
|
||||
encryption: 'ssl-tls',
|
||||
username: 'stable@example.test',
|
||||
password: 'first-secret',
|
||||
} as any);
|
||||
await service.saveConfig(
|
||||
{ userId: 'user-d', tenantId: 'tenant-d' },
|
||||
{
|
||||
protocol: 'imap',
|
||||
encryption: 'ssl-tls',
|
||||
username: 'stable@example.test',
|
||||
password: 'first-secret',
|
||||
} as any,
|
||||
);
|
||||
|
||||
await service.saveConfig('tenant-d', {
|
||||
protocol: 'imap',
|
||||
encryption: 'ssl-tls',
|
||||
password: 'rotated-secret',
|
||||
} as any);
|
||||
await service.saveConfig(
|
||||
{ userId: 'user-d', tenantId: 'tenant-d' },
|
||||
{
|
||||
protocol: 'imap',
|
||||
encryption: 'ssl-tls',
|
||||
password: 'rotated-secret',
|
||||
} as any,
|
||||
);
|
||||
|
||||
const raw = prisma.__store.get('tenant-d');
|
||||
const raw = prisma.__store.get('user-d');
|
||||
const decrypted = JSON.parse(crypto.decrypt(raw.encryptedInboxCreds));
|
||||
expect(decrypted.username).toBe('stable@example.test');
|
||||
expect(decrypted.password).toBe('rotated-secret');
|
||||
@@ -156,14 +180,54 @@ describe('TenderEmailConfigService', () => {
|
||||
const crypto = makeFakeCrypto();
|
||||
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||
|
||||
const result = await service.saveConfig('tenant-e', {
|
||||
protocol: 'imap',
|
||||
encryption: 'ssl-tls',
|
||||
username: 'x@example.test',
|
||||
password: 'y',
|
||||
} as any);
|
||||
const result = await service.saveConfig(
|
||||
{ userId: 'user-e', tenantId: 'tenant-e' },
|
||||
{
|
||||
protocol: 'imap',
|
||||
encryption: 'ssl-tls',
|
||||
username: 'x@example.test',
|
||||
password: 'y',
|
||||
} as any,
|
||||
);
|
||||
|
||||
expect(result).not.toHaveProperty('encryptedInboxCreds');
|
||||
expect(result).not.toHaveProperty('password');
|
||||
});
|
||||
|
||||
it('saveConfig writes BOTH userId and tenantId on create (Phase 17, D-01: tenantId stays denormalized)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const crypto = makeFakeCrypto();
|
||||
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||
|
||||
await service.saveConfig(
|
||||
{ userId: 'user-f', tenantId: 'tenant-f' },
|
||||
{ protocol: 'imap', encryption: 'ssl-tls' } as any,
|
||||
);
|
||||
|
||||
const raw = prisma.__store.get('user-f');
|
||||
expect(raw.userId).toBe('user-f');
|
||||
expect(raw.tenantId).toBe('tenant-f');
|
||||
});
|
||||
|
||||
it('two users of the SAME tenant each get their own row — the second save never overwrites the first (Phase 17, D-01)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const crypto = makeFakeCrypto();
|
||||
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||
|
||||
await service.saveConfig(
|
||||
{ userId: 'user-g1', tenantId: 'tenant-shared' },
|
||||
{ protocol: 'imap', encryption: 'ssl-tls', host: 'imap.user-g1.test' } as any,
|
||||
);
|
||||
await service.saveConfig(
|
||||
{ userId: 'user-g2', tenantId: 'tenant-shared' },
|
||||
{ protocol: 'imap', encryption: 'ssl-tls', host: 'imap.user-g2.test' } as any,
|
||||
);
|
||||
|
||||
const configG1 = await service.getConfigForApi('user-g1');
|
||||
const configG2 = await service.getConfigForApi('user-g2');
|
||||
|
||||
expect(configG1!.host).toBe('imap.user-g1.test');
|
||||
expect(configG2!.host).toBe('imap.user-g2.test');
|
||||
expect(prisma.__store.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user