This commit is contained in:
delete 2026-06-10 20:51:17 +03:00
commit b15b95781c
108 changed files with 14802 additions and 0 deletions

761
internal/db/apikeys.go Normal file
View file

@ -0,0 +1,761 @@
package db
import (
"context"
"database/sql"
"errors"
"fmt"
"sort"
"strings"
"time"
)
type CreateAPIKeyParams struct {
Name string
KeyPrefix string
KeyHash string
Description string
ScopeMode ScopeMode
CanDownload bool
CanUpload bool
CanDelete bool
CanManageProjects bool
IsActive bool
ExpiresAt *time.Time
CreatedByUserID *int64
}
type UpdateAPIKeyParams struct {
Name string
Description string
ScopeMode ScopeMode
CanDownload bool
CanUpload bool
CanDelete bool
CanManageProjects bool
ExpiresAt *time.Time
}
func (r *APIKeyRepository) List(ctx context.Context) ([]APIKeyListItem, error) {
rows, err := r.q.QueryContext(
ctx,
`SELECT
ak.id,
ak.name,
ak.key_prefix,
ak.key_hash,
ak.description,
ak.scope_mode,
ak.can_download,
ak.can_upload,
ak.can_delete,
ak.can_manage_projects,
ak.is_active,
ak.expires_at,
ak.created_at,
ak.updated_at,
ak.last_used_at,
ak.created_by_user_id,
COUNT(DISTINCT ap.project_id) AS project_rule_count,
COUNT(DISTINCT at.tag_id) AS tag_rule_count
FROM api_keys AS ak
LEFT JOIN api_key_project_access AS ap ON ap.api_key_id = ak.id
LEFT JOIN api_key_tag_access AS at ON at.api_key_id = ak.id
GROUP BY ak.id
ORDER BY ak.is_active DESC, ak.updated_at DESC, ak.created_at DESC, ak.name COLLATE NOCASE`,
)
if err != nil {
return nil, fmt.Errorf("query api keys: %w", err)
}
defer rows.Close()
items := make([]APIKeyListItem, 0)
for rows.Next() {
item, err := scanAPIKeyListItem(rows)
if err != nil {
return nil, fmt.Errorf("scan api key list item: %w", err)
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate api keys: %w", err)
}
return items, nil
}
func (r *APIKeyRepository) GetByID(ctx context.Context, id int64) (*APIKey, error) {
key, err := scanAPIKey(r.q.QueryRowContext(
ctx,
`SELECT
id,
name,
key_prefix,
key_hash,
description,
scope_mode,
can_download,
can_upload,
can_delete,
can_manage_projects,
is_active,
expires_at,
created_at,
updated_at,
last_used_at,
created_by_user_id
FROM api_keys
WHERE id = ?`,
id,
))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("scan api key by id: %w", err)
}
return key, nil
}
func (r *APIKeyRepository) GetByHash(ctx context.Context, keyHash string) (*APIKey, error) {
key, err := scanAPIKey(r.q.QueryRowContext(
ctx,
`SELECT
id,
name,
key_prefix,
key_hash,
description,
scope_mode,
can_download,
can_upload,
can_delete,
can_manage_projects,
is_active,
expires_at,
created_at,
updated_at,
last_used_at,
created_by_user_id
FROM api_keys
WHERE key_hash = ?
LIMIT 1`,
strings.TrimSpace(keyHash),
))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("scan api key by hash: %w", err)
}
return key, nil
}
func (r *APIKeyRepository) Create(ctx context.Context, params CreateAPIKeyParams) (*APIKey, error) {
isActive := 0
if params.IsActive {
isActive = 1
}
result, err := r.q.ExecContext(
ctx,
`INSERT INTO api_keys (
name,
key_prefix,
key_hash,
description,
scope_mode,
can_download,
can_upload,
can_delete,
can_manage_projects,
is_active,
expires_at,
created_by_user_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
strings.TrimSpace(params.Name),
strings.TrimSpace(params.KeyPrefix),
strings.TrimSpace(params.KeyHash),
strings.TrimSpace(params.Description),
params.ScopeMode,
boolToInt(params.CanDownload),
boolToInt(params.CanUpload),
boolToInt(params.CanDelete),
boolToInt(params.CanManageProjects),
isActive,
nullableTimestampValue(params.ExpiresAt),
params.CreatedByUserID,
)
if err != nil {
if isUniqueConstraintError(err) {
return nil, errors.Join(ErrConflict, fmt.Errorf("insert api key: %w", err))
}
return nil, fmt.Errorf("insert api key: %w", err)
}
apiKeyID, err := result.LastInsertId()
if err != nil {
return nil, fmt.Errorf("load inserted api key id: %w", err)
}
return r.GetByID(ctx, apiKeyID)
}
func (r *APIKeyRepository) Update(ctx context.Context, apiKeyID int64, params UpdateAPIKeyParams) (*APIKey, error) {
result, err := r.q.ExecContext(
ctx,
`UPDATE api_keys
SET name = ?,
description = ?,
scope_mode = ?,
can_download = ?,
can_upload = ?,
can_delete = ?,
can_manage_projects = ?,
expires_at = ?
WHERE id = ?`,
strings.TrimSpace(params.Name),
strings.TrimSpace(params.Description),
params.ScopeMode,
boolToInt(params.CanDownload),
boolToInt(params.CanUpload),
boolToInt(params.CanDelete),
boolToInt(params.CanManageProjects),
nullableTimestampValue(params.ExpiresAt),
apiKeyID,
)
if err != nil {
return nil, fmt.Errorf("update api key: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return nil, fmt.Errorf("read updated api key rows: %w", err)
}
if rowsAffected == 0 {
return nil, ErrNotFound
}
return r.GetByID(ctx, apiKeyID)
}
func (r *APIKeyRepository) SetActive(ctx context.Context, apiKeyID int64, isActive bool) error {
result, err := r.q.ExecContext(
ctx,
`UPDATE api_keys SET is_active = ? WHERE id = ?`,
boolToInt(isActive),
apiKeyID,
)
if err != nil {
return fmt.Errorf("update api key active state: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("read updated api key rows: %w", err)
}
if rowsAffected == 0 {
return ErrNotFound
}
return nil
}
func (r *APIKeyRepository) TouchLastUsedAt(ctx context.Context, apiKeyID int64, usedAt time.Time) error {
result, err := r.q.ExecContext(
ctx,
`UPDATE api_keys SET last_used_at = ? WHERE id = ?`,
formatTimestamp(usedAt),
apiKeyID,
)
if err != nil {
return fmt.Errorf("update api key last_used_at: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("read updated api key rows: %w", err)
}
if rowsAffected == 0 {
return ErrNotFound
}
return nil
}
func (r *APIKeyRepository) ListProjectAccess(ctx context.Context, apiKeyID int64) ([]Project, error) {
rows, err := r.q.QueryContext(
ctx,
`SELECT
p.id,
p.name,
p.slug,
p.description,
p.is_active,
p.created_at,
p.updated_at
FROM projects AS p
INNER JOIN api_key_project_access AS ap ON ap.project_id = p.id
WHERE ap.api_key_id = ?
ORDER BY p.name COLLATE NOCASE`,
apiKeyID,
)
if err != nil {
return nil, fmt.Errorf("query api key project access: %w", err)
}
defer rows.Close()
projects := make([]Project, 0)
for rows.Next() {
project, err := scanProject(rows)
if err != nil {
return nil, fmt.Errorf("scan api key project access: %w", err)
}
projects = append(projects, *project)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate api key project access: %w", err)
}
return projects, nil
}
func (r *APIKeyRepository) ListTagAccess(ctx context.Context, apiKeyID int64) ([]Tag, error) {
rows, err := r.q.QueryContext(
ctx,
`SELECT
t.id,
t.name,
t.slug,
t.description,
t.created_at,
t.updated_at
FROM tags AS t
INNER JOIN api_key_tag_access AS at ON at.tag_id = t.id
WHERE at.api_key_id = ?
ORDER BY t.name COLLATE NOCASE`,
apiKeyID,
)
if err != nil {
return nil, fmt.Errorf("query api key tag access: %w", err)
}
defer rows.Close()
tags := make([]Tag, 0)
for rows.Next() {
tag, err := scanTag(rows)
if err != nil {
return nil, fmt.Errorf("scan api key tag access: %w", err)
}
tags = append(tags, *tag)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate api key tag access: %w", err)
}
return tags, nil
}
func (r *APIKeyRepository) ReplaceProjectAccess(ctx context.Context, apiKeyID int64, projectIDs []int64) error {
if err := r.ClearProjectAccess(ctx, apiKeyID); err != nil {
return err
}
for _, projectID := range normalizeIDList(projectIDs) {
if _, err := r.q.ExecContext(
ctx,
`INSERT OR IGNORE INTO api_key_project_access (api_key_id, project_id) VALUES (?, ?)`,
apiKeyID,
projectID,
); err != nil {
return fmt.Errorf("insert api key project access: %w", err)
}
}
return nil
}
func (r *APIKeyRepository) ReplaceTagAccess(ctx context.Context, apiKeyID int64, tagIDs []int64) error {
if err := r.ClearTagAccess(ctx, apiKeyID); err != nil {
return err
}
for _, tagID := range normalizeIDList(tagIDs) {
if _, err := r.q.ExecContext(
ctx,
`INSERT OR IGNORE INTO api_key_tag_access (api_key_id, tag_id) VALUES (?, ?)`,
apiKeyID,
tagID,
); err != nil {
return fmt.Errorf("insert api key tag access: %w", err)
}
}
return nil
}
func (r *APIKeyRepository) ClearProjectAccess(ctx context.Context, apiKeyID int64) error {
if _, err := r.q.ExecContext(ctx, `DELETE FROM api_key_project_access WHERE api_key_id = ?`, apiKeyID); err != nil {
return fmt.Errorf("delete api key project access: %w", err)
}
return nil
}
func (r *APIKeyRepository) ClearTagAccess(ctx context.Context, apiKeyID int64) error {
if _, err := r.q.ExecContext(ctx, `DELETE FROM api_key_tag_access WHERE api_key_id = ?`, apiKeyID); err != nil {
return fmt.Errorf("delete api key tag access: %w", err)
}
return nil
}
func (r *APIKeyRepository) ListAccessibleProjects(ctx context.Context, apiKeyID int64, scopeMode ScopeMode) ([]Project, error) {
rows, err := r.q.QueryContext(
ctx,
`SELECT
p.id,
p.name,
p.slug,
p.description,
p.is_active,
p.created_at,
p.updated_at
FROM projects AS p
WHERE p.is_active = 1 AND (
? = 'all_projects'
OR (
? = 'project_allow_list'
AND EXISTS (
SELECT 1
FROM api_key_project_access AS ap
WHERE ap.api_key_id = ?
AND ap.project_id = p.id
)
)
OR (
? = 'project_deny_list'
AND NOT EXISTS (
SELECT 1
FROM api_key_project_access AS ap
WHERE ap.api_key_id = ?
AND ap.project_id = p.id
)
)
OR (
? = 'tag_allow_list'
AND EXISTS (
SELECT 1
FROM project_tags AS pt
INNER JOIN api_key_tag_access AS at ON at.tag_id = pt.tag_id
WHERE at.api_key_id = ?
AND pt.project_id = p.id
)
)
OR (
? = 'tag_deny_list'
AND NOT EXISTS (
SELECT 1
FROM project_tags AS pt
INNER JOIN api_key_tag_access AS at ON at.tag_id = pt.tag_id
WHERE at.api_key_id = ?
AND pt.project_id = p.id
)
)
)
ORDER BY p.name COLLATE NOCASE`,
scopeMode,
scopeMode, apiKeyID,
scopeMode, apiKeyID,
scopeMode, apiKeyID,
scopeMode, apiKeyID,
)
if err != nil {
return nil, fmt.Errorf("query accessible projects: %w", err)
}
defer rows.Close()
projects := make([]Project, 0)
for rows.Next() {
project, err := scanProject(rows)
if err != nil {
return nil, fmt.Errorf("scan accessible project: %w", err)
}
projects = append(projects, *project)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate accessible projects: %w", err)
}
return projects, nil
}
func (r *APIKeyRepository) HasProjectAccess(ctx context.Context, apiKeyID int64, scopeMode ScopeMode, projectID int64) (bool, error) {
var exists int
if err := r.q.QueryRowContext(
ctx,
`SELECT EXISTS(
SELECT 1
FROM projects AS p
WHERE p.id = ?
AND p.is_active = 1
AND (
? = 'all_projects'
OR (
? = 'project_allow_list'
AND EXISTS (
SELECT 1
FROM api_key_project_access AS ap
WHERE ap.api_key_id = ?
AND ap.project_id = p.id
)
)
OR (
? = 'project_deny_list'
AND NOT EXISTS (
SELECT 1
FROM api_key_project_access AS ap
WHERE ap.api_key_id = ?
AND ap.project_id = p.id
)
)
OR (
? = 'tag_allow_list'
AND EXISTS (
SELECT 1
FROM project_tags AS pt
INNER JOIN api_key_tag_access AS at ON at.tag_id = pt.tag_id
WHERE at.api_key_id = ?
AND pt.project_id = p.id
)
)
OR (
? = 'tag_deny_list'
AND NOT EXISTS (
SELECT 1
FROM project_tags AS pt
INNER JOIN api_key_tag_access AS at ON at.tag_id = pt.tag_id
WHERE at.api_key_id = ?
AND pt.project_id = p.id
)
)
)
)`,
projectID,
scopeMode,
scopeMode, apiKeyID,
scopeMode, apiKeyID,
scopeMode, apiKeyID,
scopeMode, apiKeyID,
).Scan(&exists); err != nil {
return false, fmt.Errorf("query api key project access: %w", err)
}
return exists == 1, nil
}
func scanAPIKey(scanner rowScanner) (*APIKey, error) {
var (
key APIKey
scopeMode string
canDownload int
canUpload int
canDelete int
canManageProjects int
isActive int
expiresAtRaw sql.NullString
createdAtRaw string
updatedAtRaw string
lastUsedAtRaw sql.NullString
createdByUserIDRaw sql.NullInt64
)
if err := scanner.Scan(
&key.ID,
&key.Name,
&key.KeyPrefix,
&key.KeyHash,
&key.Description,
&scopeMode,
&canDownload,
&canUpload,
&canDelete,
&canManageProjects,
&isActive,
&expiresAtRaw,
&createdAtRaw,
&updatedAtRaw,
&lastUsedAtRaw,
&createdByUserIDRaw,
); err != nil {
return nil, err
}
createdAt, err := parseTimestamp(createdAtRaw)
if err != nil {
return nil, fmt.Errorf("parse api key created_at: %w", err)
}
updatedAt, err := parseTimestamp(updatedAtRaw)
if err != nil {
return nil, fmt.Errorf("parse api key updated_at: %w", err)
}
expiresAt, err := parseNullableTimestamp(expiresAtRaw)
if err != nil {
return nil, fmt.Errorf("parse api key expires_at: %w", err)
}
lastUsedAt, err := parseNullableTimestamp(lastUsedAtRaw)
if err != nil {
return nil, fmt.Errorf("parse api key last_used_at: %w", err)
}
key.ScopeMode = ScopeMode(scopeMode)
key.CanDownload = canDownload == 1
key.CanUpload = canUpload == 1
key.CanDelete = canDelete == 1
key.CanManageProjects = canManageProjects == 1
key.IsActive = isActive == 1
key.ExpiresAt = expiresAt
key.CreatedAt = createdAt
key.UpdatedAt = updatedAt
key.LastUsedAt = lastUsedAt
if createdByUserIDRaw.Valid {
key.CreatedByUserID = &createdByUserIDRaw.Int64
}
return &key, nil
}
func scanAPIKeyListItem(scanner rowScanner) (*APIKeyListItem, error) {
var (
item APIKeyListItem
scopeMode string
canDownload int
canUpload int
canDelete int
canManageProjects int
isActive int
expiresAtRaw sql.NullString
createdAtRaw string
updatedAtRaw string
lastUsedAtRaw sql.NullString
createdByUserIDRaw sql.NullInt64
)
if err := scanner.Scan(
&item.APIKey.ID,
&item.APIKey.Name,
&item.APIKey.KeyPrefix,
&item.APIKey.KeyHash,
&item.APIKey.Description,
&scopeMode,
&canDownload,
&canUpload,
&canDelete,
&canManageProjects,
&isActive,
&expiresAtRaw,
&createdAtRaw,
&updatedAtRaw,
&lastUsedAtRaw,
&createdByUserIDRaw,
&item.ProjectRuleCount,
&item.TagRuleCount,
); err != nil {
return nil, err
}
createdAt, err := parseTimestamp(createdAtRaw)
if err != nil {
return nil, fmt.Errorf("parse api key list created_at: %w", err)
}
updatedAt, err := parseTimestamp(updatedAtRaw)
if err != nil {
return nil, fmt.Errorf("parse api key list updated_at: %w", err)
}
expiresAt, err := parseNullableTimestamp(expiresAtRaw)
if err != nil {
return nil, fmt.Errorf("parse api key list expires_at: %w", err)
}
lastUsedAt, err := parseNullableTimestamp(lastUsedAtRaw)
if err != nil {
return nil, fmt.Errorf("parse api key list last_used_at: %w", err)
}
item.APIKey.ScopeMode = ScopeMode(scopeMode)
item.APIKey.CanDownload = canDownload == 1
item.APIKey.CanUpload = canUpload == 1
item.APIKey.CanDelete = canDelete == 1
item.APIKey.CanManageProjects = canManageProjects == 1
item.APIKey.IsActive = isActive == 1
item.APIKey.ExpiresAt = expiresAt
item.APIKey.CreatedAt = createdAt
item.APIKey.UpdatedAt = updatedAt
item.APIKey.LastUsedAt = lastUsedAt
if createdByUserIDRaw.Valid {
item.APIKey.CreatedByUserID = &createdByUserIDRaw.Int64
}
return &item, nil
}
func boolToInt(value bool) int {
if value {
return 1
}
return 0
}
func nullableTimestampValue(value *time.Time) any {
if value == nil {
return nil
}
return formatTimestamp(value.UTC())
}
func normalizeIDList(values []int64) []int64 {
seen := make(map[int64]struct{}, len(values))
normalized := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
normalized = append(normalized, value)
}
sort.Slice(normalized, func(i, j int) bool {
return normalized[i] < normalized[j]
})
return normalized
}

19
internal/db/errors.go Normal file
View file

@ -0,0 +1,19 @@
package db
import (
"errors"
"strings"
)
var (
ErrNotFound = errors.New("record not found")
ErrConflict = errors.New("record conflict")
)
func isUniqueConstraintError(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "unique constraint failed")
}

146
internal/db/migrate.go Normal file
View file

@ -0,0 +1,146 @@
package db
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
)
const migrationsTableDDL = `
CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
checksum_sha256 TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
`
func Migrate(ctx context.Context, database *sql.DB, migrationsDir string) error {
if migrationsDir == "" {
return fmt.Errorf("migrations dir is required")
}
if _, err := database.ExecContext(ctx, migrationsTableDDL); err != nil {
return fmt.Errorf("ensure schema_migrations table: %w", err)
}
applied, err := appliedMigrations(ctx, database)
if err != nil {
return err
}
files, err := listMigrationFiles(migrationsDir)
if err != nil {
return err
}
for _, filename := range files {
fullPath := filepath.Join(migrationsDir, filename)
contents, err := os.ReadFile(fullPath)
if err != nil {
return fmt.Errorf("read migration %s: %w", filename, err)
}
checksum := checksum(contents)
if appliedChecksum, ok := applied[filename]; ok {
if appliedChecksum != checksum {
return fmt.Errorf("migration %s checksum mismatch: applied=%s current=%s", filename, appliedChecksum, checksum)
}
continue
}
if err := applyMigration(ctx, database, filename, checksum, string(contents)); err != nil {
return err
}
}
return nil
}
func appliedMigrations(ctx context.Context, database *sql.DB) (map[string]string, error) {
rows, err := database.QueryContext(ctx, `SELECT name, checksum_sha256 FROM schema_migrations`)
if err != nil {
return nil, fmt.Errorf("load applied migrations: %w", err)
}
defer rows.Close()
applied := make(map[string]string)
for rows.Next() {
var name string
var checksum string
if err := rows.Scan(&name, &checksum); err != nil {
return nil, fmt.Errorf("scan applied migration: %w", err)
}
applied[name] = checksum
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate applied migrations: %w", err)
}
return applied, nil
}
func listMigrationFiles(migrationsDir string) ([]string, error) {
entries, err := os.ReadDir(migrationsDir)
if err != nil {
return nil, fmt.Errorf("read migrations dir: %w", err)
}
files := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".sql" {
continue
}
files = append(files, entry.Name())
}
slices.Sort(files)
return files, nil
}
func applyMigration(ctx context.Context, database *sql.DB, filename, checksum, sqlText string) error {
tx, err := database.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin migration %s: %w", filename, err)
}
if strings.TrimSpace(sqlText) != "" {
if _, err := tx.ExecContext(ctx, sqlText); err != nil {
tx.Rollback()
return fmt.Errorf("execute migration %s: %w", filename, err)
}
}
if _, err := tx.ExecContext(
ctx,
`INSERT INTO schema_migrations (name, checksum_sha256) VALUES (?, ?)`,
filename,
checksum,
); err != nil {
tx.Rollback()
return fmt.Errorf("record migration %s: %w", filename, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", filename, err)
}
return nil
}
func checksum(contents []byte) string {
sum := sha256.Sum256(contents)
return hex.EncodeToString(sum[:])
}

294
internal/db/migrate_test.go Normal file
View file

@ -0,0 +1,294 @@
package db_test
import (
"context"
"database/sql"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"update_server/internal/db"
)
func TestMigrateAppliesCoreSchema(t *testing.T) {
t.Parallel()
ctx := context.Background()
sqlitePath := filepath.Join(t.TempDir(), "update-server.sqlite")
database, err := db.Open(ctx, sqlitePath)
if err != nil {
t.Fatalf("open database: %v", err)
}
defer database.Close()
migrationsDir := projectMigrationsDir(t)
if err := db.Migrate(ctx, database, migrationsDir); err != nil {
t.Fatalf("apply migrations: %v", err)
}
if err := db.Migrate(ctx, database, migrationsDir); err != nil {
t.Fatalf("reapply migrations: %v", err)
}
expectedTables := []string{
"schema_migrations",
"users",
"projects",
"tags",
"project_tags",
"releases",
"api_keys",
"api_key_project_access",
"api_key_tag_access",
"sessions",
"audit_logs",
}
for _, tableName := range expectedTables {
if !tableExists(t, database, tableName) {
t.Fatalf("expected table %q to exist", tableName)
}
}
if _, err := database.ExecContext(
ctx,
`INSERT INTO users (email, password_hash, role) VALUES (?, ?, ?)`,
"admin@example.com",
"hashed-password",
"admin",
); err != nil {
t.Fatalf("insert user: %v", err)
}
if _, err := database.ExecContext(
ctx,
`INSERT INTO projects (name, slug, description) VALUES (?, ?, ?)`,
"Desktop App",
"desktop-app",
"Primary desktop client",
); err != nil {
t.Fatalf("insert project: %v", err)
}
if _, err := database.ExecContext(
ctx,
`INSERT INTO tags (name, slug, description) VALUES (?, ?, ?)`,
"Windows",
"windows",
"Windows releases",
); err != nil {
t.Fatalf("insert tag: %v", err)
}
var projectID int64
if err := database.QueryRowContext(ctx, `SELECT id FROM projects WHERE slug = ?`, "desktop-app").Scan(&projectID); err != nil {
t.Fatalf("load project id: %v", err)
}
var tagID int64
if err := database.QueryRowContext(ctx, `SELECT id FROM tags WHERE slug = ?`, "windows").Scan(&tagID); err != nil {
t.Fatalf("load tag id: %v", err)
}
var userID int64
if err := database.QueryRowContext(ctx, `SELECT id FROM users WHERE email = ?`, "admin@example.com").Scan(&userID); err != nil {
t.Fatalf("load user id: %v", err)
}
if _, err := database.ExecContext(
ctx,
`INSERT INTO project_tags (project_id, tag_id) VALUES (?, ?)`,
projectID,
tagID,
); err != nil {
t.Fatalf("insert project tag: %v", err)
}
if _, err := database.ExecContext(
ctx,
`INSERT INTO releases (project_id, version, build, filename, storage_path, checksum_sha256, size_bytes, content_type, release_notes, uploaded_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
projectID,
"1.0.0",
"",
"desktop-app-1.0.0.zip",
"artifacts/desktop-app/1.0.0/desktop-app-1.0.0.zip",
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
1024,
"application/zip",
"Initial release",
userID,
); err != nil {
t.Fatalf("insert release: %v", err)
}
if _, err := database.ExecContext(
ctx,
`INSERT INTO api_keys (name, key_prefix, key_hash, description, scope_mode, can_download, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?)`,
"Desktop Clients",
"updsrv_project",
"hash-project",
"Project-scoped desktop client access",
"project_allow_list",
1,
userID,
); err != nil {
t.Fatalf("insert project api key: %v", err)
}
var projectAPIKeyID int64
if err := database.QueryRowContext(ctx, `SELECT id FROM api_keys WHERE key_prefix = ?`, "updsrv_project").Scan(&projectAPIKeyID); err != nil {
t.Fatalf("load project api key id: %v", err)
}
if _, err := database.ExecContext(
ctx,
`INSERT INTO api_key_project_access (api_key_id, project_id) VALUES (?, ?)`,
projectAPIKeyID,
projectID,
); err != nil {
t.Fatalf("insert api key project access: %v", err)
}
if _, err := database.ExecContext(
ctx,
`INSERT INTO api_keys (name, key_prefix, key_hash, description, scope_mode, can_download, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?)`,
"Tagged Clients",
"updsrv_tag",
"hash-tag",
"Tag-scoped desktop client access",
"tag_allow_list",
1,
userID,
); err != nil {
t.Fatalf("insert tag api key: %v", err)
}
var tagAPIKeyID int64
if err := database.QueryRowContext(ctx, `SELECT id FROM api_keys WHERE key_prefix = ?`, "updsrv_tag").Scan(&tagAPIKeyID); err != nil {
t.Fatalf("load tag api key id: %v", err)
}
if _, err := database.ExecContext(
ctx,
`INSERT INTO api_key_tag_access (api_key_id, tag_id) VALUES (?, ?)`,
tagAPIKeyID,
tagID,
); err != nil {
t.Fatalf("insert api key tag access: %v", err)
}
if _, err := database.ExecContext(
ctx,
`INSERT INTO sessions (user_id, token_hash, expires_at, ip_address, user_agent) VALUES (?, ?, ?, ?, ?)`,
userID,
"session-hash",
"2030-01-01T00:00:00Z",
"127.0.0.1",
"test-agent",
); err != nil {
t.Fatalf("insert session: %v", err)
}
if _, err := database.ExecContext(
ctx,
`INSERT INTO audit_logs (actor_user_id, api_key_id, action, target_type, target_id, target_identifier, metadata_json, ip_address) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
userID,
tagAPIKeyID,
"api_key.created",
"api_key",
tagAPIKeyID,
"updsrv_tag",
`{"source":"test"}`,
"127.0.0.1",
); err != nil {
t.Fatalf("insert audit log: %v", err)
}
if _, err := database.ExecContext(
ctx,
`INSERT INTO api_key_project_access (api_key_id, project_id) VALUES (?, ?)`,
tagAPIKeyID,
projectID,
); err == nil {
t.Fatal("expected project access insert for tag-scoped key to fail")
}
if _, err := database.ExecContext(
ctx,
`INSERT INTO api_keys (name, key_prefix, key_hash, scope_mode) VALUES (?, ?, ?, ?)`,
"Broken Key",
"updsrv_invalid",
"hash-invalid",
"invalid_scope",
); err == nil {
t.Fatal("expected invalid scope_mode insert to fail")
}
}
func projectMigrationsDir(t *testing.T) string {
t.Helper()
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("resolve caller path")
}
return filepath.Join(filepath.Dir(filename), "..", "..", "migrations")
}
func tableExists(t *testing.T, database *sql.DB, tableName string) bool {
t.Helper()
var exists int
query := `SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?)`
if err := database.QueryRowContext(context.Background(), query, tableName).Scan(&exists); err != nil {
t.Fatalf("check table %s: %v", tableName, err)
}
return exists == 1
}
func TestMigrateRejectsEditedAppliedMigrations(t *testing.T) {
t.Parallel()
ctx := context.Background()
tempDir := t.TempDir()
sqlitePath := filepath.Join(tempDir, "update-server.sqlite")
database, err := db.Open(ctx, sqlitePath)
if err != nil {
t.Fatalf("open database: %v", err)
}
defer database.Close()
migrationsDir := filepath.Join(tempDir, "migrations")
if err := os.MkdirAll(migrationsDir, 0o755); err != nil {
t.Fatalf("create migrations dir: %v", err)
}
firstMigrationPath := filepath.Join(migrationsDir, "0001_test.sql")
if err := os.WriteFile(firstMigrationPath, []byte(`CREATE TABLE sample (id INTEGER PRIMARY KEY);`), 0o644); err != nil {
t.Fatalf("write migration: %v", err)
}
if err := db.Migrate(ctx, database, migrationsDir); err != nil {
t.Fatalf("apply migration: %v", err)
}
if err := os.WriteFile(firstMigrationPath, []byte(`CREATE TABLE sample (id INTEGER PRIMARY KEY, name TEXT);`), 0o644); err != nil {
t.Fatalf("rewrite migration: %v", err)
}
err = db.Migrate(ctx, database, migrationsDir)
if err == nil {
t.Fatal("expected checksum mismatch error")
}
expectedMessage := "checksum mismatch"
if !strings.Contains(err.Error(), expectedMessage) {
t.Fatalf("expected error containing %q, got %v", expectedMessage, err)
}
}

177
internal/db/models.go Normal file
View file

@ -0,0 +1,177 @@
package db
import "time"
type ScopeMode string
const (
ScopeModeAllProjects ScopeMode = "all_projects"
ScopeModeProjectAllowList ScopeMode = "project_allow_list"
ScopeModeProjectDenyList ScopeMode = "project_deny_list"
ScopeModeTagAllowList ScopeMode = "tag_allow_list"
ScopeModeTagDenyList ScopeMode = "tag_deny_list"
)
func (m ScopeMode) Valid() bool {
switch m {
case ScopeModeAllProjects,
ScopeModeProjectAllowList,
ScopeModeProjectDenyList,
ScopeModeTagAllowList,
ScopeModeTagDenyList:
return true
default:
return false
}
}
func (m ScopeMode) UsesProjectRules() bool {
return m == ScopeModeProjectAllowList || m == ScopeModeProjectDenyList
}
func (m ScopeMode) UsesTagRules() bool {
return m == ScopeModeTagAllowList || m == ScopeModeTagDenyList
}
type UserRole string
const (
UserRoleAdmin UserRole = "admin"
UserRoleEditor UserRole = "editor"
UserRoleViewer UserRole = "viewer"
)
type User struct {
ID int64
Email string
PasswordHash string
Role UserRole
IsActive bool
CreatedAt time.Time
UpdatedAt time.Time
LastLoginAt *time.Time
}
type Project struct {
ID int64
Name string
Slug string
Description string
IsActive bool
CreatedAt time.Time
UpdatedAt time.Time
}
type ProjectListItem struct {
Project Project
TagCount int
ReleaseCount int
}
type Tag struct {
ID int64
Name string
Slug string
Description string
CreatedAt time.Time
UpdatedAt time.Time
}
type TagListItem struct {
Tag Tag
ProjectCount int
}
type Release struct {
ID int64
ProjectID int64
Version string
Build string
Filename string
StoragePath string
ChecksumSHA256 string
SizeBytes int64
ContentType string
ReleaseNotes string
CreatedAt time.Time
UpdatedAt time.Time
UploadedByUserID *int64
IsActive bool
}
type ReleaseListItem struct {
Release Release
UploadedByEmail string
}
type APIKey struct {
ID int64
Name string
KeyPrefix string
KeyHash string
Description string
ScopeMode ScopeMode
CanDownload bool
CanUpload bool
CanDelete bool
CanManageProjects bool
IsActive bool
ExpiresAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
LastUsedAt *time.Time
CreatedByUserID *int64
}
func (k APIKey) Expired(now time.Time) bool {
return k.ExpiresAt != nil && !k.ExpiresAt.After(now.UTC())
}
type APIKeyListItem struct {
APIKey APIKey
ProjectRuleCount int
TagRuleCount int
AccessiblePreview int
}
type APIKeyProjectAccess struct {
APIKeyID int64
ProjectID int64
CreatedAt time.Time
}
type APIKeyTagAccess struct {
APIKeyID int64
TagID int64
CreatedAt time.Time
}
type Session struct {
ID int64
UserID int64
TokenHash string
ExpiresAt time.Time
LastSeenAt *time.Time
InvalidatedAt *time.Time
IPAddress string
UserAgent string
CreatedAt time.Time
}
type SessionWithUser struct {
Session Session
User User
}
type AuditLog struct {
ID int64
ActorUserID *int64
APIKeyID *int64
Action string
TargetType string
TargetID *int64
TargetIdentifier string
MetadataJSON string
IPAddress string
CreatedAt time.Time
}

60
internal/db/open.go Normal file
View file

@ -0,0 +1,60 @@
package db
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
_ "github.com/mattn/go-sqlite3"
)
const sqliteDriverName = "sqlite3"
func Open(ctx context.Context, sqlitePath string) (*sql.DB, error) {
if sqlitePath == "" {
return nil, fmt.Errorf("sqlite path is required")
}
if err := os.MkdirAll(filepath.Dir(sqlitePath), 0o750); err != nil {
return nil, fmt.Errorf("create sqlite dir: %w", err)
}
database, err := sql.Open(sqliteDriverName, sqlitePath)
if err != nil {
return nil, fmt.Errorf("open sqlite database: %w", err)
}
database.SetMaxOpenConns(1)
database.SetMaxIdleConns(1)
if err := applyPragmas(ctx, database); err != nil {
database.Close()
return nil, err
}
if err := database.PingContext(ctx); err != nil {
database.Close()
return nil, fmt.Errorf("ping sqlite database: %w", err)
}
return database, nil
}
func applyPragmas(ctx context.Context, database *sql.DB) error {
pragmas := []string{
"PRAGMA foreign_keys = ON;",
"PRAGMA journal_mode = WAL;",
"PRAGMA busy_timeout = 5000;",
"PRAGMA synchronous = NORMAL;",
}
for _, pragma := range pragmas {
if _, err := database.ExecContext(ctx, pragma); err != nil {
return fmt.Errorf("apply sqlite pragma %q: %w", pragma, err)
}
}
return nil
}

326
internal/db/projects.go Normal file
View file

@ -0,0 +1,326 @@
package db
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
)
type CreateProjectParams struct {
Name string
Slug string
Description string
}
type UpdateProjectParams struct {
Name string
Slug string
Description string
}
func (r *ProjectRepository) List(ctx context.Context) ([]ProjectListItem, error) {
rows, err := r.q.QueryContext(
ctx,
`SELECT
p.id,
p.name,
p.slug,
p.description,
p.is_active,
p.created_at,
p.updated_at,
COUNT(DISTINCT pt.tag_id) AS tag_count,
COUNT(DISTINCT rel.id) AS release_count
FROM projects AS p
LEFT JOIN project_tags AS pt ON pt.project_id = p.id
LEFT JOIN releases AS rel ON rel.project_id = p.id AND rel.is_active = 1
GROUP BY p.id
ORDER BY p.is_active DESC, p.updated_at DESC, p.created_at DESC, p.name COLLATE NOCASE`,
)
if err != nil {
return nil, fmt.Errorf("query projects: %w", err)
}
defer rows.Close()
projects := make([]ProjectListItem, 0)
for rows.Next() {
item, err := scanProjectListItem(rows)
if err != nil {
return nil, fmt.Errorf("scan project list item: %w", err)
}
projects = append(projects, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate projects: %w", err)
}
return projects, nil
}
func (r *ProjectRepository) GetByID(ctx context.Context, id int64) (*Project, error) {
project, err := scanProject(r.q.QueryRowContext(
ctx,
`SELECT id, name, slug, description, is_active, created_at, updated_at
FROM projects
WHERE id = ?`,
id,
))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("scan project by id: %w", err)
}
return project, nil
}
func (r *ProjectRepository) GetBySlug(ctx context.Context, slug string) (*Project, error) {
project, err := scanProject(r.q.QueryRowContext(
ctx,
`SELECT id, name, slug, description, is_active, created_at, updated_at
FROM projects
WHERE slug = ?
LIMIT 1`,
strings.TrimSpace(slug),
))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("scan project by slug: %w", err)
}
return project, nil
}
func (r *ProjectRepository) Create(ctx context.Context, params CreateProjectParams) (*Project, error) {
result, err := r.q.ExecContext(
ctx,
`INSERT INTO projects (name, slug, description) VALUES (?, ?, ?)`,
strings.TrimSpace(params.Name),
strings.TrimSpace(params.Slug),
strings.TrimSpace(params.Description),
)
if err != nil {
if isUniqueConstraintError(err) {
return nil, errors.Join(ErrConflict, fmt.Errorf("insert project: %w", err))
}
return nil, fmt.Errorf("insert project: %w", err)
}
projectID, err := result.LastInsertId()
if err != nil {
return nil, fmt.Errorf("load inserted project id: %w", err)
}
return r.GetByID(ctx, projectID)
}
func (r *ProjectRepository) Update(ctx context.Context, projectID int64, params UpdateProjectParams) (*Project, error) {
result, err := r.q.ExecContext(
ctx,
`UPDATE projects
SET name = ?, slug = ?, description = ?
WHERE id = ?`,
strings.TrimSpace(params.Name),
strings.TrimSpace(params.Slug),
strings.TrimSpace(params.Description),
projectID,
)
if err != nil {
if isUniqueConstraintError(err) {
return nil, errors.Join(ErrConflict, fmt.Errorf("update project: %w", err))
}
return nil, fmt.Errorf("update project: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return nil, fmt.Errorf("read updated project rows: %w", err)
}
if rowsAffected == 0 {
return nil, ErrNotFound
}
return r.GetByID(ctx, projectID)
}
func (r *ProjectRepository) SetActive(ctx context.Context, projectID int64, isActive bool) error {
activeValue := 0
if isActive {
activeValue = 1
}
result, err := r.q.ExecContext(
ctx,
`UPDATE projects
SET is_active = ?
WHERE id = ?`,
activeValue,
projectID,
)
if err != nil {
return fmt.Errorf("update project active state: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("read affected project rows: %w", err)
}
if rowsAffected == 0 {
return ErrNotFound
}
return nil
}
func (r *ProjectRepository) ListTags(ctx context.Context, projectID int64) ([]Tag, error) {
rows, err := r.q.QueryContext(
ctx,
`SELECT
t.id,
t.name,
t.slug,
t.description,
t.created_at,
t.updated_at
FROM tags AS t
INNER JOIN project_tags AS pt ON pt.tag_id = t.id
WHERE pt.project_id = ?
ORDER BY t.name COLLATE NOCASE`,
projectID,
)
if err != nil {
return nil, fmt.Errorf("query project tags: %w", err)
}
defer rows.Close()
tags := make([]Tag, 0)
for rows.Next() {
tag, err := scanTag(rows)
if err != nil {
return nil, fmt.Errorf("scan project tag: %w", err)
}
tags = append(tags, *tag)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate project tags: %w", err)
}
return tags, nil
}
func (r *ProjectRepository) AttachTag(ctx context.Context, projectID, tagID int64) error {
if _, err := r.q.ExecContext(
ctx,
`INSERT OR IGNORE INTO project_tags (project_id, tag_id) VALUES (?, ?)`,
projectID,
tagID,
); err != nil {
return fmt.Errorf("insert project tag link: %w", err)
}
return nil
}
func (r *ProjectRepository) DetachTag(ctx context.Context, projectID, tagID int64) error {
if _, err := r.q.ExecContext(
ctx,
`DELETE FROM project_tags WHERE project_id = ? AND tag_id = ?`,
projectID,
tagID,
); err != nil {
return fmt.Errorf("delete project tag link: %w", err)
}
return nil
}
func scanProject(scanner rowScanner) (*Project, error) {
var (
project Project
isActive int
createdAtRaw string
updatedAtRaw string
)
if err := scanner.Scan(
&project.ID,
&project.Name,
&project.Slug,
&project.Description,
&isActive,
&createdAtRaw,
&updatedAtRaw,
); err != nil {
return nil, err
}
createdAt, err := parseTimestamp(createdAtRaw)
if err != nil {
return nil, fmt.Errorf("parse project created_at: %w", err)
}
updatedAt, err := parseTimestamp(updatedAtRaw)
if err != nil {
return nil, fmt.Errorf("parse project updated_at: %w", err)
}
project.IsActive = isActive == 1
project.CreatedAt = createdAt
project.UpdatedAt = updatedAt
return &project, nil
}
func scanProjectListItem(scanner rowScanner) (*ProjectListItem, error) {
var (
item ProjectListItem
isActive int
createdAtRaw string
updatedAtRaw string
)
if err := scanner.Scan(
&item.Project.ID,
&item.Project.Name,
&item.Project.Slug,
&item.Project.Description,
&isActive,
&createdAtRaw,
&updatedAtRaw,
&item.TagCount,
&item.ReleaseCount,
); err != nil {
return nil, err
}
createdAt, err := parseTimestamp(createdAtRaw)
if err != nil {
return nil, fmt.Errorf("parse project list created_at: %w", err)
}
updatedAt, err := parseTimestamp(updatedAtRaw)
if err != nil {
return nil, fmt.Errorf("parse project list updated_at: %w", err)
}
item.Project.IsActive = isActive == 1
item.Project.CreatedAt = createdAt
item.Project.UpdatedAt = updatedAt
return &item, nil
}

285
internal/db/releases.go Normal file
View file

@ -0,0 +1,285 @@
package db
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
)
type CreateReleaseParams struct {
ProjectID int64
Version string
Build string
Filename string
StoragePath string
ChecksumSHA256 string
SizeBytes int64
ContentType string
ReleaseNotes string
UploadedByUserID *int64
IsActive bool
}
func (r *ReleaseRepository) Create(ctx context.Context, params CreateReleaseParams) (*Release, error) {
isActive := 0
if params.IsActive {
isActive = 1
}
result, err := r.q.ExecContext(
ctx,
`INSERT INTO releases (
project_id,
version,
build,
filename,
storage_path,
checksum_sha256,
size_bytes,
content_type,
release_notes,
uploaded_by_user_id,
is_active
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
params.ProjectID,
strings.TrimSpace(params.Version),
strings.TrimSpace(params.Build),
strings.TrimSpace(params.Filename),
strings.TrimSpace(params.StoragePath),
strings.TrimSpace(params.ChecksumSHA256),
params.SizeBytes,
strings.TrimSpace(params.ContentType),
strings.TrimSpace(params.ReleaseNotes),
params.UploadedByUserID,
isActive,
)
if err != nil {
if isUniqueConstraintError(err) {
return nil, errors.Join(ErrConflict, fmt.Errorf("insert release: %w", err))
}
return nil, fmt.Errorf("insert release: %w", err)
}
releaseID, err := result.LastInsertId()
if err != nil {
return nil, fmt.Errorf("load inserted release id: %w", err)
}
return r.GetByID(ctx, releaseID)
}
func (r *ReleaseRepository) GetByID(ctx context.Context, id int64) (*Release, error) {
release, err := scanRelease(r.q.QueryRowContext(
ctx,
`SELECT
id,
project_id,
version,
build,
filename,
storage_path,
checksum_sha256,
size_bytes,
content_type,
release_notes,
created_at,
updated_at,
uploaded_by_user_id,
is_active
FROM releases
WHERE id = ?`,
id,
))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("scan release by id: %w", err)
}
return release, nil
}
func (r *ReleaseRepository) GetLatestByProjectID(ctx context.Context, projectID int64) (*Release, error) {
release, err := scanRelease(r.q.QueryRowContext(
ctx,
`SELECT
id,
project_id,
version,
build,
filename,
storage_path,
checksum_sha256,
size_bytes,
content_type,
release_notes,
created_at,
updated_at,
uploaded_by_user_id,
is_active
FROM releases
WHERE project_id = ?
AND is_active = 1
ORDER BY created_at DESC, id DESC
LIMIT 1`,
projectID,
))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("scan latest release by project id: %w", err)
}
return release, nil
}
func (r *ReleaseRepository) ListByProjectID(ctx context.Context, projectID int64) ([]ReleaseListItem, error) {
rows, err := r.q.QueryContext(
ctx,
`SELECT
r.id,
r.project_id,
r.version,
r.build,
r.filename,
r.storage_path,
r.checksum_sha256,
r.size_bytes,
r.content_type,
r.release_notes,
r.created_at,
r.updated_at,
r.uploaded_by_user_id,
r.is_active,
COALESCE(u.email, '')
FROM releases AS r
LEFT JOIN users AS u ON u.id = r.uploaded_by_user_id
WHERE r.project_id = ?
ORDER BY r.created_at DESC, r.id DESC`,
projectID,
)
if err != nil {
return nil, fmt.Errorf("query project releases: %w", err)
}
defer rows.Close()
releases := make([]ReleaseListItem, 0)
for rows.Next() {
item, err := scanReleaseListItem(rows)
if err != nil {
return nil, fmt.Errorf("scan project release: %w", err)
}
releases = append(releases, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate project releases: %w", err)
}
return releases, nil
}
func scanRelease(scanner rowScanner) (*Release, error) {
var (
release Release
createdAtRaw string
updatedAtRaw string
uploadedByUserID sql.NullInt64
isActive int
)
if err := scanner.Scan(
&release.ID,
&release.ProjectID,
&release.Version,
&release.Build,
&release.Filename,
&release.StoragePath,
&release.ChecksumSHA256,
&release.SizeBytes,
&release.ContentType,
&release.ReleaseNotes,
&createdAtRaw,
&updatedAtRaw,
&uploadedByUserID,
&isActive,
); err != nil {
return nil, err
}
createdAt, err := parseTimestamp(createdAtRaw)
if err != nil {
return nil, fmt.Errorf("parse release created_at: %w", err)
}
updatedAt, err := parseTimestamp(updatedAtRaw)
if err != nil {
return nil, fmt.Errorf("parse release updated_at: %w", err)
}
release.CreatedAt = createdAt
release.UpdatedAt = updatedAt
release.IsActive = isActive == 1
if uploadedByUserID.Valid {
release.UploadedByUserID = &uploadedByUserID.Int64
}
return &release, nil
}
func scanReleaseListItem(scanner rowScanner) (*ReleaseListItem, error) {
var (
item ReleaseListItem
createdAtRaw string
updatedAtRaw string
uploadedByUserID sql.NullInt64
isActive int
)
if err := scanner.Scan(
&item.Release.ID,
&item.Release.ProjectID,
&item.Release.Version,
&item.Release.Build,
&item.Release.Filename,
&item.Release.StoragePath,
&item.Release.ChecksumSHA256,
&item.Release.SizeBytes,
&item.Release.ContentType,
&item.Release.ReleaseNotes,
&createdAtRaw,
&updatedAtRaw,
&uploadedByUserID,
&isActive,
&item.UploadedByEmail,
); err != nil {
return nil, err
}
createdAt, err := parseTimestamp(createdAtRaw)
if err != nil {
return nil, fmt.Errorf("parse release list created_at: %w", err)
}
updatedAt, err := parseTimestamp(updatedAtRaw)
if err != nil {
return nil, fmt.Errorf("parse release list updated_at: %w", err)
}
item.Release.CreatedAt = createdAt
item.Release.UpdatedAt = updatedAt
item.Release.IsActive = isActive == 1
if uploadedByUserID.Valid {
item.Release.UploadedByUserID = &uploadedByUserID.Int64
}
return &item, nil
}

284
internal/db/sessions.go Normal file
View file

@ -0,0 +1,284 @@
package db
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
type CreateSessionParams struct {
UserID int64
TokenHash string
ExpiresAt time.Time
IPAddress string
UserAgent string
}
func (r *SessionRepository) Create(ctx context.Context, params CreateSessionParams) (*Session, error) {
result, err := r.q.ExecContext(
ctx,
`INSERT INTO sessions (user_id, token_hash, expires_at, ip_address, user_agent) VALUES (?, ?, ?, ?, ?)`,
params.UserID,
params.TokenHash,
formatTimestamp(params.ExpiresAt),
params.IPAddress,
params.UserAgent,
)
if err != nil {
return nil, fmt.Errorf("insert session: %w", err)
}
sessionID, err := result.LastInsertId()
if err != nil {
return nil, fmt.Errorf("load inserted session id: %w", err)
}
return r.GetByID(ctx, sessionID)
}
func (r *SessionRepository) GetByID(ctx context.Context, id int64) (*Session, error) {
session, err := scanSession(r.q.QueryRowContext(
ctx,
`SELECT id, user_id, token_hash, expires_at, last_seen_at, invalidated_at, ip_address, user_agent, created_at
FROM sessions
WHERE id = ?`,
id,
))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("scan session by id: %w", err)
}
return session, nil
}
func (r *SessionRepository) GetActiveWithUserByTokenHash(ctx context.Context, tokenHash string, now time.Time) (*SessionWithUser, error) {
record, err := scanSessionWithUser(r.q.QueryRowContext(
ctx,
`SELECT
s.id,
s.user_id,
s.token_hash,
s.expires_at,
s.last_seen_at,
s.invalidated_at,
s.ip_address,
s.user_agent,
s.created_at,
u.id,
u.email,
u.password_hash,
u.role,
u.is_active,
u.created_at,
u.updated_at,
u.last_login_at
FROM sessions AS s
INNER JOIN users AS u ON u.id = s.user_id
WHERE s.token_hash = ?
AND s.invalidated_at IS NULL
AND s.expires_at > ?
AND u.is_active = 1
LIMIT 1`,
tokenHash,
formatTimestamp(now),
))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("scan active session by token hash: %w", err)
}
return record, nil
}
func (r *SessionRepository) Touch(ctx context.Context, sessionID int64, seenAt time.Time) error {
result, err := r.q.ExecContext(
ctx,
`UPDATE sessions SET last_seen_at = ? WHERE id = ?`,
formatTimestamp(seenAt),
sessionID,
)
if err != nil {
return fmt.Errorf("update session last_seen_at: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("read affected session rows: %w", err)
}
if rowsAffected == 0 {
return ErrNotFound
}
return nil
}
func (r *SessionRepository) InvalidateByTokenHash(ctx context.Context, tokenHash string, invalidatedAt time.Time) error {
result, err := r.q.ExecContext(
ctx,
`UPDATE sessions
SET invalidated_at = ?
WHERE token_hash = ?
AND invalidated_at IS NULL`,
formatTimestamp(invalidatedAt),
tokenHash,
)
if err != nil {
return fmt.Errorf("invalidate session by token hash: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("read invalidated session rows: %w", err)
}
if rowsAffected == 0 {
return ErrNotFound
}
return nil
}
func scanSession(scanner rowScanner) (*Session, error) {
var (
session Session
expiresAtRaw string
lastSeenAtRaw sql.NullString
invalidatedAtRaw sql.NullString
createdAtRaw string
)
if err := scanner.Scan(
&session.ID,
&session.UserID,
&session.TokenHash,
&expiresAtRaw,
&lastSeenAtRaw,
&invalidatedAtRaw,
&session.IPAddress,
&session.UserAgent,
&createdAtRaw,
); err != nil {
return nil, err
}
expiresAt, err := parseTimestamp(expiresAtRaw)
if err != nil {
return nil, fmt.Errorf("parse session expires_at: %w", err)
}
lastSeenAt, err := parseNullableTimestamp(lastSeenAtRaw)
if err != nil {
return nil, fmt.Errorf("parse session last_seen_at: %w", err)
}
invalidatedAt, err := parseNullableTimestamp(invalidatedAtRaw)
if err != nil {
return nil, fmt.Errorf("parse session invalidated_at: %w", err)
}
createdAt, err := parseTimestamp(createdAtRaw)
if err != nil {
return nil, fmt.Errorf("parse session created_at: %w", err)
}
session.ExpiresAt = expiresAt
session.LastSeenAt = lastSeenAt
session.InvalidatedAt = invalidatedAt
session.CreatedAt = createdAt
return &session, nil
}
func scanSessionWithUser(scanner rowScanner) (*SessionWithUser, error) {
var (
record SessionWithUser
sessionExpiresRaw string
sessionLastSeenRaw sql.NullString
sessionInvalidRaw sql.NullString
sessionCreatedRaw string
userRole string
userIsActive int
userCreatedRaw string
userUpdatedRaw string
userLastLoginRaw sql.NullString
)
if err := scanner.Scan(
&record.Session.ID,
&record.Session.UserID,
&record.Session.TokenHash,
&sessionExpiresRaw,
&sessionLastSeenRaw,
&sessionInvalidRaw,
&record.Session.IPAddress,
&record.Session.UserAgent,
&sessionCreatedRaw,
&record.User.ID,
&record.User.Email,
&record.User.PasswordHash,
&userRole,
&userIsActive,
&userCreatedRaw,
&userUpdatedRaw,
&userLastLoginRaw,
); err != nil {
return nil, err
}
sessionExpiresAt, err := parseTimestamp(sessionExpiresRaw)
if err != nil {
return nil, fmt.Errorf("parse joined session expires_at: %w", err)
}
sessionLastSeenAt, err := parseNullableTimestamp(sessionLastSeenRaw)
if err != nil {
return nil, fmt.Errorf("parse joined session last_seen_at: %w", err)
}
sessionInvalidatedAt, err := parseNullableTimestamp(sessionInvalidRaw)
if err != nil {
return nil, fmt.Errorf("parse joined session invalidated_at: %w", err)
}
sessionCreatedAt, err := parseTimestamp(sessionCreatedRaw)
if err != nil {
return nil, fmt.Errorf("parse joined session created_at: %w", err)
}
userCreatedAt, err := parseTimestamp(userCreatedRaw)
if err != nil {
return nil, fmt.Errorf("parse joined user created_at: %w", err)
}
userUpdatedAt, err := parseTimestamp(userUpdatedRaw)
if err != nil {
return nil, fmt.Errorf("parse joined user updated_at: %w", err)
}
userLastLoginAt, err := parseNullableTimestamp(userLastLoginRaw)
if err != nil {
return nil, fmt.Errorf("parse joined user last_login_at: %w", err)
}
record.Session.ExpiresAt = sessionExpiresAt
record.Session.LastSeenAt = sessionLastSeenAt
record.Session.InvalidatedAt = sessionInvalidatedAt
record.Session.CreatedAt = sessionCreatedAt
record.User.Role = UserRole(userRole)
record.User.IsActive = userIsActive == 1
record.User.CreatedAt = userCreatedAt
record.User.UpdatedAt = userUpdatedAt
record.User.LastLoginAt = userLastLoginAt
return &record, nil
}

131
internal/db/store.go Normal file
View file

@ -0,0 +1,131 @@
package db
import (
"context"
"database/sql"
"errors"
"fmt"
)
type querier interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...any) *sql.Row
}
type Store struct {
DB *sql.DB
Users *UserRepository
Projects *ProjectRepository
Tags *TagRepository
Releases *ReleaseRepository
APIKeys *APIKeyRepository
Sessions *SessionRepository
AuditLogs *AuditLogRepository
}
type TxStore struct {
Tx *sql.Tx
Users *UserRepository
Projects *ProjectRepository
Tags *TagRepository
Releases *ReleaseRepository
APIKeys *APIKeyRepository
Sessions *SessionRepository
AuditLogs *AuditLogRepository
}
type UserRepository struct {
q querier
}
type ProjectRepository struct {
q querier
}
type TagRepository struct {
q querier
}
type ReleaseRepository struct {
q querier
}
type APIKeyRepository struct {
q querier
}
type SessionRepository struct {
q querier
}
type AuditLogRepository struct {
q querier
}
func NewStore(database *sql.DB) *Store {
return &Store{
DB: database,
Users: &UserRepository{q: database},
Projects: &ProjectRepository{q: database},
Tags: &TagRepository{q: database},
Releases: &ReleaseRepository{q: database},
APIKeys: &APIKeyRepository{q: database},
Sessions: &SessionRepository{q: database},
AuditLogs: &AuditLogRepository{q: database},
}
}
func (s *Store) Close() error {
if s == nil || s.DB == nil {
return nil
}
return s.DB.Close()
}
func (s *Store) HealthCheck(ctx context.Context) error {
if s == nil || s.DB == nil {
return fmt.Errorf("database store is not initialized")
}
return s.DB.PingContext(ctx)
}
func (s *Store) WithTx(ctx context.Context, fn func(*TxStore) error) error {
if s == nil || s.DB == nil {
return fmt.Errorf("database store is not initialized")
}
tx, err := s.DB.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
if err := fn(newTxStore(tx)); err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
return errors.Join(err, fmt.Errorf("rollback transaction: %w", rollbackErr))
}
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
return nil
}
func newTxStore(tx *sql.Tx) *TxStore {
return &TxStore{
Tx: tx,
Users: &UserRepository{q: tx},
Projects: &ProjectRepository{q: tx},
Tags: &TagRepository{q: tx},
Releases: &ReleaseRepository{q: tx},
APIKeys: &APIKeyRepository{q: tx},
Sessions: &SessionRepository{q: tx},
AuditLogs: &AuditLogRepository{q: tx},
}
}

