     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-backend.
     3→// Use of this source code is governed by a MIT-style
     4→// license that can be found in the LICENSE file.
     5→
     6→// Package repository provides data access and persistence layer for ClusterCockpit.
     7→//
     8→// This file implements tag management functionality for job categorization and classification.
     9→// Tags support both manual assignment (via REST/GraphQL APIs) and automatic detection
    10→// (via tagger plugins). The implementation includes role-based access control through
    11→// tag scopes and maintains bidirectional consistency between the SQL database and
    12→// the file-based job archive.
    13→//
    14→// Database Schema:
    15→//
    16→//	CREATE TABLE tag (
    17→//	    id INTEGER PRIMARY KEY AUTOINCREMENT,
    18→//	    tag_type VARCHAR(255) NOT NULL,
    19→//	    tag_name VARCHAR(255) NOT NULL,
    20→//	    tag_scope VARCHAR(255) NOT NULL DEFAULT "global",
    21→//	    CONSTRAINT tag_unique UNIQUE (tag_type, tag_name, tag_scope)
    22→//	);
    23→//
    24→//	CREATE TABLE jobtag (
    25→//	    job_id INTEGER,
    26→//	    tag_id INTEGER,
    27→//	    PRIMARY KEY (job_id, tag_id),
    28→//	    FOREIGN KEY (job_id) REFERENCES job(id) ON DELETE CASCADE,
    29→//	    FOREIGN KEY (tag_id) REFERENCES tag(id) ON DELETE CASCADE
    30→//	);
    31→//
    32→// The jobtag junction table enables many-to-many relationships between jobs and tags.
    33→// CASCADE deletion ensures referential integrity when jobs or tags are removed.
    34→package repository
    35→
    36→import (
    37→	"errors"
    38→	"fmt"
    39→	"strings"
    40→
    41→	"github.com/ClusterCockpit/cc-backend/pkg/archive"
    42→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
    43→	"github.com/ClusterCockpit/cc-lib/v2/schema"
    44→	sq "github.com/Masterminds/squirrel"
    45→)
    46→
    47→// Tag Scope Rules:
    48→//
    49→// Tags in ClusterCockpit have three visibility scopes that control who can see and use them:
    50→//
    51→//  1. "global" - Visible to all users, can be used by anyone
    52→//     Example: System-generated tags like "energy-efficient", "failed", "short"
    53→//
    54→//  2. "private" - Only visible to the creating user
    55→//     Example: Personal notes like "needs-review", "interesting-case"
    56→//
    57→//  3. "admin" - Only visible to users with admin or support roles
    58→//     Example: Internal notes like "hardware-issue", "billing-problem"
    59→//
    60→// Authorization Rules:
    61→//   - Regular users can only create/see "global" and their own "private" tags
    62→//   - Admin/Support can create/see all scopes including "admin" tags
    63→//   - Users can only add tags to jobs they have permission to view
    64→//   - Tag scope is enforced at query time in GetTags() and CountTags()
    65→
    66→// AddTag adds the tag with id `tagId` to the job with the database id `jobId`.
    67→// Requires user authentication for security checks.
    68→//
    69→// The user must have permission to view the job. Tag visibility is determined by scope:
    70→//   - "global" tags: visible to all users
    71→//   - "private" tags: only visible to the tag creator
    72→//   - "admin" tags: only visible to admin/support users
    73→func (r *JobRepository) AddTag(user *schema.User, job int64, tag int64) ([]*schema.Tag, error) {
    74→	j, err := r.FindByIDWithUser(user, job)
    75→	if err != nil {
    76→		cclog.Warnf("Error finding job %d for user %s: %v", job, user.Username, err)
    77→		return nil, err
    78→	}
    79→
    80→	return r.addJobTag(job, tag, j, func() ([]*schema.Tag, error) {
    81→		return r.GetTags(user, &job)
    82→	})
    83→}
    84→
    85→// AddTagDirect adds a tag without user security checks.
    86→// Use only for internal/admin operations.
    87→func (r *JobRepository) AddTagDirect(job int64, tag int64) ([]*schema.Tag, error) {
    88→	j, err := r.FindByIDDirect(job)
    89→	if err != nil {
    90→		cclog.Warnf("Error finding job %d: %v", job, err)
    91→		return nil, err
    92→	}
    93→
    94→	return r.addJobTag(job, tag, j, func() ([]*schema.Tag, error) {
    95→		return r.GetTagsDirect(&job)
    96→	})
    97→}
    98→
    99→// RemoveTag removes the tag with the database id `tag` from the job with the database id `job`.
   100→// Requires user authentication for security checks. Used by GraphQL API.
   101→func (r *JobRepository) RemoveTag(user *schema.User, job, tag int64) ([]*schema.Tag, error) {
   102→	j, err := r.FindByIDWithUser(user, job)
   103→	if err != nil {
   104→		cclog.Warnf("Error while finding job %d for user %s during tag removal: %v", job, user.Username, err)
   105→		return nil, err
   106→	}
   107→
   108→	q := sq.Delete("jobtag").Where("jobtag.job_id = ?", job).Where("jobtag.tag_id = ?", tag)
   109→
   110→	if _, err := q.RunWith(r.stmtCache).Exec(); err != nil {
   111→		s, _, _ := q.ToSql()
   112→		cclog.Errorf("Error removing tag with %s: %v", s, err)
   113→		return nil, err
   114→	}
   115→
   116→	tags, err := r.GetTags(user, &job)
   117→	if err != nil {
   118→		cclog.Warn("Error while getting tags for job")
   119→		return nil, err
   120→	}
   121→
   122→	archiveTags, err := r.getArchiveTags(&job)
   123→	if err != nil {
   124→		cclog.Warnf("Error while getting archive tags for job %d in RemoveTag: %v", job, err)
   125→		return nil, err
   126→	}
   127→
   128→	return tags, archive.UpdateTags(j, archiveTags)
   129→}
   130→
   131→// RemoveJobTagByRequest removes a tag from the job with the database id `job` by tag type, name, and scope.
   132→// Requires user authentication for security checks. Used by REST API.
   133→func (r *JobRepository) RemoveJobTagByRequest(user *schema.User, job int64, tagType string, tagName string, tagScope string) ([]*schema.Tag, error) {
   134→	// Get Tag ID to delete
   135→	tagID, exists := r.TagID(tagType, tagName, tagScope)
   136→	if !exists {
   137→		cclog.Warnf("Tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope)
   138→		return nil, fmt.Errorf("tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope)
   139→	}
   140→
   141→	// Get Job
   142→	j, err := r.FindByIDWithUser(user, job)
   143→	if err != nil {
   144→		cclog.Warnf("Error while finding job %d for user %s during tag removal by request: %v", job, user.Username, err)
   145→		return nil, err
   146→	}
   147→
   148→	// Handle Delete
   149→	q := sq.Delete("jobtag").Where("jobtag.job_id = ?", job).Where("jobtag.tag_id = ?", tagID)
   150→
   151→	if _, err := q.RunWith(r.stmtCache).Exec(); err != nil {
   152→		s, _, _ := q.ToSql()
   153→		cclog.Errorf("Error removing tag from table 'jobTag' with %s: %v", s, err)
   154→		return nil, err
   155→	}
   156→
   157→	tags, err := r.GetTags(user, &job)
   158→	if err != nil {
   159→		cclog.Warnf("Error while getting tags for job %d in RemoveJobTagByRequest: %v", job, err)
   160→		return nil, err
   161→	}
   162→
   163→	archiveTags, err := r.getArchiveTags(&job)
   164→	if err != nil {
   165→		cclog.Warnf("Error while getting archive tags for job %d in RemoveJobTagByRequest: %v", job, err)
   166→		return nil, err
   167→	}
   168→
   169→	return tags, archive.UpdateTags(j, archiveTags)
   170→}
   171→
   172→// removeTagFromArchiveJobs updates the job archive for all affected jobs after a tag deletion.
   173→//
   174→// This function is called asynchronously (via goroutine) after removing a tag from the database
   175→// to synchronize the file-based job archive with the database state. Errors are logged but not
   176→// returned since this runs in the background.
   177→//
   178→// Parameters:
   179→//   - jobIds: Database IDs of all jobs that had the deleted tag
   180→//
   181→// Implementation note: Each job is processed individually to handle partial failures gracefully.
   182→// If one job fails to update, others will still be processed.
   183→func (r *JobRepository) removeTagFromArchiveJobs(jobIds []int64) {
   184→	for _, j := range jobIds {
   185→		tags, err := r.getArchiveTags(&j)
   186→		if err != nil {
   187→			cclog.Warnf("Error while getting tags for job %d", j)
   188→			continue
   189→		}
   190→
   191→		job, err := r.FindByIDDirect(j)
   192→		if err != nil {
   193→			cclog.Warnf("Error while getting job %d", j)
   194→			continue
   195→		}
   196→
   197→		archive.UpdateTags(job, tags)
   198→	}
   199→}
   200→
   201→// Removes a tag from db by tag info
   202→// Used by REST API. Does not update tagged jobs in Job archive.
   203→func (r *JobRepository) RemoveTagByRequest(tagType string, tagName string, tagScope string) error {
   204→	// Get Tag ID to delete
   205→	tagID, exists := r.TagID(tagType, tagName, tagScope)
   206→	if !exists {
   207→		cclog.Warnf("Tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope)
   208→		return fmt.Errorf("tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope)
   209→	}
   210→
   211→	return r.RemoveTagByID(tagID)
   212→}
   213→
   214→// Removes a tag from db by tag id
   215→// Used by GraphQL API.
   216→func (r *JobRepository) RemoveTagByID(tagID int64) error {
   217→	jobIds, err := r.FindJobIdsByTag(tagID)
   218→	if err != nil {
   219→		return err
   220→	}
   221→
   222→	// Handle Delete JobTagTable
   223→	qJobTag := sq.Delete("jobtag").Where("jobtag.tag_id = ?", tagID)
   224→
   225→	if _, err := qJobTag.RunWith(r.stmtCache).Exec(); err != nil {
   226→		s, _, _ := qJobTag.ToSql()
   227→		cclog.Errorf("Error removing tag from table 'jobTag' with %s: %v", s, err)
   228→		return err
   229→	}
   230→
   231→	// Handle Delete TagTable
   232→	qTag := sq.Delete("tag").Where("tag.id = ?", tagID)
   233→
   234→	if _, err := qTag.RunWith(r.stmtCache).Exec(); err != nil {
   235→		s, _, _ := qTag.ToSql()
   236→		cclog.Errorf("Error removing tag from table 'tag' with %s: %v", s, err)
   237→		return err
   238→	}
   239→
   240→	// asynchronously update archive jobs
   241→	go r.removeTagFromArchiveJobs(jobIds)
   242→
   243→	return nil
   244→}
   245→
   246→// CreateTag creates a new tag with the specified type, name, and scope.
   247→// Returns the database ID of the newly created tag.
   248→//
   249→// Scope defaults to "global" if empty string is provided.
   250→// Valid scopes: "global", "private", "admin"
   251→//
   252→// Example:
   253→//
   254→//	tagID, err := repo.CreateTag("performance", "high-memory", "global")
   255→func (r *JobRepository) CreateTag(tagType string, tagName string, tagScope string) (tagID int64, err error) {
   256→	// Default to "Global" scope if none defined
   257→	if tagScope == "" {
   258→		tagScope = "global"
   259→	}
   260→
   261→	q := sq.Insert("tag").Columns("tag_type", "tag_name", "tag_scope").Values(tagType, tagName, tagScope)
   262→
   263→	res, err := q.RunWith(r.stmtCache).Exec()
   264→	if err != nil {
   265→		s, _, _ := q.ToSql()
   266→		cclog.Errorf("Error inserting tag with %s: %v", s, err)
   267→		return 0, err
   268→	}
   269→
   270→	return res.LastInsertId()
   271→}
   272→
   273→// CountTags returns all tags visible to the user and the count of jobs for each tag.
   274→// Applies scope-based filtering to respect tag visibility rules.
   275→//
   276→// Returns:
   277→//   - tags: slice of tags the user can see
   278→//   - counts: map of tag name to job count
   279→//   - err: any error encountered
   280→func (r *JobRepository) CountTags(user *schema.User) (tags []schema.Tag, counts map[string]int, err error) {
   281→	tags = make([]schema.Tag, 0, 100)
   282→	xrows, err := r.DB.Queryx("SELECT id, tag_type, tag_name, tag_scope FROM tag")
   283→	if err != nil {
   284→		return nil, nil, err
   285→	}
   286→	defer xrows.Close()
   287→
   288→	for xrows.Next() {
   289→		var t schema.Tag
   290→		if err = xrows.StructScan(&t); err != nil {
   291→			return nil, nil, err
   292→		}
   293→
   294→		// Handle Scope Filtering: Tag Scope is Global, Private (== Username) or User is auth'd to view Admin Tags
   295→		readable, err := r.checkScopeAuth(user, "read", t.Scope)
   296→		if err != nil {
   297→			return nil, nil, err
   298→		}
   299→		if readable {
   300→			tags = append(tags, t)
   301→		}
   302→	}
   303→
   304→	if err := xrows.Err(); err != nil {
   305→		return nil, nil, err
   306→	}
   307→
   308→	// Query and Count Jobs with attached Tags
   309→	q := sq.Select("t.tag_type, t.tag_name, t.id, count(jt.tag_id)").
   310→		From("tag t").
   311→		LeftJoin("jobtag jt ON t.id = jt.tag_id").
   312→		GroupBy("t.tag_type, t.tag_name")
   313→
   314→	// Build scope list for filtering
   315→	var scopeBuilder strings.Builder
   316→	scopeBuilder.WriteString(`"global"`)
   317→	if user != nil {
   318→		scopeBuilder.WriteString(`,"`)
   319→		scopeBuilder.WriteString(user.Username)
   320→		scopeBuilder.WriteString(`"`)
   321→		if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
   322→			scopeBuilder.WriteString(`,"admin"`)
   323→		}
   324→	}
   325→	q = q.Where("t.tag_scope IN (" + scopeBuilder.String() + ")")
   326→
   327→	// Handle Job Ownership
   328→	if user != nil && user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) { // ADMIN || SUPPORT: Count all jobs
   329→		// cclog.Debug("CountTags: User Admin or Support -> Count all Jobs for Tags")
   330→		// Unchanged: Needs to be own case still, due to UserRole/NoRole compatibility handling in else case
   331→	} else if user != nil && user.HasRole(schema.RoleManager) { // MANAGER: Count own jobs plus project's jobs
   332→		// Build ("project1", "project2", ...) list of variable length directly in SQL string
   333→		q = q.Where("jt.job_id IN (SELECT id FROM job WHERE job.hpc_user = ? OR job.project IN (\""+strings.Join(user.Projects, "\",\"")+"\"))", user.Username)
   334→	} else if user != nil { // USER OR NO ROLE (Compatibility): Only count own jobs
   335→		q = q.Where("jt.job_id IN (SELECT id FROM job WHERE job.hpc_user = ?)", user.Username)
   336→	}
   337→
   338→	rows, err := q.RunWith(r.stmtCache).Query()
   339→	if err != nil {
   340→		return nil, nil, err
   341→	}
   342→	defer rows.Close()
   343→
   344→	counts = make(map[string]int)
   345→	for rows.Next() {
   346→		var tagType string
   347→		var tagName string
   348→		var tagID int
   349→		var count int
   350→		if err = rows.Scan(&tagType, &tagName, &tagID, &count); err != nil {
   351→			return nil, nil, err
   352→		}
   353→		// Use tagId as second Map-Key component to differentiate tags with identical names
   354→		counts[fmt.Sprint(tagType, tagName, tagID)] = count
   355→	}
   356→	err = rows.Err()
   357→
   358→	return tags, counts, err
   359→}
   360→
   361→var (
   362→	// ErrTagNotFound is returned when a tag ID or tag identifier (type, name, scope) does not exist in the database.
   363→	ErrTagNotFound = errors.New("the tag does not exist")
   364→
   365→	// ErrJobNotOwned is returned when a user attempts to tag a job they do not have permission to access.
   366→	ErrJobNotOwned = errors.New("user is not owner of job")
   367→
   368→	// ErrTagNoAccess is returned when a user attempts to use a tag they cannot access due to scope restrictions.
   369→	ErrTagNoAccess = errors.New("user not permitted to use that tag")
   370→
   371→	// ErrTagPrivateScope is returned when a user attempts to access another user's private tag.
   372→	ErrTagPrivateScope = errors.New("tag is private to another user")
   373→
   374→	// ErrTagAdminScope is returned when a non-admin user attempts to use an admin-scoped tag.
   375→	ErrTagAdminScope = errors.New("tag requires admin privileges")
   376→
   377→	// ErrTagsIncompatScopes is returned when attempting to combine admin and non-admin scoped tags in a single operation.
   378→	ErrTagsIncompatScopes = errors.New("combining admin and non-admin scoped tags not allowed")
   379→)
   380→
   381→// addJobTag is a helper function that inserts a job-tag association and updates the archive.
   382→//
   383→// This function performs three operations atomically:
   384→//  1. Inserts the job-tag association into the jobtag junction table
   385→//  2. Retrieves the updated tag list for the job (using the provided getTags callback)
   386→//  3. Updates the job archive with the new tags to maintain database-archive consistency
   387→//
   388→// Parameters:
   389→//   - jobId: Database ID of the job
   390→//   - tagId: Database ID of the tag to associate
   391→//   - job: Full job object needed for archive update
   392→//   - getTags: Callback function to retrieve updated tags (allows different security contexts)
   393→//
   394→// Returns the complete updated tag list for the job or an error.
   395→//
   396→// Note: This function does NOT validate tag scope permissions - callers must perform
   397→// authorization checks before invoking this helper.
   398→func (r *JobRepository) addJobTag(jobID int64, tagID int64, job *schema.Job, getTags func() ([]*schema.Tag, error)) ([]*schema.Tag, error) {
   399→	q := sq.Insert("jobtag").Columns("job_id", "tag_id").Values(jobID, tagID)
   400→
   401→	if _, err := q.RunWith(r.stmtCache).Exec(); err != nil {
   402→		s, _, _ := q.ToSql()
   403→		cclog.Errorf("Error adding tag with %s: %v", s, err)
   404→		return nil, err
   405→	}
   406→
   407→	tags, err := getTags()
   408→	if err != nil {
   409→		cclog.Warnf("Error getting tags for job %d: %v", jobID, err)
   410→		return nil, err
   411→	}
   412→
   413→	archiveTags, err := r.getArchiveTags(&jobID)
   414→	if err != nil {
   415→		cclog.Warnf("Error getting archive tags for job %d: %v", jobID, err)
   416→		return nil, err
   417→	}
   418→
   419→	return tags, archive.UpdateTags(job, archiveTags)
   420→}
   421→
   422→// AddTagOrCreate adds the tag with the specified type and name to the job with the database id `jobId`.
   423→// If such a tag does not yet exist, it is created.
   424→func (r *JobRepository) AddTagOrCreate(user *schema.User, jobID int64, tagType string, tagName string, tagScope string) (tagID int64, err error) {
   425→	// Default to "Global" scope if none defined
   426→	if tagScope == "" {
   427→		tagScope = "global"
   428→	}
   429→
   430→	writable, err := r.checkScopeAuth(user, "write", tagScope)
   431→	if err != nil {
   432→		return 0, err
   433→	}
   434→	if !writable {
   435→		return 0, fmt.Errorf("cannot write tag scope with current authorization")
   436→	}
   437→
   438→	tagID, exists := r.TagID(tagType, tagName, tagScope)
   439→	if !exists {
   440→		tagID, err = r.CreateTag(tagType, tagName, tagScope)
   441→		if err != nil {
   442→			return 0, err
   443→		}
   444→	}
   445→
   446→	if _, err := r.AddTag(user, jobID, tagID); err != nil {
   447→		return 0, err
   448→	}
   449→
   450→	return tagID, nil
   451→}
   452→
   453→func (r *JobRepository) AddTagOrCreateDirect(jobID int64, tagType string, tagName string) (tagID int64, err error) {
   454→	tagScope := "global"
   455→
   456→	tagID, exists := r.TagID(tagType, tagName, tagScope)
   457→	if !exists {
   458→		tagID, err = r.CreateTag(tagType, tagName, tagScope)
   459→		if err != nil {
   460→			return 0, err
   461→		}
   462→	}
   463→
   464→	cclog.Infof("Adding tag %s:%s:%s (direct)", tagType, tagName, tagScope)
   465→
   466→	if _, err := r.AddTagDirect(jobID, tagID); err != nil {
   467→		return 0, err
   468→	}
   469→
   470→	return tagID, nil
   471→}
   472→
   473→func (r *JobRepository) HasTag(jobID int64, tagType string, tagName string) bool {
   474→	var id int64
   475→	q := sq.Select("id").From("tag").Join("jobtag ON jobtag.tag_id = tag.id").
   476→		Where("jobtag.job_id = ?", jobID).Where("tag.tag_type = ?", tagType).
   477→		Where("tag.tag_name = ?", tagName)
   478→	err := q.RunWith(r.stmtCache).QueryRow().Scan(&id)
   479→	if err != nil {
   480→		return false
   481→	} else {
   482→		return true
   483→	}
   484→}
   485→
   486→// TagID returns the database id of the tag with the specified type and name.
   487→func (r *JobRepository) TagID(tagType string, tagName string, tagScope string) (tagID int64, exists bool) {
   488→	exists = true
   489→	if err := sq.Select("id").From("tag").
   490→		Where("tag.tag_type = ?", tagType).Where("tag.tag_name = ?", tagName).Where("tag.tag_scope = ?", tagScope).
   491→		RunWith(r.stmtCache).QueryRow().Scan(&tagID); err != nil {
   492→		exists = false
   493→	}
   494→	return
   495→}
   496→
   497→// TagInfo returns the database infos of the tag with the specified id.
   498→func (r *JobRepository) TagInfo(tagID int64) (tagType string, tagName string, tagScope string, exists bool) {
   499→	exists = true
   500→	if err := sq.Select("tag.tag_type", "tag.tag_name", "tag.tag_scope").From("tag").Where("tag.id = ?", tagID).
   501→		RunWith(r.stmtCache).QueryRow().Scan(&tagType, &tagName, &tagScope); err != nil {
   502→		exists = false
   503→	}
   504→	return
   505→}
   506→
   507→// GetTags returns a list of all scoped tags if job is nil or of the tags that the job with that database ID has.
   508→func (r *JobRepository) GetTags(user *schema.User, job *int64) ([]*schema.Tag, error) {
   509→	q := sq.Select("id", "tag_type", "tag_name", "tag_scope").From("tag")
   510→	if job != nil {
   511→		q = q.Join("jobtag ON jobtag.tag_id = tag.id").Where("jobtag.job_id = ?", *job)
   512→	}
   513→
   514→	rows, err := q.RunWith(r.stmtCache).Query()
   515→	if err != nil {
   516→		s, _, _ := q.ToSql()
   517→		cclog.Errorf("Error get tags with %s: %v", s, err)
   518→		return nil, err
   519→	}
   520→	defer rows.Close()
   521→
   522→	tags := make([]*schema.Tag, 0)
   523→	for rows.Next() {
   524→		tag := &schema.Tag{}
   525→		if err := rows.Scan(&tag.ID, &tag.Type, &tag.Name, &tag.Scope); err != nil {
   526→			cclog.Warnf("Error while scanning tag rows in GetTags: %v", err)
   527→			return nil, err
   528→		}
   529→		// Handle Scope Filtering: Tag Scope is Global, Private (== Username) or User is auth'd to view Admin Tags
   530→		readable, err := r.checkScopeAuth(user, "read", tag.Scope)
   531→		if err != nil {
   532→			return nil, err
   533→		}
   534→		if readable {
   535→			tags = append(tags, tag)
   536→		}
   537→	}
   538→
   539→	if err := rows.Err(); err != nil {
   540→		return nil, err
   541→	}
   542→
   543→	return tags, nil
   544→}
   545→
   546→func (r *JobRepository) GetTagsDirect(job *int64) ([]*schema.Tag, error) {
   547→	q := sq.Select("id", "tag_type", "tag_name", "tag_scope").From("tag")
   548→	if job != nil {
   549→		q = q.Join("jobtag ON jobtag.tag_id = tag.id").Where("jobtag.job_id = ?", *job)
   550→	}
   551→
   552→	rows, err := q.RunWith(r.stmtCache).Query()
   553→	if err != nil {
   554→		s, _, _ := q.ToSql()
   555→		cclog.Errorf("Error get tags with %s: %v", s, err)
   556→		return nil, err
   557→	}
   558→	defer rows.Close()
   559→
   560→	tags := make([]*schema.Tag, 0)
   561→	for rows.Next() {
   562→		tag := &schema.Tag{}
   563→		if err := rows.Scan(&tag.ID, &tag.Type, &tag.Name, &tag.Scope); err != nil {
   564→			cclog.Warnf("Error while scanning tag rows in GetTagsDirect: %v", err)
   565→			return nil, err
   566→		}
   567→		tags = append(tags, tag)
   568→	}
   569→
   570→	if err := rows.Err(); err != nil {
   571→		return nil, err
   572→	}
   573→
   574→	return tags, nil
   575→}
   576→
   577→// getArchiveTags returns all tags for a job WITHOUT applying scope-based filtering.
   578→//
   579→// This internal function is used exclusively for job archive synchronization where we need
   580→// to store all tags regardless of the current user's permissions. Unlike GetTags() which
   581→// filters by scope, this returns the complete unfiltered tag list.
   582→//
   583→// Parameters:
   584→//   - job: Pointer to job database ID, or nil to return all tags in the system
   585→//
   586→// Returns all tags without scope filtering, used only for archive operations.
   587→//
   588→// WARNING: Do NOT expose this function to user-facing APIs as it bypasses authorization.
   589→func (r *JobRepository) getArchiveTags(job *int64) ([]*schema.Tag, error) {
   590→	q := sq.Select("id", "tag_type", "tag_name", "tag_scope").From("tag")
   591→	if job != nil {
   592→		q = q.Join("jobtag ON jobtag.tag_id = tag.id").Where("jobtag.job_id = ?", *job)
   593→	}
   594→
   595→	rows, err := q.RunWith(r.stmtCache).Query()
   596→	if err != nil {
   597→		s, _, _ := q.ToSql()
   598→		cclog.Errorf("Error get tags with %s: %v", s, err)
   599→		return nil, err
   600→	}
   601→	defer rows.Close()
   602→
   603→	tags := make([]*schema.Tag, 0)
   604→	for rows.Next() {
   605→		tag := &schema.Tag{}
   606→		if err := rows.Scan(&tag.ID, &tag.Type, &tag.Name, &tag.Scope); err != nil {
   607→			cclog.Warnf("Error while scanning tag rows in getArchiveTags: %v", err)
   608→			return nil, err
   609→		}
   610→		tags = append(tags, tag)
   611→	}
   612→
   613→	if err := rows.Err(); err != nil {
   614→		return nil, err
   615→	}
   616→
   617→	return tags, nil
   618→}
   619→
   620→func (r *JobRepository) ImportTag(jobID int64, tagType string, tagName string, tagScope string) (err error) {
   621→	// Import has no scope ctx, only import from metafile to DB (No recursive archive update required), only returns err
   622→
   623→	tagID, exists := r.TagID(tagType, tagName, tagScope)
   624→	if !exists {
   625→		tagID, err = r.CreateTag(tagType, tagName, tagScope)
   626→		if err != nil {
   627→			return err
   628→		}
   629→	}
   630→
   631→	q := sq.Insert("jobtag").Columns("job_id", "tag_id").Values(jobID, tagID)
   632→
   633→	if _, err := q.RunWith(r.stmtCache).Exec(); err != nil {
   634→		s, _, _ := q.ToSql()
   635→		cclog.Errorf("Error adding tag on import with %s: %v", s, err)
   636→		return err
   637→	}
   638→
   639→	return nil
   640→}
   641→
   642→// checkScopeAuth validates whether a user is authorized to perform an operation on a tag with the given scope.
   643→//
   644→// This function implements the tag scope authorization matrix:
   645→//
   646→//	Scope        | Read Access                      | Write Access
   647→//	-------------|----------------------------------|----------------------------------
   648→//	"global"     | All users                        | Admin, Support, API-only
   649→//	"admin"      | Admin, Support                   | Admin, API-only
   650→//	<username>   | Owner only                       | Owner only (private tags)
   651→//
   652→// Parameters:
   653→//   - user: User attempting the operation (must not be nil)
   654→//   - operation: Either "read" or "write"
   655→//   - scope: Tag scope value ("global", "admin", or username for private tags)
   656→//
   657→// Returns:
   658→//   - pass: true if authorized, false if denied
   659→//   - err: error only if operation is invalid or user is nil
   660→//
   661→// Special cases:
   662→//   - API-only users (single role: RoleApi) can write to admin and global scopes for automation
   663→//   - Private tags use the username as scope, granting exclusive access to that user
   664→func (r *JobRepository) checkScopeAuth(user *schema.User, operation string, scope string) (pass bool, err error) {
   665→	if user != nil {
   666→		switch {
   667→		case operation == "write" && scope == "admin":
   668→			if user.HasRole(schema.RoleAdmin) || (len(user.Roles) == 1 && user.HasRole(schema.RoleAPI)) {
   669→				return true, nil
   670→			}
   671→			return false, nil
   672→		case operation == "write" && scope == "global":
   673→			if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) || (len(user.Roles) == 1 && user.HasRole(schema.RoleAPI)) {
   674→				return true, nil
   675→			}
   676→			return false, nil
   677→		case operation == "write" && scope == user.Username:
   678→			return true, nil
   679→		case operation == "read" && scope == "admin":
   680→			return user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}), nil
   681→		case operation == "read" && scope == "global":
   682→			return true, nil
   683→		case operation == "read" && scope == user.Username:
   684→			return true, nil
   685→		default:
   686→			if operation == "read" || operation == "write" {
   687→				// No acceptable scope: deny tag
   688→				return false, nil
   689→			} else {
   690→				return false, fmt.Errorf("error while checking tag operation auth: unknown operation (%s)", operation)
   691→			}
   692→		}
   693→	} else {
   694→		return false, fmt.Errorf("error while checking tag operation auth: no user in context")
   695→	}
   696→}
   697→


