package services // UserViewService is the CRUD layer for paliad.user_views โ€” saved Custom // View definitions per user. // // Design: docs/design-data-display-model-2026-05-06.md ยง5. // // Visibility: every read and write is scoped to the calling user via the // RLS policy `user_views_owner_all` on auth.uid() = user_id. The service // also AND-joins user_id in the SQL for defense-in-depth (RLS can be // disabled in tests, the code-level check still holds). import ( "context" "database/sql" "encoding/json" "errors" "fmt" "regexp" "time" "github.com/google/uuid" "github.com/jmoiron/sqlx" ) // UserView is the persisted shape of a saved Custom View. type UserView struct { ID uuid.UUID `db:"id" json:"id"` UserID uuid.UUID `db:"user_id" json:"user_id"` Slug string `db:"slug" json:"slug"` Name string `db:"name" json:"name"` Icon *string `db:"icon" json:"icon,omitempty"` FilterSpec json.RawMessage `db:"filter_spec" json:"filter_spec"` RenderSpec json.RawMessage `db:"render_spec" json:"render_spec"` SortOrder int `db:"sort_order" json:"sort_order"` ShowCount bool `db:"show_count" json:"show_count"` LastUsedAt *time.Time `db:"last_used_at" json:"last_used_at,omitempty"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } // UserViewService manages paliad.user_views. type UserViewService struct { db *sqlx.DB } // NewUserViewService wires the service to the pool. func NewUserViewService(db *sqlx.DB) *UserViewService { return &UserViewService{db: db} } // CreateUserViewInput is the payload for Create. type CreateUserViewInput struct { Slug string Name string Icon *string FilterSpec FilterSpec RenderSpec RenderSpec ShowCount bool // SortOrder is server-assigned (MAX+1) on create โ€” callers cannot set it. } // UpdateUserViewInput is the partial-update payload. All fields are // optional; nil means "no change". type UpdateUserViewInput struct { Slug *string Name *string Icon *string // pointer-to-pointer-of-string would be clearer for "clear vs unchanged"; we treat *string{""} as "clear" FilterSpec *FilterSpec RenderSpec *RenderSpec SortOrder *int ShowCount *bool } // slugRE caps slugs to URL-safe lowercase. Same shape as the migration // comment promises (^[a-z0-9][a-z0-9-]{0,62}$). var slugRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`) // ErrUserViewSlugTaken signals "slug already exists for this user". The // HTTP layer maps this to 409. var ErrUserViewSlugTaken = errors.New("user_view slug already exists for this user") // ListForUser returns the caller's saved views, ordered by sort_order ASC // then name. Result is the same shape /api/user-views returns to the // frontend on app load (sidebar hydration). func (s *UserViewService) ListForUser(ctx context.Context, userID uuid.UUID) ([]UserView, error) { var rows []UserView err := s.db.SelectContext(ctx, &rows, ` SELECT id, user_id, slug, name, icon, filter_spec, render_spec, sort_order, show_count, last_used_at, created_at, updated_at FROM paliad.user_views WHERE user_id = $1 ORDER BY sort_order ASC, name ASC`, userID) if err != nil { return nil, fmt.Errorf("list user_views: %w", err) } return rows, nil } // GetBySlug fetches one view by slug. Returns (nil, nil) when the slug // is unknown for this user. func (s *UserViewService) GetBySlug(ctx context.Context, userID uuid.UUID, slug string) (*UserView, error) { var v UserView err := s.db.GetContext(ctx, &v, ` SELECT id, user_id, slug, name, icon, filter_spec, render_spec, sort_order, show_count, last_used_at, created_at, updated_at FROM paliad.user_views WHERE user_id = $1 AND slug = $2`, userID, slug) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, fmt.Errorf("get user_view: %w", err) } return &v, nil } // GetByID fetches one view by id. Same nil-on-miss semantic. func (s *UserViewService) GetByID(ctx context.Context, userID, id uuid.UUID) (*UserView, error) { var v UserView err := s.db.GetContext(ctx, &v, ` SELECT id, user_id, slug, name, icon, filter_spec, render_spec, sort_order, show_count, last_used_at, created_at, updated_at FROM paliad.user_views WHERE user_id = $1 AND id = $2`, userID, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, fmt.Errorf("get user_view: %w", err) } return &v, nil } // Create persists a new view for the caller. Server-assigns sort_order // to MAX(existing)+1 inside the same tx so two parallel creates don't // collide. func (s *UserViewService) Create(ctx context.Context, userID uuid.UUID, input CreateUserViewInput) (*UserView, error) { if err := validateCreateInput(input); err != nil { return nil, err } filterJSON, err := MarshalFilterSpec(input.FilterSpec) if err != nil { return nil, err } renderJSON, err := MarshalRenderSpec(input.RenderSpec) if err != nil { return nil, err } tx, err := s.db.BeginTxx(ctx, nil) if err != nil { return nil, fmt.Errorf("begin tx: %w", err) } defer func() { _ = tx.Rollback() }() var nextSortOrder int if err := tx.GetContext(ctx, &nextSortOrder, ` SELECT COALESCE(MAX(sort_order), -1) + 1 FROM paliad.user_views WHERE user_id = $1`, userID); err != nil { return nil, fmt.Errorf("compute next sort_order: %w", err) } var v UserView err = tx.GetContext(ctx, &v, ` INSERT INTO paliad.user_views (user_id, slug, name, icon, filter_spec, render_spec, sort_order, show_count) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, user_id, slug, name, icon, filter_spec, render_spec, sort_order, show_count, last_used_at, created_at, updated_at`, userID, input.Slug, input.Name, input.Icon, filterJSON, renderJSON, nextSortOrder, input.ShowCount) if err != nil { if isUniqueViolation(err) { return nil, fmt.Errorf("%w: %s", ErrUserViewSlugTaken, input.Slug) } return nil, fmt.Errorf("insert user_view: %w", err) } if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit user_view create: %w", err) } return &v, nil } // Update applies a partial update to an existing view. Returns // (nil, nil) if the row doesn't exist for this user. func (s *UserViewService) Update(ctx context.Context, userID, id uuid.UUID, input UpdateUserViewInput) (*UserView, error) { current, err := s.GetByID(ctx, userID, id) if err != nil { return nil, err } if current == nil { return nil, nil } // Coalesce input over current. slug := current.Slug if input.Slug != nil { slug = *input.Slug } if err := validateSlug(slug); err != nil { return nil, err } name := current.Name if input.Name != nil { name = *input.Name } if err := validateName(name); err != nil { return nil, err } icon := current.Icon if input.Icon != nil { s := *input.Icon if s == "" { icon = nil } else { icon = &s } } filterJSON := []byte(current.FilterSpec) if input.FilterSpec != nil { b, err := MarshalFilterSpec(*input.FilterSpec) if err != nil { return nil, err } filterJSON = b } renderJSON := []byte(current.RenderSpec) if input.RenderSpec != nil { b, err := MarshalRenderSpec(*input.RenderSpec) if err != nil { return nil, err } renderJSON = b } sortOrder := current.SortOrder if input.SortOrder != nil { sortOrder = *input.SortOrder } showCount := current.ShowCount if input.ShowCount != nil { showCount = *input.ShowCount } var v UserView err = s.db.GetContext(ctx, &v, ` UPDATE paliad.user_views SET slug = $3, name = $4, icon = $5, filter_spec = $6, render_spec = $7, sort_order = $8, show_count = $9, updated_at = now() WHERE user_id = $1 AND id = $2 RETURNING id, user_id, slug, name, icon, filter_spec, render_spec, sort_order, show_count, last_used_at, created_at, updated_at`, userID, id, slug, name, icon, filterJSON, renderJSON, sortOrder, showCount) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { if isUniqueViolation(err) { return nil, fmt.Errorf("%w: %s", ErrUserViewSlugTaken, slug) } return nil, fmt.Errorf("update user_view: %w", err) } return &v, nil } // Delete removes a saved view. Single Yes/No modal on the frontend // (Q25 lock-in); no audit emit (these are personal working state). // Returns (false, nil) when the row didn't exist. func (s *UserViewService) Delete(ctx context.Context, userID, id uuid.UUID) (bool, error) { res, err := s.db.ExecContext(ctx, ` DELETE FROM paliad.user_views WHERE user_id = $1 AND id = $2`, userID, id) if err != nil { return false, fmt.Errorf("delete user_view: %w", err) } n, err := res.RowsAffected() if err != nil { return false, fmt.Errorf("delete user_view rows affected: %w", err) } return n > 0, nil } // Touch updates last_used_at to now. Fire-and-forget from the page // handler โ€” no error surface to the user. func (s *UserViewService) Touch(ctx context.Context, userID, id uuid.UUID) error { _, err := s.db.ExecContext(ctx, ` UPDATE paliad.user_views SET last_used_at = now() WHERE user_id = $1 AND id = $2`, userID, id) if err != nil { return fmt.Errorf("touch user_view: %w", err) } return nil } // MostRecent returns the caller's most-recently-used view, or nil if // the user has none / has never opened one. Used for the /views landing // (Q10 most-recently-used default). func (s *UserViewService) MostRecent(ctx context.Context, userID uuid.UUID) (*UserView, error) { var v UserView err := s.db.GetContext(ctx, &v, ` SELECT id, user_id, slug, name, icon, filter_spec, render_spec, sort_order, show_count, last_used_at, created_at, updated_at FROM paliad.user_views WHERE user_id = $1 AND last_used_at IS NOT NULL ORDER BY last_used_at DESC LIMIT 1`, userID) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, fmt.Errorf("most-recent user_view: %w", err) } return &v, nil } // ============================================================================ // Validators (slug + name + create input) // ============================================================================ func validateSlug(slug string) error { if slug == "" { return fmt.Errorf("%w: slug is required", ErrInvalidInput) } if !slugRE.MatchString(slug) { return fmt.Errorf("%w: slug must match ^[a-z0-9][a-z0-9-]{0,62}$ (got %q)", ErrInvalidInput, slug) } if IsReservedUserViewSlug(slug) { return fmt.Errorf("%w: slug %q is reserved", ErrInvalidInput, slug) } return nil } func validateName(name string) error { if name == "" { return fmt.Errorf("%w: name is required", ErrInvalidInput) } // 1-character names are fine (some users may want 1-letter shortcuts). // 200 is the codebase-wide cap (matches Notes / Checklists). if len(name) > 200 { return fmt.Errorf("%w: name exceeds 200 characters", ErrInvalidInput) } return nil } func validateCreateInput(input CreateUserViewInput) error { if err := validateSlug(input.Slug); err != nil { return err } if err := validateName(input.Name); err != nil { return err } if input.Icon != nil && len(*input.Icon) > 64 { return fmt.Errorf("%w: icon key exceeds 64 characters", ErrInvalidInput) } if err := input.FilterSpec.Validate(); err != nil { return err } if err := input.RenderSpec.Validate(); err != nil { return err } return nil } // isUniqueViolation is shared with event_type_service.go (defined there).