Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "ScrapMedias" ADD COLUMN "commentId" UUID;

-- AddForeignKey
ALTER TABLE "ScrapMedias" ADD CONSTRAINT "ScrapMedias_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "Comments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
4 changes: 4 additions & 0 deletions prisma/schema/comment.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ model Comment {
updatedAt DateTime @updatedAt @db.Timestamptz()
deletedAt DateTime? @db.Timestamptz()


scrapMedias ScrapMedia[]


@@map("Comments")
@@index([taskId, workspaceId, createdAt(sort: Desc)], name: "IX_Comments_taskId_workspaceId_createdAt")
}
4 changes: 4 additions & 0 deletions prisma/schema/scrapMedia.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ model ScrapMedia {
deletedAt DateTime? @db.Timestamptz()
templateId String? @db.Uuid
template TaskTemplate? @relation(fields: [templateId], references: [id], onDelete: Cascade)
commentId String? @db.Uuid
comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)



@@index([createdAt])
@@index([filePath])
Expand Down
22 changes: 11 additions & 11 deletions src/app/api/comments/comment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,17 +388,17 @@ export class CommentService extends BaseService {
for (const { originalSrc, newUrl } of replacements) {
htmlString = htmlString.replace(originalSrc, newUrl)
}
// const filePaths = newFilePaths.map(({ newFilePath }) => newFilePath)
// await this.db.scrapMedia.updateMany({
// where: {
// filePath: {
// in: filePaths,
// },
// },
// data: {
// taskId: task_id,
// },
// }) //todo: add support for commentId in scrapMedias.
const filePaths = newFilePaths.map(({ newFilePath }) => newFilePath)
await this.db.scrapMedia.updateMany({
where: {
filePath: {
in: filePaths,
},
},
data: {
commentId: commentId,
},
})
return htmlString
} //todo: make this resuable since this is highly similar to what we are doing on tasks.

