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
1 change: 1 addition & 0 deletions apps/api/plane/app/serializers/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ class Meta:
"is_password_autoset",
"is_email_verified",
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate field in serializer: is_email_verified appears twice in the fields list (lines 75 and 79). This is redundant and should be removed. Keep only one occurrence of this field.

Suggested change
"is_email_verified",

Copilot uses AI. Check for mistakes.
"last_login_medium",
"last_login_time",
]
read_only_fields = fields

Expand Down
29 changes: 29 additions & 0 deletions apps/api/plane/app/views/workspace/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
from plane.license.utils.instance_value import get_configuration_value
from plane.bgtasks.workspace_seed_task import workspace_seed
from plane.bgtasks.event_tracking_task import track_event
from plane.utils.url import contains_url
from plane.utils.analytics_events import WORKSPACE_CREATED, WORKSPACE_DELETED


class WorkSpaceViewSet(BaseViewSet):
Expand Down Expand Up @@ -131,6 +133,20 @@ def create(self, request):

workspace_seed.delay(serializer.data["id"])

track_event.delay(
user_id=request.user.id,
event_name=WORKSPACE_CREATED,
slug=data["slug"],
event_properties={
"user_id": request.user.id,
"workspace_id": data["id"],
"workspace_slug": data["slug"],
"role": "owner",
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded role value may be inaccurate: The role is hardcoded as "owner" in the event properties, but this should be determined based on the actual workspace ownership. The preprocess_data_properties function already handles role determination for certain events. Consider either using the numeric role value (20) that was set during workspace member creation on line 124, or let the preprocessing function handle it consistently.

Suggested change
"role": "owner",
"role": data.get("role"),

Copilot uses AI. Check for mistakes.
"workspace_name": data["name"],
"created_at": data["created_at"],
},
Comment on lines +140 to +147
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant data in event properties: user_id, workspace_id, and workspace_slug are passed both as function parameters and included again in the event_properties dictionary. This creates redundancy and could lead to inconsistencies. Consider removing these from event_properties since they're already available as function parameters.

Copilot uses AI. Check for mistakes.
)

