The file /Users/jan/prg/CC/cc-backend/internal/repository/dbConnection.go has been updated successfully.

func setupSqlite(db *sql.DB) error {
	pragmas := []string{
		"temp_store = memory",
	}

	for _, pragma := range pragmas {
		_, err := db.Exec("PRAGMA " + pragma)
		if err != nil {
			return err
		}
	}

	// Update query planner statistics so SQLite picks optimal indexes.
	// Without this, SQLite guesses row distributions and often chooses wrong
	// indexes for queries with IN clauses + ORDER BY, causing full table sorts
	// in temp B-trees instead of using covering indexes.
	if _, err := db.Exec("ANALYZE"); err != nil {
		cclog.Warnf("Failed to run ANALYZE: %v", err)
	}

	return nil
}

// 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 (
	"database/sql"
	"fmt"
	"net/url"
	"sync"
	"time"

	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/jmoiron/sqlx"
	"github.com/mattn/go-sqlite3"
	"github.com/qustavo/sqlhooks/v2"
)

var (
	dbConnOnce     sync.Once
	dbConnInstance *DBConnection
)

type DBConnection struct {
	DB     *sqlx.DB
	Driver string
}

type DatabaseOptions struct {
	URL                   string
	MaxOpenConnections    int
	MaxIdleConnections    int
	ConnectionMaxLifetime time.Duration
	ConnectionMaxIdleTime time.Duration
}

func setupSqlite(db *sql.DB) error {
	pragmas := []string{
		"temp_store = memory",
	}

	for _, pragma := range pragmas {
		_, err := db.Exec("PRAGMA " + pragma)
		if err != nil {
			return err
		}
	}

	return nil
}

func Connect(db string) {
	var err error
	var dbHandle *sqlx.DB

	dbConnOnce.Do(func() {
		opts := DatabaseOptions{
			URL:                   db,
			MaxOpenConnections:    repoConfig.MaxOpenConnections,
			MaxIdleConnections:    repoConfig.MaxIdleConnections,
			ConnectionMaxLifetime: repoConfig.ConnectionMaxLifetime,
			ConnectionMaxIdleTime: repoConfig.ConnectionMaxIdleTime,
		}

		// TODO: Have separate DB handles for Writes and Reads
		// Optimize SQLite connection: https://kerkour.com/sqlite-for-servers
		connectionURLParams := make(url.Values)
		connectionURLParams.Add("_txlock", "immediate")
		connectionURLParams.Add("_journal_mode", "WAL")
		connectionURLParams.Add("_busy_timeout", "5000")
		connectionURLParams.Add("_synchronous", "NORMAL")
		connectionURLParams.Add("_cache_size", "1000000000")
		connectionURLParams.Add("_foreign_keys", "true")
		opts.URL = fmt.Sprintf("file:%s?%s", opts.URL, connectionURLParams.Encode())

		if cclog.Loglevel() == "debug" {
			sql.Register("sqlite3WithHooks", sqlhooks.Wrap(&sqlite3.SQLiteDriver{}, &Hooks{}))
			dbHandle, err = sqlx.Open("sqlite3WithHooks", opts.URL)
		} else {
			dbHandle, err = sqlx.Open("sqlite3", opts.URL)
		}

		if err != nil {
			cclog.Abortf("DB Connection: Could not connect to SQLite database with sqlx.Open().\nError: %s\n", err.Error())
		}

		err = setupSqlite(dbHandle.DB)
		if err != nil {
			cclog.Abortf("Failed sqlite db setup.\nError: %s\n", err.Error())
		}

		dbHandle.SetMaxOpenConns(opts.MaxOpenConnections)
		dbHandle.SetMaxIdleConns(opts.MaxIdleConnections)
		dbHandle.SetConnMaxLifetime(opts.ConnectionMaxLifetime)
		dbHandle.SetConnMaxIdleTime(opts.ConnectionMaxIdleTime)

		dbConnInstance = &DBConnection{DB: dbHandle}
		err = checkDBVersion(dbHandle.DB)
		if err != nil {
			cclog.Abortf("DB Connection: Failed DB version check.\nError: %s\n", err.Error())
		}
	})
}

func GetConnection() *DBConnection {
	if dbConnInstance == nil {
		cclog.Fatalf("Database connection not initialized!")
	}

	return dbConnInstance
}

// ResetConnection closes the current database connection and resets the connection state.
// This function is intended for testing purposes only to allow test isolation.
func ResetConnection() error {
	if dbConnInstance != nil && dbConnInstance.DB != nil {
		if err := dbConnInstance.DB.Close(); err != nil {
			return fmt.Errorf("failed to close database connection: %w", err)
		}
	}

	dbConnInstance = nil
	dbConnOnce = sync.Once{}
	jobRepoInstance = nil
	jobRepoOnce = sync.Once{}
	nodeRepoInstance = nil
	nodeRepoOnce = sync.Once{}
	userRepoInstance = nil
	userRepoOnce = sync.Once{}
	userCfgRepoInstance = nil
	userCfgRepoOnce = sync.Once{}

	return nil
}

+  // Update query planner statistics so SQLite picks optimal indexes.

+  // Without this, SQLite guesses row distributions and often chooses wrong

+  // indexes for queries with IN clauses + ORDER BY, causing full table sorts

+  // in temp B-trees instead of using covering indexes.