Explore the cc-backend codebase to find all periodic/scheduled tasks and background goroutines that write to the SQLite database. Look for:

1. Goroutines started with `go func()` that do DB writes
2. Ticker/timer-based loops that write to the DB
3. Archiving operations that write to the DB
4. Retention/cleanup operations
5. Any cron-like scheduled operations

Search in:
- cmd/cc-backend/ (main entry point, initialization)
- internal/archiver/
- internal/taskmanager/ or similar
- internal/repository/
- Any file with "schedule", "ticker", "cron", "periodic", "retention", "cleanup" patterns

For each, note the frequency, what DB operations it performs, and whether those operations are already batched/transacted.

Also look at the REST API file internal/api/rest.go to understand all registered routes and which ones do DB writes.

Prompt:
Explore the cc-backend codebase to find all periodic/scheduled tasks and background goroutines that write to the SQLite database. Look for:

1. Goroutines started with `go func()` that do DB writes
2. Ticker/timer-based loops that write to the DB
3. Archiving operations that write to the DB
4. Retention/cleanup operations
5. Any cron-like scheduled operations

Search in:
- cmd/cc-backend/ (main entry point, initialization)
- internal/archiver/
- internal/taskmanager/ or similar
- internal/repository/
- Any file with "schedule", "ticker", "cron", "periodic", "retention", "cleanup" patterns

