     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→

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

// Package repository provides data access and persistence layer for ClusterCockpit.
//
// This file implements tag management functionality for job categorization and classification.
// Tags support both manual assignment (via REST/GraphQL APIs) and automatic detection
// (via tagger plugins). The implementation includes role-based access control through
// tag scopes and maintains bidirectional consistency between the SQL database and
// the file-based job archive.
//
// Database Schema:
//
//	CREATE TABLE tag (
//	    id INTEGER PRIMARY KEY AUTOINCREMENT,
//	    tag_type VARCHAR(255) NOT NULL,
//	    tag_name VARCHAR(255) NOT NULL,
//	    tag_scope VARCHAR(255) NOT NULL DEFAULT "global",
//	    CONSTRAINT tag_unique UNIQUE (tag_type, tag_name, tag_scope)
//	);
//
//	CREATE TABLE jobtag (
//	    job_id INTEGER,
//	    tag_id INTEGER,
//	    PRIMARY KEY (job_id, tag_id),
//	    FOREIGN KEY (job_id) REFERENCES job(id) ON DELETE CASCADE,
//	    FOREIGN KEY (tag_id) REFERENCES tag(id) ON DELETE CASCADE
//	);
//
// The jobtag junction table enables many-to-many relationships between jobs and tags.
// CASCADE deletion ensures referential integrity when jobs or tags are removed.
package repository

import (
	"errors"
	"fmt"
	"strings"

	"github.com/ClusterCockpit/cc-backend/pkg/archive"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	sq "github.com/Masterminds/squirrel"
)

// Tag Scope Rules:
//
// Tags in ClusterCockpit have three visibility scopes that control who can see and use them:
//
//  1. "global" - Visible to all users, can be used by anyone
//     Example: System-generated tags like "energy-efficient", "failed", "short"
//
//  2. "private" - Only visible to the creating user
//     Example: Personal notes like "needs-review", "interesting-case"
//
//  3. "admin" - Only visible to users with admin or support roles
//     Example: Internal notes like "hardware-issue", "billing-problem"
//
// Authorization Rules:
//   - Regular users can only create/see "global" and their own "private" tags
//   - Admin/Support can create/see all scopes including "admin" tags
//   - Users can only add tags to jobs they have permission to view
//   - Tag scope is enforced at query time in GetTags() and CountTags()

// AddTag adds the tag with id `tagId` to the job with the database id `jobId`.
// Requires user authentication for security checks.
//
// The user must have permission to view the job. Tag visibility is determined by scope:
//   - "global" tags: visible to all users
//   - "private" tags: only visible to the tag creator
//   - "admin" tags: only visible to admin/support users
func (r *JobRepository) AddTag(user *schema.User, job int64, tag int64) ([]*schema.Tag, error) {
	j, err := r.FindByIDWithUser(user, job)
	if err != nil {
		cclog.Warnf("Error finding job %d for user %s: %v", job, user.Username, err)
		return nil, err
	}

	return r.addJobTag(job, tag, j, func() ([]*schema.Tag, error) {
		return r.GetTags(user, &job)
	})
}

// AddTagDirect adds a tag without user security checks.
// Use only for internal/admin operations.
func (r *JobRepository) AddTagDirect(job int64, tag int64) ([]*schema.Tag, error) {
	j, err := r.FindByIDDirect(job)
	if err != nil {
		cclog.Warnf("Error finding job %d: %v", job, err)
		return nil, err
	}

	return r.addJobTag(job, tag, j, func() ([]*schema.Tag, error) {
		return r.GetTagsDirect(&job)
	})
}

// RemoveTag removes the tag with the database id `tag` from the job with the database id `job`.
// Requires user authentication for security checks. Used by GraphQL API.
func (r *JobRepository) RemoveTag(user *schema.User, job, tag int64) ([]*schema.Tag, error) {
	j, err := r.FindByIDWithUser(user, job)
	if err != nil {
		cclog.Warnf("Error while finding job %d for user %s during tag removal: %v", job, user.Username, err)
		return nil, err
	}

	q := sq.Delete("jobtag").Where("jobtag.job_id = ?", job).Where("jobtag.tag_id = ?", tag)

	if _, err := q.RunWith(r.stmtCache).Exec(); err != nil {
		s, _, _ := q.ToSql()
		cclog.Errorf("Error removing tag with %s: %v", s, err)
		return nil, err
	}

	tags, err := r.GetTags(user, &job)
	if err != nil {
		cclog.Warn("Error while getting tags for job")
		return nil, err
	}

	archiveTags, err := r.getArchiveTags(&job)
	if err != nil {
		cclog.Warnf("Error while getting archive tags for job %d in RemoveTag: %v", job, err)
		return nil, err
	}

	return tags, archive.UpdateTags(j, archiveTags)
}

