feat: organizations decide who sees whom; sharing only by owners
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m30s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m30s
- Server admins manage organizations (one per user); new admin section, organization on user creation and invites, setup creates the first one - Sharing modes private / organizations / open, enforced centrally (CanSee, CanShareWith) for directory, all shares and groups; unknown and invisible targets give the same error - Losing visibility (org change, mode change, leaving a group) prunes shares, group memberships and task assignments; dry run warns before tightening - Only owners share; editors can no longer reshare lists or remove others' shares; legacy task_lists.team_id and old /organizations endpoints removed (FLAW-06, FLAW-10, FLAW-24) - Schema version: outdated databases are wiped and recreated on start - Fix setup sample data (createSampleData was ignored); setup and admin use a shared ToggleSwitch instead of checkboxes - Docs: sharing-and-permissions rewritten, ui-patterns, REST, DB, flaws Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
73fad1c5db
commit
84395f3322
26 files changed
+1421
-609
No files matched your search
+100
-69
@@ -191,12 +191,7 @@ func (s *Server) Router() http.Handler {
|
||||
r.Put("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
|
||||
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.Delete("/api/v1/organizations/{id}", s.handleDeleteOrganization)
|
||||
r.Get("/api/v1/organizations/{id}/teams", s.handleGetOrgTeams)
|
||||
r.Post("/api/v1/organizations/{id}/teams", s.handleCreateOrgTeam)
|
||||
// Groups (user-managed; stored as teams)
|
||||
r.Get("/api/v1/teams", s.handleGetUserTeams)
|
||||
r.Post("/api/v1/teams", s.handleCreateTeamDirect)
|
||||
r.Put("/api/v1/teams/{id}", s.handleUpdateTeam)
|
||||
@@ -210,7 +205,7 @@ func (s *Server) Router() http.Handler {
|
||||
r.Post("/api/v1/keys", s.handleCreateAPIKey)
|
||||
r.Delete("/api/v1/keys/{id}", s.handleDeleteAPIKey)
|
||||
|
||||
// Staff Routes (Server-Admin & Team-Admin)
|
||||
// Staff Routes (Server-Admin)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.staffMiddleware)
|
||||
r.Get("/api/v1/admin/users", s.handleAdminGetUsers)
|
||||
@@ -229,6 +224,14 @@ func (s *Server) Router() http.Handler {
|
||||
r.Use(s.adminMiddleware)
|
||||
r.Get("/api/v1/admin/settings", s.handleAdminGetSettings)
|
||||
r.Put("/api/v1/admin/settings", s.handleAdminUpdateSettings)
|
||||
r.Get("/api/v1/admin/settings/sharing-impact", s.handleAdminSharingImpact)
|
||||
|
||||
// Organizations decide who can see whom; only server admins manage them
|
||||
r.Get("/api/v1/admin/organizations", s.handleAdminGetOrganizations)
|
||||
r.Post("/api/v1/admin/organizations", s.handleAdminCreateOrganization)
|
||||
r.Put("/api/v1/admin/organizations/{id}", s.handleAdminRenameOrganization)
|
||||
r.Delete("/api/v1/admin/organizations/{id}", s.handleAdminDeleteOrganization)
|
||||
r.Put("/api/v1/admin/users/{id}/organization", s.handleAdminSetUserOrganization)
|
||||
r.Post("/api/v1/admin/smtp/test", s.handleAdminTestSMTP)
|
||||
})
|
||||
})
|
||||
@@ -453,6 +456,7 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
"allowRegistration": allowReg,
|
||||
"instanceName": instanceName,
|
||||
"demoMode": s.cfg.DemoMode,
|
||||
"sharingMode": s.store.SharingMode(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -470,7 +474,7 @@ func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
|
||||
InstanceName string `json:"instanceName"`
|
||||
AllowRegistration bool `json:"allowRegistration"`
|
||||
SharingMode string `json:"sharingMode"`
|
||||
SeedSampleData bool `json:"seedSampleData"`
|
||||
CreateSampleData bool `json:"createSampleData"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||||
@@ -502,13 +506,18 @@ func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
|
||||
sys.InstanceName = strings.TrimSpace(req.InstanceName)
|
||||
}
|
||||
sys.AllowRegistration = req.AllowRegistration
|
||||
if req.SharingMode == "open" || req.SharingMode == "teams_only" || req.SharingMode == "strict_private" {
|
||||
sys.SharingMode = req.SharingMode
|
||||
}
|
||||
sys.SharingMode = storage.NormalizeSharingMode(req.SharingMode)
|
||||
_ = s.store.UpdateSystemSettings(sys)
|
||||
// In organization mode the first admin starts in an organization named after the instance
|
||||
if sys.SharingMode == storage.SharingOrganizations {
|
||||
if org, err := s.store.CreateOrganization(sys.InstanceName); err == nil {
|
||||
_ = s.store.SetUserOrganization(user.ID, org.ID)
|
||||
user.OrganizationID = org.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if req.SeedSampleData {
|
||||
if req.CreateSampleData {
|
||||
_ = s.store.SeedStarterContent(user)
|
||||
}
|
||||
|
||||
@@ -583,8 +592,9 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if validInvite != nil {
|
||||
_ = s.store.MarkUserInviteUsed(validInvite.ID, user.ID)
|
||||
if validInvite.TeamID != "" {
|
||||
_ = s.store.AddTeamMember(validInvite.TeamID, user.ID, "member")
|
||||
if validInvite.OrganizationID != "" {
|
||||
_ = s.store.SetUserOrganization(user.ID, validInvite.OrganizationID)
|
||||
user.OrganizationID = validInvite.OrganizationID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2244,7 +2254,6 @@ func (s *Server) handleUpdateTaskList(w http.ResponseWriter, r *http.Request) {
|
||||
Title *string `json:"title"`
|
||||
Icon *string `json:"icon"`
|
||||
Color *string `json:"color"`
|
||||
TeamID *string `json:"teamId"`
|
||||
SpaceID *string `json:"spaceId"`
|
||||
Columns *[]domain.KanbanColumn `json:"columns"`
|
||||
}
|
||||
@@ -2253,7 +2262,7 @@ func (s *Server) handleUpdateTaskList(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
audienceBefore := s.store.GetTaskListUserIDs(listID)
|
||||
tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.TeamID, req.SpaceID, req.Columns)
|
||||
tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.SpaceID, req.Columns)
|
||||
if err != nil {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
@@ -2354,9 +2363,8 @@ func (s *Server) broadcastTaskListChange(listID string, audienceBefore []string,
|
||||
|
||||
// --- ORGANIZATIONS & TEAMS ---
|
||||
|
||||
func (s *Server) handleGetOrganizations(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
orgs, err := s.store.GetOrganizationsForUser(userID)
|
||||
func (s *Server) handleAdminGetOrganizations(w http.ResponseWriter, r *http.Request) {
|
||||
orgs, err := s.store.GetOrganizations()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -2364,8 +2372,7 @@ func (s *Server) handleGetOrganizations(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, orgs)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateOrganization(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
func (s *Server) handleAdminCreateOrganization(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
@@ -2373,7 +2380,7 @@ func (s *Server) handleCreateOrganization(w http.ResponseWriter, r *http.Request
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
org, err := s.store.CreateOrganization(userID, req.Name)
|
||||
org, err := s.store.CreateOrganization(req.Name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
@@ -2381,45 +2388,52 @@ func (s *Server) handleCreateOrganization(w http.ResponseWriter, r *http.Request
|
||||
writeJSON(w, http.StatusCreated, org)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteOrganization(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := chi.URLParam(r, "id")
|
||||
if err := s.store.DeleteOrganization(orgID); err != nil {
|
||||
func (s *Server) handleAdminRenameOrganization(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
if err := s.store.RenameOrganization(chi.URLParam(r, "id"), req.Name); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"updated": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminDeleteOrganization(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.store.DeleteOrganization(chi.URLParam(r, "id")); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
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) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
orgID := chi.URLParam(r, "id")
|
||||
func (s *Server) handleAdminSetUserOrganization(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
OrganizationID string `json:"organizationId"`
|
||||
}
|
||||
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 {
|
||||
if err := s.store.SetUserOrganization(chi.URLParam(r, "id"), req.OrganizationID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
// Automatically add creator to team
|
||||
_ = s.store.AddTeamMember(team.ID, userID, "admin")
|
||||
team.Members, _ = s.store.GetTeamMembers(team.ID)
|
||||
writeJSON(w, http.StatusCreated, team)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"updated": true})
|
||||
}
|
||||
|
||||
// handleAdminSharingImpact counts the shares and group memberships a switch to ?mode= would remove.
|
||||
func (s *Server) handleAdminSharingImpact(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.store.PruneSharing(r.URL.Query().Get("mode"), true)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int64{"affected": n})
|
||||
}
|
||||
|
||||
func (s *Server) isTeamAdmin(userID, teamID string) bool {
|
||||
@@ -2438,20 +2452,25 @@ func (s *Server) handleCreateTeamDirect(w http.ResponseWriter, r *http.Request)
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
OrgID string `json:"orgId"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
writeError(w, http.StatusBadRequest, "Name für das Team / die Gruppe ist erforderlich")
|
||||
if s.store.SharingMode() == storage.SharingPrivate {
|
||||
writeError(w, http.StatusForbidden, storage.ErrSharingDisabled.Error())
|
||||
return
|
||||
}
|
||||
orgID := strings.TrimSpace(req.OrgID)
|
||||
if orgID == "" {
|
||||
orgID = "default"
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
writeError(w, http.StatusBadRequest, "Name für die Gruppe ist erforderlich")
|
||||
return
|
||||
}
|
||||
// A group belongs to its creator's organization; members can only come from there
|
||||
creator, _ := s.store.GetUserByID(userID)
|
||||
orgID := ""
|
||||
if creator != nil {
|
||||
orgID = creator.OrganizationID
|
||||
}
|
||||
team, err := s.store.CreateTeam(orgID, name, req.Description)
|
||||
if err != nil {
|
||||
@@ -2534,17 +2553,19 @@ func (s *Server) handleAddTeamMember(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
targetUserID := strings.TrimSpace(req.UserID)
|
||||
if targetUserID == "" && strings.TrimSpace(req.Email) != "" {
|
||||
u, err := s.store.GetUserByEmail(req.Email)
|
||||
if err != nil || u == nil {
|
||||
writeError(w, http.StatusNotFound, "Kein Benutzer mit dieser E-Mail gefunden")
|
||||
return
|
||||
if u, err := s.store.GetUserByEmail(req.Email); err == nil && u != nil {
|
||||
targetUserID = u.ID
|
||||
}
|
||||
targetUserID = u.ID
|
||||
}
|
||||
if targetUserID == "" {
|
||||
if targetUserID == "" && strings.TrimSpace(req.Email) == "" {
|
||||
writeError(w, http.StatusBadRequest, "Benutzer-ID oder E-Mail erforderlich")
|
||||
return
|
||||
}
|
||||
// Only people the group admin can see; unknown and invisible look the same
|
||||
if targetUserID == "" || !s.store.CanShareWith(userID, "user", targetUserID) {
|
||||
writeError(w, http.StatusNotFound, storage.ErrTargetNotFound.Error())
|
||||
return
|
||||
}
|
||||
role := strings.ToLower(strings.TrimSpace(req.Role))
|
||||
if role != "admin" && role != "member" {
|
||||
role = "member"
|
||||
@@ -2621,10 +2642,11 @@ func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
caller, _ := s.store.GetUserByID(callerID)
|
||||
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
OrganizationID string `json:"organizationId"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
@@ -2652,6 +2674,11 @@ func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if req.OrganizationID != "" {
|
||||
if err := s.store.SetUserOrganization(u.ID, req.OrganizationID); err == nil {
|
||||
u.OrganizationID = req.OrganizationID
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, u)
|
||||
}
|
||||
|
||||
@@ -2760,10 +2787,10 @@ func (s *Server) handleAdminCreateInvite(w http.ResponseWriter, r *http.Request)
|
||||
caller, _ := s.store.GetUserByID(callerID)
|
||||
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
TeamID string `json:"teamId"`
|
||||
ValidDays int `json:"validDays"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
OrganizationID string `json:"organizationId"`
|
||||
ValidDays int `json:"validDays"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
@@ -2772,7 +2799,7 @@ func (s *Server) handleAdminCreateInvite(w http.ResponseWriter, r *http.Request)
|
||||
if req.Role == "admin" && (caller == nil || caller.Role != "admin") {
|
||||
req.Role = "member"
|
||||
}
|
||||
inv, err := s.store.CreateUserInvite(callerID, req.Email, req.Role, req.TeamID, req.ValidDays)
|
||||
inv, err := s.store.CreateUserInvite(callerID, req.Email, req.Role, req.OrganizationID, req.ValidDays)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
@@ -2807,10 +2834,15 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
previousMode := s.store.SharingMode()
|
||||
if err := s.store.UpdateSystemSettings(&settings); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
// A stricter mode takes away what is no longer allowed (shares, group memberships, assignments)
|
||||
if settings.SharingMode != previousMode {
|
||||
_, _ = s.store.PruneSharing(settings.SharingMode, false)
|
||||
}
|
||||
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
|
||||
writeJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
@@ -3037,4 +3069,3 @@ func (s *Server) startAlarmTicker() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "test_mindsift.db")
|
||||
store, err := storage.NewStore(dbPath)
|
||||
openSharing(t, store)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test store: %v", err)
|
||||
}
|
||||
@@ -57,6 +58,7 @@ func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
|
||||
func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "fresh_mindsift.db")
|
||||
store, err := storage.NewStore(dbPath)
|
||||
openSharing(t, store)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create fresh store: %v", err)
|
||||
}
|
||||
@@ -95,8 +97,8 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
|
||||
"displayName": "Server Owner",
|
||||
"instanceName": "My MindSift",
|
||||
"allowRegistration": false,
|
||||
"sharingMode": "teams_only",
|
||||
"seedSampleData": false
|
||||
"sharingMode": "organizations",
|
||||
"createSampleData": false
|
||||
}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(setupBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -118,7 +120,7 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
|
||||
arrayEndpoints := []string{
|
||||
"/api/v1/task-lists",
|
||||
"/api/v1/tasks",
|
||||
"/api/v1/organizations",
|
||||
"/api/v1/admin/organizations",
|
||||
"/api/v1/teams",
|
||||
"/api/v1/lists",
|
||||
}
|
||||
@@ -436,6 +438,7 @@ func TestNewFullFeatures(t *testing.T) {
|
||||
func TestLoginWithUsernameAndDemoMode(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "demo_api_test.db")
|
||||
store, err := storage.NewStore(dbPath)
|
||||
openSharing(t, store)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create store: %v", err)
|
||||
}
|
||||
@@ -523,6 +526,7 @@ func TestLoginWithUsernameAndDemoMode(t *testing.T) {
|
||||
func TestSpaceMembersDirectInvitationAPI(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "space_members_test.db")
|
||||
store, err := storage.NewStore(dbPath)
|
||||
openSharing(t, store)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create store: %v", err)
|
||||
}
|
||||
@@ -626,6 +630,8 @@ func TestSpaceMembersDirectInvitationAPI(t *testing.T) {
|
||||
t.Fatalf("failed to create team: %v", err)
|
||||
}
|
||||
_ = store.AddTeamMember(team.ID, normalUser.ID, "member")
|
||||
// Sharing with a group requires being in it
|
||||
_ = store.AddTeamMember(team.ID, adminUser.ID, "admin")
|
||||
|
||||
addTeamBody := []byte(`{"targetType":"team","targetId":"` + team.ID + `","role":"editor"}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/lists/"+createdList.ID+"/shares", bytes.NewReader(addTeamBody))
|
||||
@@ -754,6 +760,7 @@ func TestWorkspaceTagsAPI(t *testing.T) {
|
||||
func TestWebhookTriageBadgeAndIngestion(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "test_webhook.db")
|
||||
store, err := storage.NewStore(dbPath)
|
||||
openSharing(t, store)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to init store: %v", err)
|
||||
}
|
||||
@@ -773,7 +780,7 @@ func TestWebhookTriageBadgeAndIngestion(t *testing.T) {
|
||||
"displayName": "Home User",
|
||||
"instanceName": "MindSift Home",
|
||||
"allowRegistration": false,
|
||||
"seedSampleData": false
|
||||
"createSampleData": false
|
||||
}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(setupBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -845,6 +852,7 @@ func TestWebhookTriageBadgeAndIngestion(t *testing.T) {
|
||||
func TestSSEGlobalEndpoint(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "test_sse.db")
|
||||
store, err := storage.NewStore(dbPath)
|
||||
openSharing(t, store)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to init store: %v", err)
|
||||
}
|
||||
@@ -864,7 +872,7 @@ func TestSSEGlobalEndpoint(t *testing.T) {
|
||||
"displayName": "SSE User",
|
||||
"instanceName": "MindSift SSE",
|
||||
"allowRegistration": false,
|
||||
"seedSampleData": false
|
||||
"createSampleData": false
|
||||
}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(setupBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -1309,8 +1317,52 @@ func TestDocumentSharingStrictlyReadOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// openSharing lets tests that are not about visibility share freely between their users.
|
||||
func openSharing(t *testing.T, store *storage.Store) {
|
||||
t.Helper()
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
sys, err := store.GetSystemSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSystemSettings: %v", err)
|
||||
}
|
||||
sys.SharingMode = storage.SharingOpen
|
||||
if err := store.UpdateSystemSettings(sys); err != nil {
|
||||
t.Fatalf("UpdateSystemSettings: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The setup switch "Beispieldaten anlegen" fills the new admin's account, but creates no other users
|
||||
// (shared demo accounts exist only in demo mode).
|
||||
func TestFirstBootSetupWithSampleData(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "sample.db")
|
||||
store, err := storage.NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
srv := NewServer(&config.Config{Port: "8080", DatabasePath: dbPath, JWTSecret: "test-jwt-secret-key"}, store, ws.NewHub(), nil)
|
||||
|
||||
body := []byte(`{"email":"me@example.com","password":"secret","displayName":"Ich","createSampleData":true}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Router().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("setup: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var res struct {
|
||||
User domain.User `json:"user"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &res)
|
||||
|
||||
|
||||
|
||||
if n, _ := store.CountUsers(); n != 1 {
|
||||
t.Fatalf("sample data must not create users, found %d", n)
|
||||
}
|
||||
docs, _ := store.GetDocuments(res.User.ID)
|
||||
lists, _ := store.GetTaskLists(res.User.ID)
|
||||
if len(docs) == 0 || len(lists) == 0 {
|
||||
t.Fatalf("expected sample notes and task lists, got %d notes, %d task lists", len(docs), len(lists))
|
||||
}
|
||||
}
|
||||
+17
-18
@@ -131,20 +131,21 @@ type User struct {
|
||||
DiscordWebhookURL string `json:"discordWebhookUrl"`
|
||||
CustomWebhookURL string `json:"customWebhookUrl"`
|
||||
NotifyBeforeMinutes int `json:"notifyBeforeMinutes"`
|
||||
OrganizationID string `json:"organizationId,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type UserInvite struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Role string `json:"role"` // member | admin
|
||||
TeamID string `json:"teamId,omitempty"`
|
||||
TeamName string `json:"teamName,omitempty"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
UsedBy string `json:"usedBy,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Role string `json:"role"` // member | admin
|
||||
OrganizationID string `json:"organizationId,omitempty"`
|
||||
OrganizationName string `json:"organizationName,omitempty"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
UsedBy string `json:"usedBy,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type ThoughtStatus string
|
||||
@@ -233,8 +234,6 @@ type TaskList struct {
|
||||
UserID string `json:"userId"`
|
||||
SpaceID string `json:"spaceId,omitempty"`
|
||||
SpaceName string `json:"spaceName,omitempty"`
|
||||
TeamID string `json:"teamId,omitempty"`
|
||||
TeamName string `json:"teamName,omitempty"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
IsOwner bool `json:"isOwner"`
|
||||
Permission string `json:"permission,omitempty"` // owner | editor | viewer
|
||||
@@ -249,12 +248,12 @@ type TaskList struct {
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Organization is defined by server admins; each user belongs to at most one.
|
||||
type Organization struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
Teams []Team `json:"teams,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
MemberCount int `json:"memberCount"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type Team struct {
|
||||
@@ -277,7 +276,7 @@ type TeamMember struct {
|
||||
}
|
||||
|
||||
type SystemSettings struct {
|
||||
SharingMode string `json:"sharingMode"` // open | teams_only | strict_private
|
||||
SharingMode string `json:"sharingMode"` // private | organizations | open
|
||||
AllowRegistration bool `json:"allowRegistration"`
|
||||
InstanceName string `json:"instanceName"`
|
||||
SMTPHost string `json:"smtpHost"`
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
func newSyncTestStore(t *testing.T) (*Store, *domain.User) {
|
||||
t.Helper()
|
||||
store, err := NewStore(filepath.Join(t.TempDir(), "sync.db"))
|
||||
openSharing(t, store)
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
|
||||
+143
-313
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -57,7 +58,55 @@ func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// schemaVersion is stored in PRAGMA user_version. Databases from an older version are wiped and rebuilt
|
||||
// on start: MindSift is not in production yet, so breaking changes need no migration path.
|
||||
const schemaVersion = 2
|
||||
|
||||
func (s *Store) resetOutdatedSchema() error {
|
||||
var version int
|
||||
if err := s.db.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil {
|
||||
return err
|
||||
}
|
||||
if version >= schemaVersion {
|
||||
return nil
|
||||
}
|
||||
conn, err := s.db.Conn(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
ctx := context.Background()
|
||||
rows, err := conn.QueryContext(ctx, `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var tables []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if rows.Scan(&name) == nil {
|
||||
tables = append(tables, name)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
if len(tables) > 0 {
|
||||
slog.Warn("Veraltetes Datenbankschema: alle Daten werden gelöscht und neu angelegt", "version", version, "neu", schemaVersion)
|
||||
}
|
||||
if _, err := conn.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, t := range tables {
|
||||
if _, err := conn.ExecContext(ctx, `DROP TABLE IF EXISTS "`+strings.ReplaceAll(t, `"`, `""`)+`"`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = conn.ExecContext(ctx, `PRAGMA foreign_keys = ON`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
if err := s.resetOutdatedSchema(); err != nil {
|
||||
return fmt.Errorf("reset outdated schema: %w", err)
|
||||
}
|
||||
schema := `
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
@@ -79,15 +128,17 @@ func (s *Store) migrate() error {
|
||||
custom_webhook_url TEXT NOT NULL DEFAULT '',
|
||||
notify_before_minutes INTEGER NOT NULL DEFAULT 30,
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
organization_id TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_org ON users(organization_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_invites (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
team_id TEXT NOT NULL DEFAULT '',
|
||||
organization_id TEXT NOT NULL DEFAULT '',
|
||||
created_by TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_by TEXT NOT NULL DEFAULT '',
|
||||
@@ -124,7 +175,6 @@ func (s *Store) migrate() error {
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
space_id TEXT DEFAULT '',
|
||||
team_id TEXT DEFAULT '',
|
||||
title TEXT NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT '📋',
|
||||
color TEXT NOT NULL DEFAULT '#2563eb',
|
||||
@@ -135,17 +185,17 @@ func (s *Store) migrate() error {
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_lists_user ON task_lists(user_id, updated_at DESC);
|
||||
|
||||
-- Organizations are defined by server admins and decide who can see whom (sharing_mode = organizations)
|
||||
CREATE TABLE IF NOT EXISTS organizations (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
owner_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Teams are the user-managed groups ("Gruppen"); org_id is the creator's organization
|
||||
CREATE TABLE IF NOT EXISTS teams (
|
||||
id TEXT PRIMARY KEY,
|
||||
org_id TEXT NOT NULL DEFAULT 'default',
|
||||
org_id TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
@@ -351,7 +401,6 @@ func (s *Store) migrate() error {
|
||||
"CREATE INDEX IF NOT EXISTS idx_doc_shares_user ON doc_shares(user_id);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_doc_shares_doc ON doc_shares(doc_id);",
|
||||
|
||||
"ALTER TABLE task_lists ADD COLUMN team_id TEXT NOT NULL DEFAULT '';",
|
||||
"ALTER TABLE task_lists ADD COLUMN columns TEXT NOT NULL DEFAULT '[]';",
|
||||
"CREATE TABLE IF NOT EXISTS task_list_shares (id TEXT PRIMARY KEY, task_list_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), target_id TEXT NOT NULL, created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(task_list_id, target_type, target_id));",
|
||||
"CREATE INDEX IF NOT EXISTS idx_task_list_shares_target ON task_list_shares(target_type, target_id);",
|
||||
@@ -424,7 +473,7 @@ func (s *Store) migrate() error {
|
||||
|
||||
// Default system settings (public registration disabled by default; invite or admin creation required)
|
||||
_, _ = s.db.Exec(`INSERT OR IGNORE INTO system_settings (key, value) VALUES
|
||||
('sharing_mode', 'teams_only'),
|
||||
('sharing_mode', 'organizations'),
|
||||
('allow_registration', 'false'),
|
||||
('instance_name', 'MindSift'),
|
||||
('smtp_host', ''),
|
||||
@@ -434,32 +483,6 @@ func (s *Store) migrate() error {
|
||||
('smtp_from', ''),
|
||||
('smtp_tls', 'true')`)
|
||||
|
||||
// Migrate existing teams table if it still has a legacy FOREIGN KEY to organizations
|
||||
var teamsTableSQL string
|
||||
_ = s.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='teams'`).Scan(&teamsTableSQL)
|
||||
if strings.Contains(strings.ToUpper(teamsTableSQL), "ORGANIZATIONS") {
|
||||
if conn, err := s.db.Conn(context.Background()); err == nil {
|
||||
_, _ = conn.ExecContext(context.Background(), `PRAGMA foreign_keys = OFF;`)
|
||||
_, _ = conn.ExecContext(context.Background(), `
|
||||
BEGIN TRANSACTION;
|
||||
CREATE TABLE IF NOT EXISTS teams_migrated (
|
||||
id TEXT PRIMARY KEY,
|
||||
org_id TEXT NOT NULL DEFAULT 'default',
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
INSERT OR IGNORE INTO teams_migrated (id, org_id, name, description, created_at)
|
||||
SELECT id, COALESCE(NULLIF(org_id, ''), 'default'), name, COALESCE(description, ''), created_at FROM teams;
|
||||
DROP TABLE teams;
|
||||
ALTER TABLE teams_migrated RENAME TO teams;
|
||||
COMMIT;
|
||||
`)
|
||||
_, _ = conn.ExecContext(context.Background(), `PRAGMA foreign_keys = ON;`)
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up legacy roles in team_members and users
|
||||
_, _ = s.db.Exec(`UPDATE team_members SET role = 'admin' WHERE role IN ('team_admin', 'owner')`)
|
||||
_, _ = s.db.Exec(`UPDATE team_members SET role = 'member' WHERE role NOT IN ('admin', 'member')`)
|
||||
@@ -479,7 +502,8 @@ func (s *Store) migrate() error {
|
||||
}
|
||||
s.migrateAnchorTags()
|
||||
|
||||
return nil
|
||||
_, err := s.db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, schemaVersion))
|
||||
return err
|
||||
}
|
||||
|
||||
// EnsureDefaultUser is retained strictly for unit test convenience
|
||||
@@ -508,7 +532,7 @@ func (s *Store) EnsureDefaultUser() (*domain.User, error) {
|
||||
|
||||
// --- USERS ---
|
||||
|
||||
const userSelectCols = `id, email, username, password_hash, display_name, role, is_active, token_version, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at`
|
||||
const userSelectCols = `id, email, username, password_hash, display_name, role, is_active, token_version, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, organization_id, created_at`
|
||||
|
||||
func parseAlarmChannels(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
@@ -531,7 +555,7 @@ func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) {
|
||||
err := row.Scan(
|
||||
&u.ID, &u.Email, &u.Username, &u.PasswordHash, &u.DisplayName,
|
||||
&u.Role, &isActiveInt, &u.TokenVersion, &channelsRaw, &u.AlarmEmail,
|
||||
&u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.AvatarURL, &u.CreatedAt,
|
||||
&u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.AvatarURL, &u.OrganizationID, &u.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -660,8 +684,13 @@ func (s *Store) EnsureDemoAccounts() error {
|
||||
_, _ = s.db.Exec(`UPDATE users SET is_active = 1 WHERE id = ?`, normalUser.ID)
|
||||
}
|
||||
|
||||
// 3. Sample data for the admin, partly shared with the demo user (only on first start)
|
||||
// 3. Sample data for the admin, partly shared with the demo user (only on first start). Both accounts
|
||||
// share an organization so they can see each other in the default sharing mode.
|
||||
if adminCreated {
|
||||
if org, err := s.CreateOrganization("Demo"); err == nil {
|
||||
_ = s.SetUserOrganization(adminUser.ID, org.ID)
|
||||
_ = s.SetUserOrganization(normalUser.ID, org.ID)
|
||||
}
|
||||
_ = s.SeedStarterContent(adminUser)
|
||||
s.seedDemoShares(adminUser, normalUser)
|
||||
}
|
||||
@@ -701,46 +730,6 @@ func (s *Store) GetAllUsers() ([]domain.User, error) {
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetDiscoverableUsers(viewerID string) ([]domain.User, error) {
|
||||
viewer, err := s.GetUserByID(viewerID)
|
||||
if err != nil {
|
||||
return []domain.User{}, nil
|
||||
}
|
||||
sys, _ := s.GetSystemSettings()
|
||||
mode := "teams_only"
|
||||
if sys != nil && sys.SharingMode != "" {
|
||||
mode = sys.SharingMode
|
||||
}
|
||||
|
||||
if viewer.Role == "admin" || mode == "open" || mode == "teams_only" {
|
||||
return s.GetAllUsers()
|
||||
}
|
||||
if mode == "strict_private" {
|
||||
return []domain.User{*viewer}, nil
|
||||
}
|
||||
|
||||
// users sharing at least one team, organization or space with viewer
|
||||
rows, err := s.db.Query(
|
||||
`SELECT DISTINCT `+userSelectCols+` FROM users WHERE id = ? OR id IN (
|
||||
SELECT tm2.user_id FROM team_members tm1
|
||||
JOIN team_members tm2 ON tm1.team_id = tm2.team_id
|
||||
WHERE tm1.user_id = ?
|
||||
)`,
|
||||
viewerID, viewerID,
|
||||
)
|
||||
if err != nil {
|
||||
return []domain.User{*viewer}, nil
|
||||
}
|
||||
users := make([]domain.User, 0)
|
||||
for rows.Next() {
|
||||
if u, err := scanUser(rows); err == nil {
|
||||
users = append(users, *u)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateUser(
|
||||
userID string,
|
||||
displayName, email, role *string,
|
||||
@@ -821,6 +810,8 @@ func (s *Store) DeleteUser(userID string) error {
|
||||
_, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE user_id = ?`, userID)
|
||||
_, _ = s.db.Exec(`DELETE FROM task_assignees WHERE user_id = ?`, userID)
|
||||
_, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, userID)
|
||||
// Shares pointing at the deleted account are no longer visible to anyone
|
||||
s.pruneSharingNow()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -975,7 +966,6 @@ func scanThought(rows interface {
|
||||
func (s *Store) accessibleTaskListIDsQuery() string {
|
||||
return `SELECT id FROM task_lists
|
||||
WHERE user_id = ?
|
||||
OR (team_id != '' AND team_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
OR id IN (
|
||||
SELECT task_list_id FROM task_list_shares
|
||||
WHERE (target_type = 'user' AND target_id = ?)
|
||||
@@ -987,7 +977,7 @@ func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error
|
||||
row := s.db.QueryRow(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
|
||||
FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`,
|
||||
thoughtID, userID, userID, userID, userID, userID,
|
||||
thoughtID, userID, userID, userID, userID,
|
||||
)
|
||||
t, err := scanThought(row)
|
||||
if err != nil {
|
||||
@@ -1045,7 +1035,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
|
||||
AND task_list_id = ?
|
||||
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
|
||||
ORDER BY is_completed ASC, sort_order ASC, created_at DESC`,
|
||||
userID, userID, userID, userID, userID, taskListID,
|
||||
userID, userID, userID, userID, taskListID,
|
||||
)
|
||||
} else {
|
||||
rows, err = s.db.Query(
|
||||
@@ -1054,7 +1044,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
|
||||
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
||||
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
|
||||
ORDER BY is_completed ASC, sort_order ASC, created_at DESC`,
|
||||
userID, userID, userID, userID, userID,
|
||||
userID, userID, userID, userID,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -3080,7 +3070,7 @@ func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIde
|
||||
if cleanType == "team" {
|
||||
err = s.db.QueryRow(`SELECT id, name FROM teams WHERE id = ?`, cleanTarget).Scan(&targetID, &targetName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gruppe nicht gefunden")
|
||||
return nil, ErrTargetNotFound
|
||||
}
|
||||
} else {
|
||||
var u *domain.User
|
||||
@@ -3097,7 +3087,7 @@ func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIde
|
||||
if s.db.QueryRow(`SELECT id, name FROM teams WHERE id = ?`, cleanTarget).Scan(&targetID, &targetName) == nil {
|
||||
cleanType = "team"
|
||||
} else {
|
||||
return nil, fmt.Errorf("benutzer nicht gefunden")
|
||||
return nil, ErrTargetNotFound
|
||||
}
|
||||
} else {
|
||||
if u.ID == f.OwnerID {
|
||||
@@ -3110,6 +3100,10 @@ func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIde
|
||||
}
|
||||
}
|
||||
|
||||
if !s.CanShareWith(currentUserID, cleanType, targetID) {
|
||||
return nil, ErrTargetNotFound
|
||||
}
|
||||
|
||||
now := domain.NowISO()
|
||||
shareID := domain.NewID("fsh")
|
||||
|
||||
@@ -3175,7 +3169,8 @@ func (s *Store) DeleteFolderShare(currentUserID, folderID, shareID string) error
|
||||
}
|
||||
cleanID := strings.TrimSpace(shareID)
|
||||
|
||||
if !f.IsOwner && f.Permission != "write" {
|
||||
// Only the owner manages shares; everyone else can only leave the folder
|
||||
if !f.IsOwner {
|
||||
_, err = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ? AND target_type = 'user' AND target_id = ? AND (id = ? OR target_id = ?)`,
|
||||
folderID, currentUserID, cleanID, cleanID)
|
||||
_, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ? AND user_id = ?`, folderID, currentUserID)
|
||||
@@ -3295,8 +3290,8 @@ func (s *Store) CreateDocTeamShare(currentUserID, docID, teamID, permission stri
|
||||
cleanTeamID := strings.TrimSpace(teamID)
|
||||
var teamName string
|
||||
err = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, cleanTeamID).Scan(&teamName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("team nicht gefunden")
|
||||
if err != nil || !s.CanShareWith(currentUserID, "team", cleanTeamID) {
|
||||
return nil, ErrTargetNotFound
|
||||
}
|
||||
|
||||
permission = normalizeSharePermission(permission)
|
||||
@@ -3370,12 +3365,15 @@ func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permi
|
||||
if s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, cleanTarget).Scan(&tName) == nil {
|
||||
return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, permission)
|
||||
}
|
||||
return nil, fmt.Errorf("benutzer nicht gefunden")
|
||||
return nil, ErrTargetNotFound
|
||||
}
|
||||
|
||||
if targetUser.ID == doc.UserID {
|
||||
return nil, fmt.Errorf("dokument gehört bereits diesem Benutzer")
|
||||
}
|
||||
if !s.CanShareWith(currentUserID, "user", targetUser.ID) {
|
||||
return nil, ErrTargetNotFound
|
||||
}
|
||||
|
||||
now := domain.NowISO()
|
||||
shareID := domain.NewID("share")
|
||||
@@ -3409,7 +3407,8 @@ func (s *Store) DeleteDocShare(currentUserID, docID, targetID string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !doc.IsOwner && doc.Permission != "write" && currentUserID != targetID {
|
||||
// Only the owner manages shares; everyone else can only remove their own
|
||||
if !doc.IsOwner && currentUserID != targetID {
|
||||
return fmt.Errorf("keine Berechtigung zum Entfernen der Freigabe")
|
||||
}
|
||||
|
||||
@@ -3427,22 +3426,15 @@ func (s *Store) DeleteDocShare(currentUserID, docID, targetID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// GetCollaborators returns the active people the user can share with (everyone visible except the user).
|
||||
func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, email, display_name, role, is_active, avatar_url, created_at FROM users WHERE id != ? AND is_active = 1 ORDER BY display_name ASC`,
|
||||
currentUserID,
|
||||
)
|
||||
visible, err := s.GetDiscoverableUsers(currentUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
users := make([]domain.User, 0)
|
||||
for rows.Next() {
|
||||
var u domain.User
|
||||
var activeInt int
|
||||
if err := rows.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &activeInt, &u.AvatarURL, &u.CreatedAt); err == nil {
|
||||
u.IsActive = activeInt == 1
|
||||
users := make([]domain.User, 0, len(visible))
|
||||
for _, u := range visible {
|
||||
if u.ID != currentUserID && u.IsActive {
|
||||
users = append(users, u)
|
||||
}
|
||||
}
|
||||
@@ -3511,23 +3503,22 @@ func (s *Store) AddListShare(currentUserID, listID, targetType, targetID, role s
|
||||
targetUser, _ = s.GetUserByEmail(targetID)
|
||||
}
|
||||
if targetUser == nil {
|
||||
return nil, fmt.Errorf("benutzer nicht gefunden: %s", targetID)
|
||||
return nil, ErrTargetNotFound
|
||||
}
|
||||
targetID = targetUser.ID
|
||||
}
|
||||
|
||||
// Verify current user is owner or editor of the list
|
||||
// Only the owner shares
|
||||
var ownerID string
|
||||
err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID)
|
||||
if err != nil {
|
||||
return nil, errors.New("liste nicht gefunden")
|
||||
}
|
||||
if ownerID != currentUserID {
|
||||
var myRole string
|
||||
err = s.db.QueryRow(`SELECT role FROM list_shares WHERE list_id = ? AND target_type = 'user' AND target_id = ?`, listID, currentUserID).Scan(&myRole)
|
||||
if err != nil || myRole != "editor" {
|
||||
return nil, errors.New("keine Berechtigung zum Teilen dieser Liste")
|
||||
}
|
||||
return nil, errors.New("nur der Eigentümer kann die Liste freigeben")
|
||||
}
|
||||
if !s.CanShareWith(currentUserID, targetType, targetID) {
|
||||
return nil, ErrTargetNotFound
|
||||
}
|
||||
|
||||
now := domain.NowISO()
|
||||
@@ -4206,7 +4197,6 @@ func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color strin
|
||||
ID: domain.NewID("tkl"),
|
||||
UserID: userID,
|
||||
SpaceID: spaceID,
|
||||
TeamID: teamID,
|
||||
Title: strings.TrimSpace(title),
|
||||
Icon: icon,
|
||||
Color: color,
|
||||
@@ -4216,13 +4206,13 @@ func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color strin
|
||||
IsOwner: true,
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO task_lists (id, user_id, space_id, team_id, title, icon, color, columns, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
tl.ID, tl.UserID, tl.SpaceID, tl.TeamID, tl.Title, tl.Icon, tl.Color, string(colsJSON), tl.CreatedAt, tl.UpdatedAt,
|
||||
`INSERT INTO task_lists (id, user_id, space_id, title, icon, color, columns, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
tl.ID, tl.UserID, tl.SpaceID, tl.Title, tl.Icon, tl.Color, string(colsJSON), tl.CreatedAt, tl.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if teamID != "" {
|
||||
if teamID != "" && s.CanShareWith(userID, "team", teamID) {
|
||||
_, _ = s.AddTaskListShare(tl.ID, "team", teamID, "editor", userID)
|
||||
}
|
||||
return s.GetTaskListByID(userID, tl.ID)
|
||||
@@ -4236,8 +4226,8 @@ var ErrAssigneeNoAccess = errors.New("Aufgaben können nur Personen mit Zugriff
|
||||
|
||||
// GetTaskListPermission returns "owner", "editor", "viewer" or "" (no access) for the given user.
|
||||
func (s *Store) GetTaskListPermission(userID, listID string) string {
|
||||
var ownerID, teamID string
|
||||
if err := s.db.QueryRow(`SELECT user_id, COALESCE(team_id, '') FROM task_lists WHERE id = ?`, listID).Scan(&ownerID, &teamID); err != nil {
|
||||
var ownerID string
|
||||
if err := s.db.QueryRow(`SELECT user_id FROM task_lists WHERE id = ?`, listID).Scan(&ownerID); err != nil {
|
||||
return ""
|
||||
}
|
||||
if ownerID == userID {
|
||||
@@ -4267,15 +4257,6 @@ func (s *Store) GetTaskListPermission(userID, listID string) string {
|
||||
}
|
||||
_ = rows.Close()
|
||||
}
|
||||
if perm == "" && teamID != "" {
|
||||
// Legacy team assignment without a share row grants edit access
|
||||
var isMember, hasShareRow int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID).Scan(&isMember)
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM task_list_shares WHERE task_list_id = ? AND target_type = 'team' AND target_id = ?`, listID, teamID).Scan(&hasShareRow)
|
||||
if isMember > 0 && hasShareRow == 0 {
|
||||
perm = "editor"
|
||||
}
|
||||
}
|
||||
return perm
|
||||
}
|
||||
|
||||
@@ -4303,6 +4284,9 @@ func (s *Store) AddTaskListShare(taskListID, targetType, targetID, permission, c
|
||||
if permission != "viewer" {
|
||||
permission = "editor"
|
||||
}
|
||||
if !s.CanShareWith(createdBy, targetType, targetID) {
|
||||
return nil, ErrTargetNotFound
|
||||
}
|
||||
now := domain.NowISO()
|
||||
share := &domain.TaskListShare{
|
||||
ID: domain.NewID("tls"),
|
||||
@@ -4325,7 +4309,6 @@ func (s *Store) AddTaskListShare(taskListID, targetType, targetID, permission, c
|
||||
taskListID, targetType, targetID).Scan(&share.ID, &share.CreatedAt)
|
||||
|
||||
if targetType == "team" {
|
||||
_, _ = s.db.Exec(`UPDATE task_lists SET team_id = ? WHERE id = ? AND (team_id = '' OR team_id IS NULL)`, targetID, taskListID)
|
||||
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, targetID).Scan(&share.TargetName)
|
||||
} else {
|
||||
_ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, targetID).Scan(&share.TargetName, &share.Email)
|
||||
@@ -4334,16 +4317,10 @@ func (s *Store) AddTaskListShare(taskListID, targetType, targetID, permission, c
|
||||
}
|
||||
|
||||
func (s *Store) RemoveTaskListShare(taskListID, shareIDOrTargetID string) error {
|
||||
var targetType, targetID string
|
||||
_ = s.db.QueryRow(`SELECT target_type, target_id FROM task_list_shares WHERE task_list_id = ? AND (id = ? OR target_id = ?)`, taskListID, shareIDOrTargetID, shareIDOrTargetID).Scan(&targetType, &targetID)
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`DELETE FROM task_list_shares WHERE task_list_id = ? AND (id = ? OR target_id = ?)`,
|
||||
taskListID, shareIDOrTargetID, shareIDOrTargetID,
|
||||
)
|
||||
if targetType == "team" {
|
||||
_, _ = s.db.Exec(`UPDATE task_lists SET team_id = '' WHERE id = ? AND team_id = ?`, taskListID, targetID)
|
||||
}
|
||||
s.PruneTaskAssignees()
|
||||
return err
|
||||
}
|
||||
@@ -4395,13 +4372,12 @@ func (s *Store) queryUserIDs(query string, args ...interface{}) []string {
|
||||
return ids
|
||||
}
|
||||
|
||||
// taskListAccessSQL selects every user ID with access to the task list given as the single parameter.
|
||||
// It mirrors GetTaskListUserIDs and is correlated into PruneTaskAssignees.
|
||||
// taskListAccessSQL selects every user ID with access to a task list: owner, direct shares and members of
|
||||
// shared groups. %[1]s is the list ID expression ("?" or a correlated column).
|
||||
const taskListAccessSQL = `SELECT user_id FROM task_lists WHERE id = %[1]s
|
||||
UNION SELECT target_id FROM task_list_shares WHERE task_list_id = %[1]s AND target_type = 'user'
|
||||
UNION SELECT tm.user_id FROM team_members tm
|
||||
WHERE tm.team_id IN (SELECT target_id FROM task_list_shares WHERE task_list_id = %[1]s AND target_type = 'team')
|
||||
OR tm.team_id IN (SELECT team_id FROM task_lists WHERE id = %[1]s AND team_id != '')`
|
||||
WHERE tm.team_id IN (SELECT target_id FROM task_list_shares WHERE task_list_id = %[1]s AND target_type = 'team')`
|
||||
|
||||
func (s *Store) getTaskAssignees(taskID string) []string {
|
||||
ids := s.queryUserIDs(`SELECT user_id FROM task_assignees WHERE task_id = ? ORDER BY created_at ASC`, taskID)
|
||||
@@ -4518,20 +4494,12 @@ func (s *Store) PruneTaskAssignees() {
|
||||
)
|
||||
}
|
||||
|
||||
// GetTaskListUserIDs returns every user who can see the task list: owner, direct shares, members of shared teams
|
||||
// and members of the legacy team_id.
|
||||
// GetTaskListUserIDs returns every user who can see the task list: owner, direct shares and members of shared teams.
|
||||
func (s *Store) GetTaskListUserIDs(listID string) []string {
|
||||
if listID == "" {
|
||||
return []string{}
|
||||
}
|
||||
return s.queryUserIDs(
|
||||
`SELECT user_id FROM task_lists WHERE id = ?
|
||||
UNION SELECT target_id FROM task_list_shares WHERE task_list_id = ? AND target_type = 'user'
|
||||
UNION SELECT tm.user_id FROM team_members tm
|
||||
WHERE tm.team_id IN (SELECT target_id FROM task_list_shares WHERE task_list_id = ? AND target_type = 'team')
|
||||
OR tm.team_id IN (SELECT team_id FROM task_lists WHERE id = ? AND team_id != '')`,
|
||||
listID, listID, listID, listID,
|
||||
)
|
||||
return s.queryUserIDs(fmt.Sprintf(taskListAccessSQL, "?"), listID, listID, listID)
|
||||
}
|
||||
|
||||
// GetSharedFolderUserIDs returns the owner of a shared workspace folder and everyone it is shared with.
|
||||
@@ -4562,25 +4530,7 @@ func (s *Store) getMembersForTaskList(tl *domain.TaskList) []domain.TaskListMemb
|
||||
Role: "owner",
|
||||
}
|
||||
|
||||
// 2. Team members if tl.TeamID != ""
|
||||
if tl.TeamID != "" {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT u.id, u.display_name, u.email, u.avatar_url FROM team_members tm JOIN users u ON u.id = tm.user_id WHERE tm.team_id = ?`,
|
||||
tl.TeamID,
|
||||
)
|
||||
if err == nil {
|
||||
for rows.Next() {
|
||||
var m domain.TaskListMember
|
||||
if err := rows.Scan(&m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL); err == nil {
|
||||
m.Role = "member"
|
||||
memberMap[m.UserID] = m
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// 3. From task_list_shares
|
||||
// 2. From task_list_shares
|
||||
for _, sh := range tl.Shares {
|
||||
if sh.TargetType == "team" {
|
||||
rows, err := s.db.Query(
|
||||
@@ -4615,17 +4565,16 @@ func (s *Store) getMembersForTaskList(tl *domain.TaskList) []domain.TaskListMemb
|
||||
|
||||
func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, user_id, space_id, COALESCE(team_id, ''), title, icon, color, COALESCE(columns, '[]'), created_at, updated_at
|
||||
`SELECT id, user_id, space_id, title, icon, color, COALESCE(columns, '[]'), created_at, updated_at
|
||||
FROM task_lists
|
||||
WHERE user_id = ?
|
||||
OR (team_id != '' AND team_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
OR id IN (
|
||||
SELECT task_list_id FROM task_list_shares
|
||||
WHERE (target_type = 'user' AND target_id = ?)
|
||||
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
)
|
||||
ORDER BY updated_at DESC`,
|
||||
userID, userID, userID, userID,
|
||||
userID, userID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return []domain.TaskList{}, err
|
||||
@@ -4642,7 +4591,7 @@ func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
|
||||
for rows.Next() {
|
||||
var tl domain.TaskList
|
||||
var colsJSON string
|
||||
if err := rows.Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.TeamID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt); err == nil {
|
||||
if err := rows.Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt); err == nil {
|
||||
tl.IsOwner = (tl.UserID == userID)
|
||||
_ = json.Unmarshal([]byte(colsJSON), &tl.Columns)
|
||||
if len(tl.Columns) == 0 {
|
||||
@@ -4663,10 +4612,6 @@ func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
|
||||
).Scan(&list[i].TasksCount)
|
||||
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, list[i].UserID).Scan(&list[i].OwnerName)
|
||||
|
||||
if list[i].TeamID != "" {
|
||||
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, list[i].TeamID).Scan(&list[i].TeamName)
|
||||
}
|
||||
|
||||
shares, _ := s.GetTaskListShares(list[i].ID)
|
||||
list[i].Shares = shares
|
||||
list[i].Permission = s.GetTaskListPermission(userID, list[i].ID)
|
||||
@@ -4679,19 +4624,18 @@ func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error)
|
||||
var tl domain.TaskList
|
||||
var colsJSON string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, user_id, space_id, COALESCE(team_id, ''), title, icon, color, COALESCE(columns, '[]'), created_at, updated_at
|
||||
`SELECT id, user_id, space_id, title, icon, color, COALESCE(columns, '[]'), created_at, updated_at
|
||||
FROM task_lists
|
||||
WHERE id = ? AND (
|
||||
user_id = ?
|
||||
OR (team_id != '' AND team_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
OR id IN (
|
||||
SELECT task_list_id FROM task_list_shares
|
||||
WHERE (target_type = 'user' AND target_id = ?)
|
||||
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
)
|
||||
)`,
|
||||
listID, userID, userID, userID, userID,
|
||||
).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.TeamID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt)
|
||||
listID, userID, userID, userID,
|
||||
).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -4707,9 +4651,6 @@ func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error)
|
||||
}
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE task_list_id = ? AND is_completed = 0 AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))`, tl.ID).Scan(&tl.TasksCount)
|
||||
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, tl.UserID).Scan(&tl.OwnerName)
|
||||
if tl.TeamID != "" {
|
||||
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, tl.TeamID).Scan(&tl.TeamName)
|
||||
}
|
||||
shares, _ := s.GetTaskListShares(tl.ID)
|
||||
tl.Shares = shares
|
||||
tl.Permission = s.GetTaskListPermission(userID, tl.ID)
|
||||
@@ -4717,7 +4658,7 @@ func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error)
|
||||
return &tl, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, teamID, spaceID *string, columns *[]domain.KanbanColumn) (*domain.TaskList, error) {
|
||||
func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, spaceID *string, columns *[]domain.KanbanColumn) (*domain.TaskList, error) {
|
||||
tl, err := s.GetTaskListByID(userID, listID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -4740,28 +4681,15 @@ func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, teamID
|
||||
if columns != nil {
|
||||
tl.Columns = *columns
|
||||
}
|
||||
if teamID != nil {
|
||||
oldTeamID := tl.TeamID
|
||||
tl.TeamID = *teamID
|
||||
if oldTeamID != "" && oldTeamID != *teamID {
|
||||
_ = s.RemoveTaskListShare(listID, oldTeamID)
|
||||
}
|
||||
if *teamID != "" && *teamID != oldTeamID {
|
||||
_, _ = s.AddTaskListShare(listID, "team", *teamID, "editor", userID)
|
||||
}
|
||||
}
|
||||
tl.UpdatedAt = domain.NowISO()
|
||||
colsJSON, _ := json.Marshal(tl.Columns)
|
||||
_, err = s.db.Exec(
|
||||
`UPDATE task_lists SET title = ?, icon = ?, color = ?, space_id = ?, team_id = ?, columns = ?, updated_at = ? WHERE id = ?`,
|
||||
tl.Title, tl.Icon, tl.Color, tl.SpaceID, tl.TeamID, string(colsJSON), tl.UpdatedAt, listID,
|
||||
`UPDATE task_lists SET title = ?, icon = ?, color = ?, space_id = ?, columns = ?, updated_at = ? WHERE id = ?`,
|
||||
tl.Title, tl.Icon, tl.Color, tl.SpaceID, string(colsJSON), tl.UpdatedAt, listID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if teamID != nil {
|
||||
s.PruneTaskAssignees()
|
||||
}
|
||||
return s.GetTaskListByID(userID, listID)
|
||||
}
|
||||
|
||||
@@ -4775,96 +4703,12 @@ func (s *Store) DeleteTaskList(userID, listID string) error {
|
||||
|
||||
// --- ORGANIZATIONS & TEAMS ---
|
||||
|
||||
func (s *Store) CreateOrganization(ownerID, name string) (*domain.Organization, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, errors.New("organization name required")
|
||||
}
|
||||
org := &domain.Organization{
|
||||
ID: domain.NewID("org"),
|
||||
Name: name,
|
||||
OwnerID: ownerID,
|
||||
Teams: []domain.Team{},
|
||||
CreatedAt: domain.NowISO(),
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO organizations (id, name, owner_id, created_at) VALUES (?, ?, ?, ?)`,
|
||||
org.ID, org.Name, org.OwnerID, org.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return org, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteOrganization(orgID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM organizations WHERE id = ?`, orgID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetOrganizationsForUser(userID string) ([]domain.Organization, error) {
|
||||
viewer, _ := s.GetUserByID(userID)
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
|
||||
if viewer != nil && viewer.Role == "admin" {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT id, name, owner_id, created_at FROM organizations ORDER BY created_at ASC`,
|
||||
)
|
||||
} else {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT DISTINCT o.id, o.name, o.owner_id, o.created_at
|
||||
FROM organizations o
|
||||
LEFT JOIN teams t ON t.org_id = o.id
|
||||
LEFT JOIN team_members tm ON tm.team_id = t.id
|
||||
WHERE o.owner_id = ? OR tm.user_id = ?
|
||||
ORDER BY o.created_at ASC`,
|
||||
userID, userID,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return []domain.Organization{}, err
|
||||
}
|
||||
|
||||
orgs := make([]domain.Organization, 0)
|
||||
for rows.Next() {
|
||||
var o domain.Organization
|
||||
if err := rows.Scan(&o.ID, &o.Name, &o.OwnerID, &o.CreatedAt); err == nil {
|
||||
o.Teams = []domain.Team{}
|
||||
orgs = append(orgs, o)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
for i := range orgs {
|
||||
teams, _ := s.GetTeamsForOrg(orgs[i].ID)
|
||||
orgs[i].Teams = teams
|
||||
}
|
||||
return orgs, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateTeam(orgID, name, description string) (*domain.Team, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, errors.New("team name required")
|
||||
}
|
||||
orgID = strings.TrimSpace(orgID)
|
||||
if orgID == "" {
|
||||
orgID = "default"
|
||||
}
|
||||
|
||||
var orgExists int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM organizations WHERE id = ?`, orgID).Scan(&orgExists)
|
||||
if orgExists == 0 {
|
||||
var ownerID string
|
||||
_ = s.db.QueryRow(`SELECT id FROM users ORDER BY created_at ASC LIMIT 1`).Scan(&ownerID)
|
||||
if ownerID != "" {
|
||||
_, _ = s.db.Exec(
|
||||
`INSERT OR IGNORE INTO organizations (id, name, owner_id, created_at) VALUES (?, ?, ?, ?)`,
|
||||
orgID, "Standard", ownerID, domain.NowISO(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
team := &domain.Team{
|
||||
ID: domain.NewID("team"),
|
||||
@@ -4878,18 +4722,6 @@ func (s *Store) CreateTeam(orgID, name, description string) (*domain.Team, error
|
||||
`INSERT INTO teams (id, org_id, name, description, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
team.ID, team.OrgID, team.Name, team.Description, team.CreatedAt,
|
||||
)
|
||||
if err != nil && strings.Contains(strings.ToUpper(err.Error()), "FOREIGN KEY") {
|
||||
if conn, connErr := s.db.Conn(context.Background()); connErr == nil {
|
||||
_, _ = conn.ExecContext(context.Background(), `PRAGMA foreign_keys = OFF;`)
|
||||
_, err = conn.ExecContext(
|
||||
context.Background(),
|
||||
`INSERT INTO teams (id, org_id, name, description, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
team.ID, team.OrgID, team.Name, team.Description, team.CreatedAt,
|
||||
)
|
||||
_, _ = conn.ExecContext(context.Background(), `PRAGMA foreign_keys = ON;`)
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -4898,7 +4730,7 @@ func (s *Store) CreateTeam(orgID, name, description string) (*domain.Team, error
|
||||
|
||||
func (s *Store) DeleteTeam(teamID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM teams WHERE id = ?`, teamID)
|
||||
s.PruneTaskAssignees()
|
||||
s.pruneSharingNow()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5078,7 +4910,8 @@ func (s *Store) RemoveTeamMember(teamID, userID string) error {
|
||||
}
|
||||
}
|
||||
_, err = s.db.Exec(`DELETE FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID)
|
||||
s.PruneTaskAssignees()
|
||||
// Whoever leaves a group also loses the shares they made with it
|
||||
s.pruneSharingNow()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5108,7 +4941,7 @@ func (s *Store) GetTeamMembers(teamID string) ([]domain.TeamMember, error) {
|
||||
|
||||
// --- USER INVITES ---
|
||||
|
||||
func (s *Store) CreateUserInvite(createdBy, email, role, teamID string, validDays int) (*domain.UserInvite, error) {
|
||||
func (s *Store) CreateUserInvite(createdBy, email, role, orgID string, validDays int) (*domain.UserInvite, error) {
|
||||
if role != "admin" && role != "member" {
|
||||
role = "member"
|
||||
}
|
||||
@@ -5118,19 +4951,19 @@ func (s *Store) CreateUserInvite(createdBy, email, role, teamID string, validDay
|
||||
code := "INV-" + strings.ToUpper(domain.NewID("c")[2:])
|
||||
now := time.Now().UTC()
|
||||
inv := &domain.UserInvite{
|
||||
ID: domain.NewID("inv"),
|
||||
Code: code,
|
||||
Email: strings.ToLower(strings.TrimSpace(email)),
|
||||
Role: role,
|
||||
TeamID: strings.TrimSpace(teamID),
|
||||
CreatedBy: createdBy,
|
||||
ExpiresAt: now.AddDate(0, 0, validDays).Format(time.RFC3339),
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
ID: domain.NewID("inv"),
|
||||
Code: code,
|
||||
Email: strings.ToLower(strings.TrimSpace(email)),
|
||||
Role: role,
|
||||
OrganizationID: strings.TrimSpace(orgID),
|
||||
CreatedBy: createdBy,
|
||||
ExpiresAt: now.AddDate(0, 0, validDays).Format(time.RFC3339),
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO user_invites (id, code, email, role, team_id, created_by, expires_at, used_by, created_at)
|
||||
`INSERT INTO user_invites (id, code, email, role, organization_id, created_by, expires_at, used_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, '', ?)`,
|
||||
inv.ID, inv.Code, inv.Email, inv.Role, inv.TeamID, inv.CreatedBy, inv.ExpiresAt, inv.CreatedAt,
|
||||
inv.ID, inv.Code, inv.Email, inv.Role, inv.OrganizationID, inv.CreatedBy, inv.ExpiresAt, inv.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -5140,9 +4973,9 @@ func (s *Store) CreateUserInvite(createdBy, email, role, teamID string, validDay
|
||||
|
||||
func (s *Store) GetUserInvites() ([]domain.UserInvite, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT i.id, i.code, i.email, i.role, i.team_id, COALESCE(t.name, ''), i.created_by, i.expires_at, i.used_by, i.created_at
|
||||
`SELECT i.id, i.code, i.email, i.role, i.organization_id, COALESCE(o.name, ''), i.created_by, i.expires_at, i.used_by, i.created_at
|
||||
FROM user_invites i
|
||||
LEFT JOIN teams t ON t.id = i.team_id
|
||||
LEFT JOIN organizations o ON o.id = i.organization_id
|
||||
ORDER BY i.created_at DESC`,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -5153,7 +4986,7 @@ func (s *Store) GetUserInvites() ([]domain.UserInvite, error) {
|
||||
invites := make([]domain.UserInvite, 0)
|
||||
for rows.Next() {
|
||||
var inv domain.UserInvite
|
||||
if err := rows.Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.TeamID, &inv.TeamName, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt); err == nil {
|
||||
if err := rows.Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.OrganizationID, &inv.OrganizationName, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt); err == nil {
|
||||
invites = append(invites, inv)
|
||||
}
|
||||
}
|
||||
@@ -5164,10 +4997,10 @@ func (s *Store) GetUserInviteByCode(code string) (*domain.UserInvite, error) {
|
||||
code = strings.ToUpper(strings.TrimSpace(code))
|
||||
var inv domain.UserInvite
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, code, email, role, team_id, created_by, expires_at, used_by, created_at
|
||||
`SELECT id, code, email, role, organization_id, created_by, expires_at, used_by, created_at
|
||||
FROM user_invites WHERE UPPER(code) = ?`,
|
||||
code,
|
||||
).Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.TeamID, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt)
|
||||
).Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.OrganizationID, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, errors.New("Ungültiger Einladungscode")
|
||||
}
|
||||
@@ -5213,7 +5046,7 @@ func (s *Store) SeedSMTPDefaultsFromEnv(host, port, user, pass, from string, tls
|
||||
|
||||
func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) {
|
||||
settings := &domain.SystemSettings{
|
||||
SharingMode: "teams_only",
|
||||
SharingMode: SharingOrganizations,
|
||||
AllowRegistration: false,
|
||||
InstanceName: "MindSift",
|
||||
SMTPPort: "587",
|
||||
@@ -5268,9 +5101,7 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
|
||||
if settings.SMTPPort == "" {
|
||||
settings.SMTPPort = "587"
|
||||
}
|
||||
if settings.SharingMode == "" {
|
||||
settings.SharingMode = "teams_only"
|
||||
}
|
||||
settings.SharingMode = NormalizeSharingMode(settings.SharingMode)
|
||||
if settings.InstanceName == "" {
|
||||
settings.InstanceName = "MindSift"
|
||||
}
|
||||
@@ -5354,4 +5185,3 @@ func (s *Store) MarkTaskAlarmSent(taskID string) error {
|
||||
_, err := s.db.Exec(`UPDATE thoughts SET alarm_sent = 1 WHERE id = ?`, taskID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
dbPath := filepath.Join(tmpDir, "test_mindsift.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
openSharing(t, store)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
@@ -106,7 +107,7 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
}
|
||||
|
||||
// 6. Test Organizations & Teams
|
||||
org, err := store.CreateOrganization(user.ID, "Mindsift Inc")
|
||||
org, err := store.CreateOrganization("Mindsift Inc")
|
||||
if err != nil || org.ID == "" {
|
||||
t.Fatalf("CreateOrganization failed: %v", err)
|
||||
}
|
||||
@@ -138,7 +139,7 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
if err != nil || sysSettings.InstanceName == "" {
|
||||
t.Fatalf("GetSystemSettings failed: %v", err)
|
||||
}
|
||||
sysSettings.SharingMode = "teams_only"
|
||||
sysSettings.SharingMode = SharingOpen
|
||||
err = store.UpdateSystemSettings(sysSettings)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateSystemSettings failed: %v", err)
|
||||
@@ -179,7 +180,7 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
t.Fatalf("viewer must not delete tasks, got %v", err)
|
||||
}
|
||||
newTitle := "Umbenannt"
|
||||
if _, err := store.UpdateTaskList(user2.ID, taskList.ID, &newTitle, nil, nil, nil, nil, nil); !errors.Is(err, ErrTaskListReadOnly) {
|
||||
if _, err := store.UpdateTaskList(user2.ID, taskList.ID, &newTitle, nil, nil, nil, nil); !errors.Is(err, ErrTaskListReadOnly) {
|
||||
t.Fatalf("viewer must not update task list, got %v", err)
|
||||
}
|
||||
if _, err := store.AddTaskListShare(taskList.ID, "user", user2.ID, "editor", user.ID); err != nil {
|
||||
@@ -236,6 +237,7 @@ func TestPerUserDocPinning(t *testing.T) {
|
||||
dbPath := filepath.Join(tmpDir, "pin_test.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
openSharing(t, store)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
@@ -312,6 +314,7 @@ func TestEnsureDemoAccountsAndLoginLookup(t *testing.T) {
|
||||
dbPath := filepath.Join(tmpDir, "demo_test.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
openSharing(t, store)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
@@ -368,6 +371,7 @@ func TestFolderManagementAndDeletion(t *testing.T) {
|
||||
dbPath := filepath.Join(tmpDir, "folder_test.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
openSharing(t, store)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
@@ -441,6 +445,7 @@ func TestWorkspaceBlockHashtagsAndDetails(t *testing.T) {
|
||||
dbPath := filepath.Join(tmpDir, "tags_test.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
openSharing(t, store)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
@@ -542,6 +547,7 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
|
||||
dbPath := filepath.Join(tmpDir, "test_db_first.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
openSharing(t, store)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
@@ -692,6 +698,7 @@ func TestBlockListAndDerivedData(t *testing.T) {
|
||||
dbPath := filepath.Join(tmpDir, "tabula_rasa_test.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
openSharing(t, store)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
@@ -752,4 +759,18 @@ func TestBlockListAndDerivedData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// openSharing lets tests that are not about visibility share freely between their users.
|
||||
func openSharing(t *testing.T, store *Store) {
|
||||
t.Helper()
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
sys, err := store.GetSystemSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSystemSettings: %v", err)
|
||||
}
|
||||
sys.SharingMode = SharingOpen
|
||||
if err := store.UpdateSystemSettings(sys); err != nil {
|
||||
t.Fatalf("UpdateSystemSettings: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,13 @@ func TestTaskAssigneesFollowListAccess(t *testing.T) {
|
||||
t.Fatalf("UpdateThoughtTriage failed: %v", err)
|
||||
}
|
||||
|
||||
org, _ := store.CreateOrganization(owner.ID, "Familie")
|
||||
// Everyone but the stranger shares the organization, so they can see each other
|
||||
org, _ := store.CreateOrganization("Familie")
|
||||
for _, u := range []string{owner.ID, direct.ID, viaTeam.ID} {
|
||||
if err := store.SetUserOrganization(u, org.ID); err != nil {
|
||||
t.Fatalf("SetUserOrganization failed: %v", err)
|
||||
}
|
||||
}
|
||||
team, err := store.CreateTeam(org.ID, "WG", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTeam failed: %v", err)
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
// Sharing modes (system_settings.sharing_mode). They decide who can see whom; every way of finding
|
||||
// people (directory, search, sharing, groups) goes through CanSee / CanShareWith.
|
||||
const (
|
||||
SharingPrivate = "private" // nobody sees anybody, sharing and groups are off
|
||||
SharingOrganizations = "organizations" // only members of the same organization see each other
|
||||
SharingOpen = "open" // everybody sees everybody
|
||||
)
|
||||
|
||||
// ErrSharingDisabled is returned for sharing or group actions while the server runs in private mode.
|
||||
var ErrSharingDisabled = errors.New("Teilen ist auf diesem Server deaktiviert")
|
||||
|
||||
// ErrTargetNotFound is returned both for unknown people/groups and for ones the caller may not see,
|
||||
// so nobody can probe which accounts exist.
|
||||
var ErrTargetNotFound = errors.New("Benutzer oder Gruppe nicht gefunden")
|
||||
|
||||
func NormalizeSharingMode(mode string) string {
|
||||
switch mode {
|
||||
case SharingPrivate, SharingOpen:
|
||||
return mode
|
||||
}
|
||||
return SharingOrganizations
|
||||
}
|
||||
|
||||
func (s *Store) SharingMode() string {
|
||||
sys, _ := s.GetSystemSettings()
|
||||
if sys == nil {
|
||||
return SharingOrganizations
|
||||
}
|
||||
return NormalizeSharingMode(sys.SharingMode)
|
||||
}
|
||||
|
||||
func (s *Store) userOrganization(userID string) string {
|
||||
var org string
|
||||
_ = s.db.QueryRow(`SELECT organization_id FROM users WHERE id = ?`, userID).Scan(&org)
|
||||
return org
|
||||
}
|
||||
|
||||
// CanSee reports whether viewer may find target and share with it. Server admins follow the same rule;
|
||||
// they manage everybody through the admin endpoints instead.
|
||||
func (s *Store) CanSee(viewerID, targetID string) bool {
|
||||
if viewerID == "" || targetID == "" {
|
||||
return false
|
||||
}
|
||||
if viewerID == targetID {
|
||||
return true
|
||||
}
|
||||
switch s.SharingMode() {
|
||||
case SharingOpen:
|
||||
var n int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM users WHERE id = ?`, targetID).Scan(&n)
|
||||
return n > 0
|
||||
case SharingPrivate:
|
||||
return false
|
||||
}
|
||||
org := s.userOrganization(viewerID)
|
||||
return org != "" && org == s.userOrganization(targetID)
|
||||
}
|
||||
|
||||
// CanShareWith reports whether owner may share something with a user or group: users must be visible,
|
||||
// groups must have owner as a member. Sharing is only ever done by the owner of the shared item.
|
||||
func (s *Store) CanShareWith(ownerID, targetType, targetID string) bool {
|
||||
if s.SharingMode() == SharingPrivate {
|
||||
return false
|
||||
}
|
||||
if targetType == "team" {
|
||||
var n int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM team_members WHERE team_id = ? AND user_id = ?`, targetID, ownerID).Scan(&n)
|
||||
return n > 0
|
||||
}
|
||||
return ownerID != targetID && s.CanSee(ownerID, targetID)
|
||||
}
|
||||
|
||||
// GetDiscoverableUsers returns everyone the viewer can see, the viewer included.
|
||||
func (s *Store) GetDiscoverableUsers(viewerID string) ([]domain.User, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
switch s.SharingMode() {
|
||||
case SharingOpen:
|
||||
rows, err = s.db.Query(`SELECT ` + userSelectCols + ` FROM users ORDER BY display_name COLLATE NOCASE`)
|
||||
case SharingPrivate:
|
||||
rows, err = s.db.Query(`SELECT `+userSelectCols+` FROM users WHERE id = ?`, viewerID)
|
||||
default:
|
||||
rows, err = s.db.Query(
|
||||
`SELECT `+userSelectCols+` FROM users
|
||||
WHERE id = ? OR (organization_id != '' AND organization_id = (SELECT organization_id FROM users WHERE id = ?))
|
||||
ORDER BY display_name COLLATE NOCASE`,
|
||||
viewerID, viewerID,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return []domain.User{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
users := make([]domain.User, 0)
|
||||
for rows.Next() {
|
||||
if u, err := scanUser(rows); err == nil {
|
||||
users = append(users, *u)
|
||||
}
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// visibleSQL is the SQL form of CanSee for two user ID expressions under the given mode.
|
||||
func visibleSQL(mode, a, b string) string {
|
||||
switch mode {
|
||||
case SharingOpen:
|
||||
return "1"
|
||||
case SharingPrivate:
|
||||
return fmt.Sprintf("(%s = %s)", a, b)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
`(%[1]s = %[2]s OR EXISTS (SELECT 1 FROM users ua JOIN users ub ON ub.organization_id = ua.organization_id
|
||||
WHERE ua.id = %[1]s AND ub.id = %[2]s AND ua.organization_id != ''))`, a, b)
|
||||
}
|
||||
|
||||
// PruneSharing removes everything that is no longer allowed under mode: group memberships across
|
||||
// organizations, shares with people the owner cannot see, and shares with groups the owner is not in.
|
||||
// Task assignments follow via PruneTaskAssignees. With dryRun nothing changes and only the number of
|
||||
// affected memberships and shares is returned (used to warn before tightening the mode).
|
||||
func (s *Store) PruneSharing(mode string, dryRun bool) (int64, error) {
|
||||
mode = NormalizeSharingMode(mode)
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var stmts []string
|
||||
switch mode {
|
||||
case SharingPrivate:
|
||||
stmts = append(stmts, `DELETE FROM team_members`)
|
||||
case SharingOrganizations:
|
||||
// A group belongs to its creator's organization (teams.org_id)
|
||||
stmts = append(stmts, `DELETE FROM team_members WHERE
|
||||
(SELECT organization_id FROM users WHERE id = team_members.user_id) != (SELECT org_id FROM teams WHERE id = team_members.team_id)
|
||||
OR (SELECT org_id FROM teams WHERE id = team_members.team_id) = ''`)
|
||||
}
|
||||
|
||||
type shareTable struct{ table, owner, userCol, teamCol string }
|
||||
tables := []shareTable{
|
||||
{"doc_shares", "(SELECT user_id FROM documents WHERE id = doc_shares.doc_id)", "doc_shares.user_id", ""},
|
||||
{"doc_team_shares", "(SELECT user_id FROM documents WHERE id = doc_team_shares.doc_id)", "", "doc_team_shares.team_id"},
|
||||
{"task_list_shares", "(SELECT user_id FROM task_lists WHERE id = task_list_shares.task_list_id)", "task_list_shares.target_id", "task_list_shares.target_id"},
|
||||
{"list_shares", "(SELECT user_id FROM lists WHERE id = list_shares.list_id)", "list_shares.target_id", "list_shares.target_id"},
|
||||
{"workspace_folder_shares", "(SELECT owner_id FROM workspace_shared_folders WHERE id = workspace_folder_shares.folder_id)", "workspace_folder_shares.target_id", "workspace_folder_shares.target_id"},
|
||||
}
|
||||
for _, t := range tables {
|
||||
hasType := t.userCol != "" && t.teamCol != ""
|
||||
if t.userCol != "" {
|
||||
cond := "NOT " + visibleSQL(mode, t.owner, t.userCol)
|
||||
if hasType {
|
||||
cond = "target_type = 'user' AND " + cond
|
||||
}
|
||||
stmts = append(stmts, fmt.Sprintf(`DELETE FROM %s WHERE %s`, t.table, cond))
|
||||
}
|
||||
if t.teamCol != "" {
|
||||
cond := fmt.Sprintf(`NOT EXISTS (SELECT 1 FROM team_members WHERE team_id = %s AND user_id = %s)`, t.teamCol, t.owner)
|
||||
if hasType {
|
||||
cond = "target_type = 'team' AND " + cond
|
||||
}
|
||||
stmts = append(stmts, fmt.Sprintf(`DELETE FROM %s WHERE %s`, t.table, cond))
|
||||
}
|
||||
}
|
||||
|
||||
var affected int64
|
||||
for _, q := range stmts {
|
||||
res, err := tx.Exec(q)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("prune sharing: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
affected += n
|
||||
}
|
||||
// Groups without members disappear; groups that lost their last admin get one
|
||||
if _, err := tx.Exec(`DELETE FROM teams WHERE id NOT IN (SELECT team_id FROM team_members)`); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE team_members SET role = 'admin' WHERE rowid IN (
|
||||
SELECT MIN(rowid) FROM team_members GROUP BY team_id HAVING SUM(role = 'admin') = 0)`); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if dryRun {
|
||||
return affected, nil
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.PruneTaskAssignees()
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
// pruneSharingNow applies the current mode after a membership or organization change.
|
||||
func (s *Store) pruneSharingNow() {
|
||||
_, _ = s.PruneSharing(s.SharingMode(), false)
|
||||
}
|
||||
|
||||
// --- ORGANIZATIONS (managed by server admins only) ---
|
||||
|
||||
func (s *Store) GetOrganizations() ([]domain.Organization, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT o.id, o.name, o.created_at, (SELECT COUNT(*) FROM users u WHERE u.organization_id = o.id)
|
||||
FROM organizations o ORDER BY o.name COLLATE NOCASE`,
|
||||
)
|
||||
if err != nil {
|
||||
return []domain.Organization{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
orgs := make([]domain.Organization, 0)
|
||||
for rows.Next() {
|
||||
var o domain.Organization
|
||||
if rows.Scan(&o.ID, &o.Name, &o.CreatedAt, &o.MemberCount) == nil {
|
||||
orgs = append(orgs, o)
|
||||
}
|
||||
}
|
||||
return orgs, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateOrganization(name string) (*domain.Organization, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, errors.New("Name der Organisation ist erforderlich")
|
||||
}
|
||||
org := &domain.Organization{ID: domain.NewID("org"), Name: name, CreatedAt: domain.NowISO()}
|
||||
if _, err := s.db.Exec(`INSERT INTO organizations (id, name, created_at) VALUES (?, ?, ?)`, org.ID, org.Name, org.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return org, nil
|
||||
}
|
||||
|
||||
func (s *Store) RenameOrganization(orgID, name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return errors.New("Name der Organisation ist erforderlich")
|
||||
}
|
||||
res, err := s.db.Exec(`UPDATE organizations SET name = ? WHERE id = ?`, name, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return errors.New("Organisation nicht gefunden")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteOrganization removes the organization; its members end up without one and lose what they
|
||||
// shared with each other.
|
||||
func (s *Store) DeleteOrganization(orgID string) error {
|
||||
if _, err := s.db.Exec(`UPDATE users SET organization_id = '' WHERE organization_id = ?`, orgID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.Exec(`DELETE FROM organizations WHERE id = ?`, orgID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.pruneSharingNow()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetUserOrganization moves a user into an organization ("" = none). Leaving one drops the user's
|
||||
// groups, shares and task assignments there.
|
||||
func (s *Store) SetUserOrganization(userID, orgID string) error {
|
||||
orgID = strings.TrimSpace(orgID)
|
||||
if orgID != "" {
|
||||
var n int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM organizations WHERE id = ?`, orgID).Scan(&n)
|
||||
if n == 0 {
|
||||
return errors.New("Organisation nicht gefunden")
|
||||
}
|
||||
}
|
||||
res, err := s.db.Exec(`UPDATE users SET organization_id = ? WHERE id = ?`, orgID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return errors.New("Benutzer nicht gefunden")
|
||||
}
|
||||
s.pruneSharingNow()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
func setSharingMode(t *testing.T, store *Store, mode string) {
|
||||
t.Helper()
|
||||
sys, _ := store.GetSystemSettings()
|
||||
sys.SharingMode = mode
|
||||
if err := store.UpdateSystemSettings(sys); err != nil {
|
||||
t.Fatalf("UpdateSystemSettings: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisibilityFollowsOrganizations(t *testing.T) {
|
||||
store, err := NewStore(filepath.Join(t.TempDir(), "visibility.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
if store.SharingMode() != SharingOrganizations {
|
||||
t.Fatalf("default mode should be organizations, got %q", store.SharingMode())
|
||||
}
|
||||
|
||||
anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
|
||||
ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member")
|
||||
carl, _ := store.CreateUser("carl@example.com", "hash", "Carl", "admin")
|
||||
dora, _ := store.CreateUser("dora@example.com", "hash", "Dora", "member")
|
||||
org1, _ := store.CreateOrganization("Familie")
|
||||
org2, _ := store.CreateOrganization("Firma")
|
||||
_ = store.SetUserOrganization(anna.ID, org1.ID)
|
||||
_ = store.SetUserOrganization(ben.ID, org1.ID)
|
||||
_ = store.SetUserOrganization(carl.ID, org2.ID)
|
||||
|
||||
// Who sees whom (server admins follow the same rule)
|
||||
cases := []struct {
|
||||
a, b *domain.User
|
||||
want bool
|
||||
}{
|
||||
{anna, ben, true}, {ben, anna, true}, {anna, carl, false}, {carl, anna, false},
|
||||
{dora, anna, false}, {anna, dora, false}, {dora, dora, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := store.CanSee(c.a.ID, c.b.ID); got != c.want {
|
||||
t.Errorf("CanSee(%s, %s) = %v, want %v", c.a.DisplayName, c.b.DisplayName, got, c.want)
|
||||
}
|
||||
}
|
||||
if users, _ := store.GetDiscoverableUsers(anna.ID); len(users) != 2 {
|
||||
t.Errorf("Anna should see herself and Ben, got %d users", len(users))
|
||||
}
|
||||
if users, _ := store.GetDiscoverableUsers(dora.ID); len(users) != 1 || users[0].ID != dora.ID {
|
||||
t.Errorf("Dora without organization should only see herself, got %+v", users)
|
||||
}
|
||||
|
||||
// Sharing: visible people only; an invisible e-mail looks like an unknown one
|
||||
doc, _ := store.CreateDocument(anna.ID, "", "Einkauf", "📄", "", nil, nil, false)
|
||||
if _, err := store.CreateDocShare(anna.ID, doc.ID, ben.ID, "write"); err != nil {
|
||||
t.Fatalf("share with Ben failed: %v", err)
|
||||
}
|
||||
if _, err := store.CreateDocShare(anna.ID, doc.ID, "carl@example.com", "read"); !errors.Is(err, ErrTargetNotFound) {
|
||||
t.Errorf("share with invisible Carl must look like not found, got %v", err)
|
||||
}
|
||||
if _, err := store.CreateDocShare(anna.ID, doc.ID, "nobody@example.com", "read"); !errors.Is(err, ErrTargetNotFound) {
|
||||
t.Errorf("share with unknown address must be not found, got %v", err)
|
||||
}
|
||||
|
||||
// Only the owner shares a list
|
||||
list, _ := store.CreateList(anna.ID, "Wocheneinkauf", "🛒", "shopping")
|
||||
if _, err := store.AddListShare(anna.ID, list.ID, "user", ben.ID, "editor"); err != nil {
|
||||
t.Fatalf("list share with Ben failed: %v", err)
|
||||
}
|
||||
if _, err := store.AddListShare(ben.ID, list.ID, "user", anna.ID, "editor"); err == nil {
|
||||
t.Errorf("an editor must not reshare a list")
|
||||
}
|
||||
|
||||
// Groups: only members may share with them
|
||||
team, _ := store.CreateTeam(org1.ID, "WG", "")
|
||||
_ = store.AddTeamMember(team.ID, anna.ID, "admin")
|
||||
_ = store.AddTeamMember(team.ID, ben.ID, "member")
|
||||
tl, _ := store.CreateTaskList(anna.ID, "", "", "Haushalt", "🏠", "")
|
||||
if _, err := store.AddTaskListShare(tl.ID, "team", team.ID, "editor", anna.ID); err != nil {
|
||||
t.Fatalf("group share failed: %v", err)
|
||||
}
|
||||
carlList, _ := store.CreateTaskList(carl.ID, "", "", "Fremd", "🔒", "")
|
||||
if _, err := store.AddTaskListShare(carlList.ID, "team", team.ID, "editor", carl.ID); !errors.Is(err, ErrTargetNotFound) {
|
||||
t.Errorf("non-member must not share with a group, got %v", err)
|
||||
}
|
||||
task, _ := store.CreateThoughtWithDetails(anna.ID, "Bad putzen", "normal", "manual", "", tl.ID, "todo", "")
|
||||
_, _ = store.UpdateThoughtTriage(anna.ID, task.ID, "keep", "task", "", "", tl.ID)
|
||||
if _, err := store.SetTaskAssignees(anna.ID, task.ID, []string{ben.ID}); err != nil {
|
||||
t.Fatalf("assign Ben failed: %v", err)
|
||||
}
|
||||
|
||||
// A dry run counts without changing anything
|
||||
n, err := store.PruneSharing(SharingPrivate, true)
|
||||
if err != nil || n == 0 {
|
||||
t.Fatalf("dry run should report affected rows, got %d (%v)", n, err)
|
||||
}
|
||||
if shares, _ := store.GetDocShares(doc.ID); len(shares) != 1 {
|
||||
t.Fatalf("dry run must not remove shares")
|
||||
}
|
||||
|
||||
// Ben moves to the other organization: he loses shares, group and assignment
|
||||
if err := store.SetUserOrganization(ben.ID, org2.ID); err != nil {
|
||||
t.Fatalf("SetUserOrganization failed: %v", err)
|
||||
}
|
||||
if shares, _ := store.GetDocShares(doc.ID); len(shares) != 0 {
|
||||
t.Errorf("doc share with Ben should be gone, got %+v", shares)
|
||||
}
|
||||
if l, _ := store.GetListByID(anna.ID, list.ID); len(l.Shares) != 0 {
|
||||
t.Errorf("list share with Ben should be gone, got %+v", l.Shares)
|
||||
}
|
||||
if members, _ := store.GetTeamMembers(team.ID); len(members) != 1 {
|
||||
t.Errorf("Ben should have left the group, members: %+v", members)
|
||||
}
|
||||
if th, _ := store.GetThoughtByID(anna.ID, task.ID); len(th.Assignees) != 0 {
|
||||
t.Errorf("Ben's assignment should be gone, got %v", th.Assignees)
|
||||
}
|
||||
|
||||
// Open mode: everyone sees everyone
|
||||
setSharingMode(t, store, SharingOpen)
|
||||
if !store.CanSee(anna.ID, carl.ID) || !store.CanSee(dora.ID, anna.ID) {
|
||||
t.Errorf("open mode should make everyone visible")
|
||||
}
|
||||
|
||||
// Private mode: no sharing at all, groups disappear
|
||||
if _, err := store.PruneSharing(SharingPrivate, false); err != nil {
|
||||
t.Fatalf("PruneSharing private failed: %v", err)
|
||||
}
|
||||
setSharingMode(t, store, SharingPrivate)
|
||||
if store.CanSee(anna.ID, ben.ID) || store.CanShareWith(anna.ID, "team", team.ID) {
|
||||
t.Errorf("private mode must not allow seeing or sharing")
|
||||
}
|
||||
if tls, _ := store.GetTaskListShares(tl.ID); len(tls) != 0 {
|
||||
t.Errorf("private mode should remove all task list shares, got %+v", tls)
|
||||
}
|
||||
if _, err := store.GetTeamByID(team.ID); err == nil {
|
||||
t.Errorf("groups should be gone in private mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutdatedSchemaIsWiped(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "old.db")
|
||||
store, err := NewStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
_, _ = store.CreateUser("old@example.com", "hash", "Alt", "member")
|
||||
_, _ = store.db.Exec(`PRAGMA user_version = 1`)
|
||||
_ = store.Close()
|
||||
|
||||
store, err = NewStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
if n, _ := store.CountUsers(); n != 0 {
|
||||
t.Fatalf("outdated database should start empty, found %d users", n)
|
||||
}
|
||||
var v int
|
||||
_ = store.db.QueryRow(`PRAGMA user_version`).Scan(&v)
|
||||
if v != schemaVersion {
|
||||
t.Fatalf("user_version = %d, want %d", v, schemaVersion)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user