feat: Phase 1 - Core API server with authentication
- Added database schema for users, api_keys, sync_state, change_log, and sync_config - Implemented API key generation and validation with bcrypt hashing - Created Chi-based REST API server with endpoints for: - Task CRUD operations (create, read, update, delete) - Task actions (complete, start, stop) - Tag management (list, add, remove) - Projects listing - Health check endpoint - Added middleware for authentication and CORS - Implemented change log tracking with triggers (key:value format) - Added configurable change log retention (default 30 days) - Created server CLI commands (opal server start, opal server keygen) - Dependencies added: golang.org/x/crypto/bcrypt, github.com/go-chi/chi/v5
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.jnss.me/joakim/opal/internal/engine"
|
||||
)
|
||||
|
||||
// GetChangesRequest represents the request for getting changes
|
||||
type GetChangesRequest struct {
|
||||
Since int64 `json:"since"`
|
||||
ClientID string `json:"client_id"`
|
||||
}
|
||||
|
||||
// GetChanges returns tasks that have changed since a given timestamp
|
||||
func GetChanges(w http.ResponseWriter, r *http.Request) {
|
||||
var req GetChangesRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
errorResponse(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
// Query change_log for entries since timestamp
|
||||
db := engine.GetDB()
|
||||
if db == nil {
|
||||
errorResponse(w, http.StatusInternalServerError, "database not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.Query(`
|
||||
SELECT id, task_uuid, change_type, changed_at, data
|
||||
FROM change_log
|
||||
WHERE changed_at > ?
|
||||
ORDER BY id ASC
|
||||
`, req.Since)
|
||||
if err != nil {
|
||||
errorResponse(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type Change struct {
|
||||
ID int64 `json:"id"`
|
||||
TaskUUID string `json:"task_uuid"`
|
||||
ChangeType string `json:"change_type"`
|
||||
ChangedAt int64 `json:"changed_at"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
var changes []Change
|
||||
for rows.Next() {
|
||||
var change Change
|
||||
if err := rows.Scan(&change.ID, &change.TaskUUID, &change.ChangeType, &change.ChangedAt, &change.Data); err != nil {
|
||||
errorResponse(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
changes = append(changes, change)
|
||||
}
|
||||
|
||||
// Update sync_state for client
|
||||
_, _ = db.Exec(`
|
||||
INSERT OR REPLACE INTO sync_state (client_id, last_sync, last_change_id)
|
||||
VALUES (?, ?, ?)
|
||||
`, req.ClientID, engine.GetCurrentTimestamp(), 0)
|
||||
|
||||
jsonResponse(w, http.StatusOK, changes)
|
||||
}
|
||||
|
||||
// PushChangesRequest represents the request for pushing changes
|
||||
type PushChangesRequest struct {
|
||||
Tasks []json.RawMessage `json:"tasks"`
|
||||
ClientID string `json:"client_id"`
|
||||
}
|
||||
|
||||
// PushChanges accepts task changes from clients
|
||||
func PushChanges(w http.ResponseWriter, r *http.Request) {
|
||||
var req PushChangesRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
errorResponse(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
db := engine.GetDB()
|
||||
if db == nil {
|
||||
errorResponse(w, http.StatusInternalServerError, "database not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
// For each task, parse and apply (last-write-wins for now)
|
||||
var processed int
|
||||
var conflicts int
|
||||
|
||||
for _, taskData := range req.Tasks {
|
||||
var task engine.Task
|
||||
if err := json.Unmarshal(taskData, &task); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if task exists
|
||||
existing, err := engine.GetTask(task.UUID)
|
||||
if err != nil {
|
||||
// Task doesn't exist - create it
|
||||
if err := task.Save(); err != nil {
|
||||
continue
|
||||
}
|
||||
// Add tags
|
||||
for _, tag := range task.Tags {
|
||||
_ = task.AddTag(tag)
|
||||
}
|
||||
processed++
|
||||
continue
|
||||
}
|
||||
|
||||
// Task exists - check timestamps for conflicts
|
||||
if existing.Modified.Unix() > task.Modified.Unix() {
|
||||
// Server version is newer - conflict (but we'll apply last-write-wins)
|
||||
conflicts++
|
||||
}
|
||||
|
||||
// Apply changes (last-write-wins)
|
||||
task.ID = existing.ID // Preserve database ID
|
||||
if err := task.Save(); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Sync tags
|
||||
existingTags := make(map[string]bool)
|
||||
for _, tag := range existing.Tags {
|
||||
existingTags[tag] = true
|
||||
}
|
||||
|
||||
// Add new tags
|
||||
for _, tag := range task.Tags {
|
||||
if !existingTags[tag] {
|
||||
_ = task.AddTag(tag)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove missing tags
|
||||
for _, tag := range existing.Tags {
|
||||
found := false
|
||||
for _, newTag := range task.Tags {
|
||||
if tag == newTag {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
_ = task.RemoveTag(tag)
|
||||
}
|
||||
}
|
||||
|
||||
processed++
|
||||
}
|
||||
|
||||
// Update sync_state
|
||||
_, _ = db.Exec(`
|
||||
INSERT OR REPLACE INTO sync_state (client_id, last_sync, last_change_id)
|
||||
VALUES (?, ?, ?)
|
||||
`, req.ClientID, engine.GetCurrentTimestamp(), 0)
|
||||
|
||||
jsonResponse(w, http.StatusOK, map[string]interface{}{
|
||||
"processed": processed,
|
||||
"conflicts": conflicts,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user