return Response(data, status=status.HTTP_201_CREATED)
return Response(
[serializer.errors[error][0] for error in serializer.errors],
Expand Down Expand Up @@ -164,6 +180,19 @@ def destroy(self, request, *args, **kwargs):
# Get the workspace
workspace = self.get_object()
self.remove_last_workspace_ids_from_user_settings(workspace.id)
track_event.delay(
user_id=request.user.id,
event_name=WORKSPACE_DELETED,
slug=workspace.slug,
event_properties={
"user_id": request.user.id,
"workspace_id": workspace.id,
"workspace_slug": workspace.slug,
"role": "owner",
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded role value may be inaccurate: The role is hardcoded as "owner" in the event properties. However, the preprocess_data_properties function checks if the user is the workspace owner and sets the role accordingly. Since WORKSPACE_DELETED is one of the events that triggers this preprocessing (line 42), the hardcoded value will be overwritten. Either remove the hardcoded "role" field and let preprocessing handle it, or ensure the logic is consistent.

Suggested change
"role": "owner",

Copilot uses AI. Check for mistakes.
"workspace_name": workspace.name,
"deleted_at": str(timezone.now().isoformat()),
},
Comment on lines +187 to +194
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant data in event properties: user_id, workspace_id, and workspace_slug are passed both as function parameters and included again in the event_properties dictionary. This creates redundancy and could lead to inconsistencies. Consider removing these from event_properties since they're already available as function parameters.

Copilot uses AI. Check for mistakes.
)
Comment on lines +183 to +195
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's find the Workspace model to understand soft delete behavior
fd -t f "models.py" apps/api/plane | head -20

Repository: makeplane/plane

Length of output: 41


🏁 Script executed:

# Find event_tracking_task.py
fd -t f "event_tracking_task.py" apps/

Repository: makeplane/plane

Length of output: 104


🏁 Script executed:

# Search for preprocess_data_properties function
rg "preprocess_data_properties" --type py -B 2 -A 10

Repository: makeplane/plane

Length of output: 2295


🏁 Script executed:

# Verify isoformat() return type with Python
python3 << 'EOF'
from datetime import datetime
result = datetime.now().isoformat()
print(f"Type of isoformat(): {type(result)}")
print(f"Value: {result}")
print(f"str(isoformat()) == isoformat(): {str(result) == result}")
EOF

Repository: makeplane/plane

Length of output: 145


🏁 Script executed:

# Find Workspace model
rg "class Workspace" --type py -A 5 | head -50

Repository: makeplane/plane

Length of output: 3360


🏁 Script executed:

# Check for soft delete implementation (is_deleted field, deleted_at, etc.)
rg "is_deleted|deleted_at" apps/api/plane/db/models/ --type py | grep -i workspace

Repository: makeplane/plane

Length of output: 3379


🏁 Script executed:

# Look at the actual destroy method in the view file
cat -n apps/api/plane/app/views/workspace/base.py | sed -n '180,200p'

Repository: makeplane/plane

Length of output: 955


🏁 Script executed:

# Find the Workspace model definition
fd -t f "workspace.py" apps/api/plane/db/models/

Repository: makeplane/plane

Length of output: 96


🏁 Script executed:

# Get the Workspace model class definition
cat -n apps/api/plane/db/models/workspace.py | head -100

Repository: makeplane/plane

Length of output: 3306


🏁 Script executed:

# Look for custom manager or queryset in Workspace model
rg "class Workspace" apps/api/plane/db/models/workspace.py -A 30 | head -50

Repository: makeplane/plane

Length of output: 1952


🏁 Script executed:

# Check the BaseModel to understand soft delete implementation
cat -n apps/api/plane/db/models/base.py | head -150

Repository: makeplane/plane

Length of output: 1710


🏁 Script executed:

# Search for the manager or queryset that filters deleted records
rg "deleted_at" apps/api/plane/db/models/base.py -B 5 -A 5

Repository: makeplane/plane

Length of output: 41


🏁 Script executed:

# Check AuditModel which BaseModel inherits from
rg "class AuditModel" apps/api/plane --type py -A 20

Repository: makeplane/plane

Length of output: 1389


🏁 Script executed:

# Also search for where deleted_at is defined in the codebase
rg "deleted_at = models" apps/api/plane/db/ --type py

Repository: makeplane/plane

Length of output: 174


🏁 Script executed:

# Check the SoftDeleteModel implementation
rg "class SoftDeleteModel" apps/api/plane/db/mixins.py -A 50

Repository: makeplane/plane

Length of output: 1771


🏁 Script executed:

# Find the SoftDeletionManager implementation
rg "class SoftDeletionManager" apps/api/plane --type py -A 30

Repository: makeplane/plane

Length of output: 1966


Remove redundant str() wrapper and address async workspace retrieval risk.

Line 193 uses str(timezone.now().isoformat()), but .isoformat() already returns a string—the str() wrapper is redundant.

Additionally, the async track_event.delay() call at line 183 is executed before super().destroy() at line 196. When the async task later calls Workspace.objects.get(slug=slug) in preprocess_data_properties, the workspace has been soft-deleted and is filtered out by the default manager (which filters deleted_at__isnull=True), causing the query to fail. While the exception is caught and the event is still tracked with the pre-populated role: "owner", the role enrichment attempt fails silently. Consider either moving track_event.delay() after super().destroy() or using Workspace.all_objects.get() to query the deleted workspace.

-                "deleted_at": str(timezone.now().isoformat()),
+                "deleted_at": timezone.now().isoformat(),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
track_event.delay(
user_id=request.user.id,
event_name=WORKSPACE_DELETED,
slug=workspace.slug,
event_properties={
"user_id": request.user.id,
"workspace_id": workspace.id,
"workspace_slug": workspace.slug,
"role": "owner",
"workspace_name": workspace.name,
"deleted_at": str(timezone.now().isoformat()),
},
)
track_event.delay(
user_id=request.user.id,
event_name=WORKSPACE_DELETED,
slug=workspace.slug,
event_properties={
"user_id": request.user.id,
"workspace_id": workspace.id,
"workspace_slug": workspace.slug,
"role": "owner",
"workspace_name": workspace.name,
"deleted_at": timezone.now().isoformat(),
},
)
🤖 Prompt for AI Agents
In apps/api/plane/app/views/workspace/base.py around lines 183 to 195, remove
the redundant str() wrapper around timezone.now().isoformat() and move the
track_event.delay(...) call to after the super().destroy() call (so scheduling
happens once deletion is complete); also ensure the event_properties include all
necessary workspace info (id, slug, name, role) so the async task does not need
to re-query the default Workspace manager for a soft-deleted record
(alternatively, change the async task to use Workspace.all_objects.get(...) when
enriching).

return super().destroy(request, *args, **kwargs)


Expand Down
52 changes: 41 additions & 11 deletions apps/api/plane/app/views/workspace/invite.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@
WorkSpaceMemberSerializer,
)
from plane.app.views.base import BaseAPIView
from plane.bgtasks.event_tracking_task import workspace_invite_event
from plane.bgtasks.event_tracking_task import track_event
from plane.bgtasks.workspace_invitation_task import workspace_invitation
from plane.db.models import User, Workspace, WorkspaceMember, WorkspaceMemberInvite
from plane.utils.cache import invalidate_cache, invalidate_cache_directly
from plane.utils.host import base_host
from plane.utils.ip_address import get_client_ip
from plane.utils.analytics_events import USER_JOINED_WORKSPACE, USER_INVITED_TO_WORKSPACE
from .. import BaseViewSet


