Skip to content

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

Capture
Item
stash: plan_id nil
↓ add to / create plan
Plan canvas
place-cards + interest votes + when-poll
↓ Promote (reuses EventService.CreateEvent)
Event + EventItem[]
snapshotted read-only board
Promoting archives the source plan (promoted_to_event_id set); archived plans are read-only.

Three rules carry most of the design:

  1. Grouping is by Location. Items sharing a location_id render as one place-card. PlanLocation is the per-(plan, location) aggregate (vote and item counts), not a container the items FK into.
  2. Reuse events, do not fork them. A promoted event is a normal event: same table, same EventService.CreateEvent path, same chat, participants, and notifications. The planner only adds the promoted_from_plan_id back-link and the snapshotted event_items board.
  3. Promote is one-shot. Promotion claims the plan with a conditional update (promoted_to_event_id flips 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).

Route Purpose
GET /plans/:id/members Roster
POST /plans/:id/members Add a collaborator
DELETE /plans/:id/members/:userId Remove a collaborator
POST /plans/:id/share-link Mint a join-link token
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. 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 (item_service.go).

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. UpdateParsedLink keeps the existing value when an incoming field is empty; only status always reflects the latest parse state.
  • save_count is 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 the Item, so nothing private crosses.
  • Cleanup. DeleteLink GCs a parsed_links row inline when its last pointer goes. Item hard-deletes cascade item_links at the DB level and bypass that, so the cron:parsed_link_sweep job 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:

  1. Claims the plan with a conditional promoted_to_event_id update.
  2. Calls EventService.CreateEvent, which provisions the group chat and adds the host.
  3. In one transaction: inserts the host and accepted collaborators as approved participants (and chat members), snapshots each selected item into an EventItem via models.EventItemFromItem, then archives the source plan.

The request carries shared_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 supplied location_id exists. 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.
  • 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