From a57abbe039175fe4ea2fa4366c55c1ba2d2c1a89 Mon Sep 17 00:00:00 2001 From: Stanislaw <56738034+anvme@users.noreply.github.com> Date: Thu, 7 May 2026 03:37:22 +0200 Subject: [PATCH 1/2] fix: clean up link, pixel, board rows on user/team deletion deleteUser and deleteTeam left link/pixel/board rows (and their share rows) in the database after the owner was removed. /q/ and /p/ also kept serving deleted entries because the routes did not filter deletedAt and Redis cached lookups for 24h. - deleteUser: clean up link/pixel/board + shares for the deleted user. Cloud mode: soft-delete link/pixel, hard-delete board, only userId-owned. Non-cloud: hard-delete everything matching userId or owned teamIds. - deleteTeam: same cleanup, scoped to teamId. - /q and /p route handlers: filter deletedAt: null at the call sites (not in findLink/findPixel helpers, which would null-deref the permission checks at src/permissions/link.ts and pixel.ts). - Post-transaction Redis invalidation mirrors deleteWebsite. --- src/app/(collect)/p/[slug]/route.ts | 2 ++ src/app/(collect)/q/[slug]/route.ts | 2 ++ src/queries/prisma/team.ts | 37 ++++++++++++++++++++++-- src/queries/prisma/user.ts | 44 +++++++++++++++++++++++++++-- 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/src/app/(collect)/p/[slug]/route.ts b/src/app/(collect)/p/[slug]/route.ts index fd18a2834..36ba5a892 100644 --- a/src/app/(collect)/p/[slug]/route.ts +++ b/src/app/(collect)/p/[slug]/route.ts @@ -21,6 +21,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ slug return findPixel({ where: { slug, + deletedAt: null, }, }); }, @@ -34,6 +35,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ slug pixel = await findPixel({ where: { slug, + deletedAt: null, }, }); diff --git a/src/app/(collect)/q/[slug]/route.ts b/src/app/(collect)/q/[slug]/route.ts index aa9c26f3f..f585fc02e 100644 --- a/src/app/(collect)/q/[slug]/route.ts +++ b/src/app/(collect)/q/[slug]/route.ts @@ -19,6 +19,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ slug return findLink({ where: { slug, + deletedAt: null, }, }); }, @@ -32,6 +33,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ slug link = await findLink({ where: { slug, + deletedAt: null, }, }); diff --git a/src/queries/prisma/team.ts b/src/queries/prisma/team.ts index de938df35..b24d65227 100644 --- a/src/queries/prisma/team.ts +++ b/src/queries/prisma/team.ts @@ -2,6 +2,7 @@ import { Prisma, type Team } from '@/generated/prisma/client'; import { ROLES } from '@/lib/constants'; import { uuid } from '@/lib/crypto'; import prisma from '@/lib/prisma'; +import redis from '@/lib/redis'; import type { PageResult, QueryFilters } from '@/lib/types'; import TeamFindManyArgs = Prisma.TeamFindManyArgs; @@ -144,6 +145,24 @@ export async function deleteTeam(teamId: string) { const { client, transaction } = prisma; const cloudMode = !!process.env.CLOUD_MODE; + const [links, pixels, boards] = await Promise.all([ + client.link.findMany({ where: { teamId }, select: { id: true, slug: true } }), + client.pixel.findMany({ where: { teamId }, select: { id: true, slug: true } }), + client.board.findMany({ where: { teamId }, select: { id: true } }), + ]); + const entityIds = [...links.map(l => l.id), ...pixels.map(p => p.id), ...boards.map(b => b.id)]; + const linkSlugs = links.map(l => l.slug); + const pixelSlugs = pixels.map(p => p.slug); + + const invalidateRedis = async () => { + if (redis.enabled && (linkSlugs.length || pixelSlugs.length)) { + await Promise.all([ + ...linkSlugs.map(slug => redis.client.del(`link:${slug}`)), + ...pixelSlugs.map(slug => redis.client.del(`pixel:${slug}`)), + ]); + } + }; + if (cloudMode) { return transaction([ client.team.update({ @@ -154,7 +173,14 @@ export async function deleteTeam(teamId: string) { id: teamId, }, }), - ]); + client.share.deleteMany({ where: { entityId: { in: entityIds } } }), + client.link.updateMany({ data: { deletedAt: new Date() }, where: { teamId } }), + client.pixel.updateMany({ data: { deletedAt: new Date() }, where: { teamId } }), + client.board.deleteMany({ where: { teamId } }), + ]).then(async result => { + await invalidateRedis(); + return result; + }); } return transaction([ @@ -163,10 +189,17 @@ export async function deleteTeam(teamId: string) { teamId, }, }), + client.share.deleteMany({ where: { entityId: { in: entityIds } } }), + client.link.deleteMany({ where: { teamId } }), + client.pixel.deleteMany({ where: { teamId } }), + client.board.deleteMany({ where: { teamId } }), client.team.delete({ where: { id: teamId, }, }), - ]); + ]).then(async result => { + await invalidateRedis(); + return result; + }); } diff --git a/src/queries/prisma/user.ts b/src/queries/prisma/user.ts index 467ea1e02..c527e6026 100644 --- a/src/queries/prisma/user.ts +++ b/src/queries/prisma/user.ts @@ -2,6 +2,7 @@ import { Prisma } from '@/generated/prisma/client'; import { ROLES } from '@/lib/constants'; import { getRandomChars } from '@/lib/generate'; import prisma from '@/lib/prisma'; +import redis from '@/lib/redis'; import type { QueryFilters, Role } from '@/lib/types'; import UserFindManyArgs = Prisma.UserFindManyArgs; @@ -126,6 +127,31 @@ export async function deleteUser(userId: string) { const teamIds = teams.map(a => a.id); + // Cloud mode keeps owned teams (and their team-owned content), so cleanup + // only covers user-direct rows. Non-cloud hard-deletes owned teams below, + // so we must also clean up team-owned content. + const ownedFilter = cloudMode + ? { userId } + : { OR: [{ userId }, { teamId: { in: teamIds } }] }; + + const [links, pixels, boards] = await Promise.all([ + client.link.findMany({ where: ownedFilter, select: { id: true, slug: true } }), + client.pixel.findMany({ where: ownedFilter, select: { id: true, slug: true } }), + client.board.findMany({ where: ownedFilter, select: { id: true } }), + ]); + const entityIds = [...links.map(l => l.id), ...pixels.map(p => p.id), ...boards.map(b => b.id)]; + const linkSlugs = links.map(l => l.slug); + const pixelSlugs = pixels.map(p => p.slug); + + const invalidateRedis = async () => { + if (redis.enabled && (linkSlugs.length || pixelSlugs.length)) { + await Promise.all([ + ...linkSlugs.map(slug => redis.client.del(`link:${slug}`)), + ...pixelSlugs.map(slug => redis.client.del(`pixel:${slug}`)), + ]); + } + }; + if (cloudMode) { return transaction([ client.website.updateMany({ @@ -143,7 +169,14 @@ export async function deleteUser(userId: string) { id: userId, }, }), - ]); + client.share.deleteMany({ where: { entityId: { in: entityIds } } }), + client.link.updateMany({ data: { deletedAt: new Date() }, where: { userId } }), + client.pixel.updateMany({ data: { deletedAt: new Date() }, where: { userId } }), + client.board.deleteMany({ where: { userId } }), + ]).then(async result => { + await invalidateRedis(); + return result; + }); } return transaction([ @@ -194,6 +227,10 @@ export async function deleteUser(userId: string) { ], }, }), + client.share.deleteMany({ where: { entityId: { in: entityIds } } }), + client.link.deleteMany({ where: ownedFilter }), + client.pixel.deleteMany({ where: ownedFilter }), + client.board.deleteMany({ where: ownedFilter }), client.website.deleteMany({ where: { id: { in: websiteIds } }, }), @@ -202,5 +239,8 @@ export async function deleteUser(userId: string) { id: userId, }, }), - ]); + ]).then(async result => { + await invalidateRedis(); + return result; + }); } From 71ee000f21375ec615b468c139b431ca24433dd5 Mon Sep 17 00:00:00 2001 From: Stanislaw <56738034+anvme@users.noreply.github.com> Date: Thu, 7 May 2026 04:14:38 +0200 Subject: [PATCH 2/2] fix: avoid restamping deletedAt + skip Redis DEL for already-soft-deleted slugs Address Greptile review feedback on #4243. - Cloud-mode link.updateMany / pixel.updateMany now filter where: { ..., deletedAt: null } so a previously soft-deleted row keeps its original deletion timestamp instead of being restamped with the current time. - Pre-transaction findMany now selects deletedAt; the Redis invalidation list filters to only live slugs, avoiding harmless but wasted DEL calls for already-soft-deleted entries. Note: the share.deleteMany cleanup still uses the broad entityId list (not filtered by deletedAt) so that orphan share rows of already-soft-deleted links/pixels are still cleaned up. Filtering the prefetch itself, as Greptile's exact suggestion proposed, would skip those shares while link.deleteMany still hard-deletes the rows, leaving orphan share rows behind. Verified empirically with a 3-scenario reproduction. --- src/queries/prisma/team.ts | 26 ++++++++++++++++++++------ src/queries/prisma/user.ts | 26 ++++++++++++++++++++------ 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/queries/prisma/team.ts b/src/queries/prisma/team.ts index b24d65227..38215335b 100644 --- a/src/queries/prisma/team.ts +++ b/src/queries/prisma/team.ts @@ -146,13 +146,20 @@ export async function deleteTeam(teamId: string) { const cloudMode = !!process.env.CLOUD_MODE; const [links, pixels, boards] = await Promise.all([ - client.link.findMany({ where: { teamId }, select: { id: true, slug: true } }), - client.pixel.findMany({ where: { teamId }, select: { id: true, slug: true } }), + client.link.findMany({ + where: { teamId }, + select: { id: true, slug: true, deletedAt: true }, + }), + client.pixel.findMany({ + where: { teamId }, + select: { id: true, slug: true, deletedAt: true }, + }), client.board.findMany({ where: { teamId }, select: { id: true } }), ]); const entityIds = [...links.map(l => l.id), ...pixels.map(p => p.id), ...boards.map(b => b.id)]; - const linkSlugs = links.map(l => l.slug); - const pixelSlugs = pixels.map(p => p.slug); + // Only invalidate Redis cache for slugs that are still live (not already soft-deleted). + const linkSlugs = links.filter(l => !l.deletedAt).map(l => l.slug); + const pixelSlugs = pixels.filter(p => !p.deletedAt).map(p => p.slug); const invalidateRedis = async () => { if (redis.enabled && (linkSlugs.length || pixelSlugs.length)) { @@ -174,8 +181,15 @@ export async function deleteTeam(teamId: string) { }, }), client.share.deleteMany({ where: { entityId: { in: entityIds } } }), - client.link.updateMany({ data: { deletedAt: new Date() }, where: { teamId } }), - client.pixel.updateMany({ data: { deletedAt: new Date() }, where: { teamId } }), + // deletedAt: null avoids restamping rows that were already soft-deleted earlier. + client.link.updateMany({ + data: { deletedAt: new Date() }, + where: { teamId, deletedAt: null }, + }), + client.pixel.updateMany({ + data: { deletedAt: new Date() }, + where: { teamId, deletedAt: null }, + }), client.board.deleteMany({ where: { teamId } }), ]).then(async result => { await invalidateRedis(); diff --git a/src/queries/prisma/user.ts b/src/queries/prisma/user.ts index c527e6026..21870036c 100644 --- a/src/queries/prisma/user.ts +++ b/src/queries/prisma/user.ts @@ -135,13 +135,20 @@ export async function deleteUser(userId: string) { : { OR: [{ userId }, { teamId: { in: teamIds } }] }; const [links, pixels, boards] = await Promise.all([ - client.link.findMany({ where: ownedFilter, select: { id: true, slug: true } }), - client.pixel.findMany({ where: ownedFilter, select: { id: true, slug: true } }), + client.link.findMany({ + where: ownedFilter, + select: { id: true, slug: true, deletedAt: true }, + }), + client.pixel.findMany({ + where: ownedFilter, + select: { id: true, slug: true, deletedAt: true }, + }), client.board.findMany({ where: ownedFilter, select: { id: true } }), ]); const entityIds = [...links.map(l => l.id), ...pixels.map(p => p.id), ...boards.map(b => b.id)]; - const linkSlugs = links.map(l => l.slug); - const pixelSlugs = pixels.map(p => p.slug); + // Only invalidate Redis cache for slugs that are still live (not already soft-deleted). + const linkSlugs = links.filter(l => !l.deletedAt).map(l => l.slug); + const pixelSlugs = pixels.filter(p => !p.deletedAt).map(p => p.slug); const invalidateRedis = async () => { if (redis.enabled && (linkSlugs.length || pixelSlugs.length)) { @@ -170,8 +177,15 @@ export async function deleteUser(userId: string) { }, }), client.share.deleteMany({ where: { entityId: { in: entityIds } } }), - client.link.updateMany({ data: { deletedAt: new Date() }, where: { userId } }), - client.pixel.updateMany({ data: { deletedAt: new Date() }, where: { userId } }), + // deletedAt: null avoids restamping rows that were already soft-deleted earlier. + client.link.updateMany({ + data: { deletedAt: new Date() }, + where: { userId, deletedAt: null }, + }), + client.pixel.updateMany({ + data: { deletedAt: new Date() }, + where: { userId, deletedAt: null }, + }), client.board.deleteMany({ where: { userId } }), ]).then(async result => { await invalidateRedis();