Expand Down Expand Up @@ -121,6 +122,19 @@ def create(self, request, slug):
current_site,
request.user.email,
)
track_event.delay(
user_id=request.user.id,
event_name=USER_INVITED_TO_WORKSPACE,
slug=slug,
event_properties={
"user_id": request.user.id,
"workspace_id": workspace.id,
"workspace_slug": workspace.slug,
"invitee_role": invitation.role,
"invited_at": str(timezone.now()),
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent timestamp formatting: str(timezone.now()) is used here, while str(timezone.now().isoformat()) is used in other places (e.g., line 50 in workspace_project_join.py and line 193 in workspace/base.py). This creates inconsistent data in analytics. Consider standardizing to either isoformat() or regular string formatting across all event tracking calls.

Suggested change
"invited_at": str(timezone.now()),
"invited_at": timezone.now().isoformat(),

Copilot uses AI. Check for mistakes.
"invitee_email": invitation.email,
},
Comment on lines +129 to +136
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant data in event properties: user_id, workspace_id, and workspace_slug are passed both as function parameters and included again in the event_properties dictionary. This creates redundancy and could lead to inconsistencies. Consider removing these from event_properties since they're already available as function parameters.

Copilot uses AI. Check for mistakes.
)
Comment on lines +125 to +137
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use .isoformat() for consistent timestamp format.

Line 134 uses str(timezone.now()) which produces a different format than the .isoformat() method used elsewhere in the codebase. This inconsistency can cause issues in analytics systems expecting ISO 8601 format.

🔎 Proposed fix
-                    "invited_at": str(timezone.now()),
+                    "invited_at": timezone.now().isoformat(),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
track_event.delay(
user_id=request.user.id,
event_name=USER_INVITED_TO_WORKSPACE,
slug=slug,
event_properties={
"user_id": request.user.id,
"workspace_id": workspace.id,
"workspace_slug": workspace.slug,
"invitee_role": invitation.role,
"invited_at": str(timezone.now()),
"invitee_email": invitation.email,
},
)
track_event.delay(
user_id=request.user.id,
event_name=USER_INVITED_TO_WORKSPACE,
slug=slug,
event_properties={
"user_id": request.user.id,
"workspace_id": workspace.id,
"workspace_slug": workspace.slug,
"invitee_role": invitation.role,
"invited_at": timezone.now().isoformat(),
"invitee_email": invitation.email,
},
)
🤖 Prompt for AI Agents
In apps/api/plane/app/views/workspace/invite.py around lines 125 to 137, the
tracked event uses str(timezone.now()) which yields a non-ISO timestamp; replace
that call with timezone.now().isoformat() so the event's "invited_at" field uses
a consistent ISO 8601 formatted timestamp (preserving timezone awareness).


return Response({"message": "Emails sent successfully"}, status=status.HTTP_200_OK)

