feat: Beute L3–L5 (3D-Szene, Umbauen, Durchsicht, offline) und Diktat (T7 L1)

Beute L3 Runden 2+3 und Review-Fixes:
- Glas-Holo ohne warmes Leuchten, Wanderboden-Fuss, Fokusflug mit
  Geist-Ansicht, Inhalte im Fokus, Rähmchen mit Art-Farbe beim Anheben
- Hinweis-Geometrie je Zarge nur einmal je Layout gebaut; Drehen mit
  Pointer-Capture, endet auch bei Loslassen ausserhalb (rig-pointer.ts)
- Tiefenunschärfe ab 55 fps, Leistungsprobe bei jeder Bewegungsstufe,
  Context-Loss-Listener aufgeräumt, Trefferboxen als skalierte Einheitsbox

Beute L4 Umbauen (/colonies/[id]/hive): Entwurf mit Basis-Schnappschuss
beim ersten Schritt, Rückgängig/Wiederholen, Hinweis bei Serverstand-
Änderung, Konfliktblatt.

Beute L5:
- Durchsicht-Abschnitt „Beute“: Aufbau-Änderungen in der Durchsicht,
  Blatt-Formulare stoppen das Submit-Bubbling durch Portale (sonst
  speicherte „Speichern“ im Blatt die ganze Durchsicht), Fokus nach
  Rückgängig, Folgevorschläge beim Speichern
- Offline: Beute im Offline-Paket, Warteschlange pending-hive-changes
  (DB v5), Abarbeiten in Reihenfolge, keine Online-Änderung überholt
  wartende, Hinweis bei abgelehnter Änderung mit „Warteschlange öffnen“

T7 L1 Diktat: Spracherkennungs-Kapsel (Web Speech API), Einwilligung
standardmässig aus, Diktat-Knopf an Freitextfeldern, Seite
„Sprache & Datenschutz“; axe-core als exakte devDependency.

Backend: NotificationsService liest Empfänger und schreibt in einer
Transaktion mit FOR KEY SHARE (paralleles Löschen eines Benutzers
führte zu P2003 und verzögerten Vorschlägen; Ursache der wackeligen
Endtests). Zwei e2e-Prüfungen gegen geteilten DB-Zustand entschärft.

Gates: Frontend lint, typecheck, 2740 Tests, build; Backend lint,
typecheck, 4266 Unit-Tests, build; parallele e2e 3x grün (978).
Browser-Prüfung 1280/390 px, Tastatur, offline, reduced motion.

Refs #19 #20 #21 #22 #27 #32

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 09:12:04 +02:00
1 parent ab45d6e24e
commit 1a57de8573
123 files changed
+7639 -355

No files matched your search