314
internal/db/tags.go Normal file
View file

@ -0,0 +1,314 @@
package db
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
)
type CreateTagParams struct {
Name string
Slug string
Description string
}
type UpdateTagParams struct {
Name string
Slug string
Description string
}
func (r *TagRepository) List(ctx context.Context) ([]TagListItem, error) {
rows, err := r.q.QueryContext(
ctx,
`SELECT
t.id,
t.name,
t.slug,
t.description,
t.created_at,
t.updated_at,
COUNT(DISTINCT pt.project_id) AS project_count
FROM tags AS t
LEFT JOIN project_tags AS pt ON pt.tag_id = t.id
GROUP BY t.id
ORDER BY t.name COLLATE NOCASE`,
)
if err != nil {
return nil, fmt.Errorf("query tags: %w", err)
}
defer rows.Close()
tags := make([]TagListItem, 0)
for rows.Next() {
item, err := scanTagListItem(rows)
if err != nil {
return nil, fmt.Errorf("scan tag list item: %w", err)
}
tags = append(tags, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate tags: %w", err)
}
return tags, nil
}
func (r *TagRepository) ListAvailableForProject(ctx context.Context, projectID int64) ([]Tag, error) {
rows, err := r.q.QueryContext(
ctx,
`SELECT
t.id,
t.name,
t.slug,
t.description,
t.created_at,
t.updated_at
FROM tags AS t
WHERE NOT EXISTS (
SELECT 1
FROM project_tags AS pt
WHERE pt.project_id = ?
AND pt.tag_id = t.id
)
ORDER BY t.name COLLATE NOCASE`,
projectID,
)
if err != nil {
return nil, fmt.Errorf("query available project tags: %w", err)
}
defer rows.Close()
tags := make([]Tag, 0)
for rows.Next() {
tag, err := scanTag(rows)
if err != nil {
return nil, fmt.Errorf("scan available project tag: %w", err)
}
tags = append(tags, *tag)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate available project tags: %w", err)
}
return tags, nil
}
func (r *TagRepository) GetByID(ctx context.Context, id int64) (*Tag, error) {
tag, err := scanTag(r.q.QueryRowContext(
ctx,
`SELECT id, name, slug, description, created_at, updated_at
FROM tags
WHERE id = ?`,
id,
))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("scan tag by id: %w", err)
}
return tag, nil
}
func (r *TagRepository) Create(ctx context.Context, params CreateTagParams) (*Tag, error) {
result, err := r.q.ExecContext(
ctx,
`INSERT INTO tags (name, slug, description) VALUES (?, ?, ?)`,
strings.TrimSpace(params.Name),
strings.TrimSpace(params.Slug),
strings.TrimSpace(params.Description),
)
if err != nil {
if isUniqueConstraintError(err) {
return nil, errors.Join(ErrConflict, fmt.Errorf("insert tag: %w", err))
}
return nil, fmt.Errorf("insert tag: %w", err)
}
tagID, err := result.LastInsertId()
if err != nil {
return nil, fmt.Errorf("load inserted tag id: %w", err)
}
return r.GetByID(ctx, tagID)
}
func (r *TagRepository) Update(ctx context.Context, tagID int64, params UpdateTagParams) (*Tag, error) {
result, err := r.q.ExecContext(
ctx,
`UPDATE tags
SET name = ?, slug = ?, description = ?
WHERE id = ?`,
strings.TrimSpace(params.Name),
strings.TrimSpace(params.Slug),
strings.TrimSpace(params.Description),
tagID,
)
if err != nil {
if isUniqueConstraintError(err) {
return nil, errors.Join(ErrConflict, fmt.Errorf("update tag: %w", err))
}
return nil, fmt.Errorf("update tag: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return nil, fmt.Errorf("read updated tag rows: %w", err)
}
if rowsAffected == 0 {
return nil, ErrNotFound
}
return r.GetByID(ctx, tagID)
}
func (r *TagRepository) Delete(ctx context.Context, tagID int64) error {
var projectCount int
if err := r.q.QueryRowContext(
ctx,
`SELECT COUNT(1) FROM project_tags WHERE tag_id = ?`,
tagID,
).Scan(&projectCount); err != nil {
return fmt.Errorf("count tag assignments: %w", err)
}
if projectCount > 0 {
return errors.Join(ErrConflict, fmt.Errorf("tag is assigned to %d project(s)", projectCount))
}
result, err := r.q.ExecContext(ctx, `DELETE FROM tags WHERE id = ?`, tagID)
if err != nil {
return fmt.Errorf("delete tag: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("read deleted tag rows: %w", err)
}
if rowsAffected == 0 {
return ErrNotFound
}
return nil
}
func (r *TagRepository) ListProjects(ctx context.Context, tagID int64) ([]Project, error) {
rows, err := r.q.QueryContext(
ctx,
`SELECT
p.id,
p.name,
p.slug,
p.description,
p.is_active,
p.created_at,
p.updated_at
FROM projects AS p
INNER JOIN project_tags AS pt ON pt.project_id = p.id
WHERE pt.tag_id = ?
ORDER BY p.name COLLATE NOCASE`,
tagID,
)
if err != nil {
return nil, fmt.Errorf("query tag projects: %w", err)
}
defer rows.Close()
projects := make([]Project, 0)
for rows.Next() {
project, err := scanProject(rows)
if err != nil {
return nil, fmt.Errorf("scan tag project: %w", err)
}
projects = append(projects, *project)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate tag projects: %w", err)
}
return projects, nil
}
func scanTag(scanner rowScanner) (*Tag, error) {
var (
tag Tag
createdAtRaw string
updatedAtRaw string
)
if err := scanner.Scan(
&tag.ID,
&tag.Name,
&tag.Slug,
&tag.Description,
&createdAtRaw,
&updatedAtRaw,
); err != nil {
return nil, err
}
createdAt, err := parseTimestamp(createdAtRaw)
if err != nil {
return nil, fmt.Errorf("parse tag created_at: %w", err)
}
updatedAt, err := parseTimestamp(updatedAtRaw)
if err != nil {
return nil, fmt.Errorf("parse tag updated_at: %w", err)
}
tag.CreatedAt = createdAt
tag.UpdatedAt = updatedAt
return &tag, nil
}
func scanTagListItem(scanner rowScanner) (*TagListItem, error) {
var (
item TagListItem
createdAtRaw string
updatedAtRaw string
)
if err := scanner.Scan(
&item.Tag.ID,
&item.Tag.Name,
&item.Tag.Slug,
&item.Tag.Description,
&createdAtRaw,
&updatedAtRaw,
&item.ProjectCount,
); err != nil {
return nil, err
}
createdAt, err := parseTimestamp(createdAtRaw)
if err != nil {
return nil, fmt.Errorf("parse tag list created_at: %w", err)
}
updatedAt, err := parseTimestamp(updatedAtRaw)
if err != nil {
return nil, fmt.Errorf("parse tag list updated_at: %w", err)
}
item.Tag.CreatedAt = createdAt
item.Tag.UpdatedAt = updatedAt
return &item, nil
}

38
internal/db/time.go Normal file
View file

@ -0,0 +1,38 @@
package db
import (
"database/sql"
"fmt"
"strings"
"time"
)
type rowScanner interface {
Scan(dest ...any) error
}
func formatTimestamp(value time.Time) string {
return value.UTC().Format(time.RFC3339)
}
func parseTimestamp(raw string) (time.Time, error) {
parsed, err := time.Parse(time.RFC3339, raw)
if err != nil {
return time.Time{}, fmt.Errorf("parse timestamp %q: %w", raw, err)
}
return parsed.UTC(), nil
}
func parseNullableTimestamp(raw sql.NullString) (*time.Time, error) {
if !raw.Valid || strings.TrimSpace(raw.String) == "" {
return nil, nil
}
parsed, err := parseTimestamp(raw.String)
if err != nil {
return nil, err
}
return &parsed, nil
}

165
internal/db/users.go Normal file
View file

@ -0,0 +1,165 @@
package db
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
type CreateUserParams struct {
Email string
PasswordHash string
Role UserRole
IsActive bool
}
func (r *UserRepository) HasActiveAdmin(ctx context.Context) (bool, error) {
var exists int
if err := r.q.QueryRowContext(
ctx,
`SELECT EXISTS(SELECT 1 FROM users WHERE role = ? AND is_active = 1 LIMIT 1)`,
UserRoleAdmin,
).Scan(&exists); err != nil {
return false, fmt.Errorf("query active admin existence: %w", err)
}
return exists == 1, nil
}
func (r *UserRepository) GetByID(ctx context.Context, id int64) (*User, error) {
user, err := scanUser(r.q.QueryRowContext(
ctx,
`SELECT id, email, password_hash, role, is_active, created_at, updated_at, last_login_at
FROM users
WHERE id = ?`,
id,
))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("scan user by id: %w", err)
}
return user, nil
}
func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*User, error) {
user, err := scanUser(r.q.QueryRowContext(
ctx,
`SELECT id, email, password_hash, role, is_active, created_at, updated_at, last_login_at
FROM users
WHERE email = ? COLLATE NOCASE
LIMIT 1`,
strings.TrimSpace(email),
))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("scan user by email: %w", err)
}
return user, nil
}
func (r *UserRepository) Create(ctx context.Context, params CreateUserParams) (*User, error) {
isActive := 0
if params.IsActive {
isActive = 1
}
result, err := r.q.ExecContext(
ctx,
`INSERT INTO users (email, password_hash, role, is_active) VALUES (?, ?, ?, ?)`,
strings.TrimSpace(params.Email),
params.PasswordHash,
params.Role,
isActive,
)
if err != nil {
return nil, fmt.Errorf("insert user: %w", err)
}
userID, err := result.LastInsertId()
if err != nil {
return nil, fmt.Errorf("load inserted user id: %w", err)
}
return r.GetByID(ctx, userID)
}
func (r *UserRepository) UpdateLastLoginAt(ctx context.Context, userID int64, loggedInAt time.Time) error {
result, err := r.q.ExecContext(
ctx,
`UPDATE users SET last_login_at = ? WHERE id = ?`,
formatTimestamp(loggedInAt),
userID,
)
if err != nil {
return fmt.Errorf("update user last_login_at: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("read affected user rows: %w", err)
}
if rowsAffected == 0 {
return ErrNotFound
}
return nil
}
func scanUser(scanner rowScanner) (*User, error) {
var (
user User
role string
isActive int
createdAtRaw string
updatedAtRaw string
lastLoginRaw sql.NullString
)
if err := scanner.Scan(
&user.ID,
&user.Email,
&user.PasswordHash,
&role,
&isActive,
&createdAtRaw,
&updatedAtRaw,
&lastLoginRaw,
); err != nil {
return nil, err
}
createdAt, err := parseTimestamp(createdAtRaw)
if err != nil {
return nil, fmt.Errorf("parse user created_at: %w", err)
}
updatedAt, err := parseTimestamp(updatedAtRaw)
if err != nil {
return nil, fmt.Errorf("parse user updated_at: %w", err)
}
lastLoginAt, err := parseNullableTimestamp(lastLoginRaw)
if err != nil {
return nil, fmt.Errorf("parse user last_login_at: %w", err)
}
user.Role = UserRole(role)
user.IsActive = isActive == 1
user.CreatedAt = createdAt
user.UpdatedAt = updatedAt
user.LastLoginAt = lastLoginAt
return &user, nil
}