feat(phase 4d): public-listing fields so projax becomes the portfolio source of truth
Adds five additive columns on projax.items and propagates them through
every read/write path. flexsiebels.de (and any future portfolio renderer)
can now pull the public set via the MCP `list_items(public=true)` filter
and stop hard-coding project lists.
## Schema (migration 0014)
- public boolean default false (partial index when true)
- public_description text default ''
- public_live_url text default ''
- public_source_url text default ''
- public_screenshots text[] default '{}'
- items_unified view rebuilt to include the five new columns
- items_public_idx PARTIAL INDEX WHERE public = true (5% of rows)
## Store
- Item struct + scan/scanItems extended (5 cols)
- UpdateInput accepts the new fields with full-replace semantics
- new SetPublic(ids, bool) for bulk write
- SearchFilters gains Public *bool — nil = no filter
## MCP
- list_items: new `public` boolean filter (input schema + handler)
- update_item: 5 new partial-update fields (nil pointer = leave alone)
- itemView always emits the 5 fields (even when public=false) so consumers
can preview "what would publish" without a second round-trip
- 2 new integration tests against the DB
## Web
- /i/{path} grows a "Public listing" fieldset: toggle + textarea + 2 URL
inputs + screenshot list editor with add/remove rows + inline JS for
the editor. Values persist when public is off so toggling never
destroys typed-in content.
- /admin/bulk action bar gains "Make public" / "Make private" via a new
select; SQL update is a single statement per action.
- /?public=1 and /?public=0 chip parameters narrow the tree page.
Active() + QueryString() + TogglePublic() round-trip the state.
- parseScreenshotList helper trims + drops empties + preserves order
- 5 integration tests: migration landed, form round-trip, bulk action
round-trip, detail-page affordances, tree-filter narrowing
## docs/design.md §15
Documents the schema, MCP contract, UI surfaces, flexsiebels consumption
pattern, and what's NOT in scope (flexsiebels-side render, asset hosting,
approval workflows).
## Out of scope (per task brief)
- Flexsiebels rendering — separate task in m/flexsiebels.de after this ships
- Asset hosting (projax stores URLs, never bytes — same PER discipline)
- Multi-stage publish workflow (boolean is enough)
This commit is contained in:
@@ -35,6 +35,13 @@ type Item struct {
|
||||
SourceRefID *string // mai.projects.id when a 'mai-project' item_links row exists
|
||||
Tags []string
|
||||
Management []string
|
||||
// Phase 4d public-listing fields. Source of truth for the portfolio
|
||||
// flexsiebels.de + any future public consumer renders via MCP.
|
||||
Public bool
|
||||
PublicDescription string
|
||||
PublicLiveURL string
|
||||
PublicSourceURL string
|
||||
PublicScreenshots []string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -90,7 +97,8 @@ var ErrNotFound = errors.New("projax: item not found")
|
||||
|
||||
const itemsUnifiedCols = `id, kind, title, slug, paths, parent_ids, content_md, aliases,
|
||||
metadata, status, pinned, archived, start_time, end_time, source, source_ref_id,
|
||||
tags, management, created_at, updated_at`
|
||||
tags, management, public, public_description, public_live_url, public_source_url,
|
||||
public_screenshots, created_at, updated_at`
|
||||
|
||||
func scanItem(row pgx.Row) (*Item, error) {
|
||||
var it Item
|
||||
@@ -99,6 +107,7 @@ func scanItem(row pgx.Row) (*Item, error) {
|
||||
&it.Aliases, &it.Metadata, &it.Status, &it.Pinned, &it.Archived,
|
||||
&it.StartTime, &it.EndTime, &it.Source, &it.SourceRefID,
|
||||
&it.Tags, &it.Management,
|
||||
&it.Public, &it.PublicDescription, &it.PublicLiveURL, &it.PublicSourceURL, &it.PublicScreenshots,
|
||||
&it.CreatedAt, &it.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -116,6 +125,7 @@ func scanItems(rows pgx.Rows) ([]*Item, error) {
|
||||
&it.Aliases, &it.Metadata, &it.Status, &it.Pinned, &it.Archived,
|
||||
&it.StartTime, &it.EndTime, &it.Source, &it.SourceRefID,
|
||||
&it.Tags, &it.Management,
|
||||
&it.Public, &it.PublicDescription, &it.PublicLiveURL, &it.PublicSourceURL, &it.PublicScreenshots,
|
||||
&it.CreatedAt, &it.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -262,6 +272,13 @@ type UpdateInput struct {
|
||||
EndTime *time.Time
|
||||
Tags []string
|
||||
Management []string
|
||||
// Phase 4d public-listing fields. Full-replace semantics on Update —
|
||||
// callers that want partial-update use UpdatePublic instead.
|
||||
Public bool
|
||||
PublicDescription string
|
||||
PublicLiveURL string
|
||||
PublicSourceURL string
|
||||
PublicScreenshots []string
|
||||
}
|
||||
|
||||
func (s *Store) Update(ctx context.Context, id string, in UpdateInput) (*Item, error) {
|
||||
@@ -274,15 +291,22 @@ func (s *Store) Update(ctx context.Context, id string, in UpdateInput) (*Item, e
|
||||
if in.ParentIDs == nil {
|
||||
in.ParentIDs = []string{}
|
||||
}
|
||||
if in.PublicScreenshots == nil {
|
||||
in.PublicScreenshots = []string{}
|
||||
}
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
update projax.items
|
||||
set title=$2, slug=$3, parent_ids=$4, content_md=$5,
|
||||
status=$6, pinned=$7, archived=$8, start_time=$9, end_time=$10,
|
||||
tags=$11, management=$12
|
||||
tags=$11, management=$12,
|
||||
public=$13, public_description=$14, public_live_url=$15,
|
||||
public_source_url=$16, public_screenshots=$17
|
||||
where id=$1 and deleted_at is null`,
|
||||
id, in.Title, in.Slug, in.ParentIDs, in.ContentMD,
|
||||
in.Status, in.Pinned, in.Archived, in.StartTime, in.EndTime,
|
||||
in.Tags, in.Management,
|
||||
in.Public, in.PublicDescription, in.PublicLiveURL,
|
||||
in.PublicSourceURL, in.PublicScreenshots,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update: %w", err)
|
||||
@@ -290,6 +314,19 @@ func (s *Store) Update(ctx context.Context, id string, in UpdateInput) (*Item, e
|
||||
return s.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// SetPublic flips just the public boolean on selected items. Used by the
|
||||
// /admin/bulk "Make public" / "Make private" actions so callers don't have
|
||||
// to round-trip every other field.
|
||||
func (s *Store) SetPublic(ctx context.Context, ids []string, public bool) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := s.Pool.Exec(ctx,
|
||||
`update projax.items set public = $2 where id = any($1::uuid[]) and deleted_at is null`,
|
||||
ids, public)
|
||||
return err
|
||||
}
|
||||
|
||||
// Reparent replaces parent_ids entirely with the given set. Used by the
|
||||
// classify page's inline form and any "move to under X" action.
|
||||
func (s *Store) Reparent(ctx context.Context, id string, parentIDs []string) (*Item, error) {
|
||||
@@ -689,6 +726,7 @@ type SearchFilters struct {
|
||||
Q string // ILIKE prefix-match on title/slug/aliases/content_md
|
||||
HasRepo *bool // if non-nil, item must (or must not) have a gitea-repo link
|
||||
HasCalDAV *bool // if non-nil, item must (or must not) have a caldav-list link
|
||||
Public *bool // if non-nil, item must (or must not) be marked public — Phase 4d
|
||||
Limit int // 0 → no limit
|
||||
}
|
||||
|
||||
@@ -740,6 +778,9 @@ func (s *Store) ListByFilters(ctx context.Context, f SearchFilters) ([]*Item, er
|
||||
}
|
||||
conds = append(conds, fmt.Sprintf("%s (select 1 from projax.item_links l where l.item_id = u.id and l.ref_type = 'caldav-list')", op))
|
||||
}
|
||||
if f.Public != nil {
|
||||
conds = append(conds, fmt.Sprintf("u.public = %s", addArg(*f.Public)))
|
||||
}
|
||||
q := `select ` + itemsUnifiedCols + ` from projax.items_unified u where ` + joinAnd(conds) + ` order by u.paths[1] nulls last, u.slug`
|
||||
if f.Limit > 0 {
|
||||
q += fmt.Sprintf(" limit %d", f.Limit)
|
||||
|
||||
Reference in New Issue
Block a user