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/<slug> and /p/<slug> 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.
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user