feat: complete triage, multi-task lists, kanban, workspace wiki, and admin management
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 16s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 16s
This commit is contained in:
1 parent
5c4e130862
commit
4ef71b61cd
12 files changed
+3940
-664
No files matched your search
+453
-27
@@ -1,11 +1,13 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
@@ -25,12 +27,14 @@ type Server struct {
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server {
|
||||
return &Server{
|
||||
srv := &Server{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
wsHub: wsHub,
|
||||
staticFS: staticFS,
|
||||
}
|
||||
go srv.startDiscordAlarmTicker()
|
||||
return srv
|
||||
}
|
||||
|
||||
func (s *Server) Router() http.Handler {
|
||||
@@ -60,6 +64,7 @@ func (s *Server) Router() http.Handler {
|
||||
r.Use(s.authMiddleware)
|
||||
|
||||
r.Get("/api/v1/auth/me", s.handleMe)
|
||||
r.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings)
|
||||
r.Get("/api/v1/stats", s.handleStats)
|
||||
r.Get("/api/v1/search", s.handleSearch)
|
||||
r.Get("/api/v1/export", s.handleExport)
|
||||
@@ -76,6 +81,12 @@ func (s *Server) Router() http.Handler {
|
||||
r.Patch("/api/v1/triage/{id}", s.handleTriageAction)
|
||||
r.Get("/api/v1/tasks", s.handleGetTasks)
|
||||
|
||||
// Task Lists
|
||||
r.Get("/api/v1/task-lists", s.handleGetTaskLists)
|
||||
r.Post("/api/v1/task-lists", s.handleCreateTaskList)
|
||||
r.Put("/api/v1/task-lists/{id}", s.handleUpdateTaskList)
|
||||
r.Delete("/api/v1/task-lists/{id}", s.handleDeleteTaskList)
|
||||
|
||||
// Workspace Documents
|
||||
r.Get("/api/v1/documents", s.handleGetDocuments)
|
||||
r.Post("/api/v1/documents", s.handleCreateDocument)
|
||||
@@ -97,10 +108,31 @@ func (s *Server) Router() http.Handler {
|
||||
r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem)
|
||||
r.Post("/api/v1/lists/{id}/clear-completed", s.handleClearCompletedItems)
|
||||
|
||||
// Organizations & Teams
|
||||
r.Get("/api/v1/organizations", s.handleGetOrganizations)
|
||||
r.Post("/api/v1/organizations", s.handleCreateOrganization)
|
||||
r.Get("/api/v1/organizations/{id}/teams", s.handleGetOrgTeams)
|
||||
r.Post("/api/v1/organizations/{id}/teams", s.handleCreateOrgTeam)
|
||||
r.Get("/api/v1/teams", s.handleGetUserTeams)
|
||||
r.Post("/api/v1/teams/{id}/members", s.handleAddTeamMember)
|
||||
r.Delete("/api/v1/teams/{id}/members/{userId}", s.handleRemoveTeamMember)
|
||||
|
||||
// API Keys Management
|
||||
r.Get("/api/v1/keys", s.handleGetAPIKeys)
|
||||
r.Post("/api/v1/keys", s.handleCreateAPIKey)
|
||||
r.Delete("/api/v1/keys/{id}", s.handleDeleteAPIKey)
|
||||
|
||||
// Admin Panel Routes
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.adminMiddleware)
|
||||
r.Get("/api/v1/admin/users", s.handleAdminGetUsers)
|
||||
r.Post("/api/v1/admin/users", s.handleAdminCreateUser)
|
||||
r.Patch("/api/v1/admin/users/{id}", s.handleAdminUpdateUser)
|
||||
r.Put("/api/v1/admin/users/{id}/password", s.handleAdminResetPassword)
|
||||
r.Delete("/api/v1/admin/users/{id}", s.handleAdminDeleteUser)
|
||||
r.Get("/api/v1/admin/settings", s.handleAdminGetSettings)
|
||||
r.Put("/api/v1/admin/settings", s.handleAdminUpdateSettings)
|
||||
})
|
||||
})
|
||||
|
||||
// Embedded Frontend SPA Handler
|
||||
@@ -186,6 +218,22 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) adminMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "Nicht autorisiert")
|
||||
return
|
||||
}
|
||||
user, err := s.store.GetUserByID(userID)
|
||||
if err != nil || user == nil || user.Role != "admin" {
|
||||
writeError(w, http.StatusForbidden, "Administrator-Rechte erforderlich")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// --- HANDLERS ---
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -197,7 +245,8 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.cfg.AllowRegistration {
|
||||
sysSettings, _ := s.store.GetSystemSettings()
|
||||
if !s.cfg.AllowRegistration || (sysSettings != nil && !sysSettings.AllowRegistration) {
|
||||
count, _ := s.store.CountUsers()
|
||||
if count > 0 {
|
||||
writeError(w, http.StatusForbidden, "Registrierung ist auf dieser Instanz deaktiviert")
|
||||
@@ -228,7 +277,13 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.store.CreateUser(req.Email, hash, req.DisplayName)
|
||||
count, _ := s.store.CountUsers()
|
||||
role := "member"
|
||||
if count == 0 {
|
||||
role = "admin"
|
||||
}
|
||||
|
||||
user, err := s.store.CreateUser(req.Email, hash, req.DisplayName, role)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusConflict, "E-Mail-Adresse existiert bereits")
|
||||
return
|
||||
@@ -263,6 +318,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusUnauthorized, "E-Mail oder Passwort ungültig")
|
||||
return
|
||||
}
|
||||
if !user.IsActive {
|
||||
writeError(w, http.StatusForbidden, "Dieses Benutzerkonto ist deaktiviert")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
|
||||
if err != nil {
|
||||
@@ -326,10 +385,12 @@ func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) {
|
||||
Priority string `json:"priority"`
|
||||
Source string `json:"source"`
|
||||
DueDate string `json:"dueDate"`
|
||||
DueDate2 string `json:"due_date"` // Compatibility with snake_case README example
|
||||
Target string `json:"target"` // inbox | task | list_item | document
|
||||
DueDate2 string `json:"due_date"`
|
||||
Target string `json:"target"` // inbox | task | list_item | document
|
||||
TargetListID string `json:"targetListId"`
|
||||
TargetList2 string `json:"target_list_id"`
|
||||
TaskListID string `json:"taskListId"`
|
||||
TaskListID2 string `json:"task_list_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
@@ -343,8 +404,12 @@ func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) {
|
||||
if targetListID == "" {
|
||||
targetListID = req.TargetList2
|
||||
}
|
||||
taskListID := req.TaskListID
|
||||
if taskListID == "" {
|
||||
taskListID = req.TaskListID2
|
||||
}
|
||||
|
||||
thought, err := s.store.CreateThought(userID, req.Content, req.Priority, req.Source, dueDate)
|
||||
thought, err := s.store.CreateThoughtWithDetails(userID, req.Content, req.Priority, req.Source, dueDate, taskListID, "todo", "")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
@@ -352,7 +417,7 @@ func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
target := strings.TrimSpace(req.Target)
|
||||
if target == "task" || target == "document" || (target == "list_item" && targetListID != "") {
|
||||
if converted, convErr := s.store.UpdateThoughtTriage(userID, thought.ID, "keep", target, targetListID, dueDate); convErr == nil && converted != nil {
|
||||
if converted, convErr := s.store.UpdateThoughtTriage(userID, thought.ID, "keep", target, targetListID, dueDate, taskListID); convErr == nil && converted != nil {
|
||||
thought = converted
|
||||
if target == "list_item" && targetListID != "" {
|
||||
if lst, lErr := s.store.GetListByID(targetListID); lErr == nil && lst != nil {
|
||||
@@ -396,6 +461,8 @@ func (s *Server) handleTriageAction(w http.ResponseWriter, r *http.Request) {
|
||||
ConvertTo2 string `json:"convert_to"`
|
||||
TargetListID string `json:"targetListId"`
|
||||
TargetList2 string `json:"target_list_id"`
|
||||
TaskListID string `json:"taskListId"`
|
||||
TaskListID2 string `json:"task_list_id"`
|
||||
DueDate string `json:"dueDate"`
|
||||
DueDate2 string `json:"due_date"`
|
||||
}
|
||||
@@ -412,12 +479,16 @@ func (s *Server) handleTriageAction(w http.ResponseWriter, r *http.Request) {
|
||||
if targetListID == "" {
|
||||
targetListID = req.TargetList2
|
||||
}
|
||||
taskListID := req.TaskListID
|
||||
if taskListID == "" {
|
||||
taskListID = req.TaskListID2
|
||||
}
|
||||
dueDate := req.DueDate
|
||||
if dueDate == "" {
|
||||
dueDate = req.DueDate2
|
||||
}
|
||||
|
||||
updated, err := s.store.UpdateThoughtTriage(userID, thoughtID, req.Action, convertTo, targetListID, dueDate)
|
||||
updated, err := s.store.UpdateThoughtTriage(userID, thoughtID, req.Action, convertTo, targetListID, dueDate, taskListID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
@@ -435,7 +506,8 @@ func (s *Server) handleTriageAction(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleGetTasks(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
tasks, err := s.store.GetTasks(userID)
|
||||
taskListID := r.URL.Query().Get("taskListId")
|
||||
tasks, err := s.store.GetTasks(userID, taskListID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -448,17 +520,20 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
||||
thoughtID := chi.URLParam(r, "id")
|
||||
|
||||
var req struct {
|
||||
Content *string `json:"content"`
|
||||
IsCompleted *bool `json:"isCompleted"`
|
||||
DueDate *string `json:"dueDate"`
|
||||
Priority *string `json:"priority"`
|
||||
Content *string `json:"content"`
|
||||
IsCompleted *bool `json:"isCompleted"`
|
||||
DueDate *string `json:"dueDate"`
|
||||
Priority *string `json:"priority"`
|
||||
TaskListID *string `json:"taskListId"`
|
||||
KanbanStatus *string `json:"kanbanStatus"`
|
||||
Notes *string `json:"notes"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority)
|
||||
updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
@@ -501,19 +576,20 @@ func (s *Server) handleGetDocuments(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
var req struct {
|
||||
SpaceID string `json:"spaceId"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
Blocks []domain.DocumentBlock `json:"blocks"`
|
||||
Tags []string `json:"tags"`
|
||||
IsPinned bool `json:"isPinned"`
|
||||
SpaceID string `json:"spaceId"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
FolderPath string `json:"folderPath"`
|
||||
Blocks []domain.DocumentBlock `json:"blocks"`
|
||||
Tags []string `json:"tags"`
|
||||
IsPinned bool `json:"isPinned"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := s.store.CreateDocument(userID, req.SpaceID, req.Title, req.Icon, req.Blocks, req.Tags, req.IsPinned)
|
||||
doc, err := s.store.CreateDocument(userID, req.SpaceID, req.Title, req.Icon, req.FolderPath, req.Blocks, req.Tags, req.IsPinned)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
@@ -536,18 +612,19 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
docID := chi.URLParam(r, "id")
|
||||
var req struct {
|
||||
Title *string `json:"title"`
|
||||
Icon *string `json:"icon"`
|
||||
Blocks *[]domain.DocumentBlock `json:"blocks"`
|
||||
Tags *[]string `json:"tags"`
|
||||
IsPinned *bool `json:"isPinned"`
|
||||
Title *string `json:"title"`
|
||||
Icon *string `json:"icon"`
|
||||
FolderPath *string `json:"folderPath"`
|
||||
Blocks *[]domain.DocumentBlock `json:"blocks"`
|
||||
Tags *[]string `json:"tags"`
|
||||
IsPinned *bool `json:"isPinned"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := s.store.UpdateDocument(userID, docID, req.Title, req.Icon, req.Blocks, req.Tags, req.IsPinned)
|
||||
doc, err := s.store.UpdateDocument(userID, docID, req.Title, req.Icon, req.FolderPath, req.Blocks, req.Tags, req.IsPinned)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
@@ -906,3 +983,352 @@ func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=mindsift-export.json")
|
||||
writeJSON(w, http.StatusOK, exportData)
|
||||
}
|
||||
|
||||
// --- USER SETTINGS ---
|
||||
|
||||
func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
var req struct {
|
||||
DisplayName *string `json:"displayName"`
|
||||
Email *string `json:"email"`
|
||||
Password *string `json:"password"`
|
||||
DiscordWebhookURL *string `json:"discordWebhookUrl"`
|
||||
NotifyBeforeMinutes *int `json:"notifyBeforeMinutes"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
if req.Password != nil && strings.TrimSpace(*req.Password) != "" {
|
||||
hash, err := auth.HashPassword(*req.Password)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Hashen des Passworts")
|
||||
return
|
||||
}
|
||||
_ = s.store.UpdateUserPassword(userID, hash)
|
||||
}
|
||||
u, err := s.store.UpdateUser(userID, req.DisplayName, req.Email, nil, nil, req.DiscordWebhookURL, req.NotifyBeforeMinutes)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
// --- TASK LISTS ---
|
||||
|
||||
func (s *Server) handleGetTaskLists(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
lists, err := s.store.GetTaskLists(userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, lists)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateTaskList(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
var req struct {
|
||||
SpaceID string `json:"spaceId"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
tl, err := s.store.CreateTaskList(userID, req.SpaceID, req.Title, req.Icon, req.Color)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, tl)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateTaskList(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
listID := chi.URLParam(r, "id")
|
||||
var req struct {
|
||||
Title *string `json:"title"`
|
||||
Icon *string `json:"icon"`
|
||||
Color *string `json:"color"`
|
||||
SpaceID *string `json:"spaceId"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.SpaceID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tl)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteTaskList(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
listID := chi.URLParam(r, "id")
|
||||
if err := s.store.DeleteTaskList(userID, listID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
// --- ORGANIZATIONS & TEAMS ---
|
||||
|
||||
func (s *Server) handleGetOrganizations(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
orgs, err := s.store.GetOrganizationsForUser(userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, orgs)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateOrganization(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
org, err := s.store.CreateOrganization(userID, req.Name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, org)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetOrgTeams(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := chi.URLParam(r, "id")
|
||||
teams, err := s.store.GetTeamsForOrg(orgID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, teams)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateOrgTeam(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := chi.URLParam(r, "id")
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
team, err := s.store.CreateTeam(orgID, req.Name, req.Description)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, team)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetUserTeams(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
teams, err := s.store.GetTeamsForUser(userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, teams)
|
||||
}
|
||||
|
||||
func (s *Server) handleAddTeamMember(w http.ResponseWriter, r *http.Request) {
|
||||
teamID := chi.URLParam(r, "id")
|
||||
var req struct {
|
||||
UserID string `json:"userId"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
if err := s.store.AddTeamMember(teamID, req.UserID, req.Role); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"added": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleRemoveTeamMember(w http.ResponseWriter, r *http.Request) {
|
||||
teamID := chi.URLParam(r, "id")
|
||||
memberUserID := chi.URLParam(r, "userId")
|
||||
if err := s.store.RemoveTeamMember(teamID, memberUserID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"removed": true})
|
||||
}
|
||||
|
||||
// --- ADMIN PANEL ---
|
||||
|
||||
func (s *Server) handleAdminGetUsers(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := s.store.GetAllUsers()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, users)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
if req.Email == "" || req.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, "E-Mail und Passwort erforderlich")
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Passwort-Hash fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
u, err := s.store.CreateUser(req.Email, hash, req.DisplayName, req.Role)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, u)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
targetUserID := chi.URLParam(r, "id")
|
||||
var req struct {
|
||||
DisplayName *string `json:"displayName"`
|
||||
Email *string `json:"email"`
|
||||
Role *string `json:"role"`
|
||||
IsActive *bool `json:"isActive"`
|
||||
DiscordWebhookURL *string `json:"discordWebhookUrl"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
u, err := s.store.UpdateUser(targetUserID, req.DisplayName, req.Email, req.Role, req.IsActive, req.DiscordWebhookURL, nil)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
targetUserID := chi.URLParam(r, "id")
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Password) == "" {
|
||||
writeError(w, http.StatusBadRequest, "Neues Passwort erforderlich")
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Passwort-Hash fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
if err := s.store.UpdateUserPassword(targetUserID, hash); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
targetUserID := chi.URLParam(r, "id")
|
||||
currUserID := auth.GetUserID(r.Context())
|
||||
if targetUserID == currUserID {
|
||||
writeError(w, http.StatusBadRequest, "Sie können Ihren eigenen Benutzer nicht löschen")
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteUser(targetUserID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := s.store.GetSystemSettings()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var settings domain.SystemSettings
|
||||
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
if err := s.store.UpdateSystemSettings(&settings); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
// --- DISCORD ALARMS BACKGROUND SCHEDULER ---
|
||||
|
||||
func (s *Server) startDiscordAlarmTicker() {
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
for range ticker.C {
|
||||
alarms, err := s.store.GetDueTasksForAlarm()
|
||||
if err != nil || len(alarms) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
for _, a := range alarms {
|
||||
if a.DueDate <= today && a.DiscordWebhookURL != "" {
|
||||
payload := map[string]interface{}{
|
||||
"content": fmt.Sprintf("⏰ **MindSift Aufgaben-Erinnerung**"),
|
||||
"embeds": []map[string]interface{}{
|
||||
{
|
||||
"title": a.Title,
|
||||
"description": fmt.Sprintf("Diese Aufgabe ist fällig am **%s** (Priorität: %s)", a.DueDate, a.Priority),
|
||||
"color": 3066993,
|
||||
"footer": map[string]string{
|
||||
"text": "MindSift Reminder",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, mErr := json.Marshal(payload)
|
||||
if mErr == nil {
|
||||
resp, pErr := httpClient.Post(a.DiscordWebhookURL, "application/json", bytes.NewReader(body))
|
||||
if pErr == nil {
|
||||
_ = resp.Body.Close()
|
||||
_ = s.store.MarkTaskAlarmSent(a.TaskID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,3 +184,106 @@ func TestStaleTokenFallbackAndQuickDumpToTriageAndDirectTargets(t *testing.T) {
|
||||
t.Fatalf("expected 200 OK for triage action, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFullFeatures(t *testing.T) {
|
||||
srv, _, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
|
||||
// 1. Test TaskLists: Create & Get
|
||||
taskListPayload := map[string]string{
|
||||
"title": "Homelab & Docker",
|
||||
"icon": "🐳",
|
||||
"color": "#3b82f6",
|
||||
}
|
||||
body, _ := json.Marshal(taskListPayload)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/task-lists", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 Created for TaskList, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var createdTaskList domain.TaskList
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &createdTaskList)
|
||||
if createdTaskList.Title != "Homelab & Docker" {
|
||||
t.Fatalf("expected task list title 'Homelab & Docker', got %q", createdTaskList.Title)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/task-lists", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for GET /api/v1/task-lists, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// 2. Test User Settings: Update Discord Webhook & Profile
|
||||
settingsPayload := map[string]interface{}{
|
||||
"displayName": "Sandro D.",
|
||||
"discordWebhookUrl": "https://discord.com/api/webhooks/test/123",
|
||||
"notifyBeforeMinutes": 15,
|
||||
}
|
||||
body, _ = json.Marshal(settingsPayload)
|
||||
req = httptest.NewRequest(http.MethodPatch, "/api/v1/auth/settings", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for PATCH /api/v1/auth/settings, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 3. Test Admin routes (default user is admin)
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/users", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for GET /api/v1/admin/users, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/settings", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for GET /api/v1/admin/settings, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 4. Test Kanban & Task details update
|
||||
taskUpdatePayload := map[string]interface{}{
|
||||
"kanbanStatus": "in_progress",
|
||||
"notes": "Detailnotizen für die Aufgabe",
|
||||
"taskListId": createdTaskList.ID,
|
||||
}
|
||||
body, _ = json.Marshal(taskUpdatePayload)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader([]byte(`{"content":"Kanban Task Test","target":"task"}`)))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
var taskThought domain.Thought
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &taskThought)
|
||||
|
||||
body, _ = json.Marshal(taskUpdatePayload)
|
||||
req = httptest.NewRequest(http.MethodPatch, "/api/v1/thoughts/"+taskThought.ID, bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for PATCH /api/v1/thoughts/{id}, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var updatedThought domain.Thought
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &updatedThought)
|
||||
if updatedThought.KanbanStatus != "in_progress" || updatedThought.Notes != "Detailnotizen für die Aufgabe" {
|
||||
t.Fatalf("expected updated kanbanStatus and notes, got %+v", updatedThought)
|
||||
}
|
||||
}
|
||||
@@ -41,11 +41,15 @@ func ExtractTags(content string) []string {
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"-"`
|
||||
DisplayName string `json:"displayName"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"-"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"` // admin | member
|
||||
IsActive bool `json:"isActive"`
|
||||
DiscordWebhookURL string `json:"discordWebhookUrl"`
|
||||
NotifyBeforeMinutes int `json:"notifyBeforeMinutes"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type ThoughtStatus string
|
||||
@@ -69,13 +73,60 @@ type Thought struct {
|
||||
ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item
|
||||
ConvertedRefID string `json:"convertedRefId,omitempty"` // referenced entity ID
|
||||
DueDate string `json:"dueDate,omitempty"`
|
||||
TaskListID string `json:"taskListId,omitempty"`
|
||||
KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done
|
||||
Notes string `json:"notes,omitempty"`
|
||||
AlarmSent bool `json:"alarmSent,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
TriagedAt string `json:"triagedAt,omitempty"`
|
||||
}
|
||||
|
||||
type TaskList struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
SpaceID string `json:"spaceId,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
Color string `json:"color"`
|
||||
TasksCount int `json:"tasksCount,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Organization struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type Team struct {
|
||||
ID string `json:"id"`
|
||||
OrgID string `json:"orgId"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Members []TeamMember `json:"members,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type TeamMember struct {
|
||||
TeamID string `json:"teamId"`
|
||||
UserID string `json:"userId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"` // owner | editor | viewer
|
||||
JoinedAt string `json:"joinedAt"`
|
||||
}
|
||||
|
||||
type SystemSettings struct {
|
||||
SharingMode string `json:"sharingMode"` // open | teams_only | strict_private
|
||||
AllowRegistration bool `json:"allowRegistration"`
|
||||
InstanceName string `json:"instanceName"`
|
||||
}
|
||||
|
||||
type DocumentBlock struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // h1 | h2 | h3 | paragraph | todo | bullet | code | callout | divider
|
||||
Type string `json:"type"` // h1 | h2 | h3 | paragraph | todo | bullet | code | callout | divider | table | progress
|
||||
Content string `json:"content"`
|
||||
Checked bool `json:"checked,omitempty"`
|
||||
Lang string `json:"lang,omitempty"`
|
||||
@@ -90,6 +141,7 @@ type Document struct {
|
||||
Content string `json:"content"`
|
||||
Blocks []DocumentBlock `json:"blocks"`
|
||||
Tags []string `json:"tags"`
|
||||
FolderPath string `json:"folderPath,omitempty"`
|
||||
IsPinned bool `json:"isPinned"`
|
||||
IsArchived bool `json:"isArchived"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
@@ -180,4 +232,5 @@ type FullExport struct {
|
||||
Documents []Document `json:"documents"`
|
||||
Spaces []Space `json:"spaces"`
|
||||
Lists []SharedList `json:"lists"`
|
||||
TaskLists []TaskList `json:"taskLists,omitempty"`
|
||||
}
|
||||
+742
-94
File diff suppressed because it is too large.
Load diff
@@ -38,7 +38,7 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
}
|
||||
|
||||
// 2. Test Tinder-Style Triage -> Convert to Document
|
||||
triaged, err := store.UpdateThoughtTriage(user.ID, thought.ID, "keep", "document", "", "")
|
||||
triaged, err := store.UpdateThoughtTriage(user.ID, thought.ID, "keep", "document", "", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateThoughtTriage failed: %v", err)
|
||||
}
|
||||
@@ -73,4 +73,39 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
if err != nil || validatedUser.ID != user.ID {
|
||||
t.Fatalf("ValidateAPIKeyAndGetUser failed: %v", err)
|
||||
}
|
||||
|
||||
// 5. Test TaskLists CRUD
|
||||
taskList, err := store.CreateTaskList(user.ID, sp.ID, "Haus & Garten", "🏡", "#10b981")
|
||||
if err != nil || taskList.ID == "" {
|
||||
t.Fatalf("CreateTaskList failed: %v", err)
|
||||
}
|
||||
taskLists, err := store.GetTaskLists(user.ID)
|
||||
if err != nil || len(taskLists) == 0 {
|
||||
t.Fatalf("GetTaskLists failed: %v", err)
|
||||
}
|
||||
|
||||
// 6. Test Organizations & Teams
|
||||
org, err := store.CreateOrganization(user.ID, "Mindsift Inc")
|
||||
if err != nil || org.ID == "" {
|
||||
t.Fatalf("CreateOrganization failed: %v", err)
|
||||
}
|
||||
team, err := store.CreateTeam(org.ID, "DevOps", "Infrastructure team")
|
||||
if err != nil || team.ID == "" {
|
||||
t.Fatalf("CreateTeam failed: %v", err)
|
||||
}
|
||||
err = store.AddTeamMember(team.ID, user.ID, "owner")
|
||||
if err != nil {
|
||||
t.Fatalf("AddTeamMember failed: %v", err)
|
||||
}
|
||||
|
||||
// 7. Test System Settings
|
||||
sysSettings, err := store.GetSystemSettings()
|
||||
if err != nil || sysSettings.InstanceName == "" {
|
||||
t.Fatalf("GetSystemSettings failed: %v", err)
|
||||
}
|
||||
sysSettings.SharingMode = "teams_only"
|
||||
err = store.UpdateSystemSettings(sysSettings)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateSystemSettings failed: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user