Skip to content
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 @@ -623,7 +624,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