Plans¶
Purpose¶
The plan domain turns captured ideas into real events. A Plan is a date-free collaborative canvas of Items grouped by the place they resolve to. Members vote on places and on availability, then the host promotes the plan into a normal Event, carrying a curated board of items along.
The domain lives in backend/internal/services/plan/ and owns three surfaces:
the plan canvas, the item board (the stash plus per-plan items), and the
availability poll. Item capture and link parsing live here too
(item_service.go, capture_parsers.go).
Mental model¶
stash: plan_id nil
place-cards + interest votes + when-poll
snapshotted read-only board
promoted_to_event_id set); archived plans are read-only.Three rules carry most of the design:
- Grouping is by
Location. Items sharing alocation_idrender as one place-card.PlanLocationis the per-(plan, location) aggregate (vote and item counts), not a container the items FK into. - Reuse events, do not fork them. A promoted event is a normal event: same
table, same
EventService.CreateEventpath, same chat, participants, and notifications. The planner only adds thepromoted_from_plan_idback-link and the snapshottedevent_itemsboard. - Promote is one-shot. Promotion claims the plan with a conditional update
(
promoted_to_event_idflips only while NULL) so concurrent promotes collapse to one winner, then archives the source plan.
Surface area¶
Routes are mounted in backend/internal/services/plan/routes.go under the
verified group. RegisterRoutes wires three handlers: PlanHandler (canvas),
PlanGroupHandler (poll, members, share-link), and ItemHandler (item board).
Plan canvas¶
| Route | Purpose |
|---|---|
POST /plans, GET /plans, GET/PATCH/DELETE /plans/:id |
CRUD |
POST /plans/:id/archive, /restore, /leave |
Lifecycle + membership exit |
GET /plans/:id/place-cards |
Items grouped into place-cards |
PUT /plans/:id/place-cards/:plid/vote |
Vote for the leading place |
PUT /plans/:id/rsvp |
Set attendance intent (going / maybe) |
POST /plans/:id/promote |
Promote plan into an event |
GET /users/:userId/plans |
A user's visible plans on their profile |
Availability poll¶
| Route | Purpose |
|---|---|
GET /plans/:id/poll |
Read the when-poll |
POST /plans/:id/poll/options |
Propose a day or time option |
PUT /plans/:id/poll/:axis |
Vote on the day or time axis |
The leading day and time write Plan.LeadingDate / LeadingTime, prefilling the
promote sheet. The vote tables stay the source of truth
(plan_availability_service.go).
Members + share-link¶
| Route | Purpose |
|---|---|
GET /plans/:id/members |
Roster |
POST /plans/:id/members |
Add a collaborator (host-only) |
DELETE /plans/:id/members/:userId |
Remove a collaborator (host-only) |
POST /plans/:id/share-link |
Mint a join-link token (host-only) |
POST /plans/join/:token |
Join via link token |
Membership and share tokens live in plan_collaboration_service.go.
Item board¶
The stash (plan_id nil) and a plan's canvas share one items table. Capture,
enrichment, and place resolution all run here.
| Route | Purpose |
|---|---|
POST /items |
Capture a new item |
POST /items/save-location |
Save a found place to the stash or a plan |
GET /items, GET /items/:id |
List / detail |
GET /items/badge, GET /items/locations |
Triage badge + picker locations |
PATCH /items/:id, DELETE /items/:id |
Edit / delete |
POST /items/:id/triage, /restore |
Stash triage flow (resolve the "is this X?" prompt; un-archive) |
DELETE /items/:id/typed-links |
Dismiss a resolved typed link (optional ?url= to target one) |
POST /items/:id/copy, /create-plan |
Duplicate into a plan / spin off a plan |
POST /items/:id/reparse |
Re-run the parse pipeline |
POST /items/:id/photos, DELETE /items/:id/photos/:photoId |
Photos |
GET/POST /items/:id/notes, DELETE /items/:id/notes/:noteId |
Notes |
DELETE /items/:id/links/:linkId, DELETE /items/:id/typed-links |
Link children |
POST /items dedups a capture against the owner's active stash by a
content_key: the client's stable payload hash when present (image shares, whose
upload keys differ per attempt), else a server-derived hash of the normalized URL
or the text. A re-share or accidental re-add collapses onto the first item. If
the only match is archived, the re-add brings it back out of the archive
(restored to parsed, floated to the top) rather than stacking a duplicate. A
partial-unique index on (owner_id, content_key) (stash rows only) is the
backstop (item_service.go, deriveContentKey).
POST /items/save-location takes { location_id, plan_id? }. It creates a
place-role Item titled after the venue and, when a plan_id is given, syncs
the plan's PlanLocation aggregate via FindOrCreate. In the stash it dedups on
a deterministic p:<location_id> content key, so the save is a one-way,
idempotent upsert: re-saving a location resurfaces the one existing card (and
un-archives it if it had been archived) rather than duplicating it
(item_service.go, SaveLocation).
Adding a place to a plan is a taste signal: it bumps the actor's affinity for the location's category up (see Discovery → Category affinity).
Data model¶
| Table | Model | Role |
|---|---|---|
plans |
Plan |
The aggregate: status (active/archived), promoted_to_event_id, leading_date/leading_time, visibility, host_rsvp |
plan_locations |
PlanLocation |
Place-card aggregate per (plan, location); FKs the shared Location row |
plan_interest_votes |
PlanInterestVote |
One-per-user place vote; most distinct voters wins |
plan_availability_options / _votes |
PlanAvailabilityOption / Vote |
Day/time poll |
plan_collaborators |
PlanCollaborator |
Members with role (owner/member) and RSVP (going/maybe) |
plan_share_tokens |
PlanShareToken |
Share-link join tokens |
items |
Item |
Stash + canvas cards (role, links, photos, notes, resolved location_id) |
parsed_links |
(shared) | One row per canonical_url: the parsed content (title, thumbnail, resolved place, metadata, status) + a save_count. Shared across every item and user that saved the URL. |
item_links |
ItemLink |
An item's pointer at a parsed_links row (item_id, parsed_link_id, position). The API flattens the two. |
The host has no collaborator row: their attendance intent is stored on
Plan.HostRSVP, a member's on their PlanCollaborator row.
Links are normalized and shared: item_links is a thin per-item pointer,
parsed_links holds the parsed content keyed by canonical_url. So a re-parse
updates one record and every item that saved the URL sees it, and the re-hosted
thumbnail is stored once. Consequences of sharing:
- Re-parse regression guard. Because the record is shared, a degraded
re-parse (404, paywall, bot-block, empty result) must not blank good content
for everyone.
UpdateParsedLinkkeeps the existing value when an incoming field is empty; onlystatusalways reflects the latest parse state. save_countis lifetime popularity, not a live reference count: it only increments (on a new save) and is never decremented, so it reads as "how popular this was". Only derived, public content is shared; user-owned fields (title edits, notes, tags) live on theItem, so nothing private crosses.- Cleanup.
DeleteLinkGCs aparsed_linksrow inline when its last pointer goes. Item hard-deletes cascadeitem_linksat the DB level and bypass that, so thecron:parsed_link_sweepjob GCs any orphaned rows as a backstop (item_store.go:AddLink/DeleteLink/SweepOrphanParsedLinks).
Promotion¶
PlanService.PromoteFromPlan (plan_service.go) is host-only and rejects
archived or already-promoted plans. It resolves when (from the leading poll
result or the request), where (from the leading place or request), then:
- Loads and authorizes every requested board item before anything is
created. Each item runs through
PlanAccess.CanReadItem, the same gate a direct item read uses, and an id that resolves to nothing is rejected. Hosting the plan does not by itself grant read on an id the caller supplied. - Claims the plan with a conditional
promoted_to_event_idupdate. - Calls
EventService.CreateEvent, which provisions the group chat and adds the host. - In one transaction: inserts the host and accepted collaborators as
approvedparticipants (and chat members), snapshots each selected item into anEventItemviamodels.EventItemFromItem, then archives the source plan.
The request carries event_item_ids (which items become the board, in order)
and gated_item_ids (a subset hidden from non-approved viewers). On a
post-event transaction failure the claim is retained so retries surface "already
promoted" rather than minting a second event.
Snapshot, not reference
Board items are copied into event-owned event_items rows with jsonb photos
and links. The board renders self-contained and survives the source plan item
being edited or deleted. The read path lives on the event
domain (GET /events/:id/items).
Invariants & gotchas¶
- The plan service owns no place resolution. Link parsing, enrichment, and
location resolution run in the item pipeline (
capture_parsers.go,item_service.go); the canvas only validates that a suppliedlocation_idexists. Place rows themselves are the shared Location records. - Leading place and leading time are denormalized onto the plan for promote prefill; the vote tables stay authoritative.
- Archived plans reject every mutation at the service layer via
plan_access.go(Load,CanEdit,IsAcceptedMember); promotion archives implicitly and cannot be undone. - Promotion carries only accepted collaborators. Invited-but-not-accepted members do not become event participants.
ItemStore.FindByIDsis unscoped by design. It resolves any id it is handed, so every caller authorizes the returned rows itself. The one definition of "may read this item" isPlanAccess.CanReadItem: a stash item belongs to its owner alone, a plan item to anyone who may edit that plan.- Item child deletes are scoped to their parent. Photo, link, and note
deletes all carry
item_idalongside the child id, so a child id paired with an unrelated item deletes nothing. - Notes are author-owned. A note delete matches on author, so an item owner cannot remove a collaborator's note. Removing the member or the item are the available remedies; there is no per-note moderation path.
- Minting a share link is host-only, matching
AddMember/RemoveMember: handing out a join token grants membership rather than editing a plan.
Cross-links¶
- Events: the promote target and the snapshotted board read path
- Locations: the shared place rows that place-cards aggregate over
- Discovery: where a saved place is found before
save-location - Code:
backend/internal/services/plan/(plan_service.go,plan_availability_service.go,plan_collaboration_service.go,plan_access.go,item_service.go,item_handler.go,capture_parsers.go,routes.go),backend/internal/models/plan.go,plan_location.go,plan_availability.go,plan_collaborator.go,item.go