feat: organizations decide who sees whom; sharing only by owners
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:
sandrodachandClaude Opus 5.5 committed 2026-10-28 22:00:33 +01:00
1 parent 73fad1c5db
commit 84395f3322
26 files changed
+1421 -609

No files matched your search

+100 -69
View File
@@ -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() {
}
}
}
+59 -7
View File
@@ -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
View File
@@ -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"`
+1
View File
@@ -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
View File
@@ -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
}
+25 -4
View File
@@ -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)
}
}
+7 -1
View File
@@ -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)
+289
View File
@@ -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
}
+170
View File
@@ -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)
}
}