Skip to content

Headless API Gap Analysis & Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task.

Goal: Implement all stubbed headless API endpoints that the frontend depends on, bringing the Django backend to full parity with the frontend's expected API surface.

Architecture: Each task creates a Model (if needed), a view implementation, and wires the URL. Stubs are replaced with real DB queries following existing patterns in the codebase.

Tech Stack: Django 6, Django Rest Framework, PostgreSQL


Gap Analysis Summary

CategoryTotalImplementedPartialStub
Auth/User6411
Spaces8404
Posts6501
Comments2200
Notifications7520
Notification Prefs3300
Bookmarks2002
Events2002
Recurring Events2002
Community2101
Search1100
Reactions1001
Invitations1100
Courses3300
Chat/Messages6006
TOTAL5229320

File Structure

apps/
├── spaces/
│   ├── models/
│   │   ├── bookmark.py              # (new) Bookmark model
│   │   └── post_follower.py         # (new) PostFollower model
│   ├── migrations/
│   │   └── 0007_bookmark.postfollower.py
│   └── views/
│       └── headless_events.py       # (new) event headless views
├── headless/
│   ├── views.py                     # (modify) replace stubs
│   └── urls.py                      # (modify) add missing routes
└── tests/
    └── test_headless_gaps.py        # (new) tests for new implementations

Task 1: Bookmark Model + Migration

Covers: BookmarkListView, BookmarkDeleteView, useBookmarks(), useToggleBookmark(), useSpaceBookmarks()

Files:

  • Create: apps/spaces/models/bookmark.py

  • Modify: apps/spaces/models/__init__.py

  • Create: apps/spaces/migrations/0007_bookmark.py

  • [ ] Step 1: Create Bookmark model

python
# apps/spaces/models/bookmark.py
from django.conf import settings
from django.db import models

from apps.utils.models import BaseModel


class Bookmark(BaseModel):
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="bookmarks",
    )
    community = models.ForeignKey(
        "communities.Community",
        on_delete=models.CASCADE,
        related_name="bookmarks",
    )
    post = models.ForeignKey(
        "spaces.Post",
        on_delete=models.CASCADE,
        related_name="bookmarks",
    )

    class Meta:
        unique_together = ["user", "post"]

    def __str__(self):
        return f"{self.user} bookmarked {self.post}"
  • [ ] Step 2: Export from __init__.py

Add to apps/spaces/models/__init__.py:

python
from apps.spaces.models.bookmark import Bookmark

And add "Bookmark" to __all__.

  • [ ] Step 3: Create and apply migration

Run: make manage ARGS='makemigrations spaces' Run: make migrate

  • [ ] Step 4: Commit
bash
git add apps/spaces/models/bookmark.py apps/spaces/models/__init__.py apps/spaces/migrations/
git commit -m "feat: add Bookmark model"

Task 2: PostFollower Model + Migration

Covers: PostFollowersView, PostFollowerToggleView, usePostFollowers(), useFollowPost()

Files:

  • Create: apps/spaces/models/post_follower.py

  • Modify: apps/spaces/models/__init__.py

  • Create: apps/spaces/migrations/0008_postfollower.py

  • [ ] Step 1: Create PostFollower model

python
# apps/spaces/models/post_follower.py
from django.conf import settings
from django.db import models

from apps.utils.models import BaseModel


class PostFollower(BaseModel):
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="post_follows",
    )
    post = models.ForeignKey(
        "spaces.Post",
        on_delete=models.CASCADE,
        related_name="followers",
    )

    class Meta:
        unique_together = ["user", "post"]

    def __str__(self):
        return f"{self.user} follows {self.post}"
  • [ ] Step 2: Export and migrate

Add to apps/spaces/models/__init__.py:

python
from apps.spaces.models.post_follower import PostFollower

Run: make manage ARGS='makemigrations spaces' Run: make migrate

  • [ ] Step 3: Commit
bash
git add apps/spaces/models/post_follower.py apps/spaces/models/__init__.py apps/spaces/migrations/
git commit -m "feat: add PostFollower model"

Task 3: Implement Bookmarks Headless Views

Covers: BookmarkListView, BookmarkDeleteView, useBookmarks(), useToggleBookmark(), useSpaceBookmarks()

Files:

  • Modify: apps/headless/views.py — replace BookmarkListView, BookmarkDeleteView stubs; add wiring for BookmarkCreateView

  • [ ] Step 1: Replace BookmarkListView stub

python
# Replace in apps/headless/views.py