// RemoveJobTagByRequest removes a tag from the job with the database id `job` by tag type, name, and scope.
// Requires user authentication for security checks. Used by REST API.
func (r *JobRepository) RemoveJobTagByRequest(user *schema.User, job int64, tagType string, tagName string, tagScope string) ([]*schema.Tag, error) {
	// Get Tag ID to delete
	tagID, exists := r.TagID(tagType, tagName, tagScope)
	if !exists {
		cclog.Warnf("Tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope)
		return nil, fmt.Errorf("tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope)
	}

	// Get Job
	j, err := r.FindByIDWithUser(user, job)
	if err != nil {
		cclog.Warnf("Error while finding job %d for user %s during tag removal by request: %v", job, user.Username, err)
		return nil, err
	}

	// Handle Delete
	q := sq.Delete("jobtag").Where("jobtag.job_id = ?", job).Where("jobtag.tag_id = ?", tagID)

	if _, err := q.RunWith(r.stmtCache).Exec(); err != nil {
		s, _, _ := q.ToSql()
		cclog.Errorf("Error removing tag from table 'jobTag' with %s: %v", s, err)
		return nil, err
	}

	tags, err := r.GetTags(user, &job)
	if err != nil {
		cclog.Warnf("Error while getting tags for job %d in RemoveJobTagByRequest: %v", job, err)
		return nil, err
	}

	archiveTags, err := r.getArchiveTags(&job)
	if err != nil {
		cclog.Warnf("Error while getting archive tags for job %d in RemoveJobTagByRequest: %v", job, err)
		return nil, err
	}

	return tags, archive.UpdateTags(j, archiveTags)
}

// removeTagFromArchiveJobs updates the job archive for all affected jobs after a tag deletion.
//
// This function is called asynchronously (via goroutine) after removing a tag from the database
// to synchronize the file-based job archive with the database state. Errors are logged but not
// returned since this runs in the background.
//
// Parameters:
//   - jobIds: Database IDs of all jobs that had the deleted tag
//
// Implementation note: Each job is processed individually to handle partial failures gracefully.
// If one job fails to update, others will still be processed.
func (r *JobRepository) removeTagFromArchiveJobs(jobIds []int64) {
	for _, j := range jobIds {
		tags, err := r.getArchiveTags(&j)
		if err != nil {
			cclog.Warnf("Error while getting tags for job %d", j)
			continue
		}

		job, err := r.FindByIDDirect(j)
		if err != nil {
			cclog.Warnf("Error while getting job %d", j)
			continue
		}

		archive.UpdateTags(job, tags)
	}
}

// Removes a tag from db by tag info
// Used by REST API. Does not update tagged jobs in Job archive.
func (r *JobRepository) RemoveTagByRequest(tagType string, tagName string, tagScope string) error {
	// Get Tag ID to delete
	tagID, exists := r.TagID(tagType, tagName, tagScope)
	if !exists {
		cclog.Warnf("Tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope)
		return fmt.Errorf("tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope)
	}

	return r.RemoveTagByID(tagID)
}

// Removes a tag from db by tag id
// Used by GraphQL API.
func (r *JobRepository) RemoveTagByID(tagID int64) error {
	jobIds, err := r.FindJobIdsByTag(tagID)
	if err != nil {
		return err
	}

	// Handle Delete JobTagTable
	qJobTag := sq.Delete("jobtag").Where("jobtag.tag_id = ?", tagID)

	if _, err := qJobTag.RunWith(r.stmtCache).Exec(); err != nil {
		s, _, _ := qJobTag.ToSql()
		cclog.Errorf("Error removing tag from table 'jobTag' with %s: %v", s, err)
		return err
	}

	// Handle Delete TagTable
	qTag := sq.Delete("tag").Where("tag.id = ?", tagID)

	if _, err := qTag.RunWith(r.stmtCache).Exec(); err != nil {
		s, _, _ := qTag.ToSql()
		cclog.Errorf("Error removing tag from table 'tag' with %s: %v", s, err)
		return err
	}

	// asynchronously update archive jobs
	go r.removeTagFromArchiveJobs(jobIds)

	return nil
}

// CreateTag creates a new tag with the specified type, name, and scope.
// Returns the database ID of the newly created tag.
//
// Scope defaults to "global" if empty string is provided.
// Valid scopes: "global", "private", "admin"
//
// Example:
//
//	tagID, err := repo.CreateTag("performance", "high-memory", "global")
func (r *JobRepository) CreateTag(tagType string, tagName string, tagScope string) (tagID int64, err error) {
	// Default to "Global" scope if none defined
	if tagScope == "" {
		tagScope = "global"
	}

	q := sq.Insert("tag").Columns("tag_type", "tag_name", "tag_scope").Values(tagType, tagName, tagScope)

	res, err := q.RunWith(r.stmtCache).Exec()
	if err != nil {
		s, _, _ := q.ToSql()
		cclog.Errorf("Error inserting tag with %s: %v", s, err)
		return 0, err
	}

	return res.LastInsertId()
}

// CountTags returns all tags visible to the user and the count of jobs for each tag.
// Applies scope-based filtering to respect tag visibility rules.
//
// Returns:
//   - tags: slice of tags the user can see
//   - counts: map of tag name to job count
//   - err: any error encountered
func (r *JobRepository) CountTags(user *schema.User) (tags []schema.Tag, counts map[string]int, err error) {
	tags = make([]schema.Tag, 0, 100)
	xrows, err := r.DB.Queryx("SELECT id, tag_type, tag_name, tag_scope FROM tag")
	if err != nil {
		return nil, nil, err
	}
	defer xrows.Close()

	for xrows.Next() {
		var t schema.Tag
		if err = xrows.StructScan(&t); err != nil {
			return nil, nil, err
		}

		// Handle Scope Filtering: Tag Scope is Global, Private (== Username) or User is auth'd to view Admin Tags
		readable, err := r.checkScopeAuth(user, "read", t.Scope)
		if err != nil {
			return nil, nil, err
		}
		if readable {
			tags = append(tags, t)
		}
	}

	if err := xrows.Err(); err != nil {
		return nil, nil, err
	}

	// Query and Count Jobs with attached Tags
	q := sq.Select("t.tag_type, t.tag_name, t.id, count(jt.tag_id)").
		From("tag t").
		LeftJoin("jobtag jt ON t.id = jt.tag_id").
		GroupBy("t.tag_type, t.tag_name")

	// Build scope list for filtering
	var scopeBuilder strings.Builder
	scopeBuilder.WriteString(`"global"`)
	if user != nil {
		scopeBuilder.WriteString(`,"`)
		scopeBuilder.WriteString(user.Username)
		scopeBuilder.WriteString(`"`)
		if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
			scopeBuilder.WriteString(`,"admin"`)
		}
	}
	q = q.Where("t.tag_scope IN (" + scopeBuilder.String() + ")")

	// Handle Job Ownership
	if user != nil && user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) { // ADMIN || SUPPORT: Count all jobs
		// cclog.Debug("CountTags: User Admin or Support -> Count all Jobs for Tags")
		// Unchanged: Needs to be own case still, due to UserRole/NoRole compatibility handling in else case
	} else if user != nil && user.HasRole(schema.RoleManager) { // MANAGER: Count own jobs plus project's jobs
		// Build ("project1", "project2", ...) list of variable length directly in SQL string
		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)
	} else if user != nil { // USER OR NO ROLE (Compatibility): Only count own jobs
		q = q.Where("jt.job_id IN (SELECT id FROM job WHERE job.hpc_user = ?)", user.Username)
	}

	rows, err := q.RunWith(r.stmtCache).Query()
	if err != nil {
		return nil, nil, err
	}
	defer rows.Close()

	counts = make(map[string]int)
	for rows.Next() {
		var tagType string
		var tagName string
		var tagID int
		var count int
		if err = rows.Scan(&tagType, &tagName, &tagID, &count); err != nil {
			return nil, nil, err
		}
		// Use tagId as second Map-Key component to differentiate tags with identical names
		counts[fmt.Sprint(tagType, tagName, tagID)] = count
	}
	err = rows.Err()

	return tags, counts, err
}

var (
	// ErrTagNotFound is returned when a tag ID or tag identifier (type, name, scope) does not exist in the database.
	ErrTagNotFound = errors.New("the tag does not exist")

	// ErrJobNotOwned is returned when a user attempts to tag a job they do not have permission to access.
	ErrJobNotOwned = errors.New("user is not owner of job")

	// ErrTagNoAccess is returned when a user attempts to use a tag they cannot access due to scope restrictions.
	ErrTagNoAccess = errors.New("user not permitted to use that tag")

	// ErrTagPrivateScope is returned when a user attempts to access another user's private tag.
	ErrTagPrivateScope = errors.New("tag is private to another user")

	// ErrTagAdminScope is returned when a non-admin user attempts to use an admin-scoped tag.
	ErrTagAdminScope = errors.New("tag requires admin privileges")

	// ErrTagsIncompatScopes is returned when attempting to combine admin and non-admin scoped tags in a single operation.
	ErrTagsIncompatScopes = errors.New("combining admin and non-admin scoped tags not allowed")
)

// addJobTag is a helper function that inserts a job-tag association and updates the archive.
//
// This function performs three operations atomically:
//  1. Inserts the job-tag association into the jobtag junction table
//  2. Retrieves the updated tag list for the job (using the provided getTags callback)
//  3. Updates the job archive with the new tags to maintain database-archive consistency
//
// Parameters:
//   - jobId: Database ID of the job
//   - tagId: Database ID of the tag to associate
//   - job: Full job object needed for archive update
//   - getTags: Callback function to retrieve updated tags (allows different security contexts)
//
// Returns the complete updated tag list for the job or an error.
//
// Note: This function does NOT validate tag scope permissions - callers must perform
// authorization checks before invoking this helper.
func (r *JobRepository) addJobTag(jobID int64, tagID int64, job *schema.Job, getTags func() ([]*schema.Tag, error)) ([]*schema.Tag, error) {
	q := sq.Insert("jobtag").Columns("job_id", "tag_id").Values(jobID, tagID)

	if _, err := q.RunWith(r.stmtCache).Exec(); err != nil {
		s, _, _ := q.ToSql()
		cclog.Errorf("Error adding tag with %s: %v", s, err)
		return nil, err
	}

	tags, err := getTags()
	if err != nil {
		cclog.Warnf("Error getting tags for job %d: %v", jobID, err)
		return nil, err
	}

	archiveTags, err := r.getArchiveTags(&jobID)
	if err != nil {
		cclog.Warnf("Error getting archive tags for job %d: %v", jobID, err)
		return nil, err
	}

	return tags, archive.UpdateTags(job, archiveTags)
}

// AddTagOrCreate adds the tag with the specified type and name to the job with the database id `jobId`.
// If such a tag does not yet exist, it is created.
func (r *JobRepository) AddTagOrCreate(user *schema.User, jobID int64, tagType string, tagName string, tagScope string) (tagID int64, err error) {
	// Default to "Global" scope if none defined
	if tagScope == "" {
		tagScope = "global"
	}

	writable, err := r.checkScopeAuth(user, "write", tagScope)
	if err != nil {
		return 0, err
	}
	if !writable {
		return 0, fmt.Errorf("cannot write tag scope with current authorization")
	}

	tagID, exists := r.TagID(tagType, tagName, tagScope)
	if !exists {
		tagID, err = r.CreateTag(tagType, tagName, tagScope)
		if err != nil {
			return 0, err
		}
	}

	if _, err := r.AddTag(user, jobID, tagID); err != nil {
		return 0, err
	}

	return tagID, nil
}

func (r *JobRepository) AddTagOrCreateDirect(jobID int64, tagType string, tagName string) (tagID int64, err error) {
	tagScope := "global"

	tagID, exists := r.TagID(tagType, tagName, tagScope)
	if !exists {
		tagID, err = r.CreateTag(tagType, tagName, tagScope)
		if err != nil {
			return 0, err
		}
	}

	cclog.Infof("Adding tag %s:%s:%s (direct)", tagType, tagName, tagScope)

	if _, err := r.AddTagDirect(jobID, tagID); err != nil {
		return 0, err
	}

	return tagID, nil
}

func (r *JobRepository) HasTag(jobID int64, tagType string, tagName string) bool {
	var id int64
	q := sq.Select("id").From("tag").Join("jobtag ON jobtag.tag_id = tag.id").
		Where("jobtag.job_id = ?", jobID).Where("tag.tag_type = ?", tagType).
		Where("tag.tag_name = ?", tagName)
	err := q.RunWith(r.stmtCache).QueryRow().Scan(&id)
	if err != nil {
		return false
	} else {
		return true
	}
}

// TagID returns the database id of the tag with the specified type and name.
func (r *JobRepository) TagID(tagType string, tagName string, tagScope string) (tagID int64, exists bool) {
	exists = true
	if err := sq.Select("id").From("tag").
		Where("tag.tag_type = ?", tagType).Where("tag.tag_name = ?", tagName).Where("tag.tag_scope = ?", tagScope).
		RunWith(r.stmtCache).QueryRow().Scan(&tagID); err != nil {
		exists = false
	}
	return
}

// TagInfo returns the database infos of the tag with the specified id.
func (r *JobRepository) TagInfo(tagID int64) (tagType string, tagName string, tagScope string, exists bool) {
	exists = true
	if err := sq.Select("tag.tag_type", "tag.tag_name", "tag.tag_scope").From("tag").Where("tag.id = ?", tagID).
		RunWith(r.stmtCache).QueryRow().Scan(&tagType, &tagName, &tagScope); err != nil {
		exists = false
	}
	return
}

// GetTags returns a list of all scoped tags if job is nil or of the tags that the job with that database ID has.
func (r *JobRepository) GetTags(user *schema.User, job *int64) ([]*schema.Tag, error) {
	q := sq.Select("id", "tag_type", "tag_name", "tag_scope").From("tag")
	if job != nil {
		q = q.Join("jobtag ON jobtag.tag_id = tag.id").Where("jobtag.job_id = ?", *job)
	}

	rows, err := q.RunWith(r.stmtCache).Query()
	if err != nil {
		s, _, _ := q.ToSql()
		cclog.Errorf("Error get tags with %s: %v", s, err)
		return nil, err
	}
	defer rows.Close()

	tags := make([]*schema.Tag, 0)
	for rows.Next() {
		tag := &schema.Tag{}
		if err := rows.Scan(&tag.ID, &tag.Type, &tag.Name, &tag.Scope); err != nil {
			cclog.Warnf("Error while scanning tag rows in GetTags: %v", err)
			return nil, err
		}
		// Handle Scope Filtering: Tag Scope is Global, Private (== Username) or User is auth'd to view Admin Tags
		readable, err := r.checkScopeAuth(user, "read", tag.Scope)
		if err != nil {
			return nil, err
		}
		if readable {
			tags = append(tags, tag)
		}
	}

	if err := rows.Err(); err != nil {
		return nil, err
	}

	return tags, nil
}

func (r *JobRepository) GetTagsDirect(job *int64) ([]*schema.Tag, error) {
	q := sq.Select("id", "tag_type", "tag_name", "tag_scope").From("tag")
	if job != nil {
		q = q.Join("jobtag ON jobtag.tag_id = tag.id").Where("jobtag.job_id = ?", *job)
	}

	rows, err := q.RunWith(r.stmtCache).Query()
	if err != nil {
		s, _, _ := q.ToSql()
		cclog.Errorf("Error get tags with %s: %v", s, err)
		return nil, err
	}
	defer rows.Close()

	tags := make([]*schema.Tag, 0)
	for rows.Next() {
		tag := &schema.Tag{}
		if err := rows.Scan(&tag.ID, &tag.Type, &tag.Name, &tag.Scope); err != nil {
			cclog.Warnf("Error while scanning tag rows in GetTagsDirect: %v", err)
			return nil, err
		}
		tags = append(tags, tag)
	}

	if err := rows.Err(); err != nil {
		return nil, err
	}

	return tags, nil
}

// getArchiveTags returns all tags for a job WITHOUT applying scope-based filtering.
//
// This internal function is used exclusively for job archive synchronization where we need
// to store all tags regardless of the current user's permissions. Unlike GetTags() which
// filters by scope, this returns the complete unfiltered tag list.
//
// Parameters:
//   - job: Pointer to job database ID, or nil to return all tags in the system
//
// Returns all tags without scope filtering, used only for archive operations.
//
// WARNING: Do NOT expose this function to user-facing APIs as it bypasses authorization.
func (r *JobRepository) getArchiveTags(job *int64) ([]*schema.Tag, error) {
	q := sq.Select("id", "tag_type", "tag_name", "tag_scope").From("tag")
	if job != nil {
		q = q.Join("jobtag ON jobtag.tag_id = tag.id").Where("jobtag.job_id = ?", *job)
	}

	rows, err := q.RunWith(r.stmtCache).Query()
	if err != nil {
		s, _, _ := q.ToSql()
		cclog.Errorf("Error get tags with %s: %v", s, err)
		return nil, err
	}
	defer rows.Close()

	tags := make([]*schema.Tag, 0)
	for rows.Next() {
		tag := &schema.Tag{}
		if err := rows.Scan(&tag.ID, &tag.Type, &tag.Name, &tag.Scope); err != nil {
			cclog.Warnf("Error while scanning tag rows in getArchiveTags: %v", err)
			return nil, err
		}
		tags = append(tags, tag)
	}

	if err := rows.Err(); err != nil {
		return nil, err
	}

	return tags, nil
}

func (r *JobRepository) ImportTag(jobID int64, tagType string, tagName string, tagScope string) (err error) {
	// Import has no scope ctx, only import from metafile to DB (No recursive archive update required), only returns err

	tagID, exists := r.TagID(tagType, tagName, tagScope)
	if !exists {
		tagID, err = r.CreateTag(tagType, tagName, tagScope)
		if err != nil {
			return err
		}
	}

	q := sq.Insert("jobtag").Columns("job_id", "tag_id").Values(jobID, tagID)

	if _, err := q.RunWith(r.stmtCache).Exec(); err != nil {
		s, _, _ := q.ToSql()
		cclog.Errorf("Error adding tag on import with %s: %v", s, err)
		return err
	}

	return nil
}

// checkScopeAuth validates whether a user is authorized to perform an operation on a tag with the given scope.
//
// This function implements the tag scope authorization matrix:
//
//	Scope        | Read Access                      | Write Access
//	-------------|----------------------------------|----------------------------------
//	"global"     | All users                        | Admin, Support, API-only
//	"admin"      | Admin, Support                   | Admin, API-only
//	<username>   | Owner only                       | Owner only (private tags)
//
// Parameters:
//   - user: User attempting the operation (must not be nil)
//   - operation: Either "read" or "write"
//   - scope: Tag scope value ("global", "admin", or username for private tags)
//
// Returns:
//   - pass: true if authorized, false if denied
//   - err: error only if operation is invalid or user is nil
//
// Special cases:
//   - API-only users (single role: RoleApi) can write to admin and global scopes for automation
//   - Private tags use the username as scope, granting exclusive access to that user
func (r *JobRepository) checkScopeAuth(user *schema.User, operation string, scope string) (pass bool, err error) {
	if user != nil {
		switch {
		case operation == "write" && scope == "admin":
			if user.HasRole(schema.RoleAdmin) || (len(user.Roles) == 1 && user.HasRole(schema.RoleAPI)) {
				return true, nil
			}
			return false, nil
		case operation == "write" && scope == "global":
			if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) || (len(user.Roles) == 1 && user.HasRole(schema.RoleAPI)) {
				return true, nil
			}
			return false, nil
		case operation == "write" && scope == user.Username:
			return true, nil
		case operation == "read" && scope == "admin":
			return user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}), nil
		case operation == "read" && scope == "global":
			return true, nil
		case operation == "read" && scope == user.Username:
			return true, nil
		default:
			if operation == "read" || operation == "write" {
				// No acceptable scope: deny tag
				return false, nil
			} else {
				return false, fmt.Errorf("error while checking tag operation auth: unknown operation (%s)", operation)
			}
		}
	} else {
		return false, fmt.Errorf("error while checking tag operation auth: no user in context")
	}
}


     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 archiver provides asynchronous job archiving functionality for ClusterCockpit.
     7→//
     8→// The archiver runs a background worker goroutine that processes job archiving requests
     9→// from a buffered channel. When jobs complete, their metric data is archived from the
    10→// metric store to the configured archive backend (filesystem, S3, etc.).
    11→//
    12→// # Architecture
    13→//
    14→// The archiver uses a producer-consumer pattern:
    15→//   - Producer: TriggerArchiving() sends jobs to archiveChannel
    16→//   - Consumer: archivingWorker() processes jobs from the channel
    17→//   - Coordination: sync.WaitGroup tracks pending archive operations
    18→//
    19→// # Lifecycle
    20→//
    21→//  1. Start(repo, ctx) - Initialize worker with context for cancellation
    22→//  2. TriggerArchiving(job) - Queue job for archiving (called when job stops)
    23→//  3. archivingWorker() - Background goroutine processes jobs
    24→//  4. Shutdown(timeout) - Graceful shutdown with timeout
    25→//
    26→// # Graceful Shutdown
    27→//
    28→// The archiver supports graceful shutdown with configurable timeout:
    29→//   - Closes channel to reject new jobs
    30→//   - Waits for pending jobs to complete (up to timeout)
    31→//   - Cancels context if timeout exceeded
    32→//   - Ensures worker goroutine exits cleanly
    33→//
    34→// # Example Usage
    35→//
    36→//	// Initialize archiver
    37→//	ctx, cancel := context.WithCancel(context.Background())
    38→//	defer cancel()
    39→//	archiver.Start(jobRepository, ctx)
    40→//
    41→//	// Trigger archiving when job completes
    42→//	archiver.TriggerArchiving(job)
    43→//
    44→//	// Graceful shutdown with 10 second timeout
    45→//	if err := archiver.Shutdown(10 * time.Second); err != nil {
    46→//	    log.Printf("Archiver shutdown timeout: %v", err)
    47→//	}
    48→package archiver
    49→
    50→import (
    51→	"context"
    52→	"fmt"
    53→	"sync"
    54→	"time"
    55→
    56→	"github.com/ClusterCockpit/cc-backend/internal/repository"
    57→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
    58→	"github.com/ClusterCockpit/cc-lib/v2/schema"
    59→	sq "github.com/Masterminds/squirrel"
    60→)
    61→
    62→var (
    63→	archivePending sync.WaitGroup
    64→	archiveChannel chan *schema.Job
    65→	jobRepo        *repository.JobRepository
    66→	shutdownCtx    context.Context
    67→	shutdownCancel context.CancelFunc
    68→	workerDone     chan struct{}
    69→)
    70→
    71→// Start initializes the archiver and starts the background worker goroutine.
    72→//
    73→// The archiver processes job archiving requests asynchronously via a buffered channel.
    74→// Jobs are sent to the channel using TriggerArchiving() and processed by the worker.
    75→//
    76→// Parameters:
    77→//   - r: JobRepository instance for database operations
    78→//   - ctx: Context for cancellation (shutdown signal propagation)
    79→//
    80→// The worker goroutine will run until:
    81→//   - ctx is cancelled (via parent shutdown)
    82→//   - archiveChannel is closed (via Shutdown())
    83→//
    84→// Must be called before TriggerArchiving(). Safe to call only once.
    85→func Start(r *repository.JobRepository, ctx context.Context) {
    86→	shutdownCtx, shutdownCancel = context.WithCancel(ctx)
    87→	archiveChannel = make(chan *schema.Job, 128)
    88→	workerDone = make(chan struct{})
    89→	jobRepo = r
    90→
    91→	go archivingWorker()
    92→}
    93→
    94→// archivingWorker is the background goroutine that processes job archiving requests.
    95→//
    96→// The worker loop:
    97→//  1. Blocks waiting for jobs on archiveChannel or shutdown signal
    98→//  2. Fetches job metadata from repository
    99→//  3. Archives job data to configured backend (calls ArchiveJob)
   100→//  4. Updates job footprint and energy metrics in database
   101→//  5. Marks job as successfully archived
   102→//  6. Calls job stop hooks
   103→//
   104→// The worker exits when:
   105→//   - shutdownCtx is cancelled (timeout during shutdown)
   106→//   - archiveChannel is closed (normal shutdown)
   107→//
   108→// Errors during archiving are logged and the job is marked as failed,
   109→// but the worker continues processing other jobs.
   110→func archivingWorker() {
   111→	defer close(workerDone)
   112→
   113→	for {
   114→		select {
   115→		case <-shutdownCtx.Done():
   116→			cclog.Info("Archive worker received shutdown signal")
   117→			return
   118→
   119→		case job, ok := <-archiveChannel:
   120→			if !ok {
   121→				cclog.Info("Archive channel closed, worker exiting")
   122→				return
   123→			}
   124→
   125→			start := time.Now()
   126→			// not using meta data, called to load JobMeta into Cache?
   127→			// will fail if job meta not in repository
   128→			if _, err := jobRepo.FetchMetadata(job); err != nil {
   129→				cclog.Errorf("archiving job (dbid: %d) failed at check metadata step: %s", *job.ID, err.Error())
   130→				jobRepo.UpdateMonitoringStatus(*job.ID, schema.MonitoringStatusArchivingFailed)
   131→				archivePending.Done()
   132→				continue
   133→			}
   134→
   135→			// ArchiveJob will fetch all the data from a MetricDataRepository and push into configured archive backend
   136→			// Use shutdown context to allow cancellation
   137→			jobMeta, err := ArchiveJob(job, shutdownCtx)
   138→			if err != nil {
   139→				cclog.Errorf("archiving job (dbid: %d) failed at archiving job step: %s", *job.ID, err.Error())
   140→				jobRepo.UpdateMonitoringStatus(*job.ID, schema.MonitoringStatusArchivingFailed)
   141→				archivePending.Done()
   142→				continue
   143→			}
   144→
   145→			stmt := sq.Update("job").Where("job.id = ?", job.ID)
   146→
   147→			if stmt, err = jobRepo.UpdateFootprint(stmt, jobMeta); err != nil {
   148→				cclog.Errorf("archiving job (dbid: %d) failed at update Footprint step: %s", *job.ID, err.Error())
   149→				archivePending.Done()
   150→				continue
   151→			}
   152→			if stmt, err = jobRepo.UpdateEnergy(stmt, jobMeta); err != nil {
   153→				cclog.Errorf("archiving job (dbid: %d) failed at update Energy step: %s", *job.ID, err.Error())
   154→				archivePending.Done()
   155→				continue
   156→			}
   157→			// Update the jobs database entry one last time:
   158→			stmt = jobRepo.MarkArchived(stmt, schema.MonitoringStatusArchivingSuccessful)
   159→			if err := jobRepo.Execute(stmt); err != nil {
   160→				cclog.Errorf("archiving job (dbid: %d) failed at db execute: %s", *job.ID, err.Error())
   161→				archivePending.Done()
   162→				continue
   163→			}
   164→			cclog.Debugf("archiving job %d took %s", job.JobID, time.Since(start))
   165→			cclog.Infof("archiving job (dbid: %d) successful", *job.ID)
   166→
   167→			repository.CallJobStopHooks(job)
   168→			archivePending.Done()
   169→		}
   170→	}
   171→}
   172→
   173→// TriggerArchiving queues a job for asynchronous archiving.
   174→//
   175→// This function should be called when a job completes (stops) to archive its
   176→// metric data from the metric store to the configured archive backend.
   177→//
   178→// The function:
   179→//  1. Increments the pending job counter (WaitGroup)
   180→//  2. Sends the job to the archiving channel (buffered, capacity 128)
   181→//  3. Returns immediately (non-blocking unless channel is full)
   182→//
   183→// The actual archiving is performed asynchronously by the worker goroutine.
   184→// Upon completion, the worker will decrement the pending counter.
   185→//
   186→// Panics if Start() has not been called first.
   187→func TriggerArchiving(job *schema.Job) {
   188→	if archiveChannel == nil {
   189→		cclog.Fatal("Cannot archive without archiving channel. Did you Start the archiver?")
   190→	}
   191→
   192→	archivePending.Add(1)
   193→	archiveChannel <- job
   194→}
   195→
   196→// Shutdown performs a graceful shutdown of the archiver with a configurable timeout.
   197→//
   198→// The shutdown process:
   199→//  1. Closes archiveChannel - no new jobs will be accepted
   200→//  2. Waits for pending jobs to complete (up to timeout duration)
   201→//  3. If timeout is exceeded:
   202→//     - Cancels shutdownCtx to interrupt ongoing ArchiveJob operations
   203→//     - Returns error indicating timeout
   204→//  4. Waits for worker goroutine to exit cleanly
   205→//
   206→// Parameters:
   207→//   - timeout: Maximum duration to wait for pending jobs to complete
   208→//     (recommended: 10-30 seconds for production)
   209→//
   210→// Returns:
   211→//   - nil if all jobs completed within timeout
   212→//   - error if timeout was exceeded (some jobs may not have been archived)
   213→//
   214→// Jobs that don't complete within the timeout will be marked as failed.
   215→// The function always ensures the worker goroutine exits before returning.
   216→//
   217→// Example:
   218→//
   219→//	if err := archiver.Shutdown(10 * time.Second); err != nil {
   220→//	    log.Printf("Some jobs did not complete: %v", err)
   221→//	}
   222→func Shutdown(timeout time.Duration) error {
   223→	cclog.Info("Initiating archiver shutdown...")
   224→
   225→	// Close channel to signal no more jobs will be accepted
   226→	close(archiveChannel)
   227→
   228→	// Create a channel to signal when all jobs are done
   229→	done := make(chan struct{})
   230→	go func() {
   231→		archivePending.Wait()
   232→		close(done)
   233→	}()
   234→
   235→	// Wait for jobs to complete or timeout
   236→	select {
   237→	case <-done:
   238→		cclog.Info("All archive jobs completed successfully")
   239→		// Wait for worker to exit
   240→		<-workerDone
   241→		return nil
   242→	case <-time.After(timeout):
   243→		cclog.Warn("Archiver shutdown timeout exceeded, cancelling remaining operations")
   244→		// Cancel any ongoing operations
   245→		shutdownCancel()
   246→		// Wait for worker to exit
   247→		<-workerDone
   248→		return fmt.Errorf("archiver shutdown timeout after %v", timeout)
   249→	}
   250→}
   251→

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