Expand Down Expand Up @@ -186,20 +200,22 @@ def post(self, request, slug, pk):
# Set the user last_workspace_id to the accepted workspace
user.last_workspace_id = workspace_invite.workspace.id
user.save()
track_event.delay(
user_id=user.id,
event_name=USER_JOINED_WORKSPACE,
slug=slug,
event_properties={
"user_id": user.id,
"workspace_id": workspace_invite.workspace.id,
"workspace_slug": workspace_invite.workspace.slug,
Comment on lines +208 to +210
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant data in event properties: user_id, workspace_id, and workspace_slug are passed both as function parameters and included again in the event_properties dictionary. This creates redundancy and could lead to inconsistencies. Consider removing these from event_properties since they're already available as function parameters.

Suggested change
"user_id": user.id,
"workspace_id": workspace_invite.workspace.id,
"workspace_slug": workspace_invite.workspace.slug,

Copilot uses AI. Check for mistakes.
"role": workspace_invite.role,
"joined_at": str(timezone.now()),
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent timestamp formatting: str(timezone.now()) is used here, while str(timezone.now().isoformat()) is used in other places (e.g., line 50 in workspace_project_join.py and line 193 in workspace/base.py). This creates inconsistent data in analytics. Consider standardizing to either isoformat() or regular string formatting across all event tracking calls.

Suggested change
"joined_at": str(timezone.now()),
"joined_at": str(timezone.now().isoformat()),

Copilot uses AI. Check for mistakes.
},
)
Comment on lines +203 to +214
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use .isoformat() for consistent timestamp format.

Line 212 uses str(timezone.now()) which produces a different format than the .isoformat() method used elsewhere in the codebase. This inconsistency can cause issues in analytics systems expecting ISO 8601 format.

🔎 Proposed fix
-                            "joined_at": str(timezone.now()),
+                            "joined_at": timezone.now().isoformat(),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
track_event.delay(
user_id=user.id,
event_name=USER_JOINED_WORKSPACE,
slug=slug,
event_properties={
"user_id": user.id,
"workspace_id": workspace_invite.workspace.id,
"workspace_slug": workspace_invite.workspace.slug,
"role": workspace_invite.role,
"joined_at": str(timezone.now()),
},
)
track_event.delay(
user_id=user.id,
event_name=USER_JOINED_WORKSPACE,
slug=slug,
event_properties={
"user_id": user.id,
"workspace_id": workspace_invite.workspace.id,
"workspace_slug": workspace_invite.workspace.slug,
"role": workspace_invite.role,
"joined_at": timezone.now().isoformat(),
},
)
🤖 Prompt for AI Agents
In apps/api/plane/app/views/workspace/invite.py around lines 203 to 214, the
event payload uses str(timezone.now()) which creates a non-ISO timestamp;
replace that with timezone.now().isoformat() so the timestamp is consistently
ISO 8601 formatted (no code block provided—update the "joined_at" value to
timezone.now().isoformat()).


# Delete the invitation
workspace_invite.delete()

# Send event
workspace_invite_event.delay(
user=user.id if user is not None else None,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=get_client_ip(request=request),
event_name="MEMBER_ACCEPTED",
accepted_from="EMAIL",
)

return Response(
{"message": "Workspace Invitation Accepted"},
status=status.HTTP_200_OK,
Expand Down Expand Up @@ -252,6 +268,20 @@ def create(self, request):
is_active=True, role=invitation.role
)

# Track event
track_event.delay(
user_id=request.user.id,
event_name=USER_JOINED_WORKSPACE,
slug=invitation.workspace.slug,
event_properties={
"user_id": request.user.id,
"workspace_id": invitation.workspace.id,
"workspace_slug": invitation.workspace.slug,
Comment on lines +277 to +279
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant data in event properties: user_id, workspace_id, and workspace_slug are passed both as function parameters and included again in the event_properties dictionary. This creates redundancy and could lead to inconsistencies. Consider removing these from event_properties since they're already available as function parameters.

Suggested change
"user_id": request.user.id,
"workspace_id": invitation.workspace.id,
"workspace_slug": invitation.workspace.slug,

Copilot uses AI. Check for mistakes.
"role": invitation.role,
"joined_at": str(timezone.now()),
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent timestamp formatting: str(timezone.now()) is used here, while str(timezone.now().isoformat()) is used in other places (e.g., line 50 in workspace_project_join.py and line 193 in workspace/base.py). This creates inconsistent data in analytics. Consider standardizing to either isoformat() or regular string formatting across all event tracking calls.

Suggested change
"joined_at": str(timezone.now()),
"joined_at": str(timezone.now().isoformat()),

Copilot uses AI. Check for mistakes.
},
)
Comment on lines +272 to +283
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use .isoformat() for consistent timestamp format.

