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 (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. 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. 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.
  2. Claims the plan with a conditional promoted_to_event_id update.
  3. Calls EventService.CreateEvent, which provisions the group chat and adds the host.
  4. 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 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 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.
  • ItemStore.FindByIDs is 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" is PlanAccess.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_id alongside 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.
  • 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