// Package archiver provides asynchronous job archiving functionality for ClusterCockpit.
//
// The archiver runs a background worker goroutine that processes job archiving requests
// from a buffered channel. When jobs complete, their metric data is archived from the
// metric store to the configured archive backend (filesystem, S3, etc.).
//
// # Architecture
//
// The archiver uses a producer-consumer pattern:
//   - Producer: TriggerArchiving() sends jobs to archiveChannel
//   - Consumer: archivingWorker() processes jobs from the channel
//   - Coordination: sync.WaitGroup tracks pending archive operations
//
// # Lifecycle
//
//  1. Start(repo, ctx) - Initialize worker with context for cancellation
//  2. TriggerArchiving(job) - Queue job for archiving (called when job stops)
//  3. archivingWorker() - Background goroutine processes jobs
//  4. Shutdown(timeout) - Graceful shutdown with timeout
//
// # Graceful Shutdown
//
// The archiver supports graceful shutdown with configurable timeout:
//   - Closes channel to reject new jobs
//   - Waits for pending jobs to complete (up to timeout)
//   - Cancels context if timeout exceeded
//   - Ensures worker goroutine exits cleanly
//
// # Example Usage
//
//	// Initialize archiver
//	ctx, cancel := context.WithCancel(context.Background())
//	defer cancel()
//	archiver.Start(jobRepository, ctx)
//
//	// Trigger archiving when job completes
//	archiver.TriggerArchiving(job)
//
//	// Graceful shutdown with 10 second timeout
//	if err := archiver.Shutdown(10 * time.Second); err != nil {
//	    log.Printf("Archiver shutdown timeout: %v", err)
//	}
package archiver