For each, note the frequency, what DB operations it performs, and whether those operations are already batched/transacted.

Also look at the REST API file internal/api/rest.go to understand all registered routes and which ones do DB writes.


<tool_use_error>InputValidationError: Read failed due to the following issues:
The required parameter `file_path` is missing
An unexpected parameter `path` was provided</tool_use_error>

InputValidationError: [
  {
    "expected": "string",
    "code": "invalid_type",
    "path": [
      "file_path"
    ],
    "message": "Invalid input: expected string, received undefined"
  },
  {
    "code": "unrecognized_keys",
    "keys": [
      "path"
    ],
    "path": [],
    "message": "Unrecognized key: \"path\""
  }
]


     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-backend.
     3→// Use of this source code is governed by a MIT-style
     4→// license that can be found in the LICENSE file.
     5→
     6→package repository
     7→
     8→import (
     9→	"context"
    10→	"database/sql"
    11→	"encoding/json"
    12→	"fmt"
    13→	"slices"
    14→	"sort"
    15→	"strings"
    16→	"sync"
    17→	"time"
    18→
    19→	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
    20→	"github.com/ClusterCockpit/cc-backend/pkg/archive"
    21→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
    22→	"github.com/ClusterCockpit/cc-lib/v2/lrucache"
    23→	"github.com/ClusterCockpit/cc-lib/v2/schema"
    24→	sq "github.com/Masterminds/squirrel"
    25→	"github.com/jmoiron/sqlx"
    26→)
    27→
    28→var (
    29→	nodeRepoOnce     sync.Once
    30→	nodeRepoInstance *NodeRepository
    31→)
    32→
    33→type NodeRepository struct {
    34→	DB        *sqlx.DB
    35→	stmtCache *sq.StmtCache
    36→	cache     *lrucache.Cache
    37→	driver    string
    38→}
    39→
    40→func GetNodeRepository() *NodeRepository {
    41→	nodeRepoOnce.Do(func() {
    42→		db := GetConnection()
    43→
    44→		nodeRepoInstance = &NodeRepository{
    45→			DB:     db.DB,
    46→			driver: db.Driver,
    47→
    48→			stmtCache: sq.NewStmtCache(db.DB),
    49→			cache:     lrucache.New(repoConfig.CacheSize),
    50→		}
    51→	})
    52→	return nodeRepoInstance
    53→}
    54→
    55→// latestStateCondition returns a squirrel expression that restricts node_state
    56→// rows to the latest per node_id using a correlated subquery.
    57→// Requires the query to join node and node_state tables.
    58→func latestStateCondition() sq.Sqlizer {
    59→	return sq.Expr(
    60→		"node_state.id = (SELECT ns2.id FROM node_state ns2 WHERE ns2.node_id = node.id ORDER BY ns2.time_stamp DESC LIMIT 1)",
    61→	)
    62→}
    63→
    64→// applyNodeFilters applies common NodeFilter conditions to a query that joins
    65→// the node and node_state tables with latestStateCondition.
    66→func applyNodeFilters(query sq.SelectBuilder, filters []*model.NodeFilter) sq.SelectBuilder {
    67→	for _, f := range filters {
    68→		if f.Cluster != nil {
    69→			query = buildStringCondition("node.cluster", f.Cluster, query)
    70→		}
    71→		if f.SubCluster != nil {
    72→			query = buildStringCondition("node.subcluster", f.SubCluster, query)
    73→		}
    74→		if f.Hostname != nil {
    75→			query = buildStringCondition("node.hostname", f.Hostname, query)
    76→		}
    77→		if f.SchedulerState != nil {
    78→			query = query.Where("node_state.node_state = ?", f.SchedulerState)
    79→		}
    80→		if f.HealthState != nil {
    81→			query = query.Where("node_state.health_state = ?", f.HealthState)
    82→		}
    83→	}
    84→	return query
    85→}
    86→
    87→func (r *NodeRepository) FetchMetadata(hostname string, cluster string) (map[string]string, error) {
    88→	start := time.Now()
    89→
    90→	RawMetaData := make([]byte, 0)
    91→
    92→	if err := sq.Select("node.meta_data").From("node").
    93→		Where("node.hostname = ?", hostname).
    94→		Where("node.cluster = ?", cluster).
    95→		RunWith(r.stmtCache).QueryRow().Scan(&RawMetaData); err != nil {
    96→		cclog.Warn("Error while scanning for node metadata")
    97→		return nil, err
    98→	}
    99→
   100→	if len(RawMetaData) == 0 {
   101→		return nil, nil
   102→	}
   103→
   104→	MetaData := make(map[string]string)
   105→
   106→	if err := json.Unmarshal(RawMetaData, &MetaData); err != nil {
   107→		cclog.Warn("Error while unmarshaling raw metadata json")
   108→		return nil, err
   109→	}
   110→
   111→	cclog.Debugf("Timer FetchMetadata %s", time.Since(start))
   112→	return MetaData, nil
   113→}
   114→
   115→func (r *NodeRepository) GetNode(hostname string, cluster string, withMeta bool) (*schema.Node, error) {
   116→	node := &schema.Node{}
   117→	if err := sq.Select("node.hostname", "node.cluster", "node.subcluster",
   118→		"node_state.node_state", "node_state.health_state").
   119→		From("node").
   120→		Join("node_state ON node_state.node_id = node.id").
   121→		Where(latestStateCondition()).
   122→		Where("node.hostname = ?", hostname).
   123→		Where("node.cluster = ?", cluster).
   124→		RunWith(r.DB).
   125→		QueryRow().Scan(&node.Hostname, &node.Cluster, &node.SubCluster, &node.NodeState, &node.HealthState); err != nil {
   126→		cclog.Warnf("Error while querying node '%s' from database: %v", hostname, err)
   127→		return nil, err
   128→	}
   129→
   130→	if withMeta {
   131→		var err error
   132→		var meta map[string]string
   133→		if meta, err = r.FetchMetadata(hostname, cluster); err != nil {
   134→			cclog.Warnf("Error while fetching metadata for node '%s'", hostname)
   135→			return nil, err
   136→		}
   137→		node.MetaData = meta
   138→	}
   139→
   140→	return node, nil
   141→}
   142→
   143→func (r *NodeRepository) GetNodeByID(id int64, withMeta bool) (*schema.Node, error) {
   144→	node := &schema.Node{}
   145→	if err := sq.Select("node.hostname", "node.cluster", "node.subcluster",
   146→		"node_state.node_state", "node_state.health_state").
   147→		From("node").
   148→		Join("node_state ON node_state.node_id = node.id").
   149→		Where(latestStateCondition()).
   150→		Where("node.id = ?", id).
   151→		RunWith(r.DB).
   152→		QueryRow().Scan(&node.Hostname, &node.Cluster, &node.SubCluster, &node.NodeState, &node.HealthState); err != nil {
   153→		cclog.Warnf("Error while querying node ID '%d' from database: %v", id, err)
   154→		return nil, err
   155→	}
   156→
   157→	if withMeta {
   158→		meta, metaErr := r.FetchMetadata(node.Hostname, node.Cluster)
   159→		if metaErr != nil {
   160→			cclog.Warnf("Error while fetching metadata for node ID '%d': %v", id, metaErr)
   161→			return nil, metaErr
   162→		}
   163→		node.MetaData = meta
   164→	}
   165→
   166→	return node, nil
   167→}
   168→
   169→// const NamedNodeInsert string = `
   170→// INSERT INTO node (time_stamp, hostname, cluster, subcluster, node_state, health_state,
   171→//
   172→//	cpus_allocated, cpus_total, memory_allocated, memory_total, gpus_allocated, gpus_total)
   173→//	VALUES (:time_stamp, :hostname, :cluster, :subcluster, :node_state, :health_state,
   174→//	:cpus_allocated, :cpus_total, :memory_allocated, :memory_total, :gpus_allocated, :gpus_total);`
   175→
   176→const NamedNodeInsert string = `
   177→INSERT INTO node (hostname, cluster, subcluster)
   178→	VALUES (:hostname, :cluster, :subcluster);`
   179→
   180→// AddNode adds a Node to the node table. This can be triggered by a node collector registration or
   181→// from a nodestate update from the job scheduler.
   182→func (r *NodeRepository) AddNode(node *schema.NodeDB) (int64, error) {
   183→	var err error
   184→
   185→	res, err := r.DB.NamedExec(NamedNodeInsert, node)
   186→	if err != nil {
   187→		cclog.Errorf("Error while adding node '%v' to database", node.Hostname)
   188→		return 0, err
   189→	}
   190→	node.ID, err = res.LastInsertId()
   191→	if err != nil {
   192→		cclog.Errorf("Error while getting last insert id for node '%v' from database", node.Hostname)
   193→		return 0, err
   194→	}
   195→
   196→	return node.ID, nil
   197→}
   198→
   199→const NamedNodeStateInsert string = `
   200→INSERT INTO node_state (time_stamp, node_state, health_state, health_metrics,
   201→	cpus_allocated, memory_allocated, gpus_allocated, jobs_running, node_id)
   202→	VALUES (:time_stamp, :node_state, :health_state, :health_metrics,
   203→	:cpus_allocated, :memory_allocated, :gpus_allocated, :jobs_running, :node_id);`
   204→
   205→// TODO: Add real Monitoring Health State
   206→
   207→// UpdateNodeState is called from the Node REST API to add a row in the node state table
   208→func (r *NodeRepository) UpdateNodeState(hostname string, cluster string, nodeState *schema.NodeStateDB) error {
   209→	var id int64
   210→
   211→	if err := sq.Select("id").From("node").
   212→		Where("node.hostname = ?", hostname).Where("node.cluster = ?", cluster).RunWith(r.DB).
   213→		QueryRow().Scan(&id); err != nil {
   214→		if err == sql.ErrNoRows {
   215→			subcluster, err := archive.GetSubClusterByNode(cluster, hostname)
   216→			if err != nil {
   217→				cclog.Errorf("Error while getting subcluster for node '%s' in cluster '%s': %v", hostname, cluster, err)
   218→				return err
   219→			}
   220→			node := schema.NodeDB{
   221→				Hostname: hostname, Cluster: cluster, SubCluster: subcluster,
   222→			}
   223→			id, err = r.AddNode(&node)
   224→			if err != nil {
   225→				cclog.Errorf("Error while adding node '%s' to database: %v", hostname, err)
   226→				return err
   227→			}
   228→
   229→			cclog.Debugf("Added node '%s' to database", hostname)
   230→		} else {
   231→			cclog.Warnf("Error while querying node '%v' from database", id)
   232→			return err
   233→		}
   234→	}
   235→
   236→	nodeState.NodeID = id
   237→
   238→	_, err := r.DB.NamedExec(NamedNodeStateInsert, nodeState)
   239→	if err != nil {
   240→		cclog.Errorf("Error while adding node state for '%v' to database", hostname)
   241→		return err
   242→	}
   243→	cclog.Debugf("Updated node state for '%s' in database", hostname)
   244→	return nil
   245→}
   246→
   247→// NodeStateUpdate holds the data needed to update one node's state in a batch operation.
   248→type NodeStateUpdate struct {
   249→	Hostname  string
   250→	Cluster   string
   251→	NodeState *schema.NodeStateDB
   252→}
   253→
   254→// BatchUpdateNodeStates inserts node state rows for multiple nodes in a single transaction.
   255→// For each node, it looks up (or creates) the node row, then inserts the state row.
   256→// This reduces lock acquisitions from 2*N to 1 for N nodes.
   257→func (r *NodeRepository) BatchUpdateNodeStates(updates []NodeStateUpdate) error {
   258→	tx, err := r.DB.Beginx()
   259→	if err != nil {
   260→		return fmt.Errorf("BatchUpdateNodeStates: begin transaction: %w", err)
   261→	}
   262→	defer tx.Rollback()
   263→
   264→	stmtLookup, err := tx.Preparex("SELECT id FROM node WHERE hostname = ? AND cluster = ?")
   265→	if err != nil {
   266→		return fmt.Errorf("BatchUpdateNodeStates: prepare lookup: %w", err)
   267→	}
   268→	defer stmtLookup.Close()
   269→
   270→	stmtInsertNode, err := tx.PrepareNamed(NamedNodeInsert)
   271→	if err != nil {
   272→		return fmt.Errorf("BatchUpdateNodeStates: prepare node insert: %w", err)
   273→	}
   274→	defer stmtInsertNode.Close()
   275→
   276→	stmtInsertState, err := tx.PrepareNamed(NamedNodeStateInsert)
   277→	if err != nil {
   278→		return fmt.Errorf("BatchUpdateNodeStates: prepare state insert: %w", err)
   279→	}
   280→	defer stmtInsertState.Close()
   281→
   282→	for _, u := range updates {
   283→		var id int64
   284→		if err := stmtLookup.QueryRow(u.Hostname, u.Cluster).Scan(&id); err != nil {
   285→			if err == sql.ErrNoRows {
   286→				subcluster, scErr := archive.GetSubClusterByNode(u.Cluster, u.Hostname)
   287→				if scErr != nil {
   288→					cclog.Errorf("BatchUpdateNodeStates: subcluster lookup for '%s' in '%s': %v", u.Hostname, u.Cluster, scErr)
   289→					continue
   290→				}
   291→				node := schema.NodeDB{
   292→					Hostname: u.Hostname, Cluster: u.Cluster, SubCluster: subcluster,
   293→				}
   294→				res, insertErr := stmtInsertNode.Exec(&node)
   295→				if insertErr != nil {
   296→					cclog.Errorf("BatchUpdateNodeStates: insert node '%s': %v", u.Hostname, insertErr)
   297→					continue
   298→				}
   299→				id, _ = res.LastInsertId()
   300→			} else {
   301→				cclog.Errorf("BatchUpdateNodeStates: lookup node '%s': %v", u.Hostname, err)
   302→				continue
   303→			}
   304→		}
   305→
   306→		u.NodeState.NodeID = id
   307→		if _, err := stmtInsertState.Exec(u.NodeState); err != nil {
   308→			cclog.Errorf("BatchUpdateNodeStates: insert state for '%s': %v", u.Hostname, err)
   309→		}
   310→	}
   311→
   312→	if err := tx.Commit(); err != nil {
   313→		return fmt.Errorf("BatchUpdateNodeStates: commit: %w", err)
   314→	}
   315→	return nil
   316→}
   317→
   318→// func (r *NodeRepository) UpdateHealthState(hostname string, healthState *schema.MonitoringState) error {
   319→// 	if _, err := sq.Update("node").Set("health_state", healthState).Where("node.id = ?", id).RunWith(r.DB).Exec(); err != nil {
   320→// 		cclog.Errorf("error while updating node '%d'", id)
   321→// 		return err
   322→// 	}
   323→//
   324→// 	return nil
   325→// }
   326→
   327→// NodeStateWithNode combines a node state row with denormalized node info.
   328→type NodeStateWithNode struct {
   329→	ID              int64  `db:"id"`
   330→	TimeStamp       int64  `db:"time_stamp"`
   331→	NodeState       string `db:"node_state"`
   332→	HealthState     string `db:"health_state"`
   333→	HealthMetrics   string `db:"health_metrics"`
   334→	CpusAllocated   int    `db:"cpus_allocated"`
   335→	MemoryAllocated int64  `db:"memory_allocated"`
   336→	GpusAllocated   int    `db:"gpus_allocated"`
   337→	JobsRunning     int    `db:"jobs_running"`
   338→	Hostname        string `db:"hostname"`
   339→	Cluster         string `db:"cluster"`
   340→	SubCluster      string `db:"subcluster"`
   341→}
   342→
   343→// FindNodeStatesBefore returns all node_state rows with time_stamp < cutoff,
   344→// joined with node info for denormalized archiving.
   345→func (r *NodeRepository) FindNodeStatesBefore(cutoff int64) ([]NodeStateWithNode, error) {
   346→	rows, err := sq.Select(
   347→		"node_state.id", "node_state.time_stamp", "node_state.node_state",
   348→		"node_state.health_state", "node_state.health_metrics",
   349→		"node_state.cpus_allocated", "node_state.memory_allocated",
   350→		"node_state.gpus_allocated", "node_state.jobs_running",
   351→		"node.hostname", "node.cluster", "node.subcluster",
   352→	).
   353→		From("node_state").
   354→		Join("node ON node_state.node_id = node.id").
   355→		Where(sq.Lt{"node_state.time_stamp": cutoff}).
   356→		Where("node_state.id NOT IN (SELECT ns2.id FROM node_state ns2 WHERE ns2.time_stamp = (SELECT MAX(ns3.time_stamp) FROM node_state ns3 WHERE ns3.node_id = ns2.node_id))").
   357→		OrderBy("node.cluster ASC", "node.subcluster ASC", "node.hostname ASC", "node_state.time_stamp ASC").
   358→		RunWith(r.DB).Query()
   359→	if err != nil {
   360→		return nil, err
   361→	}
   362→	defer rows.Close()
   363→
   364→	var result []NodeStateWithNode
   365→	for rows.Next() {
   366→		var ns NodeStateWithNode
   367→		var healthMetrics sql.NullString
   368→		if err := rows.Scan(&ns.ID, &ns.TimeStamp, &ns.NodeState,
   369→			&ns.HealthState, &healthMetrics,
   370→			&ns.CpusAllocated, &ns.MemoryAllocated,
   371→			&ns.GpusAllocated, &ns.JobsRunning,
   372→			&ns.Hostname, &ns.Cluster, &ns.SubCluster); err != nil {
   373→			return nil, err
   374→		}
   375→		ns.HealthMetrics = healthMetrics.String
   376→		result = append(result, ns)
   377→	}
   378→	return result, nil
   379→}
   380→
   381→// DeleteNodeStatesBefore removes node_state rows with time_stamp < cutoff,
   382→// but always preserves the row with the latest timestamp per node_id.
   383→func (r *NodeRepository) DeleteNodeStatesBefore(cutoff int64) (int64, error) {
   384→	res, err := r.DB.Exec(
   385→		`DELETE FROM node_state WHERE time_stamp < ?
   386→		 AND id NOT IN (
   387→		   SELECT id FROM node_state ns2
   388→		   WHERE ns2.time_stamp = (SELECT MAX(ns3.time_stamp) FROM node_state ns3 WHERE ns3.node_id = ns2.node_id)
   389→		 )`,
   390→		cutoff,
   391→	)
   392→	if err != nil {
   393→		return 0, err
   394→	}
   395→	return res.RowsAffected()
   396→}
   397→
   398→func (r *NodeRepository) DeleteNode(id int64) error {
   399→	_, err := r.DB.Exec(`DELETE FROM node WHERE node.id = ?`, id)
   400→	if err != nil {
   401→		cclog.Errorf("Error while deleting node '%d' from DB", id)
   402→		return err
   403→	}
   404→	cclog.Infof("deleted node '%d' from DB", id)
   405→	return nil
   406→}
   407→
   408→// QueryNodes returns a list of nodes based on a node filter. It always operates
   409→// on the last state (largest timestamp).
   410→func (r *NodeRepository) QueryNodes(
   411→	ctx context.Context,
   412→	filters []*model.NodeFilter,
   413→	page *model.PageRequest,
   414→	order *model.OrderByInput, // Currently unused!
   415→) ([]*schema.Node, error) {
   416→	query, qerr := AccessCheck(ctx,
   417→		sq.Select("node.hostname", "node.cluster", "node.subcluster",
   418→			"node_state.node_state", "node_state.health_state").
   419→			From("node").
   420→			Join("node_state ON node_state.node_id = node.id").
   421→			Where(latestStateCondition()))
   422→	if qerr != nil {
   423→		return nil, qerr
   424→	}
   425→
   426→	query = applyNodeFilters(query, filters)
   427→	query = query.OrderBy("node.hostname ASC")
   428→
   429→	if page != nil && page.ItemsPerPage != -1 {
   430→		limit := uint64(page.ItemsPerPage)
   431→		query = query.Offset((uint64(page.Page) - 1) * limit).Limit(limit)
   432→	}
   433→
   434→	rows, err := query.RunWith(r.stmtCache).Query()
   435→	if err != nil {
   436→		queryString, queryVars, _ := query.ToSql()
   437→		cclog.Errorf("Error while running query '%s' %v: %v", queryString, queryVars, err)
   438→		return nil, err
   439→	}
   440→	defer rows.Close()
   441→
   442→	nodes := make([]*schema.Node, 0)
   443→	for rows.Next() {
   444→		node := schema.Node{}
   445→		if err := rows.Scan(&node.Hostname, &node.Cluster, &node.SubCluster,
   446→			&node.NodeState, &node.HealthState); err != nil {
   447→			cclog.Warn("Error while scanning rows (QueryNodes)")
   448→			return nil, err
   449→		}
   450→		nodes = append(nodes, &node)
   451→	}
   452→
   453→	if err := rows.Err(); err != nil {
   454→		return nil, err
   455→	}
   456→
   457→	return nodes, nil
   458→}
   459→
   460→// QueryNodesWithMeta returns a list of nodes based on a node filter. It always operates
   461→// on the last state (largest timestamp). It includes both (!) optional JSON column data
   462→func (r *NodeRepository) QueryNodesWithMeta(
   463→	ctx context.Context,
   464→	filters []*model.NodeFilter,
   465→	page *model.PageRequest,
   466→	order *model.OrderByInput, // Currently unused!
   467→) ([]*schema.Node, error) {
   468→	query, qerr := AccessCheck(ctx,
   469→		sq.Select("node.hostname", "node.cluster", "node.subcluster",
   470→			"node_state.node_state", "node_state.health_state",
   471→			"node.meta_data", "node_state.health_metrics").
   472→			From("node").
   473→			Join("node_state ON node_state.node_id = node.id").
   474→			Where(latestStateCondition()))
   475→	if qerr != nil {
   476→		return nil, qerr
   477→	}
   478→
   479→	query = applyNodeFilters(query, filters)
   480→	query = query.OrderBy("node.hostname ASC")
   481→
   482→	if page != nil && page.ItemsPerPage != -1 {
   483→		limit := uint64(page.ItemsPerPage)
   484→		query = query.Offset((uint64(page.Page) - 1) * limit).Limit(limit)
   485→	}
   486→
   487→	rows, err := query.RunWith(r.stmtCache).Query()
   488→	if err != nil {
   489→		queryString, queryVars, _ := query.ToSql()
   490→		cclog.Errorf("Error while running query '%s' %v: %v", queryString, queryVars, err)
   491→		return nil, err
   492→	}
   493→	defer rows.Close()
   494→
   495→	nodes := make([]*schema.Node, 0)
   496→	for rows.Next() {
   497→		node := schema.Node{}
   498→		RawMetaData := make([]byte, 0)
   499→		RawMetricHealth := make([]byte, 0)
   500→
   501→		if err := rows.Scan(&node.Hostname, &node.Cluster, &node.SubCluster,
   502→			&node.NodeState, &node.HealthState, &RawMetaData, &RawMetricHealth); err != nil {
   503→			cclog.Warn("Error while scanning rows (QueryNodes)")
   504→			return nil, err
   505→		}
   506→
   507→		if len(RawMetaData) == 0 {
   508→			node.MetaData = nil
   509→		} else {
   510→			metaData := make(map[string]string)
   511→			if err := json.Unmarshal(RawMetaData, &metaData); err != nil {
   512→				cclog.Warn("Error while unmarshaling raw metadata json")
   513→				return nil, err
   514→			}
   515→			node.MetaData = metaData
   516→		}
   517→
   518→		if len(RawMetricHealth) == 0 {
   519→			node.HealthData = nil
   520→		} else {
   521→			healthData := make(map[string][]string)
   522→			if err := json.Unmarshal(RawMetricHealth, &healthData); err != nil {
   523→				cclog.Warn("Error while unmarshaling raw healthdata json")
   524→				return nil, err
   525→			}
   526→			node.HealthData = healthData
   527→		}
   528→
   529→		nodes = append(nodes, &node)
   530→	}
   531→
   532→	if err := rows.Err(); err != nil {
   533→		return nil, err
   534→	}
   535→
   536→	return nodes, nil
   537→}
   538→
   539→// CountNodes returns the total matched nodes based on a node filter. It always operates
   540→// on the last state (largest timestamp) per node.
   541→func (r *NodeRepository) CountNodes(
   542→	ctx context.Context,
   543→	filters []*model.NodeFilter,
   544→) (int, error) {
   545→	query, qerr := AccessCheck(ctx,
   546→		sq.Select("COUNT(*)").
   547→			From("node").
   548→			Join("node_state ON node_state.node_id = node.id").
   549→			Where(latestStateCondition()))
   550→	if qerr != nil {
   551→		return 0, qerr
   552→	}
   553→
   554→	query = applyNodeFilters(query, filters)
   555→
   556→	var count int
   557→	if err := query.RunWith(r.stmtCache).QueryRow().Scan(&count); err != nil {
   558→		queryString, queryVars, _ := query.ToSql()
   559→		cclog.Errorf("Error while running query '%s' %v: %v", queryString, queryVars, err)
   560→		return 0, err
   561→	}
   562→
   563→	return count, nil
   564→}
   565→
   566→func (r *NodeRepository) ListNodes(cluster string) ([]*schema.Node, error) {
   567→	q := sq.Select("node.hostname", "node.cluster", "node.subcluster",
   568→		"node_state.node_state", "node_state.health_state").
   569→		From("node").
   570→		Join("node_state ON node_state.node_id = node.id").
   571→		Where(latestStateCondition()).
   572→		Where("node.cluster = ?", cluster).
   573→		OrderBy("node.hostname ASC")
   574→
   575→	rows, err := q.RunWith(r.DB).Query()
   576→	if err != nil {
   577→		cclog.Warn("Error while querying node list")
   578→		return nil, err
   579→	}
   580→	nodeList := make([]*schema.Node, 0, 100)
   581→	defer rows.Close()
   582→	for rows.Next() {
   583→		node := &schema.Node{}
   584→		if err := rows.Scan(&node.Hostname, &node.Cluster,
   585→			&node.SubCluster, &node.NodeState, &node.HealthState); err != nil {
   586→			cclog.Warn("Error while scanning node list (ListNodes)")
   587→			return nil, err
   588→		}
   589→
   590→		nodeList = append(nodeList, node)
   591→	}
   592→
   593→	return nodeList, nil
   594→}
   595→
   596→func (r *NodeRepository) MapNodes(cluster string) (map[string]string, error) {
   597→	q := sq.Select("node.hostname", "node_state.node_state").
   598→		From("node").
   599→		Join("node_state ON node_state.node_id = node.id").
   600→		Where(latestStateCondition()).
   601→		Where("node.cluster = ?", cluster).
   602→		OrderBy("node.hostname ASC")
   603→
   604→	rows, err := q.RunWith(r.DB).Query()
   605→	if err != nil {
   606→		cclog.Warn("Error while querying node list")
   607→		return nil, err
   608→	}
   609→
   610→	stateMap := make(map[string]string)
   611→	defer rows.Close()
   612→	for rows.Next() {
   613→		var hostname, nodestate string
   614→		if err := rows.Scan(&hostname, &nodestate); err != nil {
   615→			cclog.Warn("Error while scanning node list (MapNodes)")
   616→			return nil, err
   617→		}
   618→
   619→		stateMap[hostname] = nodestate
   620→	}
   621→
   622→	return stateMap, nil
   623→}
   624→
   625→func (r *NodeRepository) CountStates(ctx context.Context, filters []*model.NodeFilter, column string) ([]*model.NodeStates, error) {
   626→	query, qerr := AccessCheck(ctx,
   627→		sq.Select(column, "COUNT(*) as count").
   628→			From("node").
   629→			Join("node_state ON node_state.node_id = node.id").
   630→			Where(latestStateCondition()).
   631→			GroupBy(column))
   632→	if qerr != nil {
   633→		return nil, qerr
   634→	}
   635→
   636→	query = applyNodeFilters(query, filters)
   637→
   638→	rows, err := query.RunWith(r.stmtCache).Query()
   639→	if err != nil {
   640→		queryString, queryVars, _ := query.ToSql()
   641→		cclog.Errorf("Error while running query '%s' %v: %v", queryString, queryVars, err)
   642→		return nil, err
   643→	}
   644→	defer rows.Close()
   645→
   646→	nodes := make([]*model.NodeStates, 0)
   647→	for rows.Next() {
   648→		var state string
   649→		var count int
   650→		if err := rows.Scan(&state, &count); err != nil {
   651→			cclog.Warn("Error while scanning rows (CountStates)")
   652→			return nil, err
   653→		}
   654→		nodes = append(nodes, &model.NodeStates{State: state, Count: count})
   655→	}
   656→
   657→	if err := rows.Err(); err != nil {
   658→		return nil, err
   659→	}
   660→
   661→	return nodes, nil
   662→}
   663→
   664→func (r *NodeRepository) CountStatesTimed(ctx context.Context, filters []*model.NodeFilter, column string) ([]*model.NodeStatesTimed, error) {
   665→	query, qerr := AccessCheck(ctx, sq.Select(column, "time_stamp", "count(*) as count").From("node")) // "cluster"?
   666→	if qerr != nil {
   667→		return nil, qerr
   668→	}
   669→
   670→	query = query.Join("node_state ON node_state.node_id = node.id")
   671→
   672→	for _, f := range filters {
   673→		// Required
   674→		if f.TimeStart != nil {
   675→			query = query.Where("time_stamp > ?", f.TimeStart)
   676→		}
   677→		// Optional
   678→		if f.Hostname != nil {
   679→			query = buildStringCondition("hostname", f.Hostname, query)
   680→		}
   681→		if f.Cluster != nil {
   682→			query = buildStringCondition("cluster", f.Cluster, query)
   683→		}
   684→		if f.SubCluster != nil {
   685→			query = buildStringCondition("subcluster", f.SubCluster, query)
   686→		}
   687→		if f.SchedulerState != nil {
   688→			query = query.Where("node_state = ?", f.SchedulerState)
   689→		}
   690→		if f.HealthState != nil {
   691→			query = query.Where("health_state = ?", f.HealthState)
   692→		}
   693→	}
   694→
   695→	// Add Group and Order
   696→	query = query.GroupBy(column + ", time_stamp").OrderBy("time_stamp ASC")
   697→
   698→	rows, err := query.RunWith(r.stmtCache).Query()
   699→	if err != nil {
   700→		queryString, queryVars, _ := query.ToSql()
   701→		cclog.Errorf("Error while running query '%s' %v: %v", queryString, queryVars, err)
   702→		return nil, err
   703→	}
   704→	defer rows.Close()
   705→
   706→	rawData := make(map[string][][]int)
   707→	for rows.Next() {
   708→		var state string
   709→		var timestamp, count int
   710→
   711→		if err := rows.Scan(&state, &timestamp, &count); err != nil {
   712→			cclog.Warnf("Error while scanning rows (CountStatesTimed) at time '%d'", timestamp)
   713→			return nil, err
   714→		}
   715→
   716→		if rawData[state] == nil {
   717→			rawData[state] = [][]int{make([]int, 0), make([]int, 0)}
   718→		}
   719→
   720→		rawData[state][0] = append(rawData[state][0], timestamp)
   721→		rawData[state][1] = append(rawData[state][1], count)
   722→	}
   723→
   724→	if err := rows.Err(); err != nil {
   725→		return nil, err
   726→	}
   727→
   728→	timedStates := make([]*model.NodeStatesTimed, 0)
   729→	for state, data := range rawData {
   730→		entry := model.NodeStatesTimed{State: state, Times: data[0], Counts: data[1]}
   731→		timedStates = append(timedStates, &entry)
   732→	}
   733→
   734→	return timedStates, nil
   735→}
   736→
   737→func (r *NodeRepository) GetNodesForList(
   738→	ctx context.Context,
   739→	cluster string,
   740→	subCluster string,
   741→	stateFilter string,
   742→	nodeFilter string,
   743→	page *model.PageRequest,
   744→) ([]string, map[string]string, int, bool, error) {
   745→	// Init Return Vars
   746→	nodes := make([]string, 0)
   747→	stateMap := make(map[string]string)
   748→	countNodes := 0
   749→	hasNextPage := false
   750→
   751→	// Build Filters
   752→	queryFilters := make([]*model.NodeFilter, 0)
   753→	if cluster != "" {
   754→		queryFilters = append(queryFilters, &model.NodeFilter{Cluster: &model.StringInput{Eq: &cluster}})
   755→	}
   756→	if subCluster != "" {
   757→		queryFilters = append(queryFilters, &model.NodeFilter{SubCluster: &model.StringInput{Eq: &subCluster}})
   758→	}
   759→	if nodeFilter != "" && stateFilter != "notindb" {
   760→		queryFilters = append(queryFilters, &model.NodeFilter{Hostname: &model.StringInput{Contains: &nodeFilter}})
   761→	}
   762→	if stateFilter != "all" && stateFilter != "notindb" {
   763→		queryState := schema.SchedulerState(stateFilter)
   764→		queryFilters = append(queryFilters, &model.NodeFilter{SchedulerState: &queryState})
   765→	}
   766→	// if healthFilter != "all" {
   767→	// 	filters = append(filters, &model.NodeFilter{HealthState: &healthFilter})
   768→	// }
   769→
   770→	// Special Case: Disable Paging for missing nodes filter, save IPP for later
   771→	var backupItems int
   772→	if stateFilter == "notindb" {
   773→		backupItems = page.ItemsPerPage
   774→		page.ItemsPerPage = -1
   775→	}
   776→
   777→	// Query Nodes From DB
   778→	rawNodes, serr := r.QueryNodes(ctx, queryFilters, page, nil) // Order not Used
   779→	if serr != nil {
   780→		cclog.Warn("error while loading node database data (Resolver.NodeMetricsList)")
   781→		return nil, nil, 0, false, serr
   782→	}
   783→
   784→	// Intermediate Node Result Info
   785→	for _, node := range rawNodes {
   786→		if node == nil {
   787→			continue
   788→		}
   789→		nodes = append(nodes, node.Hostname)
   790→		stateMap[node.Hostname] = string(node.NodeState)
   791→	}
   792→
   793→	// Special Case: Find Nodes not in DB node table but in metricStore only
   794→	if stateFilter == "notindb" {
   795→		// Reapply Original Paging
   796→		page.ItemsPerPage = backupItems
   797→		// Get Nodes From Topology
   798→		var topoNodes []string
   799→		if subCluster != "" {
   800→			scNodes := archive.NodeLists[cluster][subCluster]
   801→			topoNodes = scNodes.PrintList()
   802→		} else {
   803→			subClusterNodeLists := archive.NodeLists[cluster]
   804→			for _, nodeList := range subClusterNodeLists {
   805→				topoNodes = append(topoNodes, nodeList.PrintList()...)
   806→			}
   807→		}
   808→		// Compare to all nodes from cluster/subcluster in DB
   809→		var missingNodes []string
   810→		for _, scanNode := range topoNodes {
   811→			if !slices.Contains(nodes, scanNode) {
   812→				missingNodes = append(missingNodes, scanNode)
   813→			}
   814→		}
   815→		// Filter nodes by name
   816→		if nodeFilter != "" {
   817→			filteredNodesByName := []string{}
   818→			for _, missingNode := range missingNodes {
   819→				if strings.Contains(missingNode, nodeFilter) {
   820→					filteredNodesByName = append(filteredNodesByName, missingNode)
   821→				}
   822→			}
   823→			missingNodes = filteredNodesByName
   824→		}
   825→		// Sort Missing Nodes Alphanumerically
   826→		slices.Sort(missingNodes)
   827→		// Total Missing
   828→		countNodes = len(missingNodes)
   829→		// Apply paging
   830→		if countNodes > page.ItemsPerPage {
   831→			start := (page.Page - 1) * page.ItemsPerPage
   832→			end := start + page.ItemsPerPage
   833→			if end > countNodes {
   834→				end = countNodes
   835→				hasNextPage = false
   836→			} else {
   837→				hasNextPage = true
   838→			}
   839→			nodes = missingNodes[start:end]
   840→		} else {
   841→			nodes = missingNodes
   842→		}
   843→
   844→	} else {
   845→		// DB Nodes: Count and derive hasNextPage from count
   846→		var cerr error
   847→		countNodes, cerr = r.CountNodes(ctx, queryFilters)
   848→		if cerr != nil {
   849→			cclog.Warn("error while counting node database data (Resolver.NodeMetricsList)")
   850→			return nil, nil, 0, false, cerr
   851→		}
   852→		hasNextPage = page.Page*page.ItemsPerPage < countNodes
   853→	}
   854→
   855→	// Fallback for non-init'd node table in DB; Ignores stateFilter
   856→	if stateFilter == "all" && countNodes == 0 {
   857→		nodes, countNodes, hasNextPage = getNodesFromTopol(cluster, subCluster, nodeFilter, page)
   858→	}
   859→
   860→	return nodes, stateMap, countNodes, hasNextPage, nil
   861→}
   862→
   863→func AccessCheck(ctx context.Context, query sq.SelectBuilder) (sq.SelectBuilder, error) {
   864→	user := GetUserFromContext(ctx)
   865→	return AccessCheckWithUser(user, query)
   866→}
   867→
   868→func AccessCheckWithUser(user *schema.User, query sq.SelectBuilder) (sq.SelectBuilder, error) {
   869→	if user == nil {
   870→		var qnil sq.SelectBuilder
   871→		return qnil, fmt.Errorf("user context is nil")
   872→	}
   873→
   874→	switch {
   875→	// case len(user.Roles) == 1 && user.HasRole(schema.RoleApi): // API-User : Access NodeInfos
   876→	// 	return query, nil
   877→	case user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}): // Admin & Support : Access NodeInfos
   878→		return query, nil
   879→	default: // No known Role: No Access, return error
   880→		var qnil sq.SelectBuilder
   881→		return qnil, fmt.Errorf("user has no or unknown roles")
   882→	}
   883→}
   884→
   885→func getNodesFromTopol(cluster string, subCluster string, nodeFilter string, page *model.PageRequest) ([]string, int, bool) {
   886→	// 0) Init additional vars
   887→	hasNextPage := false
   888→	totalNodes := 0
   889→
   890→	// 1) Get list of all nodes
   891→	var topolNodes []string
   892→	if subCluster != "" {
   893→		scNodes := archive.NodeLists[cluster][subCluster]
   894→		topolNodes = scNodes.PrintList()
   895→	} else {
   896→		subClusterNodeLists := archive.NodeLists[cluster]
   897→		for _, nodeList := range subClusterNodeLists {
   898→			topolNodes = append(topolNodes, nodeList.PrintList()...)
   899→		}
   900→	}
   901→
   902→	// 2) Filter nodes
   903→	if nodeFilter != "" {
   904→		filteredNodes := []string{}
   905→		for _, node := range topolNodes {
   906→			if strings.Contains(node, nodeFilter) {
   907→				filteredNodes = append(filteredNodes, node)
   908→			}
   909→		}
   910→		topolNodes = filteredNodes
   911→	}
   912→
   913→	// 2.1) Count total nodes && Sort nodes -> Sorting invalidated after ccms return ...
   914→	totalNodes = len(topolNodes)
   915→	sort.Strings(topolNodes)
   916→
   917→	// 3) Apply paging
   918→	if len(topolNodes) > page.ItemsPerPage {
   919→		start := (page.Page - 1) * page.ItemsPerPage
   920→		end := start + page.ItemsPerPage
   921→		if end >= len(topolNodes) {
   922→			end = len(topolNodes)
   923→			hasNextPage = false
   924→		} else {
   925→			hasNextPage = true
   926→		}
   927→		topolNodes = topolNodes[start:end]
   928→	}
   929→
   930→	return topolNodes, totalNodes, hasNextPage
   931→}
   932→