feat: T4 wave 3 - notifications per user, suggestions API, varroa bands, split/queen events
- Schema III: per-user notifications (broadcast fan-out migration, category, link, requiredPermissions filter), notification preferences and settings endpoints, calendar withdrawal switch, occupiedFrames - Suggestions API: list/summary with permission visibility and paging, accept/dismiss/reopen incl. bulk, SuggestionView on all responses - Varroa traffic light by season and method, young colonies, treatment effect window, sample-based percentage; shared withdrawal-window helper on Berlin calendar days; GET /varroa-measurements/thresholds - ColonySplit and QueenAssigned domain events with calendar and webhook handlers - Outbox claims compare against now() rounded to timestamp(3); fixes the flaky e2e claims of rows written with SQL now() Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
d99a697739
commit
f468dc5f75
87 files changed
+6022
-510
No files matched your search
@@ -6,8 +6,11 @@ import {
|
||||
const FEED_VOCABULARY = '20261014100200_t4_feed_vocabulary';
|
||||
const SPLIT_QUEEN_SOURCE = '20261014100300_t4_split_queen_source';
|
||||
const VARROA_SAMPLE_SIZE = '20261014100400_t4_varroa_sample_size';
|
||||
const NOTIFICATION_FANOUT = '20261014100500_t4_notification_fanout';
|
||||
const OCCUPIED_FRAMES = '20261014100700_t4_occupied_frames';
|
||||
const NOTIFICATION_SETTINGS = '20261014100800_t4_notification_settings';
|
||||
|
||||
const TIMEOUT_MS = 120_000;
|
||||
const TIMEOUT_MS = 240_000;
|
||||
|
||||
interface InspectionSeed {
|
||||
id: string;
|
||||
@@ -36,6 +39,48 @@ async function seedColony(db: ScratchDatabase): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/** Two active users and one inactive one, plus the role they need. */
|
||||
async function seedUsers(db: ScratchDatabase): Promise<void> {
|
||||
await db.query(
|
||||
`INSERT INTO "Role" ("id", "key", "name", "updatedAt")
|
||||
VALUES ('role-1', 'MIG_TEST', 'Migration test', now())`,
|
||||
);
|
||||
for (const [id, active] of [
|
||||
['user-a', true],
|
||||
['user-b', true],
|
||||
['user-c', false],
|
||||
] as const) {
|
||||
await db.query(
|
||||
`INSERT INTO "User" ("id", "email", "passwordHash", "name", "roleId", "isActive", "updatedAt")
|
||||
VALUES ($1, $2, 'x', $1, 'role-1', $3, now())`,
|
||||
[id, `${id}@example.com`, active],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function seedNotification(
|
||||
db: ScratchDatabase,
|
||||
row: {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
relatedType: string | null;
|
||||
read?: boolean;
|
||||
createdAt?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await db.query(
|
||||
`INSERT INTO "Notification" ("id", "userId", "priority", "message", "relatedType", "read", "createdAt")
|
||||
VALUES ($1, $2, 'WARNUNG', 'm', $3, $4, $5)`,
|
||||
[
|
||||
row.id,
|
||||
row.userId,
|
||||
row.relatedType,
|
||||
row.read ?? false,
|
||||
row.createdAt ?? '2026-05-01T10:00:00Z',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async function seedInspections(
|
||||
db: ScratchDatabase,
|
||||
seeds: InspectionSeed[],
|
||||
@@ -215,4 +260,215 @@ describe('T4 schema II migrations (e2e)', () => {
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'broadcasts are copied per active user with their read flag',
|
||||
async () => {
|
||||
db = await createScratchDatabase(NOTIFICATION_FANOUT);
|
||||
await seedUsers(db);
|
||||
await seedNotification(db, {
|
||||
id: 'broadcast',
|
||||
userId: null,
|
||||
relatedType: 'COLONY',
|
||||
read: true,
|
||||
createdAt: '2026-04-01T08:00:00Z',
|
||||
});
|
||||
await seedNotification(db, {
|
||||
id: 'personal',
|
||||
userId: 'user-c',
|
||||
relatedType: 'COLONY',
|
||||
});
|
||||
|
||||
const [{ createdAt }] = await db.query<{ createdAt: string }>(
|
||||
`SELECT "createdAt"::text AS "createdAt" FROM "Notification" WHERE "id" = 'broadcast'`,
|
||||
);
|
||||
|
||||
await db.migrateThrough(NOTIFICATION_FANOUT);
|
||||
|
||||
const rows = await db.query<{
|
||||
userId: string;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
}>(
|
||||
`SELECT "userId", "read", "createdAt"::text AS "createdAt"
|
||||
FROM "Notification" `,
|
||||
);
|
||||
rows.sort((a, b) => (a.userId < b.userId ? -1 : 1));
|
||||
expect(rows.map(({ userId, read }) => ({ userId, read }))).toEqual([
|
||||
{ userId: 'user-a', read: true },
|
||||
{ userId: 'user-b', read: true },
|
||||
{ userId: 'user-c', read: false },
|
||||
]);
|
||||
expect(rows[0].createdAt).toBe(createdAt);
|
||||
expect(rows[1].createdAt).toBe(createdAt);
|
||||
expect(
|
||||
await db.query(
|
||||
`SELECT is_nullable FROM information_schema.columns
|
||||
WHERE table_schema = current_schema() AND table_name = 'Notification' AND column_name = 'userId'`,
|
||||
),
|
||||
).toEqual([{ is_nullable: 'NO' }]);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'drops broadcasts when no user is active and keeps the personal rows',
|
||||
async () => {
|
||||
db = await createScratchDatabase(NOTIFICATION_FANOUT);
|
||||
await db.query(
|
||||
`INSERT INTO "Role" ("id", "key", "name", "updatedAt")
|
||||
VALUES ('role-1', 'MIG_TEST', 'Migration test', now())`,
|
||||
);
|
||||
await db.query(
|
||||
`INSERT INTO "User" ("id", "email", "passwordHash", "name", "roleId", "isActive", "updatedAt")
|
||||
VALUES ('user-x', '[email protected]', 'x', 'x', 'role-1', false, now())`,
|
||||
);
|
||||
await seedNotification(db, {
|
||||
id: 'broadcast',
|
||||
userId: null,
|
||||
relatedType: null,
|
||||
});
|
||||
await seedNotification(db, {
|
||||
id: 'personal',
|
||||
userId: 'user-x',
|
||||
relatedType: null,
|
||||
});
|
||||
|
||||
await db.migrateThrough(NOTIFICATION_FANOUT);
|
||||
|
||||
expect(await db.query('SELECT "id" FROM "Notification"')).toEqual([
|
||||
{ id: 'personal' },
|
||||
]);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'copies carry no dedupeKey, old rows get empty requiredPermissions, and the preference defaults hold',
|
||||
async () => {
|
||||
db = await createScratchDatabase(NOTIFICATION_FANOUT);
|
||||
await seedUsers(db);
|
||||
await seedNotification(db, {
|
||||
id: 'broadcast',
|
||||
userId: null,
|
||||
relatedType: 'COLONY',
|
||||
});
|
||||
await seedNotification(db, {
|
||||
id: 'personal',
|
||||
userId: 'user-a',
|
||||
relatedType: 'COLONY',
|
||||
});
|
||||
|
||||
await db.migrateThrough(NOTIFICATION_SETTINGS);
|
||||
|
||||
expect(
|
||||
await db.query(
|
||||
`SELECT count(*)::int AS "rows",
|
||||
count("dedupeKey")::int AS "keys",
|
||||
count(*) FILTER (WHERE "requiredPermissions" = '{}')::int AS "emptyPermissions",
|
||||
count("link")::int AS "links"
|
||||
FROM "Notification"`,
|
||||
),
|
||||
).toEqual([{ rows: 3, keys: 0, emptyPermissions: 3, links: 0 }]);
|
||||
|
||||
await db.query(
|
||||
`INSERT INTO "NotificationPreference" ("userId", "updatedAt") VALUES ('user-a', now())`,
|
||||
);
|
||||
expect(
|
||||
await db.query(
|
||||
`SELECT "taskDueEnabled", "digestEnabled", "digestTime", "quietFrom", "quietUntil",
|
||||
"telegramEnabled", "telegramCategories"::text AS "telegramCategories", "telegramDetail"
|
||||
FROM "NotificationPreference"`,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
taskDueEnabled: true,
|
||||
digestEnabled: true,
|
||||
digestTime: '06:30',
|
||||
quietFrom: '21:00',
|
||||
quietUntil: '06:30',
|
||||
telegramEnabled: false,
|
||||
telegramCategories: '{TASK_DUE,DIGEST}',
|
||||
telegramDetail: true,
|
||||
},
|
||||
]);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'legacy job-failure rows (relatedType JobRun) and rows without relatedType get SYSTEM, all others SUGGESTION, before NOT NULL',
|
||||
async () => {
|
||||
db = await createScratchDatabase(NOTIFICATION_FANOUT);
|
||||
await seedUsers(db);
|
||||
await seedNotification(db, {
|
||||
id: 'n-job',
|
||||
userId: 'user-a',
|
||||
relatedType: 'JobRun',
|
||||
});
|
||||
await seedNotification(db, {
|
||||
id: 'n-none',
|
||||
userId: 'user-a',
|
||||
relatedType: null,
|
||||
});
|
||||
await seedNotification(db, {
|
||||
id: 'n-colony',
|
||||
userId: 'user-a',
|
||||
relatedType: 'COLONY',
|
||||
});
|
||||
await seedNotification(db, {
|
||||
id: 'n-broadcast',
|
||||
userId: null,
|
||||
relatedType: 'JobRun',
|
||||
});
|
||||
|
||||
await db.migrateThrough(NOTIFICATION_SETTINGS);
|
||||
|
||||
expect(
|
||||
await db.query(
|
||||
`SELECT "id", "category" FROM "Notification" WHERE "id" LIKE 'n-%' ORDER BY "id"`,
|
||||
),
|
||||
).toEqual([
|
||||
{ id: 'n-colony', category: 'SUGGESTION' },
|
||||
{ id: 'n-job', category: 'SYSTEM' },
|
||||
{ id: 'n-none', category: 'SYSTEM' },
|
||||
]);
|
||||
// The broadcast copies carry relatedType JobRun too.
|
||||
expect(
|
||||
await db.query(
|
||||
`SELECT DISTINCT "category" FROM "Notification" WHERE "relatedType" = 'JobRun' AND "id" NOT LIKE 'n-%'`,
|
||||
),
|
||||
).toEqual([{ category: 'SYSTEM' }]);
|
||||
expect(
|
||||
await db.query(
|
||||
`SELECT is_nullable FROM information_schema.columns
|
||||
WHERE table_schema = current_schema() AND table_name = 'Notification' AND column_name = 'category'`,
|
||||
),
|
||||
).toEqual([{ is_nullable: 'NO' }]);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'occupied frames migration leaves strength untouched',
|
||||
async () => {
|
||||
db = await createScratchDatabase(OCCUPIED_FRAMES);
|
||||
await seedColony(db);
|
||||
await db.query(`UPDATE "Colony" SET "strength" = 7`);
|
||||
|
||||
await db.migrateThrough(OCCUPIED_FRAMES);
|
||||
|
||||
expect(
|
||||
await db.query('SELECT "strength", "occupiedFrames" FROM "Colony"'),
|
||||
).toEqual([{ strength: 7, occupiedFrames: null }]);
|
||||
await db.query(`UPDATE "Colony" SET "occupiedFrames" = 40`);
|
||||
await expect(
|
||||
db.query(`UPDATE "Colony" SET "occupiedFrames" = 41`),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
db.query(`UPDATE "Colony" SET "occupiedFrames" = -1`),
|
||||
).rejects.toThrow();
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user