import (
	"context"
	"fmt"
	"sync"
	"time"

	"github.com/ClusterCockpit/cc-backend/internal/repository"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	sq "github.com/Masterminds/squirrel"
)

var (
	archivePending sync.WaitGroup
	archiveChannel chan *schema.Job
	jobRepo        *repository.JobRepository
	shutdownCtx    context.Context
	shutdownCancel context.CancelFunc
	workerDone     chan struct{}
)

// Start initializes the archiver and starts the background worker goroutine.
//
// The archiver processes job archiving requests asynchronously via a buffered channel.
// Jobs are sent to the channel using TriggerArchiving() and processed by the worker.
//
// Parameters:
//   - r: JobRepository instance for database operations
//   - ctx: Context for cancellation (shutdown signal propagation)
//
// The worker goroutine will run until:
//   - ctx is cancelled (via parent shutdown)
//   - archiveChannel is closed (via Shutdown())
//
// Must be called before TriggerArchiving(). Safe to call only once.
func Start(r *repository.JobRepository, ctx context.Context) {
	shutdownCtx, shutdownCancel = context.WithCancel(ctx)
	archiveChannel = make(chan *schema.Job, 128)
	workerDone = make(chan struct{})
	jobRepo = r

	go archivingWorker()
}

// archivingWorker is the background goroutine that processes job archiving requests.
//
// The worker loop:
//  1. Blocks waiting for jobs on archiveChannel or shutdown signal
//  2. Fetches job metadata from repository
//  3. Archives job data to configured backend (calls ArchiveJob)
//  4. Updates job footprint and energy metrics in database
//  5. Marks job as successfully archived
//  6. Calls job stop hooks
//
// The worker exits when:
//   - shutdownCtx is cancelled (timeout during shutdown)
//   - archiveChannel is closed (normal shutdown)
//
// Errors during archiving are logged and the job is marked as failed,
// but the worker continues processing other jobs.
func archivingWorker() {
	defer close(workerDone)

	for {
		select {
		case <-shutdownCtx.Done():
			cclog.Info("Archive worker received shutdown signal")
			return

		case job, ok := <-archiveChannel:
			if !ok {
				cclog.Info("Archive channel closed, worker exiting")
				return
			}

			start := time.Now()
			// not using meta data, called to load JobMeta into Cache?
			// will fail if job meta not in repository
			if _, err := jobRepo.FetchMetadata(job); err != nil {
				cclog.Errorf("archiving job (dbid: %d) failed at check metadata step: %s", *job.ID, err.Error())
				jobRepo.UpdateMonitoringStatus(*job.ID, schema.MonitoringStatusArchivingFailed)
				archivePending.Done()
				continue
			}

			// ArchiveJob will fetch all the data from a MetricDataRepository and push into configured archive backend
			// Use shutdown context to allow cancellation
			jobMeta, err := ArchiveJob(job, shutdownCtx)
			if err != nil {
				cclog.Errorf("archiving job (dbid: %d) failed at archiving job step: %s", *job.ID, err.Error())
				jobRepo.UpdateMonitoringStatus(*job.ID, schema.MonitoringStatusArchivingFailed)
				archivePending.Done()
				continue
			}

			stmt := sq.Update("job").Where("job.id = ?", job.ID)

			if stmt, err = jobRepo.UpdateFootprint(stmt, jobMeta); err != nil {
				cclog.Errorf("archiving job (dbid: %d) failed at update Footprint step: %s", *job.ID, err.Error())
				archivePending.Done()
				continue
			}
			if stmt, err = jobRepo.UpdateEnergy(stmt, jobMeta); err != nil {
				cclog.Errorf("archiving job (dbid: %d) failed at update Energy step: %s", *job.ID, err.Error())
				archivePending.Done()
				continue
			}
			// Update the jobs database entry one last time:
			stmt = jobRepo.MarkArchived(stmt, schema.MonitoringStatusArchivingSuccessful)
			if err := jobRepo.Execute(stmt); err != nil {
				cclog.Errorf("archiving job (dbid: %d) failed at db execute: %s", *job.ID, err.Error())
				archivePending.Done()
				continue
			}
			cclog.Debugf("archiving job %d took %s", job.JobID, time.Since(start))
			cclog.Infof("archiving job (dbid: %d) successful", *job.ID)

			repository.CallJobStopHooks(job)
			archivePending.Done()
		}
	}
}

