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/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/components/ContributorForm.tsx b/src/contributors/components/ContributorForm.tsx index b7984da1..f4d1fcf5 100644 --- a/src/contributors/components/ContributorForm.tsx +++ b/src/contributors/components/ContributorForm.tsx @@ -130,7 +130,7 @@ export function ContributorForm>(props: Contributo /> )} >(props: Contributo /> +

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

(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 : undefined, + }, }) return projectMember diff --git a/src/invites/mutations/acceptInvite.ts b/src/invites/mutations/acceptInvite.ts index 252f3bb7..bd1f9744 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,86 @@ 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: formerTeamIds.length > 0 ? formerTeamIds : undefined, + }, }) } 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: formerTeamIds.length > 0 ? formerTeamIds : undefined, + }, + }) + } 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/milestones/components/MilestoneForm.tsx b/src/milestones/components/MilestoneForm.tsx index 01155589..23731fa6 100644 --- a/src/milestones/components/MilestoneForm.tsx +++ b/src/milestones/components/MilestoneForm.tsx @@ -204,6 +204,9 @@ export function MilestoneForm>(props: MilestoneFor /> +

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

{ 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, } }) 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 ( - + ) 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 - } -) diff --git a/src/tasks/components/TaskForm.tsx b/src/tasks/components/TaskForm.tsx index 92ff41ee..489c40dd 100644 --- a/src/tasks/components/TaskForm.tsx +++ b/src/tasks/components/TaskForm.tsx @@ -377,6 +377,9 @@ export function TaskForm>(props: TaskFormProps) /> +

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

>(props: TeamFormProps) +

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