From c4aaf127b364f6f85810dd5b400a9cf321311397 Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Mon, 15 Dec 2025 13:55:47 -0600 Subject: [PATCH 1/8] fix visual issue with project notifications --- src/notifications/tables/processing/processNotification.ts | 3 ++- .../tables/processing/processProjectNotification.ts | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/notifications/tables/processing/processNotification.ts b/src/notifications/tables/processing/processNotification.ts index 76bf59dd..715ccc85 100644 --- a/src/notifications/tables/processing/processNotification.ts +++ b/src/notifications/tables/processing/processNotification.ts @@ -26,7 +26,8 @@ export function processNotification( return notifications.map((notification) => { const cleanMessage = stripHtmlTags(notification.message || "") const type = determineNotificationType(cleanMessage) - const isMarkdown = type === "Project" + const containsHtml = /<\/?[a-z][\s\S]*>/i.test(notification.message || "") + const isMarkdown = type === "Project" && !containsHtml return { id: notification.id, diff --git a/src/notifications/tables/processing/processProjectNotification.ts b/src/notifications/tables/processing/processProjectNotification.ts index aad1c459..2f256024 100644 --- a/src/notifications/tables/processing/processProjectNotification.ts +++ b/src/notifications/tables/processing/processProjectNotification.ts @@ -19,7 +19,9 @@ export function processProjectNotification( ): ProjectNotificationData[] { return notifications.map((notification) => { const cleanMessage = stripHtmlTags(notification.message || "") - const isMarkdown = determineNotificationType(notification.message || "Other") === "Project" + const containsHtml = /<\/?[a-z][\s\S]*>/i.test(notification.message || "") + const type = determineNotificationType(notification.message || "Other") + const isMarkdown = type === "Project" && !containsHtml return { id: notification.id, @@ -28,7 +30,7 @@ export function processProjectNotification( rawMessage: notification.message || "", notification: notification, routeData: notification.routeData as RouteData, - type: determineNotificationType(notification.message || "Other"), + type, isMarkdown, } }) From baef398d4dff3b81be082589ee38e58adafd7e7b Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Mon, 15 Dec 2025 14:11:33 -0600 Subject: [PATCH 2/8] team update error bug --- src/teams/mutations/updateTeam.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/teams/mutations/updateTeam.ts b/src/teams/mutations/updateTeam.ts index 9e5c3a57..cfcbe0b4 100644 --- a/src/teams/mutations/updateTeam.ts +++ b/src/teams/mutations/updateTeam.ts @@ -67,7 +67,7 @@ export default resolver.pipe( data: { teamName: team.name || existing?.name || "Unnamed Team", projectName: project?.name || "Unnamed Project", - addedby: addedBy, + addedBy, }, projectId: existing.projectId, routeData: { From 5961a2aa30bd2d74dd12c353f146104ec5024a11 Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Mon, 15 Dec 2025 14:20:38 -0600 Subject: [PATCH 3/8] making labeling clear on team versus individual tasks --- src/projectmembers/components/ProjectMemberTaskList.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/projectmembers/components/ProjectMemberTaskList.tsx b/src/projectmembers/components/ProjectMemberTaskList.tsx index a61ee338..4704366d 100644 --- a/src/projectmembers/components/ProjectMemberTaskList.tsx +++ b/src/projectmembers/components/ProjectMemberTaskList.tsx @@ -48,16 +48,20 @@ const ProjectMemberTaskList = ({ return () => eventBus.off("taskLogUpdated", handleTaskLogUpdate) }, [refetchTaskLogs]) + const typedTaskLogs = taskLogs as TaskLogTaskCompleted[] const processedData = processTaskLogHistory( - taskLogs as TaskLogTaskCompleted[], + typedTaskLogs, comments, refetchComments, currentContributor, () => refetchTaskLogs() ) + const hasTeamTasks = typedTaskLogs.some((log) => Boolean(log.assignedTo?.name)) + const cardTitle = hasTeamTasks ? "Team Tasks" : "Contributor Tasks" + return ( - + ) From 1535f609a3f25f6ae5833d543f44d4f91bba7ec0 Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Mon, 15 Dec 2025 14:56:29 -0600 Subject: [PATCH 4/8] fixing disconnect / deleting members and their notification hiccups --- db/schema.prisma | 1 + .../mutations/deleteContributor.ts | 89 +++++++++++++- src/invites/mutations/acceptInvite.ts | 76 ++++++++++-- .../mutations/deleteProjectMember.ts | 116 ------------------ 4 files changed, 158 insertions(+), 124 deletions(-) delete mode 100644 src/projectmembers/mutations/deleteProjectMember.ts diff --git a/db/schema.prisma b/db/schema.prisma index a9133375..1504b875 100644 --- a/db/schema.prisma +++ b/db/schema.prisma @@ -96,6 +96,7 @@ model ProjectMember { tags Json? commentReadStatus CommentReadStatus[] notes Note[] + formerTeamIds Json? } model ProjectPrivilege { diff --git a/src/contributors/mutations/deleteContributor.ts b/src/contributors/mutations/deleteContributor.ts index b296a01a..6b289bc7 100644 --- a/src/contributors/mutations/deleteContributor.ts +++ b/src/contributors/mutations/deleteContributor.ts @@ -26,6 +26,22 @@ export default resolver.pipe( // Get the userId from the associated users array const userId = contributorToDelete.users[0]!.id + // Reconstruct possible display names used in notification messages + const user = contributorToDelete.users[0] + const possibleDisplayNames: string[] = [] + + if (user!.firstName && user!.lastName) { + possibleDisplayNames.push(`${user!.firstName} ${user!.lastName}`) + } + + if (user!.username) { + possibleDisplayNames.push(user!.username) + } + + const notificationMarkerText = " (former contributor)" + const notificationMarkerHtml = + ' (former contributor)' + // Check if the project member has any privileges related to the project const projectPrivilege = await db.projectPrivilege.findFirst({ where: { @@ -75,6 +91,8 @@ export default resolver.pipe( }, }) + const formerTeamIds = teamProjectMembers.map((teamMember) => teamMember.id) + // Disconnect the user from each team project member individually for (const teamMember of teamProjectMembers) { await db.projectMember.update({ @@ -87,6 +105,72 @@ export default resolver.pipe( }) } + // Annotate existing notifications that reference this contributor by name + if (possibleDisplayNames.length > 0) { + console.log( + `[deleteContributor] Annotating notifications for user ${userId} with names:`, + possibleDisplayNames + ) + + const notifications = await db.notification.findMany({ + where: { + projectId: contributorToDelete.projectId, + announcement: false, + AND: [ + { + NOT: { + message: { + endsWith: notificationMarkerText, + }, + }, + }, + { + NOT: { + message: { + endsWith: notificationMarkerHtml, + }, + }, + }, + { + OR: possibleDisplayNames.map((name) => ({ + message: { + contains: name, + mode: "insensitive", + }, + })), + }, + ], + }, + select: { + id: true, + message: true, + }, + }) + + console.log( + `[deleteContributor] Found ${notifications.length} notifications requiring markers` + ) + + await Promise.all( + notifications.map((n) => { + const trimmed = n.message!.trim() + const containsHtml = /<\/?[a-z][\s\S]*>/i.test(trimmed) + const marker = containsHtml ? notificationMarkerHtml : notificationMarkerText + + return db.notification.update({ + where: { id: n.id }, + data: { + message: `${trimmed}${marker}`, + }, + }) + }) + ) + } else { + console.log( + `[deleteContributor] No display names detected for user ${userId}, skipping notification annotations` + ) + } + // Disconnect the notifications related to the project const notificationsToUpdate = await db.notification.findMany({ where: { @@ -118,7 +202,10 @@ export default resolver.pipe( // Mark the project member as deleted const projectMember = await db.projectMember.update({ where: { id: contributorToDelete.id }, - data: { deleted: true }, + data: { + deleted: true, + formerTeamIds: formerTeamIds.length > 0 ? formerTeamIds : null, + }, }) return projectMember diff --git a/src/invites/mutations/acceptInvite.ts b/src/invites/mutations/acceptInvite.ts index 252f3bb7..14fcb9fd 100644 --- a/src/invites/mutations/acceptInvite.ts +++ b/src/invites/mutations/acceptInvite.ts @@ -5,6 +5,17 @@ import sendNotification from "src/notifications/mutations/sendNotification" import { getPrivilegeText } from "src/core/utils/getPrivilegeText" import { Routes } from "@blitzjs/next" +const parseFormerTeamIds = (value: unknown): number[] => { + if (!Array.isArray(value)) return [] + return value + .map((id) => { + if (typeof id === "number") return id + const parsed = Number(id) + return Number.isFinite(parsed) ? parsed : null + }) + .filter((id): id is number => id !== null) +} + export default resolver.pipe( resolver.zod(AcceptInviteSchema), resolver.authorize(), @@ -17,27 +28,78 @@ export default resolver.pipe( if (!invite) throw new Error("Invitation not found") let projectMember + let formerTeamIds: number[] = [] + + const reconnectFormerTeams = async (teamIds: number[]) => { + if (teamIds.length === 0) return + + for (const teamId of teamIds) { + try { + await db.projectMember.update({ + where: { id: teamId }, + data: { + users: { + connect: { id: userId }, + }, + }, + }) + } catch (error) { + console.error( + `[acceptInvite] Failed to reconnect user ${userId} to team ${teamId}:`, + error + ) + } + } + } // Check if this is a reassignment invitation if (invite.reassignmentFor) { + const reassignmentTarget = await db.projectMember.findUnique({ + where: { id: invite.reassignmentFor }, + }) + if (!reassignmentTarget) { + throw new Error("Reassignment target not found") + } + formerTeamIds = parseFormerTeamIds(reassignmentTarget.formerTeamIds) + // Restore the soft-deleted ProjectMember projectMember = await db.projectMember.update({ where: { id: invite.reassignmentFor }, - data: { deleted: false }, + data: { deleted: false, tags: invite.tags as any, formerTeamIds: null }, }) } else { - // Create a new ProjectMember for fresh invitations - projectMember = await db.projectMember.create({ - data: { + // Check whether this user already has a soft-deleted ProjectMember for this project + const existingProjectMember = await db.projectMember.findFirst({ + where: { + projectId: invite.projectId, users: { - connect: { id: userId }, + some: { id: userId }, }, - projectId: invite.projectId, - tags: invite.tags as any, }, }) + + if (existingProjectMember) { + formerTeamIds = parseFormerTeamIds(existingProjectMember.formerTeamIds) + projectMember = await db.projectMember.update({ + where: { id: existingProjectMember.id }, + data: { deleted: false, tags: invite.tags as any, formerTeamIds: null }, + }) + } else { + // Create a new ProjectMember for fresh invitations + projectMember = await db.projectMember.create({ + data: { + users: { + connect: { id: userId }, + }, + projectId: invite.projectId, + tags: invite.tags as any, + }, + }) + } } + await reconnectFormerTeams(formerTeamIds) + // Create the project privilege const projectPrivilege = await db.projectPrivilege.create({ data: { diff --git a/src/projectmembers/mutations/deleteProjectMember.ts b/src/projectmembers/mutations/deleteProjectMember.ts deleted file mode 100644 index ffbf87f1..00000000 --- a/src/projectmembers/mutations/deleteProjectMember.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { resolver } from "@blitzjs/rpc" -import db from "db" -import { DeleteProjectMemberSchema } from "../schemas" -import countProjectManagers from "../queries/countProjectManagers" - -export default resolver.pipe( - resolver.zod(DeleteProjectMemberSchema), - resolver.authorize(), - async ({ id }, ctx) => { - // Find the project member to be deleted - const projectMemberToDelete = await db.projectMember.findUnique({ - where: { id }, - include: { users: true }, - }) - - // Check if projectMemberToDelete is undefined or has no users - if (!projectMemberToDelete) { - throw new Error("Contributor not found") - } - - // Ensure there's exactly one user associated with this project member - if (projectMemberToDelete.users.length !== 1) { - throw new Error("Invalid number of users associated with this project member") - } - - // Get the userId from the associated users array - const userId = projectMemberToDelete.users[0]!.id - - // Reconstruct possible display names used in notification messages - const user = projectMemberToDelete.users[0] - const possibleDisplayNames: string[] = [] - - if (user!.firstName && user!.lastName) { - possibleDisplayNames.push(`${user!.firstName} ${user!.lastName}`) - } - - if (user!.username) { - possibleDisplayNames.push(user!.username) - } - - const notificationMarker = " (former contributor)" - - // Check if the project member has any privileges related to the project - const projectPrivilege = await db.projectPrivilege.findFirst({ - where: { - userId: userId, - projectId: projectMemberToDelete.projectId, - }, - }) - - if (!projectPrivilege) { - throw new Error("Project privilege not found for the user") - } - - // Count the number of project managers in the project using the countProjectManagers query - const projectManagerCount = await countProjectManagers( - { - projectId: projectMemberToDelete.projectId, - }, - ctx - ) - - // Check if the projectMember to delete is the last project manager - if (projectPrivilege.privilege === "PROJECT_MANAGER" && projectManagerCount <= 1) { - throw new Error("Cannot delete the last project manager on the project.") - } - - // Delete project widgets associated with this user and project - await db.projectWidget.deleteMany({ - where: { - userId: userId, - projectId: projectMemberToDelete.projectId, - }, - }) - - // Proceed to delete the project privilege - await db.projectPrivilege.delete({ - where: { id: projectPrivilege.id }, - }) - - // Annotate existing notifications that reference this contributor by name - if (possibleDisplayNames.length > 0) { - const notifications = await db.notification.findMany({ - where: { - projectId: projectMemberToDelete.projectId, - announcement: false, - }, - }) - - await Promise.all( - notifications - .filter( - (n) => - !n.message.includes(notificationMarker) && - possibleDisplayNames.some((name) => n.message.includes(name)) - ) - .map((n) => - db.notification.update({ - where: { id: n.id }, - data: { - message: `${n.message}${notificationMarker}`, - }, - }) - ) - ) - } - - // Delete the project member - const projectMember = await db.projectMember.update({ - where: { id: projectMemberToDelete.id }, - data: { deleted: true }, - }) - - return projectMember - } -) From c0fa20711853b87c587904b84d52a9783e9528c6 Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Mon, 15 Dec 2025 17:39:55 -0600 Subject: [PATCH 5/8] fix stupid coloring --- src/contributors/components/ContributorForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/contributors/components/ContributorForm.tsx b/src/contributors/components/ContributorForm.tsx index b7984da1..e9659f56 100644 --- a/src/contributors/components/ContributorForm.tsx +++ b/src/contributors/components/ContributorForm.tsx @@ -130,7 +130,7 @@ export function ContributorForm>(props: Contributo /> )} Date: Mon, 15 Dec 2025 17:44:39 -0600 Subject: [PATCH 6/8] fix soft delete issue with middleware --- db/middlewares/projectMemberMiddleware.ts | 8 +++----- src/invites/mutations/acceptInvite.ts | 12 ++++++++++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/db/middlewares/projectMemberMiddleware.ts b/db/middlewares/projectMemberMiddleware.ts index 1ed678ca..bbf62a39 100644 --- a/db/middlewares/projectMemberMiddleware.ts +++ b/db/middlewares/projectMemberMiddleware.ts @@ -6,14 +6,12 @@ export default function projectMemberMiddleware(prisma) { params.model === "ProjectMember" && (params.action === "findMany" || params.action === "findFirst") ) { - // Check if `deleted` is explicitly set to `undefined` - const hasExplicitUndefined = - "deleted" in params.args.where && params.args.where.deleted === undefined + const hasExplicitDeleted = "deleted" in (params.args.where || {}) - if (!hasExplicitUndefined) { + if (!hasExplicitDeleted) { params.args.where = { ...params.args.where, - deleted: false, // ✅ Always filter out soft-deleted members + deleted: false, // ✅ Always filter out soft-deleted members unless caller overrides } } } diff --git a/src/invites/mutations/acceptInvite.ts b/src/invites/mutations/acceptInvite.ts index 14fcb9fd..bd1f9744 100644 --- a/src/invites/mutations/acceptInvite.ts +++ b/src/invites/mutations/acceptInvite.ts @@ -65,7 +65,11 @@ export default resolver.pipe( // Restore the soft-deleted ProjectMember projectMember = await db.projectMember.update({ where: { id: invite.reassignmentFor }, - data: { deleted: false, tags: invite.tags as any, formerTeamIds: null }, + data: { + deleted: false, + tags: invite.tags as any, + formerTeamIds: formerTeamIds.length > 0 ? formerTeamIds : undefined, + }, }) } else { // Check whether this user already has a soft-deleted ProjectMember for this project @@ -82,7 +86,11 @@ export default resolver.pipe( formerTeamIds = parseFormerTeamIds(existingProjectMember.formerTeamIds) projectMember = await db.projectMember.update({ where: { id: existingProjectMember.id }, - data: { deleted: false, tags: invite.tags as any, formerTeamIds: null }, + data: { + deleted: false, + tags: invite.tags as any, + formerTeamIds: formerTeamIds.length > 0 ? formerTeamIds : undefined, + }, }) } else { // Create a new ProjectMember for fresh invitations From 129f980237c6bf05160068fdfe6dbeb9785dfedc Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Mon, 15 Dec 2025 17:52:53 -0600 Subject: [PATCH 7/8] add that forgotten note about the tags --- src/contributors/components/ContributorForm.tsx | 3 +++ src/milestones/components/MilestoneForm.tsx | 3 +++ src/tasks/components/TaskForm.tsx | 3 +++ src/teams/components/TeamForm.tsx | 5 ++++- 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/contributors/components/ContributorForm.tsx b/src/contributors/components/ContributorForm.tsx index e9659f56..f4d1fcf5 100644 --- a/src/contributors/components/ContributorForm.tsx +++ b/src/contributors/components/ContributorForm.tsx @@ -164,6 +164,9 @@ export function ContributorForm>(props: Contributo /> +

+ Tags only save after you press enter, comma, or semicolon. +

>(props: MilestoneFor /> +

+ Tags only save after you press enter, comma, or semicolon. +

>(props: TaskFormProps) /> +

+ Tags only save after you press enter, comma, or semicolon. +

>(props: TeamFormProps) +

+ Tags only save after you press enter, comma, or semicolon. +

Date: Mon, 15 Dec 2025 17:58:49 -0600 Subject: [PATCH 8/8] fix typing issue --- src/contributors/mutations/deleteContributor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/contributors/mutations/deleteContributor.ts b/src/contributors/mutations/deleteContributor.ts index 6b289bc7..8481fbc2 100644 --- a/src/contributors/mutations/deleteContributor.ts +++ b/src/contributors/mutations/deleteContributor.ts @@ -204,7 +204,7 @@ export default resolver.pipe( where: { id: contributorToDelete.id }, data: { deleted: true, - formerTeamIds: formerTeamIds.length > 0 ? formerTeamIds : null, + formerTeamIds: formerTeamIds.length > 0 ? formerTeamIds : undefined, }, })