// TriggerArchiving queues a job for asynchronous archiving.
//
// This function should be called when a job completes (stops) to archive its
// metric data from the metric store to the configured archive backend.
//
// The function:
//  1. Increments the pending job counter (WaitGroup)
//  2. Sends the job to the archiving channel (buffered, capacity 128)
//  3. Returns immediately (non-blocking unless channel is full)
//
// The actual archiving is performed asynchronously by the worker goroutine.
// Upon completion, the worker will decrement the pending counter.
//
// Panics if Start() has not been called first.
func TriggerArchiving(job *schema.Job) {
	if archiveChannel == nil {
		cclog.Fatal("Cannot archive without archiving channel. Did you Start the archiver?")
	}

	archivePending.Add(1)
	archiveChannel <- job
}

// Shutdown performs a graceful shutdown of the archiver with a configurable timeout.
//
// The shutdown process:
//  1. Closes archiveChannel - no new jobs will be accepted
//  2. Waits for pending jobs to complete (up to timeout duration)
//  3. If timeout is exceeded:
//     - Cancels shutdownCtx to interrupt ongoing ArchiveJob operations
//     - Returns error indicating timeout
//  4. Waits for worker goroutine to exit cleanly
//
// Parameters:
//   - timeout: Maximum duration to wait for pending jobs to complete
//     (recommended: 10-30 seconds for production)
//
// Returns:
//   - nil if all jobs completed within timeout
//   - error if timeout was exceeded (some jobs may not have been archived)
//
// Jobs that don't complete within the timeout will be marked as failed.
// The function always ensures the worker goroutine exits before returning.
//
// Example:
//
//	if err := archiver.Shutdown(10 * time.Second); err != nil {
//	    log.Printf("Some jobs did not complete: %v", err)
//	}
func Shutdown(timeout time.Duration) error {
	cclog.Info("Initiating archiver shutdown...")

	// Close channel to signal no more jobs will be accepted
	close(archiveChannel)

	// Create a channel to signal when all jobs are done
	done := make(chan struct{})
	go func() {
		archivePending.Wait()
		close(done)
	}()

	// Wait for jobs to complete or timeout
	select {
	case <-done:
		cclog.Info("All archive jobs completed successfully")
		// Wait for worker to exit
		<-workerDone
		return nil
	case <-time.After(timeout):
		cclog.Warn("Archiver shutdown timeout exceeded, cancelling remaining operations")
		// Cancel any ongoing operations
		shutdownCancel()
		// Wait for worker to exit
		<-workerDone
		return fmt.Errorf("archiver shutdown timeout after %v", timeout)
	}
}


     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 importer
     7→
     8→import (
     9→	"bytes"
    10→	"encoding/json"
    11→	"fmt"
    12→	"os"
    13→	"strings"
    14→
    15→	"github.com/ClusterCockpit/cc-backend/internal/config"
    16→	"github.com/ClusterCockpit/cc-backend/internal/repository"
    17→	"github.com/ClusterCockpit/cc-backend/pkg/archive"
    18→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
    19→	"github.com/ClusterCockpit/cc-lib/v2/schema"
    20→)
    21→
    22→// HandleImportFlag imports jobs from file pairs specified in a comma-separated flag string.
    23→//
    24→// The flag format is: "<path-to-meta.json>:<path-to-data.json>[,<path-to-meta2.json>:<path-to-data2.json>,...]"
    25→//
    26→// For each job pair, this function:
    27→//  1. Reads and validates the metadata JSON file (schema.Job)
    28→//  2. Reads and validates the job data JSON file (schema.JobData)
    29→//  3. Enriches the job with calculated footprints and energy metrics
    30→//  4. Validates the job using SanityChecks()
    31→//  5. Imports the job into the archive
    32→//  6. Inserts the job into the database with associated tags
    33→//
    34→// Schema validation is performed if config.Keys.Validate is true.
    35→//
    36→// Returns an error if file reading, validation, enrichment, or database operations fail.
    37→// The function stops processing on the first error encountered.
    38→func HandleImportFlag(flag string) error {
    39→	r := repository.GetJobRepository()
    40→
    41→	for pair := range strings.SplitSeq(flag, ",") {
    42→		files := strings.Split(pair, ":")
    43→		if len(files) != 2 {
    44→			return fmt.Errorf("REPOSITORY/INIT > invalid import flag format")
    45→		}
    46→
    47→		raw, err := os.ReadFile(files[0])
    48→		if err != nil {
    49→			cclog.Warn("Error while reading metadata file for import")
    50→			return err
    51→		}
    52→
    53→		if config.Keys.Validate {
    54→			if err = schema.Validate(schema.Meta, bytes.NewReader(raw)); err != nil {
    55→				return fmt.Errorf("REPOSITORY/INIT > validate job meta: %v", err)
    56→			}
    57→		}
    58→		dec := json.NewDecoder(bytes.NewReader(raw))
    59→		dec.DisallowUnknownFields()
    60→		job := schema.Job{
    61→			Shared:           "none",
    62→			MonitoringStatus: schema.MonitoringStatusRunningOrArchiving,
    63→		}
    64→		if err = dec.Decode(&job); err != nil {
    65→			cclog.Warn("Error while decoding raw json metadata for import")
    66→			return err
    67→		}
    68→
    69→		raw, err = os.ReadFile(files[1])
    70→		if err != nil {
    71→			cclog.Warn("Error while reading jobdata file for import")
    72→			return err
    73→		}
    74→
    75→		if config.Keys.Validate {
    76→			if err = schema.Validate(schema.Data, bytes.NewReader(raw)); err != nil {
    77→				return fmt.Errorf("REPOSITORY/INIT > validate job data: %v", err)
    78→			}
    79→		}
    80→		dec = json.NewDecoder(bytes.NewReader(raw))
    81→		dec.DisallowUnknownFields()
    82→		jobData := schema.JobData{}
    83→		if err = dec.Decode(&jobData); err != nil {
    84→			cclog.Warn("Error while decoding raw json jobdata for import")
    85→			return err
    86→		}
    87→
    88→		job.MonitoringStatus = schema.MonitoringStatusArchivingSuccessful
    89→
    90→		if err = enrichJobMetadata(&job); err != nil {
    91→			cclog.Errorf("Error enriching job metadata: %v", err)
    92→			return err
    93→		}
    94→
    95→		if err = SanityChecks(&job); err != nil {
    96→			cclog.Warn("BaseJob SanityChecks failed")
    97→			return err
    98→		}
    99→
   100→		if err = archive.GetHandle().ImportJob(&job, &jobData); err != nil {
   101→			cclog.Error("Error while importing job")
   102→			return err
   103→		}
   104→
   105→		id, err := r.InsertJobDirect(&job)
   106→		if err != nil {
   107→			cclog.Warn("Error while job db insert")
   108→			return err
   109→		}
   110→
   111→		for _, tag := range job.Tags {
   112→			if err := r.ImportTag(id, tag.Type, tag.Name, tag.Scope); err != nil {
   113→				cclog.Error("Error while adding or creating tag on import")
   114→				return err
   115→			}
   116→		}
   117→
   118→		cclog.Infof("successfully imported a new job (jobId: %d, cluster: %s, dbid: %d)", job.JobID, job.Cluster, id)
   119→	}
   120→	return nil
   121→}
   122→

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package importer