+  if _, err := db.Exec("ANALYZE"); err != nil {

+    cclog.Warnf("Failed to run ANALYZE: %v", err)


Bash

go build ./... && go vet ./internal/repository/... && go test ./internal/repository/...

Build, vet, and test all changes


ok  	github.com/ClusterCockpit/cc-backend/internal/repository	0.882s


All passes. Here's a summary of findings and fixes:

---

## Findings

### Bug 1: Missing `defer rows.Close()` in `user.go:GetLdapUsernames()` (line 100)
**Fixed.** Every call leaked a database connection and its buffers.

### Bug 2: SQLite never runs `ANALYZE` — wrong index selection
**Fixed.** Added `ANALYZE` to `setupSqlite()` in `dbConnection.go`.

Without statistics in `sqlite_stat1`, SQLite's query planner **guessed** and chose `jobs_jobstate_energy` (a terrible index for this query) instead of scanning `jobs_starttime` in reverse. The result:

| | Before ANALYZE | After ANALYZE |
|---|---|---|
| Index used | `jobs_jobstate_energy` | `jobs_starttime` |
| Sort | **TEMP B-TREE** (materializes ALL rows) | None (index order) |
| Memory | Proportional to total matching rows | Constant |
| I/O | Full scan of all matching rows | Stops at OFFSET+LIMIT |

### Bug 3: `IN` clause + `ORDER BY` is fundamentally incompatible with composite indexes

Even with the "correct" index `(job_state, start_time)`, SQLite **cannot** merge-sort across 3 separate index range scans for `IN ('completed','running','failed')`. It always falls back to a temp B-tree sort. The only efficient plan is to use the standalone `jobs_starttime` index — which SQLite does automatically **after ANALYZE** because it realizes the 3 states cover virtually all rows, making the WHERE clause nearly a no-op.

### Observation: 79 indexes on the `job` table
This is excessive and actively harmful — it confuses the query planner (especially without ANALYZE) and slows writes. The `jobs_jobstate_starttime` index from migration 08 is also missing from the actual DB (only the 3-column `jobs_jobstate_starttime_duration` exists). This is worth investigating separately but is a schema/migration concern, not a code bug.


What optimal index selection do you suggest? The job table contains almost 20 million jobs in production causing timouts on any filter query that does not use and index.


Let me read the existing plan and explore the current index landscape and query patterns.


     1→# Fix Missing `rows.Close()` Memory Leaks in SQLite3 Queries
     2→
     3→## Context
     4→
     5→Production memory leaks traced to queries that do full table scans (e.g., job state list sorted by `start_time` on all jobs). The root cause is `sql.Rows` objects not being closed after query execution. In Go's `database/sql`, every `rows` returned by `.Query()` holds a database connection and associated buffers until `rows.Close()` is called. Without `defer rows.Close()`, these leak on every code path (both success and error returns).
     6→
     7→## Part 1: Missing `defer rows.Close()` (16 locations)
     8→
     9→**22 total `.Query()` calls** across the repository layer. **15 have `defer rows.Close()`**. **7 do not** (plus additional in tags/node). Additionally, 1 `Queryx` call in `tags.go` is also missing close.
    10→
    11→### 1a. `internal/repository/stats.go` — 7 functions
    12→
    13→Add `defer rows.Close()` immediately after the `if err != nil` check for each:
    14→
    15→| Line | Function |
    16→|------|----------|
    17→| 237 | `JobsStatsGrouped` |
    18→| 442 | `JobCountGrouped` |
    19→| 498 | `AddJobCountGrouped` |
    20→| 557 | `AddJobCount` |
    21→| 757 | `jobsStatisticsHistogram` |
    22→| 825 | `jobsDurationStatisticsHistogram` |
    23→| 950 | `jobsMetricStatisticsHistogram` |
    24→
    25→### 1b. `internal/repository/tags.go` — 5 locations
    26→
    27→| Line | Function | Detail |
    28→|------|----------|--------|
    29→| 284 | `CountTags` | Add `defer xrows.Close()` after Queryx error check |
    30→| 336 | `CountTags` | Add `defer rows.Close()` after Query error check |
    31→| 513 | `GetTags` | Add `defer rows.Close()` |
    32→| 546 | `GetTagsDirect` | Add `defer rows.Close()` |
    33→| 584 | `getArchiveTags` | Add `defer rows.Close()` |
    34→
    35→### 1c. `internal/repository/node.go` — 4 functions
    36→
    37→Replace manual `rows.Close()` in error-only paths with `defer rows.Close()`:
    38→
    39→| Line | Function | Also remove manual close at |
    40→|------|----------|----|
    41→| 368 | `QueryNodes` | L375 |
    42→| 417 | `QueryNodesWithMeta` | L427 |
    43→| 563 | `CountStates` | L569 |
    44→| 625 | `CountStatesTimed` | L633 |
    45→
    46→## Part 2: Missing `rows.Err()` Checks
    47→
    48→After a `rows.Next()` loop, `rows.Err()` should be checked to detect iteration errors (e.g., connection lost mid-scan). Currently only 3 of ~22 query sites check it.
    49→
    50→Add `rows.Err()` check after the scan loop in every function touched by Part 1. Pattern:
    51→
    52→```go
    53→for rows.Next() {
    54→    // ... scan ...
    55→}
    56→if err := rows.Err(); err != nil {
    57→    return nil, err
    58→}
    59→```
    60→
    61→Functions needing this (same 16 locations as Part 1 — all functions listed above).
    62→
    63→## Part 3: Unbounded Query Optimization
    64→
    65→### 3a. `CountStates` in `node.go:546` — counting in Go instead of SQL
    66→
    67→Currently fetches every individual row (`SELECT column FROM node ...`) and counts in Go with a map. Should use SQL aggregation instead:
    68→
    69→```go
    70→// BEFORE: fetches N rows, counts in Go
    71→sq.Select(column).From("node")...
    72→
    73→// AFTER: let SQL do the counting
    74→sq.Select(column, "COUNT(*) as count").From("node")...GroupBy(column)
    75→```
    76→
    77→This changes the scan from `rows.Scan(&state)` to `rows.Scan(&state, &count)` and eliminates the Go-side counting loop. Reduces rows transferred from N (number of nodes) to ~5 (number of distinct states).
    78→
    79→### 3b. `AddJobCount` in `stats.go:541` — uses `.Query()` instead of `.QueryRow()`
    80→
    81→`AddJobCount` builds a count query without GROUP BY (returns a single row) but uses `.Query()` + loop instead of `.QueryRow()`. Should be simplified to `QueryRow().Scan()` like `JobsStats` does. This eliminates the rows object entirely.
    82→
    83→## Summary of All Edits
    84→
    85→| File | Function | Actions |
    86→|------|----------|---------|
    87→| `stats.go` | `JobsStatsGrouped` | + `defer rows.Close()`, + `rows.Err()` |
    88→| `stats.go` | `JobCountGrouped` | + `defer rows.Close()`, + `rows.Err()` |
    89→| `stats.go` | `AddJobCountGrouped` | + `defer rows.Close()`, + `rows.Err()` |
    90→| `stats.go` | `AddJobCount` | Rewrite to use `QueryRow` instead of `Query` |
    91→| `stats.go` | `jobsStatisticsHistogram` | + `defer rows.Close()`, + `rows.Err()` |
    92→| `stats.go` | `jobsDurationStatisticsHistogram` | + `defer rows.Close()`, + `rows.Err()` |
    93→| `stats.go` | `jobsMetricStatisticsHistogram` | + `defer rows.Close()`, + `rows.Err()` |
    94→| `tags.go` | `CountTags` (xrows) | + `defer xrows.Close()`, + `xrows.Err()` after loop |
    95→| `tags.go` | `CountTags` (rows) | + `defer rows.Close()` (already has `rows.Err()`) |
    96→| `tags.go` | `GetTags` | + `defer rows.Close()`, + `rows.Err()` |
    97→| `tags.go` | `GetTagsDirect` | + `defer rows.Close()`, + `rows.Err()` |
    98→| `tags.go` | `getArchiveTags` | + `defer rows.Close()`, + `rows.Err()` |
    99→| `node.go` | `QueryNodes` | + `defer rows.Close()`, remove manual close, + `rows.Err()` |
   100→| `node.go` | `QueryNodesWithMeta` | + `defer rows.Close()`, remove manual close, + `rows.Err()` |
   101→| `node.go` | `CountStates` | Rewrite to use SQL `COUNT(*)...GROUP BY`, + `defer rows.Close()`, remove manual close |
   102→| `node.go` | `CountStatesTimed` | + `defer rows.Close()`, remove manual close, + `rows.Err()` |
   103→
   104→## Verification
   105→
   106→1. `go build ./...` — ensure it compiles
   107→2. `go test ./internal/repository/...` — run repository tests
   108→3. `go vet ./internal/repository/...` — static analysis
   109→

# Fix Missing `rows.Close()` Memory Leaks in SQLite3 Queries

## Context

Production memory leaks traced to queries that do full table scans (e.g., job state list sorted by `start_time` on all jobs). The root cause is `sql.Rows` objects not being closed after query execution. In Go's `database/sql`, every `rows` returned by `.Query()` holds a database connection and associated buffers until `rows.Close()` is called. Without `defer rows.Close()`, these leak on every code path (both success and error returns).

## Part 1: Missing `defer rows.Close()` (16 locations)

**22 total `.Query()` calls** across the repository layer. **15 have `defer rows.Close()`**. **7 do not** (plus additional in tags/node). Additionally, 1 `Queryx` call in `tags.go` is also missing close.

### 1a. `internal/repository/stats.go` — 7 functions

Add `defer rows.Close()` immediately after the `if err != nil` check for each:

| Line | Function |
|------|----------|
| 237 | `JobsStatsGrouped` |
| 442 | `JobCountGrouped` |
| 498 | `AddJobCountGrouped` |
| 557 | `AddJobCount` |
| 757 | `jobsStatisticsHistogram` |
| 825 | `jobsDurationStatisticsHistogram` |
| 950 | `jobsMetricStatisticsHistogram` |

### 1b. `internal/repository/tags.go` — 5 locations

| Line | Function | Detail |
|------|----------|--------|
| 284 | `CountTags` | Add `defer xrows.Close()` after Queryx error check |
| 336 | `CountTags` | Add `defer rows.Close()` after Query error check |
| 513 | `GetTags` | Add `defer rows.Close()` |
| 546 | `GetTagsDirect` | Add `defer rows.Close()` |
| 584 | `getArchiveTags` | Add `defer rows.Close()` |

### 1c. `internal/repository/node.go` — 4 functions

Replace manual `rows.Close()` in error-only paths with `defer rows.Close()`:

| Line | Function | Also remove manual close at |
|------|----------|----|
| 368 | `QueryNodes` | L375 |
| 417 | `QueryNodesWithMeta` | L427 |
| 563 | `CountStates` | L569 |
| 625 | `CountStatesTimed` | L633 |

## Part 2: Missing `rows.Err()` Checks

After a `rows.Next()` loop, `rows.Err()` should be checked to detect iteration errors (e.g., connection lost mid-scan). Currently only 3 of ~22 query sites check it.

Add `rows.Err()` check after the scan loop in every function touched by Part 1. Pattern:

```go
for rows.Next() {
    // ... scan ...
}
if err := rows.Err(); err != nil {
    return nil, err
}
```

Functions needing this (same 16 locations as Part 1 — all functions listed above).

## Part 3: Unbounded Query Optimization

### 3a. `CountStates` in `node.go:546` — counting in Go instead of SQL

Currently fetches every individual row (`SELECT column FROM node ...`) and counts in Go with a map. Should use SQL aggregation instead:

```go
// BEFORE: fetches N rows, counts in Go
sq.Select(column).From("node")...

// AFTER: let SQL do the counting
sq.Select(column, "COUNT(*) as count").From("node")...GroupBy(column)
```

This changes the scan from `rows.Scan(&state)` to `rows.Scan(&state, &count)` and eliminates the Go-side counting loop. Reduces rows transferred from N (number of nodes) to ~5 (number of distinct states).

### 3b. `AddJobCount` in `stats.go:541` — uses `.Query()` instead of `.QueryRow()`

`AddJobCount` builds a count query without GROUP BY (returns a single row) but uses `.Query()` + loop instead of `.QueryRow()`. Should be simplified to `QueryRow().Scan()` like `JobsStats` does. This eliminates the rows object entirely.

## Summary of All Edits

| File | Function | Actions |
|------|----------|---------|
| `stats.go` | `JobsStatsGrouped` | + `defer rows.Close()`, + `rows.Err()` |
| `stats.go` | `JobCountGrouped` | + `defer rows.Close()`, + `rows.Err()` |
| `stats.go` | `AddJobCountGrouped` | + `defer rows.Close()`, + `rows.Err()` |
| `stats.go` | `AddJobCount` | Rewrite to use `QueryRow` instead of `Query` |
| `stats.go` | `jobsStatisticsHistogram` | + `defer rows.Close()`, + `rows.Err()` |
| `stats.go` | `jobsDurationStatisticsHistogram` | + `defer rows.Close()`, + `rows.Err()` |
| `stats.go` | `jobsMetricStatisticsHistogram` | + `defer rows.Close()`, + `rows.Err()` |
| `tags.go` | `CountTags` (xrows) | + `defer xrows.Close()`, + `xrows.Err()` after loop |
| `tags.go` | `CountTags` (rows) | + `defer rows.Close()` (already has `rows.Err()`) |
| `tags.go` | `GetTags` | + `defer rows.Close()`, + `rows.Err()` |
| `tags.go` | `GetTagsDirect` | + `defer rows.Close()`, + `rows.Err()` |
| `tags.go` | `getArchiveTags` | + `defer rows.Close()`, + `rows.Err()` |
| `node.go` | `QueryNodes` | + `defer rows.Close()`, remove manual close, + `rows.Err()` |
| `node.go` | `QueryNodesWithMeta` | + `defer rows.Close()`, remove manual close, + `rows.Err()` |
| `node.go` | `CountStates` | Rewrite to use SQL `COUNT(*)...GROUP BY`, + `defer rows.Close()`, remove manual close |
| `node.go` | `CountStatesTimed` | + `defer rows.Close()`, remove manual close, + `rows.Err()` |

## Verification

1. `go build ./...` — ensure it compiles
2. `go test ./internal/repository/...` — run repository tests
3. `go vet ./internal/repository/...` — static analysis


Different task — index optimization. Let me explore the query patterns and current indexes comprehensively.


Agent

Map all job table query patterns

Prompt:
In /Users/jan/prg/CC/cc-backend, I need to understand ALL query patterns that hit the `job` table. This is for index optimization on a 20M row table.

Search thoroughly for:

1. **All SQL queries on the job table** - search for `.From("job")`, `FROM job`, `from("job")` in all .go files
2. **All WHERE clause builders** - read `internal/repository/jobQuery.go` fully, especially `BuildWhereClause` to understand all possible filter columns
3. **All ORDER BY patterns** - search for `OrderBy`, `ORDER BY` related to jobs
4. **All GROUP BY patterns** - search for `GroupBy`, `GROUP BY` related to jobs
5. **All JOIN patterns** - search for joins on the job table (jobtag, etc.)

For each query site, report:
- File and function name
- WHERE columns used
- ORDER BY columns used
- Whether it has LIMIT/pagination
- Whether it uses GROUP BY

Be very thorough - check jobQuery.go, job.go, jobFind.go, jobCreate.go, stats.go, tags.go, and any other files that query the job table.


In /Users/jan/prg/CC/cc-backend, I need to understand ALL query patterns that hit the `job` table. This is for index optimization on a 20M row table.

Search thoroughly for:

1. **All SQL queries on the job table** - search for `.From("job")`, `FROM job`, `from("job")` in all .go files
2. **All WHERE clause builders** - read `internal/repository/jobQuery.go` fully, especially `BuildWhereClause` to understand all possible filter columns
3. **All ORDER BY patterns** - search for `OrderBy`, `ORDER BY` related to jobs
4. **All GROUP BY patterns** - search for `GroupBy`, `GROUP BY` related to jobs
5. **All JOIN patterns** - search for joins on the job table (jobtag, etc.)

For each query site, report:
- File and function name
- WHERE columns used
- ORDER BY columns used
- Whether it has LIMIT/pagination
- Whether it uses GROUP BY

Be very thorough - check jobQuery.go, job.go, jobFind.go, jobCreate.go, stats.go, tags.go, and any other files that query the job table.

Prompt:
In /Users/jan/prg/CC/cc-backend, I need to understand ALL query patterns that hit the `job` table. This is for index optimization on a 20M row table.

Search thoroughly for:

1. **All SQL queries on the job table** - search for `.From("job")`, `FROM job`, `from("job")` in all .go files
2. **All WHERE clause builders** - read `internal/repository/jobQuery.go` fully, especially `BuildWhereClause` to understand all possible filter columns
3. **All ORDER BY patterns** - search for `OrderBy`, `ORDER BY` related to jobs
4. **All GROUP BY patterns** - search for `GroupBy`, `GROUP BY` related to jobs
5. **All JOIN patterns** - search for joins on the job table (jobtag, etc.)

For each query site, report:
- File and function name
- WHERE columns used
- ORDER BY columns used
- Whether it has LIMIT/pagination
- Whether it uses GROUP BY

Be very thorough - check jobQuery.go, job.go, jobFind.go, jobCreate.go, stats.go, tags.go, and any other files that query the job table.


Found 9 files
internal/repository/stats.go
internal/repository/tags.go
internal/repository/jobQuery.go
internal/repository/stats_test.go
internal/repository/jobCreate_test.go
internal/repository/jobCreate.go
internal/repository/jobFind.go
internal/repository/hooks_test.go
internal/repository/job.go


     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 job query functionality with filtering, pagination,
     7→// and security controls. This file contains the main query builders and security
     8→// checks for job retrieval operations.
     9→package repository
    10→
    11→import (
    12→	"context"
    13→	"errors"
    14→	"fmt"
    15→	"regexp"
    16→	"strings"
    17→	"time"
    18→
    19→	"github.com/ClusterCockpit/cc-backend/internal/config"
    20→	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
    21→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
    22→	"github.com/ClusterCockpit/cc-lib/v2/schema"
    23→	sq "github.com/Masterminds/squirrel"
    24→)
    25→
    26→const (
    27→	// Default initial capacity for job result slices
    28→	defaultJobsCapacity = 50
    29→)
    30→
    31→// QueryJobs retrieves jobs from the database with optional filtering, pagination,
    32→// and sorting. Security controls are automatically applied based on the user context.
    33→//
    34→// Parameters:
    35→//   - ctx: Context containing user authentication information
    36→//   - filters: Optional job filters (cluster, state, user, time ranges, etc.)
    37→//   - page: Optional pagination parameters (page number and items per page)
    38→//   - order: Optional sorting specification (column or footprint field)
    39→//
    40→// Returns a slice of jobs matching the criteria, or an error if the query fails.
    41→// The function enforces role-based access control through SecurityCheck.
    42→func (r *JobRepository) QueryJobs(
    43→	ctx context.Context,
    44→	filters []*model.JobFilter,
    45→	page *model.PageRequest,
    46→	order *model.OrderByInput,
    47→) ([]*schema.Job, error) {
    48→	query, qerr := SecurityCheck(ctx, sq.Select(jobColumns...).From("job"))
    49→	if qerr != nil {
    50→		return nil, qerr
    51→	}
    52→
    53→	if order != nil {
    54→		field := toSnakeCase(order.Field)
    55→		if order.Type == "col" {
    56→			switch order.Order {
    57→			case model.SortDirectionEnumAsc:
    58→				query = query.OrderBy(fmt.Sprintf("job.%s ASC", field))
    59→			case model.SortDirectionEnumDesc:
    60→				query = query.OrderBy(fmt.Sprintf("job.%s DESC", field))
    61→			default:
    62→				return nil, errors.New("invalid sorting order for column")
    63→			}
    64→		} else {
    65→			// Order by footprint JSON field values
    66→			query = query.Where("JSON_VALID(footprint)")
    67→			switch order.Order {
    68→			case model.SortDirectionEnumAsc:
    69→				query = query.OrderBy(fmt.Sprintf("JSON_EXTRACT(footprint, \"$.%s\") ASC", field))
    70→			case model.SortDirectionEnumDesc:
    71→				query = query.OrderBy(fmt.Sprintf("JSON_EXTRACT(footprint, \"$.%s\") DESC", field))
    72→			default:
    73→				return nil, errors.New("invalid sorting order for footprint")
    74→			}
    75→		}
    76→	}
    77→
    78→	if page != nil && page.ItemsPerPage != -1 {
    79→		limit := uint64(page.ItemsPerPage)
    80→		query = query.Offset((uint64(page.Page) - 1) * limit).Limit(limit)
    81→	}
    82→
    83→	for _, f := range filters {
    84→		query = BuildWhereClause(f, query)
    85→	}
    86→
    87→	rows, err := query.RunWith(r.stmtCache).Query()
    88→	if err != nil {
    89→		queryString, queryVars, _ := query.ToSql()
    90→		return nil, fmt.Errorf("query failed [%s] %v: %w", queryString, queryVars, err)
    91→	}
    92→	defer rows.Close()
    93→
    94→	jobs := make([]*schema.Job, 0, defaultJobsCapacity)
    95→	for rows.Next() {
    96→		job, err := scanJob(rows)
    97→		if err != nil {
    98→			cclog.Warnf("Error scanning job row: %v", err)
    99→			return nil, fmt.Errorf("failed to scan job row: %w", err)
   100→		}
   101→		jobs = append(jobs, job)
   102→	}
   103→
   104→	if err := rows.Err(); err != nil {
   105→		return nil, fmt.Errorf("error iterating job rows: %w", err)
   106→	}
   107→
   108→	return jobs, nil
   109→}
   110→
   111→// CountJobs returns the total number of jobs matching the given filters.
   112→// Security controls are automatically applied based on the user context.
   113→// Uses DISTINCT count to handle tag filters correctly (jobs may appear multiple
   114→// times when joined with the tag table).
   115→func (r *JobRepository) CountJobs(
   116→	ctx context.Context,
   117→	filters []*model.JobFilter,
   118→) (int, error) {
   119→	query, qerr := SecurityCheck(ctx, sq.Select("count(DISTINCT job.id)").From("job"))
   120→	if qerr != nil {
   121→		return 0, qerr
   122→	}
   123→
   124→	for _, f := range filters {
   125→		query = BuildWhereClause(f, query)
   126→	}
   127→
   128→	var count int
   129→	if err := query.RunWith(r.DB).Scan(&count); err != nil {
   130→		return 0, fmt.Errorf("failed to count jobs: %w", err)
   131→	}
   132→
   133→	return count, nil
   134→}
   135→
   136→// SecurityCheckWithUser applies role-based access control filters to a job query
   137→// based on the provided user's roles and permissions.
   138→//
   139→// Access rules by role:
   140→//   - API role (exclusive): Full access to all jobs
   141→//   - Admin/Support roles: Full access to all jobs
   142→//   - Manager role: Access to jobs in managed projects plus own jobs
   143→//   - User role: Access only to own jobs
   144→//
   145→// Returns an error if the user is nil or has no recognized roles.
   146→func SecurityCheckWithUser(user *schema.User, query sq.SelectBuilder) (sq.SelectBuilder, error) {
   147→	if user == nil {
   148→		var qnil sq.SelectBuilder
   149→		return qnil, fmt.Errorf("user context is nil")
   150→	}
   151→
   152→	switch {
   153→	case len(user.Roles) == 1 && user.HasRole(schema.RoleAPI):
   154→		return query, nil
   155→	case user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}):
   156→		return query, nil
   157→	case user.HasRole(schema.RoleManager):
   158→		if len(user.Projects) != 0 {
   159→			return query.Where(sq.Or{sq.Eq{"job.project": user.Projects}, sq.Eq{"job.hpc_user": user.Username}}), nil
   160→		}
   161→		cclog.Debugf("Manager '%s' has no assigned projects, restricting to personal jobs", user.Username)
   162→		return query.Where("job.hpc_user = ?", user.Username), nil
   163→	case user.HasRole(schema.RoleUser):
   164→		return query.Where("job.hpc_user = ?", user.Username), nil
   165→	default:
   166→		var qnil sq.SelectBuilder
   167→		return qnil, fmt.Errorf("user has no or unknown roles")
   168→	}
   169→}
   170→
   171→// SecurityCheck extracts the user from the context and applies role-based access
   172→// control filters to the query. This is a convenience wrapper around SecurityCheckWithUser.
   173→func SecurityCheck(ctx context.Context, query sq.SelectBuilder) (sq.SelectBuilder, error) {
   174→	user := GetUserFromContext(ctx)
   175→	return SecurityCheckWithUser(user, query)
   176→}
   177→
   178→// BuildWhereClause constructs SQL WHERE conditions from a JobFilter and applies
   179→// them to the query. Supports filtering by job properties (cluster, state, user),
   180→// time ranges, resource usage, tags, and JSON field searches in meta_data,
   181→// footprint, and resources columns.
   182→func BuildWhereClause(filter *model.JobFilter, query sq.SelectBuilder) sq.SelectBuilder {
   183→	// Primary Key
   184→	if filter.DbID != nil {
   185→		dbIDs := make([]string, len(filter.DbID))
   186→		copy(dbIDs, filter.DbID)
   187→		query = query.Where(sq.Eq{"job.id": dbIDs})
   188→	}
   189→	// Explicit indices
   190→	if filter.Cluster != nil {
   191→		query = buildStringCondition("job.cluster", filter.Cluster, query)
   192→	}
   193→	if filter.SubCluster != nil {
   194→		query = buildStringCondition("job.subcluster", filter.SubCluster, query)
   195→	}
   196→	if filter.Partition != nil {
   197→		query = buildStringCondition("job.cluster_partition", filter.Partition, query)
   198→	}
   199→	if filter.State != nil {
   200→		states := make([]string, len(filter.State))
   201→		for i, val := range filter.State {
   202→			states[i] = string(val)
   203→		}
   204→		query = query.Where(sq.Eq{"job.job_state": states})
   205→	}
   206→	if filter.Shared != nil {
   207→		query = query.Where("job.shared = ?", *filter.Shared)
   208→	}
   209→	if filter.Project != nil {
   210→		query = buildStringCondition("job.project", filter.Project, query)
   211→	}
   212→	if filter.User != nil {
   213→		query = buildStringCondition("job.hpc_user", filter.User, query)
   214→	}
   215→	if filter.NumNodes != nil {
   216→		query = buildIntCondition("job.num_nodes", filter.NumNodes, query)
   217→	}
   218→	if filter.NumAccelerators != nil {
   219→		query = buildIntCondition("job.num_acc", filter.NumAccelerators, query)
   220→	}
   221→	if filter.NumHWThreads != nil {
   222→		query = buildIntCondition("job.num_hwthreads", filter.NumHWThreads, query)
   223→	}
   224→	if filter.ArrayJobID != nil {
   225→		query = query.Where("job.array_job_id = ?", *filter.ArrayJobID)
   226→	}
   227→	if filter.StartTime != nil {
   228→		query = buildTimeCondition("job.start_time", filter.StartTime, query)
   229→	}
   230→	if filter.Duration != nil {
   231→		query = buildIntCondition("job.duration", filter.Duration, query)
   232→	}
   233→	if filter.Energy != nil {
   234→		query = buildFloatCondition("job.energy", filter.Energy, query)
   235→	}
   236→	// Indices on Tag Table
   237→	if filter.Tags != nil {
   238→		// This is an OR-Logic query: Returns all distinct jobs with at least one of the requested tags; TODO: AND-Logic query?
   239→		query = query.Join("jobtag ON jobtag.job_id = job.id").Where(sq.Eq{"jobtag.tag_id": filter.Tags}).Distinct()
   240→	}
   241→	// No explicit Indices
   242→	if filter.JobID != nil {
   243→		query = buildStringCondition("job.job_id", filter.JobID, query)
   244→	}
   245→	// Queries Within JSONs
   246→	if filter.MetricStats != nil {
   247→		for _, ms := range filter.MetricStats {
   248→			query = buildFloatJSONCondition(ms.MetricName, ms.Range, query)
   249→		}
   250→	}
   251→	if filter.Node != nil {
   252→		query = buildResourceJSONCondition("hostname", filter.Node, query)
   253→	}
   254→	if filter.JobName != nil {
   255→		query = buildMetaJSONCondition("jobName", filter.JobName, query)
   256→	}
   257→	if filter.Schedule != nil {
   258→		interactiveJobname := "interactive"
   259→		switch *filter.Schedule {
   260→		case "interactive":
   261→			iFilter := model.StringInput{Eq: &interactiveJobname}
   262→			query = buildMetaJSONCondition("jobName", &iFilter, query)
   263→		case "batch":
   264→			sFilter := model.StringInput{Neq: &interactiveJobname}
   265→			query = buildMetaJSONCondition("jobName", &sFilter, query)
   266→		}
   267→	}
   268→
   269→	// Configurable Filter to exclude recently started jobs, see config.go: ShortRunningJobsDuration
   270→	if filter.MinRunningFor != nil {
   271→		now := time.Now().Unix()
   272→		// Only jobs whose start timestamp is more than MinRunningFor seconds in the past
   273→		// If a job completed within the configured timeframe, it will still show up after the start_time matches the condition!
   274→		query = query.Where(sq.Lt{"job.start_time": (now - int64(*filter.MinRunningFor))})
   275→	}
   276→	return query
   277→}
   278→
   279→// buildIntCondition creates clauses for integer range filters, using BETWEEN only if required.
   280→func buildIntCondition(field string, cond *config.IntRange, query sq.SelectBuilder) sq.SelectBuilder {
   281→	if cond.From != 1 && cond.To != 0 {
   282→		return query.Where(field+" BETWEEN ? AND ?", cond.From, cond.To)
   283→	} else if cond.From != 1 && cond.To == 0 {
   284→		return query.Where(field+" >= ?", cond.From)
   285→	} else if cond.From == 1 && cond.To != 0 {
   286→		return query.Where(field+" <= ?", cond.To)
   287→	} else {
   288→		return query
   289→	}
   290→}
   291→
   292→// buildFloatCondition creates a clauses for float range filters, using BETWEEN only if required.
   293→func buildFloatCondition(field string, cond *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder {
   294→	if cond.From != 1.0 && cond.To != 0.0 {
   295→		return query.Where(field+" BETWEEN ? AND ?", cond.From, cond.To)
   296→	} else if cond.From != 1.0 && cond.To == 0.0 {
   297→		return query.Where(field+" >= ?", cond.From)
   298→	} else if cond.From == 1.0 && cond.To != 0.0 {
   299→		return query.Where(field+" <= ?", cond.To)
   300→	} else {
   301→		return query
   302→	}
   303→}
   304→
   305→// buildTimeCondition creates time range filters supporting absolute timestamps,
   306→// relative time ranges (last6h, last24h, last7d, last30d), or open-ended ranges.
   307→// Reminder: BETWEEN Queries are slower and dont use indices as frequently: Only use if both conditions required
   308→func buildTimeCondition(field string, cond *config.TimeRange, query sq.SelectBuilder) sq.SelectBuilder {
   309→	if cond.From != nil && cond.To != nil {
   310→		return query.Where(field+" BETWEEN ? AND ?", cond.From.Unix(), cond.To.Unix())
   311→	} else if cond.From != nil {
   312→		return query.Where(field+" >= ?", cond.From.Unix())
   313→	} else if cond.To != nil {
   314→		return query.Where(field+" <= ?", cond.To.Unix())
   315→	} else if cond.Range != "" {
   316→		now := time.Now().Unix()
   317→		var then int64
   318→		switch cond.Range {
   319→		case "last6h":
   320→			then = now - (60 * 60 * 6)
   321→		case "last24h":
   322→			then = now - (60 * 60 * 24)
   323→		case "last7d":
   324→			then = now - (60 * 60 * 24 * 7)
   325→		case "last30d":
   326→			then = now - (60 * 60 * 24 * 30)
   327→		default:
   328→			cclog.Debugf("No known named timeRange: startTime.range = %s", cond.Range)
   329→			return query
   330→		}
   331→		return query.Where(field+" >= ?", then)
   332→	} else {
   333→		return query
   334→	}
   335→}
   336→
   337→// buildFloatJSONCondition creates a filter on a numeric field within the footprint JSON column, using BETWEEN only if required.
   338→func buildFloatJSONCondition(jsonField string, cond *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder {
   339→	query = query.Where("JSON_VALID(footprint)")
   340→	if cond.From != 1.0 && cond.To != 0.0 {
   341→		return query.Where("JSON_EXTRACT(footprint, \"$."+jsonField+"\") BETWEEN ? AND ?", cond.From, cond.To)
   342→	} else if cond.From != 1.0 && cond.To == 0.0 {
   343→		return query.Where("JSON_EXTRACT(footprint, \"$."+jsonField+"\") >= ?", cond.From)
   344→	} else if cond.From == 1.0 && cond.To != 0.0 {
   345→		return query.Where("JSON_EXTRACT(footprint, \"$."+jsonField+"\") <= ?", cond.To)
   346→	} else {
   347→		return query
   348→	}
   349→}
   350→
   351→// buildStringCondition creates filters for string fields supporting equality,
   352→// inequality, prefix, suffix, substring, and IN list matching.
   353→func buildStringCondition(field string, cond *model.StringInput, query sq.SelectBuilder) sq.SelectBuilder {
   354→	if cond.Eq != nil {
   355→		return query.Where(field+" = ?", *cond.Eq)
   356→	}
   357→	if cond.Neq != nil {
   358→		return query.Where(field+" != ?", *cond.Neq)
   359→	}
   360→	if cond.StartsWith != nil {
   361→		return query.Where(field+" LIKE ?", fmt.Sprint(*cond.StartsWith, "%"))
   362→	}
   363→	if cond.EndsWith != nil {
   364→		return query.Where(field+" LIKE ?", fmt.Sprint("%", *cond.EndsWith))
   365→	}
   366→	if cond.Contains != nil {
   367→		return query.Where(field+" LIKE ?", fmt.Sprint("%", *cond.Contains, "%"))
   368→	}
   369→	if cond.In != nil {
   370→		queryElements := make([]string, len(cond.In))
   371→		copy(queryElements, cond.In)
   372→		return query.Where(sq.Or{sq.Eq{field: queryElements}})
   373→	}
   374→	return query
   375→}
   376→
   377→// buildMetaJSONCondition creates filters on fields within the meta_data JSON column.
   378→func buildMetaJSONCondition(jsonField string, cond *model.StringInput, query sq.SelectBuilder) sq.SelectBuilder {
   379→	query = query.Where("JSON_VALID(meta_data)")
   380→	if cond.Eq != nil {
   381→		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") = ?", *cond.Eq)
   382→	}
   383→	if cond.Neq != nil {
   384→		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") != ?", *cond.Neq)
   385→	}
   386→	if cond.StartsWith != nil {
   387→		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") LIKE ?", fmt.Sprint(*cond.StartsWith, "%"))
   388→	}
   389→	if cond.EndsWith != nil {
   390→		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") LIKE ?", fmt.Sprint("%", *cond.EndsWith))
   391→	}
   392→	if cond.Contains != nil {
   393→		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") LIKE ?", fmt.Sprint("%", *cond.Contains, "%"))
   394→	}
   395→	return query
   396→}
   397→
   398→// buildResourceJSONCondition creates filters on fields within the resources JSON array column.
   399→// Uses json_each to search within array elements.
   400→func buildResourceJSONCondition(jsonField string, cond *model.StringInput, query sq.SelectBuilder) sq.SelectBuilder {
   401→	query = query.Where("JSON_VALID(resources)")
   402→	if cond.Eq != nil {
   403→		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\") = ?)", *cond.Eq)
   404→	}
   405→	if cond.Neq != nil { // Currently Unused
   406→		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\") != ?)", *cond.Neq)
   407→	}
   408→	if cond.StartsWith != nil { // Currently Unused
   409→		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\")) LIKE ?)", fmt.Sprint(*cond.StartsWith, "%"))
   410→	}
   411→	if cond.EndsWith != nil { // Currently Unused
   412→		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\") LIKE ?)", fmt.Sprint("%", *cond.EndsWith))
   413→	}
   414→	if cond.Contains != nil {
   415→		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\") LIKE ?)", fmt.Sprint("%", *cond.Contains, "%"))
   416→	}
   417→	return query
   418→}
   419→
   420→var (
   421→	matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)")
   422→	matchAllCap   = regexp.MustCompile("([a-z0-9])([A-Z])")
   423→)
   424→
   425→// toSnakeCase converts camelCase strings to snake_case for SQL column names.
   426→// Includes security checks to prevent SQL injection attempts.
   427→// Panics if potentially dangerous characters are detected.
   428→func toSnakeCase(str string) string {
   429→	for _, c := range str {
   430→		if c == '\'' || c == '\\' || c == '"' || c == ';' || c == '-' || c == ' ' {
   431→			cclog.Panicf("toSnakeCase: potentially dangerous character detected in input: %q", str)
   432→		}
   433→	}
   434→
   435→	snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}")
   436→	snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}")
   437→	return strings.ToLower(snake)
   438→}
   439→


     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-backend.
     3→// Use of this source code is governed by a MIT-style
     4→// license that can be found in the LICENSE file.
     5→
     6→package repository
     7→
     8→import (
     9→	"context"
    10→	"database/sql"
    11→	"fmt"
    12→	"time"
    13→
    14→	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
    15→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
    16→	"github.com/ClusterCockpit/cc-lib/v2/schema"
    17→	sq "github.com/Masterminds/squirrel"
    18→)
    19→
    20→// Find executes a SQL query to find a specific batch job.
    21→// The job is queried using the batch job id, the cluster name,
    22→// and the start time of the job in UNIX epoch time seconds.
    23→// It returns a pointer to a schema.Job data structure and an error variable.
    24→// To check if no job was found test err == sql.ErrNoRows
    25→func (r *JobRepository) Find(
    26→	jobID *int64,
    27→	cluster *string,
    28→	startTime *int64,
    29→) (*schema.Job, error) {
    30→	if jobID == nil {
    31→		return nil, fmt.Errorf("jobID cannot be nil")
    32→	}
    33→
    34→	start := time.Now()
    35→	q := sq.Select(jobColumns...).From("job").
    36→		Where("job.job_id = ?", *jobID)
    37→
    38→	if cluster != nil {
    39→		q = q.Where("job.cluster = ?", *cluster)
    40→	}
    41→	if startTime != nil {
    42→		q = q.Where("job.start_time = ?", *startTime)
    43→	}
    44→
    45→	q = q.OrderBy("job.id DESC").Limit(1) // always use newest matching job by db id if more than one match
    46→
    47→	cclog.Debugf("Timer Find %s", time.Since(start))
    48→	return scanJob(q.RunWith(r.stmtCache).QueryRow())
    49→}
    50→
    51→// FindCached executes a SQL query to find a specific batch job from the job_cache table.
    52→// The job is queried using the batch job id, and optionally filtered by cluster name
    53→// and start time (UNIX epoch time seconds). This method uses cached job data which
    54→// may be stale but provides faster access than Find().
    55→// It returns a pointer to a schema.Job data structure and an error variable.
    56→// To check if no job was found test err == sql.ErrNoRows
    57→func (r *JobRepository) FindCached(
    58→	jobID *int64,
    59→	cluster *string,
    60→	startTime *int64,
    61→) (*schema.Job, error) {
    62→	if jobID == nil {
    63→		return nil, fmt.Errorf("jobID cannot be nil")
    64→	}
    65→
    66→	q := sq.Select(jobCacheColumns...).From("job_cache").
    67→		Where("job_cache.job_id = ?", *jobID)
    68→
    69→	if cluster != nil {
    70→		q = q.Where("job_cache.cluster = ?", *cluster)
    71→	}
    72→	if startTime != nil {
    73→		q = q.Where("job_cache.start_time = ?", *startTime)
    74→	}
    75→
    76→	q = q.OrderBy("job_cache.id DESC").Limit(1) // always use newest matching job by db id if more than one match
    77→
    78→	return scanJob(q.RunWith(r.stmtCache).QueryRow())
    79→}
    80→
    81→// FindAll executes a SQL query to find all batch jobs matching the given criteria.
    82→// Jobs are queried using the batch job id, and optionally filtered by cluster name
    83→// and start time (UNIX epoch time seconds).
    84→// It returns a slice of pointers to schema.Job data structures and an error variable.
    85→// An empty slice is returned if no matching jobs are found.
    86→func (r *JobRepository) FindAll(
    87→	jobID *int64,
    88→	cluster *string,
    89→	startTime *int64,
    90→) ([]*schema.Job, error) {
    91→	if jobID == nil {
    92→		return nil, fmt.Errorf("jobID cannot be nil")
    93→	}
    94→
    95→	start := time.Now()
    96→	q := sq.Select(jobColumns...).From("job").
    97→		Where("job.job_id = ?", *jobID)
    98→
    99→	if cluster != nil {
   100→		q = q.Where("job.cluster = ?", *cluster)
   101→	}
   102→	if startTime != nil {
   103→		q = q.Where("job.start_time = ?", *startTime)
   104→	}
   105→
   106→	rows, err := q.RunWith(r.stmtCache).Query()
   107→	if err != nil {
   108→		cclog.Errorf("Error while running FindAll query for jobID=%d: %v", *jobID, err)
   109→		return nil, fmt.Errorf("failed to execute FindAll query: %w", err)
   110→	}
   111→	defer rows.Close()
   112→
   113→	jobs := make([]*schema.Job, 0, 10)
   114→	for rows.Next() {
   115→		job, err := scanJob(rows)
   116→		if err != nil {
   117→			cclog.Warnf("Error while scanning rows in FindAll: %v", err)
   118→			return nil, fmt.Errorf("failed to scan job row: %w", err)
   119→		}
   120→		jobs = append(jobs, job)
   121→	}
   122→	cclog.Debugf("Timer FindAll %s", time.Since(start))
   123→	return jobs, nil
   124→}
   125→
   126→// GetJobList returns job IDs for non-running jobs.
   127→// This is useful to process large job counts and intended to be used
   128→// together with FindById to process jobs one by one.
   129→// Use limit and offset for pagination. Use limit=0 to get all results (not recommended for large datasets).
   130→func (r *JobRepository) GetJobList(limit int, offset int) ([]int64, error) {
   131→	query := sq.Select("id").From("job").
   132→		Where("job.job_state != 'running'")
   133→
   134→	// Add pagination if limit is specified
   135→	if limit > 0 {
   136→		query = query.Limit(uint64(limit)).Offset(uint64(offset))
   137→	}
   138→
   139→	rows, err := query.RunWith(r.stmtCache).Query()
   140→	if err != nil {
   141→		cclog.Errorf("Error while running GetJobList query (limit=%d, offset=%d): %v", limit, offset, err)
   142→		return nil, fmt.Errorf("failed to execute GetJobList query: %w", err)
   143→	}
   144→	defer rows.Close()
   145→
   146→	jl := make([]int64, 0, 1000)
   147→	for rows.Next() {
   148→		var id int64
   149→		err := rows.Scan(&id)
   150→		if err != nil {
   151→			cclog.Warnf("Error while scanning rows in GetJobList: %v", err)
   152→			return nil, fmt.Errorf("failed to scan job ID: %w", err)
   153→		}
   154→		jl = append(jl, id)
   155→	}
   156→
   157→	cclog.Debugf("JobRepository.GetJobList(): Return job count %d", len(jl))
   158→	return jl, nil
   159→}
   160→
   161→// FindByID executes a SQL query to find a specific batch job.
   162→// The job is queried using the database id.
   163→// It returns a pointer to a schema.Job data structure and an error variable.
   164→// To check if no job was found test err == sql.ErrNoRows
   165→func (r *JobRepository) FindByID(ctx context.Context, jobID int64) (*schema.Job, error) {
   166→	q := sq.Select(jobColumns...).
   167→		From("job").Where("job.id = ?", jobID)
   168→
   169→	q, qerr := SecurityCheck(ctx, q)
   170→	if qerr != nil {
   171→		return nil, qerr
   172→	}
   173→
   174→	return scanJob(q.RunWith(r.stmtCache).QueryRow())
   175→}
   176→
   177→// FindByIDWithUser executes a SQL query to find a specific batch job.
   178→// The job is queried using the database id. The user is passed directly,
   179→// instead as part of the context.
   180→// It returns a pointer to a schema.Job data structure and an error variable.
   181→// To check if no job was found test err == sql.ErrNoRows
   182→func (r *JobRepository) FindByIDWithUser(user *schema.User, jobID int64) (*schema.Job, error) {
   183→	q := sq.Select(jobColumns...).
   184→		From("job").Where("job.id = ?", jobID)
   185→
   186→	q, qerr := SecurityCheckWithUser(user, q)
   187→	if qerr != nil {
   188→		return nil, qerr
   189→	}
   190→
   191→	return scanJob(q.RunWith(r.stmtCache).QueryRow())
   192→}
   193→
   194→// FindByIDDirect executes a SQL query to find a specific batch job.
   195→// The job is queried using the database id.
   196→// It returns a pointer to a schema.Job data structure and an error variable.
   197→// To check if no job was found test err == sql.ErrNoRows
   198→func (r *JobRepository) FindByIDDirect(jobID int64) (*schema.Job, error) {
   199→	q := sq.Select(jobColumns...).
   200→		From("job").Where("job.id = ?", jobID)
   201→	return scanJob(q.RunWith(r.stmtCache).QueryRow())
   202→}
   203→
   204→// FindByJobID executes a SQL query to find a specific batch job.
   205→// The job is queried using the slurm id and the clustername.
   206→// It returns a pointer to a schema.Job data structure and an error variable.
   207→// To check if no job was found test err == sql.ErrNoRows
   208→func (r *JobRepository) FindByJobID(ctx context.Context, jobID int64, startTime int64, cluster string) (*schema.Job, error) {
   209→	q := sq.Select(jobColumns...).
   210→		From("job").
   211→		Where("job.job_id = ?", jobID).
   212→		Where("job.cluster = ?", cluster).
   213→		Where("job.start_time = ?", startTime)
   214→
   215→	q, qerr := SecurityCheck(ctx, q)
   216→	if qerr != nil {
   217→		return nil, qerr
   218→	}
   219→
   220→	return scanJob(q.RunWith(r.stmtCache).QueryRow())
   221→}
   222→
   223→// IsJobOwner checks if the specified user owns the batch job identified by jobID,
   224→// startTime, and cluster. Returns true if the user is the owner, false otherwise.
   225→// This method does not return errors; it returns false for both non-existent jobs
   226→// and jobs owned by other users.
   227→func (r *JobRepository) IsJobOwner(jobID int64, startTime int64, user string, cluster string) bool {
   228→	q := sq.Select("id").
   229→		From("job").
   230→		Where("job.job_id = ?", jobID).
   231→		Where("job.hpc_user = ?", user).
   232→		Where("job.cluster = ?", cluster).
   233→		Where("job.start_time = ?", startTime)
   234→
   235→	_, err := scanJob(q.RunWith(r.stmtCache).QueryRow())
   236→	if err != nil && err != sql.ErrNoRows {
   237→		cclog.Warnf("IsJobOwner: unexpected error for jobID=%d, user=%s, cluster=%s: %v", jobID, user, cluster, err)
   238→	}
   239→	return err != sql.ErrNoRows
   240→}
   241→
   242→func (r *JobRepository) FindConcurrentJobs(
   243→	ctx context.Context,
   244→	job *schema.Job,
   245→) (*model.JobLinkResultList, error) {
   246→	if job == nil {
   247→		return nil, nil
   248→	}
   249→
   250→	query, qerr := SecurityCheck(ctx, sq.Select("job.id", "job.job_id", "job.start_time").From("job"))
   251→	if qerr != nil {
   252→		return nil, qerr
   253→	}
   254→
   255→	query = query.Where("cluster = ?", job.Cluster)
   256→
   257→	if len(job.Resources) == 0 {
   258→		return nil, fmt.Errorf("job has no resources defined")
   259→	}
   260→
   261→	var startTime int64
   262→	var stopTime int64
   263→
   264→	startTime = job.StartTime
   265→	hostname := job.Resources[0].Hostname
   266→
   267→	if job.State == schema.JobStateRunning {
   268→		stopTime = time.Now().Unix()
   269→	} else {
   270→		stopTime = startTime + int64(job.Duration)
   271→	}
   272→
   273→	// Time buffer constant for finding overlapping jobs
   274→	// overlapBufferEnd: 200s buffer at job end to account for scheduling/cleanup overlap
   275→	const overlapBufferEnd = 200
   276→
   277→	stopTimeTail := stopTime - overlapBufferEnd
   278→	startTimeFront := startTime + overlapBufferEnd
   279→
   280→	queryRunning := query.Where("job.job_state = ?", "running").
   281→		Where("job.start_time <= ?", stopTimeTail)
   282→	// Get At Least One Exact Hostname Match from JSON Resources Array in Database
   283→	queryRunning = queryRunning.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, '$.hostname') = ?)", hostname)
   284→
   285→	query = query.Where("job.job_state != ?", "running").
   286→		Where("job.start_time < ?", stopTimeTail).
   287→		Where("(job.start_time + job.duration) > ?", startTimeFront)
   288→	// Get At Least One Exact Hostname Match from JSON Resources Array in Database
   289→	query = query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, '$.hostname') = ?)", hostname)
   290→
   291→	rows, err := query.RunWith(r.stmtCache).Query()
   292→	if err != nil {
   293→		cclog.Errorf("Error while running concurrent jobs query: %v", err)
   294→		return nil, fmt.Errorf("failed to execute concurrent jobs query: %w", err)
   295→	}
   296→	defer rows.Close()
   297→
   298→	items := make([]*model.JobLink, 0, 10)
   299→	queryString := fmt.Sprintf("cluster=%s", job.Cluster)
   300→
   301→	for rows.Next() {
   302→		var id, jobID, startTime sql.NullInt64
   303→
   304→		if err = rows.Scan(&id, &jobID, &startTime); err != nil {
   305→			cclog.Warnf("Error while scanning concurrent job rows: %v", err)
   306→			return nil, fmt.Errorf("failed to scan concurrent job row: %w", err)
   307→		}
   308→
   309→		if id.Valid {
   310→			queryString += fmt.Sprintf("&jobId=%d", int(jobID.Int64))
   311→			items = append(items,
   312→				&model.JobLink{
   313→					ID:    fmt.Sprint(id.Int64),
   314→					JobID: int(jobID.Int64),
   315→				})
   316→		}
   317→	}
   318→
   319→	rows, err = queryRunning.RunWith(r.stmtCache).Query()
   320→	if err != nil {
   321→		cclog.Errorf("Error while running concurrent running jobs query: %v", err)
   322→		return nil, fmt.Errorf("failed to execute concurrent running jobs query: %w", err)
   323→	}
   324→	defer rows.Close()
   325→
   326→	for rows.Next() {
   327→		var id, jobID, startTime sql.NullInt64
   328→
   329→		if err := rows.Scan(&id, &jobID, &startTime); err != nil {
   330→			cclog.Warnf("Error while scanning running concurrent job rows: %v", err)
   331→			return nil, fmt.Errorf("failed to scan running concurrent job row: %w", err)
   332→		}
   333→
   334→		if id.Valid {
   335→			queryString += fmt.Sprintf("&jobId=%d", int(jobID.Int64))
   336→			items = append(items,
   337→				&model.JobLink{
   338→					ID:    fmt.Sprint(id.Int64),
   339→					JobID: int(jobID.Int64),
   340→				})
   341→		}
   342→	}
   343→
   344→	cnt := len(items)
   345→
   346→	return &model.JobLinkResultList{
   347→		ListQuery: &queryString,
   348→		Items:     items,
   349→		Count:     &cnt,
   350→	}, nil
   351→}
   352→


     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-backend.
     3→// Use of this source code is governed by a MIT-style
     4→// license that can be found in the LICENSE file.
     5→
     6→package repository
     7→
     8→import (
     9→	"encoding/json"
    10→	"fmt"
    11→
    12→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
    13→	"github.com/ClusterCockpit/cc-lib/v2/schema"
    14→	sq "github.com/Masterminds/squirrel"
    15→)
    16→
    17→const NamedJobCacheInsert string = `INSERT INTO job_cache (
    18→	job_id, hpc_user, project, cluster, subcluster, cluster_partition, array_job_id, num_nodes, num_hwthreads, num_acc,
    19→	shared, monitoring_status, smt, job_state, start_time, duration, walltime, footprint, energy, energy_footprint, resources, meta_data
    20→) VALUES (
    21→	:job_id, :hpc_user, :project, :cluster, :subcluster, :cluster_partition, :array_job_id, :num_nodes, :num_hwthreads, :num_acc,
    22→  :shared, :monitoring_status, :smt, :job_state, :start_time, :duration, :walltime, :footprint,  :energy, :energy_footprint, :resources, :meta_data
    23→);`
    24→
    25→const NamedJobInsert string = `INSERT INTO job (
    26→	job_id, hpc_user, project, cluster, subcluster, cluster_partition, array_job_id, num_nodes, num_hwthreads, num_acc,
    27→	shared, monitoring_status, smt, job_state, start_time, duration, walltime, footprint, energy, energy_footprint, resources, meta_data
    28→) VALUES (
    29→	:job_id, :hpc_user, :project, :cluster, :subcluster, :cluster_partition, :array_job_id, :num_nodes, :num_hwthreads, :num_acc,
    30→  :shared, :monitoring_status, :smt, :job_state, :start_time, :duration, :walltime, :footprint,  :energy, :energy_footprint, :resources, :meta_data
    31→);`
    32→
    33→// InsertJobDirect inserts a job directly into the job table (not job_cache).
    34→// Use this when the returned ID will be used for operations on the job table
    35→// (e.g., adding tags), or for imported jobs that are already completed.
    36→func (r *JobRepository) InsertJobDirect(job *schema.Job) (int64, error) {
    37→	r.Mutex.Lock()
    38→	defer r.Mutex.Unlock()
    39→
    40→	res, err := r.DB.NamedExec(NamedJobInsert, job)
    41→	if err != nil {
    42→		cclog.Warn("Error while NamedJobInsert (direct)")
    43→		return 0, err
    44→	}
    45→	id, err := res.LastInsertId()
    46→	if err != nil {
    47→		cclog.Warn("Error while getting last insert ID (direct)")
    48→		return 0, err
    49→	}
    50→
    51→	return id, nil
    52→}
    53→
    54→func (r *JobRepository) InsertJob(job *schema.Job) (int64, error) {
    55→	r.Mutex.Lock()
    56→	defer r.Mutex.Unlock()
    57→
    58→	res, err := r.DB.NamedExec(NamedJobCacheInsert, job)
    59→	if err != nil {
    60→		cclog.Warn("Error while NamedJobInsert")
    61→		return 0, err
    62→	}
    63→	id, err := res.LastInsertId()
    64→	if err != nil {
    65→		cclog.Warn("Error while getting last insert ID")
    66→		return 0, err
    67→	}
    68→
    69→	return id, nil
    70→}
    71→
    72→func (r *JobRepository) SyncJobs() ([]*schema.Job, error) {
    73→	r.Mutex.Lock()
    74→	defer r.Mutex.Unlock()
    75→
    76→	query := sq.Select(jobCacheColumns...).From("job_cache")
    77→
    78→	rows, err := query.RunWith(r.stmtCache).Query()
    79→	if err != nil {
    80→		cclog.Errorf("Error while running query %v", err)
    81→		return nil, err
    82→	}
    83→	defer rows.Close()
    84→
    85→	jobs := make([]*schema.Job, 0, 50)
    86→	for rows.Next() {
    87→		job, err := scanJob(rows)
    88→		if err != nil {
    89→			cclog.Warn("Error while scanning rows")
    90→			return nil, err
    91→		}
    92→		jobs = append(jobs, job)
    93→	}
    94→
    95→	// Use INSERT OR IGNORE to skip jobs already transferred by the stop path
    96→	_, err = r.DB.Exec(
    97→		"INSERT OR IGNORE INTO job (job_id, cluster, subcluster, start_time, hpc_user, project, cluster_partition, array_job_id, num_nodes, num_hwthreads, num_acc, shared, monitoring_status, smt, job_state, duration, walltime, footprint, energy, energy_footprint, resources, meta_data) SELECT job_id, cluster, subcluster, start_time, hpc_user, project, cluster_partition, array_job_id, num_nodes, num_hwthreads, num_acc, shared, monitoring_status, smt, job_state, duration, walltime, footprint, energy, energy_footprint, resources, meta_data FROM job_cache")
    98→	if err != nil {
    99→		cclog.Warnf("Error while Job sync: %v", err)
   100→		return nil, err
   101→	}
   102→
   103→	_, err = r.DB.Exec("DELETE FROM job_cache")
   104→	if err != nil {
   105→		cclog.Warnf("Error while Job cache clean: %v", err)
   106→		return nil, err
   107→	}
   108→
   109→	// Resolve correct job.id from the job table. The IDs read from job_cache
   110→	// are from a different auto-increment sequence and must not be used to
   111→	// query the job table.
   112→	for _, job := range jobs {
   113→		var newID int64
   114→		if err := sq.Select("job.id").From("job").
   115→			Where("job.job_id = ? AND job.cluster = ? AND job.start_time = ?",
   116→				job.JobID, job.Cluster, job.StartTime).
   117→			RunWith(r.stmtCache).QueryRow().Scan(&newID); err != nil {
   118→			cclog.Warnf("SyncJobs: could not resolve job table id for job %d on %s: %v",
   119→				job.JobID, job.Cluster, err)
   120→			continue
   121→		}
   122→		job.ID = &newID
   123→	}
   124→
   125→	return jobs, nil
   126→}
   127→
   128→// TransferCachedJobToMain moves a job from job_cache to the job table.
   129→// Caller must hold r.Mutex. Returns the new job table ID.
   130→func (r *JobRepository) TransferCachedJobToMain(cacheID int64) (int64, error) {
   131→	res, err := r.DB.Exec(
   132→		"INSERT INTO job (job_id, cluster, subcluster, start_time, hpc_user, project, cluster_partition, array_job_id, num_nodes, num_hwthreads, num_acc, shared, monitoring_status, smt, job_state, duration, walltime, footprint, energy, energy_footprint, resources, meta_data) SELECT job_id, cluster, subcluster, start_time, hpc_user, project, cluster_partition, array_job_id, num_nodes, num_hwthreads, num_acc, shared, monitoring_status, smt, job_state, duration, walltime, footprint, energy, energy_footprint, resources, meta_data FROM job_cache WHERE id = ?",
   133→		cacheID)
   134→	if err != nil {
   135→		return 0, fmt.Errorf("transferring cached job %d to main table failed: %w", cacheID, err)
   136→	}
   137→
   138→	newID, err := res.LastInsertId()
   139→	if err != nil {
   140→		return 0, fmt.Errorf("getting new job ID after transfer failed: %w", err)
   141→	}
   142→
   143→	_, err = r.DB.Exec("DELETE FROM job_cache WHERE id = ?", cacheID)
   144→	if err != nil {
   145→		return 0, fmt.Errorf("deleting cached job %d after transfer failed: %w", cacheID, err)
   146→	}
   147→
   148→	return newID, nil
   149→}
   150→
   151→// Start inserts a new job in the table, returning the unique job ID.
   152→// Statistics are not transfered!
   153→func (r *JobRepository) Start(job *schema.Job) (id int64, err error) {
   154→	job.RawFootprint, err = json.Marshal(job.Footprint)
   155→	if err != nil {
   156→		return -1, fmt.Errorf("REPOSITORY/JOB > encoding footprint field failed: %w", err)
   157→	}
   158→
   159→	job.RawResources, err = json.Marshal(job.Resources)
   160→	if err != nil {
   161→		return -1, fmt.Errorf("REPOSITORY/JOB > encoding resources field failed: %w", err)
   162→	}
   163→
   164→	job.RawMetaData, err = json.Marshal(job.MetaData)
   165→	if err != nil {
   166→		return -1, fmt.Errorf("REPOSITORY/JOB > encoding metaData field failed: %w", err)
   167→	}
   168→
   169→	return r.InsertJob(job)
   170→}
   171→
   172→// StartDirect inserts a new job directly into the job table (not job_cache).
   173→// Use this when the returned ID will immediately be used for job table
   174→// operations such as adding tags.
   175→func (r *JobRepository) StartDirect(job *schema.Job) (id int64, err error) {
   176→	job.RawFootprint, err = json.Marshal(job.Footprint)
   177→	if err != nil {
   178→		return -1, fmt.Errorf("REPOSITORY/JOB > encoding footprint field failed: %w", err)
   179→	}
   180→
   181→	job.RawResources, err = json.Marshal(job.Resources)
   182→	if err != nil {
   183→		return -1, fmt.Errorf("REPOSITORY/JOB > encoding resources field failed: %w", err)
   184→	}
   185→
   186→	job.RawMetaData, err = json.Marshal(job.MetaData)
   187→	if err != nil {
   188→		return -1, fmt.Errorf("REPOSITORY/JOB > encoding metaData field failed: %w", err)
   189→	}
   190→
   191→	return r.InsertJobDirect(job)
   192→}
   193→
   194→// Stop updates the job with the database id jobId using the provided arguments.
   195→func (r *JobRepository) Stop(
   196→	jobID int64,
   197→	duration int32,
   198→	state schema.JobState,
   199→	monitoringStatus int32,
   200→) (err error) {
   201→	// Invalidate cache entries as job state is changing
   202→	r.cache.Del(fmt.Sprintf("metadata:%d", jobID))
   203→	r.cache.Del(fmt.Sprintf("energyFootprint:%d", jobID))
   204→
   205→	stmt := sq.Update("job").
   206→		Set("job_state", state).
   207→		Set("duration", duration).
   208→		Set("monitoring_status", monitoringStatus).
   209→		Where("job.id = ?", jobID)
   210→
   211→	_, err = stmt.RunWith(r.stmtCache).Exec()
   212→	return err
   213→}
   214→
   215→


