diff --git a/backend/api/docs/utils.py b/backend/api/docs/utils.py
index 20623732c..afdd920f2 100644
--- a/backend/api/docs/utils.py
+++ b/backend/api/docs/utils.py
@@ -46,6 +46,8 @@ def get_readable_field_name(field_name):
return "string"
case "RegexField":
return "string"
+ case "UUIDField":
+ return "string"
case _:
return "object"
diff --git a/backend/api/event/utils.py b/backend/api/event/utils.py
index b8c1453c8..d8234fbfc 100644
--- a/backend/api/event/utils.py
+++ b/backend/api/event/utils.py
@@ -33,6 +33,7 @@ def check_custom_code(code):
"api",
"dashboard",
"forgot-password",
+ "guest-import",
"login",
"new-event",
"settings",
diff --git a/backend/api/guest_import/serializers.py b/backend/api/guest_import/serializers.py
new file mode 100644
index 000000000..6b6e14deb
--- /dev/null
+++ b/backend/api/guest_import/serializers.py
@@ -0,0 +1,39 @@
+from rest_framework import serializers
+
+
+class GuestDataSummarySerializer(serializers.Serializer):
+ created_events = serializers.IntegerField()
+ participated_events = serializers.IntegerField()
+
+
+class GuestEventSerializer(serializers.Serializer):
+ url_code = serializers.CharField(allow_null=True)
+ public_id = serializers.UUIDField()
+ title = serializers.CharField()
+
+
+class GuestParticipationSerializer(serializers.Serializer):
+ url_code = serializers.CharField(allow_null=True)
+ public_id = serializers.UUIDField()
+ title = serializers.CharField()
+ guest_display_name = serializers.CharField()
+ account_display_name = serializers.CharField(allow_null=True)
+
+
+class GuestDataSerializer(serializers.Serializer):
+ created_events = serializers.ListField(
+ child=GuestEventSerializer(),
+ allow_empty=True,
+ )
+ participated_events = serializers.ListField(
+ child=GuestParticipationSerializer(),
+ allow_empty=True,
+ )
+
+
+class GuestImportDataSerializer(serializers.Serializer):
+ availability_choices = serializers.DictField(
+ required=True,
+ child=serializers.ChoiceField(required=True, choices=["guest", "account"]),
+ allow_empty=True,
+ )
diff --git a/backend/api/guest_import/urls.py b/backend/api/guest_import/urls.py
new file mode 100644
index 000000000..a4b484349
--- /dev/null
+++ b/backend/api/guest_import/urls.py
@@ -0,0 +1,9 @@
+from django.urls import path
+
+from . import views
+
+urlpatterns = [
+ path("get-summary/", views.get_summary),
+ path("get-data/", views.get_data),
+ path("import-data/", views.import_data),
+]
diff --git a/backend/api/guest_import/utils.py b/backend/api/guest_import/utils.py
new file mode 100644
index 000000000..6ebe44448
--- /dev/null
+++ b/backend/api/guest_import/utils.py
@@ -0,0 +1,38 @@
+import logging
+
+from django.db import transaction
+
+from api.models import UserSession
+from api.settings import GUEST_COOKIE_NAME
+from api.utils import get_session, set_session_cookie, update_session_metadata
+
+logger = logging.getLogger("api")
+
+
+def get_guest_account(request, response):
+ """
+ Returns the guest account associated with the request, refreshing the session token
+ cookie and updating the metadata on the session.
+
+ If there is no guest account, this will return `None`.
+
+ This does NOT make the guest user available on the `request` object.
+ """
+ guest_token = request.COOKIES.get(GUEST_COOKIE_NAME)
+
+ if guest_token:
+ logger.debug("Guest session token: %s", guest_token)
+ try:
+ with transaction.atomic():
+ session = get_session(guest_token)
+ if not session:
+ raise UserSession.DoesNotExist
+
+ update_session_metadata(request, session)
+ except UserSession.DoesNotExist:
+ logger.info("Guest session expired.")
+ else:
+ set_session_cookie(response, GUEST_COOKIE_NAME, session.session_token, True)
+ return session.user_account
+
+ return None
diff --git a/backend/api/guest_import/views.py b/backend/api/guest_import/views.py
new file mode 100644
index 000000000..05806524b
--- /dev/null
+++ b/backend/api/guest_import/views.py
@@ -0,0 +1,231 @@
+import logging
+
+from django.core.exceptions import ObjectDoesNotExist
+from django.db import transaction
+from rest_framework.response import Response
+
+from api.decorators import (
+ api_endpoint,
+ require_account_auth,
+ validate_json_input,
+ validate_output,
+)
+from api.guest_import.serializers import (
+ GuestDataSerializer,
+ GuestDataSummarySerializer,
+ GuestImportDataSerializer,
+)
+from api.guest_import.utils import get_guest_account
+from api.models import (
+ EventParticipant,
+ UserEvent,
+)
+from api.utils import MessageOutputSerializer
+
+logger = logging.getLogger("api")
+
+
+@api_endpoint("GET")
+@require_account_auth
+@validate_output(GuestDataSummarySerializer)
+def get_summary(request):
+ """
+ Returns counts of events created and participated in by the guest user.
+ """
+ response = Response()
+
+ guest_user = get_guest_account(request, response)
+
+ if guest_user is None:
+ response.data = {
+ "created_events": 0,
+ "participated_events": 0,
+ }
+ response.status_code = 200
+ return response
+
+ events = UserEvent.objects.filter(user_account=guest_user).count()
+
+ participations = EventParticipant.objects.filter(user_account=guest_user).count()
+
+ response.data = {
+ "created_events": events,
+ "participated_events": participations,
+ }
+ response.status_code = 200
+
+ return response
+
+
+@api_endpoint("GET")
+@require_account_auth
+@validate_output(GuestDataSerializer)
+def get_data(request):
+ """
+ Returns brief information about events created and participated in by the guest user.
+ """
+ response = Response()
+
+ guest_user = get_guest_account(request, response)
+ account_user = request.user
+
+ if guest_user is None:
+ response.data = {
+ "created_events": [],
+ "participated_events": [],
+ }
+ response.status_code = 200
+ return response
+
+ def get_url_code(event):
+ try:
+ return event.url_code.url_code
+ except ObjectDoesNotExist:
+ return None
+
+ events = UserEvent.objects.filter(user_account=guest_user).select_related(
+ "url_code"
+ )
+ created_events = [
+ {
+ "url_code": get_url_code(event),
+ "public_id": event.public_id,
+ "title": event.title,
+ }
+ for event in events
+ ]
+
+ participations = EventParticipant.objects.filter(
+ user_account=guest_user,
+ ).select_related("user_event__url_code")
+ participated_events = [
+ {
+ "url_code": get_url_code(participation.user_event),
+ "public_id": participation.user_event.public_id,
+ "title": participation.user_event.title,
+ "guest_display_name": participation.display_name,
+ "account_display_name": None,
+ }
+ for participation in participations
+ ]
+
+ conflicts = EventParticipant.objects.filter(
+ user_account=account_user,
+ user_event__in=participations.values_list("user_event", flat=True),
+ ).select_related("user_event")
+ participation_map = {p["public_id"]: p for p in participated_events}
+ for conflict in conflicts:
+ participation = participation_map.get(conflict.user_event.public_id)
+ if participation:
+ participation["account_display_name"] = conflict.display_name
+ logger.info(
+ "%s conflicted events found during guest import for user: %s",
+ len(conflicts),
+ account_user.email,
+ )
+
+ response.data = {
+ "created_events": created_events,
+ "participated_events": participated_events,
+ }
+ response.status_code = 200
+
+ return response
+
+
+@api_endpoint("POST")
+@require_account_auth
+@validate_json_input(GuestImportDataSerializer)
+@validate_output(MessageOutputSerializer)
+def import_data(request):
+ """
+ Transfers ownership of events and availabilities created by the guest to the account.
+
+ The body of the request should contain a mapping of event's public ID to the user's
+ chosen submission to keep, for each event the guest had submitted availability to. If
+ there is no conflict, "guest" should be specified.
+
+ Every conflicted submission that is not kept will be deleted when calling this
+ endpoint.
+ """
+ availability_choices = request.validated_data.get("availability_choices", {})
+
+ response = Response()
+
+ guest_user = get_guest_account(request, response)
+ account_user = request.user
+
+ def no_data_found():
+ response.data = {
+ "error": {"general": ["No guest data found."]},
+ }
+ response.status_code = 400
+ return response
+
+ if guest_user is None:
+ return no_data_found()
+
+ with transaction.atomic():
+ # Check if the guest has any data to import
+ guest_events = UserEvent.objects.filter(user_account=guest_user)
+ guest_submissions = EventParticipant.objects.filter(
+ user_account=guest_user
+ ).select_related("user_event")
+ if not guest_events.exists() and not guest_submissions.exists():
+ logger.warning(
+ "No guest data found for import attempt for user: %s",
+ account_user.email,
+ )
+ return no_data_found()
+
+ # Check if all the guest user's submissions are accounted for in the choices
+ if set(availability_choices.keys()) != set(
+ str(submission.user_event.public_id) for submission in guest_submissions
+ ):
+ logger.warning(
+ "Availability choices do not match guest submissions for user: %s",
+ account_user.email,
+ )
+ response.data = {
+ "error": {
+ "availability_choices": [
+ "Availability choices do not match guest submissions.",
+ ]
+ }
+ }
+ response.status_code = 400
+ return response
+
+ # Transfer event ownership
+ UserEvent.objects.filter(user_account=guest_user).update(
+ user_account=account_user
+ )
+
+ # === DELETE DISCARDED SUBMISSIONS ===
+ account_chosen = []
+ guest_chosen = []
+ for public_id, choice in availability_choices.items():
+ match choice:
+ case "account":
+ account_chosen.append(public_id)
+ case "guest":
+ guest_chosen.append(public_id)
+
+ # Start by removing the guest's submissions from events that have "account" specified
+ EventParticipant.objects.filter(
+ user_account=guest_user, user_event__public_id__in=account_chosen
+ ).delete()
+ # Then remove the account's submissions from events that have "guest" specified
+ EventParticipant.objects.filter(
+ user_account=account_user, user_event__public_id__in=guest_chosen
+ ).delete()
+
+ # Then of all the remaining guest submissions transfer ownership to the account
+ EventParticipant.objects.filter(user_account=guest_user).update(
+ user_account=account_user
+ )
+
+ response.data = {"message": ["Guest data imported successfully."]}
+ response.status_code = 200
+
+ return response
diff --git a/backend/api/migrations/0034_userevent_public_id.py b/backend/api/migrations/0034_userevent_public_id.py
new file mode 100644
index 000000000..828882545
--- /dev/null
+++ b/backend/api/migrations/0034_userevent_public_id.py
@@ -0,0 +1,20 @@
+# Generated by Django 5.2 on 2026-07-09 17:46
+
+import uuid
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("api", "0033_urlcode_unique_case_insensitive_url_code"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="userevent",
+ name="public_id",
+ field=models.UUIDField(default=uuid.uuid4, editable=False, null=True),
+ ),
+ ]
diff --git a/backend/api/migrations/0035_populate_public_id.py b/backend/api/migrations/0035_populate_public_id.py
new file mode 100644
index 000000000..a94f77315
--- /dev/null
+++ b/backend/api/migrations/0035_populate_public_id.py
@@ -0,0 +1,23 @@
+# Generated by Django 5.2 on 2026-07-09 17:47
+
+import uuid
+
+from django.db import migrations
+
+
+def gen_uuid(apps, _):
+ UserEvent = apps.get_model("api", "UserEvent")
+ for row in UserEvent.objects.iterator():
+ row.public_id = uuid.uuid4()
+ row.save(update_fields=["public_id"])
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("api", "0034_userevent_public_id"),
+ ]
+
+ operations = [
+ migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop),
+ ]
diff --git a/backend/api/migrations/0036_apply_unique_constraint.py b/backend/api/migrations/0036_apply_unique_constraint.py
new file mode 100644
index 000000000..e44d54684
--- /dev/null
+++ b/backend/api/migrations/0036_apply_unique_constraint.py
@@ -0,0 +1,20 @@
+# Generated by Django 5.2 on 2026-07-09 17:47
+
+import uuid
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("api", "0035_populate_public_id"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="userevent",
+ name="public_id",
+ field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
+ ),
+ ]
diff --git a/backend/api/models.py b/backend/api/models.py
index 813fd6121..f21023fca 100644
--- a/backend/api/models.py
+++ b/backend/api/models.py
@@ -88,6 +88,7 @@ class UserLogin(models.Model):
class UserEvent(models.Model):
user_event_id = models.AutoField(primary_key=True)
+ public_id = models.UUIDField(unique=True, editable=False, default=uuid.uuid4)
user_account = models.ForeignKey(
UserAccount, on_delete=models.CASCADE, related_name="events"
)
diff --git a/backend/api/urls.py b/backend/api/urls.py
index 4f12fec0f..6c4a104ef 100644
--- a/backend/api/urls.py
+++ b/backend/api/urls.py
@@ -7,4 +7,5 @@
path("availability/", include("api.availability.urls")),
path("dashboard/", include("api.dashboard.urls")),
path("account/", include("api.account.urls")),
+ path("guest-import/", include("api.guest_import.urls")),
]
diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx
index 0d90179a9..8ca06b1bc 100644
--- a/frontend/src/app/(auth)/login/page.tsx
+++ b/frontend/src/app/(auth)/login/page.tsx
@@ -10,6 +10,7 @@ import AuthPageLayout from "@/components/layout/auth-page";
import LinkText from "@/components/link-text";
import TextInputField from "@/components/text-input-field";
import ActionButton from "@/features/button/components/action";
+import { DONT_SHOW_AGAIN_KEY } from "@/features/guest-import/constants";
import { useFormErrors } from "@/lib/hooks/use-form-errors";
import { MESSAGES } from "@/lib/messages";
import { clientPost } from "@/lib/utils/api/client-fetch";
@@ -24,7 +25,7 @@ export default function Page() {
const router = useRouter();
const searchParams = useSearchParams();
- const callbackUrl = getSafeRedirectUrl(searchParams.get("callbackUrl"));
+ const callbackUrl = searchParams.get("callbackUrl");
// TOASTS AND ERROR STATES
const { errors, handleError, clearAllErrors } = useFormErrors();
@@ -54,12 +55,22 @@ export default function Page() {
}
try {
+ let dontShowGuestImport = false;
+ try {
+ dontShowGuestImport =
+ localStorage.getItem(DONT_SHOW_AGAIN_KEY) === "true";
+ } catch {}
+ const redirectUrl = getSafeRedirectUrl(
+ callbackUrl,
+ dontShowGuestImport ? undefined : "/guest-import/login",
+ );
+
await clientPost(ROUTES.auth.login, {
email,
password,
remember_me: rememberMe,
});
- router.push(callbackUrl);
+ router.push(redirectUrl);
router.refresh();
return true;
} catch (e) {
diff --git a/frontend/src/app/dashboard/page-client.tsx b/frontend/src/app/dashboard/page-client.tsx
index aac3c7afd..aabf6106e 100644
--- a/frontend/src/app/dashboard/page-client.tsx
+++ b/frontend/src/app/dashboard/page-client.tsx
@@ -81,20 +81,17 @@ export default function ClientPage({
Dashboard
{!logged_in && (
-
+
- This data is only available from this browser.{" "}
+ This data is only available from this browser,{" "}
- Create an account
+ create an account
{" "}
- to sync data across devices.
-
-
- Currently, guest data cannot be transferred to an account. Keep an
- eye out for updates!
+ to sync across devices. You can import this data into your account
+ at any time in account settings.
)}
diff --git a/frontend/src/app/guest-import/import/loading.tsx b/frontend/src/app/guest-import/import/loading.tsx
new file mode 100644
index 000000000..98c580f76
--- /dev/null
+++ b/frontend/src/app/guest-import/import/loading.tsx
@@ -0,0 +1,41 @@
+import HeaderSpacer from "@/features/header/components/header-spacer";
+
+export default function Loading() {
+ return (
+