import (
	"bytes"
	"encoding/json"
	"fmt"
	"os"
	"strings"

	"github.com/ClusterCockpit/cc-backend/internal/config"
	"github.com/ClusterCockpit/cc-backend/internal/repository"
	"github.com/ClusterCockpit/cc-backend/pkg/archive"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
)

// HandleImportFlag imports jobs from file pairs specified in a comma-separated flag string.
//
// The flag format is: "<path-to-meta.json>:<path-to-data.json>[,<path-to-meta2.json>:<path-to-data2.json>,...]"
//
// For each job pair, this function:
//  1. Reads and validates the metadata JSON file (schema.Job)
//  2. Reads and validates the job data JSON file (schema.JobData)
//  3. Enriches the job with calculated footprints and energy metrics
//  4. Validates the job using SanityChecks()
//  5. Imports the job into the archive
//  6. Inserts the job into the database with associated tags
//
// Schema validation is performed if config.Keys.Validate is true.
//
// Returns an error if file reading, validation, enrichment, or database operations fail.
// The function stops processing on the first error encountered.
func HandleImportFlag(flag string) error {
	r := repository.GetJobRepository()

	for pair := range strings.SplitSeq(flag, ",") {
		files := strings.Split(pair, ":")
		if len(files) != 2 {
			return fmt.Errorf("REPOSITORY/INIT > invalid import flag format")
		}

		raw, err := os.ReadFile(files[0])
		if err != nil {
			cclog.Warn("Error while reading metadata file for import")
			return err
		}

		if config.Keys.Validate {
			if err = schema.Validate(schema.Meta, bytes.NewReader(raw)); err != nil {
				return fmt.Errorf("REPOSITORY/INIT > validate job meta: %v", err)
			}
		}
		dec := json.NewDecoder(bytes.NewReader(raw))
		dec.DisallowUnknownFields()
		job := schema.Job{
			Shared:           "none",
			MonitoringStatus: schema.MonitoringStatusRunningOrArchiving,
		}
		if err = dec.Decode(&job); err != nil {
			cclog.Warn("Error while decoding raw json metadata for import")
			return err
		}

		raw, err = os.ReadFile(files[1])
		if err != nil {
			cclog.Warn("Error while reading jobdata file for import")
			return err
		}

		if config.Keys.Validate {
			if err = schema.Validate(schema.Data, bytes.NewReader(raw)); err != nil {
				return fmt.Errorf("REPOSITORY/INIT > validate job data: %v", err)
			}
		}
		dec = json.NewDecoder(bytes.NewReader(raw))
		dec.DisallowUnknownFields()
		jobData := schema.JobData{}
		if err = dec.Decode(&jobData); err != nil {
			cclog.Warn("Error while decoding raw json jobdata for import")
			return err
		}

		job.MonitoringStatus = schema.MonitoringStatusArchivingSuccessful

		if err = enrichJobMetadata(&job); err != nil {
			cclog.Errorf("Error enriching job metadata: %v", err)
			return err
		}

		if err = SanityChecks(&job); err != nil {
			cclog.Warn("BaseJob SanityChecks failed")
			return err
		}

		if err = archive.GetHandle().ImportJob(&job, &jobData); err != nil {
			cclog.Error("Error while importing job")
			return err
		}

		id, err := r.InsertJobDirect(&job)
		if err != nil {
			cclog.Warn("Error while job db insert")
			return err
		}

		for _, tag := range job.Tags {
			if err := r.ImportTag(id, tag.Type, tag.Name, tag.Scope); err != nil {
				cclog.Error("Error while adding or creating tag on import")
				return err
			}
		}

		cclog.Infof("successfully imported a new job (jobId: %d, cluster: %s, dbid: %d)", job.JobID, job.Cluster, id)
	}
	return nil
}


