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 |
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.
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:
- 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 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 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.
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