@@ -1,12 +1,13 @@
import { Module } from '@nestjs/common';
import { AutopilotModule } from '../autopilot/autopilot.module';
import { DomainEventsModule } from '../domain-events/domain-events.module';
import { HiveModule } from '../hive/hive.module';
import { ApiariesController } from './apiaries.controller';
import { ApiariesService } from './apiaries.service';
import { ApiaryFieldService } from './apiary-field.service';
@Module({
imports: [AutopilotModule, DomainEventsModule],
imports: [AutopilotModule, DomainEventsModule, HiveModule],
controllers: [ApiariesController],
providers: [ApiariesService, ApiaryFieldService],
exports: [ApiariesService],
@@ -4,6 +4,8 @@ import { PrismaService } from '../prisma/prisma.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import type { ColonyNextSteps } from '../autopilot/next-step-ranking';
import { NextStepsService } from '../autopilot/next-steps.service';
import { HiveService } from '../hive/hive.service';
import { HiveCatalogService } from '../hive/hive-catalog.service';
describe('ApiaryFieldService', () => {
let service: ApiaryFieldService;
@@ -17,6 +19,8 @@ describe('ApiaryFieldService', () => {
};
let permissionResolver: { getPermissions: jest.Mock };
let nextSteps: { forColonies: jest.Mock; warningCounts: jest.Mock };
let hives: { getMany: jest.Mock };
let hiveCatalogs: { listSystems: jest.Mock };
const stepsOf = (
colonyId: string,
@@ -87,10 +91,14 @@ describe('ApiaryFieldService', () => {
),
warningCounts: jest.fn().mockResolvedValue(new Map()),
};
hives = { getMany: jest.fn().mockResolvedValue({}) };
hiveCatalogs = { listSystems: jest.fn().mockResolvedValue([]) };
service = new ApiaryFieldService(
prisma as unknown as PrismaService,
permissionResolver as unknown as PermissionResolverService,
nextSteps as unknown as NextStepsService,
hives as unknown as HiveService,
hiveCatalogs as unknown as HiveCatalogService,
);
});
@@ -129,6 +137,40 @@ describe('ApiaryFieldService', () => {
expect(prisma.task.findMany).not.toHaveBeenCalled();
});
it('carries the hives of the apiary colonies and the hive catalog', async () => {
grant('colonies:read');
const view = { colonyId: 'c2', systemId: 's1', version: 3 };
hives.getMany.mockResolvedValue({ c2: view });
const systems = [{ id: 's1', name: 'Zander', active: true, partTypes: [] }];
hiveCatalogs.listSystems.mockResolvedValue(systems);
const pack = await service.offlinePack('apiary-1', 'u1');
expect(hives.getMany).toHaveBeenCalledWith(['c2', 'c10']);
expect(pack.hives).toEqual({ c2: view });
expect(pack.hiveCatalog).toEqual(systems);
});
it('limits the hive catalog to active systems and those the hives use', async () => {
grant('colonies:read');
hives.getMany.mockResolvedValue({ c2: { colonyId: 'c2', systemId: 's2' } });
hiveCatalogs.listSystems.mockResolvedValue([
{ id: 's1', active: true },
{ id: 's2', active: false },
{ id: 's3', active: false },
]);
const pack = await service.offlinePack('apiary-1', 'u1');
expect(pack.hiveCatalog?.map((system) => system.id)).toEqual(['s1', 's2']);
});
it('keeps colonies without a hive setup out of the hives', async () => {
grant('colonies:read');
const pack = await service.offlinePack('apiary-1', 'u1');
expect(pack.hives).toEqual({});
});
it('fills the permitted sections and the last inspection date', async () => {
grant('colonies:read', 'inspections:read', 'queens:read', 'tasks:read');
const date = new Date('2026-05-01T10:00:00.000Z');
@@ -2,6 +2,8 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import type { ColonyNextSteps } from '../autopilot/next-step-ranking';
import { NextStepsService } from '../autopilot/next-steps.service';
import { HiveCatalogService } from '../hive/hive-catalog.service';
import { HiveService } from '../hive/hive.service';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { PrismaService } from '../prisma/prisma.service';
import type {
@@ -12,6 +14,15 @@ import type {
const INSPECTIONS_PER_COLONY = 3;
/** Active systems plus the ones the pack's hives use (a hidden system still builds an old hive). */
function activeOrUsed<S extends { id: string; active: boolean }>(
systems: S[],
hives: Record<string, { systemId: string }>,
): S[] {
const used = new Set(Object.values(hives).map((hive) => hive.systemId));
return systems.filter((system) => system.active || used.has(system.id));
}
type PackColony = OfflinePackV1['colonies'][number];
interface FieldData {
@@ -33,6 +44,8 @@ export class ApiaryFieldService {
private readonly prisma: PrismaService,
private readonly permissionResolver: PermissionResolverService,
private readonly nextSteps: NextStepsService,
private readonly hives: HiveService,
private readonly hiveCatalogs: HiveCatalogService,
) {}
async colonyOverview(
@@ -45,12 +58,14 @@ export class ApiaryFieldService {
async offlinePack(apiaryId: string, userId: string): Promise<OfflinePackV1> {
const { apiary, colonies, permissions, overview, nextSteps } =
await this.load(apiaryId, userId);
const [queens, inspections, tasks] = await Promise.all([
const [queens, inspections, tasks, hives, hiveCatalog] = await Promise.all([
permissions.has('queens:read') ? this.loadQueens(colonies) : null,
permissions.has('inspections:read')
? this.loadInspections(colonies)
: null,
permissions.has('tasks:read') ? this.loadTasks(apiaryId, colonies) : null,
this.hives.getMany(colonies.map((colony) => colony.id)),
this.hiveCatalogs.listSystems(),
]);
return {
version: 1,
@@ -61,6 +76,8 @@ export class ApiaryFieldService {
queens,
inspections,
tasks,
hives,
hiveCatalog: activeOrUsed(hiveCatalog, hives),
nextSteps: Object.fromEntries(
colonies.map((colony) => [
colony.id,
@@ -7,6 +7,8 @@ import type {
Queen,
Task,
} from '../../generated/prisma';
import type { HiveSystemDto } from '../hive/hive-catalog.service';
import type { HiveSetupView } from '../hive/hive.service';
import type { NextStep } from '../autopilot/next-step-ranking';
import type { SeasonSegmentKey } from '../season/season-types';
@@ -106,4 +108,11 @@ export interface OfflinePackV1 {
* returns as `steps`). Absent in packs cached before T4.
*/
nextSteps?: Record<string, NextStep[]>;
/**
* The hive setup per colony id, only for colonies of this apiary that have
* one. Absent in packs cached before the hive card.
*/
hives?: Record<string, HiveSetupView>;
/** The hive systems with their part types. Absent in older packs. */
hiveCatalog?: HiveSystemDto[];
}
@@ -79,6 +79,7 @@ describe('HiveService', () => {
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
findMany: jest.Mock;
};
hiveChange: {
findUnique: jest.Mock;
@@ -112,6 +113,7 @@ describe('HiveService', () => {
},
hiveSetup: {
findUnique: jest.fn().mockResolvedValue(setup()),
findMany: jest.fn().mockResolvedValue([setup()]),
create: jest.fn().mockImplementation(({ data }: { data: object }) => ({
...data,
updatedAt: new Date('2026-10-02T10:00:00.000Z'),
@@ -169,6 +171,30 @@ describe('HiveService', () => {
});
});
describe('getMany', () => {
it('returns the views of the colonies that have a setup, keyed by colony', async () => {
const views = await service.getMany([COLONY, 'colony-without']);
expect(tx.hiveSetup.findMany).toHaveBeenCalledWith({
where: { colonyId: { in: [COLONY, 'colony-without'] } },
});
expect(Object.keys(views)).toEqual([COLONY]);
expect(views[COLONY].facts.boxCount).toBe(1);
});
it('loads each catalog once and skips the query for no colonies', async () => {
tx.hiveSetup.findMany.mockResolvedValue([
setup(),
{ ...setup(2), colonyId: 'colony-2' },
]);
await service.getMany([COLONY, 'colony-2']);
expect(catalogs.loadCatalog).toHaveBeenCalledTimes(1);
tx.hiveSetup.findMany.mockClear();
await expect(service.getMany([])).resolves.toEqual({});
expect(tx.hiveSetup.findMany).not.toHaveBeenCalled();
});
});
describe('create', () => {
const input = (overrides = {}) => ({
systemId: SYSTEM_ID,
+42 -2
View File
@@ -20,7 +20,7 @@ import { deriveHiveFacts } from './hive-facts';
import { HiveCatalogService } from './hive-catalog.service';
import { canonicalOp } from './hive-canonical';
import { applyHiveOps } from './hive-ops';
import type { HiveFacts, HiveOp, HiveState } from './hive-types';
import type { HiveCatalog, HiveFacts, HiveOp, HiveState } from './hive-types';
import { validateHiveState } from './hive-validate';
export interface HiveSetupView {
@@ -139,6 +139,31 @@ export class HiveService {
return this.view(setup);
}
/** The setups of the given colonies, keyed by colony id; colonies without one are left out. */
async getMany(
colonyIds: readonly string[],
): Promise<Record<string, HiveSetupView>> {
if (colonyIds.length === 0) return {};
const setups = await this.prisma.hiveSetup.findMany({
where: { colonyId: { in: [...colonyIds] } },
});
const catalogs = new Map<string, Promise<HiveCatalog>>();
const catalogOf = (systemId: string) => {
let catalog = catalogs.get(systemId);
if (!catalog) {
catalog = this.catalogs.loadCatalog(systemId);
catalogs.set(systemId, catalog);
}
return catalog;
};
const views = await Promise.all(
setups.map(async (setup) =>
this.viewWith(setup, await catalogOf(setup.systemId)),
),
);
return Object.fromEntries(views.map((view) => [view.colonyId, view]));
}
async create(
colonyId: string,
input: CreateSetupInput,
@@ -364,7 +389,22 @@ export class HiveService {
state: Prisma.JsonValue;
updatedAt: Date;
}): Promise<HiveSetupView> {
const catalog = await this.catalogs.loadCatalog(setup.systemId);
return this.viewWith(
setup,
await this.catalogs.loadCatalog(setup.systemId),
);
}
private viewWith(
setup: {
colonyId: string;
systemId: string;
version: number;
state: Prisma.JsonValue;
updatedAt: Date;
},
catalog: HiveCatalog,
): HiveSetupView {
const state = setup.state as unknown as HiveState;
return {
colonyId: setup.colonyId,
@@ -32,6 +32,7 @@ describe('NotificationsService', () => {
user: { findMany: jest.Mock };
$connect: jest.Mock;
$transaction: jest.Mock;
$queryRaw: jest.Mock;
};
beforeEach(() => {
@@ -52,10 +53,19 @@ describe('NotificationsService', () => {
// A transaction client lacks $connect; the root client has it.
$connect: jest.fn(),
$transaction: jest.fn(),
// The FOR KEY SHARE lock on the recipients: every user still exists.
$queryRaw: jest.fn().mockImplementation(() => {
const found = prisma.user.findMany.mock.results.at(-1);
return Promise.resolve(found ? (found.value as unknown) : []);
}),
};
prisma.$transaction.mockImplementation((work: (tx: unknown) => unknown) =>
Promise.resolve(work(prisma)),
);
prisma.$transaction.mockImplementation((work: (tx: unknown) => unknown) => {
// A transaction client has no $connect; the code tells the two apart.
const txClient = Object.fromEntries(
Object.entries(prisma).filter(([key]) => key !== '$connect'),
);
return Promise.resolve(work(txClient));
});
held = new Set();
service = new NotificationsService(
prisma as unknown as PrismaService,
@@ -78,6 +88,28 @@ describe('NotificationsService', () => {
void service.record({ priority: 'INFO', category: 'SYSTEM', message: 'm' });
});
describe('recipient locking', () => {
it('locks the recipients in the write transaction and skips a user deleted meanwhile', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]);
prisma.$queryRaw.mockResolvedValue([{ id: 'u1' }]);
const rows = await service.recordForUsers(['u1', 'u2'], {
priority: 'INFO',
category: 'SYSTEM',
message: 'm',
});
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
expect(
(prisma.$queryRaw.mock.calls[0] as [{ sql: string }])[0].sql,
).toContain('FOR KEY SHARE');
expect(rows.map((row) => (row as { userId: string }).userId)).toEqual([
'u1',
]);
});
});
describe('record', () => {
it('writes one row for an eligible recipient and returns it', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
@@ -138,6 +170,7 @@ describe('NotificationsService', () => {
it('writes through the transaction client when one is given', async () => {
const tx = {
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
$queryRaw: jest.fn().mockResolvedValue([{ id: 'u1' }]),
notification: {
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n' }]),
},
@@ -180,7 +213,10 @@ describe('NotificationsService', () => {
const rows = await service.recordForUsers(['u1', 'u2'], base);
expect(planner.planIn).toHaveBeenCalledTimes(1);
expect(planner.planIn).toHaveBeenCalledWith(prisma, rows);
expect(planner.planIn).toHaveBeenCalledWith(
expect.objectContaining({ notification: prisma.notification }),
rows,
);
expect(
prisma.notification.createManyAndReturn.mock.invocationCallOrder[0],
).toBeLessThan(planner.planIn.mock.invocationCallOrder[0]);
@@ -189,6 +225,7 @@ describe('NotificationsService', () => {
it('plans with the caller transaction and opens none of its own', async () => {
const tx = {
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
$queryRaw: jest.fn().mockResolvedValue([{ id: 'u1' }]),
notification: {
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n' }]),
},
@@ -316,6 +353,7 @@ describe('NotificationsService', () => {
it('reads and writes through the transaction client when one is given', async () => {
const tx = {
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
$queryRaw: jest.fn().mockResolvedValue([{ id: 'u1' }]),
notification: {
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n1' }]),
},
@@ -342,6 +380,7 @@ describe('NotificationsService', () => {
let calls: string[];
let tx: {
$executeRaw: jest.Mock;
$queryRaw: jest.Mock;
user: { findMany: jest.Mock };
notification: { findMany: jest.Mock; createManyAndReturn: jest.Mock };
};
@@ -353,6 +392,11 @@ describe('NotificationsService', () => {
calls.push('lock');
return Promise.resolve(1);
}),
// The key-share lock keeps every user the read just returned.
$queryRaw: jest.fn().mockImplementation(() => {
const read = tx.user.findMany.mock.results.at(-1);
return Promise.resolve(read ? (read.value as unknown) : []);
}),
user: {
// Like the database: the id filter narrows the three eligible users.
findMany: jest
@@ -104,17 +104,18 @@ export class NotificationsService {
input: CreateNotificationInput,
tx?: Prisma.TransactionClient,
): Promise<Notification | null> {
const db: DbClient = tx ?? this.prisma;
const { userId, dedupeKey, ...content } = input;
const keys = uniqueSorted(content.requiredPermissions ?? []);
const recipients = await this.recipients(db, keys, [userId]);
const [row] = await this.write(
db,
recipients.map((id) => ({ userId: id, dedupeKey: dedupeKey ?? null })),
content,
keys,
);
return row ?? null;
return this.inTransaction(tx, async (db) => {
const recipients = await this.recipients(db, keys, [userId]);
const [row] = await this.write(
db,
recipients.map((id) => ({ userId: id, dedupeKey: dedupeKey ?? null })),
content,
keys,
);
return row ?? null;
});
}
/** The given users, minus those missing a permission the content needs. */
@@ -127,7 +128,9 @@ export class NotificationsService {
return [];
}
const keys = uniqueSorted(input.requiredPermissions ?? []);
return this.fanOut(tx ?? this.prisma, keys, input, userIds);
return this.inTransaction(tx, (db) =>
this.fanOut(db, keys, input, userIds),
);
}
/** Every user holding all of `permission` (and the content's permissions). */
@@ -146,7 +149,9 @@ export class NotificationsService {
...recipientKeys,
...(input.requiredPermissions ?? []),
]);
return this.fanOut(tx ?? this.prisma, keys, input, undefined);
return this.inTransaction(tx, (db) =>
this.fanOut(db, keys, input, undefined),
);
}
/**
@@ -264,8 +269,20 @@ export class NotificationsService {
return { updated: count };
}
/**
* Recipients are read and then inserted: both steps belong to one
* transaction, in which recipients() locks the user rows, so a user deleted
* in between cannot turn the insert into a foreign key violation.
*/
private inTransaction<T>(
tx: Prisma.TransactionClient | undefined,
work: (db: Prisma.TransactionClient) => Promise<T>,
): Promise<T> {
return tx ? work(tx) : this.prisma.$transaction((own) => work(own));
}
private async fanOut(
db: DbClient,
db: Prisma.TransactionClient,
keys: readonly PermissionKey[],
input: FanOutNotificationInput,
userIds: readonly string[] | undefined,
@@ -287,7 +304,7 @@ export class NotificationsService {
/** Active user, active role, every key granted - in one query. */
private async recipients(
db: DbClient,
db: Prisma.TransactionClient,
keys: readonly PermissionKey[],
userIds: readonly string[] | undefined,
): Promise<string[]> {
@@ -305,7 +322,15 @@ export class NotificationsService {
select: { id: true },
orderBy: { id: 'asc' },
});
return users.map((user) => user.id);
if (users.length === 0) {
return [];
}
// FOR KEY SHARE: a concurrent DELETE of one of these users waits for this
// transaction; one that already committed drops out of the result.
const locked = await db.$queryRaw<{ id: string }[]>(
Prisma.sql`SELECT "id" FROM "User" WHERE "id" = ANY(${users.map((user) => user.id)}) ORDER BY "id" FOR KEY SHARE`,
);
return locked.map((user) => user.id);
}
private async write(