Let me check a few more write-heavy paths.


     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 taskmanager
     7→
     8→import (
     9→	"context"
    10→	"math"
    11→	"time"
    12→
    13→	"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"
    14→	"github.com/ClusterCockpit/cc-backend/pkg/archive"
    15→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
    16→	"github.com/ClusterCockpit/cc-lib/v2/schema"
    17→	sq "github.com/Masterminds/squirrel"
    18→	"github.com/go-co-op/gocron/v2"
    19→)
    20→
    21→func RegisterFootprintWorker() {
    22→	var frequency string
    23→	if Keys.FootprintWorker != "" {
    24→		frequency = Keys.FootprintWorker
    25→	} else {
    26→		frequency = "10m"
    27→	}
    28→
    29→	d, err := parseDuration(frequency)
    30→	if err != nil {
    31→		cclog.Errorf("RegisterFootprintWorker: %v", err)
    32→		return
    33→	}
    34→
    35→	cclog.Infof("Register Footprint Update service with %s interval", frequency)
    36→
    37→	s.NewJob(gocron.DurationJob(d),
    38→		gocron.NewTask(
    39→			func() {
    40→				s := time.Now()
    41→				c := 0
    42→				ce := 0
    43→				cl := 0
    44→				cclog.Infof("Update Footprints started at %s", s.Format(time.RFC3339))
    45→
    46→				for _, cluster := range archive.Clusters {
    47→					sCluster := time.Now()
    48→					jobs, err := jobRepo.FindRunningJobs(cluster.Name)
    49→					if err != nil {
    50→						continue
    51→					}
    52→					// NOTE: Additional SubCluster Loop Could Allow For Limited List Of Footprint-Metrics Only.
    53→					//       - Chunk-Size Would Then Be 'SubCluster' (Running Jobs, Transactions) as Lists Can Change Within SCs
    54→					//       - Would Require Review of 'updateFootprint' Usage (Logic Could Possibly Be Included Here Completely)
    55→					allMetrics := make([]string, 0)
    56→					metricConfigs := archive.GetCluster(cluster.Name).MetricConfig
    57→					for _, mc := range metricConfigs {
    58→						allMetrics = append(allMetrics, mc.Name)
    59→					}
    60→
    61→					pendingStatements := []sq.UpdateBuilder{}
    62→
    63→					for _, job := range jobs {
    64→						cclog.Debugf("Prepare job %d", job.JobID)
    65→						cl++
    66→
    67→						sJob := time.Now()
    68→
    69→						ms, err := metricdispatch.GetMetricDataRepo(job.Cluster, job.SubCluster)
    70→						if err != nil {
    71→							cclog.Errorf("failed to access metricDataRepo for cluster %s-%s: %s",
    72→								job.Cluster, job.SubCluster, err.Error())
    73→							continue
    74→						}
    75→
    76→						jobStats, err := ms.LoadStats(job, allMetrics, context.Background())
    77→						if err != nil {
    78→							cclog.Errorf("error wile loading job data stats for footprint update: %v", err)
    79→							ce++
    80→							continue
    81→						}
    82→
    83→						job.Statistics = make(map[string]schema.JobStatistics)
    84→
    85→						for _, metric := range allMetrics {
    86→							avg, min, max := 0.0, 0.0, 0.0
    87→							data, ok := jobStats[metric] // JobStats[Metric1:[Hostname1:[Stats], Hostname2:[Stats], ...], Metric2[...] ...]
    88→							if ok {
    89→								for _, res := range job.Resources {
    90→									hostStats, ok := data[res.Hostname]
    91→									if ok {
    92→										avg += hostStats.Avg
    93→										min = math.Min(min, hostStats.Min)
    94→										max = math.Max(max, hostStats.Max)
    95→									}
    96→
    97→								}
    98→							}
    99→
   100→							// Add values rounded to 2 digits: repo.LoadStats may return unrounded
   101→							job.Statistics[metric] = schema.JobStatistics{
   102→								Unit: schema.Unit{
   103→									Prefix: archive.GetMetricConfig(job.Cluster, metric).Unit.Prefix,
   104→									Base:   archive.GetMetricConfig(job.Cluster, metric).Unit.Base,
   105→								},
   106→								Avg: (math.Round((avg/float64(job.NumNodes))*100) / 100),
   107→								Min: (math.Round(min*100) / 100),
   108→								Max: (math.Round(max*100) / 100),
   109→							}
   110→						}
   111→
   112→						// Build Statement per Job, Add to Pending Array
   113→						stmt := sq.Update("job")
   114→						stmt, err = jobRepo.UpdateFootprint(stmt, job)
   115→						if err != nil {
   116→							cclog.Errorf("update job (dbid: %d) statement build failed at footprint step: %s", *job.ID, err.Error())
   117→							ce++
   118→							continue
   119→						}
   120→						stmt = stmt.Where("job.id = ?", job.ID)
   121→
   122→						pendingStatements = append(pendingStatements, stmt)
   123→						cclog.Debugf("Job %d took %s", job.JobID, time.Since(sJob))
   124→					}
   125→
   126→					t, err := jobRepo.TransactionInit()
   127→					if err != nil {
   128→						cclog.Errorf("failed TransactionInit %v", err)
   129→						cclog.Errorf("skipped %d transactions for cluster %s", len(pendingStatements), cluster.Name)
   130→						ce += len(pendingStatements)
   131→					} else {
   132→						for _, ps := range pendingStatements {
   133→							query, args, err := ps.ToSql()
   134→							if err != nil {
   135→								cclog.Errorf("failed in ToSQL conversion: %v", err)
   136→								ce++
   137→							} else {
   138→								// args...: Footprint-JSON, Energyfootprint-JSON, TotalEnergy, JobID
   139→								jobRepo.TransactionAdd(t, query, args...)
   140→								c++
   141→							}
   142→						}
   143→						jobRepo.TransactionEnd(t)
   144→					}
   145→					cclog.Debugf("Finish Cluster %s, took %s\n", cluster.Name, time.Since(sCluster))
   146→				}
   147→				cclog.Infof("Updating %d (of %d; Skipped %d) Footprints is done and took %s", c, cl, ce, time.Since(s))
   148→			}))
   149→}
   150→

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package taskmanager

import (
	"context"
	"math"
	"time"

	"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"
	"github.com/ClusterCockpit/cc-backend/pkg/archive"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	sq "github.com/Masterminds/squirrel"
	"github.com/go-co-op/gocron/v2"
)

func RegisterFootprintWorker() {
	var frequency string
	if Keys.FootprintWorker != "" {
		frequency = Keys.FootprintWorker
	} else {
		frequency = "10m"
	}

	d, err := parseDuration(frequency)
	if err != nil {
		cclog.Errorf("RegisterFootprintWorker: %v", err)
		return
	}

	cclog.Infof("Register Footprint Update service with %s interval", frequency)

	s.NewJob(gocron.DurationJob(d),
		gocron.NewTask(
			func() {
				s := time.Now()
				c := 0
				ce := 0
				cl := 0
				cclog.Infof("Update Footprints started at %s", s.Format(time.RFC3339))

				for _, cluster := range archive.Clusters {
					sCluster := time.Now()
					jobs, err := jobRepo.FindRunningJobs(cluster.Name)
					if err != nil {
						continue
					}
					// NOTE: Additional SubCluster Loop Could Allow For Limited List Of Footprint-Metrics Only.
					//       - Chunk-Size Would Then Be 'SubCluster' (Running Jobs, Transactions) as Lists Can Change Within SCs
					//       - Would Require Review of 'updateFootprint' Usage (Logic Could Possibly Be Included Here Completely)
					allMetrics := make([]string, 0)
					metricConfigs := archive.GetCluster(cluster.Name).MetricConfig
					for _, mc := range metricConfigs {
						allMetrics = append(allMetrics, mc.Name)
					}

					pendingStatements := []sq.UpdateBuilder{}

					for _, job := range jobs {
						cclog.Debugf("Prepare job %d", job.JobID)
						cl++

						sJob := time.Now()

						ms, err := metricdispatch.GetMetricDataRepo(job.Cluster, job.SubCluster)
						if err != nil {
							cclog.Errorf("failed to access metricDataRepo for cluster %s-%s: %s",
								job.Cluster, job.SubCluster, err.Error())
							continue
						}

						jobStats, err := ms.LoadStats(job, allMetrics, context.Background())
						if err != nil {
							cclog.Errorf("error wile loading job data stats for footprint update: %v", err)
							ce++
							continue
						}

						job.Statistics = make(map[string]schema.JobStatistics)

						for _, metric := range allMetrics {
							avg, min, max := 0.0, 0.0, 0.0
							data, ok := jobStats[metric] // JobStats[Metric1:[Hostname1:[Stats], Hostname2:[Stats], ...], Metric2[...] ...]
							if ok {
								for _, res := range job.Resources {
									hostStats, ok := data[res.Hostname]
									if ok {
										avg += hostStats.Avg
										min = math.Min(min, hostStats.Min)
										max = math.Max(max, hostStats.Max)
									}

								}
							}

							// Add values rounded to 2 digits: repo.LoadStats may return unrounded
							job.Statistics[metric] = schema.JobStatistics{
								Unit: schema.Unit{
									Prefix: archive.GetMetricConfig(job.Cluster, metric).Unit.Prefix,
									Base:   archive.GetMetricConfig(job.Cluster, metric).Unit.Base,
								},
								Avg: (math.Round((avg/float64(job.NumNodes))*100) / 100),
								Min: (math.Round(min*100) / 100),
								Max: (math.Round(max*100) / 100),
							}
						}

						// Build Statement per Job, Add to Pending Array
						stmt := sq.Update("job")
						stmt, err = jobRepo.UpdateFootprint(stmt, job)
						if err != nil {
							cclog.Errorf("update job (dbid: %d) statement build failed at footprint step: %s", *job.ID, err.Error())
							ce++
							continue
						}
						stmt = stmt.Where("job.id = ?", job.ID)

						pendingStatements = append(pendingStatements, stmt)
						cclog.Debugf("Job %d took %s", job.JobID, time.Since(sJob))
					}

					t, err := jobRepo.TransactionInit()
					if err != nil {
						cclog.Errorf("failed TransactionInit %v", err)
						cclog.Errorf("skipped %d transactions for cluster %s", len(pendingStatements), cluster.Name)
						ce += len(pendingStatements)
					} else {
						for _, ps := range pendingStatements {
							query, args, err := ps.ToSql()
							if err != nil {
								cclog.Errorf("failed in ToSQL conversion: %v", err)
								ce++
							} else {
								// args...: Footprint-JSON, Energyfootprint-JSON, TotalEnergy, JobID
								jobRepo.TransactionAdd(t, query, args...)
								c++
							}
						}
						jobRepo.TransactionEnd(t)
					}
					cclog.Debugf("Finish Cluster %s, took %s\n", cluster.Name, time.Since(sCluster))
				}
				cclog.Infof("Updating %d (of %d; Skipped %d) Footprints is done and took %s", c, cl, ce, time.Since(s))
			}))
}


     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 taskmanager
     7→
     8→import (
     9→	"time"
    10→
    11→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
    12→	"github.com/go-co-op/gocron/v2"
    13→)
    14→
    15→func RegisterUpdateDurationWorker() {
    16→	var frequency string
    17→	if Keys.DurationWorker != "" {
    18→		frequency = Keys.DurationWorker
    19→	} else {
    20→		frequency = "5m"
    21→	}
    22→
    23→	d, err := parseDuration(frequency)
    24→	if err != nil {
    25→		cclog.Errorf("RegisterUpdateDurationWorker: %v", err)
    26→		return
    27→	}
    28→
    29→	cclog.Infof("Register Duration Update service with %s interval", frequency)
    30→
    31→	s.NewJob(gocron.DurationJob(d),
    32→		gocron.NewTask(
    33→			func() {
    34→				start := time.Now()
    35→				cclog.Infof("Update duration started at %s", start.Format(time.RFC3339))
    36→				jobRepo.UpdateDuration()
    37→				cclog.Infof("Update duration is done and took %s", time.Since(start))
    38→			}))
    39→}
    40→

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package taskmanager

