diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 4564b2c..5b34dfb 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -44,6 +44,7 @@ import { WeatherModule } from './weather/weather.module'; import { ProfileModule } from './profile/profile.module'; import { SiteSettingsModule } from './site-settings/site-settings.module'; import { BackupModule } from './backup/backup.module'; +import { ShopSyncModule } from './shop-sync/shop-sync.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { PermissionsGuard } from './auth/guards/permissions.guard'; import { PermissionsModule } from './auth/permissions/permissions.module'; @@ -97,6 +98,7 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea ProfileModule, SiteSettingsModule, BackupModule, + ShopSyncModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts index 60cb331..09c302e 100644 --- a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts +++ b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts @@ -306,6 +306,15 @@ export const PERMISSION_CATALOG = [ ], 3200, ), + // Deliberately separate from harvests:read etc.: those endpoints return + // internal notes and full records. The shop sync account needs only the + // allowlisted feed, so it gets this one permission and nothing else. + ...group( + 'Shop-Synchronisation', + 'shop-sync', + [['read', 'Freigegebene Daten für den Shop lesen']], + 3300, + ), ] as const satisfies readonly PermissionCatalogEntry[]; export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key']; diff --git a/ApiCore-Backend/src/shop-sync/shop-sync.controller.ts b/ApiCore-Backend/src/shop-sync/shop-sync.controller.ts new file mode 100644 index 0000000..c551c42 --- /dev/null +++ b/ApiCore-Backend/src/shop-sync/shop-sync.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { RequirePermission } from '../auth/decorators/require-permission.decorator'; +import { ShopSyncFeed, ShopSyncService } from './shop-sync.service'; + +@ApiTags('shop-sync') +@ApiBearerAuth() +@Controller('shop-sync') +export class ShopSyncController { + constructor(private readonly shopSync: ShopSyncService) {} + + @RequirePermission('shop-sync:read') + @Get() + feed(): Promise { + return this.shopSync.feed(); + } +} diff --git a/ApiCore-Backend/src/shop-sync/shop-sync.module.ts b/ApiCore-Backend/src/shop-sync/shop-sync.module.ts new file mode 100644 index 0000000..d433576 --- /dev/null +++ b/ApiCore-Backend/src/shop-sync/shop-sync.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ShopSyncController } from './shop-sync.controller'; +import { ShopSyncService } from './shop-sync.service'; + +@Module({ + controllers: [ShopSyncController], + providers: [ShopSyncService], +}) +export class ShopSyncModule {} diff --git a/ApiCore-Backend/src/shop-sync/shop-sync.service.ts b/ApiCore-Backend/src/shop-sync/shop-sync.service.ts new file mode 100644 index 0000000..3cd9875 --- /dev/null +++ b/ApiCore-Backend/src/shop-sync/shop-sync.service.ts @@ -0,0 +1,140 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { ProductStatus } from '../../generated/prisma'; + +/** + * Bumped only for breaking changes (a removed or renamed field, a changed + * meaning). Adding a field keeps the version - consumers must ignore fields + * they do not know. + */ +export const SHOP_SYNC_CONTRACT_VERSION = 1; + +export interface ShopSyncHarvest { + id: string; + apiaryId: string; + apiaryName: string; + date: Date; + honeyType: string | null; + honeySuperCount: number | null; + grossWeight: number | null; + netWeight: number | null; +} + +export interface ShopSyncProduct { + id: string; + name: string; + description: string | null; + sku: string | null; + packagingSize: number | null; + weight: number | null; + salesUnit: string | null; + status: ProductStatus; +} + +export interface ShopSyncBottling { + id: string; + lotNumber: string; + date: Date; + bestBeforeDate: Date; + /** Grams per jar. */ + jarSize: number; + jarCount: number; + productId: string | null; + /** Harvests behind this bottling, oldest first. */ + harvestIds: string[]; +} + +export interface ShopSyncFeed { + contractVersion: number; + generatedAt: Date; + harvests: ShopSyncHarvest[]; + products: ShopSyncProduct[]; + bottlings: ShopSyncBottling[]; +} + +/** + * The data ApiCore releases to the shop (Wabenhain) - and only that. + * + * Every query uses an explicit `select`: a field reaches the shop only when + * it is named here. Notes, colonies, treatments, users and anything added to + * the models later stay inside ApiCore unless someone deliberately adds them + * to this allowlist. ApiCore stays the source of truth for these facts; the + * shop decides prices, texts and visibility on its own side. + * + * Always the full set, no "changed since" filter: the volume is small (a few + * dozen records a year), and a full comparison heals itself - a missed or + * failed run cannot leave a correction behind. + */ +@Injectable() +export class ShopSyncService { + constructor(private readonly prisma: PrismaService) {} + + async feed(): Promise { + const [harvests, products, bottlings] = await Promise.all([ + this.prisma.harvest.findMany({ + select: { + id: true, + apiaryId: true, + date: true, + honeyType: true, + honeySuperCount: true, + grossWeight: true, + netWeight: true, + apiary: { select: { name: true } }, + }, + orderBy: { date: 'asc' }, + }), + this.prisma.product.findMany({ + select: { + id: true, + name: true, + description: true, + sku: true, + packagingSize: true, + weight: true, + salesUnit: true, + status: true, + }, + orderBy: { name: 'asc' }, + }), + this.prisma.bottlingBatch.findMany({ + select: { + id: true, + lotNumber: true, + date: true, + bestBeforeDate: true, + jarSize: true, + jarCount: true, + productId: true, + honeyBatch: { + select: { + extraction: { + select: { + harvests: { + select: { harvestId: true }, + orderBy: { harvest: { date: 'asc' } }, + }, + }, + }, + }, + }, + }, + orderBy: { date: 'asc' }, + }), + ]); + + return { + contractVersion: SHOP_SYNC_CONTRACT_VERSION, + generatedAt: new Date(), + harvests: harvests.map(({ apiary, ...harvest }) => ({ + ...harvest, + apiaryName: apiary.name, + })), + products, + bottlings: bottlings.map(({ honeyBatch, ...bottling }) => ({ + ...bottling, + harvestIds: honeyBatch.extraction.harvests.map((h) => h.harvestId), + })), + }; + } +} diff --git a/ApiCore-Backend/test/shop-sync.e2e-spec.ts b/ApiCore-Backend/test/shop-sync.e2e-spec.ts new file mode 100644 index 0000000..46c0695 --- /dev/null +++ b/ApiCore-Backend/test/shop-sync.e2e-spec.ts @@ -0,0 +1,179 @@ +import { INestApplication } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { createTestApp } from './utils/create-test-app'; +import { login } from './utils/api'; + +interface ShopSyncFeedBody { + contractVersion: number; + generatedAt: string; + harvests: Record[]; + products: Record[]; + bottlings: Record[]; +} + +/** + * The shop sync contract: one endpoint, an explicit field allowlist, and a + * permission that grants nothing else. Wabenhain relies on this shape. + */ +describe('Shop sync (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; + const password = 'SuperSecret123!'; + let syncToken: string; + let readonlyToken: string; + let harvestId: string; + let bottlingId: string; + let productId: string; + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + const passwordHash = await bcrypt.hash(password, 12); + + const syncRole = await prisma.role.create({ + data: { + key: `SHOP_SYNC_${suffix}`, + name: 'Shop-Sync', + permissions: { + create: [{ permission: { connect: { key: 'shop-sync:read' } } }], + }, + }, + }); + await prisma.user.create({ + data: { + email: `shop-sync-${suffix}@example.com`, + passwordHash, + name: 'Shop Sync', + roleId: syncRole.id, + }, + }); + await prisma.user.create({ + data: { + email: `shop-sync-ro-${suffix}@example.com`, + passwordHash, + name: 'Readonly', + role: { connect: { key: 'READONLY' } }, + }, + }); + syncToken = (await login(app, `shop-sync-${suffix}@example.com`, password)) + .accessToken; + readonlyToken = ( + await login(app, `shop-sync-ro-${suffix}@example.com`, password) + ).accessToken; + + const owner = await prisma.user.create({ + data: { + email: `shop-sync-owner-${suffix}@example.com`, + passwordHash, + name: 'Owner', + role: { connect: { key: 'ADMIN' } }, + }, + }); + const apiary = await prisma.apiary.create({ + data: { name: `Streuobst ${suffix}`, notes: 'Schlüssel unter dem Stein' }, + }); + const harvest = await prisma.harvest.create({ + data: { + apiaryId: apiary.id, + userId: owner.id, + date: new Date('2026-07-14T00:00:00.000Z'), + honeyType: 'Sommertracht', + netWeight: 41, + notes: 'Volk 7 nach Varroa-Behandlung schwach', + }, + }); + harvestId = harvest.id; + const extraction = await prisma.extraction.create({ + data: { + date: new Date('2026-07-20T00:00:00.000Z'), + userId: owner.id, + notes: 'intern', + harvests: { create: [{ harvestId }] }, + }, + }); + const honeyBatch = await prisma.honeyBatch.create({ + data: { + batchNumber: `HB-${suffix}`, + extractionId: extraction.id, + amount: 50, + }, + }); + const product = await prisma.product.create({ + data: { name: `Sommertracht 500 g ${suffix}`, sku: `SKU-${suffix}` }, + }); + productId = product.id; + const bottling = await prisma.bottlingBatch.create({ + data: { + honeyBatchId: honeyBatch.id, + productId, + date: new Date('2026-08-01T00:00:00.000Z'), + bestBeforeDate: new Date('2028-08-01T00:00:00.000Z'), + jarSize: 500, + jarCount: 60, + totalAmount: 30, + lotNumber: `L-${suffix}`, + }, + }); + bottlingId = bottling.id; + }); + + afterAll(async () => { + await app.close(); + }); + + it('delivers the versioned feed to an account holding only shop-sync:read', async () => { + const response = await request(app.getHttpServer()) + .get('/api/v1/shop-sync') + .set('Authorization', `Bearer ${syncToken}`) + .expect(200); + const body = response.body as ShopSyncFeedBody; + + expect(body.contractVersion).toBe(1); + const harvest = body.harvests.find((h) => h.id === harvestId); + expect(harvest).toMatchObject({ + apiaryName: `Streuobst ${suffix}`, + honeyType: 'Sommertracht', + netWeight: 41, + }); + const bottling = body.bottlings.find((b) => b.id === bottlingId); + expect(bottling).toMatchObject({ + lotNumber: `L-${suffix}`, + jarSize: 500, + jarCount: 60, + productId, + harvestIds: [harvestId], + }); + expect(body.products.some((p) => p.id === productId)).toBe(true); + }); + + it('never carries internal notes or other unlisted fields', async () => { + const response = await request(app.getHttpServer()) + .get('/api/v1/shop-sync') + .set('Authorization', `Bearer ${syncToken}`) + .expect(200); + const raw = JSON.stringify(response.body); + + expect(raw).not.toContain('Varroa'); + expect(raw).not.toContain('Schlüssel'); + expect(raw).not.toContain('"notes"'); + expect(raw).not.toContain('"userId"'); + }); + + it('grants the sync account nothing beyond the feed', async () => { + await request(app.getHttpServer()) + .get('/api/v1/harvests') + .set('Authorization', `Bearer ${syncToken}`) + .expect(403); + }); + + it('is not readable without the shop-sync permission', async () => { + await request(app.getHttpServer()) + .get('/api/v1/shop-sync') + .set('Authorization', `Bearer ${readonlyToken}`) + .expect(403); + }); +}); diff --git a/docs/pruefung-2026-09.md b/docs/pruefung-2026-09.md index 3058459..b153952 100644 --- a/docs/pruefung-2026-09.md +++ b/docs/pruefung-2026-09.md @@ -56,7 +56,7 @@ Umgesetzt in Wabenhain `d38f430` (AT: Entscheidungslogik; CR: DB-Ablauf der Serv ## Nächste Schritte 1. ~~Zuständigkeit pro Feld im Sync~~ – erledigt. -2. Eigene, versionierte Sync-Schnittstelle in ApiCore (`GET /api/v1/sync/…`, nur freigegebene Felder, „geändert seit“). +2. ~~Eigene, versionierte Sync-Schnittstelle~~ – erledigt: `GET /api/v1/shop-sync` (Vertragsversion 1, Feld-Allowlist, Berechtigung `shop-sync:read`). Bewusst ohne „geändert seit“: kleine Datenmenge, Voll-Abgleich heilt verpasste Läufe selbst. Wabenhain nutzt sie seit `5642f0c`, mit Rückfall auf die alten Endpunkte bei HTTP 404. 3. Nur-Lese-API-Schlüssel statt Passwort-Login des Service-Kontos. 4. Ende-zu-Ende-Test beider Systeme gegen Test-Instanzen. 5. Merge/Push.