Files
projax/store/store.go
mAi 96b61f7ed4 feat(phase 2 caldav): list + link + create CalDAV calendars
m's CalDAV server (dav.msbls.de, SabreDAV) now feeds projax via a thin
read-only-plus-create-on-demand integration. No background sync; tasks
fetched live on detail-page render.

New caldav/ package
- ListCalendars (PROPFIND Depth: 1, filters non-calendar collections)
- ListTodos (REPORT calendar-query for VTODO; hand-rolled iCalendar
  parser for UID/SUMMARY/STATUS/DUE/PRIORITY/LAST-MODIFIED — RFC 5545
  line-folding aware)
- CreateCalendar (MKCALENDAR, 405 → ErrCalendarExists for the "link
  instead" branch)
- httptest-stubbed tests cover all four paths.

Store
- ItemLink shape + LinksByType / LinksByRefType / AddLink / DeleteLink.
  AddLink upserts on (item_id, ref_type, ref_id, rel) so re-linking the
  same calendar is idempotent.

Web
- GET /admin/caldav — discovery + auto-suggested matches + manual
  linker. Suggestion = lowercased displayname == projax slug or title.
- POST /admin/caldav/link — insert item_links row.
- POST /admin/caldav/unlink — delete by link id.
- POST /i/{path}/caldav/create — MKCALENDAR at <base>/<slug>/, then
  AddLink. On 405 (already exists), fall back to link-only.
- Detail page Tasks section: per-calendar block with open VTODOs +
  collapsed completed (30d window). Errors per calendar logged and
  skipped, so one bad calendar does not blank the page.
- nav adds /admin/caldav link.

main.go
- DAV_URL + DAV_USER + DAV_PASSWORD optional. Missing DAV_URL → CalDAV
  off (admin page renders "not configured" notice). DAV_URL set but
  user/pass missing → fail fast at boot.

docs/design.md gains §5 documenting the integration shape.
deploy/dokploy.yaml lists the two new secrets + the env var.

Phase 2.b (writeback / two-way / background sync) is parked.
2026-05-15 16:57:43 +02:00

446 lines
13 KiB
Go

package store
import (
"context"
"errors"
"fmt"
"slices"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Item is the unified row shape served by projax.items_unified. Phase 1.5
// collapsed the area/project distinction (kind keeps the slot for future
// types but 'area' is no longer a special value) and extended the tree to
// a DAG: an item can have zero or more parents and surface under multiple
// paths simultaneously.
type Item struct {
ID string
Kind []string
Title string
Slug string
Paths []string // sorted, deduped — one entry per ancestor lineage
ParentIDs []string
ContentMD string
Aliases []string
Metadata map[string]any
Status string
Pinned bool
Archived bool
StartTime *time.Time
EndTime *time.Time
Source string // always "projax" after Phase 1.5; kept for forward-compat
SourceRefID *string // mai.projects.id when a 'mai-project' item_links row exists
Tags []string
Management []string
CreatedAt time.Time
UpdatedAt time.Time
}
// IsRoot reports whether this item sits at the top of the DAG (no parents).
func (it *Item) IsRoot() bool { return len(it.ParentIDs) == 0 }
// PrimaryPath returns the first path (alphabetically) for routing & display.
// Empty string when paths is empty (defensive — every persisted row has at
// least one path).
func (it *Item) PrimaryPath() string {
if len(it.Paths) == 0 {
return ""
}
return it.Paths[0]
}
// OtherPaths returns all paths except the primary one, for the "also at: …"
// breadcrumb on the detail page.
func (it *Item) OtherPaths() []string {
if len(it.Paths) <= 1 {
return nil
}
return it.Paths[1:]
}
// HasManagement reports whether the given mode (e.g. "mai") is set on the item.
func (it *Item) HasManagement(mode string) bool { return slices.Contains(it.Management, mode) }
// HasTag reports whether the item carries the given tag.
func (it *Item) HasTag(tag string) bool { return slices.Contains(it.Tags, tag) }
// Editable is preserved for template forward-compat. All rows are editable
// in projax after the mai.projects unification.
func (it *Item) Editable() bool { return true }
// SourceRefDeref returns the source ref id (empty string if nil) for templates.
func (it *Item) SourceRefDeref() string {
if it.SourceRefID == nil {
return ""
}
return *it.SourceRefID
}
// Store wraps a pgx pool with the queries projax needs.
type Store struct {
Pool *pgxpool.Pool
}
func New(pool *pgxpool.Pool) *Store { return &Store{Pool: pool} }
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`
func scanItem(row pgx.Row) (*Item, error) {
var it Item
if err := row.Scan(
&it.ID, &it.Kind, &it.Title, &it.Slug, &it.Paths, &it.ParentIDs, &it.ContentMD,
&it.Aliases, &it.Metadata, &it.Status, &it.Pinned, &it.Archived,
&it.StartTime, &it.EndTime, &it.Source, &it.SourceRefID,
&it.Tags, &it.Management,
&it.CreatedAt, &it.UpdatedAt,
); err != nil {
return nil, err
}
return &it, nil
}
func scanItems(rows pgx.Rows) ([]*Item, error) {
defer rows.Close()
var out []*Item
for rows.Next() {
var it Item
if err := rows.Scan(
&it.ID, &it.Kind, &it.Title, &it.Slug, &it.Paths, &it.ParentIDs, &it.ContentMD,
&it.Aliases, &it.Metadata, &it.Status, &it.Pinned, &it.Archived,
&it.StartTime, &it.EndTime, &it.Source, &it.SourceRefID,
&it.Tags, &it.Management,
&it.CreatedAt, &it.UpdatedAt,
); err != nil {
return nil, err
}
out = append(out, &it)
}
return out, rows.Err()
}
// ListAll returns every visible row from items_unified. Caller groups by tree.
func (s *Store) ListAll(ctx context.Context) ([]*Item, error) {
rows, err := s.Pool.Query(ctx,
`select `+itemsUnifiedCols+` from projax.items_unified order by paths[1]`)
if err != nil {
return nil, err
}
return scanItems(rows)
}
// GetByPath looks up a single item by any of its paths. Multi-parent items
// can be accessed via /i/work.paliad or /i/dev.paliad interchangeably.
func (s *Store) GetByPath(ctx context.Context, path string) (*Item, error) {
row := s.Pool.QueryRow(ctx,
`select `+itemsUnifiedCols+` from projax.items_unified where $1 = any(paths) limit 1`, path)
it, err := scanItem(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return it, nil
}
// GetByID looks up a single projax-native item by uuid.
func (s *Store) GetByID(ctx context.Context, id string) (*Item, error) {
row := s.Pool.QueryRow(ctx,
`select `+itemsUnifiedCols+` from projax.items_unified where id = $1`, id)
it, err := scanItem(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return it, nil
}
// Roots returns the top-level items (no parents), ordered by slug.
func (s *Store) Roots(ctx context.Context) ([]*Item, error) {
rows, err := s.Pool.Query(ctx,
`select `+itemsUnifiedCols+` from projax.items_unified
where cardinality(parent_ids) = 0
order by slug`)
if err != nil {
return nil, err
}
return scanItems(rows)
}
// MaiOrphans lists mai-managed items that landed at root and need m to
// re-parent them. This includes both backfilled items that the heuristic
// misplaced and brand-new mai.projects rows created by mai code (which the
// reverse sync trigger drops at root by design).
func (s *Store) MaiOrphans(ctx context.Context) ([]*Item, error) {
rows, err := s.Pool.Query(ctx,
`select `+itemsUnifiedCols+` from projax.items_unified
where cardinality(parent_ids) = 0 and 'mai' = any(management)
order by slug`)
if err != nil {
return nil, err
}
return scanItems(rows)
}
// CreateInput captures the editable surface of a projax-native item.
type CreateInput struct {
Kind []string
Title string
Slug string
ParentIDs []string
ContentMD string
Status string
Pinned bool
StartTime *time.Time
EndTime *time.Time
Tags []string
Management []string
Metadata map[string]any
}
func (s *Store) Create(ctx context.Context, in CreateInput) (*Item, error) {
if len(in.Kind) == 0 {
return nil, errors.New("kind required")
}
if in.Title == "" {
return nil, errors.New("title required")
}
if in.Slug == "" {
return nil, errors.New("slug required")
}
if in.Status == "" {
in.Status = "active"
}
if in.Tags == nil {
in.Tags = []string{}
}
if in.Management == nil {
in.Management = []string{}
}
if in.ParentIDs == nil {
in.ParentIDs = []string{}
}
metadata := in.Metadata
if metadata == nil {
metadata = map[string]any{}
}
var id string
err := s.Pool.QueryRow(ctx, `
insert into projax.items
(kind, title, slug, parent_ids, content_md, status, pinned, start_time, end_time,
tags, management, metadata)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
returning id`,
in.Kind, in.Title, in.Slug, in.ParentIDs, in.ContentMD, in.Status, in.Pinned, in.StartTime, in.EndTime,
in.Tags, in.Management, metadata,
).Scan(&id)
if err != nil {
return nil, fmt.Errorf("insert: %w", err)
}
return s.GetByID(ctx, id)
}
// UpdateInput captures the editable surface of an existing projax-native item.
type UpdateInput struct {
Title string
Slug string
ParentIDs []string
ContentMD string
Status string
Pinned bool
Archived bool
StartTime *time.Time
EndTime *time.Time
Tags []string
Management []string
}
func (s *Store) Update(ctx context.Context, id string, in UpdateInput) (*Item, error) {
if in.Tags == nil {
in.Tags = []string{}
}
if in.Management == nil {
in.Management = []string{}
}
if in.ParentIDs == nil {
in.ParentIDs = []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
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,
)
if err != nil {
return nil, fmt.Errorf("update: %w", err)
}
return s.GetByID(ctx, id)
}
// 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) {
if parentIDs == nil {
parentIDs = []string{}
}
_, err := s.Pool.Exec(ctx,
`update projax.items set parent_ids = $2 where id = $1 and deleted_at is null`,
id, parentIDs,
)
if err != nil {
return nil, fmt.Errorf("reparent: %w", err)
}
return s.GetByID(ctx, id)
}
// AddParent appends a parent without disturbing existing ones — used by the
// multi-parent UI to surface a project under a second branch.
func (s *Store) AddParent(ctx context.Context, id, parentID string) (*Item, error) {
_, err := s.Pool.Exec(ctx, `
update projax.items
set parent_ids = case
when $2::uuid = any(parent_ids) then parent_ids
else array_append(parent_ids, $2::uuid)
end
where id = $1 and deleted_at is null`,
id, parentID,
)
if err != nil {
return nil, fmt.Errorf("add parent: %w", err)
}
return s.GetByID(ctx, id)
}
// ItemLink mirrors a projax.item_links row — external pointer attached to
// an item (calendar URL, gitea repo, mai project id, …).
type ItemLink struct {
ID string
ItemID string
RefType string
RefID string
Rel string
Note *string
Metadata map[string]any
CreatedAt time.Time
}
// LinksByType returns every item_link of the given ref_type for one item.
func (s *Store) LinksByType(ctx context.Context, itemID, refType string) ([]*ItemLink, error) {
rows, err := s.Pool.Query(ctx, `
select id, item_id, ref_type, ref_id, rel, note, metadata, created_at
from projax.item_links
where item_id = $1 and ref_type = $2
order by created_at`, itemID, refType)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*ItemLink
for rows.Next() {
var l ItemLink
if err := rows.Scan(&l.ID, &l.ItemID, &l.RefType, &l.RefID, &l.Rel, &l.Note, &l.Metadata, &l.CreatedAt); err != nil {
return nil, err
}
out = append(out, &l)
}
return out, rows.Err()
}
// LinksByRefType returns every item_link of the given ref_type across the
// whole schema. Used by /admin/caldav to find already-linked calendars.
func (s *Store) LinksByRefType(ctx context.Context, refType string) ([]*ItemLink, error) {
rows, err := s.Pool.Query(ctx, `
select id, item_id, ref_type, ref_id, rel, note, metadata, created_at
from projax.item_links
where ref_type = $1
order by created_at`, refType)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*ItemLink
for rows.Next() {
var l ItemLink
if err := rows.Scan(&l.ID, &l.ItemID, &l.RefType, &l.RefID, &l.Rel, &l.Note, &l.Metadata, &l.CreatedAt); err != nil {
return nil, err
}
out = append(out, &l)
}
return out, rows.Err()
}
// AddLink inserts an item_link. ON CONFLICT (item_id, ref_type, ref_id, rel)
// the existing row is returned untouched.
func (s *Store) AddLink(ctx context.Context, itemID, refType, refID, rel string, metadata map[string]any) (*ItemLink, error) {
if rel == "" {
rel = "contains"
}
if metadata == nil {
metadata = map[string]any{}
}
var id string
err := s.Pool.QueryRow(ctx, `
insert into projax.item_links (item_id, ref_type, ref_id, rel, metadata)
values ($1, $2, $3, $4, $5)
on conflict (item_id, ref_type, ref_id, rel) do update set metadata = excluded.metadata
returning id`,
itemID, refType, refID, rel, metadata,
).Scan(&id)
if err != nil {
return nil, fmt.Errorf("add link: %w", err)
}
row := s.Pool.QueryRow(ctx, `
select id, item_id, ref_type, ref_id, rel, note, metadata, created_at
from projax.item_links where id = $1`, id)
var l ItemLink
if err := row.Scan(&l.ID, &l.ItemID, &l.RefType, &l.RefID, &l.Rel, &l.Note, &l.Metadata, &l.CreatedAt); err != nil {
return nil, err
}
return &l, nil
}
// DeleteLink removes a single item_link by id.
func (s *Store) DeleteLink(ctx context.Context, id string) error {
_, err := s.Pool.Exec(ctx, `delete from projax.item_links where id = $1`, id)
return err
}
// AllTags returns the deduplicated tag vocabulary in alphabetical order.
// Used by the tree page filter chips.
func (s *Store) AllTags(ctx context.Context) ([]string, error) {
rows, err := s.Pool.Query(ctx,
`select distinct unnest(tags) as tag from projax.items where deleted_at is null order by tag`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// SoftDelete marks a projax-native item deleted_at = now().
func (s *Store) SoftDelete(ctx context.Context, id string) error {
_, err := s.Pool.Exec(ctx, `update projax.items set deleted_at = now() where id = $1`, id)
return err
}