Skip to content
Merged
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
8 changes: 3 additions & 5 deletions db/middlewares/projectMemberMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Expand Down
1 change: 1 addition & 0 deletions db/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ model ProjectMember {
tags Json?
commentReadStatus CommentReadStatus[]
notes Note[]
formerTeamIds Json?
}

model ProjectPrivilege {
Expand Down
5 changes: 4 additions & 1 deletion src/contributors/components/ContributorForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export function ContributorForm<S extends z.ZodType<any, any>>(props: Contributo
/>
)}
<LabelSelectField
className="select text-primary select-bordered border-primary border-2 w-1/2 mb-4 w-1/2"
className="select text-primary bg-base-300 select-bordered bg-base-300 border-primary border-2 w-1/2 mb-4 w-1/2"
name="privilege"
label="Select Privilege:"
options={MemberPrivilegesOptions}
Expand Down Expand Up @@ -164,6 +164,9 @@ export function ContributorForm<S extends z.ZodType<any, any>>(props: Contributo
/>
</span>
</label>
<p className="text-md italic text-base-content/80 mb-2">
Tags only save after you press enter, comma, or semicolon.
</p>
<ReactTags
tags={tags}
name="tags"
Expand Down
89 changes: 88 additions & 1 deletion src/contributors/mutations/deleteContributor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
'<span class="text-base-content/70" data-former-contributor="true"> (former contributor)</span>'

// Check if the project member has any privileges related to the project
const projectPrivilege = await db.projectPrivilege.findFirst({
where: {
Expand Down Expand Up @@ -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({
Expand All @@ -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: {
Expand Down Expand Up @@ -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
Expand Down
84 changes: 77 additions & 7 deletions src/invites/mutations/acceptInvite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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: {
Expand Down
3 changes: 3 additions & 0 deletions src/milestones/components/MilestoneForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,9 @@ export function MilestoneForm<S extends z.ZodType<any, any>>(props: MilestoneFor
/>
</span>
</label>
<p className="text-md italic text-base-content/80 mb-2">
Tags only save after you press enter, comma, or semicolon.
</p>
<ReactTags
tags={tags}
name="tags"
Expand Down
3 changes: 2 additions & 1 deletion src/notifications/tables/processing/processNotification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,7 +30,7 @@ export function processProjectNotification(
rawMessage: notification.message || "",
notification: notification,
routeData: notification.routeData as RouteData,
type: determineNotificationType(notification.message || "Other"),
type,
isMarkdown,
}
})
Expand Down
8 changes: 6 additions & 2 deletions src/projectmembers/components/ProjectMemberTaskList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<CollapseCard title="Contributor Tasks" className="mt-4">
<CollapseCard title={cardTitle} className="mt-4">
<Table columns={tableColumns} data={processedData} addPagination={true} />
</CollapseCard>
)
Expand Down
Loading