summaryrefslogtreecommitdiff
path: root/internal/worktime/integrity.go
blob: 2676bf3bcd529ef0a60e5b1f1cb7265afb0cad1f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
package worktime

import (
	"errors"
	"fmt"
	"slices"
	"strings"
	"time"
)

// DefaultMaxSessionSpan is the standard maximum allowed login/logout span.
const DefaultMaxSessionSpan = 15 * time.Hour

// IntegrityIssue represents a database consistency violation.
type IntegrityIssue struct {
	Kind     string
	Category string
	Entry    Entry
	Message  string
}

// OpenSession represents a category that is currently logged in.
type OpenSession struct {
	Category string
	Login    Entry
}

// String returns a human-readable description of the issue.
func (i IntegrityIssue) String() string {
	when := time.Unix(i.Entry.Epoch, 0).Format("2006-01-02 15:04:05")
	return fmt.Sprintf("[%s] category=%s source=%s at=%s: %s", i.Kind, i.Category, i.Entry.Source, when, i.Message)
}

// CheckIntegrity loads all databases and validates login/logout consistency.
func CheckIntegrity(dbDir string, maxSessionSpan time.Duration) ([]IntegrityIssue, error) {
	if maxSessionSpan <= 0 {
		return nil, errors.New("max session span must be positive")
	}

	entries, err := LoadAll(dbDir)
	if err != nil {
		return nil, err
	}

	return CheckEntriesIntegrity(entries, maxSessionSpan), nil
}

// CheckEntriesIntegrity validates entry consistency for login/logout flows.
func CheckEntriesIntegrity(entries []Entry, maxSessionSpan time.Duration) []IntegrityIssue {
	activeByCategory := map[string]Entry{}
	issues := make([]IntegrityIssue, 0)

	for _, entry := range entries {
		category := normalizeCategory(entry.What)
		action := strings.ToLower(strings.TrimSpace(entry.Action))

		switch action {
		case actionLogin:
			if previous, active := activeByCategory[category]; active {
				issues = append(issues, IntegrityIssue{
					Kind:     "double-login",
					Category: category,
					Entry:    entry,
					Message: fmt.Sprintf(
						"login while already logged in (previous login at %s from %s)",
						time.Unix(previous.Epoch, 0).Format("2006-01-02 15:04:05"),
						previous.Source,
					),
				})
				continue
			}

			activeByCategory[category] = entry

		case actionLogout:
			login, active := activeByCategory[category]
			if !active {
				issues = append(issues, IntegrityIssue{
					Kind:     "logout-without-login",
					Category: category,
					Entry:    entry,
					Message:  "logout without a matching login",
				})
				continue
			}

			span := time.Duration(entry.Epoch-login.Epoch) * time.Second
			if span <= 0 {
				issues = append(issues, IntegrityIssue{
					Kind:     "non-positive-session",
					Category: category,
					Entry:    entry,
					Message: fmt.Sprintf(
						"logout timestamp is not after login (login at %s)",
						time.Unix(login.Epoch, 0).Format("2006-01-02 15:04:05"),
					),
				})
			}
			if span > maxSessionSpan {
				issues = append(issues, IntegrityIssue{
					Kind:     "session-too-long",
					Category: category,
					Entry:    entry,
					Message:  fmt.Sprintf("session spans %.2fh (max %.2fh)", span.Hours(), maxSessionSpan.Hours()),
				})
			}

			delete(activeByCategory, category)
		}
	}

	return issues
}

// OpenSessions returns categories with an active login that has no corresponding logout yet.
func OpenSessions(entries []Entry) []OpenSession {
	activeByCategory := map[string]Entry{}

	for _, entry := range entries {
		category := normalizeCategory(entry.What)
		action := strings.ToLower(strings.TrimSpace(entry.Action))
		switch action {
		case actionLogin:
			activeByCategory[category] = entry
		case actionLogout:
			delete(activeByCategory, category)
		}
	}

	categories := make([]string, 0, len(activeByCategory))
	for category := range activeByCategory {
		categories = append(categories, category)
	}
	slices.Sort(categories)

	sessions := make([]OpenSession, 0, len(categories))
	for _, category := range categories {
		sessions = append(sessions, OpenSession{
			Category: category,
			Login:    activeByCategory[category],
		})
	}
	return sessions
}