Analyze current indexes and migrations

Prompt:
In /Users/jan/prg/CC/cc-backend, I need to understand the current index situation on the job table.

1. Read ALL migration files in `internal/repository/migrations/sqlite3/` - especially 08_add-footprint.up.sql and 09_add-job-cache.up.sql which define the current indexes
2. List EVERY CREATE INDEX statement for the job table across all migrations
3. Note which indexes were DROPped in later migrations
4. Read the job table schema (CREATE TABLE) from 01_init-schema.up.sql and any modifications in later migrations

I need the complete picture of:
- All 79+ indexes currently defined
- The job table column list with types
- Which columns have individual indexes vs composite indexes
- Any covering index patterns

Report the full list organized by leading column.


<persisted-output>
Output too large (49.5KB). Full output saved to: /Users/jan/.claude/projects/-Users-jan-prg-CC-cc-backend/42401d2e-7d1c-4c0e-abe6-356cb2d48747/tool-results/toolu_01HzXCwt8PMuBHGPmQuBvUFf.txt

Preview (first 2KB):
     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 the data access layer for cc-backend using the repository pattern.
     7→//
     8→// The repository pattern abstracts database operations and provides a clean interface for
     9→// data access. Each major entity (Job, User, Node, Tag) has its own repository with CRUD
    10→// operations and specialized queries.
    11→//
    12→// # Database Connection
    13→//
    14→// Initialize the database connection before using any repository:
    15→//
    16→//	repository.Connect("sqlite3", "./var/job.db")
    17→//
    18→// # Configuration
    19→//
    20→// Optional: Configure repository settings before initialization:
    21→//
    22→//	repository.SetConfig(&repository.RepositoryConfig{
    23→//	    CacheSize: 2 * 1024 * 1024,     // 2MB cache
    24→//	    MaxOpenConnections: 8,           // Connection pool size
    25→//	    MinRunningJobDuration: 300,      // Filter threshold
    26→//	})
    27→//
    28→// If not configured, sensible defaults are used automatically.
    29→//
    30→// # Repositories
    31→//
    32→//   - JobRepository: Job lifecycle management and querying
    33→//   - UserRepository: User management and authentication
    34→//   - NodeRepository: Cluster node state tracking
    35→//   - Tags: Job tagging and categorization
    36→//
    37→// # Caching
    38→//
    39→// Repositories use LRU caching to improve performance. Cache keys are constructed
    40→// as "type:id" (e.g., "metadata:123"). Cache is automatically invalidated on
    41→// mutations to maintain consistency.
    42→//
    43→// # Transaction Support
    44→//
    45→// For batch operations, use transactions:
    46→//
    47→//	t, err := jobRepo.TransactionInit()
