#25 — Activity Feed per List #25
Labels
No labels
priority/could
priority/must
priority/should
priority/wont
status/blocked
status/claimed
status/done-migrated
type/bug
type/feature
type/infra
type/tech-debt
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
robert/todo#25
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Story: Activity Feed per List
As a list member,
I want to see a chronological log of what has happened in a list,
so that I can catch up on changes since I last looked without asking others.
Acceptance criteria:
Out of scope for this story:
#26)Blockers: None
Priority: Should — answers "what changed?" without requiring real-time notifications.
security (
25_activity_feed_security.md)Security Pre-Review: Story
#25— Activity Feed per ListReviewer: Security Agent
Date: 2026-07-11
Design reviewed:
25_activity_feed_design.mdChecklist
GetActivityFeedForListQuerydeclaresAuthorizeTodoListAccessForCurrentUserQueryvia
ConfigureDi.CreateActivityEventCommandisinternal, never HTTP-mapped, and every call site is alreadygated by that call site's own authorization (todo handlers via
AuthorizeTodoListAccessForCurrentUserQuery,rename/remove via
AuthorizeTodoListOwnerAccessForCurrentUserQuery) — same accepted pattern asCreateNotificationCommand.TodoListIdand requires current-user membership —matches the established pattern; no bypass.
ActivityEventDtocarriesKind,Body,ActorUserId,ActorDisplayName,TodoNr,CreatedAt,Idonly. No password hash/salt/session data. Exposing which member did what to othermembers is the explicit intent of the story (AC: "All list members ... can view the feed") — not a leak.
CreateActivityEventCommandis never constructed from client input;TodoListIdand
ActorUserIdare always server-derived in the calling handler, never taken verbatim from the request DTO(e.g.
ActorUserIdforRemoveTodoListMemberCommandHandleris the acting owner's id fromGetCurrentUserIdQuery, notrequest.MemberId).Bodyis built server-side from already-validated Vogen value objects (TodoTitle,TodoListTitle) — no new unconstrained string surface.Findings
1. Stored-XSS risk if the frontend ever renders
Body/ActorDisplayNameviadangerouslySetInnerHTML.TodoTitleallows arbitrary user text (no HTML-stripping), so a title like<img src=x onerror=...>ends upverbatim inside
Body. This is not a new risk introduced by this feature —TodoItemalready displays the sametitles today — but it must be carried forward correctly here.
Requirement (not a blocker):
ActivityEventItem.tsxmust renderBodyandActorDisplayNameas plain Reactchildren (
{...}), neverdangerouslySetInnerHTML. React's default escaping is sufficient. Flagging for the FrontendEngineer and for final security review to confirm.
2.
ActorUserId: ON DELETE SET NULL— reviewed and approved.This is a correct, deliberate departure from
NotificationEntity's cascade behavior (see design doc §2.1'srationale). Approved: it avoids silently deleting other members' shared history when one past contributor deletes
their own account, and the
SET NULL+ generic fallback string means no lingering FK-joinable PII about thedeleted user remains.
3. Known/accepted limitation — freeform names baked into
Bodyare not scrubbed on later account deletion.E.g.
removed Sam from the listkeeps "Sam" as a literal string even if Sam later deletes their entire account.This mirrors the existing, already-accepted behavior of
NotificationEntity.Body(which already bakes in listtitles that aren't scrubbed either), and "GDPR compliance tooling" is explicitly listed as out of scope for this
WG-household app in
05_security_agent.md. Accepted, not a blocker — noted here for the record rather thansilently overlooked.
Verdict
Approved to proceed to implementation, with finding
#1carried forward as a concrete implementationrequirement to verify in the final security review (§ QA/Security final pass) rather than a design blocker.
design (
25_activity_feed_design.md)Design: Story
#25— Activity Feed per ListStatus: Ready for Security pre-review
Author: Software Architect
Date: 2026-07-11
1. Summary
A new
ActivityEventEntitytable records an append-only, list-scoped log of seven event kinds. Events are recordedas a side effect inside the seven existing command handlers that cause them (todo create/complete/reopen/delete,
member joined/removed, list renamed) — there is no generic domain-event bus in this codebase (confirmed against
the Notification feature,
#26), so each handler must inject and call a new internalCreateActivityEventCommandthe same way existing handlers inject
CreateNotificationCommand.The feed is read-only and pull-based: the frontend fetches a page when the "Activity" tab is opened and again
on "Load more". No WebSocket channel is added — the acceptance criteria don't require live updates (that's
explicitly out of scope, deferred to
#26-style notifications), and adding one would be premature for a featurewhose only interaction is "open tab, scroll".
TodoCreatedcreated '{title}'TodoCompletedcompleted '{title}'TodoReopenedreopened '{title}'TodoDeleteddeleted '{title}'MemberJoinedjoined the listMemberRemovedremoved {removedMemberName} from the listListRenamedrenamed the list from '{old}' to '{new}'Bodynever embeds the actor's own name — the frontend prepends the actor's display name as a distinct UIelement (per AC: "shows the acting user's name" as its own thing, so it can be styled/linked separately from the
action text), e.g.
{ActorDisplayName} {Body}.2. DB Schema Changes
2.1 New table:
ActivityEventEntityTodoListId:ON DELETE CASCADE— same asNotificationEntity. A deleted list's history is meaningless.ActorUserId:ON DELETE SET NULL— deliberately not cascade, unlikeNotificationEntity.RecipientId.This is the one place this design departs from the
#26precedent, and it's worth spelling out why: aNotificationEntityrow is private to its single recipient, so cascading it away when that user deletes theiraccount is correct — the data has no other viewer. An
ActivityEventEntityrow is shared, multi-user history.If Alice created ten todos over a list's lifetime and later deletes her account (
#10, GDPR deletion), cascading onActorUserIdwould silently erase those ten rows from every other member's feed — the shared history changesunderneath the remaining members for a reason that has nothing to do with the list.
SET NULLkeeps the row; thequery layer (§5.1) falls back to a fixed string (
"A former member") whenActorUserIdis null. No other dataidentifies who Alice was once her account is gone, which is the intended GDPR effect.
The removed/target member's name in
MemberRemoved(and any other name baked intoBody) is a plain stringliteral written at event-creation time, not a live join — so it always reads correctly regardless of what happens
to that user's account afterward, matching how
NotificationEntity.Bodyalready works.No stored
ActorDisplayNamecolumn. Unlike this paragraph's first draft assumption,NotificationDto'sTodoListTitleis not a denormalized column onNotificationEntity— it's a live EF join(
x.TodoList.Title) done in the query projection.ActorDisplayNamefollows the same convention: joined fromActorUser.Nameat read time, with theSET NULLfallback above.2.2 EF Core entity
2.3 New enum
2.4 New Vogen value object
Register in
CqsTodo/DbContext/VogenEfCoreConverters.cs:[EfCoreConverter<ActivityEventId>].2.5 Migration name
AddActivityEventEntity3. DTO
4. Internal command:
CreateActivityEventCommandNot exposed as an API endpoint (same convention as
CreateNotificationCommand—internalrecord,MapRequestsonly maps
publictypes, noConfigureDi/authorization since callers already enforce it).Handler logic (mirrors
CreateNotificationCommandHandler):ActivityEventEntitywithCreatedAt = DateTimeOffset.UtcNow.DbContext, left-joiningActorUser.Name(fallback"A former member"if null), projectto
ActivityEventDto.4.1 Hook points — seven existing handlers each gain one injected dependency
Each handler injects
IHandler<CreateActivityEventCommand, ActivityEventDto> createActivityEventHandler(andIHandler<GetCurrentUserIdQuery, UserId?>where not already present) and calls it after the primary writesucceeds, exactly where
CreateNotificationCommandis called inAssignTodoCommandHandler/AcceptListInvitationCommandHandlertoday:CreateTodoCommandHandlerTodoNr = entity.NrCheckTodoCommandHandlerTodoNr = request.TodoNrUncheckTodoCommandHandlerTodoNr = request.TodoNrDeleteTodoCommandHandlerentity.Title.ValuebeforedbContext.Remove(entity)— the row is gone afterAcceptListInvitationCommandHandleruserId)!isAlreadyMemberbranchRemoveTodoListMemberCommandHandlermembership.User.Name(requires.Include(x => x.User)or a small projection) beforedbContext.Remove(membership)RenameTodoListCommandHandlerExecuteUpdateAsync— the handler currently goes straight to the update with no prior read; addvar oldTitle = await dbContext.Set<TodoListEntity>().Where(x => x.Id == request.Id).Select(x => x.Title).SingleOrDefaultAsync(...), throwEntityNotFoundExceptionif null (replaces the existingrows == 0check)CheckTodoCommandHandler/UncheckTodoCommandHandlerdon't currently injectGetCurrentUserIdQuery— add it.RemoveTodoListMemberCommandHandler/RenameTodoListCommandHandlerlikewise needGetCurrentUserIdQueryadded.5. Query:
GetActivityFeedForListQuery5.1 Handler logic
Taketo[1, 100], clampSkipto>= 0(DoS guard, same rationale as Notification'sLimitclamp).CreatedAt DESC,Skip(skip).Take(take + 1)to detect whether more rows exist.ActivityEventDto:HasMore = rows.Count > take; return only the firsttakerows plusHasMore.Authorization:
Any list member (owner or regular member) passes this check — matches AC "All list members can view the feed."
6. Frontend
ActivityFeedPanel.tsxcomponent, rendered behind an "Activity" tab alongside the existing todo list view(reuses the
TabsUI primitive already insrc/components/ui/tabs.tsx, used elsewhere e.g.SettingsModal).ActivityEventItem.tsx: renders{ActorDisplayName} {Body}+ relative time (reusesrc/lib/relativeTime.ts, already used byNotificationItem.tsx) with atitleattribute (native browsertooltip) holding the absolute timestamp — same pattern as
NotificationItem.getActivityFeed(listId, skip, take)call insrc/api/api.tsx, mapped toPOST api/GetActivityFeedForListQueryper theMapRequestsconvention (all queries are POST).HasMore === false.No infinite-scroll observer for this iteration — a button is simpler and satisfies the AC ("via a 'Load more'
button" is explicitly listed as an acceptable option).
7. Out of scope (per story)
Filtering by event type/user, editing/deleting events, real-time push — all explicitly excluded in the story. Not
revisited here.
security_final (
25_activity_feed_security_final.md)Security Final Review: Story
#25— Activity Feed per ListReviewer: Security Agent
Date: 2026-07-11
Reviewed against:
061f084(backend),4c67108(backend tests),aba1617(frontend)Verification of the pre-review requirement
Finding
#1from pre-review (stored-XSS via todo titles baked intoBody) — verified fixed.ActivityEventItem.tsxrendersevent.actorDisplayNameandevent.bodyas plain JSX children(
{event.actorDisplayName}/{event.body}); nodangerouslySetInnerHTMLanywhere in the new components(confirmed by grep). Added
ActivityEventItem.test.tsxtestrenders todo titles containing HTML-like text safely as text, not markupasserts a body containing<img src=x onerror=...>renders as literal text and no<img>element is created in the DOM.Re-run of the backend checklist against the actual implementation
GetActivityFeedForListQueryHandler.ConfigureDideclaresAuthorizeTodoListAccessForCurrentUserQuery(x.TodoListId)— any list member can read, matching the AC.CreateActivityEventCommandstaysinternal/unmapped; every call site is already behind its ownauthorization (
AuthorizeTodoListAccessForCurrentUserQueryfor the four todo hooks and member-joined,AuthorizeTodoListOwnerAccessForCurrentUserQueryfor rename/member-removal).RemoveTodoListMemberCommandHandlerpassescurrentUserId!.Value(the authenticated owner) as the actor, neverrequest.MemberId(the removed member).Same pattern holds in all seven hook sites.
ActivityEventDtounchanged from design — no password/session fields.20260711105003_AddActivityEventEntity.cscreatesFK_ActivityEventEntity_UserEntity_ActorUserIdwithonDelete: ReferentialAction.SetNull, matching thedesign decision and the pre-existing note left in
NotificationEntity.csanticipating exactly this("Any future ActorId FK must NOT use ON DELETE CASCADE"). Verified with a DB-level test
(
Activity_event_survives_with_null_actor_when_actor_account_is_deleted) that actually deletes theUserEntityrow and asserts the event row survives withActorUserId == null.GetActivityFeedForListQueryHandlerthrowsArgumentExceptionforTake <= 0,Take > 100, orSkip < 0— matches theGetNotificationsForCurrentUserQueryprecedent exactly (testsThrows_ArgumentException_when_Take_is_out_of_range/..._Skip_is_negative).New observation (informational, not a blocker)
DeleteTodoCommandHandlernow records the todo's title inBodyafter the row has already been removedfrom the
DbContext's change tracker (title captured into a localstringbeforedbContext.Remove(entity)).This is correct and intentional — confirmed the title snapshot happens before deletion, and the corresponding
test (
Creates_activity_event_with_title_snapshot_after_todo_is_gone) exercises this. No action needed.Verdict
Approved. The one concrete requirement carried over from the design review is verified fixed with a
regression test. No new findings block this feature from moving to
done/.