package service import ( "axolotl/models" "axolotl/store" "maps" "regexp" "slices" "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)) } func (s *nodeServiceImpl) GetByID(id string) (*models.Node, error) { return s.store.GetNode(id) } func (s *nodeServiceImpl) Exists(id string) (bool, error) { return s.store.NodeExists(id) } func (s *nodeServiceImpl) Delete(id string) error { return s.store.DeleteNode(id) } func (s *nodeServiceImpl) CanClose(id string) (bool, []string, error) { node, err := s.store.GetNode(id) if err != nil { return false, nil, err } blockerIDs := node.Relations()[string(models.RelBlocks)] var blocking []string for _, bID := range blockerIDs { blocker, err := s.store.GetNode(bID) if err != nil { return false, nil, err } if blocker.GetProperty("status") == "open" { blocking = append(blocking, bID) } } return len(blocking) == 0, blocking, nil } func (s *nodeServiceImpl) List(opts ...ListOption) ([]*models.Node, error) { f := &listFilter{} for _, opt := range opts { opt(f) } // Resolve rel filter targets from names to node IDs (read-only, no auto-creation). resolvedRels := make([]*models.Rel, 0, len(f.relPrefixes)) for _, rel := range f.relPrefixes { resolvedID, ok := s.lookupRelTarget(rel.Type, rel.Target) if !ok { return nil, nil // target doesn't exist; no nodes can match } resolvedRels = append(resolvedRels, &models.Rel{Type: rel.Type, Target: resolvedID}) } return s.store.FindNodes(f.tagPrefixes, resolvedRels) } // lookupRelTarget resolves a rel filter target (name or ID) to a node ID without // creating anything. Returns (id, false) when the target doesn't exist in the store. 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 // other rel types expect a raw node ID } id, err := s.resolveIDByNameAndType(s.store, target, nodeType) if err != nil || id == "" { return "", false } return id, true } func (s *nodeServiceImpl) Create(title, content, dueDate string, tags []string, rels map[models.RelType][]string) (*models.Node, error) { 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, title, content, dueDate, now, now); err != nil { return err } for _, m := range mentions(title + " " + 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 } if rt == models.RelAssignee || rt == models.RelCreated { var err error if tgt, err = s.resolveUserRef(st, tgt); err != nil { return err } } if rt == models.RelInNamespace { var err error if tgt, err = s.resolveNamespaceRef(st, tgt); err != nil { return err } } if err := st.AddEdge(id, tgt, 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(node *models.Node) error { current, err := s.store.GetNode(node.ID) if err != nil { return err } return s.store.Transaction(func(st store.Store) error { // Update scalar fields if changed. if node.Title != current.Title || node.Content != current.Content || node.DueDate != current.DueDate { updatedAt := time.Now().UTC().Format(time.RFC3339) if err := st.UpdateNode(node.ID, node.Title, node.Content, node.DueDate, updatedAt); err != nil { return err } } // Sync mention edges when title or content changed. if node.Title != current.Title || node.Content != current.Content { newMentions := mentions(node.Title + " " + node.Content) existingMentionIDs := make(map[string]bool) for _, uid := range current.Relations()[string(models.RelMentions)] { existingMentionIDs[uid] = true } mentionedUserIDs := make(map[string]bool) for _, m := range newMentions { userID, err := s.resolveUserRef(st, m) if err != nil { return err } mentionedUserIDs[userID] = true if !existingMentionIDs[userID] { if err := st.AddEdge(node.ID, userID, models.RelMentions); err != nil { return err } } } for uid := range existingMentionIDs { if !mentionedUserIDs[uid] { if err := st.RemoveEdge(node.ID, uid, models.RelMentions); err != nil { return err } } } } // Sync tags. currentTags := current.Tags() nodeTags := node.Tags() for _, t := range currentTags { if !slices.Contains(nodeTags, t) { if err := st.RemoveTag(node.ID, t); err != nil { return err } } } for _, t := range nodeTags { if !slices.Contains(currentTags, t) { if err := st.AddTag(node.ID, t); err != nil { return err } } } // Sync edges (excluding mention edges, already handled above). currentRels := current.Relations() nodeRels := node.Relations() for rt, tgts := range currentRels { if rt == string(models.RelMentions) { continue } for _, tgt := range tgts { if nodeRels[rt] == nil || !slices.Contains(nodeRels[rt], tgt) { if err := st.RemoveEdge(node.ID, tgt, models.RelType(rt)); err != nil { return err } } } } for rt, tgts := range nodeRels { if rt == string(models.RelMentions) { continue } for _, tgt := range tgts { if currentRels[rt] == nil || !slices.Contains(currentRels[rt], tgt) { resolvedTgt := tgt if models.RelType(rt) == models.RelAssignee || models.RelType(rt) == models.RelCreated { var err error if resolvedTgt, err = s.resolveUserRef(st, tgt); err != nil { return err } } if models.RelType(rt) == models.RelInNamespace { var err error if resolvedTgt, err = s.resolveNamespaceRef(st, tgt); err != nil { return err } } if err := st.AddEdge(node.ID, resolvedTgt, models.RelType(rt)); err != nil { return err } } } } return nil }) } // resolveIDByNameAndType finds a node by title and _type tag. // Loads all nodes of that type and filters in-memory (few users/namespaces expected). 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 }