Line 281 uses str(timezone.now()) which produces a different format than the .isoformat() method used elsewhere in the codebase. This inconsistency can cause issues in analytics systems expecting ISO 8601 format.

🔎 Proposed fix
-                    "joined_at": str(timezone.now()),
+                    "joined_at": timezone.now().isoformat(),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
track_event.delay(
user_id=request.user.id,
event_name=USER_JOINED_WORKSPACE,
slug=invitation.workspace.slug,
event_properties={
"user_id": request.user.id,
"workspace_id": invitation.workspace.id,
"workspace_slug": invitation.workspace.slug,
"role": invitation.role,
"joined_at": str(timezone.now()),
},
)
track_event.delay(
user_id=request.user.id,
event_name=USER_JOINED_WORKSPACE,
slug=invitation.workspace.slug,
event_properties={
"user_id": request.user.id,
"workspace_id": invitation.workspace.id,
"workspace_slug": invitation.workspace.slug,
"role": invitation.role,
"joined_at": timezone.now().isoformat(),
},
)
🤖 Prompt for AI Agents
In apps/api/plane/app/views/workspace/invite.py around lines 272 to 283, the
event payload uses str(timezone.now()) which yields a non-ISO timestamp; replace
it with timezone.now().isoformat() so the "joined_at" field uses a consistent
ISO 8601 format (ensure timezone-aware datetime remains preserved when calling
.isoformat()).


# Bulk create the user for all the workspaces
WorkspaceMember.objects.bulk_create(
[
Expand Down
22 changes: 19 additions & 3 deletions apps/api/plane/authentication/utils/workspace_project_join.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
# Django imports
from django.utils import timezone

# Module imports
from plane.db.models import (
ProjectMember,
ProjectMemberInvite,
WorkspaceMember,
WorkspaceMemberInvite,
)
from plane.utils.cache import invalidate_cache_directly
from plane.bgtasks.event_tracking_task import track_event
from plane.utils.analytics_events import USER_JOINED_WORKSPACE


def process_workspace_project_invitations(user):
Expand All @@ -25,15 +31,25 @@ def process_workspace_project_invitations(user):
ignore_conflicts=True,
)

[
for workspace_member_invite in workspace_member_invites:
invalidate_cache_directly(
path=f"/api/workspaces/{str(workspace_member_invite.workspace.slug)}/members/",
url_params=False,
user=False,
multiple=True,
)
for workspace_member_invite in workspace_member_invites
]
track_event.delay(
user_id=user.id,
event_name=USER_JOINED_WORKSPACE,
slug=workspace_member_invite.workspace.slug,
event_properties={
"user_id": user.id,
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant conversion: user.id is already a UUID, so passing it to track_event.delay() and then converting it to string twice in event_properties (as "user_id": user.id) is inefficient. The user_id parameter already captures this information, so including it again in event_properties is redundant.

Suggested change
"user_id": user.id,

Copilot uses AI. Check for mistakes.
"workspace_id": workspace_member_invite.workspace.id,
"workspace_slug": workspace_member_invite.workspace.slug,
"role": workspace_member_invite.role,
"joined_at": str(timezone.now().isoformat()),
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The joined_at timestamp is being computed inside the loop, which means each workspace invitation will receive the same timestamp even though the track_event.delay() calls execute asynchronously. Consider capturing the timestamp once before the loop to ensure consistency across all events, or alternatively, move the timestamp computation to inside the async task itself.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent timestamp formatting: str(timezone.now().isoformat()) is used here, while str(timezone.now()) is used in other places (e.g., line 134, 212, 281). This creates inconsistent data in analytics. Consider standardizing to either isoformat() or regular string formatting across all event tracking calls.

Suggested change
"joined_at": str(timezone.now().isoformat()),
"joined_at": str(timezone.now()),

Copilot uses AI. Check for mistakes.
},
)

# Check if user has any project invites
project_member_invites = ProjectMemberInvite.objects.filter(email=user.email, accepted=True)
Expand Down
80 changes: 43 additions & 37 deletions apps/api/plane/bgtasks/event_tracking_task.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import logging
import os
import uuid
from typing import Dict, Any

# third party imports
from celery import shared_task
Expand All @@ -8,6 +10,11 @@
# module imports
from plane.license.utils.instance_value import get_configuration_value
from plane.utils.exception_logger import log_exception
from plane.db.models import Workspace
from plane.utils.analytics_events import USER_INVITED_TO_WORKSPACE, WORKSPACE_DELETED


logger = logging.getLogger("plane.worker")


def posthogConfiguration():
Expand All @@ -17,7 +24,10 @@ def posthogConfiguration():
"key": "POSTHOG_API_KEY",
"default": os.environ.get("POSTHOG_API_KEY", None),
},
{"key": "POSTHOG_HOST", "default": os.environ.get("POSTHOG_HOST", None)},
{
"key": "POSTHOG_HOST",
"default": os.environ.get("POSTHOG_HOST", None),
},
]
)
if POSTHOG_API_KEY and POSTHOG_HOST:
Expand All @@ -26,46 +36,42 @@ def posthogConfiguration():
return None, None


