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

@@ -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(