Skip to content

Commit 89fdbd3

Browse files
jmartin82claude
andcommitted
feat: add scheduled changes with calendar view
Pre-register planned changes before execution. Scheduled changes appear in the change log (with a status filter) and on a new /calendar page showing upcoming changes by month. Past-due scheduled changes are surfaced as "overdue" and require explicit confirmation to close out. Schema: split created_at (insert time) from event_at (semantic when), add status ENUM('executed','scheduled') with idempotent migration. Backend: - ConfirmChange handler (PATCH /api/changes/:id/confirm), 409 on replay - status= filter including virtual overdue (status=scheduled AND event_at < NOW()) - MCP confirm_change tool; register_change and list_changes updated for status Frontend: - Mode toggle (Already happened / Schedule for later) on AddChange and EditChangeDialog - Scheduled/Overdue badges and Mark as Done on ChangeItem - Status filter select in FilterBar - Calendar page with overdue section, month grid, day detail panel Tests: 67 frontend tests, 136 backend unit tests, all passing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 5174ebd commit 89fdbd3

23 files changed

Lines changed: 1821 additions & 210 deletions

README.md

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,11 @@ OpsLedger provides a single source of truth for all changes made to your infrast
2222

2323
### Change Management
2424
- **Register Changes** - Log infrastructure, deployment, and configuration changes with rich metadata including system, environment, change type, and detailed descriptions
25-
- **Real-time Filtering** - Filter changes by system, environment, user, change type, and time range
25+
- **Scheduled Changes** - Pre-register planned changes before they happen. Scheduled changes appear in the change log and on a dedicated calendar view. Past-due scheduled changes are flagged as **overdue** and require explicit confirmation to close out
26+
- **Confirm Changes** - Mark a scheduled change as executed (via the change log, calendar, or API), optionally overriding the execution timestamp
27+
- **Real-time Filtering** - Filter changes by system, environment, user, change type, status (executed / scheduled / overdue), and time range
2628
- **Search & Autocomplete** - Quickly find changes with full-text search and intelligent autocomplete for systems, environments, and users
29+
- **Calendar View** - Visual month grid at `/calendar` showing upcoming scheduled changes with overdue highlighting and day-level detail
2730

2831
### Authentication & Authorization
2932
- **Role-Based Access Control** - Three roles with distinct permissions:
@@ -66,7 +69,7 @@ Teams can see what changes others are making, reducing duplicate work and improv
6669

6770
### Agent vs Human Change Tracking
6871
Track changes made by different sources to understand automation impact:
69-
- **MCP Agents** - Log changes from AI agents and automation tools (e.g., Claude Code, custom MCP servers)
72+
- **MCP Agents** - Log changes from AI agents and automation tools (e.g., Claude Code, custom MCP servers). Available MCP tools: `register_change`, `confirm_change`, `update_change`, `list_changes`, `delete_change`
7073
- **REST API** - Track changes made via direct API calls from external systems
7174
- **UI Changes** - Record changes made by humans through the web interface
7275

@@ -152,13 +155,39 @@ VITE_API_URL=http://localhost:8081/api
152155
|--------|----------|-------------|
153156
| GET | `/api/changes` | List changes (supports filtering) |
154157
| POST | `/api/changes` | Create a new change |
158+
| PUT | `/api/changes/:id` | Update an existing change |
159+
| DELETE | `/api/changes/:id` | Delete a change |
160+
| PATCH | `/api/changes/:id/confirm` | Confirm a scheduled change as executed |
155161

156162
**Supported Filters:**
157163
- `system` - Filter by system name
158164
- `environment` - Filter by environment (prod, staging, dev, etc.)
159-
- `type` - Filter by change type (infrastructure, deployment, configuration)
160-
- `user_id` - Filter by user
161-
- `from` / `to` - Date range filtering
165+
- `type` - Filter by change type (`infrastructure`, `deployment`, `configuration`)
166+
- `status` - Filter by status (`executed`, `scheduled`, `overdue`)
167+
- `user` - Filter by user
168+
- `from` / `to` - Date range filtering (ISO 8601)
169+
- `search` - Full-text search across description and system
170+
171+
**Creating a scheduled change:**
172+
```json
173+
POST /api/changes
174+
{
175+
"system": "api-gateway",
176+
"type": "deployment",
177+
"description": "Upgrade to v3.0",
178+
"status": "scheduled",
179+
"timestamp": "2026-06-15T10:00:00Z"
180+
}
181+
```
182+
When `status` is `"scheduled"`, `timestamp` is required and must be in the future.
183+
When `status` is `"executed"` (default), `timestamp` is optional and defaults to now.
184+
185+
**Confirming a scheduled change:**
186+
```json
187+
PATCH /api/changes/:id/confirm
188+
{ "timestamp": "2026-06-15T10:05:00Z" } // optional — defaults to now
189+
```
190+
Returns `409 Conflict` if the change is already executed.
162191

163192
### Admin API Keys
164193

backend/database/migrations.go

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
11
package database
22