@shared_task
def auth_events(user, email, user_agent, ip, event_name, medium, first_time):
try:
POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration()
def preprocess_data_properties(
user_id: uuid.UUID, event_name: str, slug: str, data_properties: Dict[str, Any]
) -> Dict[str, Any]:
if event_name == USER_INVITED_TO_WORKSPACE or event_name == WORKSPACE_DELETED:
try:
# Check if the current user is the workspace owner
workspace = Workspace.objects.get(slug=slug)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Workspace lookup fails during deletion events

In preprocess_data_properties, a workspace lookup by slug is performed during WORKSPACE_DELETED events. However, because the workspace is soft-deleted and its slug is modified (appended with a timestamp) before the background task executes, the lookup consistently fails. This causes the actor's role to be incorrectly reported as unknown in analytics.

Additional Locations (1)

Fix in Cursor Fix in Web

if str(workspace.owner_id) == str(user_id):
data_properties["role"] = "owner"
else:
data_properties["role"] = "admin"
except Workspace.DoesNotExist:
logger.warning(f"Workspace {slug} does not exist while sending event {event_name} for user {user_id}")
data_properties["role"] = "unknown"
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The preprocess_data_properties function modifies the data_properties dictionary but only for specific events (USER_INVITED_TO_WORKSPACE and WORKSPACE_DELETED). However, it silently returns the dictionary unchanged for other events. This could lead to confusion. Consider adding a log statement or comment explaining this behavior, or restructure to make the conditional processing more explicit.

Suggested change
data_properties["role"] = "unknown"
data_properties["role"] = "unknown"
else:
# No preprocessing is required for this event; return properties unchanged
logger.debug(
"No preprocessing applied for event '%s'; returning properties unchanged",
event_name,
)

Copilot uses AI. Check for mistakes.
Comment on lines +43 to +52
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential race condition: The workspace lookup in the database (line 45) could fail if the workspace is deleted between the time the event is queued and when this preprocessing function runs. While the exception is caught, this creates a scenario where a role of "unknown" is assigned. Consider if this is the intended behavior, or if the role should be passed explicitly in the event properties to avoid this race condition.

Copilot uses AI. Check for mistakes.

if POSTHOG_API_KEY and POSTHOG_HOST:
posthog = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST)
posthog.capture(
email,
event=event_name,
properties={
"event_id": uuid.uuid4().hex,
"user": {"email": email, "id": str(user)},
"device_ctx": {"ip": ip, "user_agent": user_agent},
"medium": medium,
"first_time": first_time,
},
)
except Exception as e:
log_exception(e)
return
return data_properties
Comment on lines +39 to +54
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Role type inconsistency: integers vs strings.