...
</persisted-output>


In /Users/jan/prg/CC/cc-backend, I need to understand the current index situation on the job table.

1. Read ALL migration files in `internal/repository/migrations/sqlite3/` - especially 08_add-footprint.up.sql and 09_add-job-cache.up.sql which define the current indexes
2. List EVERY CREATE INDEX statement for the job table across all migrations
3. Note which indexes were DROPped in later migrations
4. Read the job table schema (CREATE TABLE) from 01_init-schema.up.sql and any modifications in later migrations

I need the complete picture of:
- All 79+ indexes currently defined
- The job table column list with types
- Which columns have individual indexes vs composite indexes
- Any covering index patterns

Report the full list organized by leading column.

Prompt:
In /Users/jan/prg/CC/cc-backend, I need to understand the current index situation on the job table.

1. Read ALL migration files in `internal/repository/migrations/sqlite3/` - especially 08_add-footprint.up.sql and 09_add-job-cache.up.sql which define the current indexes
2. List EVERY CREATE INDEX statement for the job table across all migrations
3. Note which indexes were DROPped in later migrations
4. Read the job table schema (CREATE TABLE) from 01_init-schema.up.sql and any modifications in later migrations

I need the complete picture of:
- All 79+ indexes currently defined
- The job table column list with types
- Which columns have individual indexes vs composite indexes
- Any covering index patterns

Report the full list organized by leading column.


Bash

find /Users/jan/prg/CC/cc-backend/internal/repository/migrations/sqlite3 -name "*.up.sql" | sort


     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→