feat: Abfüllungs-Archiv und Social-Media-Links auf der Herkunftsseite
Abfüllungen hatten keine Übersichtsseite - die Navigation ging direkt auf /bottling/new, sodass eine angelegte Abfüllung danach nirgends mehr sichtbar war. Neue Seite /bottling listet alle Abfüllungen (Archiv), Klick führt zur bestehenden Detail-/Bearbeiten-Seite. Navigationslink zeigt jetzt auf die Liste statt direkt auf "Neu". Neues Modul site-settings (Singleton-Tabelle SiteSettings) für YouTube-/Instagram-/TikTok-Links, verwaltbar unter Verwaltung > Website (neue Berechtigung site-settings:read/update). Die öffentliche Herkunftsseite (/herkunft/[lot]) zeigt die konfigurierten Links jetzt zusätzlich zur Chargen-Herkunft an - PublicService liefert sie über das bereits oeffentliche /public/batches/:lotNumber mit aus, keine zusaetzliche unauthentifizierte Route noetig. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
1 parent
33fa768062
commit
351f9ef34c
19 files changed
+612
-4
No files matched your search
@@ -0,0 +1,28 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsUrl, MaxLength, ValidateIf } from 'class-validator';
|
||||
|
||||
// An empty string clears the link (stored as null); anything else must be a
|
||||
// real URL - ValidateIf skips the @IsUrl check only for the empty-string
|
||||
// "clear" case so a genuinely malformed value still fails validation.
|
||||
export class UpdateSiteSettingsDto {
|
||||
@ApiPropertyOptional({ description: 'Leerer String löscht den Link' })
|
||||
@IsOptional()
|
||||
@ValidateIf((_, value: string) => value !== '')
|
||||
@IsUrl({ require_protocol: true })
|
||||
@MaxLength(300)
|
||||
youtubeUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Leerer String löscht den Link' })
|
||||
@IsOptional()
|
||||
@ValidateIf((_, value: string) => value !== '')
|
||||
@IsUrl({ require_protocol: true })
|
||||
@MaxLength(300)
|
||||
instagramUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Leerer String löscht den Link' })
|
||||
@IsOptional()
|
||||
@ValidateIf((_, value: string) => value !== '')
|
||||
@IsUrl({ require_protocol: true })
|
||||
@MaxLength(300)
|
||||
tiktokUrl?: string;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Body, Controller, Get, Patch } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { UpdateSiteSettingsDto } from './dto/update-site-settings.dto';
|
||||
import { SiteSettingsService } from './site-settings.service';
|
||||
|
||||
@ApiTags('site-settings')
|
||||
@ApiBearerAuth()
|
||||
@Controller('site-settings')
|
||||
export class SiteSettingsController {
|
||||
constructor(private readonly siteSettingsService: SiteSettingsService) {}
|
||||
|
||||
@RequirePermission('site-settings:read')
|
||||
@Get()
|
||||
get() {
|
||||
return this.siteSettingsService.get();
|
||||
}
|
||||
|
||||
@RequirePermission('site-settings:update')
|
||||
@Patch()
|
||||
update(
|
||||
@Body() dto: UpdateSiteSettingsDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.siteSettingsService.update(dto, actor.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SiteSettingsController } from './site-settings.controller';
|
||||
import { SiteSettingsService } from './site-settings.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SiteSettingsController],
|
||||
providers: [SiteSettingsService],
|
||||
exports: [SiteSettingsService],
|
||||
})
|
||||
export class SiteSettingsModule {}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { SiteSettingsService } from './site-settings.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
|
||||
describe('SiteSettingsService', () => {
|
||||
let service: SiteSettingsService;
|
||||
let prisma: {
|
||||
siteSettings: {
|
||||
findUnique: jest.Mock;
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
|
||||
const existing = {
|
||||
id: 'singleton',
|
||||
youtubeUrl: 'https://youtube.com/@wabenhain',
|
||||
instagramUrl: null as string | null,
|
||||
tiktokUrl: null as string | null,
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
siteSettings: {
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new SiteSettingsService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
);
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('returns the existing singleton row', async () => {
|
||||
prisma.siteSettings.findUnique.mockResolvedValue(existing);
|
||||
|
||||
const result = await service.get();
|
||||
|
||||
expect(result).toEqual(existing);
|
||||
expect(prisma.siteSettings.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates the singleton row on first access', async () => {
|
||||
prisma.siteSettings.findUnique.mockResolvedValue(null);
|
||||
prisma.siteSettings.create.mockResolvedValue({
|
||||
id: 'singleton',
|
||||
youtubeUrl: null,
|
||||
instagramUrl: null,
|
||||
tiktokUrl: null,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.get();
|
||||
|
||||
expect(prisma.siteSettings.create).toHaveBeenCalledWith({
|
||||
data: { id: 'singleton' },
|
||||
});
|
||||
expect(result.id).toBe('singleton');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('stores a valid link and records an audit entry', async () => {
|
||||
prisma.siteSettings.findUnique.mockResolvedValue(existing);
|
||||
prisma.siteSettings.update.mockResolvedValue({
|
||||
...existing,
|
||||
instagramUrl: 'https://instagram.com/wabenhain',
|
||||
});
|
||||
|
||||
const result = await service.update(
|
||||
{ instagramUrl: 'https://instagram.com/wabenhain' },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(prisma.siteSettings.update).toHaveBeenCalledWith({
|
||||
where: { id: 'singleton' },
|
||||
data: {
|
||||
youtubeUrl: undefined,
|
||||
instagramUrl: 'https://instagram.com/wabenhain',
|
||||
tiktokUrl: undefined,
|
||||
},
|
||||
});
|
||||
expect(result.instagramUrl).toBe('https://instagram.com/wabenhain');
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'SiteSettings',
|
||||
action: 'UPDATE',
|
||||
userId: 'user-1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('clears a link when given an empty string', async () => {
|
||||
prisma.siteSettings.findUnique.mockResolvedValue(existing);
|
||||
prisma.siteSettings.update.mockResolvedValue({
|
||||
...existing,
|
||||
youtubeUrl: null,
|
||||
});
|
||||
|
||||
await service.update({ youtubeUrl: '' }, 'user-1');
|
||||
|
||||
expect(prisma.siteSettings.update).toHaveBeenCalledWith({
|
||||
where: { id: 'singleton' },
|
||||
data: {
|
||||
youtubeUrl: null,
|
||||
instagramUrl: undefined,
|
||||
tiktokUrl: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves fields untouched when omitted from the dto', async () => {
|
||||
prisma.siteSettings.findUnique.mockResolvedValue(existing);
|
||||
prisma.siteSettings.update.mockResolvedValue(existing);
|
||||
|
||||
await service.update({}, 'user-1');
|
||||
|
||||
expect(prisma.siteSettings.update).toHaveBeenCalledWith({
|
||||
where: { id: 'singleton' },
|
||||
data: {
|
||||
youtubeUrl: undefined,
|
||||
instagramUrl: undefined,
|
||||
tiktokUrl: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { SiteSettings } from '../../generated/prisma';
|
||||
import { UpdateSiteSettingsDto } from './dto/update-site-settings.dto';
|
||||
|
||||
const SINGLETON_ID = 'singleton';
|
||||
|
||||
function emptyToNull(value: string | undefined): string | null {
|
||||
return value === '' ? null : (value ?? null);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SiteSettingsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
) {}
|
||||
|
||||
// Find-or-create rather than a migration-time seed row: keeps the
|
||||
// singleton pattern self-contained in this service instead of coupling it
|
||||
// to seed.ts/migration ordering.
|
||||
async get(): Promise<SiteSettings> {
|
||||
const existing = await this.prisma.siteSettings.findUnique({
|
||||
where: { id: SINGLETON_ID },
|
||||
});
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
return this.prisma.siteSettings.create({ data: { id: SINGLETON_ID } });
|
||||
}
|
||||
|
||||
async update(
|
||||
dto: UpdateSiteSettingsDto,
|
||||
actingUserId: string,
|
||||
): Promise<SiteSettings> {
|
||||
const before = await this.get();
|
||||
const settings = await this.prisma.siteSettings.update({
|
||||
where: { id: SINGLETON_ID },
|
||||
data: {
|
||||
youtubeUrl:
|
||||
dto.youtubeUrl !== undefined
|
||||
? emptyToNull(dto.youtubeUrl)
|
||||
: undefined,
|
||||
instagramUrl:
|
||||
dto.instagramUrl !== undefined
|
||||
? emptyToNull(dto.instagramUrl)
|
||||
: undefined,
|
||||
tiktokUrl:
|
||||
dto.tiktokUrl !== undefined ? emptyToNull(dto.tiktokUrl) : undefined,
|
||||
},
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'SiteSettings',
|
||||
entityId: SINGLETON_ID,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: settings,
|
||||
});
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user