class BookmarkListView(APIView):
    """GET /api/headless/v1/bookmarks."""

    permission_classes = [RequiresCommunity, permissions.IsAuthenticated, RequiresActiveMembership]

    def get(self, request, *args, **kwargs):
        from apps.spaces.models import Bookmark

        bookmarks = Bookmark.objects.filter(
            user=request.user,
            community=request.community,
        ).select_related("post__space").order_by("-created_at")

        paginator = HeadlessPagination()
        page = paginator.paginate_queryset(bookmarks, request)

        records = []
        for b in (page or bookmarks):
            records.append({
                "id": b.id,
                "record_id": b.post_id,
                "bookmarkable_type": "Post",
                "created_at": b.created_at.isoformat(),
                "bookmark_record": {
                    "id": b.post.id,
                    "name": b.post.body[:100],
                    "slug": str(b.post.id),
                    "url": f"/posts/{b.post.id}",
                    "space": {
                        "id": b.post.space.id,
                        "name": b.post.space.name,
                        "slug": b.post.space.slug,
                    },
                },
            })

        return paginator.get_paginated_response(records)
  • [ ] Step 2: Replace BookmarkDeleteView stub
python
class BookmarkDeleteView(APIView):
    """DELETE /api/headless/v1/bookmarks/{postId}."""

    permission_classes = [RequiresCommunity, permissions.IsAuthenticated, RequiresActiveMembership]

    def delete(self, request, post_id, *args, **kwargs):
        from apps.spaces.models import Bookmark

        Bookmark.objects.filter(
            user=request.user,
            community=request.community,
            post_id=post_id,
        ).delete()

        return Response({"success": True})
  • [ ] Step 3: Add BookmarkCreateView (POST /bookmarks)