import (
	"time"

	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/go-co-op/gocron/v2"
)

func RegisterUpdateDurationWorker() {
	var frequency string
	if Keys.DurationWorker != "" {
		frequency = Keys.DurationWorker
	} else {
		frequency = "5m"
	}

	d, err := parseDuration(frequency)
	if err != nil {
		cclog.Errorf("RegisterUpdateDurationWorker: %v", err)
		return
	}

	cclog.Infof("Register Duration Update service with %s interval", frequency)

	s.NewJob(gocron.DurationJob(d),
		gocron.NewTask(
			func() {
				start := time.Now()
				cclog.Infof("Update duration started at %s", start.Format(time.RFC3339))
				jobRepo.UpdateDuration()
				cclog.Infof("Update duration is done and took %s", time.Since(start))
			}))
}


     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→package repository
     6→
     7→import (
     8→	"fmt"
     9→
    10→	"github.com/jmoiron/sqlx"
    11→)
    12→
    13→// Transaction wraps a database transaction for job-related operations.
    14→type Transaction struct {
    15→	tx *sqlx.Tx
    16→}
    17→
    18→// TransactionInit begins a new transaction.
    19→func (r *JobRepository) TransactionInit() (*Transaction, error) {
    20→	tx, err := r.DB.Beginx()
    21→	if err != nil {
    22→		return nil, fmt.Errorf("beginning transaction: %w", err)
    23→	}
    24→	return &Transaction{tx: tx}, nil
    25→}
    26→
    27→// Commit commits the transaction.
    28→// After calling Commit, the transaction should not be used again.
    29→func (t *Transaction) Commit() error {
    30→	if t.tx == nil {
    31→		return fmt.Errorf("transaction already committed or rolled back")
    32→	}
    33→	err := t.tx.Commit()
    34→	t.tx = nil // Mark as completed
    35→	if err != nil {
    36→		return fmt.Errorf("committing transaction: %w", err)
    37→	}
    38→	return nil
    39→}
    40→
    41→// Rollback rolls back the transaction.
    42→// It's safe to call Rollback on an already committed or rolled back transaction.
    43→func (t *Transaction) Rollback() error {
    44→	if t.tx == nil {
    45→		return nil // Already committed/rolled back
    46→	}
    47→	err := t.tx.Rollback()
    48→	t.tx = nil // Mark as completed
    49→	if err != nil {
    50→		return fmt.Errorf("rolling back transaction: %w", err)
    51→	}
    52→	return nil
    53→}
    54→
    55→// TransactionEnd commits the transaction.
    56→// Deprecated: Use Commit() instead.
    57→func (r *JobRepository) TransactionEnd(t *Transaction) error {
    58→	return t.Commit()
    59→}
    60→
    61→// TransactionAddNamed executes a named query within the transaction.
    62→func (r *JobRepository) TransactionAddNamed(
    63→	t *Transaction,
    64→	query string,
    65→	args ...any,
    66→) (int64, error) {
    67→	if t.tx == nil {
    68→		return 0, fmt.Errorf("transaction is nil or already completed")
    69→	}
    70→
    71→	res, err := t.tx.NamedExec(query, args)
    72→	if err != nil {
    73→		return 0, fmt.Errorf("named exec: %w", err)
    74→	}
    75→
    76→	id, err := res.LastInsertId()
    77→	if err != nil {
    78→		return 0, fmt.Errorf("getting last insert id: %w", err)
    79→	}
    80→
    81→	return id, nil
    82→}
    83→
    84→// TransactionAdd executes a query within the transaction.
    85→func (r *JobRepository) TransactionAdd(t *Transaction, query string, args ...any) (int64, error) {
    86→	if t.tx == nil {
    87→		return 0, fmt.Errorf("transaction is nil or already completed")
    88→	}
    89→
    90→	res, err := t.tx.Exec(query, args...)
    91→	if err != nil {
    92→		return 0, fmt.Errorf("exec: %w", err)
    93→	}
    94→
    95→	id, err := res.LastInsertId()
    96→	if err != nil {
    97→		return 0, fmt.Errorf("getting last insert id: %w", err)
    98→	}
    99→
   100→	return id, nil
   101→}
   102→

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package repository

import (
	"fmt"

	"github.com/jmoiron/sqlx"
)

// Transaction wraps a database transaction for job-related operations.
type Transaction struct {
	tx *sqlx.Tx
}

// TransactionInit begins a new transaction.
func (r *JobRepository) TransactionInit() (*Transaction, error) {
	tx, err := r.DB.Beginx()
	if err != nil {
		return nil, fmt.Errorf("beginning transaction: %w", err)
	}
	return &Transaction{tx: tx}, nil
}

// Commit commits the transaction.
// After calling Commit, the transaction should not be used again.
func (t *Transaction) Commit() error {
	if t.tx == nil {
		return fmt.Errorf("transaction already committed or rolled back")
	}
	err := t.tx.Commit()
	t.tx = nil // Mark as completed
	if err != nil {
		return fmt.Errorf("committing transaction: %w", err)
	}
	return nil
}

// Rollback rolls back the transaction.
// It's safe to call Rollback on an already committed or rolled back transaction.
func (t *Transaction) Rollback() error {
	if t.tx == nil {
		return nil // Already committed/rolled back
	}
	err := t.tx.Rollback()
	t.tx = nil // Mark as completed
	if err != nil {
		return fmt.Errorf("rolling back transaction: %w", err)
	}
	return nil
}

// TransactionEnd commits the transaction.
// Deprecated: Use Commit() instead.
func (r *JobRepository) TransactionEnd(t *Transaction) error {
	return t.Commit()
}

// TransactionAddNamed executes a named query within the transaction.
func (r *JobRepository) TransactionAddNamed(
	t *Transaction,
	query string,
	args ...any,
) (int64, error) {
	if t.tx == nil {
		return 0, fmt.Errorf("transaction is nil or already completed")
	}

	res, err := t.tx.NamedExec(query, args)
	if err != nil {
		return 0, fmt.Errorf("named exec: %w", err)
	}

	id, err := res.LastInsertId()
	if err != nil {
		return 0, fmt.Errorf("getting last insert id: %w", err)
	}

	return id, nil
}

// TransactionAdd executes a query within the transaction.
func (r *JobRepository) TransactionAdd(t *Transaction, query string, args ...any) (int64, error) {
	if t.tx == nil {
		return 0, fmt.Errorf("transaction is nil or already completed")
	}

	res, err := t.tx.Exec(query, args...)
	if err != nil {
		return 0, fmt.Errorf("exec: %w", err)
	}

	id, err := res.LastInsertId()
	if err != nil {
		return 0, fmt.Errorf("getting last insert id: %w", err)
	}

	return id, nil
}


Let me also check UpdateDuration to understand its DB pattern.