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,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")
|
||||
);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user