hotfix: clean up published docs with deleted collections (#5624)

This commit is contained in:
Mir Arif Hasan 2025-12-02 14:07:08 +06:00 committed by GitHub
parent 217563e7dd
commit 88c7e189cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 279 additions and 15 deletions

View file

@ -34,6 +34,7 @@ export class PublishedDocsResolver {
@ResolveField(() => User, {
description: 'Returns the creator of the published document',
nullable: true,
})
async creator(@Parent() publishedDocs: PublishedDocs): Promise<User> {
const creator = await this.publishedDocsService.getPublishedDocsCreator(
@ -41,11 +42,7 @@ export class PublishedDocsResolver {
);
if (E.isLeft(creator)) throwErr(creator.left);
return {
...creator.right,
currentGQLSession: JSON.stringify(creator.right.currentGQLSession),
currentRESTSession: JSON.stringify(creator.right.currentRESTSession),
};
return creator.right;
}
@ResolveField(() => PublishedDocsCollection, {

View file

@ -47,8 +47,8 @@ const user: User = {
lastLoggedOn: currentTime,
lastActiveOn: currentTime,
createdOn: currentTime,
currentGQLSession: JSON.stringify({}),
currentRESTSession: JSON.stringify({}),
currentGQLSession: {} as any,
currentRESTSession: {} as any,
};
const userPublishedDoc: DBPublishedDocs = {
@ -179,6 +179,9 @@ describe('getPublishedDocByID', () => {
describe('getAllUserPublishedDocs', () => {
test('should return a list of user published documents with pagination', async () => {
mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([userPublishedDoc]);
mockPrisma.userCollection.findMany.mockResolvedValueOnce([
{ id: 'collection_1' },
] as any);
const result = await publishedDocsService.getAllUserPublishedDocs(
user.uid,
@ -190,6 +193,7 @@ describe('getAllUserPublishedDocs', () => {
test('should return an empty array when no documents found', async () => {
mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([]);
mockPrisma.userCollection.findMany.mockResolvedValueOnce([]);
const result = await publishedDocsService.getAllUserPublishedDocs(
user.uid,
@ -201,6 +205,9 @@ describe('getAllUserPublishedDocs', () => {
test('should return paginated results correctly', async () => {
const docs = [userPublishedDoc, { ...userPublishedDoc, id: 'pub_doc_3' }];
mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([docs[0]]);
mockPrisma.userCollection.findMany.mockResolvedValueOnce([
{ id: 'collection_1' },
] as any);
const result = await publishedDocsService.getAllUserPublishedDocs(
user.uid,
@ -208,11 +215,94 @@ describe('getAllUserPublishedDocs', () => {
);
expect(result).toHaveLength(1);
});
test('should filter out published docs with non-existent collections', async () => {
const doc1 = {
...userPublishedDoc,
id: 'pub_doc_1',
collectionID: 'collection_1',
};
const doc2 = {
...userPublishedDoc,
id: 'pub_doc_2',
collectionID: 'collection_2',
};
const doc3 = {
...userPublishedDoc,
id: 'pub_doc_3',
collectionID: 'collection_3',
};
mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([doc1, doc2, doc3]);
// Only collection_1 and collection_3 exist
mockPrisma.userCollection.findMany.mockResolvedValueOnce([
{ id: 'collection_1' },
{ id: 'collection_3' },
] as any);
const result = await publishedDocsService.getAllUserPublishedDocs(
user.uid,
{ skip: 0, take: 10 },
);
// Should only return docs with existing collections
expect(result).toHaveLength(2);
expect(result.map((d) => d.id)).toEqual(['pub_doc_1', 'pub_doc_3']);
});
test('should delete published docs with non-existent collections', async () => {
const doc1 = {
...userPublishedDoc,
id: 'pub_doc_1',
collectionID: 'collection_1',
};
const doc2 = {
...userPublishedDoc,
id: 'pub_doc_2',
collectionID: 'collection_deleted',
};
mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([doc1, doc2]);
mockPrisma.userCollection.findMany.mockResolvedValueOnce([
{ id: 'collection_1' },
] as any);
mockPrisma.publishedDocs.deleteMany.mockResolvedValueOnce({
count: 1,
} as any);
await publishedDocsService.getAllUserPublishedDocs(user.uid, {
skip: 0,
take: 10,
});
expect(mockPrisma.publishedDocs.deleteMany).toHaveBeenCalledWith({
where: {
id: { in: ['pub_doc_2'] },
},
});
});
test('should not call deleteMany when all collections exist', async () => {
mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([userPublishedDoc]);
mockPrisma.userCollection.findMany.mockResolvedValueOnce([
{ id: 'collection_1' },
] as any);
await publishedDocsService.getAllUserPublishedDocs(user.uid, {
skip: 0,
take: 10,
});
expect(mockPrisma.publishedDocs.deleteMany).not.toHaveBeenCalled();
});
});
describe('getAllTeamPublishedDocs', () => {
test('should return a list of team published documents with pagination', async () => {
mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([teamPublishedDoc]);
mockPrisma.teamCollection.findMany.mockResolvedValueOnce([
{ id: 'team_collection_1' },
] as any);
const result = await publishedDocsService.getAllTeamPublishedDocs(
'team_1',
@ -225,6 +315,7 @@ describe('getAllTeamPublishedDocs', () => {
test('should return an empty array when no team documents found', async () => {
mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([]);
mockPrisma.teamCollection.findMany.mockResolvedValueOnce([]);
const result = await publishedDocsService.getAllTeamPublishedDocs(
'team_1',
@ -236,6 +327,9 @@ describe('getAllTeamPublishedDocs', () => {
test('should filter by teamID and collectionID correctly', async () => {
mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([teamPublishedDoc]);
mockPrisma.teamCollection.findMany.mockResolvedValueOnce([
{ id: 'team_collection_1' },
] as any);
await publishedDocsService.getAllTeamPublishedDocs(
'team_1',
@ -256,6 +350,88 @@ describe('getAllTeamPublishedDocs', () => {
},
});
});
test('should filter out published docs with non-existent team collections', async () => {
const doc1 = {
...teamPublishedDoc,
id: 'pub_doc_1',
collectionID: 'team_collection_1',
};
const doc2 = {
...teamPublishedDoc,
id: 'pub_doc_2',
collectionID: 'team_collection_2',
};
const doc3 = {
...teamPublishedDoc,
id: 'pub_doc_3',
collectionID: 'team_collection_3',
};
mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([doc1, doc2, doc3]);
// Only team_collection_1 and team_collection_3 exist
mockPrisma.teamCollection.findMany.mockResolvedValueOnce([
{ id: 'team_collection_1' },
{ id: 'team_collection_3' },
] as any);
const result = await publishedDocsService.getAllTeamPublishedDocs(
'team_1',
undefined,
{ skip: 0, take: 10 },
);
// Should only return docs with existing collections
expect(result).toHaveLength(2);
expect(result.map((d) => d.id)).toEqual(['pub_doc_1', 'pub_doc_3']);
});
test('should delete published docs with non-existent team collections', async () => {
const doc1 = {
...teamPublishedDoc,
id: 'pub_doc_1',
collectionID: 'team_collection_1',
};
const doc2 = {
...teamPublishedDoc,
id: 'pub_doc_2',
collectionID: 'team_collection_deleted',
};
mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([doc1, doc2]);
mockPrisma.teamCollection.findMany.mockResolvedValueOnce([
{ id: 'team_collection_1' },
] as any);
mockPrisma.publishedDocs.deleteMany.mockResolvedValueOnce({
count: 1,
} as any);
await publishedDocsService.getAllTeamPublishedDocs('team_1', undefined, {
skip: 0,
take: 10,
});
expect(mockPrisma.publishedDocs.deleteMany).toHaveBeenCalledWith({
where: {
id: { in: ['pub_doc_2'] },
},
});
});
test('should not call deleteMany when all team collections exist', async () => {
mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([teamPublishedDoc]);
mockPrisma.teamCollection.findMany.mockResolvedValueOnce([
{ id: 'team_collection_1' },
] as any);
await publishedDocsService.getAllTeamPublishedDocs(
'team_1',
'team_collection_1',
{ skip: 0, take: 10 },
);
expect(mockPrisma.publishedDocs.deleteMany).not.toHaveBeenCalled();
});
});
describe('createPublishedDoc', () => {
@ -650,7 +826,14 @@ describe('getPublishedDocsCreator', () => {
const result = await publishedDocsService.getPublishedDocsCreator(
userPublishedDoc.id,
);
expect(result).toEqualRight(user);
const expectedUser = {
...user,
currentGQLSession: JSON.stringify(user.currentGQLSession),
currentRESTSession: JSON.stringify(user.currentRESTSession),
};
expect(result).toEqualRight(expectedUser);
});
test('should throw PUBLISHED_DOCS_NOT_FOUND when document ID is invalid', async () => {

View file

@ -14,8 +14,9 @@ import {
PUBLISHED_DOCS_INVALID_COLLECTION,
PUBLISHED_DOCS_NOT_FOUND,
PUBLISHED_DOCS_UPDATE_FAILED,
TEAM_INVALID_COLL_ID,
TEAM_INVALID_ID,
USERS_NOT_FOUND,
USER_COLL_NOT_FOUND,
} from 'src/errors';
import * as E from 'fp-ts/Either';
import { PublishedDocs } from './published-docs.model';
@ -155,9 +156,16 @@ export class PublishedDocsService {
const user = await this.prisma.user.findUnique({
where: { uid: publishedDocs.creatorUid },
});
if (!user) return E.left(USERS_NOT_FOUND);
return E.right(user);
const creator = user
? {
...user,
currentGQLSession: JSON.stringify(user.currentGQLSession),
currentRESTSession: JSON.stringify(user.currentRESTSession),
}
: null;
return E.right(creator);
}
/**
@ -235,7 +243,20 @@ export class PublishedDocsService {
query.tree === TreeLevel.FULL,
);
if (E.isLeft(collectionResult)) return E.left(collectionResult.left);
if (E.isLeft(collectionResult)) {
// Delete the published doc if its collection is missing
const isCollectionNotFound =
collectionResult.left === USER_COLL_NOT_FOUND ||
collectionResult.left === TEAM_INVALID_COLL_ID;
if (isCollectionNotFound) {
await this.prisma.publishedDocs.delete({
where: { id: publishedDocs.id },
});
}
return E.left(collectionResult.left);
}
return E.right(
this.cast({
@ -248,6 +269,26 @@ export class PublishedDocsService {
return E.right(this.cast(publishedDocs));
}
/**
* Cleanup orphaned published documents whose collections no longer exist
*/
private async cleanupOrphanedPublishedDocs<
T extends { id: string; collectionID: string },
>(docs: T[], existingCollectionIDs: Set<string>): Promise<T[]> {
const docsToDelete = docs.filter(
(doc) => !existingCollectionIDs.has(doc.collectionID),
);
if (docsToDelete.length > 0) {
const idsToDelete = docsToDelete.map((doc) => doc.id);
this.prisma.publishedDocs.deleteMany({
where: { id: { in: idsToDelete } },
});
}
return docs.filter((doc) => existingCollectionIDs.has(doc.collectionID));
}
/**
* Get all published documents for a user with pagination
* @param userUid - The UID of the user
@ -266,7 +307,29 @@ export class PublishedDocsService {
},
});
return docs.map((doc) => this.cast(doc));
if (docs.length === 0) return [];
// Cross-check if all collections exist
const collectionIDs = docs.map((doc) => doc.collectionID);
const existingCollections = await this.prisma.userCollection.findMany({
where: {
id: { in: collectionIDs },
userUid,
},
select: { id: true },
});
const existingCollectionIDs = new Set(
existingCollections.map((col) => col.id),
);
const validDocs = await this.cleanupOrphanedPublishedDocs<DbPublishedDocs>(
docs,
existingCollectionIDs,
);
// Return only docs with existing collections
return validDocs.map((doc) => this.cast(doc));
}
/**
@ -290,7 +353,29 @@ export class PublishedDocsService {
},
});
return docs.map((doc) => this.cast(doc));
if (docs.length === 0) return [];
// Cross-check if all collections exist
const collectionIDs = docs.map((doc) => doc.collectionID);
const existingCollections = await this.prisma.teamCollection.findMany({
where: {
id: { in: collectionIDs },
teamID,
},
select: { id: true },
});
const existingCollectionIDs = new Set(
existingCollections.map((col) => col.id),
);
const validDocs = await this.cleanupOrphanedPublishedDocs<DbPublishedDocs>(
docs,
existingCollectionIDs,
);
// Return only docs with existing collections
return validDocs.map((doc) => this.cast(doc));
}
/**

View file

@ -40,7 +40,6 @@ import {
import { CollectionFolder } from 'src/types/CollectionFolder';
import { PrismaError } from 'src/prisma/prisma-error-codes';
import { SortOptions } from 'src/types/SortOptions';
import { UserRequest } from 'src/user-request/user-request.model';
@Injectable()
export class UserCollectionService {