diff --git a/.github/workflows/deployPR.yml b/.github/workflows/deployPR.yml index 5afde46212..e71fb0e688 100644 --- a/.github/workflows/deployPR.yml +++ b/.github/workflows/deployPR.yml @@ -329,6 +329,10 @@ jobs: "20260228185557_default_ticket_sweep_enabled" \ "SELECT COUNT(*) FROM information_schema.columns WHERE table_name='janitor_configs' AND column_name='ticket_sweep_enabled' AND column_default='true';" + resolve_migration \ + "20260301004827_add_fast_track_to_feature" \ + "SELECT COUNT(*) FROM information_schema.columns WHERE table_name='features' AND column_name='is_fast_track';" + resolve_migration \ "20260319000000_add_created_by_id_to_whiteboards" \ "SELECT COUNT(*) FROM information_schema.columns WHERE table_name='whiteboards' AND column_name='created_by_id';" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d0bb521f26..edc748ae35 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,6 +30,8 @@ jobs: - name: Install dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' + env: + DATABASE_URL: postgresql://dummy:dummy@localhost:5432/dummy run: npm ci - name: Run unit tests diff --git a/prisma/migrations/20260301004827_add_fast_track_to_feature/migration.sql b/prisma/migrations/20260301004827_add_fast_track_to_feature/migration.sql index 68c409bef3..455ce7c9f3 100644 --- a/prisma/migrations/20260301004827_add_fast_track_to_feature/migration.sql +++ b/prisma/migrations/20260301004827_add_fast_track_to_feature/migration.sql @@ -1,2 +1,2 @@ -- AlterTable -ALTER TABLE "features" ADD COLUMN "is_fast_track" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "features" ADD COLUMN IF NOT EXISTS "is_fast_track" BOOLEAN NOT NULL DEFAULT false; diff --git a/src/__tests__/integration/api/admin/pr-stats.test.ts b/src/__tests__/integration/api/admin/pr-stats.test.ts index 7d4db52b7c..c325e024d1 100644 --- a/src/__tests__/integration/api/admin/pr-stats.test.ts +++ b/src/__tests__/integration/api/admin/pr-stats.test.ts @@ -104,7 +104,7 @@ describe("GET /api/admin/workspaces/[id]/pr-stats (integration)", () => { messageId: message.id, type: "PULL_REQUEST", content: { - url: `https://github.com/testorg/testrepo/pull/${Math.floor(Math.random() * 9999)}`, + url: `https://github.com/testorg/testrepo/pull/${ageHours * 100}`, repo: "testorg/testrepo", status, title: `Test PR (${status})`, @@ -144,6 +144,7 @@ describe("GET /api/admin/workspaces/[id]/pr-stats (integration)", () => { repositoryUrl: "https://github.com/testorg/bucketrepo", }); + let artifactCounter = 1; async function seedDoneArtifact(ageHours: number) { const task = await createTestTask({ workspaceId: workspace.id, createdById: regularUser.id }); const message = await createTestChatMessage({ taskId: task.id, message: "test" }); @@ -153,7 +154,7 @@ describe("GET /api/admin/workspaces/[id]/pr-stats (integration)", () => { messageId: message.id, type: "PULL_REQUEST", content: { - url: `https://github.com/testorg/bucketrepo/pull/${Math.floor(Math.random() * 9999)}`, + url: `https://github.com/testorg/bucketrepo/pull/${artifactCounter++}`, repo: "testorg/bucketrepo", status: "DONE", title: "Test PR", diff --git a/src/__tests__/unit/services/notifications.test.ts b/src/__tests__/unit/services/notifications.test.ts index 548f245a01..2fe9293901 100644 --- a/src/__tests__/unit/services/notifications.test.ts +++ b/src/__tests__/unit/services/notifications.test.ts @@ -182,16 +182,16 @@ describe("createAndSendNotification", () => { await createAndSendNotification(baseInput); expect(create).toHaveBeenCalledOnce(); - expect(mockedSendDirectMessage).not.toHaveBeenCalled(); - expect(update).toHaveBeenCalledWith( + expect(create).toHaveBeenCalledWith( expect.objectContaining({ - where: { id: "notif-1" }, data: expect.objectContaining({ sendAfter: expect.any(Date), message: baseInput.message, }), }) ); + expect(mockedSendDirectMessage).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); }); }); diff --git a/src/services/notifications.ts b/src/services/notifications.ts index eeb3c6bb4e..3954d381e3 100644 --- a/src/services/notifications.ts +++ b/src/services/notifications.ts @@ -75,7 +75,11 @@ export async function createAndSendNotification(input: { : null; const dmReady = isDirectMessageConfigured() && !!decryptedPubkey; - // 4. Always insert a row — use SKIPPED when DM is not ready + // 4. Compute deferred fields up-front to avoid a two-step create+update race + const isDeferred = dmReady && DEFERRED_NOTIFICATION_TYPES.has(input.notificationType); + const sendAfter = isDeferred ? new Date(Date.now() + DEFERRED_DELAY_MS) : null; + + // 5. Insert a single row with all fields set atomically const record = await db.notificationTrigger.create({ data: { targetUserId: input.targetUserId, @@ -88,10 +92,11 @@ export async function createAndSendNotification(input: { : NotificationTriggerStatus.SKIPPED, notificationMethod: NotificationMethod.SPHINX, notificationTimestamps: [], + ...(isDeferred && { sendAfter, message: input.message }), }, }); - // 5. Stop here if DM is not configured — no send attempted + // 6. Stop here if DM is not configured — no send attempted if (!dmReady) { logger.info( `[Notifications] DM not ready — record created as SKIPPED for ${input.notificationType}`, @@ -101,15 +106,10 @@ export async function createAndSendNotification(input: { return; } - // 6. Deferred types: store sendAfter + message, return without sending - if (DEFERRED_NOTIFICATION_TYPES.has(input.notificationType)) { - const sendAfter = new Date(Date.now() + DEFERRED_DELAY_MS); - await db.notificationTrigger.update({ - where: { id: record.id }, - data: { sendAfter, message: input.message }, - }); + // 7. Deferred types: log and return — sendAfter already persisted above + if (isDeferred) { logger.info( - `[Notifications] Deferred ${input.notificationType} — will dispatch after ${sendAfter.toISOString()}`, + `[Notifications] Deferred ${input.notificationType} — will dispatch after ${sendAfter!.toISOString()}`, "NOTIFICATIONS", { recordId: record.id, targetUserId: input.targetUserId, taskId, featureId } );