The preprocess_data_properties function enriches role for only two event types (USER_INVITED_TO_WORKSPACE and WORKSPACE_DELETED), setting role as strings ("owner", "admin", "unknown"). However, other events like USER_JOINED_WORKSPACE pass role as integers (5, 15, 20) from WorkspaceMemberInvite.role. This creates inconsistent role data types across events, making analytics queries more complex and error-prone.

Consider either:

  1. Extending preprocess_data_properties to handle all event types and standardize role to string format, or
  2. Converting integer roles to human-readable strings at the source before calling track_event.
🔎 Proposed fix to extend role enrichment to all events
+# Add helper function to map role integer to string
+def get_role_string(role_int, is_owner=False):
+    if is_owner:
+        return "owner"
+    role_map = {5: "guest", 10: "viewer", 15: "member", 20: "admin"}
+    return role_map.get(role_int, "unknown")
+
 def preprocess_data_properties(
     user_id: uuid.UUID, event_name: str, slug: str, data_properties: Dict[str, Any]
 ) -> Dict[str, Any]:
-    if event_name == USER_INVITED_TO_WORKSPACE or event_name == WORKSPACE_DELETED:
+    # Standardize role for all events
+    if "role" in data_properties:
         try:
-            # Check if the current user is the workspace owner
             workspace = Workspace.objects.get(slug=slug)
-            if str(workspace.owner_id) == str(user_id):
+            is_owner = str(workspace.owner_id) == str(user_id)
+            
+            # If role is already a string, keep it; otherwise convert from integer
+            if isinstance(data_properties["role"], int):
+                data_properties["role"] = get_role_string(data_properties["role"], is_owner)
+            elif data_properties["role"] not in ["owner", "admin", "member", "viewer", "guest"]:
+                # Fallback for unexpected string values
                 data_properties["role"] = "owner"
-            else:
-                data_properties["role"] = "admin"
         except Workspace.DoesNotExist:
             logger.warning(f"Workspace {slug} does not exist while sending event {event_name} for user {user_id}")
             data_properties["role"] = "unknown"
🤖 Prompt for AI Agents
In apps/api/plane/bgtasks/event_tracking_task.py around lines 39 to 54, the
function only sets role strings for USER_INVITED_TO_WORKSPACE and
WORKSPACE_DELETED while other events provide integer roles, causing inconsistent
types; update the function to standardize role to a human-readable string for
all workspace-related events by: 1) adding a mapping dict from integer role
codes (e.g., WorkspaceMemberInvite.role values) to strings
("owner","admin","member" or similar) and using it when
data_properties.get("role") is an int; 2) extending the conditional to include
other relevant events (e.g., USER_JOINED_WORKSPACE) so role enrichment runs for
them too; and 3) keeping the existing workspace-owner lookup as a fallback and
setting "unknown" on exceptions or missing data to ensure output is always a
string.



@shared_task
def workspace_invite_event(user, email, user_agent, ip, event_name, accepted_from):
try:
POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration()
def track_event(user_id: uuid.UUID, event_name: str, slug: str, event_properties: Dict[str, Any]):
POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration()

if POSTHOG_API_KEY and POSTHOG_HOST:
posthog = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST)
posthog.capture(
email,
event=event_name,
properties={
"event_id": uuid.uuid4().hex,
"user": {"email": email, "id": str(user)},
"device_ctx": {"ip": ip, "user_agent": user_agent},
"accepted_from": accepted_from,
},
)
if not (POSTHOG_API_KEY and POSTHOG_HOST):
logger.warning("Event tracking is not configured")
return

try:
# preprocess the data properties for massaging the payload
# in the correct format for posthog
data_properties = preprocess_data_properties(user_id, event_name, slug, event_properties)
groups = {
"workspace": slug,
}
# track the event using posthog
posthog = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST)
posthog.capture(distinct_id=str(user_id), event=event_name, properties=data_properties, groups=groups)
except Exception as e:
log_exception(e)
return
return False
4 changes: 4 additions & 0 deletions apps/api/plane/utils/analytics_events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
USER_JOINED_WORKSPACE = "user_joined_workspace"
USER_INVITED_TO_WORKSPACE = "user_invited_to_workspace"
WORKSPACE_CREATED = "workspace_created"
WORKSPACE_DELETED = "workspace_deleted"
Loading