feat: dedicated, versioned shop-sync feed for Wabenhain

GET /api/v1/shop-sync returns exactly the data ApiCore releases to the
shop: harvest facts, article master data and bottlings with their
harvest ids. Every query uses an explicit select, so notes, colonies,
treatments and fields added to the models later never leave ApiCore
unless deliberately added to the allowlist.

- New permission shop-sync:read (granted to ADMIN automatically, to no
  other role): the shop's service account needs nothing else, in
  particular no access to harvest or extraction records with notes.
- contractVersion 1; only breaking changes bump it.
- Always the full set: small volume, and a full comparison heals missed
  runs by itself.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-27 20:31:46 +02:00
1 parent 3c7ff1e0b7
commit ede86c062a
7 files changed
+357 -1

No files matched your search

+2
View File
@@ -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: [
@@ -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'];
@@ -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<ShopSyncFeed> {
return this.shopSync.feed();
}
}
@@ -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 {}
@@ -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<ShopSyncFeed> {
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),
})),
};
}
}
+179
View File
@@ -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<string, unknown>[];
products: Record<string, unknown>[];
bottlings: Record<string, unknown>[];
}
/**
* 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<App>;
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);
});
});