package service import ( "axolotl/models" "axolotl/store" "fmt" "maps" "regexp" "slices" "strings" "time" ) type nodeServiceImpl struct { store store.Store userID string } var mentionRegex = regexp.MustCompile(`@([a-z0-9_]+)`) func mentions(t string) []string { seen := make(map[string]bool) for _, m := range mentionRegex.FindAllStringSubmatch(t, -1) { seen[m[1]] = true } return slices.Collect(maps.Keys(seen)) } // --- Validation --- var ( validTypes = map[string]bool{"issue": true, "note": true, "user": true, "namespace": true} validStatuses = map[string]bool{"open": true, "done": true} validPrios = map[string]bool{"high": true, "medium": true, "low": true} ) func validateTags(tags []string) error { for _, t := range tags { if v, ok := strings.CutPrefix(t, "_type::"); ok { if !validTypes[v] { return fmt.Errorf("invalid type %q: must be one of issue, note, user, namespace", v) } } else if v, ok := strings.CutPrefix(t, "_status::"); ok { if !validStatuses[v] { return fmt.Errorf("invalid status %q: must be one of open, done", v) } } else if v, ok := strings.CutPrefix(t, "_prio::"); ok { if !validPrios[v] { return fmt.Errorf("invalid priority %q: must be one of high, medium, low", v) } } } return nil } // tagValue returns the value of the first tag with the given prefix, or "". func tagValue(tags []string, prefix string) string { for _, t := range tags { if v, ok := strings.CutPrefix(t, prefix); ok { return v } } return "" } // --- Query --- func (s *nodeServiceImpl) GetByID(id string) (*models.Node, error) { return s.store.GetNode(id) } func (s *nodeServiceImpl) List(filter ListFilter) ([]*models.Node, error) { var relFilters []*models.Rel for _, ri := range filter.Rels { id, ok := s.lookupRelTarget(ri.Type, ri.Target) if !ok { return nil, nil // named target doesn't exist; no nodes can match } relFilters = append(relFilters, &models.Rel{Type: ri.Type, Target: id}) } return s.store.FindNodes(filter.Tags, relFilters) } // --- Lifecycle --- func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) { // Copy tags so we can extend without mutating the input. tags := make([]string, len(input.Tags)) copy(tags, input.Tags) // Apply defaults. nodeType := tagValue(tags, "_type::") if nodeType == "" { nodeType = "issue" tags = append(tags, "_type::issue") } if nodeType == "issue" && tagValue(tags, "_status::") == "" { tags = append(tags, "_status::open") } // Validate special tags. if err := validateTags(tags); err != nil { return nil, err } // Build initial relation map from rels input. rels := make(map[models.RelType][]string) hasNamespace := false for _, ri := range input.Rels { if ri.Type == models.RelInNamespace { hasNamespace = true } rels[ri.Type] = append(rels[ri.Type], ri.Target) } if !hasNamespace { rels[models.RelInNamespace] = []string{s.userID} } id, err := s.store.GenerateID() if err != nil { return nil, err } err = s.store.Transaction(func(st store.Store) error { now := time.Now().UTC().Format(time.RFC3339) if err := st.AddNode(id, input.Title, input.Content, input.DueDate, now, now); err != nil { return err } for _, m := range mentions(input.Title + " " + input.Content) { userID, err := s.resolveUserRef(st, m) if err != nil { return err } if err := st.AddEdge(id, userID, models.RelMentions); err != nil { return err } } for _, t := range tags { if err := st.AddTag(id, t); err != nil { return err } } hasCreated := false for rt, tgts := range rels { for _, tgt := range tgts { if rt == models.RelCreated { hasCreated = true } resolved, err := s.resolveRelTarget(st, RelInput{Type: rt, Target: tgt}) if err != nil { return err } if err := st.AddEdge(id, resolved, rt); err != nil { return err } } } if !hasCreated { userID, err := s.resolveUserRef(st, s.userID) if err != nil { return err } if err := st.AddEdge(id, userID, models.RelCreated); err != nil { return err } } return nil }) if err != nil { return nil, err } return s.store.GetNode(id) } func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, error) { // Enforce blocking constraint before allowing status=done. for _, t := range input.AddTags { if t == "_status::done" { if err := s.checkBlockers(id); err != nil { return nil, err } break } } // Validate tags being added. if err := validateTags(input.AddTags); err != nil { return nil, err } err := s.store.Transaction(func(st store.Store) error { current, err := st.GetNode(id) if err != nil { return err } // Determine final scalar values. title, content, dueDate := current.Title, current.Content, current.DueDate if input.Title != nil { title = *input.Title } if input.Content != nil { content = *input.Content } if input.DueDate != nil { dueDate = *input.DueDate } now := time.Now().UTC().Format(time.RFC3339) if err := st.UpdateNode(id, title, content, dueDate, now); err != nil { return err } // Compute new tag set using the model's AddTag/RemoveTag to preserve // property-prefix replacement semantics. tmp := models.NewNode() for _, t := range current.Tags() { tmp.AddTag(t) } for _, t := range input.AddTags { tmp.AddTag(t) } for _, t := range input.RemoveTags { tmp.RemoveTag(t) //nolint: the error is only for _type:: removal, which is intentionally prevented } // Sync tags to store. currentTags, newTags := current.Tags(), tmp.Tags() for _, t := range currentTags { if !slices.Contains(newTags, t) { if err := st.RemoveTag(id, t); err != nil { return err } } } for _, t := range newTags { if !slices.Contains(currentTags, t) { if err := st.AddTag(id, t); err != nil { return err } } } // Sync mention edges when title or content changed. if input.Title != nil || input.Content != nil { if err := s.syncMentions(st, id, current, title, content); err != nil { return err } } currentRels := current.Relations() for _, ri := range input.AddRels { resolved, err := s.resolveRelTarget(st, ri) if err != nil { return err } // Single-value relations replace the previous target. if ri.Type == models.RelAssignee || ri.Type == models.RelInNamespace { for _, oldTgt := range currentRels[string(ri.Type)] { if err := st.RemoveEdge(id, oldTgt, ri.Type); err != nil { return err } } } if err := st.AddEdge(id, resolved, ri.Type); err != nil { return err } } for _, ri := range input.RemoveRels { resolved, err := s.resolveRelTarget(st, ri) if err != nil { return err } if err := st.RemoveEdge(id, resolved, ri.Type); err != nil { return err } } return nil }) if err != nil { return nil, err } return s.store.GetNode(id) } func (s *nodeServiceImpl) Delete(id string) error { return s.store.DeleteNode(id) } // --- User management --- func (s *nodeServiceImpl) AddUser(name string) (*models.Node, error) { var id string err := s.store.Transaction(func(st store.Store) error { var err error id, err = s.ensureUser(st, name) return err }) if err != nil { return nil, err } return s.store.GetNode(id) } func (s *nodeServiceImpl) ListUsers() ([]*models.Node, error) { return s.store.FindNodes([]string{"_type::user"}, nil) } // --- Internal helpers --- func (s *nodeServiceImpl) checkBlockers(id string) error { // Find all nodes that declare a blocks → id relation (i.e., open blockers). blockers, err := s.store.FindNodes(nil, []*models.Rel{{Type: models.RelBlocks, Target: id}}) if err != nil { return err } var blocking []string for _, b := range blockers { if b.GetProperty("status") == "open" { blocking = append(blocking, b.ID) } } if len(blocking) > 0 { return fmt.Errorf("cannot close: blocked by %v", blocking) } return nil } func (s *nodeServiceImpl) syncMentions(st store.Store, id string, current *models.Node, newTitle, newContent string) error { existingMentionIDs := make(map[string]bool) for _, uid := range current.Relations()[string(models.RelMentions)] { existingMentionIDs[uid] = true } mentionedUserIDs := make(map[string]bool) for _, m := range mentions(newTitle + " " + newContent) { userID, err := s.resolveUserRef(st, m) if err != nil { return err } mentionedUserIDs[userID] = true if !existingMentionIDs[userID] { if err := st.AddEdge(id, userID, models.RelMentions); err != nil { return err } } } for uid := range existingMentionIDs { if !mentionedUserIDs[uid] { if err := st.RemoveEdge(id, uid, models.RelMentions); err != nil { return err } } } return nil } // resolveRelTarget resolves a RelInput target to a node ID, auto-creating users // and namespaces as needed. Use only inside a transaction. func (s *nodeServiceImpl) resolveRelTarget(st store.Store, ri RelInput) (string, error) { switch ri.Type { case models.RelAssignee, models.RelCreated, models.RelMentions: return s.resolveUserRef(st, ri.Target) case models.RelInNamespace: return s.resolveNamespaceRef(st, ri.Target) default: return ri.Target, nil // blocks/subtask/related expect raw node IDs } } // lookupRelTarget resolves a filter target to a node ID without creating anything. // Returns ("", false) when the target doesn't exist. func (s *nodeServiceImpl) lookupRelTarget(relType models.RelType, target string) (string, bool) { if exists, _ := s.store.NodeExists(target); exists { return target, true } var nodeType string switch relType { case models.RelAssignee, models.RelCreated, models.RelMentions: nodeType = "user" case models.RelInNamespace: nodeType = "namespace" default: return target, true } id, err := s.resolveIDByNameAndType(s.store, target, nodeType) if err != nil || id == "" { return "", false } return id, true } // resolveIDByNameAndType finds a node by title and _type tag without creating it. func (s *nodeServiceImpl) resolveIDByNameAndType(st store.Store, title, nodeType string) (string, error) { nodes, err := st.FindNodes([]string{"_type::" + nodeType}, nil) if err != nil { return "", err } for _, n := range nodes { if n.Title == title { return n.ID, nil } } return "", nil } func (s *nodeServiceImpl) resolveUserRef(st store.Store, ref string) (string, error) { if exists, _ := st.NodeExists(ref); exists { return ref, nil } return s.ensureUser(st, ref) } func (s *nodeServiceImpl) ensureUser(st store.Store, username string) (string, error) { userID, err := s.resolveIDByNameAndType(st, username, "user") if err != nil { return "", err } if userID != "" { return userID, nil } id, err := st.GenerateID() if err != nil { return "", err } now := time.Now().UTC().Format(time.RFC3339) if err := st.AddNode(id, username, "", "", now, now); err != nil { return "", err } if err := st.AddTag(id, "_type::user"); err != nil { return "", err } return id, nil } func (s *nodeServiceImpl) resolveNamespaceRef(st store.Store, ref string) (string, error) { if exists, _ := st.NodeExists(ref); exists { return ref, nil } return s.ensureNamespace(st, ref) } func (s *nodeServiceImpl) ensureNamespace(st store.Store, name string) (string, error) { nsID, err := s.resolveIDByNameAndType(st, name, "namespace") if err != nil { return "", err } if nsID != "" { return nsID, nil } id, err := st.GenerateID() if err != nil { return "", err } now := time.Now().UTC().Format(time.RFC3339) if err := st.AddNode(id, name, "", "", now, now); err != nil { return "", err } if err := st.AddTag(id, "_type::namespace"); err != nil { return "", err } if err := st.AddEdge(id, id, models.RelInNamespace); err != nil { return "", err } userID, err := s.resolveUserRef(st, s.userID) if err != nil { return "", err } if err := st.AddEdge(id, userID, models.RelCreated); err != nil { return "", err } return id, nil }