3-
import "database/sql"
3+
import (
4+
"database/sql"
5+
"strings"
6+
)
7+
8+
// isMySQLDupErr returns true for MySQL "duplicate column" (1060) or "duplicate key name" (1061).
9+
func isMySQLDupErr(err error) bool {
10+
if err == nil {
11+
return false
12+
}
13+
s := err.Error()
14+
return strings.Contains(s, "Error 1060") || strings.Contains(s, "Duplicate column") ||
15+
strings.Contains(s, "Error 1061") || strings.Contains(s, "Duplicate key name")
16+
}
417

518
func Migrate(db *sql.DB) error {
619
_, err := db.Exec(`
@@ -49,16 +62,44 @@ func Migrate(db *sql.DB) error {
4962
user VARCHAR(255) NULL,
5063
type ENUM('infrastructure', 'deployment', 'configuration') NOT NULL,
5164
description TEXT NOT NULL,
65+
status ENUM('executed', 'scheduled') NOT NULL DEFAULT 'executed',
66+
event_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
5267
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
68+
INDEX idx_changes_event_at (event_at DESC),
5369
INDEX idx_changes_created_at (created_at DESC),
5470
INDEX idx_changes_system (system),
55-
INDEX idx_changes_type (type)
71+
INDEX idx_changes_type (type),
72+
INDEX idx_changes_status (status)
5673
)
5774
`)
5875
if err != nil {
5976
return err
6077
}
6178

79+
// Idempotent schema upgrades for existing deployments.
80+
migrations := []string{
81+
`ALTER TABLE changes ADD COLUMN status ENUM('executed','scheduled') NULL`,
82+
`ALTER TABLE changes ADD COLUMN event_at DATETIME NULL`,
83+
`UPDATE changes SET event_at = created_at WHERE event_at IS NULL`,
84+
`UPDATE changes SET status = 'executed' WHERE status IS NULL`,
85+
`ALTER TABLE changes MODIFY COLUMN status ENUM('executed','scheduled') NOT NULL DEFAULT 'executed'`,
86+
`ALTER TABLE changes MODIFY COLUMN event_at DATETIME NOT NULL`,
87+
`ALTER TABLE changes ADD INDEX idx_changes_event_at (event_at DESC)`,
88+
`ALTER TABLE changes ADD INDEX idx_changes_status (status)`,
89+
}
90+
for _, m := range migrations {
91+
if _, merr := db.Exec(m); merr != nil && !isMySQLDupErr(merr) {
92+
// UPDATE and MODIFY statements won't trigger dup errors; only ADD COLUMN/INDEX can.
93+
// Re-check: skip dup errors only for structural DDL, propagate data errors.
94+
if strings.HasPrefix(m, "UPDATE ") || strings.HasPrefix(m, "ALTER TABLE changes MODIFY") {
95+
return merr
96+
}
97+
if !isMySQLDupErr(merr) {
98+
return merr
99+
}
100+
}
101+
}
102+
62103
_, err = db.Exec(`
63104
CREATE TABLE IF NOT EXISTS audit_log (
64105
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,

backend/handlers/changes.go

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,12 @@ type createChangeRequest struct {
2424
User *string `json:"user"`
2525
Type string `json:"type"`
2626
Description string `json:"description"`
27-
Timestamp *string `json:"timestamp"`
27+
Status string `json:"status"` // "executed" (default) | "scheduled"
28+
Timestamp *string `json:"timestamp"` // event_at: when it happened or when it's planned
29+
}
30+
31+
type confirmChangeRequest struct {
32+
Timestamp *string `json:"timestamp"` // actual execution time (optional)
2833
}
2934

3035
type listChangesResponse struct {
@@ -53,7 +58,13 @@ func (h *ChangeHandler) Create(c echo.Context) error {
5358
return c.JSON(http.StatusBadRequest, map[string]string{"error": "type must be infrastructure, deployment, or configuration"})
5459
}
5560

56-
// Normalize empty strings to nil for nullable fields
61+
if req.Status == "" {
62+
req.Status = "executed"
63+
}
64+
if req.Status != "executed" && req.Status != "scheduled" {
65+
return c.JSON(http.StatusBadRequest, map[string]string{"error": "status must be executed or scheduled"})
66+
}
67+
5768
env := req.Environment
5869
if env != nil && *env == "" {
5970
env = nil
@@ -72,7 +83,16 @@ func (h *ChangeHandler) Create(c echo.Context) error {
7283
ts = &parsed
7384
}
7485

75-
change, err := models.CreateChange(h.DB, req.System, env, user, req.Type, req.Description, ts)
86+
if req.Status == "scheduled" {
87+
if ts == nil {
88+
return c.JSON(http.StatusBadRequest, map[string]string{"error": "timestamp is required for scheduled changes"})
89+
}
90+
if !ts.After(time.Now()) {
91+
return c.JSON(http.StatusBadRequest, map[string]string{"error": "scheduled timestamp must be in the future"})
92+
}
93+
}
94+
95+
change, err := models.CreateChange(h.DB, req.System, env, user, req.Type, req.Description, req.Status, ts)
7696
if err != nil {
7797
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create change"})
7898
}
@@ -108,6 +128,13 @@ func (h *ChangeHandler) Update(c echo.Context) error {
108128
return c.JSON(http.StatusBadRequest, map[string]string{"error": "type must be infrastructure, deployment, or configuration"})
109129
}
110130

131+
if req.Status == "" {
132+
req.Status = "executed"
133+
}
134+
if req.Status != "executed" && req.Status != "scheduled" {
135+
return c.JSON(http.StatusBadRequest, map[string]string{"error": "status must be executed or scheduled"})
136+
}
137+
111138
env := req.Environment
112139
if env != nil && *env == "" {
113140
env = nil
@@ -126,7 +153,11 @@ func (h *ChangeHandler) Update(c echo.Context) error {
126153
ts = &parsed
127154
}
128155

129-
change, err := models.UpdateChange(h.DB, id, req.System, env, user, req.Type, req.Description, ts)
156+
if req.Status == "scheduled" && ts == nil {
157+
return c.JSON(http.StatusBadRequest, map[string]string{"error": "timestamp is required for scheduled changes"})
158+
}
159+
160+
change, err := models.UpdateChange(h.DB, id, req.System, env, user, req.Type, req.Description, req.Status, ts)
130161
if err != nil {
131162
if err == sql.ErrNoRows {
132163
return c.JSON(http.StatusNotFound, map[string]string{"error": "Change not found"})
@@ -141,6 +172,48 @@ func (h *ChangeHandler) Update(c echo.Context) error {
141172
return c.JSON(http.StatusOK, change)
142173
}
143174

175+
func (h *ChangeHandler) Confirm(c echo.Context) error {
176+
if err := h.requireWriteAccess(c); err != nil {
177+
return err
178+
}
179+
180+
id := c.Param("id")
181+
if _, err := uuid.Parse(id); err != nil {
182+
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid change ID"})
183+
}
184+
185+
var req confirmChangeRequest
186+
if err := c.Bind(&req); err != nil {
187+
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
188+
}
189+
190+
var executedAt *time.Time
191+
if req.Timestamp != nil && *req.Timestamp != "" {
192+
parsed, err := time.Parse(time.RFC3339, *req.Timestamp)
193+
if err != nil {
194+
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid timestamp format, use RFC3339"})
195+
}
196+
executedAt = &parsed
197+
}
198+
199+
change, err := models.ConfirmChange(h.DB, id, executedAt)
200+
if err != nil {
201+
if err == sql.ErrNoRows {
202+
return c.JSON(http.StatusNotFound, map[string]string{"error": "Change not found"})
203+
}
204+
if err == models.ErrAlreadyExecuted {
205+
return c.JSON(http.StatusConflict, map[string]string{"error": "Change is already marked as executed"})
206+
}
207+
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to confirm change"})
208+
}
209+
210+
auditLog(h.DB, c, "change.confirm", "change", nil, strPtr(change.ID), strPtr(change.System+": "+change.Description))
211+
if h.Hub != nil {
212+
h.Hub.Publish(SSEEvent{Type: "change.updated", Data: change})
213+
}
214+
return c.JSON(http.StatusOK, change)
215+
}
216+
144217
func (h *ChangeHandler) Delete(c echo.Context) error {
145218
if err := h.requireWriteAccess(c); err != nil {
146219
return err
@@ -151,7 +224,6 @@ func (h *ChangeHandler) Delete(c echo.Context) error {
151224
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid change ID"})
152225
}
153226

154-
// Fetch change first for audit log details
155227
change, err := models.GetChangeByID(h.DB, id)
156228
if err != nil {
157229
if err == sql.ErrNoRows {
@@ -181,9 +253,14 @@ func (h *ChangeHandler) List(c echo.Context) error {
181253
Environment: c.QueryParam("environment"),
182254
User: c.QueryParam("user"),
183255
Type: c.QueryParam("type"),
256+
Status: c.QueryParam("status"),
184257
Search: c.QueryParam("search"),
185258
}
186259

260+
if c.QueryParam("sort") == "asc" {
261+
f.SortAsc = true
262+
}
263+
187264
if v := c.QueryParam("limit"); v != "" {
188265
if n, err := strconv.Atoi(v); err == nil {
189266
f.Limit = n
@@ -195,7 +272,6 @@ func (h *ChangeHandler) List(c echo.Context) error {
195272
}
196273
}
197274

198-
// Time range: explicit from/to take precedence over shorthand
199275
if v := c.QueryParam("from"); v != "" {
200276
if t, err := time.Parse(time.RFC3339, v); err == nil {
201277
f.From = &t
@@ -273,6 +349,5 @@ func (h *ChangeHandler) requireReadAccess(c echo.Context) error {
273349
return c.JSON(http.StatusForbidden, map[string]string{"error": "API key missing changes:read scope"})
274350
}
275351

276-
// JWT: any authenticated user can read
277352
return nil
278353
}

0 commit comments

Comments
 (0)