From 9ba123841925c9e47a2ea01aed3cc4407100ee23 Mon Sep 17 00:00:00 2001 From: KiwiGaze <1244666638@qq.com> Date: Sun, 8 Mar 2026 23:24:39 +0800 Subject: [PATCH 1/3] fix: add missing deletedAt: null filters to Event, EventReference, and TaskReference queries Soft-deleted records were leaking into conflict detection, sync status queries, retry operations, and disconnect flows because read/update queries lacked deletedAt: null in their where clauses. --- apps/web/lib/conflict-detection/index.ts | 5 +++-- .../src/services/calendar-sync-retry.service.ts | 1 + .../src/services/calendar-sync-status.service.ts | 7 +++++-- .../src/services/event-sync-status.service.ts | 2 +- .../src/repositories/task-reference.repository.ts | 5 +++-- .../services/__tests__/credential-repair.service.test.ts | 1 + .../services/__tests__/destination-change.service.test.ts | 6 +++--- .../src/services/__tests__/task-connection.service.test.ts | 3 ++- .../task-sync/src/services/credential-repair.service.ts | 1 + packages/task-sync/src/services/task-connection.service.ts | 2 ++ packages/task-sync/src/services/task-sync-retry.service.ts | 1 + 11 files changed, 23 insertions(+), 11 deletions(-) diff --git a/apps/web/lib/conflict-detection/index.ts b/apps/web/lib/conflict-detection/index.ts index 176c836d..6ec74f08 100644 --- a/apps/web/lib/conflict-detection/index.ts +++ b/apps/web/lib/conflict-detection/index.ts @@ -71,6 +71,7 @@ export async function checkInternalConflicts( where: { userId, allDay: false, + deletedAt: null, ...(excludeEventId && { id: { not: excludeEventId } }), start: { lt: effectiveEnd }, end: { gt: effectiveStart }, @@ -715,8 +716,8 @@ export async function checkEventConflictsBeforeUpdate( // Look up existing event to get its current synced time range // This is used to exclude the event's external copy from conflict detection - const existingEvent = await prisma.event.findUnique({ - where: { id: eventId }, + const existingEvent = await prisma.event.findFirst({ + where: { id: eventId, deletedAt: null }, select: { start: true, end: true }, }); diff --git a/packages/calendar-sync/src/services/calendar-sync-retry.service.ts b/packages/calendar-sync/src/services/calendar-sync-retry.service.ts index 90e80e36..d1c4182f 100644 --- a/packages/calendar-sync/src/services/calendar-sync-retry.service.ts +++ b/packages/calendar-sync/src/services/calendar-sync-retry.service.ts @@ -110,6 +110,7 @@ export function createCalendarSyncRetryService( destinationId: destination.id, syncStatus: { in: RETRYABLE_STATUSES }, syncStatusReason: { not: SYNC_RETRY_SKIP_REASON_EVENT_MISSING }, + deletedAt: null, ...(limitedEventIds ? { eventId: { in: limitedEventIds } } : {}), }, ...(limitedEventIds diff --git a/packages/calendar-sync/src/services/calendar-sync-status.service.ts b/packages/calendar-sync/src/services/calendar-sync-status.service.ts index a4779602..fb2d1bd9 100644 --- a/packages/calendar-sync/src/services/calendar-sync-status.service.ts +++ b/packages/calendar-sync/src/services/calendar-sync-status.service.ts @@ -57,6 +57,7 @@ export function createCalendarSyncStatusService( where: { id: { in: input.eventIds }, userId: input.userId, + deletedAt: null, }, select: { id: true, createdAt: true }, }); @@ -69,6 +70,7 @@ export function createCalendarSyncStatusService( eventId: { in: input.eventIds }, destinationId: input.destinationId, provider: input.provider, + deletedAt: null, }, orderBy: { lastSyncAttemptAt: "desc" }, }); @@ -120,8 +122,8 @@ export function createCalendarSyncStatusService( }, async getSingleStatus(input: GetSingleStatusInput) { - const event = await prisma.event.findUnique({ - where: { id: input.eventId }, + const event = await prisma.event.findFirst({ + where: { id: input.eventId, deletedAt: null }, select: { id: true, userId: true, createdAt: true }, }); @@ -134,6 +136,7 @@ export function createCalendarSyncStatusService( eventId: input.eventId, destinationId: input.destinationId, provider: input.provider, + deletedAt: null, }, orderBy: { lastSyncAttemptAt: "desc" }, }); diff --git a/packages/calendar-sync/src/services/event-sync-status.service.ts b/packages/calendar-sync/src/services/event-sync-status.service.ts index b7a8af27..9195ac7c 100644 --- a/packages/calendar-sync/src/services/event-sync-status.service.ts +++ b/packages/calendar-sync/src/services/event-sync-status.service.ts @@ -56,7 +56,7 @@ export function createEventSyncStatusService(prisma: EncryptedPrismaClient) { // Verify ownership first: only process events owned by this user const ownedEvents = await prisma.event.findMany({ - where: { id: { in: eventIds }, userId }, + where: { id: { in: eventIds }, userId, deletedAt: null }, select: { id: true, createdAt: true }, }); diff --git a/packages/task-sync/src/repositories/task-reference.repository.ts b/packages/task-sync/src/repositories/task-reference.repository.ts index 3e03ca91..da33d136 100644 --- a/packages/task-sync/src/repositories/task-reference.repository.ts +++ b/packages/task-sync/src/repositories/task-reference.repository.ts @@ -273,7 +273,7 @@ export function createTaskReferenceRepository( async disableByDestination(destinationId) { await prisma.taskReference.updateMany({ - where: { destinationId }, + where: { destinationId, deletedAt: null }, data: { syncStatus: "DISABLED", syncStatusReason: "DESTINATION_DISABLED", @@ -283,7 +283,7 @@ export function createTaskReferenceRepository( async resetForRebuild(destinationId, newExternalTaskListId) { await prisma.taskReference.updateMany({ - where: { destinationId }, + where: { destinationId, deletedAt: null }, data: { externalTaskId: null, externalTaskListId: newExternalTaskListId, @@ -301,6 +301,7 @@ export function createTaskReferenceRepository( credentialId, provider, syncStatus: { in: ["FAILED", "PENDING_RETRY"] }, + deletedAt: null, }, }); }, diff --git a/packages/task-sync/src/services/__tests__/credential-repair.service.test.ts b/packages/task-sync/src/services/__tests__/credential-repair.service.test.ts index ff77b27c..ef8205f8 100644 --- a/packages/task-sync/src/services/__tests__/credential-repair.service.test.ts +++ b/packages/task-sync/src/services/__tests__/credential-repair.service.test.ts @@ -191,6 +191,7 @@ describe("CredentialRepairService", () => { "TASK_SYNC_CREDENTIAL_REFRESH_FAILED", ], }, + deletedAt: null, }, data: { syncStatus: "PENDING", diff --git a/packages/task-sync/src/services/__tests__/destination-change.service.test.ts b/packages/task-sync/src/services/__tests__/destination-change.service.test.ts index 55eb56fb..020ff775 100644 --- a/packages/task-sync/src/services/__tests__/destination-change.service.test.ts +++ b/packages/task-sync/src/services/__tests__/destination-change.service.test.ts @@ -55,7 +55,7 @@ describe("DestinationChangeService", () => { await service.handleDisable("dest-1", "user-1"); expect(mockPrisma.taskReference.updateMany).toHaveBeenCalledWith({ - where: { destinationId: "dest-1" }, + where: { destinationId: "dest-1", deletedAt: null }, data: { syncStatus: "DISABLED", syncStatusReason: "DESTINATION_DISABLED", @@ -192,7 +192,7 @@ describe("DestinationChangeService", () => { ); expect(mockPrisma.taskReference.updateMany).toHaveBeenCalledWith({ - where: { destinationId: "dest-1" }, + where: { destinationId: "dest-1", deletedAt: null }, data: { externalTaskId: null, externalTaskListId: "new-list-id", @@ -255,7 +255,7 @@ describe("DestinationChangeService", () => { await service.handleCredentialChange("dest-1", "user-1", "new-list-id"); expect(mockPrisma.taskReference.updateMany).toHaveBeenCalledWith({ - where: { destinationId: "dest-1" }, + where: { destinationId: "dest-1", deletedAt: null }, data: { externalTaskId: null, externalTaskListId: "new-list-id", diff --git a/packages/task-sync/src/services/__tests__/task-connection.service.test.ts b/packages/task-sync/src/services/__tests__/task-connection.service.test.ts index ce66e22c..c77267fe 100644 --- a/packages/task-sync/src/services/__tests__/task-connection.service.test.ts +++ b/packages/task-sync/src/services/__tests__/task-connection.service.test.ts @@ -395,7 +395,7 @@ describe("TaskConnectionService", () => { expect(result).toEqual({ disconnectedCredentials: 2 }); expect(mockPrisma.taskReference.updateMany).toHaveBeenCalledWith({ - where: { credentialId: { in: ["cred-1", "cred-2"] } }, + where: { credentialId: { in: ["cred-1", "cred-2"] }, deletedAt: null }, data: { syncStatusReason: null, lastSyncAttemptAt: null, @@ -425,6 +425,7 @@ describe("TaskConnectionService", () => { where: { credentialId: "cred-9", provider: "GOOGLE_TASKS", + deletedAt: null, }, data: { syncStatusReason: null, diff --git a/packages/task-sync/src/services/credential-repair.service.ts b/packages/task-sync/src/services/credential-repair.service.ts index 5b0268ec..4d997b0d 100644 --- a/packages/task-sync/src/services/credential-repair.service.ts +++ b/packages/task-sync/src/services/credential-repair.service.ts @@ -77,6 +77,7 @@ export function createCredentialRepairService( credentialId, syncStatus: "FAILED", syncStatusReason: { in: [...CREDENTIAL_ERROR_REASONS] }, + deletedAt: null, }, data: { syncStatus: "PENDING", diff --git a/packages/task-sync/src/services/task-connection.service.ts b/packages/task-sync/src/services/task-connection.service.ts index 4f4ce6f4..65d7041b 100644 --- a/packages/task-sync/src/services/task-connection.service.ts +++ b/packages/task-sync/src/services/task-connection.service.ts @@ -516,6 +516,7 @@ export function createTaskConnectionService( await tx.taskReference.updateMany({ where: { credentialId: { in: credentialIds }, + deletedAt: null, }, data: { syncStatusReason: null, @@ -702,6 +703,7 @@ export function createTaskConnectionService( where: { credentialId, provider: credential.provider, + deletedAt: null, }, data: { syncStatusReason: null, diff --git a/packages/task-sync/src/services/task-sync-retry.service.ts b/packages/task-sync/src/services/task-sync-retry.service.ts index 76bfec9f..e09dc85b 100644 --- a/packages/task-sync/src/services/task-sync-retry.service.ts +++ b/packages/task-sync/src/services/task-sync-retry.service.ts @@ -120,6 +120,7 @@ export function createTaskSyncRetryService( provider: destination.provider, credential: { userId: input.userId }, syncStatus: { in: RETRYABLE_STATUSES }, + deletedAt: null, ...(limitedTaskIds ? { taskId: { in: limitedTaskIds } } : {}), }, ...(limitedTaskIds From 88d40ddd40b64f1211da87362f1ed4f6b8daf521 Mon Sep 17 00:00:00 2001 From: KiwiGaze <1244666638@qq.com> Date: Sun, 8 Mar 2026 23:38:45 +0800 Subject: [PATCH 2/3] fix: revert Event.deletedAt filters on sync status services Status services must see tombstoned events during the delete-in-flight window (between Event.deletedAt being set and the external provider confirming deletion). EventReference.deletedAt filters are kept since references only get deletedAt set after external delete succeeds. --- .../src/services/calendar-sync-status.service.ts | 5 ++--- .../calendar-sync/src/services/event-sync-status.service.ts | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/calendar-sync/src/services/calendar-sync-status.service.ts b/packages/calendar-sync/src/services/calendar-sync-status.service.ts index fb2d1bd9..abd3da4c 100644 --- a/packages/calendar-sync/src/services/calendar-sync-status.service.ts +++ b/packages/calendar-sync/src/services/calendar-sync-status.service.ts @@ -57,7 +57,6 @@ export function createCalendarSyncStatusService( where: { id: { in: input.eventIds }, userId: input.userId, - deletedAt: null, }, select: { id: true, createdAt: true }, }); @@ -122,8 +121,8 @@ export function createCalendarSyncStatusService( }, async getSingleStatus(input: GetSingleStatusInput) { - const event = await prisma.event.findFirst({ - where: { id: input.eventId, deletedAt: null }, + const event = await prisma.event.findUnique({ + where: { id: input.eventId }, select: { id: true, userId: true, createdAt: true }, }); diff --git a/packages/calendar-sync/src/services/event-sync-status.service.ts b/packages/calendar-sync/src/services/event-sync-status.service.ts index 9195ac7c..b7a8af27 100644 --- a/packages/calendar-sync/src/services/event-sync-status.service.ts +++ b/packages/calendar-sync/src/services/event-sync-status.service.ts @@ -56,7 +56,7 @@ export function createEventSyncStatusService(prisma: EncryptedPrismaClient) { // Verify ownership first: only process events owned by this user const ownedEvents = await prisma.event.findMany({ - where: { id: { in: eventIds }, userId, deletedAt: null }, + where: { id: { in: eventIds }, userId }, select: { id: true, createdAt: true }, }); From c9e69ac65e23be5946f0e27056754ffded1afa27 Mon Sep 17 00:00:00 2001 From: KiwiGaze <1244666638@qq.com> Date: Sun, 8 Mar 2026 23:39:49 +0800 Subject: [PATCH 3/3] fix: scope existing-event lookup by userId in conflict detection Prevents building excludeTimeRange from another user's event if a mismatched eventId is passed to checkEventConflictsBeforeUpdate. --- apps/web/lib/conflict-detection/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/lib/conflict-detection/index.ts b/apps/web/lib/conflict-detection/index.ts index 6ec74f08..0c9896d0 100644 --- a/apps/web/lib/conflict-detection/index.ts +++ b/apps/web/lib/conflict-detection/index.ts @@ -717,7 +717,7 @@ export async function checkEventConflictsBeforeUpdate( // Look up existing event to get its current synced time range // This is used to exclude the event's external copy from conflict detection const existingEvent = await prisma.event.findFirst({ - where: { id: eventId, deletedAt: null }, + where: { id: eventId, userId, deletedAt: null }, select: { start: true, end: true }, });