python
class BookmarkCreateView(APIView):
    """POST /api/headless/v1/bookmarks."""

    permission_classes = [RequiresCommunity, permissions.IsAuthenticated, RequiresActiveMembership]

    def post(self, request, *args, **kwargs):
        from apps.spaces.models import Bookmark, Post

        post_id = request.data.get("bookmarkable_id")
        post = get_object_or_404(Post, pk=post_id, community=request.community)

        bookmark, created = Bookmark.objects.get_or_create(
            user=request.user,
            community=request.community,
            post=post,
        )

        return Response({"success": True}, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
  • [ ] Step 4: Wire URL

Add to apps/headless/urls.py:

python
path("bookmarks", views.BookmarkCreateView.as_view(), name="bookmarks-create"),
path("bookmarks/<int:post_id>", views.BookmarkDeleteView.as_view(), name="bookmarks-delete"),

Replace existing stub URLs.

  • [ ] Step 5: Commit
bash
git add apps/headless/views.py apps/headless/urls.py
git commit -m "feat: implement bookmark headless API endpoints"

Task 4: Implement PostFollowers Headless Views

Covers: PostFollowersView, PostFollowerToggleView, usePostFollowers(), useFollowPost()

Files:

  • Modify: apps/headless/views.py — replace PostFollowersView, PostFollowerToggleView stubs

  • [ ] Step 1: Replace PostFollowersView stub

python
class PostFollowersView(APIView):
    """GET /api/headless/v1/posts/{postId}/post_followers."""

    permission_classes = [RequiresCommunity, permissions.IsAuthenticated, RequiresActiveMembership]

    def get(self, request, post_id, *args, **kwargs):
        from apps.spaces.models import PostFollower

        followers = PostFollower.objects.filter(
            post_id=post_id,
            post__community=request.community,
        ).select_related("user")

        records = []
        for f in followers:
            records.append({
                "id": f.id,
                "post_id": f.post_id,
                "community_member_id": f.user.id,
                "name": f.user.get_display_name(),
                "avatar_url": f.user.avatar_url if f.user.avatar else None,
            })

        return Response({
            "page": 1,
            "per_page": 20,
            "has_next_page": False,
            "count": len(records),
            "page_count": 1,
            "records": records,
        })
  • [ ] Step 2: Replace PostFollowerToggleView stub
python
class PostFollowerToggleView(APIView):
    """POST/DELETE /api/headless/v1/posts/{postId}/post_followers."""

    permission_classes = [RequiresCommunity, permissions.IsAuthenticated, RequiresActiveMembership]

    def post(self, request, post_id, *args, **kwargs):
        from apps.spaces.models import PostFollower, Post

        post = get_object_or_404(Post, pk=post_id, community=request.community)
        follower, created = PostFollower.objects.get_or_create(
            user=request.user,
            post=post,
        )
        return Response({"success": True, "is_following": True})

    def delete(self, request, post_id, *args, **kwargs):
        from apps.spaces.models import PostFollower

        PostFollower.objects.filter(
            user=request.user,
            post_id=post_id,
        ).delete()
        return Response({"success": True, "is_following": False})
  • [ ] Step 3: Commit
bash
git add apps/headless/views.py
git commit -m "feat: implement post followers headless API endpoints"

Task 5: Implement Events Headless Views

Covers: CommunityEventsView, EventAttendeesView, useCommunityEvents(), useEventAttendees(), useRsvpEvent(), useCancelRsvp()

Files:

  • Modify: apps/headless/views.py — replace CommunityEventsView, EventAttendeesView stubs; wire EventAttendeeCreateView, EventAttendeeDeleteView

  • [ ] Step 1: Replace CommunityEventsView stub

python
class CommunityEventsView(APIView):
    """GET /api/headless/v1/community_events."""

    permission_classes = [RequiresCommunity, permissions.IsAuthenticated, RequiresActiveMembership]

    def get(self, request, *args, **kwargs):
        from apps.spaces.models import Event, RSVP

        events = Event.objects.filter(
            community=request.community,
            space__isnull=False,
        ).select_related("host__user", "space").order_by("starts_at")

        paginator = HeadlessPagination()
        page = paginator.paginate_queryset(events, request)

        records = []
        for e in (page or events):
            rsvp = RSVP.objects.filter(event=e, membership=request.membership).first()
            records.append({
                "id": e.id,
                "display_title": e.title,
                "slug": str(e.id),
                "body_plain_text": e.description,
                "url": f"/events/{e.id}",
                "created_at": e.created_at.isoformat(),
                "event_type": "virtual" if e.virtual_meeting_url else "in_person",
                "rsvp_count": RSVP.objects.filter(event=e, status=RSVP.Status.GOING).count(),
                "rsvped_event": rsvp.status if rsvp else None,
                "event_setting_attributes": {
                    "starts_at": e.starts_at.isoformat(),
                    "ends_at": e.ends_at.isoformat(),
                    "in_person_location": e.location,
                    "virtual_location_url": e.virtual_meeting_url,
                    "rsvp_limit": None,
                },
                "space": {"id": e.space.id, "name": e.space.name, "slug": e.space.slug},
                "author": {
                    "id": e.host.id,
                    "name": e.host.user.get_display_name(),
                    "avatar_url": e.host.user.avatar_url if e.host.user.avatar else None,
                },
            })

        return paginator.get_paginated_response(records)
  • [ ] Step 2: Replace EventAttendeesView stub
python
class EventAttendeesView(APIView):
    """GET/POST/DELETE /api/headless/v1/events/{eventId}/event_attendees."""

    permission_classes = [RequiresCommunity, permissions.IsAuthenticated, RequiresActiveMembership]

    def get(self, request, event_id, *args, **kwargs):
        from apps.spaces.models import Event, RSVP

        event = get_object_or_404(Event, pk=event_id, community=request.community)
        rsvps = RSVP.objects.filter(event=event).select_related("membership__user")

        records = []
        for r in rsvps:
            records.append({
                "id": r.id,
                "community_member_id": r.membership.id,
                "avatar_url": r.membership.user.avatar_url if r.membership.user.avatar else None,
                "rsvp_date": r.created_at.isoformat(),
                "rsvp_status": r.status,
                "name": r.membership.user.get_display_name(),
            })

        return Response({
            "page": 1,
            "per_page": 20,
            "has_next_page": False,
            "count": len(records),
            "page_count": 1,
            "records": records,
        })

    def post(self, request, event_id, *args, **kwargs):
        from apps.spaces.models import Event, RSVP

        event = get_object_or_404(Event, pk=event_id, community=request.community)
        rsvp, _ = RSVP.objects.update_or_create(
            event=event,
            membership=request.membership,
            defaults={"status": RSVP.Status.GOING},
        )
        return Response({"success": True})

    def delete(self, request, event_id, *args, **kwargs):
        from apps.spaces.models import RSVP

        RSVP.objects.filter(
            event_id=event_id,
            membership=request.membership,
        ).delete()
        return Response({"success": True})
  • [ ] Step 3: Commit
bash
git add apps/headless/views.py
git commit -m "feat: implement events headless API endpoints"

Task 6: Implement Space Join/Leave/Topics/Bookmarks

Covers: SpaceJoinView, SpaceLeaveView, SpaceTopicsView, SpaceBookmarksView

Files:

  • Modify: apps/headless/views.py

  • [ ] Step 1: Replace SpaceJoinView/SpaceLeaveView stubs

These are already partially implemented (return success:True). The Space model uses access_groups for access control, not membership. Leave as-is for now — the stub behavior is correct for public spaces.

  • [ ] Step 2: Replace SpaceTopicsView stub
python
class SpaceTopicsView(APIView):
    """GET /api/headless/v1/spaces/{spaceId}/topics."""

    permission_classes = [RequiresCommunity, permissions.IsAuthenticated, RequiresActiveMembership]

    def get(self, request, space_id, *args, **kwargs):
        from django.db.models import Count
        from apps.spaces.models import Space, Tag

        space = get_object_or_404(
            Space.objects.visible_to(request.membership),
            pk=space_id,
        )

        tags = Tag.objects.filter(
            community=request.community,
            post__space=space,
        ).annotate(posts_count=Count("post")).order_by("-posts_count")

        records = []
        for t in tags:
            records.append({
                "id": t.id,
                "name": t.name,
                "slug": t.slug,
                "posts_count": t.posts_count,
                "space_id": space.id,
            })

        return Response({
            "page": 1,
            "per_page": 20,
            "has_next_page": False,
            "count": len(records),
            "page_count": 1,
            "records": records,
        })
  • [ ] Step 3: Replace SpaceBookmarksView stub
python
class SpaceBookmarksView(APIView):
    """GET /api/headless/v1/spaces/{spaceId}/bookmarks."""

    permission_classes = [RequiresCommunity, permissions.IsAuthenticated, RequiresActiveMembership]

    def get(self, request, space_id, *args, **kwargs):
        from apps.spaces.models import Bookmark, Space

        space = get_object_or_404(
            Space.objects.visible_to(request.membership),
            pk=space_id,
        )

        bookmarks = Bookmark.objects.filter(
            user=request.user,
            community=request.community,
            post__space=space,
        ).select_related("post")

        records = []
        for b in bookmarks:
            records.append({
                "id": b.id,
                "name": b.post.body[:100],
                "display_type": "post",
                "links": [{
                    "id": b.post.id,
                    "name": b.post.body[:100],
                    "display_type": "post",
                    "url": f"/posts/{b.post.id}",
                    "emoji": None,
                    "custom_emoji_url": None,
                }],
            })

        return Response({
            "page": 1,
            "per_page": 20,
            "has_next_page": False,
            "count": len(records),
            "page_count": 1,
            "records": records,
        })
  • [ ] Step 4: Commit
bash
git add apps/headless/views.py
git commit -m "feat: implement space topics and bookmarks headless endpoints"

Task 7: Implement Reactions Headless View

Covers: ReactionCreateView, useAddReaction()

Files:

  • Modify: apps/headless/views.py

  • [ ] Step 1: Replace ReactionCreateView stub

python
class ReactionCreateView(APIView):
    """POST /api/headless/v1/reactions."""

    permission_classes = [RequiresCommunity, permissions.IsAuthenticated, RequiresActiveMembership]

    def post(self, request, *args, **kwargs):
        from django.contrib.contenttypes.models import ContentType
        from apps.spaces.models import Post, Comment, Image, Reaction

        reactable_type = request.data.get("reactable_type", "")
        reactable_id = request.data.get("reactable_id")
        emoji = request.data.get("reaction", "like")

        model_map = {"Post": Post, "Comment": Comment, "Image": Image}
        model = model_map.get(reactable_type)
        if model is None:
            return Response({"error": "Invalid reactable_type"}, status=status.HTTP_400_BAD_REQUEST)

        target = get_object_or_404(model, pk=reactable_id, community=request.community)
        content_type = ContentType.objects.get_for_model(target)

        reaction, _ = Reaction.objects.update_or_create(
            membership=request.membership,
            content_type=content_type,
            object_id=target.pk,
            defaults={"emoji": emoji},
        )

        return Response({"id": reaction.id, "reaction": reaction.emoji})
  • [ ] Step 2: Commit
bash
git add apps/headless/views.py
git commit -m "feat: implement reaction create headless endpoint"

Covers: CommunityLinksView, MemberSpacesView, useCommunityLinks(), useMemberSpaces()

Files:

  • Modify: apps/headless/views.py

  • [ ] Step 1: Replace CommunityLinksView stub

python
class CommunityLinksView(APIView):
    """GET /api/headless/v1/community_links."""

    permission_classes = [RequiresCommunity, permissions.IsAuthenticated, RequiresActiveMembership]

    def get(self, request, *args, **kwargs):
        # No CommunityLink model exists yet — return empty for now
        # This is expected: Circle.so custom links are a premium feature
        return Response({
            "page": 1,
            "per_page": 20,
            "has_next_page": False,
            "count": 0,
            "page_count": 1,
            "records": [],
        })
  • [ ] Step 2: Replace MemberSpacesView stub
python
class MemberSpacesView(APIView):
    """GET /api/headless/v1/community_members/{memberId}/spaces."""

    permission_classes = [RequiresCommunity, permissions.IsAuthenticated, RequiresActiveMembership]

    def get(self, request, member_id, *args, **kwargs):
        from apps.spaces.models import Space

        membership = get_object_or_404(
            Membership, pk=member_id, community=request.community
        )

        spaces = Space.objects.visible_to(membership).select_related("space_group")

        records = []
        for s in spaces:
            records.append({
                "id": s.id,
                "name": s.name,
                "slug": s.slug,
                "space_type": s.space_type,
                "space_group_id": s.space_group_id,
            })

        return Response({
            "page": 1,
            "per_page": 20,
            "has_next_page": False,
            "count": len(records),
            "page_count": 1,
            "records": records,
        })
  • [ ] Step 3: Commit
bash
git add apps/headless/views.py
git commit -m "feat: implement community links and member spaces headless endpoints"

Task 9: Partial Implementations — Fix Hardcoded Fields

Covers: PublicMemberProfileView, NotificationsNewCountView, SpaceNotificationDetailsView, PageProfileFieldsView

Files:

  • Modify: apps/headless/views.py

  • [ ] Step 1: Fix PublicMemberProfileView — add comments_count, followers_count

python
# In PublicMemberProfileView.get(), add:
from apps.spaces.models import Comment, PostFollower

comments_count = Comment.objects.filter(
    author=membership,
    community=request.community,
).count()

followers_count = PostFollower.objects.filter(
    post__author=membership,
    post__community=request.community,
).values("user").distinct().count()

Include in response: "comments_count": comments_count, "followers_count": followers_count

  • [ ] Step 2: Fix PageProfileFieldsView — include required and options
python
# In PageProfileFieldsView.get(), update the record building:
records.append({
    "id": d.id,
    "key": d.key,
    "label": d.name,
    "field_type": d.field_type,
    "required": d.is_required,
    "options": d.options or [],
})
  • [ ] Step 3: Commit
bash
git add apps/headless/views.py
git commit -m "feat: fix hardcoded fields in partial headless implementations"

Task 10: Tests

Covers: All new implementations

Files:

  • Create: apps/headless/tests/test_headless_gaps.py

  • [ ] Step 1: Write tests for bookmarks

python
class BookmarkAPITests(CommunityTestCase):
    def _login(self):
        user, _ = CustomUser.objects.get_or_create(
            username="bookmark@example.com",
            defaults={"email": "bookmark@example.com"},
        )
        user.set_password(PASSWORD)
        user.save()
        self.client.login(username="bookmark@example.com", password=PASSWORD)
        return user

    def test_create_and_list_bookmarks(self):
        user = self._login()
        membership = self.create_membership(user)
        space = self.create_space(community=self.community)
        post = self.create_post(space=space, author=membership)

        response = self.client.post(
            "/api/headless/v1/bookmarks",
            data={"bookmarkable_type": "Post", "bookmarkable_id": post.id},
            content_type="application/json",
            HTTP_HOST=self.community.slug + ".localhost",
        )
        self.assertEqual(response.status_code, 201)

        response = self.client.get(
            "/api/headless/v1/bookmarks",
            HTTP_HOST=self.community.slug + ".localhost",
        )
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.data["count"], 1)
  • [ ] Step 2: Write tests for post followers

  • [ ] Step 3: Write tests for events

  • [ ] Step 4: Run tests

Run: make test ARGS='apps.headless.tests.test_headless_gaps' Expected: All tests pass

  • [ ] Step 5: Commit
bash
git add apps/headless/tests/test_headless_gaps.py
git commit -m "feat: add tests for headless API gap implementations"

Task 11: Verify & Lint

  • [ ] Step 1: Run full test suite

Run: make test Expected: All tests pass (pre-existing JWT failures only)

  • [ ] Step 2: Run linter

Run: make ruff Expected: No new errors

  • [ ] Step 3: Commit any fixes
bash
git add -A
git commit -m "fix: lint and formatting for headless gap implementations"

Strum — Documentação.