Expand Down
50 changes: 32 additions & 18 deletions src/app/api/workers/scrap-medias/scrap-medias.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ export class ScrapMediaService {
.map((image) => image.templateId)
.filter((templateId): templateId is string => templateId !== null)

const commentIds = scrapMedias
.map((medias) => medias.commentId)
.filter((commentId): commentId is string => commentId !== null)

const tasks = taskIds.length
? await db.task.findMany({
where: {
Expand All @@ -46,41 +50,51 @@ export class ScrapMediaService {
})
: []

const comments =
commentIds.length > 0
? await db.comment.findMany({
where: {
id: { in: commentIds },
},
})
: []

const scrapMediasToDelete = []
const scrapMediasToDeleteFromBucket = []

for (const image of scrapMedias) {
for (const media of scrapMedias) {
try {
// For each scrap image, check if the task or taskTemplate still has the img url in its body
const task = tasks.find((_task) => _task.id === image.taskId)
const taskTemplate = taskTemplates.find((_template) => _template.id === image.templateId)
const task = tasks.find((_task) => _task.id === media.taskId)
const taskTemplate = taskTemplates.find((_template) => _template.id === media.templateId)
const comment = comments.find((_comment) => _comment.id === media.commentId)

const isInTaskBody = task && (task.body || '').includes(image.filePath)
const isInTemplateBody = taskTemplate && (taskTemplate.body || '').includes(image.filePath)
const isInTaskBody = task && (task.body || '').includes(media.filePath)
const isInTemplateBody = taskTemplate && (taskTemplate.body || '').includes(media.filePath)
const isInCommentBody = comment && (comment.content || '').includes(media.filePath)

if (!task && !taskTemplate) {
console.error('Could not find task for scrap image', image)
scrapMediasToDelete.push(image.id)
scrapMediasToDeleteFromBucket.push(image.filePath)
if (!task && !taskTemplate && !comment) {
console.error('Could not find location of scrap media', media)
scrapMediasToDelete.push(media.id)
scrapMediasToDeleteFromBucket.push(media.filePath)
continue
}
// If image is in task body
if (isInTaskBody || isInTemplateBody) {
scrapMediasToDelete.push(image.id)
// If media is valid
if (isInTaskBody || isInTemplateBody || isInCommentBody) {
scrapMediasToDelete.push(media.id)
continue
}
// If image is not in task body

scrapMediasToDeleteFromBucket.push(image.filePath)
scrapMediasToDelete.push(image.id)
// If media is not valid
scrapMediasToDeleteFromBucket.push(media.filePath)
scrapMediasToDelete.push(media.id)
} catch (e: unknown) {
console.error('Error processing scrap image', e)
console.error('Error processing scrap media', e)
}
}

if (!!scrapMediasToDeleteFromBucket.length)
await db.attachment.deleteMany({ where: { filePath: { in: scrapMediasToDeleteFromBucket } } })

console.info('ScrapMediaWorker#deleteFromBucket | Deleting these medias', scrapMediasToDeleteFromBucket)
// remove attachments from bucket
await supabase.removeAttachmentsFromBucket(scrapMediasToDeleteFromBucket)

Expand Down
4 changes: 2 additions & 2 deletions src/app/detail/ui/NewTaskCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { selectCreateTemplate } from '@/redux/features/templateSlice'
import { DateString } from '@/types/date'
import { CreateTaskRequest, Viewers } from '@/types/dto/tasks.dto'
import { WorkflowStateResponse } from '@/types/dto/workflowStates.dto'
import { FilterByOptions, IAssigneeCombined, InputValue, ITemplate, UserIds } from '@/types/interfaces'
import { AttachmentTypes, FilterByOptions, IAssigneeCombined, InputValue, ITemplate, UserIds } from '@/types/interfaces'
import { getAssigneeName, UserIdsType } from '@/utils/assignee'
import { deleteEditorAttachmentsHandler, uploadAttachmentHandler } from '@/utils/attachmentUtils'
import { createUploadFn } from '@/utils/createUploadFn'
Expand Down Expand Up @@ -340,7 +340,7 @@ export const NewTaskCard = ({
placeholder="Add description.."
editorClass="tapwrite-task-editor"
uploadFn={uploadFn}
deleteEditorAttachments={(url) => deleteEditorAttachmentsHandler(url, token ?? '', null, null)}
deleteEditorAttachments={(url) => deleteEditorAttachmentsHandler(url, token ?? '', AttachmentTypes.TASK)}
attachmentLayout={(props) => (
<AttachmentLayout {...props} isComment={true} onUploadStatusChange={handleUploadStatusChange} />
)}
Expand Down
4 changes: 2 additions & 2 deletions src/app/detail/ui/TaskEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import store from '@/redux/store'
import { CreateAttachmentRequest } from '@/types/dto/attachments.dto'
import { TaskResponse } from '@/types/dto/tasks.dto'
import { UserType } from '@/types/interfaces'
import { AttachmentTypes, UserType } from '@/types/interfaces'
import { getDeleteMessage } from '@/utils/dialogMessages'
import { deleteEditorAttachmentsHandler, getAttachmentPayload, uploadAttachmentHandler } from '@/utils/attachmentUtils'
import { Box } from '@mui/material'
Expand Down Expand Up @@ -83,7 +83,7 @@
setUpdateDetail(currentTask.body ?? '')
}
}
}, [activeTask?.title, activeTask?.body, task_id, activeUploads, task])

Check warning on line 86 in src/app/detail/ui/TaskEditor.tsx

View workflow job for this annotation

GitHub Actions / Run linters

React Hook useEffect has missing dependencies: 'activeTask' and 'isUserTyping'. Either include them or remove the dependency array

const _titleUpdateDebounced = async (title: string) => updateTaskTitle(title)

Expand Down Expand Up @@ -195,7 +195,7 @@
placeholder="Add description..."
uploadFn={uploadFn}
handleImageDoubleClick={handleImagePreview}
deleteEditorAttachments={(url) => deleteEditorAttachmentsHandler(url, token ?? '', task_id, null)}
deleteEditorAttachments={(url) => deleteEditorAttachmentsHandler(url, token ?? '', AttachmentTypes.TASK, task_id)}
attachmentLayout={(props) => <AttachmentLayout {...props} />}
addAttachmentButton
maxUploadLimit={MAX_UPLOAD_LIMIT}
Expand Down
2 changes: 1 addition & 1 deletion src/app/manage-templates/ui/NewTemplateCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ export const NewTemplateCard = ({
placeholder="Add description.."
editorClass="tapwrite-task-editor"
uploadFn={uploadFn}
deleteEditorAttachments={(url) => deleteEditorAttachmentsHandler(url, token ?? '', null, null)}
deleteEditorAttachments={(url) => deleteEditorAttachmentsHandler(url, token ?? '', AttachmentTypes.TEMPLATE)}
attachmentLayout={(props) => (
<AttachmentLayout {...props} isComment={true} onUploadStatusChange={handleUploadStatusChange} />
)}
Expand Down
4 changes: 3 additions & 1 deletion src/app/manage-templates/ui/TemplateDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
setUpdateDetail(currentTemplate.body ?? '')
}
}
}, [activeTemplate?.title, activeTemplate?.body, template_id, activeUploads, template])

Check warning on line 61 in src/app/manage-templates/ui/TemplateDetails.tsx

View workflow job for this annotation

GitHub Actions / Run linters

React Hook useEffect has missing dependencies: 'activeTemplate' and 'isUserTyping'. Either include them or remove the dependency array

const _titleUpdateDebounced = async (title: string) => updateTemplateTitle(title)
const [titleUpdateDebounced, cancelTitleUpdateDebounced] = useDebounceWithCancel(_titleUpdateDebounced, 1500)
Expand Down Expand Up @@ -164,7 +164,9 @@
placeholder="Add description..."
uploadFn={uploadFn}
handleImageDoubleClick={handleImagePreview}
deleteEditorAttachments={(url) => deleteEditorAttachmentsHandler(url, token ?? '', template_id, null)}
deleteEditorAttachments={(url) =>
deleteEditorAttachmentsHandler(url, token ?? '', AttachmentTypes.TEMPLATE, template_id)
}
attachmentLayout={(props) => <AttachmentLayout {...props} />}
addAttachmentButton
maxUploadLimit={MAX_UPLOAD_LIMIT}
Expand Down
9 changes: 1 addition & 8 deletions src/app/manage-templates/ui/TemplateForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,14 +160,7 @@ const NewTemplateFormInputs = () => {
placeholder="Add description.."
editorClass="tapwrite-description-h-full"
uploadFn={uploadFn}
deleteEditorAttachments={(url) =>
deleteEditorAttachmentsHandler(
url,
token ?? '',
null,
targetMethod == TargetMethod.POST ? null : targetTemplateId,
)
}
deleteEditorAttachments={(url) => deleteEditorAttachmentsHandler(url, token ?? '', AttachmentTypes.TEMPLATE)}
attachmentLayout={(props) => <AttachmentLayout {...props} />}
maxUploadLimit={MAX_UPLOAD_LIMIT}
parentContainerStyle={{ gap: '0px', minHeight: '60px' }}
Expand Down
3 changes: 2 additions & 1 deletion src/app/ui/NewTaskForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import store from '@/redux/store'
import { HomeParamActions } from '@/types/constants'
import { WorkflowStateResponse } from '@/types/dto/workflowStates.dto'
import {
AttachmentTypes,
CreateTaskErrors,
FilterByOptions,
FilterOptions,
Expand Down Expand Up @@ -619,7 +620,7 @@ const NewTaskFormInputs = ({ isEditorReadonly }: NewTaskFormInputsProps) => {
editorClass="tapwrite-description-h-full"
uploadFn={uploadFn}
readonly={isEditorReadonly}
deleteEditorAttachments={(url) => deleteEditorAttachmentsHandler(url, token ?? '', null, null)}
deleteEditorAttachments={(url) => deleteEditorAttachmentsHandler(url, token ?? '', AttachmentTypes.TASK)}
attachmentLayout={(props) => <AttachmentLayout {...props} />}
maxUploadLimit={MAX_UPLOAD_LIMIT}
parentContainerStyle={{ gap: '0px', minHeight: '60px' }}
Expand Down
148 changes: 148 additions & 0 deletions src/cmd/backfill-attachments/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import DBClient from '@/lib/db'
import { CreateAttachmentRequestSchema } from '@/types/dto/attachments.dto'
import { getFilePathFromUrl } from '@/utils/signedUrlReplacer'
import { SupabaseActions } from '@/utils/SupabaseActions'
import { Task, Comment } from '@prisma/client'

const ATTACHMENT_TAG_REGEX = /<\s*[a-zA-Z]+\s+[^>]*data-type="attachment"[^>]*src="([^"]+)"[^>]*>/g
const IMG_TAG_REGEX = /<img\s+[^>]*src="([^"]+)"[^>]*>/g

interface AttachmentRequest {
createdById: string
workspaceId: string
attachmentRequest: ReturnType<typeof CreateAttachmentRequestSchema.parse>
}

interface ProcessedAttachments {
taskAttachmentRequests: AttachmentRequest[]
commentAttachmentRequests: AttachmentRequest[]
filesNotFoundInBucket: string[]
}

async function extractAttachmentsFromContent(
content: string,
supabaseActions: SupabaseActions,
filesNotFound: string[],
): Promise<Array<{ filePath: string; fileSize?: number; fileType?: string; fileName?: string }>> {
const attachments: Array<{ filePath: string; fileSize?: number; fileType?: string; fileName?: string }> = []
const regexes = [IMG_TAG_REGEX, ATTACHMENT_TAG_REGEX]

for (const regex of regexes) {
let match
regex.lastIndex = 0
while ((match = regex.exec(content)) !== null) {
const originalSrc = match[1]
const filePath = getFilePathFromUrl(originalSrc)
if (!filePath) continue
const fileMetaData = await supabaseActions.getMetaData(filePath)
if (!fileMetaData) {
filesNotFound.push(filePath)
continue
}
const fileName = filePath.split('/').pop()
attachments.push({
filePath,
fileSize: fileMetaData.size,
fileType: fileMetaData.contentType,
fileName,
})
}
}
return attachments
}

async function createAttachmentRequests(tasks: Task[], comments: Comment[]): Promise<ProcessedAttachments> {
const taskAttachmentRequests: AttachmentRequest[] = []
const commentAttachmentRequests: AttachmentRequest[] = []
const filesNotFoundInBucket: string[] = []
const supabaseActions = new SupabaseActions()

for (const task of tasks) {
const bodyString = task.body ?? ''
const attachments = await extractAttachmentsFromContent(bodyString, supabaseActions, filesNotFoundInBucket)
for (const attachment of attachments) {
taskAttachmentRequests.push({
createdById: task.createdById,
workspaceId: task.workspaceId,
attachmentRequest: CreateAttachmentRequestSchema.parse({
taskId: task.id,
...attachment,
}),
})
}
}

for (const comment of comments) {
const contentString = comment.content ?? ''
const attachments = await extractAttachmentsFromContent(contentString, supabaseActions, filesNotFoundInBucket)
for (const attachment of attachments) {
commentAttachmentRequests.push({
createdById: comment.initiatorId,
workspaceId: comment.workspaceId,
attachmentRequest: CreateAttachmentRequestSchema.parse({
commentId: comment.id,
...attachment,
}),
})
}
}

if (taskAttachmentRequests.length) {
console.info('🔥 Task attachments to be populated:', taskAttachmentRequests.length)
}
if (commentAttachmentRequests.length) {
console.info('🔥 Comment attachments to be populated:', commentAttachmentRequests.length)
}
if (filesNotFoundInBucket.length) {
console.warn('⚠️ Files not found in bucket:', filesNotFoundInBucket)
}

return { taskAttachmentRequests, commentAttachmentRequests, filesNotFoundInBucket }
}

async function createAttachmentsInDatabase(
db: ReturnType<typeof DBClient.getInstance>,
attachmentRequests: AttachmentRequest[],
) {
let created = 0
let skipped = 0

for (const { createdById, workspaceId, attachmentRequest } of attachmentRequests) {
try {
const existing = await db.attachment.findFirst({
where: { filePath: attachmentRequest.filePath },
})
if (existing) {
skipped++
continue
}
await db.attachment.create({
data: {
...attachmentRequest,
createdById,
workspaceId,
},
})
created++
} catch (error) {
console.error('❌ Failed to create attachment:', attachmentRequest, error)
}
}

console.info(`📊 Created: ${created}, Skipped (already exists): ${skipped}`)
}

async function run() {
console.info('🧑🏻‍💻 Backfilling attachment entries for tasks and comments')

const db = DBClient.getInstance()
const [tasks, comments] = await Promise.all([db.task.findMany(), db.comment.findMany()])

const { taskAttachmentRequests, commentAttachmentRequests } = await createAttachmentRequests(tasks, comments)

await createAttachmentsInDatabase(db, [...taskAttachmentRequests, ...commentAttachmentRequests])

console.info('✅ Backfill complete')
}

run()
10 changes: 8 additions & 2 deletions src/components/cards/CommentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import store from '@/redux/store'
import { CommentResponse, CreateComment, UpdateComment } from '@/types/dto/comment.dto'
import { AttachmentTypes, IAssigneeCombined } from '@/types/interfaces'
import { getAssigneeName } from '@/utils/assignee'
import { deleteEditorAttachmentsHandler, getAttachmentPayload } from '@/utils/attachmentUtils'
import { deleteEditorAttachmentsHandler, getAttachmentPayload, getCustomFilePath } from '@/utils/attachmentUtils'
import { createUploadFn } from '@/utils/createUploadFn'
import { fetcher } from '@/utils/fetcher'
import { getTimeDifference } from '@/utils/getTimeDifference'
Expand Down Expand Up @@ -330,7 +330,13 @@ export const CommentCard = ({
editorClass={isReadOnly ? 'tapwrite-comment' : 'tapwrite-comment-editable'}
addAttachmentButton={!isReadOnly}
uploadFn={uploadFn}
deleteEditorAttachments={(url) => deleteEditorAttachmentsHandler(url, token ?? '', task_id, null)}
deleteEditorAttachments={(url) => {
const commentId = z.string().parse(commentIdRef.current)
const customFilePath = tokenPayload?.workspaceId
? getCustomFilePath(tokenPayload?.workspaceId, task_id, commentId, url)
: undefined
return deleteEditorAttachmentsHandler(url, token ?? '', AttachmentTypes.COMMENT, commentId, customFilePath)
}}
maxUploadLimit={MAX_UPLOAD_LIMIT}
attachmentLayout={(props) => <AttachmentLayout {...props} isComment={true} />}
hardbreak
Expand Down
Loading
Loading