Merge branch 'feat/inda-383-daily-stat' into 'main'
[ProtonMail-WebClient.git] / packages / docs-core / lib / UseCase / LoadThreads.ts
blobe7e913fc09d73f76e29f6f017bdcdb343ddde127
1 import { CommentThread } from '../Models'
2 import { CommentThreadType, CommentType, ServerTime } from '@proton/docs-shared'
3 import { Result } from '@proton/docs-shared'
4 import metrics from '@proton/metrics'
5 import type { DecryptComment } from './DecryptComment'
6 import type { DocsApi } from '../Api/DocsApi'
7 import type { DocumentEntitlements, PublicDocumentEntitlements } from '../Types/DocumentEntitlements'
8 import type { LocalCommentsState } from '../Services/Comments/LocalCommentsState'
9 import type { LoggerInterface } from '@proton/utils/logs'
10 import type { NodeMeta } from '@proton/drive-store'
11 import type { UseCaseInterface } from '../Domain/UseCase/UseCaseInterface'
12 import { isPrivateNodeMeta } from '@proton/drive-store/lib/interface'
14 /**
15  * Updates the local comment state by loading and decrypting all threads from the API for the document.
16  */
17 export class LoadThreads implements UseCaseInterface<void> {
18   constructor(
19     private api: DocsApi,
20     private decryptComment: DecryptComment,
21     private logger: LoggerInterface,
22   ) {}
24   async execute(dto: {
25     entitlements: PublicDocumentEntitlements | DocumentEntitlements
26     commentsState: LocalCommentsState
27   }): Promise<Result<void>> {
28     const result = await this.api.getAllThreadIDs(dto.entitlements.nodeMeta)
29     if (result.isFailed()) {
30       metrics.docs_comments_download_error_total.increment({
31         reason: 'server_error',
32       })
34       return Result.fail(result.getError().message)
35     }
37     const response = result.getValue()
39     await Promise.all(
40       response.CommentThreads.map(async (threadID) => {
41         return this.loadThread({ threadID, entitlements: dto.entitlements, commentsState: dto.commentsState })
42       }),
43     )
45     dto.commentsState.sortThreadsAndNotify()
47     return Result.ok()
48   }
50   private async loadThread(dto: {
51     threadID: string
52     entitlements: PublicDocumentEntitlements | DocumentEntitlements
53     commentsState: LocalCommentsState
54   }): Promise<Result<void>> {
55     const thread = await this.api.getThread({
56       nodeMeta: dto.entitlements.nodeMeta,
57       threadId: dto.threadID,
58     })
59     if (thread.isFailed()) {
60       metrics.docs_comments_download_error_total.increment({
61         reason: 'server_error',
62       })
64       return Result.fail(thread.getError().message)
65     }
67     const { CommentThread: commentThreadDto } = thread.getValue()
68     const corruptThreadIds: Set<string> = new Set()
70     const comments = await Promise.all(
71       commentThreadDto.Comments.map(async (commentDto) => {
72         const result = await this.decryptComment.execute(commentDto, commentThreadDto.Mark, dto.entitlements.keys)
73         if (!result.isFailed()) {
74           return result.getValue()
75         }
77         /**
78          * If the comment or suggestion refers to a date before the encryption incident was resolved,
79          * and given that decryption has failed, we delete this comment thread from the API.
80          * See DRVDOC-1194 for more information.
81          */
82         if (commentDto.Type === CommentType.Suggestion || commentThreadDto.Type === CommentThreadType.Suggestion) {
83           this.logger.error(`[LoadThreads] Failed to decrypt suggestion comment: ${result.getError()}`)
84           const dateEncryptionIncidentWasResolved = new Date('2024-10-03T12:00:00+02:00')
85           const dateCommentWasCreated = new ServerTime(commentDto.CreateTime).date
86           const commentWasCreatedBeforeEncryptionResolution = dateCommentWasCreated < dateEncryptionIncidentWasResolved
87           if (commentWasCreatedBeforeEncryptionResolution) {
88             corruptThreadIds.add(dto.threadID)
89           }
90         } else {
91           this.logger.error(`[LoadThreads] Failed to decrypt comment: ${result.getError()}`)
92         }
94         return undefined
95       }),
96     )
98     if (corruptThreadIds.size > 0 && isPrivateNodeMeta(dto.entitlements.nodeMeta)) {
99       void this.deleteCorruptSuggestionThreads(Array.from(corruptThreadIds), dto.entitlements.nodeMeta)
101       if (corruptThreadIds.has(dto.threadID)) {
102         return Result.ok()
103       }
104     }
106     const successfulComments = comments.filter((result) => !!result)
108     const localThread = new CommentThread(
109       commentThreadDto.CommentThreadID,
110       new ServerTime(commentThreadDto.CreateTime),
111       new ServerTime(commentThreadDto.ModifyTime),
112       commentThreadDto.Mark,
113       successfulComments,
114       false,
115       commentThreadDto.State,
116       commentThreadDto.Type,
117       commentThreadDto.CommentThreadID,
118     )
120     dto.commentsState.addThread(localThread)
122     return Result.ok()
123   }
125   private async deleteCorruptSuggestionThreads(threadIds: string[], nodeMeta: NodeMeta): Promise<void> {
126     for (const threadId of threadIds) {
127       this.logger.error(`[LoadThreads] Deleting corrupt suggestion thread: ${threadId}`)
129       /** First reject the thread with the API, so that the API will allow deletion, since it otherwise won't */
130       const rejectResponse = await this.api.changeSuggestionThreadState({
131         nodeMeta: {
132           volumeId: nodeMeta.volumeId,
133           linkId: nodeMeta.linkId,
134         },
135         threadId,
136         action: 'reject',
137       })
139       if (rejectResponse.isFailed()) {
140         this.logger.info(`[LoadThreads] Failed to reject corrupt thread: ${rejectResponse.getError()}`)
141         return
142       }
144       void this.api.deleteThread({
145         nodeMeta: {
146           volumeId: nodeMeta.volumeId,
147           linkId: nodeMeta.linkId,
148         },
149         threadId,
150       })
151     }
152   }