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:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-02 19:57:29 +02:00
1 parent 33fa768062
commit 351f9ef34c
19 files changed
+612 -4

No files matched your search

@@ -0,0 +1,10 @@
-- CreateTable
CREATE TABLE "SiteSettings" (
"id" TEXT NOT NULL DEFAULT 'singleton',
"youtubeUrl" TEXT,
"instagramUrl" TEXT,
"tiktokUrl" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SiteSettings_pkey" PRIMARY KEY ("id")
);
+15
View File
@@ -1025,3 +1025,18 @@ model AutomationRule {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ---------------------------------------------------------------------------
// Website-Einstellungen – Social-Media-Links auf der öffentlichen Herkunftsseite
// ---------------------------------------------------------------------------
// Singleton row (fixed id) rather than a key/value table: exactly one set of
// site-wide social links exists, and a fixed-id upsert is simpler than
// enforcing "at most one row" any other way.
model SiteSettings {
id String @id @default("singleton")
youtubeUrl String?
instagramUrl String?
tiktokUrl String?
updatedAt DateTime @updatedAt
}
+2
View File
@@ -40,6 +40,7 @@ import { TaskTemplatesModule } from './task-templates/task-templates.module';
import { SwarmControlModule } from './swarm-control/swarm-control.module';
import { WeatherModule } from './weather/weather.module';
import { ProfileModule } from './profile/profile.module';
import { SiteSettingsModule } from './site-settings/site-settings.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { PermissionsGuard } from './auth/guards/permissions.guard';
import { PermissionsModule } from './auth/permissions/permissions.module';
@@ -87,6 +88,7 @@ import { PermissionsModule } from './auth/permissions/permissions.module';
SwarmControlModule,
WeatherModule,
ProfileModule,
SiteSettingsModule,
],
controllers: [],
providers: [
@@ -276,6 +276,15 @@ export const PERMISSION_CATALOG = [
...group('Statistik', 'statistics', [['read', 'Ansehen']], 2800),
...group('Suche', 'search', [['read', 'Ansehen']], 2900),
...group('QR-Codes', 'qr', [['read', 'Ansehen']], 3000),
...group(
'Website-Einstellungen',
'site-settings',
[
['read', 'Ansehen'],
['update', 'Bearbeiten'],
],
3100,
),
] as const satisfies readonly PermissionCatalogEntry[];
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { SiteSettingsModule } from '../site-settings/site-settings.module';
import { PublicController } from './public.controller';
import { PublicService } from './public.service';
@Module({
imports: [SiteSettingsModule],
controllers: [PublicController],
providers: [PublicService],
})
@@ -1,14 +1,26 @@
import { NotFoundException } from '@nestjs/common';
import { PublicService } from './public.service';
import { PrismaService } from '../prisma/prisma.service';
import { SiteSettingsService } from '../site-settings/site-settings.service';
describe('PublicService', () => {
let service: PublicService;
let prisma: { bottlingBatch: { findUnique: jest.Mock } };
let siteSettings: { get: jest.Mock };
beforeEach(() => {
prisma = { bottlingBatch: { findUnique: jest.fn() } };
service = new PublicService(prisma as unknown as PrismaService);
siteSettings = {
get: jest.fn().mockResolvedValue({
youtubeUrl: null,
instagramUrl: null,
tiktokUrl: null,
}),
};
service = new PublicService(
prisma as unknown as PrismaService,
siteSettings as unknown as SiteSettingsService,
);
});
it('throws NotFoundException when the lot does not exist', async () => {
@@ -66,4 +78,32 @@ describe('PublicService', () => {
expect(result.apiaries).toEqual([{ name: 'Stand Wald', location: 'Wald' }]);
expect(result.harvestDates).toEqual([harvestDate1, harvestDate2]);
});
it('includes the configured social links', async () => {
siteSettings.get.mockResolvedValue({
youtubeUrl: 'https://youtube.com/@wabenhain',
instagramUrl: 'https://instagram.com/wabenhain',
tiktokUrl: null,
});
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({
lotNumber: 'WH-2026-001',
date: new Date('2026-08-20'),
bestBeforeDate: new Date('2028-08-20'),
jarSize: 500,
product: null,
honeyBatch: {
honeyType: null,
waterContent: null,
extraction: { date: null, harvests: [] },
},
});
const result = await service.findBatchByLot('WH-2026-001');
expect(result.socialLinks).toEqual({
youtube: 'https://youtube.com/@wabenhain',
instagram: 'https://instagram.com/wabenhain',
tiktok: null,
});
});
});
+19 -1
View File
@@ -1,11 +1,18 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { SiteSettingsService } from '../site-settings/site-settings.service';
export interface PublicApiaryOrigin {
name: string;
location: string | null;
}
export interface PublicSocialLinks {
youtube: string | null;
instagram: string | null;
tiktok: string | null;
}
export interface PublicBatch {
lotNumber: string;
productName: string | null;
@@ -18,11 +25,15 @@ export interface PublicBatch {
extractedDate: Date | null;
harvestDates: Date[];
apiaries: PublicApiaryOrigin[];
socialLinks: PublicSocialLinks;
}
@Injectable()
export class PublicService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly siteSettings: SiteSettingsService,
) {}
async findBatchByLot(lotNumber: string): Promise<PublicBatch> {
const batch = await this.prisma.bottlingBatch.findUnique({
@@ -60,6 +71,8 @@ export class PublicService {
.sort((a, b) => a - b)
.map((time) => new Date(time));
const settings = await this.siteSettings.get();
return {
lotNumber: batch.lotNumber,
productName: batch.product?.name ?? null,
@@ -72,6 +85,11 @@ export class PublicService {
extractedDate: batch.honeyBatch.extraction.date,
harvestDates,
apiaries: [...apiaryByName.values()],
socialLinks: {
youtube: settings.youtubeUrl,
instagram: settings.instagramUrl,
tiktok: settings.tiktokUrl,
},
};
}
}
@@ -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;
}
}