refactor: split sqlite.go and server.go by domain (FLAW-20)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m31s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m31s
Pure moves, no behaviour change: the storage layer now has users, apikeys, thoughts, tasklists, documents, folders, tags, lists, teams and system files next to sqlite.go (store, schema, migrations); the API has handlers_*.go per area plus realtime.go and alarms.go next to server.go (server, router, middleware). Declarations were moved verbatim with an AST-based tool; imports pruned with goimports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7bba174454
commit
74c394efaa
26 files changed
+7702
-7467
No files matched your search
@@ -0,0 +1,75 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
// --- API KEYS & WEBHOOKS ---
|
||||
|
||||
func (s *Store) CreateAPIKey(userID, name string) (*domain.APIKey, error) {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
name = "Smarthome / Webhook Key"
|
||||
}
|
||||
rawKey, prefix, hash := auth.GenerateAPIKey()
|
||||
k := &domain.APIKey{
|
||||
ID: domain.NewID("key"),
|
||||
UserID: userID,
|
||||
Name: strings.TrimSpace(name),
|
||||
KeyPrefix: prefix,
|
||||
KeyHash: hash,
|
||||
RawKey: rawKey,
|
||||
Scopes: "ingest,read",
|
||||
CreatedAt: domain.NowISO(),
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO api_keys (id, user_id, name, key_prefix, key_hash, scopes, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
k.ID, k.UserID, k.Name, k.KeyPrefix, k.KeyHash, k.Scopes, k.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetAPIKeys(userID string) ([]domain.APIKey, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, user_id, name, key_prefix, scopes, last_used_at, created_at
|
||||
FROM api_keys WHERE user_id = ? ORDER BY created_at DESC`,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
keys := make([]domain.APIKey, 0)
|
||||
for rows.Next() {
|
||||
var k domain.APIKey
|
||||
if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyPrefix, &k.Scopes, &k.LastUsedAt, &k.CreatedAt); err == nil {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteAPIKey(userID, keyID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM api_keys WHERE id = ? AND user_id = ?`, keyID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ValidateAPIKeyAndGetUser(rawKey string) (*domain.User, error) {
|
||||
hash := auth.HashAPIKey(strings.TrimSpace(rawKey))
|
||||
var keyID, userID string
|
||||
err := s.db.QueryRow(`SELECT id, user_id FROM api_keys WHERE key_hash = ?`, hash).Scan(&keyID, &userID)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid API key")
|
||||
}
|
||||
_, _ = s.db.Exec(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, domain.NowISO(), keyID)
|
||||
return s.GetUserByID(userID)
|
||||
}
|
||||
@@ -0,0 +1,958 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
func (s *Store) loadDocBlocks(docID string, fallbackJSON string) []domain.DocumentBlock {
|
||||
if d, _, err := loadCollabDoc(s.db, docID); err == nil && len(d.Blocks) > 0 {
|
||||
blocks := flatDocumentBlocks(docID, d)
|
||||
if links := s.docTaskLinks(docID); len(links) > 0 {
|
||||
for i := range blocks {
|
||||
blocks[i].TaskID = links[blocks[i].ID]
|
||||
}
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
// Very old notes only stored blocks_json; convert them once
|
||||
blocks := make([]domain.DocumentBlock, 0)
|
||||
if fallbackJSON != "" && fallbackJSON != "[]" {
|
||||
_ = json.Unmarshal([]byte(fallbackJSON), &blocks)
|
||||
if len(blocks) > 0 {
|
||||
s.replaceDocBlocksInDB(docID, "", blocks, domain.NowISO())
|
||||
if d, _, err := loadCollabDoc(s.db, docID); err == nil {
|
||||
return flatDocumentBlocks(docID, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
// GetAuthorizedDocUsers returns all user IDs who have read/write/owner access to docID.
|
||||
func (s *Store) GetAuthorizedDocUsers(docID string) ([]string, error) {
|
||||
userSet := make(map[string]bool)
|
||||
|
||||
var ownerID, sharedFolderID string
|
||||
err := s.db.QueryRow(`SELECT user_id, shared_folder_id FROM documents WHERE id = ?`, docID).Scan(&ownerID, &sharedFolderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ownerID != "" {
|
||||
userSet[ownerID] = true
|
||||
}
|
||||
|
||||
// Direct user shares
|
||||
uRows, err := s.db.Query(`SELECT user_id FROM doc_shares WHERE doc_id = ?`, docID)
|
||||
if err == nil {
|
||||
for uRows.Next() {
|
||||
var uid string
|
||||
if err := uRows.Scan(&uid); err == nil && uid != "" {
|
||||
userSet[uid] = true
|
||||
}
|
||||
}
|
||||
_ = uRows.Close()
|
||||
}
|
||||
|
||||
// Team shares
|
||||
tRows, err := s.db.Query(`
|
||||
SELECT tm.user_id
|
||||
FROM doc_team_shares dts
|
||||
JOIN team_members tm ON tm.team_id = dts.team_id
|
||||
WHERE dts.doc_id = ?
|
||||
`, docID)
|
||||
if err == nil {
|
||||
for tRows.Next() {
|
||||
var uid string
|
||||
if err := tRows.Scan(&uid); err == nil && uid != "" {
|
||||
userSet[uid] = true
|
||||
}
|
||||
}
|
||||
_ = tRows.Close()
|
||||
}
|
||||
|
||||
// Shared folder access
|
||||
if sharedFolderID != "" {
|
||||
sfUsers, err := s.GetAuthorizedSharedFolderUsers(sharedFolderID)
|
||||
if err == nil {
|
||||
for _, uid := range sfUsers {
|
||||
userSet[uid] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
users := make([]string, 0, len(userSet))
|
||||
for uid := range userSet {
|
||||
users = append(users, uid)
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// GetAuthorizedSharedFolderUsers returns all user IDs who have access to a shared folder.
|
||||
func (s *Store) GetAuthorizedSharedFolderUsers(folderID string) ([]string, error) {
|
||||
userSet := make(map[string]bool)
|
||||
|
||||
var ownerID string
|
||||
if err := s.db.QueryRow(`SELECT owner_id FROM workspace_shared_folders WHERE id = ?`, folderID).Scan(&ownerID); err == nil && ownerID != "" {
|
||||
userSet[ownerID] = true
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(`
|
||||
SELECT target_type, target_id FROM workspace_folder_shares WHERE folder_id = ?
|
||||
`, folderID)
|
||||
if err == nil {
|
||||
type shareTarget struct{ tType, tID string }
|
||||
var targets []shareTarget
|
||||
for rows.Next() {
|
||||
var st shareTarget
|
||||
if err := rows.Scan(&st.tType, &st.tID); err == nil {
|
||||
targets = append(targets, st)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
for _, st := range targets {
|
||||
if st.tType == "user" && st.tID != "" {
|
||||
userSet[st.tID] = true
|
||||
} else if st.tType == "team" && st.tID != "" {
|
||||
mRows, err := s.db.Query(`SELECT user_id FROM team_members WHERE team_id = ?`, st.tID)
|
||||
if err == nil {
|
||||
for mRows.Next() {
|
||||
var uid string
|
||||
if err := mRows.Scan(&uid); err == nil && uid != "" {
|
||||
userSet[uid] = true
|
||||
}
|
||||
}
|
||||
_ = mRows.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
users := make([]string, 0, len(userSet))
|
||||
for uid := range userSet {
|
||||
users = append(users, uid)
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateDocument(userID, spaceID, title, icon, folderPath string, blocks []domain.DocumentBlock, tags []string, isPinned bool) (*domain.Document, error) {
|
||||
if strings.TrimSpace(title) == "" {
|
||||
title = "Unbenannte Notiz"
|
||||
}
|
||||
if icon == "" {
|
||||
icon = "📄"
|
||||
}
|
||||
if blocks == nil {
|
||||
blocks = []domain.DocumentBlock{
|
||||
{ID: domain.NewID("blk"), Type: "paragraph", Content: ""},
|
||||
}
|
||||
}
|
||||
tags = stripFolderTags(tags)
|
||||
|
||||
var plainBuilder strings.Builder
|
||||
for _, b := range blocks {
|
||||
if b.Content != "" {
|
||||
plainBuilder.WriteString(b.Content)
|
||||
plainBuilder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
blocksJSON, _ := json.Marshal(blocks)
|
||||
tagsJSON, _ := json.Marshal(tags)
|
||||
now := domain.NowISO()
|
||||
pinnedInt := 0
|
||||
if isPinned {
|
||||
pinnedInt = 1
|
||||
}
|
||||
|
||||
cleanFolder := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath)))
|
||||
if cleanFolder == "." {
|
||||
cleanFolder = ""
|
||||
}
|
||||
|
||||
mounts, _, _ := s.getUserFolderMounts(userID)
|
||||
var sharedFolderID, subFolderPath string
|
||||
if fID, sub, ok := matchMountPath(cleanFolder, mounts); ok {
|
||||
sharedFolderID = fID
|
||||
subFolderPath = sub
|
||||
}
|
||||
|
||||
doc := &domain.Document{
|
||||
ID: domain.NewID("doc"),
|
||||
UserID: userID,
|
||||
SpaceID: spaceID,
|
||||
Title: strings.TrimSpace(title),
|
||||
Icon: icon,
|
||||
Content: plainBuilder.String(),
|
||||
Blocks: blocks,
|
||||
Tags: tags,
|
||||
FolderPath: cleanFolder,
|
||||
IsPinned: isPinned,
|
||||
SharedFolderID: sharedFolderID,
|
||||
SubFolderPath: subFolderPath,
|
||||
IsSharedFolder: sharedFolderID != "",
|
||||
Permission: "owner",
|
||||
IsOwner: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO documents (id, user_id, space_id, title, icon, content, blocks_json, tags, folder_path, is_pinned, is_archived, created_at, updated_at, shared_folder_id, sub_folder_path)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`,
|
||||
doc.ID, doc.UserID, doc.SpaceID, doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), doc.FolderPath, pinnedInt, doc.CreatedAt, doc.UpdatedAt, doc.SharedFolderID, doc.SubFolderPath,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isPinned {
|
||||
_, _ = s.db.Exec(`INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at) VALUES (?, ?, ?)`, userID, doc.ID, doc.CreatedAt)
|
||||
}
|
||||
s.replaceDocBlocksInDB(doc.ID, userID, doc.Blocks, now)
|
||||
_ = s.indexDocBlockTags(doc.ID, doc.UserID, doc.Blocks)
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// getUserFolderMounts returns map[folder_id]mount_path and map[folder_id]folder_name for all shared folders accessible to userID
|
||||
func (s *Store) getUserFolderMounts(userID string) (map[string]string, map[string]string, error) {
|
||||
mounts := make(map[string]string)
|
||||
names := make(map[string]string)
|
||||
|
||||
rows, err := s.db.Query(`
|
||||
SELECT f.id, f.name, COALESCE(m.mount_path, f.name) as mount_path
|
||||
FROM workspace_shared_folders f
|
||||
LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ?
|
||||
LEFT JOIN workspace_folder_shares s ON s.folder_id = f.id AND (
|
||||
(s.target_type = 'user' AND s.target_id = ?) OR
|
||||
(s.target_type = 'team' AND s.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
)
|
||||
WHERE f.owner_id = ? OR s.id IS NOT NULL
|
||||
GROUP BY f.id
|
||||
`, userID, userID, userID, userID)
|
||||
if err != nil {
|
||||
return mounts, names, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var fID, fName, mPath string
|
||||
if err := rows.Scan(&fID, &fName, &mPath); err == nil {
|
||||
names[fID] = fName
|
||||
if mPath == "" {
|
||||
mPath = fName
|
||||
}
|
||||
mounts[fID] = mPath
|
||||
}
|
||||
}
|
||||
return mounts, names, nil
|
||||
}
|
||||
|
||||
// matchMountPath checks if folderPath falls within any mounted shared folder.
|
||||
// Returns (sharedFolderID, relativeSubPath, isMatch).
|
||||
func matchMountPath(folderPath string, mounts map[string]string) (string, string, bool) {
|
||||
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath)))
|
||||
if clean == "." {
|
||||
clean = ""
|
||||
}
|
||||
if clean == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
var bestFolderID string
|
||||
var bestSubPath string
|
||||
var longestMountLen int = -1
|
||||
|
||||
for fID, mPath := range mounts {
|
||||
cleanMount := filepath.ToSlash(filepath.Clean(strings.TrimSpace(mPath)))
|
||||
if cleanMount == "." {
|
||||
cleanMount = ""
|
||||
}
|
||||
if cleanMount == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if clean == cleanMount {
|
||||
if len(cleanMount) > longestMountLen {
|
||||
longestMountLen = len(cleanMount)
|
||||
bestFolderID = fID
|
||||
bestSubPath = ""
|
||||
}
|
||||
} else if strings.HasPrefix(clean, cleanMount+"/") {
|
||||
sub := strings.TrimPrefix(clean, cleanMount+"/")
|
||||
if len(cleanMount) > longestMountLen {
|
||||
longestMountLen = len(cleanMount)
|
||||
bestFolderID = fID
|
||||
bestSubPath = sub
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if longestMountLen >= 0 {
|
||||
return bestFolderID, bestSubPath, true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func resolveDocumentPath(userID string, doc *domain.Document, mounts map[string]string, names map[string]string, userFolders map[string]string) {
|
||||
if doc.SharedFolderID != "" {
|
||||
mountPath, ok := mounts[doc.SharedFolderID]
|
||||
if !ok || mountPath == "" {
|
||||
mountPath = names[doc.SharedFolderID]
|
||||
if mountPath == "" {
|
||||
mountPath = "Geteilter Ordner"
|
||||
}
|
||||
}
|
||||
doc.IsSharedFolder = true
|
||||
if doc.SubFolderPath != "" {
|
||||
doc.FolderPath = mountPath + "/" + doc.SubFolderPath
|
||||
} else {
|
||||
doc.FolderPath = mountPath
|
||||
}
|
||||
} else {
|
||||
if uf, ok := userFolders[doc.ID]; ok {
|
||||
doc.FolderPath = uf
|
||||
} else if !doc.IsOwner {
|
||||
doc.FolderPath = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func scanDocument(rows interface {
|
||||
Scan(dest ...any) error
|
||||
}) (*domain.Document, error) {
|
||||
d := &domain.Document{}
|
||||
var blocksStr, tagsStr string
|
||||
var isPinned, isArchived int
|
||||
err := rows.Scan(
|
||||
&d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content,
|
||||
&blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt,
|
||||
&d.SharedFolderID, &d.SubFolderPath,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.IsPinned = isPinned != 0
|
||||
d.IsArchived = isArchived != 0
|
||||
d.Blocks = []domain.DocumentBlock{}
|
||||
d.Tags = []string{}
|
||||
_ = json.Unmarshal([]byte(blocksStr), &d.Blocks)
|
||||
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
|
||||
d.Tags = stripFolderTags(d.Tags)
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (s *Store) scanDocumentWithBlocks(rows interface {
|
||||
Scan(dest ...any) error
|
||||
}) (*domain.Document, error) {
|
||||
d := &domain.Document{}
|
||||
var blocksStr, tagsStr string
|
||||
var isPinned, isArchived int
|
||||
err := rows.Scan(
|
||||
&d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content,
|
||||
&blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt,
|
||||
&d.SharedFolderID, &d.SubFolderPath,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.IsPinned = isPinned != 0
|
||||
d.IsArchived = isArchived != 0
|
||||
d.Tags = []string{}
|
||||
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
|
||||
d.Tags = stripFolderTags(d.Tags)
|
||||
d.Blocks = s.loadDocBlocks(d.ID, blocksStr)
|
||||
d.MarkdownCache = domain.CompileBlocksToMarkdown(d.Blocks)
|
||||
d.FolderID = d.FolderPath
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
|
||||
mounts, names, _ := s.getUserFolderMounts(userID)
|
||||
|
||||
userFolders := make(map[string]string)
|
||||
ufRows, err := s.db.Query(`SELECT doc_id, folder_path FROM doc_user_folders WHERE user_id = ?`, userID)
|
||||
if err == nil {
|
||||
for ufRows.Next() {
|
||||
var did, fp string
|
||||
if err := ufRows.Scan(&did, &fp); err == nil {
|
||||
userFolders[did] = fp
|
||||
}
|
||||
}
|
||||
_ = ufRows.Close()
|
||||
}
|
||||
|
||||
// 1. Fetch user's own documents (with per-user is_pinned from doc_pins)
|
||||
rows, err := s.db.Query(
|
||||
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
|
||||
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
|
||||
d.is_archived, d.created_at, d.updated_at,
|
||||
d.shared_folder_id, d.sub_folder_path
|
||||
FROM documents d
|
||||
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
|
||||
WHERE d.user_id = ? AND d.is_archived = 0
|
||||
ORDER BY is_pinned DESC, d.updated_at DESC`,
|
||||
userID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type rawDocRow struct {
|
||||
doc domain.Document
|
||||
blocksStr string
|
||||
}
|
||||
var ownRaw []rawDocRow
|
||||
for rows.Next() {
|
||||
d := domain.Document{}
|
||||
var blocksStr, tagsStr string
|
||||
var isPinned, isArchived int
|
||||
if err := rows.Scan(
|
||||
&d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content,
|
||||
&blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt,
|
||||
&d.SharedFolderID, &d.SubFolderPath,
|
||||
); err == nil {
|
||||
d.IsPinned = isPinned != 0
|
||||
d.IsArchived = isArchived != 0
|
||||
d.Tags = []string{}
|
||||
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
|
||||
d.Tags = stripFolderTags(d.Tags)
|
||||
d.Permission = "owner"
|
||||
d.IsOwner = true
|
||||
resolveDocumentPath(userID, &d, mounts, names, userFolders)
|
||||
ownRaw = append(ownRaw, rawDocRow{doc: d, blocksStr: blocksStr})
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
docs := make([]domain.Document, 0, len(ownRaw))
|
||||
for _, r := range ownRaw {
|
||||
r.doc.Blocks = s.loadDocBlocks(r.doc.ID, r.blocksStr)
|
||||
docs = append(docs, r.doc)
|
||||
}
|
||||
|
||||
// 2. Fetch documents shared with the user directly, via teams, or via shared folders
|
||||
sharedRows, err := s.db.Query(
|
||||
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
|
||||
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
|
||||
d.is_archived, d.created_at, d.updated_at,
|
||||
d.shared_folder_id, d.sub_folder_path,
|
||||
CASE
|
||||
WHEN wsf.owner_id = ? THEN 'owner'
|
||||
WHEN COALESCE(s.permission, '') = 'write' OR COALESCE(dts.permission, '') = 'write' OR COALESCE(wfs.permission, '') = 'write' THEN 'write'
|
||||
ELSE 'read'
|
||||
END AS permission,
|
||||
u.display_name
|
||||
FROM documents d
|
||||
JOIN users u ON d.user_id = u.id
|
||||
LEFT JOIN doc_shares s ON d.id = s.doc_id AND s.user_id = ?
|
||||
LEFT JOIN (
|
||||
SELECT dts2.doc_id, dts2.permission
|
||||
FROM doc_team_shares dts2
|
||||
JOIN team_members tm ON tm.team_id = dts2.team_id
|
||||
WHERE tm.user_id = ?
|
||||
) dts ON d.id = dts.doc_id
|
||||
LEFT JOIN workspace_shared_folders wsf ON d.shared_folder_id = wsf.id
|
||||
LEFT JOIN (
|
||||
SELECT wfs2.folder_id, wfs2.permission
|
||||
FROM workspace_folder_shares wfs2
|
||||
WHERE (wfs2.target_type = 'user' AND wfs2.target_id = ?)
|
||||
OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
) wfs ON d.shared_folder_id = wfs.folder_id
|
||||
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
|
||||
WHERE d.is_archived = 0 AND d.user_id != ? AND (
|
||||
s.user_id IS NOT NULL OR dts.doc_id IS NOT NULL OR wsf.owner_id = ? OR wfs.folder_id IS NOT NULL
|
||||
)
|
||||
GROUP BY d.id
|
||||
ORDER BY is_pinned DESC, d.updated_at DESC`,
|
||||
userID, userID, userID, userID, userID, userID, userID, userID,
|
||||
)
|
||||
if err == nil {
|
||||
var sharedRaw []rawDocRow
|
||||
for sharedRows.Next() {
|
||||
d := domain.Document{}
|
||||
var blocksStr, tagsStr string
|
||||
var isPinned, isArchived int
|
||||
var perm, ownerName string
|
||||
if scanErr := sharedRows.Scan(
|
||||
&d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content,
|
||||
&blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt,
|
||||
&d.SharedFolderID, &d.SubFolderPath,
|
||||
&perm, &ownerName,
|
||||
); scanErr == nil {
|
||||
d.IsPinned = isPinned != 0
|
||||
d.IsArchived = isArchived != 0
|
||||
d.Tags = []string{}
|
||||
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
|
||||
d.Tags = stripFolderTags(d.Tags)
|
||||
d.Permission = perm
|
||||
d.IsOwner = (perm == "owner")
|
||||
d.OwnerName = ownerName
|
||||
resolveDocumentPath(userID, &d, mounts, names, userFolders)
|
||||
sharedRaw = append(sharedRaw, rawDocRow{doc: d, blocksStr: blocksStr})
|
||||
}
|
||||
}
|
||||
_ = sharedRows.Close()
|
||||
|
||||
for _, r := range sharedRaw {
|
||||
r.doc.Blocks = s.loadDocBlocks(r.doc.ID, r.blocksStr)
|
||||
docs = append(docs, r.doc)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Attach shares to owned documents
|
||||
for i := range docs {
|
||||
if docs[i].IsOwner {
|
||||
if shares, err := s.GetDocShares(docs[i].ID); err == nil {
|
||||
docs[i].SharedWith = shares
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return docs, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error) {
|
||||
mounts, names, _ := s.getUserFolderMounts(userID)
|
||||
userFolders := make(map[string]string)
|
||||
var personalFp string
|
||||
if err := s.db.QueryRow(`SELECT folder_path FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID).Scan(&personalFp); err == nil {
|
||||
userFolders[docID] = personalFp
|
||||
}
|
||||
|
||||
// Try owned document first (with per-user is_pinned)
|
||||
row := s.db.QueryRow(
|
||||
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
|
||||
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
|
||||
d.is_archived, d.created_at, d.updated_at,
|
||||
d.shared_folder_id, d.sub_folder_path
|
||||
FROM documents d
|
||||
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
|
||||
WHERE d.id = ? AND (d.user_id = ? OR (d.is_archived = 1 AND d.archived_by = ?))`,
|
||||
userID, docID, userID, userID,
|
||||
)
|
||||
doc, err := s.scanDocumentWithBlocks(row)
|
||||
if err == nil {
|
||||
doc.Permission = "owner"
|
||||
doc.IsOwner = doc.UserID == userID
|
||||
if doc.IsArchived {
|
||||
doc.Permission = "read" // read-only while in the trash
|
||||
}
|
||||
resolveDocumentPath(userID, doc, mounts, names, userFolders)
|
||||
if shares, err := s.GetDocShares(doc.ID); err == nil {
|
||||
doc.SharedWith = shares
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// Try shared document (direct, via team, or via shared folder)
|
||||
sharedRow := s.db.QueryRow(
|
||||
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
|
||||
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
|
||||
d.is_archived, d.created_at, d.updated_at,
|
||||
d.shared_folder_id, d.sub_folder_path,
|
||||
CASE
|
||||
WHEN wsf.owner_id = ? THEN 'owner'
|
||||
WHEN COALESCE(s.permission, '') = 'write' OR COALESCE(dts.permission, '') = 'write' OR COALESCE(wfs.permission, '') = 'write' THEN 'write'
|
||||
ELSE 'read'
|
||||
END AS permission,
|
||||
u.display_name
|
||||
FROM documents d
|
||||
JOIN users u ON d.user_id = u.id
|
||||
LEFT JOIN doc_shares s ON d.id = s.doc_id AND s.user_id = ?
|
||||
LEFT JOIN (
|
||||
SELECT dts2.doc_id, dts2.permission
|
||||
FROM doc_team_shares dts2
|
||||
JOIN team_members tm ON tm.team_id = dts2.team_id
|
||||
WHERE tm.user_id = ?
|
||||
) dts ON d.id = dts.doc_id
|
||||
LEFT JOIN workspace_shared_folders wsf ON d.shared_folder_id = wsf.id
|
||||
LEFT JOIN (
|
||||
SELECT wfs2.folder_id, wfs2.permission
|
||||
FROM workspace_folder_shares wfs2
|
||||
WHERE (wfs2.target_type = 'user' AND wfs2.target_id = ?)
|
||||
OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
) wfs ON d.shared_folder_id = wfs.folder_id
|
||||
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
|
||||
WHERE d.id = ? AND d.is_archived = 0 AND (s.user_id = ? OR dts.doc_id IS NOT NULL OR wsf.owner_id = ? OR wfs.folder_id IS NOT NULL)
|
||||
LIMIT 1`,
|
||||
userID, userID, userID, userID, userID, userID, docID, userID, userID,
|
||||
)
|
||||
d := &domain.Document{}
|
||||
var blocksStr, tagsStr string
|
||||
var isPinned, isArchived int
|
||||
var perm, ownerName string
|
||||
err = sharedRow.Scan(
|
||||
&d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content,
|
||||
&blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt,
|
||||
&d.SharedFolderID, &d.SubFolderPath,
|
||||
&perm, &ownerName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.IsPinned = isPinned != 0
|
||||
d.IsArchived = isArchived != 0
|
||||
d.Tags = []string{}
|
||||
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
|
||||
d.Tags = stripFolderTags(d.Tags)
|
||||
d.Blocks = s.loadDocBlocks(d.ID, blocksStr)
|
||||
d.Permission = perm
|
||||
d.IsOwner = (perm == "owner")
|
||||
d.OwnerName = ownerName
|
||||
resolveDocumentPath(userID, d, mounts, names, userFolders)
|
||||
if shares, err := s.GetDocShares(d.ID); err == nil {
|
||||
d.SharedWith = shares
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *string, isPinned *bool) (*domain.Document, error) {
|
||||
doc, err := s.GetDocumentByID(userID, docID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if doc.IsArchived {
|
||||
return nil, ErrDocInTrash
|
||||
}
|
||||
|
||||
// Pins and folder placement are personal; title and icon need edit rights. The content (blocks) is
|
||||
// only changed through the operation log (POST /docs/{id}/sync).
|
||||
changesContent := title != nil || icon != nil
|
||||
if changesContent && !CanEditDoc(s.DocRole(userID, docID)) {
|
||||
return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)")
|
||||
}
|
||||
|
||||
if isPinned != nil {
|
||||
doc.IsPinned = *isPinned
|
||||
if *isPinned {
|
||||
_, _ = s.db.Exec(`INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at) VALUES (?, ?, ?)`, userID, docID, domain.NowISO())
|
||||
if doc.IsOwner {
|
||||
_, _ = s.db.Exec(`UPDATE documents SET is_pinned = 1 WHERE id = ?`, docID)
|
||||
}
|
||||
} else {
|
||||
_, _ = s.db.Exec(`DELETE FROM doc_pins WHERE user_id = ? AND doc_id = ?`, userID, docID)
|
||||
if doc.IsOwner {
|
||||
_, _ = s.db.Exec(`UPDATE documents SET is_pinned = 0 WHERE id = ?`, docID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if folderPath != nil {
|
||||
movedDoc, moveErr := s.MoveDocument(userID, docID, *folderPath)
|
||||
if moveErr != nil {
|
||||
return nil, moveErr
|
||||
}
|
||||
doc.FolderPath = movedDoc.FolderPath
|
||||
doc.SharedFolderID = movedDoc.SharedFolderID
|
||||
doc.SubFolderPath = movedDoc.SubFolderPath
|
||||
doc.IsSharedFolder = movedDoc.IsSharedFolder
|
||||
}
|
||||
|
||||
if !changesContent {
|
||||
return doc, nil
|
||||
}
|
||||
if title != nil {
|
||||
doc.Title = strings.TrimSpace(*title)
|
||||
s.applyLegacyTitle(docID, userID, doc.Title)
|
||||
}
|
||||
if icon != nil {
|
||||
doc.Icon = *icon
|
||||
}
|
||||
doc.UpdatedAt = domain.NowISO()
|
||||
if _, err := s.db.Exec(`UPDATE documents SET icon = ?, updated_at = ? WHERE id = ?`, doc.Icon, doc.UpdatedAt, docID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetDocumentByID(userID, docID)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteDocument(userID, docID string) error {
|
||||
doc, err := s.GetDocumentByID(userID, docID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !doc.IsOwner {
|
||||
// Collaborator removing their shared access
|
||||
_, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID)
|
||||
return s.DeleteDocShare(userID, docID, userID)
|
||||
}
|
||||
if doc.IsArchived {
|
||||
return s.hardDeleteDocument(docID)
|
||||
}
|
||||
return s.trashDocument(userID, docID)
|
||||
}
|
||||
|
||||
func (s *Store) reindexAllDocBlockTags() error {
|
||||
rows, err := s.db.Query(`SELECT id, user_id, blocks_json FROM documents WHERE is_archived = 0`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type docInfo struct {
|
||||
id, userID, blocksJSON string
|
||||
}
|
||||
var docs []docInfo
|
||||
for rows.Next() {
|
||||
var d docInfo
|
||||
if err := rows.Scan(&d.id, &d.userID, &d.blocksJSON); err == nil {
|
||||
docs = append(docs, d)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
for _, d := range docs {
|
||||
blocks := s.loadDocBlocks(d.id, d.blocksJSON)
|
||||
if len(blocks) > 0 {
|
||||
_ = s.indexDocBlockTags(d.id, d.userID, blocks)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) indexDocBlockTags(docID, _ string, blocks []domain.DocumentBlock) error {
|
||||
_, _ = s.db.Exec(`DELETE FROM block_tags WHERE document_id = ?`, docID)
|
||||
if len(blocks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
stmt, err := s.db.Prepare(`INSERT OR IGNORE INTO block_tags (tag, document_id, block_id) VALUES (?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, b := range blocks {
|
||||
if b.Content == "" {
|
||||
continue
|
||||
}
|
||||
tags := domain.ExtractTags(b.Content)
|
||||
for _, tag := range tags {
|
||||
_, _ = stmt.Exec(tag, docID, b.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetDocShares(docID string) ([]domain.DocShare, error) {
|
||||
shares := make([]domain.DocShare, 0)
|
||||
|
||||
// 1. Direct user shares
|
||||
uRows, err := s.db.Query(
|
||||
`SELECT s.id, s.doc_id, s.user_id, u.email, u.display_name, s.permission, s.created_by, s.created_at
|
||||
FROM doc_shares s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.doc_id = ?
|
||||
ORDER BY s.created_at ASC`,
|
||||
docID,
|
||||
)
|
||||
if err == nil {
|
||||
for uRows.Next() {
|
||||
var sh domain.DocShare
|
||||
if err := uRows.Scan(&sh.ID, &sh.DocID, &sh.UserID, &sh.UserEmail, &sh.DisplayName, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil {
|
||||
sh.TargetType = "user"
|
||||
sh.TargetID = sh.UserID
|
||||
sh.TargetName = sh.DisplayName
|
||||
shares = append(shares, sh)
|
||||
}
|
||||
}
|
||||
_ = uRows.Close()
|
||||
}
|
||||
|
||||
// 2. Team shares
|
||||
tRows, err := s.db.Query(
|
||||
`SELECT s.id, s.doc_id, s.team_id, t.name, s.permission, s.created_by, s.created_at
|
||||
FROM doc_team_shares s
|
||||
JOIN teams t ON s.team_id = t.id
|
||||
WHERE s.doc_id = ?
|
||||
ORDER BY s.created_at ASC`,
|
||||
docID,
|
||||
)
|
||||
if err == nil {
|
||||
for tRows.Next() {
|
||||
var sh domain.DocShare
|
||||
var teamID, teamName string
|
||||
if err := tRows.Scan(&sh.ID, &sh.DocID, &teamID, &teamName, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil {
|
||||
sh.TargetType = "team"
|
||||
sh.TargetID = teamID
|
||||
sh.TargetName = teamName
|
||||
sh.DisplayName = teamName
|
||||
shares = append(shares, sh)
|
||||
}
|
||||
}
|
||||
_ = tRows.Close()
|
||||
}
|
||||
|
||||
return shares, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateDocTeamShare(currentUserID, docID, teamID, permission string) (*domain.DocShare, error) {
|
||||
doc, err := s.GetDocumentByID(currentUserID, docID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !doc.IsOwner {
|
||||
return nil, fmt.Errorf("nur der Eigentümer kann Freigaben verwalten")
|
||||
}
|
||||
|
||||
cleanTeamID := strings.TrimSpace(teamID)
|
||||
var teamName string
|
||||
err = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, cleanTeamID).Scan(&teamName)
|
||||
if err != nil || !s.CanShareWith(currentUserID, "team", cleanTeamID) {
|
||||
return nil, ErrTargetNotFound
|
||||
}
|
||||
|
||||
permission = normalizeSharePermission(permission)
|
||||
|
||||
now := domain.NowISO()
|
||||
shareID := domain.NewID("share")
|
||||
_, err = s.db.Exec(
|
||||
`INSERT INTO doc_team_shares (id, doc_id, team_id, permission, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(doc_id, team_id) DO UPDATE SET permission = excluded.permission`,
|
||||
shareID, docID, cleanTeamID, permission, now, currentUserID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &domain.DocShare{
|
||||
ID: shareID,
|
||||
DocID: docID,
|
||||
TargetType: "team",
|
||||
TargetID: cleanTeamID,
|
||||
TargetName: teamName,
|
||||
DisplayName: teamName,
|
||||
Permission: permission,
|
||||
CreatedBy: currentUserID,
|
||||
CreatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// normalizeSharePermission maps share roles from the API to the stored values: "write" (editor) or "read" (viewer).
|
||||
func normalizeSharePermission(p string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(p)) {
|
||||
case "write", "editor":
|
||||
return "write"
|
||||
}
|
||||
return "read"
|
||||
}
|
||||
|
||||
func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permission string) (*domain.DocShare, error) {
|
||||
doc, err := s.GetDocumentByID(currentUserID, docID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if doc.IsArchived {
|
||||
return nil, ErrDocInTrash
|
||||
}
|
||||
if !doc.IsOwner {
|
||||
return nil, fmt.Errorf("nur der Eigentümer kann Freigaben verwalten")
|
||||
}
|
||||
|
||||
cleanTarget := strings.TrimSpace(targetUserIdentifier)
|
||||
|
||||
// If identifier looks like a team or is prefixed with team_, route to CreateDocTeamShare
|
||||
if strings.HasPrefix(cleanTarget, "team_") {
|
||||
return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, permission)
|
||||
}
|
||||
|
||||
permission = normalizeSharePermission(permission)
|
||||
|
||||
// Resolve target user by ID, username, or Email
|
||||
var targetUser *domain.User
|
||||
if strings.HasPrefix(cleanTarget, "usr_") {
|
||||
targetUser, _ = s.GetUserByID(cleanTarget)
|
||||
}
|
||||
if targetUser == nil {
|
||||
targetUser, _ = s.GetUserByLogin(cleanTarget)
|
||||
}
|
||||
if targetUser == nil {
|
||||
targetUser, err = s.GetUserByID(cleanTarget)
|
||||
}
|
||||
if err != nil || targetUser == nil {
|
||||
// Check if it's a team ID that doesn't start with team_
|
||||
var tName string
|
||||
if s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, cleanTarget).Scan(&tName) == nil {
|
||||
return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, permission)
|
||||
}
|
||||
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")
|
||||
_, err = s.db.Exec(
|
||||
`INSERT INTO doc_shares (id, doc_id, user_id, permission, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(doc_id, user_id) DO UPDATE SET permission = excluded.permission`,
|
||||
shareID, docID, targetUser.ID, permission, now, currentUserID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &domain.DocShare{
|
||||
ID: shareID,
|
||||
DocID: docID,
|
||||
TargetType: "user",
|
||||
TargetID: targetUser.ID,
|
||||
TargetName: targetUser.DisplayName,
|
||||
UserID: targetUser.ID,
|
||||
UserEmail: targetUser.Email,
|
||||
DisplayName: targetUser.DisplayName,
|
||||
Permission: permission,
|
||||
CreatedBy: currentUserID,
|
||||
CreatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteDocShare(currentUserID, docID, targetID string) error {
|
||||
doc, err := s.GetDocumentByID(currentUserID, docID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 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")
|
||||
}
|
||||
|
||||
cleanTarget := strings.TrimSpace(targetID)
|
||||
// Try deleting team share first
|
||||
res, err := s.db.Exec(`DELETE FROM doc_team_shares WHERE doc_id = ? AND (id = ? OR team_id = ?)`, docID, cleanTarget, cleanTarget)
|
||||
if err == nil {
|
||||
if rows, _ := res.RowsAffected(); rows > 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to user share
|
||||
_, err = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ? AND (id = ? OR user_id = ?)`, docID, cleanTarget, cleanTarget)
|
||||
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) {
|
||||
visible, err := s.GetDiscoverableUsers(currentUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users := make([]domain.User, 0, len(visible))
|
||||
for _, u := range visible {
|
||||
if u.ID != currentUserID && u.IsActive {
|
||||
users = append(users, u)
|
||||
}
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
@@ -0,0 +1,862 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
func (s *Store) GetFolders(userID string) ([]string, error) {
|
||||
folderSet := make(map[string]bool)
|
||||
|
||||
// 1. Collect from resolved documents for this user
|
||||
docs, err := s.GetDocuments(userID)
|
||||
if err == nil {
|
||||
for _, doc := range docs {
|
||||
if doc.FolderPath != "" {
|
||||
addFolderSegments(doc.FolderPath, folderSet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Add mounted shared folders (so even empty shared folders appear in the tree)
|
||||
mounts, _, err := s.getUserFolderMounts(userID)
|
||||
if err == nil {
|
||||
for sfID, mPath := range mounts {
|
||||
if mPath != "" {
|
||||
addFolderSegments(mPath, folderSet)
|
||||
|
||||
// 2b. Add all subfolders created in this shared folder
|
||||
sRows, err := s.db.Query(`SELECT sub_path FROM workspace_folders WHERE shared_folder_id = ?`, sfID)
|
||||
if err == nil {
|
||||
for sRows.Next() {
|
||||
var sp string
|
||||
if err := sRows.Scan(&sp); err == nil && sp != "" {
|
||||
fullP := mPath + "/" + sp
|
||||
addFolderSegments(fullP, folderSet)
|
||||
}
|
||||
}
|
||||
_ = sRows.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Add personal empty folders created by this user
|
||||
pRows, err := s.db.Query(`SELECT path FROM workspace_folders WHERE user_id = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL)`, userID)
|
||||
if err == nil {
|
||||
for pRows.Next() {
|
||||
var p string
|
||||
if err := pRows.Scan(&p); err == nil && p != "" {
|
||||
addFolderSegments(p, folderSet)
|
||||
}
|
||||
}
|
||||
_ = pRows.Close()
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(folderSet))
|
||||
for f := range folderSet {
|
||||
result = append(result, f)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func addFolderSegments(fp string, set map[string]bool) {
|
||||
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(fp)))
|
||||
if clean == "." || clean == "" {
|
||||
return
|
||||
}
|
||||
parts := strings.Split(clean, "/")
|
||||
var cur string
|
||||
for _, p := range parts {
|
||||
if p != "" && p != "." {
|
||||
if cur == "" {
|
||||
cur = p
|
||||
} else {
|
||||
cur = cur + "/" + p
|
||||
}
|
||||
set[cur] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) CreateFolder(userID, folderPath string) (string, error) {
|
||||
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath)))
|
||||
if clean == "" || clean == "." || strings.HasPrefix(clean, "/") || strings.Contains(clean, "..") {
|
||||
return "", fmt.Errorf("ungültiger Ordnerpfad")
|
||||
}
|
||||
|
||||
mounts, _, err := s.getUserFolderMounts(userID)
|
||||
var sfID, subPath string
|
||||
if err == nil {
|
||||
sfID, subPath, _ = matchMountPath(clean, mounts)
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
id := domain.NewID("wfld")
|
||||
|
||||
if sfID != "" {
|
||||
var existsID string
|
||||
err := s.db.QueryRow(`SELECT id FROM workspace_folders WHERE shared_folder_id = ? AND sub_path = ?`, sfID, subPath).Scan(&existsID)
|
||||
if err == sql.ErrNoRows {
|
||||
_, _ = s.db.Exec(`INSERT INTO workspace_folders (id, user_id, shared_folder_id, path, sub_path, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
id, userID, sfID, clean, subPath, now)
|
||||
}
|
||||
} else {
|
||||
var existsID string
|
||||
err := s.db.QueryRow(`SELECT id FROM workspace_folders WHERE user_id = ? AND path = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL)`, userID, clean).Scan(&existsID)
|
||||
if err == sql.ErrNoRows {
|
||||
_, _ = s.db.Exec(`INSERT INTO workspace_folders (id, user_id, shared_folder_id, path, sub_path, created_at) VALUES (?, ?, '', ?, '', ?)`,
|
||||
id, userID, clean, now)
|
||||
}
|
||||
}
|
||||
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) {
|
||||
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath)))
|
||||
if clean == "" || clean == "." || strings.HasPrefix(clean, "/") || strings.Contains(clean, "..") {
|
||||
return nil, fmt.Errorf("ungültiger Ordnerpfad")
|
||||
}
|
||||
|
||||
var deletedDocIDs []string
|
||||
seenDocID := make(map[string]bool)
|
||||
|
||||
mounts, _, err := s.getUserFolderMounts(userID)
|
||||
if err == nil {
|
||||
sfID, subPath, isMatch := matchMountPath(clean, mounts)
|
||||
if isMatch && sfID != "" {
|
||||
if subPath == "" {
|
||||
// Root of a shared folder
|
||||
shFolder, sfErr := s.GetSharedFolderByID(userID, sfID)
|
||||
if sfErr == nil && !shFolder.IsOwner {
|
||||
// Collaborator removing mount/share
|
||||
_ = s.DeleteSharedFolder(userID, sfID)
|
||||
return []string{}, nil
|
||||
}
|
||||
rows, qErr := s.db.Query(`SELECT id FROM documents WHERE shared_folder_id = ?`, sfID)
|
||||
if qErr == nil {
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err == nil && !seenDocID[id] {
|
||||
seenDocID[id] = true
|
||||
deletedDocIDs = append(deletedDocIDs, id)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
}
|
||||
_, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ?`, sfID)
|
||||
} else {
|
||||
// Subfolder inside a shared folder
|
||||
rows, qErr := s.db.Query(
|
||||
`SELECT id FROM documents WHERE shared_folder_id = ? AND (sub_folder_path = ? OR sub_folder_path LIKE ?)`,
|
||||
sfID, subPath, subPath+"/%",
|
||||
)
|
||||
if qErr == nil {
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err == nil && !seenDocID[id] {
|
||||
seenDocID[id] = true
|
||||
deletedDocIDs = append(deletedDocIDs, id)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
}
|
||||
_, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ? AND (sub_path = ? OR sub_path LIKE ?)`,
|
||||
sfID, subPath, subPath+"/%")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find all personal documents owned by this user in this folder and subfolders
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id FROM documents
|
||||
WHERE user_id = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL) AND (folder_path = ? OR folder_path LIKE ?)`,
|
||||
userID, clean, clean+"/%",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err == nil && !seenDocID[id] {
|
||||
seenDocID[id] = true
|
||||
deletedDocIDs = append(deletedDocIDs, id)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
// The notes of the folder go to the trash; restoring one creates its folder again
|
||||
for _, id := range deletedDocIDs {
|
||||
_ = s.trashDocument(userID, id)
|
||||
}
|
||||
|
||||
// Also clear any personal folder placements for shared notes in this folder
|
||||
_, _ = s.db.Exec(`UPDATE doc_user_folders SET folder_path = '' WHERE user_id = ? AND (folder_path = ? OR folder_path LIKE ?)`, userID, clean, clean+"/%")
|
||||
_, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`, userID, clean, clean+"/%")
|
||||
|
||||
return deletedDocIDs, nil
|
||||
}
|
||||
|
||||
func (s *Store) MoveDocument(userID, docID, targetFolderPath string) (*domain.Document, error) {
|
||||
doc, err := s.GetDocumentByID(userID, docID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if doc.IsArchived {
|
||||
return nil, ErrDocInTrash
|
||||
}
|
||||
|
||||
cleanTarget := filepath.ToSlash(filepath.Clean(strings.TrimSpace(targetFolderPath)))
|
||||
if cleanTarget == "." {
|
||||
cleanTarget = ""
|
||||
}
|
||||
|
||||
mounts, _, err := s.getUserFolderMounts(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := domain.NowISO()
|
||||
|
||||
// Check if target folder falls within any mounted shared folder
|
||||
targetFolderID, subPath, isTargetShared := matchMountPath(cleanTarget, mounts)
|
||||
|
||||
if isTargetShared {
|
||||
// Moving into / within a shared folder
|
||||
shFolder, err := s.GetSharedFolderByID(userID, targetFolderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !shFolder.IsOwner {
|
||||
return nil, fmt.Errorf("nur der Eigentümer dieses geteilten Ordners kann Dateien dorthin verschieben")
|
||||
}
|
||||
|
||||
// Update document to belong to targetSharedFolderID
|
||||
_, err = s.db.Exec(`UPDATE documents SET shared_folder_id = ?, sub_folder_path = ?, updated_at = ? WHERE id = ?`,
|
||||
targetFolderID, subPath, now, docID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Remove personal override if any
|
||||
_, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID)
|
||||
|
||||
doc.SharedFolderID = targetFolderID
|
||||
doc.SubFolderPath = subPath
|
||||
doc.IsSharedFolder = true
|
||||
doc.FolderPath = cleanTarget
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// Target is NOT inside a shared folder (personal / standalone)
|
||||
if doc.SharedFolderID != "" {
|
||||
// Moving OUT of a shared folder into a personal folder
|
||||
if !doc.IsOwner {
|
||||
return nil, fmt.Errorf("keine Berechtigung, die Notiz aus dem geteilten Ordner zu verschieben")
|
||||
}
|
||||
_, err = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '', folder_path = ?, updated_at = ? WHERE id = ?`,
|
||||
cleanTarget, now, docID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc.SharedFolderID = ""
|
||||
doc.SubFolderPath = ""
|
||||
doc.IsSharedFolder = false
|
||||
doc.FolderPath = cleanTarget
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// Standalone note
|
||||
if doc.IsOwner {
|
||||
// Owner moves their own document -> updates documents.folder_path
|
||||
_, err = s.db.Exec(`UPDATE documents SET folder_path = ?, updated_at = ? WHERE id = ?`, cleanTarget, now, docID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc.FolderPath = cleanTarget
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// Recipient moves an individually shared note -> updates doc_user_folders ONLY!
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO doc_user_folders (user_id, doc_id, folder_path, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, doc_id) DO UPDATE SET folder_path = excluded.folder_path, updated_at = excluded.updated_at
|
||||
`, userID, docID, cleanTarget, now, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
doc.FolderPath = cleanTarget
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// MoveFolder moves a folder (and all its documents/subfolders) to a new target parent path.
|
||||
func (s *Store) MoveFolder(userID, sourcePath, targetParentPath string) (string, []string, error) {
|
||||
cleanSource := filepath.ToSlash(filepath.Clean(strings.TrimSpace(sourcePath)))
|
||||
if cleanSource == "" || cleanSource == "." || strings.HasPrefix(cleanSource, "/") || strings.Contains(cleanSource, "..") {
|
||||
return "", nil, fmt.Errorf("ungültiger Quell-Ordnerpfad")
|
||||
}
|
||||
cleanTarget := filepath.ToSlash(filepath.Clean(strings.TrimSpace(targetParentPath)))
|
||||
if cleanTarget == "." {
|
||||
cleanTarget = ""
|
||||
}
|
||||
if cleanTarget != "" && (cleanTarget == cleanSource || strings.HasPrefix(cleanTarget, cleanSource+"/")) {
|
||||
return "", nil, fmt.Errorf("ein Ordner kann nicht in sich selbst verschoben werden")
|
||||
}
|
||||
|
||||
folderName := filepath.Base(cleanSource)
|
||||
var newFolderPath string
|
||||
if cleanTarget == "" {
|
||||
newFolderPath = folderName
|
||||
} else {
|
||||
newFolderPath = cleanTarget + "/" + folderName
|
||||
}
|
||||
|
||||
if newFolderPath == cleanSource {
|
||||
return cleanSource, []string{}, nil
|
||||
}
|
||||
|
||||
// 1. Check if sourcePath is the root mount of a shared folder
|
||||
mounts, _, err := s.getUserFolderMounts(userID)
|
||||
if err == nil {
|
||||
for sfID, mPath := range mounts {
|
||||
if mPath == cleanSource {
|
||||
err := s.UpdateFolderMount(userID, sfID, newFolderPath)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return newFolderPath, []string{}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Move all affected documents
|
||||
docs, err := s.GetDocuments(userID)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
var movedDocIDs []string
|
||||
for _, doc := range docs {
|
||||
if doc.FolderPath == cleanSource || strings.HasPrefix(doc.FolderPath, cleanSource+"/") {
|
||||
var nextDocFolder string
|
||||
if doc.FolderPath == cleanSource {
|
||||
nextDocFolder = newFolderPath
|
||||
} else {
|
||||
suffix := strings.TrimPrefix(doc.FolderPath, cleanSource)
|
||||
nextDocFolder = newFolderPath + suffix
|
||||
}
|
||||
_, moveErr := s.MoveDocument(userID, doc.ID, nextDocFolder)
|
||||
if moveErr == nil {
|
||||
movedDocIDs = append(movedDocIDs, doc.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Re-map workspace_folders entries
|
||||
targetSFID, targetSub, isTargetSF := matchMountPath(newFolderPath, mounts)
|
||||
sourceSFID, sourceSub, isSourceSF := matchMountPath(cleanSource, mounts)
|
||||
|
||||
wfRows, qErr := s.db.Query(`SELECT id, user_id, shared_folder_id, path, sub_path FROM workspace_folders`)
|
||||
if qErr == nil {
|
||||
type wfRecord struct {
|
||||
id, uid, sfID, path, subPath string
|
||||
}
|
||||
var records []wfRecord
|
||||
for wfRows.Next() {
|
||||
var r wfRecord
|
||||
if err := wfRows.Scan(&r.id, &r.uid, &r.sfID, &r.path, &r.subPath); err == nil {
|
||||
records = append(records, r)
|
||||
}
|
||||
}
|
||||
_ = wfRows.Close()
|
||||
|
||||
for _, r := range records {
|
||||
if isSourceSF && r.sfID == sourceSFID {
|
||||
matches := false
|
||||
var suffix string
|
||||
if sourceSub == "" {
|
||||
matches = true
|
||||
suffix = "/" + r.subPath
|
||||
} else if r.subPath == sourceSub {
|
||||
matches = true
|
||||
suffix = ""
|
||||
} else if strings.HasPrefix(r.subPath, sourceSub+"/") {
|
||||
matches = true
|
||||
suffix = strings.TrimPrefix(r.subPath, sourceSub)
|
||||
}
|
||||
if matches {
|
||||
var newSub string
|
||||
if targetSub == "" {
|
||||
newSub = strings.TrimPrefix(folderName+suffix, "/")
|
||||
} else {
|
||||
newSub = targetSub + "/" + folderName + suffix
|
||||
}
|
||||
if isTargetSF {
|
||||
_, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ? WHERE id = ?`, targetSFID, newSub, r.id)
|
||||
} else {
|
||||
newP := newFolderPath + suffix
|
||||
_, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = '', sub_path = '', path = ? WHERE id = ?`, newP, r.id)
|
||||
}
|
||||
}
|
||||
} else if r.path == cleanSource || strings.HasPrefix(r.path, cleanSource+"/") {
|
||||
suffix := strings.TrimPrefix(r.path, cleanSource)
|
||||
newP := newFolderPath + suffix
|
||||
if isTargetSF {
|
||||
var newSub string
|
||||
if targetSub == "" {
|
||||
newSub = strings.TrimPrefix(folderName+suffix, "/")
|
||||
} else {
|
||||
newSub = targetSub + "/" + folderName + suffix
|
||||
}
|
||||
_, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ?, path = ? WHERE id = ?`, targetSFID, newSub, newP, r.id)
|
||||
} else {
|
||||
_, _ = s.db.Exec(`UPDATE workspace_folders SET path = ? WHERE id = ?`, newP, r.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = s.CreateFolder(userID, newFolderPath)
|
||||
|
||||
return newFolderPath, movedDocIDs, nil
|
||||
}
|
||||
|
||||
// syncSharedFolderContents ensures all documents and subfolders under cleanMount owned by ownerID are linked to folderID.
|
||||
func (s *Store) syncSharedFolderContents(ownerID, folderID, cleanMount string) {
|
||||
if ownerID == "" || folderID == "" || cleanMount == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Link all documents owned by ownerID in cleanMount to this shared folder
|
||||
rows, err := s.db.Query(`SELECT id, folder_path FROM documents WHERE user_id = ? AND is_archived = 0 AND (folder_path = ? OR folder_path LIKE ?)`,
|
||||
ownerID, cleanMount, cleanMount+"/%")
|
||||
if err == nil {
|
||||
type docEntry struct{ id, fp string }
|
||||
var entries []docEntry
|
||||
for rows.Next() {
|
||||
var de docEntry
|
||||
if err := rows.Scan(&de.id, &de.fp); err == nil {
|
||||
entries = append(entries, de)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
for _, de := range entries {
|
||||
var subPath string
|
||||
if de.fp == cleanMount {
|
||||
subPath = ""
|
||||
} else if strings.HasPrefix(de.fp, cleanMount+"/") {
|
||||
subPath = strings.TrimPrefix(de.fp, cleanMount+"/")
|
||||
}
|
||||
_, _ = s.db.Exec(`UPDATE documents SET shared_folder_id = ?, sub_folder_path = ? WHERE id = ?`,
|
||||
folderID, subPath, de.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Link any workspace_folders created by ownerID in cleanMount
|
||||
fRows, err := s.db.Query(`SELECT id, path FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`,
|
||||
ownerID, cleanMount, cleanMount+"/%")
|
||||
if err == nil {
|
||||
type wfEntry struct{ id, path string }
|
||||
var wfEntries []wfEntry
|
||||
for fRows.Next() {
|
||||
var wfe wfEntry
|
||||
if err := fRows.Scan(&wfe.id, &wfe.path); err == nil {
|
||||
wfEntries = append(wfEntries, wfe)
|
||||
}
|
||||
}
|
||||
_ = fRows.Close()
|
||||
|
||||
for _, wfe := range wfEntries {
|
||||
subP := ""
|
||||
if strings.HasPrefix(wfe.path, cleanMount+"/") {
|
||||
subP = strings.TrimPrefix(wfe.path, cleanMount+"/")
|
||||
}
|
||||
_, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ? WHERE id = ?`,
|
||||
folderID, subP, wfe.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) CreateSharedFolder(userID, folderName, initialMountPath string) (*domain.SharedFolder, error) {
|
||||
cleanName := strings.TrimSpace(folderName)
|
||||
if cleanName == "" {
|
||||
return nil, fmt.Errorf("ordnername darf nicht leer sein")
|
||||
}
|
||||
cleanMount := filepath.ToSlash(filepath.Clean(strings.TrimSpace(initialMountPath)))
|
||||
if cleanMount == "." || cleanMount == "" {
|
||||
cleanMount = cleanName
|
||||
}
|
||||
|
||||
// Check if already shared by this user at this mount path
|
||||
var existingID string
|
||||
err := s.db.QueryRow(`
|
||||
SELECT f.id FROM workspace_shared_folders f
|
||||
JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ?
|
||||
WHERE f.owner_id = ? AND (m.mount_path = ? OR f.name = ?)
|
||||
`, userID, userID, cleanMount, cleanName).Scan(&existingID)
|
||||
if err == nil && existingID != "" {
|
||||
s.syncSharedFolderContents(userID, existingID, cleanMount)
|
||||
return s.GetSharedFolderByID(userID, existingID)
|
||||
}
|
||||
|
||||
now := domain.NowISO()
|
||||
folderID := domain.NewID("fld")
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO workspace_shared_folders (id, owner_id, name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, folderID, userID, cleanName, now, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, userID, folderID, cleanMount, now, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.syncSharedFolderContents(userID, folderID, cleanMount)
|
||||
|
||||
var ownerName string
|
||||
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, userID).Scan(&ownerName)
|
||||
|
||||
return &domain.SharedFolder{
|
||||
ID: folderID,
|
||||
OwnerID: userID,
|
||||
OwnerName: ownerName,
|
||||
Name: cleanName,
|
||||
MountPath: cleanMount,
|
||||
IsOwner: true,
|
||||
Permission: "owner",
|
||||
Shares: []domain.FolderShare{},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetSharedFolders(userID string) ([]domain.SharedFolder, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT f.id, f.owner_id, u.display_name, f.name,
|
||||
COALESCE(m.mount_path, f.name) as mount_path,
|
||||
CASE
|
||||
WHEN f.owner_id = ? THEN 1
|
||||
ELSE 0
|
||||
END as is_owner,
|
||||
CASE
|
||||
WHEN f.owner_id = ? THEN 'owner'
|
||||
WHEN MAX(CASE WHEN s.permission = 'write' THEN 1 ELSE 0 END) = 1 THEN 'write'
|
||||
ELSE 'read'
|
||||
END as permission,
|
||||
f.created_at, f.updated_at
|
||||
FROM workspace_shared_folders f
|
||||
JOIN users u ON f.owner_id = u.id
|
||||
LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ?
|
||||
LEFT JOIN workspace_folder_shares s ON s.folder_id = f.id AND (
|
||||
(s.target_type = 'user' AND s.target_id = ?) OR
|
||||
(s.target_type = 'team' AND s.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
)
|
||||
WHERE f.owner_id = ? OR s.id IS NOT NULL
|
||||
GROUP BY f.id
|
||||
ORDER BY f.created_at ASC
|
||||
`, userID, userID, userID, userID, userID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
folders := make([]domain.SharedFolder, 0)
|
||||
for rows.Next() {
|
||||
var f domain.SharedFolder
|
||||
var isOwner int
|
||||
if err := rows.Scan(&f.ID, &f.OwnerID, &f.OwnerName, &f.Name, &f.MountPath, &isOwner, &f.Permission, &f.CreatedAt, &f.UpdatedAt); err == nil {
|
||||
f.IsOwner = isOwner != 0
|
||||
if f.IsOwner {
|
||||
if shares, err := s.GetFolderShares(f.ID); err == nil {
|
||||
f.Shares = shares
|
||||
}
|
||||
}
|
||||
folders = append(folders, f)
|
||||
}
|
||||
}
|
||||
return folders, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetSharedFolderByID(userID, folderID string) (*domain.SharedFolder, error) {
|
||||
row := s.db.QueryRow(`
|
||||
SELECT f.id, f.owner_id, u.display_name, f.name,
|
||||
COALESCE(m.mount_path, f.name) as mount_path,
|
||||
CASE
|
||||
WHEN f.owner_id = ? THEN 1
|
||||
ELSE 0
|
||||
END as is_owner,
|
||||
CASE
|
||||
WHEN f.owner_id = ? THEN 'owner'
|
||||
WHEN MAX(CASE WHEN s.permission = 'write' THEN 1 ELSE 0 END) = 1 THEN 'write'
|
||||
ELSE 'read'
|
||||
END as permission,
|
||||
f.created_at, f.updated_at
|
||||
FROM workspace_shared_folders f
|
||||
JOIN users u ON f.owner_id = u.id
|
||||
LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ?
|
||||
LEFT JOIN workspace_folder_shares s ON s.folder_id = f.id AND (
|
||||
(s.target_type = 'user' AND s.target_id = ?) OR
|
||||
(s.target_type = 'team' AND s.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
)
|
||||
WHERE f.id = ? AND (f.owner_id = ? OR s.id IS NOT NULL)
|
||||
GROUP BY f.id
|
||||
`, userID, userID, userID, userID, userID, folderID, userID)
|
||||
|
||||
var f domain.SharedFolder
|
||||
var isOwner int
|
||||
if err := row.Scan(&f.ID, &f.OwnerID, &f.OwnerName, &f.Name, &f.MountPath, &isOwner, &f.Permission, &f.CreatedAt, &f.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("geteilter Ordner nicht gefunden")
|
||||
}
|
||||
f.IsOwner = isOwner != 0
|
||||
if f.IsOwner {
|
||||
if shares, err := s.GetFolderShares(f.ID); err == nil {
|
||||
f.Shares = shares
|
||||
}
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateFolderMount(userID, folderID, mountPath string) error {
|
||||
cleanMount := filepath.ToSlash(filepath.Clean(strings.TrimSpace(mountPath)))
|
||||
if cleanMount == "." || cleanMount == "" {
|
||||
return fmt.Errorf("ungültiger Mount-Pfad")
|
||||
}
|
||||
now := domain.NowISO()
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, folder_id) DO UPDATE SET mount_path = excluded.mount_path, updated_at = excluded.updated_at
|
||||
`, userID, folderID, cleanMount, now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetFolderShares(folderID string) ([]domain.FolderShare, error) {
|
||||
shares := make([]domain.FolderShare, 0)
|
||||
rows, err := s.db.Query(`
|
||||
SELECT s.id, s.folder_id, s.target_type, s.target_id,
|
||||
CASE
|
||||
WHEN s.target_type = 'user' THEN u.display_name
|
||||
WHEN s.target_type = 'team' THEN t.name
|
||||
ELSE ''
|
||||
END as target_name,
|
||||
COALESCE(u.email, '') as email,
|
||||
COALESCE(u.avatar_url, '') as avatar_url,
|
||||
s.permission, s.created_by, s.created_at
|
||||
FROM workspace_folder_shares s
|
||||
LEFT JOIN users u ON s.target_type = 'user' AND s.target_id = u.id
|
||||
LEFT JOIN teams t ON s.target_type = 'team' AND s.target_id = t.id
|
||||
WHERE s.folder_id = ?
|
||||
ORDER BY s.created_at ASC
|
||||
`, folderID)
|
||||
if err != nil {
|
||||
return shares, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var sh domain.FolderShare
|
||||
if err := rows.Scan(&sh.ID, &sh.FolderID, &sh.TargetType, &sh.TargetID, &sh.TargetName, &sh.Email, &sh.AvatarURL, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil {
|
||||
shares = append(shares, sh)
|
||||
}
|
||||
}
|
||||
return shares, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIdentifier, permission string) (*domain.FolderShare, error) {
|
||||
f, err := s.GetSharedFolderByID(currentUserID, folderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !f.IsOwner {
|
||||
return nil, fmt.Errorf("nur der Eigentümer kann Ordner-Freigaben verwalten")
|
||||
}
|
||||
|
||||
cleanType := strings.TrimSpace(targetType)
|
||||
cleanTarget := strings.TrimSpace(targetIdentifier)
|
||||
if cleanType == "" {
|
||||
if strings.HasPrefix(cleanTarget, "team_") {
|
||||
cleanType = "team"
|
||||
} else {
|
||||
cleanType = "user"
|
||||
}
|
||||
}
|
||||
|
||||
permission = normalizeSharePermission(permission)
|
||||
|
||||
var targetID, targetName, email, avatarURL string
|
||||
if cleanType == "team" {
|
||||
err = s.db.QueryRow(`SELECT id, name FROM teams WHERE id = ?`, cleanTarget).Scan(&targetID, &targetName)
|
||||
if err != nil {
|
||||
return nil, ErrTargetNotFound
|
||||
}
|
||||
} else {
|
||||
var u *domain.User
|
||||
if strings.HasPrefix(cleanTarget, "usr_") {
|
||||
u, _ = s.GetUserByID(cleanTarget)
|
||||
}
|
||||
if u == nil {
|
||||
u, _ = s.GetUserByLogin(cleanTarget)
|
||||
}
|
||||
if u == nil {
|
||||
u, err = s.GetUserByID(cleanTarget)
|
||||
}
|
||||
if err != nil || u == nil {
|
||||
if s.db.QueryRow(`SELECT id, name FROM teams WHERE id = ?`, cleanTarget).Scan(&targetID, &targetName) == nil {
|
||||
cleanType = "team"
|
||||
} else {
|
||||
return nil, ErrTargetNotFound
|
||||
}
|
||||
} else {
|
||||
if u.ID == f.OwnerID {
|
||||
return nil, fmt.Errorf("ordner gehört bereits diesem Benutzer")
|
||||
}
|
||||
targetID = u.ID
|
||||
targetName = u.DisplayName
|
||||
email = u.Email
|
||||
avatarURL = u.AvatarURL
|
||||
}
|
||||
}
|
||||
|
||||
if !s.CanShareWith(currentUserID, cleanType, targetID) {
|
||||
return nil, ErrTargetNotFound
|
||||
}
|
||||
|
||||
now := domain.NowISO()
|
||||
shareID := domain.NewID("fsh")
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO workspace_folder_shares (id, folder_id, target_type, target_id, permission, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(folder_id, target_type, target_id) DO UPDATE SET permission = excluded.permission
|
||||
`, shareID, folderID, cleanType, targetID, permission, currentUserID, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var ownerMount string
|
||||
_ = s.db.QueryRow(`SELECT COALESCE(m.mount_path, f.name) FROM workspace_shared_folders f LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = f.owner_id WHERE f.id = ?`, folderID).Scan(&ownerMount)
|
||||
if ownerMount == "" {
|
||||
ownerMount = f.Name
|
||||
}
|
||||
s.syncSharedFolderContents(f.OwnerID, folderID, ownerMount)
|
||||
|
||||
if cleanType == "user" {
|
||||
_, _ = s.db.Exec(`
|
||||
INSERT OR IGNORE INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, targetID, folderID, f.Name, now, now)
|
||||
} else if cleanType == "team" {
|
||||
mRows, err := s.db.Query(`SELECT user_id FROM team_members WHERE team_id = ?`, targetID)
|
||||
if err == nil {
|
||||
var memberIDs []string
|
||||
for mRows.Next() {
|
||||
var uid string
|
||||
if err := mRows.Scan(&uid); err == nil && uid != "" {
|
||||
memberIDs = append(memberIDs, uid)
|
||||
}
|
||||
}
|
||||
_ = mRows.Close()
|
||||
for _, uid := range memberIDs {
|
||||
_, _ = s.db.Exec(`
|
||||
INSERT OR IGNORE INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, uid, folderID, f.Name, now, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &domain.FolderShare{
|
||||
ID: shareID,
|
||||
FolderID: folderID,
|
||||
TargetType: cleanType,
|
||||
TargetID: targetID,
|
||||
TargetName: targetName,
|
||||
Email: email,
|
||||
AvatarURL: avatarURL,
|
||||
Permission: permission,
|
||||
CreatedBy: currentUserID,
|
||||
CreatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteFolderShare(currentUserID, folderID, shareID string) error {
|
||||
f, err := s.GetSharedFolderByID(currentUserID, folderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cleanID := strings.TrimSpace(shareID)
|
||||
|
||||
// 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)
|
||||
return err
|
||||
}
|
||||
|
||||
var targetType, targetID string
|
||||
_ = s.db.QueryRow(`SELECT target_type, target_id FROM workspace_folder_shares WHERE folder_id = ? AND (id = ? OR target_id = ?)`,
|
||||
folderID, cleanID, cleanID).Scan(&targetType, &targetID)
|
||||
|
||||
_, err = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ? AND (id = ? OR target_id = ?)`, folderID, cleanID, cleanID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if targetType == "user" && targetID != "" {
|
||||
_, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ? AND user_id = ?`, folderID, targetID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSharedFolder(currentUserID, folderID string) error {
|
||||
f, err := s.GetSharedFolderByID(currentUserID, folderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !f.IsOwner {
|
||||
return s.DeleteFolderShare(currentUserID, folderID, currentUserID)
|
||||
}
|
||||
|
||||
_, _ = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '' WHERE shared_folder_id = ?`, folderID)
|
||||
_, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ?`, folderID)
|
||||
_, _ = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ?`, folderID)
|
||||
_, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ?`, folderID)
|
||||
_, err = s.db.Exec(`DELETE FROM workspace_shared_folders WHERE id = ?`, folderID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetSharedFolderDocIDs returns all non-archived document IDs belonging to a shared folder.
|
||||
func (s *Store) GetSharedFolderDocIDs(folderID string) ([]string, error) {
|
||||
rows, err := s.db.Query(`SELECT id FROM documents WHERE shared_folder_id = ? AND is_archived = 0`, folderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var docIDs []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err == nil && id != "" {
|
||||
docIDs = append(docIDs, id)
|
||||
}
|
||||
}
|
||||
return docIDs, nil
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
// --- STANDALONE REALTIME LISTS & SHARING ---
|
||||
|
||||
func (s *Store) CreateList(userID, title, icon, listType string) (*domain.SharedList, error) {
|
||||
if strings.TrimSpace(title) == "" {
|
||||
return nil, errors.New("list title required")
|
||||
}
|
||||
if icon == "" {
|
||||
icon = "🛒"
|
||||
}
|
||||
if listType == "" {
|
||||
listType = "shopping"
|
||||
}
|
||||
now := domain.NowISO()
|
||||
lst := &domain.SharedList{
|
||||
ID: domain.NewID("lst"),
|
||||
UserID: userID,
|
||||
IsOwner: true,
|
||||
Role: "editor",
|
||||
Title: strings.TrimSpace(title),
|
||||
Icon: icon,
|
||||
ListType: listType,
|
||||
Items: []domain.ListItem{},
|
||||
Shares: []domain.ListShare{},
|
||||
UpdatedAt: now,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO lists (id, user_id, title, icon, list_type, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
lst.ID, lst.UserID, lst.Title, lst.Icon, lst.ListType, lst.UpdatedAt, lst.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, userID).Scan(&lst.OwnerName)
|
||||
return lst, nil
|
||||
}
|
||||
|
||||
func (s *Store) AddListShare(currentUserID, listID, targetType, targetID, role string) (*domain.ListShare, error) {
|
||||
if targetType != "team" && targetType != "user" {
|
||||
return nil, errors.New("ungültiger Freigabetyp")
|
||||
}
|
||||
targetID = strings.TrimSpace(targetID)
|
||||
if targetID == "" {
|
||||
return nil, errors.New("Ziel-ID erforderlich")
|
||||
}
|
||||
if role != "viewer" && role != "editor" {
|
||||
role = "editor"
|
||||
}
|
||||
|
||||
if targetType == "user" {
|
||||
var targetUser *domain.User
|
||||
if strings.HasPrefix(targetID, "usr_") {
|
||||
targetUser, _ = s.GetUserByID(targetID)
|
||||
}
|
||||
if targetUser == nil {
|
||||
targetUser, _ = s.GetUserByLogin(targetID)
|
||||
}
|
||||
if targetUser == nil {
|
||||
targetUser, _ = s.GetUserByEmail(targetID)
|
||||
}
|
||||
if targetUser == nil {
|
||||
return nil, ErrTargetNotFound
|
||||
}
|
||||
targetID = targetUser.ID
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil, errors.New("nur der Eigentümer kann die Liste freigeben")
|
||||
}
|
||||
if !s.CanShareWith(currentUserID, targetType, targetID) {
|
||||
return nil, ErrTargetNotFound
|
||||
}
|
||||
|
||||
now := domain.NowISO()
|
||||
share := &domain.ListShare{
|
||||
ID: domain.NewID("lsh"),
|
||||
ListID: listID,
|
||||
TargetType: targetType,
|
||||
TargetID: targetID,
|
||||
Role: role,
|
||||
CreatedBy: currentUserID,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(
|
||||
`INSERT INTO list_shares (id, list_id, target_type, target_id, role, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(list_id, target_type, target_id) DO UPDATE SET role = excluded.role`,
|
||||
share.ID, share.ListID, share.TargetType, share.TargetID, share.Role, share.CreatedBy, share.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_ = s.db.QueryRow(`SELECT id, created_at FROM list_shares WHERE list_id = ? AND target_type = ? AND target_id = ?`,
|
||||
listID, targetType, targetID).Scan(&share.ID, &share.CreatedAt)
|
||||
|
||||
if targetType == "team" {
|
||||
_ = 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)
|
||||
}
|
||||
return share, nil
|
||||
}
|
||||
|
||||
func (s *Store) RemoveListShare(currentUserID, listID, shareIDOrTargetID string) error {
|
||||
var ownerID string
|
||||
err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID)
|
||||
if err != nil {
|
||||
return errors.New("liste nicht gefunden")
|
||||
}
|
||||
if ownerID != currentUserID {
|
||||
return errors.New("nur der Eigentümer kann Freigaben entfernen")
|
||||
}
|
||||
|
||||
cleanTarget := strings.TrimSpace(shareIDOrTargetID)
|
||||
_, err = s.db.Exec(
|
||||
`DELETE FROM list_shares WHERE list_id = ? AND (id = ? OR target_id = ?)`,
|
||||
listID, cleanTarget, cleanTarget,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetListShares(listID string) ([]domain.ListShare, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, list_id, target_type, target_id, role, created_by, created_at
|
||||
FROM list_shares WHERE list_id = ? ORDER BY created_at ASC`,
|
||||
listID,
|
||||
)
|
||||
if err != nil {
|
||||
return []domain.ListShare{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
shares := make([]domain.ListShare, 0)
|
||||
for rows.Next() {
|
||||
var sh domain.ListShare
|
||||
if err := rows.Scan(&sh.ID, &sh.ListID, &sh.TargetType, &sh.TargetID, &sh.Role, &sh.CreatedBy, &sh.CreatedAt); err == nil {
|
||||
if sh.TargetType == "team" {
|
||||
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, sh.TargetID).Scan(&sh.TargetName)
|
||||
} else {
|
||||
_ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, sh.TargetID).Scan(&sh.TargetName, &sh.Email)
|
||||
}
|
||||
shares = append(shares, sh)
|
||||
}
|
||||
}
|
||||
return shares, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT DISTINCT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at,
|
||||
u.display_name,
|
||||
CASE
|
||||
WHEN l.user_id = ? THEN 'owner'
|
||||
WHEN ls.role IS NOT NULL THEN ls.role
|
||||
ELSE 'editor'
|
||||
END as effective_role
|
||||
FROM lists l
|
||||
LEFT JOIN users u ON u.id = l.user_id
|
||||
LEFT JOIN list_shares ls ON ls.list_id = l.id AND (
|
||||
(ls.target_type = 'user' AND ls.target_id = ?)
|
||||
OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
)
|
||||
WHERE l.user_id = ?
|
||||
OR ls.id IS NOT NULL
|
||||
ORDER BY l.updated_at DESC`,
|
||||
userID, userID, userID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lists := make([]domain.SharedList, 0)
|
||||
for rows.Next() {
|
||||
var l domain.SharedList
|
||||
var ownerName sql.NullString
|
||||
var effectiveRole string
|
||||
if err := rows.Scan(&l.ID, &l.UserID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt, &ownerName, &effectiveRole); err == nil {
|
||||
l.OwnerName = ownerName.String
|
||||
l.IsOwner = (l.UserID == userID)
|
||||
l.Role = effectiveRole
|
||||
lists = append(lists, l)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
for i := range lists {
|
||||
lists[i].Items, _ = s.GetListItems(lists[i].ID)
|
||||
lists[i].Shares, _ = s.GetListShares(lists[i].ID)
|
||||
lists[i].Members = s.getListMembers(lists[i].ID, lists[i].UserID)
|
||||
}
|
||||
return lists, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetListByID(userID, listID string) (*domain.SharedList, error) {
|
||||
var l domain.SharedList
|
||||
var ownerName sql.NullString
|
||||
var effectiveRole string
|
||||
|
||||
var query string
|
||||
var args []interface{}
|
||||
if userID == "" {
|
||||
query = `SELECT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at,
|
||||
u.display_name, 'owner' as effective_role
|
||||
FROM lists l
|
||||
LEFT JOIN users u ON u.id = l.user_id
|
||||
WHERE l.id = ?`
|
||||
args = []interface{}{listID}
|
||||
} else {
|
||||
query = `SELECT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at,
|
||||
u.display_name,
|
||||
CASE
|
||||
WHEN l.user_id = ? THEN 'owner'
|
||||
WHEN ls.role IS NOT NULL THEN ls.role
|
||||
ELSE 'editor'
|
||||
END as effective_role
|
||||
FROM lists l
|
||||
LEFT JOIN users u ON u.id = l.user_id
|
||||
LEFT JOIN list_shares ls ON ls.list_id = l.id AND (
|
||||
(ls.target_type = 'user' AND ls.target_id = ?)
|
||||
OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
)
|
||||
WHERE l.id = ? AND (
|
||||
l.user_id = ?
|
||||
OR ls.id IS NOT NULL
|
||||
)`
|
||||
args = []interface{}{userID, userID, userID, listID, userID}
|
||||
}
|
||||
|
||||
err := s.db.QueryRow(query, args...).Scan(&l.ID, &l.UserID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt, &ownerName, &effectiveRole)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l.OwnerName = ownerName.String
|
||||
l.IsOwner = (userID == "" || l.UserID == userID)
|
||||
l.Role = effectiveRole
|
||||
l.Items, _ = s.GetListItems(l.ID)
|
||||
l.Shares, _ = s.GetListShares(l.ID)
|
||||
l.Members = s.getListMembers(l.ID, l.UserID)
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
// getListMembers returns the owner and everyone a list is shared with (directly or via a team).
|
||||
func (s *Store) getListMembers(listID, ownerID string) []domain.TaskListMember {
|
||||
members := make([]domain.TaskListMember, 0)
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, display_name, email, avatar_url FROM users WHERE id IN (
|
||||
SELECT user_id FROM lists WHERE id = ?
|
||||
UNION SELECT target_id FROM list_shares WHERE list_id = ? AND target_type = 'user'
|
||||
UNION SELECT user_id FROM team_members WHERE team_id IN (SELECT target_id FROM list_shares WHERE list_id = ? AND target_type = 'team')
|
||||
) ORDER BY display_name`,
|
||||
listID, listID, listID,
|
||||
)
|
||||
if err != nil {
|
||||
return members
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var m domain.TaskListMember
|
||||
if rows.Scan(&m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL) == nil {
|
||||
m.Role = "member"
|
||||
if m.UserID == ownerID {
|
||||
m.Role = "owner"
|
||||
}
|
||||
members = append(members, m)
|
||||
}
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
func (s *Store) DeleteList(userID, listID string) error {
|
||||
var ownerID string
|
||||
err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID)
|
||||
if err != nil {
|
||||
return errors.New("liste nicht gefunden")
|
||||
}
|
||||
if ownerID != userID {
|
||||
return errors.New("nur der Eigentümer kann die Liste löschen")
|
||||
}
|
||||
_, err = s.db.Exec(`DELETE FROM lists WHERE id = ?`, listID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT li.id, li.list_id, li.content, li.category, li.quantity, li.is_completed, li.completed_by, li.completed_by_name,
|
||||
li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at
|
||||
FROM list_items li LEFT JOIN users u ON u.id = li.created_by AND li.created_by != ''
|
||||
WHERE li.list_id = ?
|
||||
ORDER BY li.is_completed ASC, li.sort_order ASC, li.created_at DESC`,
|
||||
listID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]domain.ListItem, 0)
|
||||
for rows.Next() {
|
||||
var it domain.ListItem
|
||||
var doneInt int
|
||||
if err := rows.Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &doneInt, &it.CompletedBy, &it.CompletedByName, &it.CreatedBy, &it.CreatedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt); err == nil {
|
||||
it.IsCompleted = doneInt != 0
|
||||
items = append(items, it)
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateListItem(listID, content, category, quantity string, sortOrder int, createdBy string) (*domain.ListItem, error) {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return nil, errors.New("item content cannot be empty")
|
||||
}
|
||||
|
||||
category = strings.TrimSpace(category)
|
||||
// Auto-extract hashtag if category is empty
|
||||
if category == "" && strings.Contains(content, "#") {
|
||||
re := regexp.MustCompile(`#([a-zA-Z0-9äöüÄÖÜß_-]+)`)
|
||||
if m := re.FindStringSubmatch(content); len(m) > 1 {
|
||||
category = m[1]
|
||||
cleaned := strings.TrimSpace(re.ReplaceAllString(content, ""))
|
||||
if cleaned != "" {
|
||||
content = cleaned
|
||||
}
|
||||
}
|
||||
} else if category != "" {
|
||||
category = strings.TrimPrefix(category, "#")
|
||||
}
|
||||
|
||||
now := domain.NowISO()
|
||||
it := &domain.ListItem{
|
||||
ID: domain.NewID("itm"),
|
||||
ListID: listID,
|
||||
Content: content,
|
||||
Category: category,
|
||||
Quantity: strings.TrimSpace(quantity),
|
||||
IsCompleted: false,
|
||||
SortOrder: sortOrder,
|
||||
CreatedBy: createdBy,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if createdBy != "" {
|
||||
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, createdBy).Scan(&it.CreatedByName)
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO list_items (id, list_id, content, category, quantity, is_completed, sort_order, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)`,
|
||||
it.ID, it.ListID, it.Content, it.Category, it.Quantity, it.SortOrder, it.CreatedAt, it.CreatedBy,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, listID)
|
||||
return it, nil
|
||||
}
|
||||
|
||||
func (s *Store) ToggleListItem(itemID string, isCompleted bool, userID, userName string) (*domain.ListItem, error) {
|
||||
now := domain.NowISO()
|
||||
doneInt := 0
|
||||
completedAt := ""
|
||||
completedBy := ""
|
||||
completedByName := ""
|
||||
if isCompleted {
|
||||
doneInt = 1
|
||||
completedAt = now
|
||||
completedBy = userID
|
||||
completedByName = userName
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE list_items SET is_completed = ?, completed_by = ?, completed_by_name = ?, completed_at = ? WHERE id = ?`,
|
||||
doneInt, completedBy, completedByName, completedAt, itemID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var it domain.ListItem
|
||||
var dInt int
|
||||
err = s.db.QueryRow(
|
||||
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at
|
||||
FROM list_items WHERE id = ?`,
|
||||
itemID,
|
||||
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
it.IsCompleted = dInt != 0
|
||||
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, it.ListID)
|
||||
return &it, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateListItem(itemID string, content *string, category *string, quantity *string) (*domain.ListItem, error) {
|
||||
now := domain.NowISO()
|
||||
var it domain.ListItem
|
||||
var dInt int
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at
|
||||
FROM list_items WHERE id = ?`,
|
||||
itemID,
|
||||
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
it.IsCompleted = dInt != 0
|
||||
|
||||
if content != nil {
|
||||
it.Content = *content
|
||||
}
|
||||
if category != nil {
|
||||
it.Category = *category
|
||||
}
|
||||
if quantity != nil {
|
||||
it.Quantity = *quantity
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`UPDATE list_items SET content = ?, category = ?, quantity = ? WHERE id = ?`, it.Content, it.Category, it.Quantity, itemID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, it.ListID)
|
||||
return &it, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteListItem(itemID string) (string, error) {
|
||||
var listID string
|
||||
_ = s.db.QueryRow(`SELECT list_id FROM list_items WHERE id = ?`, itemID).Scan(&listID)
|
||||
_, err := s.db.Exec(`DELETE FROM list_items WHERE id = ?`, itemID)
|
||||
if listID != "" {
|
||||
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, domain.NowISO(), listID)
|
||||
}
|
||||
return listID, err
|
||||
}
|
||||
|
||||
func (s *Store) ClearCompletedListItems(listID string) (int64, error) {
|
||||
res, err := s.db.Exec(`DELETE FROM list_items WHERE list_id = ? AND is_completed = 1`, listID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, domain.NowISO(), listID)
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *Store) ReorderListItems(listID string, items []domain.ListItemReorderItem) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, it := range items {
|
||||
if it.Category != nil && it.Content != nil {
|
||||
_, err = tx.Exec(
|
||||
`UPDATE list_items SET sort_order = ?, category = ?, content = ? WHERE id = ? AND list_id = ?`,
|
||||
it.SortOrder, *it.Category, *it.Content, it.ID, listID,
|
||||
)
|
||||
} else if it.Category != nil {
|
||||
_, err = tx.Exec(
|
||||
`UPDATE list_items SET sort_order = ?, category = ? WHERE id = ? AND list_id = ?`,
|
||||
it.SortOrder, *it.Category, it.ID, listID,
|
||||
)
|
||||
} else if it.Content != nil {
|
||||
_, err = tx.Exec(
|
||||
`UPDATE list_items SET sort_order = ?, content = ? WHERE id = ? AND list_id = ?`,
|
||||
it.SortOrder, *it.Content, it.ID, listID,
|
||||
)
|
||||
} else {
|
||||
_, err = tx.Exec(
|
||||
`UPDATE list_items SET sort_order = ? WHERE id = ? AND list_id = ?`,
|
||||
it.SortOrder, it.ID, listID,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = tx.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, domain.NowISO(), listID)
|
||||
return tx.Commit()
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,208 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
// --- STATS, SEARCH & EXPORT ---
|
||||
|
||||
func (s *Store) GetDashboardStats(userID string) (*domain.DashboardStats, error) {
|
||||
st := &domain.DashboardStats{}
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND status = 'inbox'`, userID).Scan(&st.InboxCount)
|
||||
// Same tasks as GetTasks without a list ("Alle Aufgaben"): own ones plus those in shared task lists
|
||||
taskScope := `(user_id = ? OR task_list_id IN (` + s.accessibleTaskListIDsQuery() + `)) AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))`
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE `+taskScope+` AND is_completed = 0`, userID, userID, userID, userID).Scan(&st.TasksOpenCount)
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE `+taskScope+` AND is_completed = 1`, userID, userID, userID, userID).Scan(&st.TasksDoneCount)
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE user_id = ? AND is_archived = 0`, userID).Scan(&st.DocsCount)
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID).Scan(&st.DiscardedCount)
|
||||
_ = s.db.QueryRow(`
|
||||
SELECT COUNT(DISTINCT l.id)
|
||||
FROM lists l
|
||||
LEFT JOIN list_shares ls ON ls.list_id = l.id AND (
|
||||
(ls.target_type = 'user' AND ls.target_id = ?)
|
||||
OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
)
|
||||
WHERE l.user_id = ? OR ls.id IS NOT NULL`,
|
||||
userID, userID, userID).Scan(&st.ListsCount)
|
||||
|
||||
// Count open (uncompleted) To-Dos across accessible workspace documents (owned or shared)
|
||||
docRows, err := s.db.Query(`
|
||||
SELECT blocks_json FROM documents
|
||||
WHERE (user_id = ? OR id IN (SELECT doc_id FROM doc_shares WHERE user_id = ?))
|
||||
AND is_archived = 0`,
|
||||
userID, userID,
|
||||
)
|
||||
if err == nil {
|
||||
for docRows.Next() {
|
||||
var bj string
|
||||
if err := docRows.Scan(&bj); err == nil && bj != "" {
|
||||
var blocks []domain.DocumentBlock
|
||||
if err := json.Unmarshal([]byte(bj), &blocks); err == nil {
|
||||
for _, b := range blocks {
|
||||
if b.Type == "todo" && !b.Checked {
|
||||
st.DocsTodosOpenCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = docRows.Close()
|
||||
}
|
||||
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (s *Store) SearchAll(userID, query string) ([]domain.Thought, []domain.Document, error) {
|
||||
res, err := s.SearchGlobal(userID, query)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return res.Thoughts, res.Documents, nil
|
||||
}
|
||||
|
||||
func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) {
|
||||
user, err := s.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
thoughts, _ := s.GetThoughts(userID, "all", 5000)
|
||||
docs, _ := s.GetDocuments(userID)
|
||||
lists, _ := s.GetAllListsForUser(userID)
|
||||
taskLists, _ := s.GetTaskLists(userID)
|
||||
|
||||
return &domain.FullExport{
|
||||
ExportedAt: domain.NowISO(),
|
||||
Version: "1.0.0",
|
||||
User: *user,
|
||||
Thoughts: thoughts,
|
||||
Documents: docs,
|
||||
Lists: lists,
|
||||
TaskLists: taskLists,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- SYSTEM SETTINGS ---
|
||||
|
||||
func (s *Store) SeedSMTPDefaultsFromEnv(host, port, user, pass, from string, tls, skipVerify bool) {
|
||||
cur, err := s.GetSystemSettings()
|
||||
if err != nil || cur == nil {
|
||||
return
|
||||
}
|
||||
// Only populate DB from env if DB has no SMTP host configured yet and env provides one
|
||||
if cur.SMTPHost == "" && strings.TrimSpace(host) != "" {
|
||||
cur.SMTPHost = strings.TrimSpace(host)
|
||||
if strings.TrimSpace(port) != "" {
|
||||
cur.SMTPPort = strings.TrimSpace(port)
|
||||
}
|
||||
cur.SMTPUser = strings.TrimSpace(user)
|
||||
cur.SMTPPass = pass
|
||||
cur.SMTPFrom = strings.TrimSpace(from)
|
||||
cur.SMTPTLS = tls
|
||||
cur.SMTPSkipVerify = skipVerify
|
||||
_ = s.UpdateSystemSettings(cur)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) {
|
||||
settings := &domain.SystemSettings{
|
||||
SharingMode: SharingOrganizations,
|
||||
AllowRegistration: false,
|
||||
InstanceName: "MindSift",
|
||||
SMTPPort: "587",
|
||||
SMTPTLS: true,
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(`SELECT key, value FROM system_settings`)
|
||||
if err != nil {
|
||||
return settings, nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err == nil {
|
||||
switch k {
|
||||
case "sharing_mode":
|
||||
settings.SharingMode = v
|
||||
case "allow_registration":
|
||||
settings.AllowRegistration = (v == "true" || v == "1")
|
||||
case "instance_name":
|
||||
settings.InstanceName = v
|
||||
case "smtp_host":
|
||||
settings.SMTPHost = v
|
||||
case "smtp_port":
|
||||
if v != "" {
|
||||
settings.SMTPPort = v
|
||||
}
|
||||
case "smtp_user":
|
||||
settings.SMTPUser = v
|
||||
case "smtp_pass":
|
||||
settings.SMTPPass = v
|
||||
case "smtp_from":
|
||||
settings.SMTPFrom = v
|
||||
case "smtp_tls":
|
||||
settings.SMTPTLS = (v != "false" && v != "0")
|
||||
case "smtp_skip_verify":
|
||||
settings.SMTPSkipVerify = v == "true"
|
||||
case "allow_private_webhooks":
|
||||
settings.AllowPrivateWebhooks = v == "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
|
||||
regStr := "false"
|
||||
if settings.AllowRegistration {
|
||||
regStr = "true"
|
||||
}
|
||||
tlsStr := "false"
|
||||
if settings.SMTPTLS {
|
||||
tlsStr = "true"
|
||||
}
|
||||
skipVerifyStr := "false"
|
||||
if settings.SMTPSkipVerify {
|
||||
skipVerifyStr = "true"
|
||||
}
|
||||
privateHooksStr := "false"
|
||||
if settings.AllowPrivateWebhooks {
|
||||
privateHooksStr = "true"
|
||||
}
|
||||
if settings.SMTPPort == "" {
|
||||
settings.SMTPPort = "587"
|
||||
}
|
||||
settings.SharingMode = NormalizeSharingMode(settings.SharingMode)
|
||||
if settings.InstanceName == "" {
|
||||
settings.InstanceName = "MindSift"
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO system_settings (key, value) VALUES
|
||||
('sharing_mode', ?),
|
||||
('allow_registration', ?),
|
||||
('instance_name', ?),
|
||||
('smtp_host', ?),
|
||||
('smtp_port', ?),
|
||||
('smtp_user', ?),
|
||||
('smtp_pass', ?),
|
||||
('smtp_from', ?),
|
||||
('smtp_tls', ?),
|
||||
('smtp_skip_verify', ?),
|
||||
('allow_private_webhooks', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
settings.SharingMode,
|
||||
regStr,
|
||||
settings.InstanceName,
|
||||
strings.TrimSpace(settings.SMTPHost),
|
||||
strings.TrimSpace(settings.SMTPPort),
|
||||
strings.TrimSpace(settings.SMTPUser),
|
||||
settings.SMTPPass,
|
||||
strings.TrimSpace(settings.SMTPFrom),
|
||||
tlsStr,
|
||||
skipVerifyStr,
|
||||
privateHooksStr,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
func (s *Store) UpdateTagPageNote(userID, tag, content string) (string, error) {
|
||||
cleanTag := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(tag), "#"))
|
||||
if cleanTag == "" {
|
||||
return "", errors.New("tag name required")
|
||||
}
|
||||
now := domain.NowISO()
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO tag_pages (user_id, tag, content, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, tag) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at`,
|
||||
userID, cleanTag, content, now,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return now, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetWorkspaceTags(userID string) ([]domain.TagSummary, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT t.tag, COUNT(DISTINCT t.document_id || ':' || t.block_id) as count
|
||||
FROM block_tags t
|
||||
JOIN documents d ON t.document_id = d.id
|
||||
LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ?
|
||||
LEFT JOIN (
|
||||
SELECT dts2.doc_id
|
||||
FROM doc_team_shares dts2
|
||||
JOIN team_members tm ON tm.team_id = dts2.team_id
|
||||
WHERE tm.user_id = ?
|
||||
) dts ON d.id = dts.doc_id
|
||||
LEFT JOIN workspace_shared_folders wsf ON d.shared_folder_id = wsf.id
|
||||
LEFT JOIN (
|
||||
SELECT wfs2.folder_id
|
||||
FROM workspace_folder_shares wfs2
|
||||
WHERE (wfs2.target_type = 'user' AND wfs2.target_id = ?)
|
||||
OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
) wfs ON d.shared_folder_id = wfs.folder_id
|
||||
WHERE (d.user_id = ? OR s.user_id = ? OR dts.doc_id IS NOT NULL OR (wsf.id IS NOT NULL AND (wsf.owner_id = ? OR wfs.folder_id IS NOT NULL)))
|
||||
AND d.is_archived = 0
|
||||
GROUP BY t.tag
|
||||
ORDER BY count DESC, t.tag ASC`,
|
||||
userID, userID, userID, userID, userID, userID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
results := make([]domain.TagSummary, 0)
|
||||
seen := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var ts domain.TagSummary
|
||||
if err := rows.Scan(&ts.Tag, &ts.Count); err == nil {
|
||||
results = append(results, ts)
|
||||
seen[ts.Tag] = true
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
// Also include tags that have a non-empty tag_pages note even if not currently used in a block
|
||||
pageRows, err := s.db.Query(
|
||||
`SELECT tag FROM tag_pages WHERE user_id = ? AND TRIM(content) != '' ORDER BY tag ASC`,
|
||||
userID,
|
||||
)
|
||||
if err == nil {
|
||||
for pageRows.Next() {
|
||||
var t string
|
||||
if err := pageRows.Scan(&t); err == nil && t != "" && !seen[t] {
|
||||
results = append(results, domain.TagSummary{Tag: t, Count: 0})
|
||||
seen[t] = true
|
||||
}
|
||||
}
|
||||
_ = pageRows.Close()
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetTagDetails(userID, tag string) (*domain.TagDetails, error) {
|
||||
cleanTag := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(tag), "#"))
|
||||
if cleanTag == "" {
|
||||
return &domain.TagDetails{Tag: "", Documents: []domain.TagDocumentGroup{}, TotalMatches: 0}, nil
|
||||
}
|
||||
|
||||
var noteContent, noteUpdatedAt string
|
||||
_ = s.db.QueryRow(
|
||||
`SELECT content, updated_at FROM tag_pages WHERE user_id = ? AND tag = ?`,
|
||||
userID, cleanTag,
|
||||
).Scan(¬eContent, ¬eUpdatedAt)
|
||||
|
||||
rows, err := s.db.Query(
|
||||
`SELECT t.block_id, d.id, d.title, d.icon, d.folder_path, d.blocks_json, d.updated_at, d.user_id,
|
||||
COALESCE(u.display_name, '') as owner_name,
|
||||
COALESCE(s.permission, dts.permission, wfs.permission, '') as shared_permission
|
||||
FROM block_tags t
|
||||
JOIN documents d ON t.document_id = d.id
|
||||
LEFT JOIN users u ON d.user_id = u.id
|
||||
LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ?
|
||||
LEFT JOIN (
|
||||
SELECT dts2.doc_id, dts2.permission
|
||||
FROM doc_team_shares dts2
|
||||
JOIN team_members tm ON tm.team_id = dts2.team_id
|
||||
WHERE tm.user_id = ?
|
||||
) dts ON d.id = dts.doc_id
|
||||
LEFT JOIN workspace_shared_folders wsf ON d.shared_folder_id = wsf.id
|
||||
LEFT JOIN (
|
||||
SELECT wfs2.folder_id, wfs2.permission
|
||||
FROM workspace_folder_shares wfs2
|
||||
WHERE (wfs2.target_type = 'user' AND wfs2.target_id = ?)
|
||||
OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
) wfs ON d.shared_folder_id = wfs.folder_id
|
||||
WHERE (d.user_id = ? OR s.user_id = ? OR dts.doc_id IS NOT NULL OR (wsf.id IS NOT NULL AND (wsf.owner_id = ? OR wfs.folder_id IS NOT NULL)))
|
||||
AND t.tag = ?
|
||||
AND d.is_archived = 0
|
||||
ORDER BY d.updated_at DESC`,
|
||||
userID, userID, userID, userID, userID, userID, userID, cleanTag,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type rawMatch struct {
|
||||
blockID string
|
||||
docID string
|
||||
title string
|
||||
icon string
|
||||
folderPath string
|
||||
blocksJSON string
|
||||
updatedAt string
|
||||
docUserID string
|
||||
ownerName string
|
||||
sharedPermission string
|
||||
}
|
||||
|
||||
var rawMatches []rawMatch
|
||||
for rows.Next() {
|
||||
var rm rawMatch
|
||||
if err := rows.Scan(&rm.blockID, &rm.docID, &rm.title, &rm.icon, &rm.folderPath, &rm.blocksJSON, &rm.updatedAt, &rm.docUserID, &rm.ownerName, &rm.sharedPermission); err == nil {
|
||||
rawMatches = append(rawMatches, rm)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
// Group rawMatches by docID
|
||||
docMap := make(map[string][]rawMatch)
|
||||
var docOrder []string
|
||||
for _, rm := range rawMatches {
|
||||
if _, exists := docMap[rm.docID]; !exists {
|
||||
docOrder = append(docOrder, rm.docID)
|
||||
}
|
||||
docMap[rm.docID] = append(docMap[rm.docID], rm)
|
||||
}
|
||||
|
||||
totalMatches := 0
|
||||
docGroups := make([]domain.TagDocumentGroup, 0, len(docOrder))
|
||||
|
||||
for _, docID := range docOrder {
|
||||
matches := docMap[docID]
|
||||
first := matches[0]
|
||||
|
||||
blocks := s.loadDocBlocks(first.docID, first.blocksJSON)
|
||||
|
||||
blockIdxMap := make(map[string]int, len(blocks))
|
||||
for i, b := range blocks {
|
||||
blockIdxMap[b.ID] = i
|
||||
}
|
||||
|
||||
matchedBlocks := make([]domain.MatchedTagBlock, 0)
|
||||
processedBlockIDs := make(map[string]bool)
|
||||
|
||||
for _, m := range matches {
|
||||
if processedBlockIDs[m.blockID] {
|
||||
continue
|
||||
}
|
||||
idx, ok := blockIdxMap[m.blockID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rootBlock := blocks[idx]
|
||||
processedBlockIDs[rootBlock.ID] = true
|
||||
|
||||
children := make([]domain.DocumentBlock, 0)
|
||||
rootLevel := rootBlock.Level
|
||||
for i := idx + 1; i < len(blocks); i++ {
|
||||
if blocks[i].Level > rootLevel {
|
||||
children = append(children, blocks[i])
|
||||
processedBlockIDs[blocks[i].ID] = true
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
matchedBlocks = append(matchedBlocks, domain.MatchedTagBlock{
|
||||
RootBlock: rootBlock,
|
||||
Children: children,
|
||||
})
|
||||
totalMatches++
|
||||
}
|
||||
|
||||
if len(matchedBlocks) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
isOwner := first.docUserID == userID
|
||||
permission := "read"
|
||||
if isOwner {
|
||||
permission = "owner"
|
||||
} else if first.sharedPermission != "" {
|
||||
permission = first.sharedPermission
|
||||
}
|
||||
|
||||
docGroups = append(docGroups, domain.TagDocumentGroup{
|
||||
DocID: first.docID,
|
||||
Title: first.title,
|
||||
Icon: first.icon,
|
||||
FolderPath: first.folderPath,
|
||||
UpdatedAt: first.updatedAt,
|
||||
IsOwner: isOwner,
|
||||
Permission: permission,
|
||||
OwnerName: first.ownerName,
|
||||
MatchedBlocks: matchedBlocks,
|
||||
})
|
||||
}
|
||||
|
||||
return &domain.TagDetails{
|
||||
Tag: cleanTag,
|
||||
NoteContent: noteContent,
|
||||
NoteUpdatedAt: noteUpdatedAt,
|
||||
Documents: docGroups,
|
||||
TotalMatches: totalMatches,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
// --- TASK LISTS ---
|
||||
|
||||
func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color string, columns ...[]domain.KanbanColumn) (*domain.TaskList, error) {
|
||||
if strings.TrimSpace(title) == "" {
|
||||
title = "Meine Aufgaben"
|
||||
}
|
||||
if icon == "" {
|
||||
icon = "📋"
|
||||
}
|
||||
if color == "" {
|
||||
color = "#2563eb"
|
||||
}
|
||||
cols := []domain.KanbanColumn{
|
||||
{ID: "todo", Name: "Zu erledigen"},
|
||||
{ID: "in_progress", Name: "In Bearbeitung", Color: "accent"},
|
||||
{ID: "waiting", Name: "Wartet auf andere", Color: "amber"},
|
||||
{ID: "done", Name: "Erledigt", IsDone: true, Color: "emerald"},
|
||||
}
|
||||
if len(columns) > 0 && len(columns[0]) > 0 {
|
||||
cols = columns[0]
|
||||
}
|
||||
colsJSON, _ := json.Marshal(cols)
|
||||
now := domain.NowISO()
|
||||
tl := &domain.TaskList{
|
||||
ID: domain.NewID("tkl"),
|
||||
UserID: userID,
|
||||
SpaceID: spaceID,
|
||||
Title: strings.TrimSpace(title),
|
||||
Icon: icon,
|
||||
Color: color,
|
||||
Columns: cols,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
IsOwner: true,
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`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 != "" && s.CanShareWith(userID, "team", teamID) {
|
||||
_, _ = s.AddTaskListShare(tl.ID, "team", teamID, "editor", userID)
|
||||
}
|
||||
return s.GetTaskListByID(userID, tl.ID)
|
||||
}
|
||||
|
||||
// ErrTaskListReadOnly is returned when a user with viewer permission tries to modify a task list or its tasks.
|
||||
var ErrTaskListReadOnly = errors.New("Keine Schreibrechte für diese Aufgabenliste (nur Lesen)")
|
||||
|
||||
// ErrAssigneeNoAccess is returned when a task should be assigned to someone who cannot see its list.
|
||||
var ErrAssigneeNoAccess = errors.New("Aufgaben können nur Personen mit Zugriff auf die Liste zugewiesen werden")
|
||||
|
||||
// GetTaskListPermission returns "owner", "editor", "viewer" or "" (no access) for the given user.
|
||||
func (s *Store) GetTaskListPermission(userID, listID string) string {
|
||||
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 {
|
||||
return "owner"
|
||||
}
|
||||
rows, err := s.db.Query(
|
||||
`SELECT permission FROM task_list_shares
|
||||
WHERE task_list_id = ? AND (
|
||||
(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,
|
||||
)
|
||||
perm := ""
|
||||
if err == nil {
|
||||
for rows.Next() {
|
||||
var p string
|
||||
if rows.Scan(&p) == nil {
|
||||
if p == "viewer" {
|
||||
if perm == "" {
|
||||
perm = "viewer"
|
||||
}
|
||||
} else {
|
||||
perm = "editor"
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
}
|
||||
return perm
|
||||
}
|
||||
|
||||
func (s *Store) CanEditTaskList(userID, listID string) bool {
|
||||
p := s.GetTaskListPermission(userID, listID)
|
||||
return p == "owner" || p == "editor"
|
||||
}
|
||||
|
||||
// ensureTaskListWritable returns ErrTaskListReadOnly if the list is set and the user may not modify it.
|
||||
func (s *Store) ensureTaskListWritable(userID, listID string) error {
|
||||
if listID == "" || s.CanEditTaskList(userID, listID) {
|
||||
return nil
|
||||
}
|
||||
return ErrTaskListReadOnly
|
||||
}
|
||||
|
||||
func (s *Store) AddTaskListShare(taskListID, targetType, targetID, permission, createdBy string) (*domain.TaskListShare, error) {
|
||||
if targetType != "team" && targetType != "user" {
|
||||
return nil, errors.New("ungültiger Freigabetyp")
|
||||
}
|
||||
targetID = strings.TrimSpace(targetID)
|
||||
if targetID == "" {
|
||||
return nil, errors.New("Ziel-ID erforderlich")
|
||||
}
|
||||
if permission != "viewer" {
|
||||
permission = "editor"
|
||||
}
|
||||
if !s.CanShareWith(createdBy, targetType, targetID) {
|
||||
return nil, ErrTargetNotFound
|
||||
}
|
||||
now := domain.NowISO()
|
||||
share := &domain.TaskListShare{
|
||||
ID: domain.NewID("tls"),
|
||||
TaskListID: taskListID,
|
||||
TargetType: targetType,
|
||||
TargetID: targetID,
|
||||
Permission: permission,
|
||||
CreatedAt: now,
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO task_list_shares (id, task_list_id, target_type, target_id, permission, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(task_list_id, target_type, target_id) DO UPDATE SET created_at = excluded.created_at, permission = excluded.permission`,
|
||||
share.ID, share.TaskListID, share.TargetType, share.TargetID, share.Permission, share.CreatedAt, createdBy,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = s.db.QueryRow(`SELECT id, created_at FROM task_list_shares WHERE task_list_id = ? AND target_type = ? AND target_id = ?`,
|
||||
taskListID, targetType, targetID).Scan(&share.ID, &share.CreatedAt)
|
||||
|
||||
if targetType == "team" {
|
||||
_ = 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)
|
||||
}
|
||||
return share, nil
|
||||
}
|
||||
|
||||
func (s *Store) RemoveTaskListShare(taskListID, shareIDOrTargetID string) error {
|
||||
_, err := s.db.Exec(
|
||||
`DELETE FROM task_list_shares WHERE task_list_id = ? AND (id = ? OR target_id = ?)`,
|
||||
taskListID, shareIDOrTargetID, shareIDOrTargetID,
|
||||
)
|
||||
s.PruneTaskAssignees()
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetTaskListShares(taskListID string) ([]domain.TaskListShare, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, task_list_id, target_type, target_id, permission, created_at
|
||||
FROM task_list_shares WHERE task_list_id = ? ORDER BY created_at ASC`,
|
||||
taskListID,
|
||||
)
|
||||
if err != nil {
|
||||
return []domain.TaskListShare{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
shares := make([]domain.TaskListShare, 0)
|
||||
for rows.Next() {
|
||||
var sh domain.TaskListShare
|
||||
if err := rows.Scan(&sh.ID, &sh.TaskListID, &sh.TargetType, &sh.TargetID, &sh.Permission, &sh.CreatedAt); err == nil {
|
||||
shares = append(shares, sh)
|
||||
}
|
||||
}
|
||||
for i := range shares {
|
||||
if shares[i].TargetType == "team" {
|
||||
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, shares[i].TargetID).Scan(&shares[i].TargetName)
|
||||
} else {
|
||||
_ = s.db.QueryRow(`SELECT display_name, email, avatar_url FROM users WHERE id = ?`, shares[i].TargetID).Scan(&shares[i].TargetName, &shares[i].Email, &shares[i].AvatarURL)
|
||||
}
|
||||
}
|
||||
return shares, nil
|
||||
}
|
||||
|
||||
// queryUserIDs runs a query returning a single user_id column and collects the distinct IDs.
|
||||
func (s *Store) queryUserIDs(query string, args ...interface{}) []string {
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return []string{}
|
||||
}
|
||||
defer rows.Close()
|
||||
ids := make([]string, 0)
|
||||
seen := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if rows.Scan(&id) == nil && id != "" && !seen[id] {
|
||||
seen[id] = true
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// 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')`
|
||||
|
||||
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)
|
||||
return ids
|
||||
}
|
||||
|
||||
// attachTaskAssignees fills Assignees for a batch of tasks with a single query.
|
||||
func (s *Store) attachTaskAssignees(tasks []domain.Thought) {
|
||||
if len(tasks) == 0 {
|
||||
return
|
||||
}
|
||||
idx := make(map[string]int, len(tasks))
|
||||
placeholders := make([]string, 0, len(tasks))
|
||||
args := make([]interface{}, 0, len(tasks))
|
||||
for i := range tasks {
|
||||
idx[tasks[i].ID] = i
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, tasks[i].ID)
|
||||
}
|
||||
rows, err := s.db.Query(
|
||||
`SELECT task_id, user_id FROM task_assignees WHERE task_id IN (`+strings.Join(placeholders, ",")+`) ORDER BY created_at ASC`,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var taskID, uid string
|
||||
if rows.Scan(&taskID, &uid) == nil {
|
||||
if i, ok := idx[taskID]; ok {
|
||||
tasks[i].Assignees = append(tasks[i].Assignees, uid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetTaskAssignees replaces the assignees of a task. Only editors of the task's list may assign, and only
|
||||
// people who can see the list are accepted.
|
||||
func (s *Store) SetTaskAssignees(userID, taskID string, assignees []string) ([]string, error) {
|
||||
thought, err := s.GetThoughtByID(userID, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if thought.TaskListID == "" {
|
||||
if len(assignees) > 0 {
|
||||
return nil, ErrAssigneeNoAccess
|
||||
}
|
||||
return []string{}, nil
|
||||
}
|
||||
if !s.CanEditTaskList(userID, thought.TaskListID) {
|
||||
return nil, ErrTaskListReadOnly
|
||||
}
|
||||
allowed := make(map[string]bool)
|
||||
for _, id := range s.GetTaskListUserIDs(thought.TaskListID) {
|
||||
allowed[id] = true
|
||||
}
|
||||
clean := make([]string, 0, len(assignees))
|
||||
seen := make(map[string]bool)
|
||||
for _, id := range assignees {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
if !allowed[id] {
|
||||
return nil, ErrAssigneeNoAccess
|
||||
}
|
||||
seen[id] = true
|
||||
clean = append(clean, id)
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
keep := make([]interface{}, 0, len(clean)+1)
|
||||
keep = append(keep, taskID)
|
||||
ph := make([]string, 0, len(clean))
|
||||
for _, id := range clean {
|
||||
keep = append(keep, id)
|
||||
ph = append(ph, "?")
|
||||
}
|
||||
delQuery := `DELETE FROM task_assignees WHERE task_id = ?`
|
||||
if len(ph) > 0 {
|
||||
delQuery += ` AND user_id NOT IN (` + strings.Join(ph, ",") + `)`
|
||||
}
|
||||
if _, err := tx.Exec(delQuery, keep...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := domain.NowISO()
|
||||
for _, id := range clean {
|
||||
if _, err := tx.Exec(`INSERT OR IGNORE INTO task_assignees (task_id, user_id, created_at) VALUES (?, ?, ?)`, taskID, id, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.getTaskAssignees(taskID), nil
|
||||
}
|
||||
|
||||
// PruneTaskAssignees drops every assignment whose user can no longer see the task's list (share removed,
|
||||
// left the team, team deleted, task moved to another list or out of any list). Called after each change
|
||||
// that can take access away, so assignments never outlive permissions.
|
||||
func (s *Store) PruneTaskAssignees() {
|
||||
_, _ = s.db.Exec(
|
||||
`DELETE FROM task_assignees WHERE rowid IN (
|
||||
SELECT ta.rowid FROM task_assignees ta
|
||||
LEFT JOIN thoughts t ON t.id = ta.task_id
|
||||
WHERE t.id IS NULL OR t.task_list_id = ''
|
||||
OR ta.user_id NOT IN (` + fmt.Sprintf(taskListAccessSQL, "t.task_list_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(fmt.Sprintf(taskListAccessSQL, "?"), listID, listID, listID)
|
||||
}
|
||||
|
||||
// GetSharedFolderUserIDs returns the owner of a shared workspace folder and everyone it is shared with.
|
||||
func (s *Store) GetSharedFolderUserIDs(folderID string) []string {
|
||||
if folderID == "" {
|
||||
return []string{}
|
||||
}
|
||||
return s.queryUserIDs(
|
||||
`SELECT owner_id FROM workspace_shared_folders WHERE id = ?
|
||||
UNION SELECT target_id FROM workspace_folder_shares WHERE folder_id = ? AND target_type = 'user'
|
||||
UNION SELECT tm.user_id FROM team_members tm
|
||||
WHERE tm.team_id IN (SELECT target_id FROM workspace_folder_shares WHERE folder_id = ? AND target_type = 'team')`,
|
||||
folderID, folderID, folderID,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Store) getMembersForTaskList(tl *domain.TaskList) []domain.TaskListMember {
|
||||
memberMap := make(map[string]domain.TaskListMember)
|
||||
|
||||
// 1. Owner
|
||||
var ownerName, ownerEmail, ownerAvatar string
|
||||
_ = s.db.QueryRow(`SELECT display_name, email, avatar_url FROM users WHERE id = ?`, tl.UserID).Scan(&ownerName, &ownerEmail, &ownerAvatar)
|
||||
memberMap[tl.UserID] = domain.TaskListMember{
|
||||
UserID: tl.UserID,
|
||||
DisplayName: ownerName,
|
||||
Email: ownerEmail,
|
||||
AvatarURL: ownerAvatar,
|
||||
Role: "owner",
|
||||
}
|
||||
|
||||
// 2. From task_list_shares
|
||||
for _, sh := range tl.Shares {
|
||||
if sh.TargetType == "team" {
|
||||
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 = ?`,
|
||||
sh.TargetID,
|
||||
)
|
||||
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()
|
||||
}
|
||||
} else if sh.TargetType == "user" {
|
||||
var m domain.TaskListMember
|
||||
if err := s.db.QueryRow(`SELECT id, display_name, email, avatar_url FROM users WHERE id = ?`, sh.TargetID).Scan(&m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL); err == nil {
|
||||
m.Role = "member"
|
||||
memberMap[m.UserID] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]domain.TaskListMember, 0, len(memberMap))
|
||||
for _, m := range memberMap {
|
||||
result = append(result, m)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, user_id, space_id, title, icon, color, COALESCE(columns, '[]'), created_at, updated_at
|
||||
FROM task_lists
|
||||
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,
|
||||
)
|
||||
if err != nil {
|
||||
return []domain.TaskList{}, err
|
||||
}
|
||||
|
||||
defaultCols := []domain.KanbanColumn{
|
||||
{ID: "todo", Name: "Zu erledigen"},
|
||||
{ID: "in_progress", Name: "In Bearbeitung", Color: "accent"},
|
||||
{ID: "waiting", Name: "Wartet auf andere", Color: "amber"},
|
||||
{ID: "done", Name: "Erledigt", IsDone: true, Color: "emerald"},
|
||||
}
|
||||
|
||||
list := make([]domain.TaskList, 0)
|
||||
for rows.Next() {
|
||||
var tl domain.TaskList
|
||||
var colsJSON string
|
||||
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 {
|
||||
tl.Columns = defaultCols
|
||||
}
|
||||
list = append(list, tl)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
for i := range list {
|
||||
list[i].Members = make([]domain.TaskListMember, 0)
|
||||
list[i].Shares = make([]domain.TaskListShare, 0)
|
||||
|
||||
_ = 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'))`,
|
||||
list[i].ID,
|
||||
).Scan(&list[i].TasksCount)
|
||||
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, list[i].UserID).Scan(&list[i].OwnerName)
|
||||
|
||||
shares, _ := s.GetTaskListShares(list[i].ID)
|
||||
list[i].Shares = shares
|
||||
list[i].Permission = s.GetTaskListPermission(userID, list[i].ID)
|
||||
list[i].Members = s.getMembersForTaskList(&list[i])
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
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, title, icon, color, COALESCE(columns, '[]'), created_at, updated_at
|
||||
FROM task_lists
|
||||
WHERE id = ? AND (
|
||||
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,
|
||||
).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tl.IsOwner = (tl.UserID == userID)
|
||||
_ = json.Unmarshal([]byte(colsJSON), &tl.Columns)
|
||||
if len(tl.Columns) == 0 {
|
||||
tl.Columns = []domain.KanbanColumn{
|
||||
{ID: "todo", Name: "Zu erledigen"},
|
||||
{ID: "in_progress", Name: "In Bearbeitung", Color: "accent"},
|
||||
{ID: "waiting", Name: "Wartet auf andere", Color: "amber"},
|
||||
{ID: "done", Name: "Erledigt", IsDone: true, Color: "emerald"},
|
||||
}
|
||||
}
|
||||
_ = 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)
|
||||
shares, _ := s.GetTaskListShares(tl.ID)
|
||||
tl.Shares = shares
|
||||
tl.Permission = s.GetTaskListPermission(userID, tl.ID)
|
||||
tl.Members = s.getMembersForTaskList(&tl)
|
||||
return &tl, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if tl.Permission == "viewer" {
|
||||
return nil, ErrTaskListReadOnly
|
||||
}
|
||||
if title != nil {
|
||||
tl.Title = strings.TrimSpace(*title)
|
||||
}
|
||||
if icon != nil && *icon != "" {
|
||||
tl.Icon = *icon
|
||||
}
|
||||
if color != nil && *color != "" {
|
||||
tl.Color = *color
|
||||
}
|
||||
if spaceID != nil {
|
||||
tl.SpaceID = *spaceID
|
||||
}
|
||||
if columns != nil {
|
||||
tl.Columns = *columns
|
||||
}
|
||||
tl.UpdatedAt = domain.NowISO()
|
||||
colsJSON, _ := json.Marshal(tl.Columns)
|
||||
_, err = s.db.Exec(
|
||||
`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
|
||||
}
|
||||
return s.GetTaskListByID(userID, listID)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteTaskList(userID, listID string) error {
|
||||
_, _ = s.db.Exec(`UPDATE thoughts SET task_list_id = '' WHERE task_list_id = ?`, listID)
|
||||
_, _ = s.db.Exec(`DELETE FROM task_list_shares WHERE task_list_id = ?`, listID)
|
||||
_, err := s.db.Exec(`DELETE FROM task_lists WHERE id = ? AND user_id = ?`, listID, userID)
|
||||
s.PruneTaskAssignees()
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
// --- ORGANIZATIONS & TEAMS ---
|
||||
|
||||
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)
|
||||
|
||||
team := &domain.Team{
|
||||
ID: domain.NewID("team"),
|
||||
OrgID: orgID,
|
||||
Name: name,
|
||||
Description: strings.TrimSpace(description),
|
||||
Members: []domain.TeamMember{},
|
||||
CreatedAt: domain.NowISO(),
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO teams (id, org_id, name, description, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
team.ID, team.OrgID, team.Name, team.Description, team.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return team, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteTeam(teamID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM teams WHERE id = ?`, teamID)
|
||||
s.pruneSharingNow()
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetAllTeams() ([]domain.Team, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, org_id, name, description, created_at FROM teams ORDER BY name COLLATE NOCASE ASC`,
|
||||
)
|
||||
if err != nil {
|
||||
return []domain.Team{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
teams := make([]domain.Team, 0)
|
||||
for rows.Next() {
|
||||
var t domain.Team
|
||||
if err := rows.Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt); err == nil {
|
||||
t.Members = []domain.TeamMember{}
|
||||
teams = append(teams, t)
|
||||
}
|
||||
}
|
||||
for i := range teams {
|
||||
members, _ := s.GetTeamMembers(teams[i].ID)
|
||||
teams[i].Members = members
|
||||
}
|
||||
return teams, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetTeamsForOrg(orgID string) ([]domain.Team, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, org_id, name, description, created_at FROM teams WHERE org_id = ? ORDER BY created_at ASC`,
|
||||
orgID,
|
||||
)
|
||||
if err != nil {
|
||||
return []domain.Team{}, err
|
||||
}
|
||||
|
||||
teams := make([]domain.Team, 0)
|
||||
for rows.Next() {
|
||||
var t domain.Team
|
||||
if err := rows.Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt); err == nil {
|
||||
t.Members = []domain.TeamMember{}
|
||||
teams = append(teams, t)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
for i := range teams {
|
||||
members, _ := s.GetTeamMembers(teams[i].ID)
|
||||
teams[i].Members = members
|
||||
}
|
||||
return teams, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetTeamByID(teamID string) (*domain.Team, error) {
|
||||
var t domain.Team
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, org_id, name, description, created_at FROM teams WHERE id = ?`,
|
||||
teamID,
|
||||
).Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Members, _ = s.GetTeamMembers(t.ID)
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetTeamsForUser(userID string) ([]domain.Team, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT DISTINCT t.id, t.org_id, t.name, t.description, t.created_at
|
||||
FROM teams t
|
||||
JOIN team_members tm ON tm.team_id = t.id
|
||||
WHERE tm.user_id = ?
|
||||
ORDER BY t.created_at ASC`,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return []domain.Team{}, err
|
||||
}
|
||||
|
||||
teams := make([]domain.Team, 0)
|
||||
for rows.Next() {
|
||||
var t domain.Team
|
||||
if err := rows.Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt); err == nil {
|
||||
t.Members = []domain.TeamMember{}
|
||||
teams = append(teams, t)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
for i := range teams {
|
||||
members, _ := s.GetTeamMembers(teams[i].ID)
|
||||
teams[i].Members = members
|
||||
}
|
||||
return teams, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateTeam(teamID, name, description string) (*domain.Team, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, errors.New("Name für die Gruppe ist erforderlich")
|
||||
}
|
||||
_, err := s.db.Exec(`UPDATE teams SET name = ?, description = ? WHERE id = ?`, name, strings.TrimSpace(description), teamID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetTeamByID(teamID)
|
||||
}
|
||||
|
||||
func (s *Store) GetTeamMemberRole(teamID, userID string) (string, error) {
|
||||
var role string
|
||||
err := s.db.QueryRow(`SELECT role FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID).Scan(&role)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return role, nil
|
||||
}
|
||||
|
||||
func (s *Store) CountTeamAdmins(teamID string) (int, error) {
|
||||
var count int
|
||||
err := s.db.QueryRow(`SELECT COUNT(*) FROM team_members WHERE team_id = ? AND role = 'admin'`, teamID).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (s *Store) AddTeamMember(teamID, userID, role string) error {
|
||||
role = strings.ToLower(strings.TrimSpace(role))
|
||||
if role != "admin" && role != "member" {
|
||||
role = "member"
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO team_members (team_id, user_id, role, joined_at) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(team_id, user_id) DO UPDATE SET role = excluded.role`,
|
||||
teamID, userID, role, domain.NowISO(),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateTeamMemberRole(teamID, userID, role string) error {
|
||||
role = strings.ToLower(strings.TrimSpace(role))
|
||||
if role != "admin" && role != "member" {
|
||||
return errors.New("Ungültige Rolle (erlaubt sind: admin, member)")
|
||||
}
|
||||
currentRole, err := s.GetTeamMemberRole(teamID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if currentRole == "" {
|
||||
return errors.New("Benutzer ist kein Mitglied dieser Gruppe")
|
||||
}
|
||||
if currentRole == "admin" && role != "admin" {
|
||||
adminCount, err := s.CountTeamAdmins(teamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if adminCount <= 1 {
|
||||
return errors.New("Die Gruppe muss mindestens einen Gruppen-Admin behalten")
|
||||
}
|
||||
}
|
||||
_, err = s.db.Exec(`UPDATE team_members SET role = ? WHERE team_id = ? AND user_id = ?`, role, teamID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) RemoveTeamMember(teamID, userID string) error {
|
||||
currentRole, err := s.GetTeamMemberRole(teamID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if currentRole == "admin" {
|
||||
adminCount, err := s.CountTeamAdmins(teamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if adminCount <= 1 {
|
||||
return errors.New("Du bist der letzte Gruppen-Admin. Bitte ernenne zuerst einen anderen Admin oder lösche die Gruppe.")
|
||||
}
|
||||
}
|
||||
_, err = s.db.Exec(`DELETE FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID)
|
||||
// Whoever leaves a group also loses the shares they made with it
|
||||
s.pruneSharingNow()
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetTeamMembers(teamID string) ([]domain.TeamMember, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT tm.team_id, tm.user_id, u.display_name, u.email, u.avatar_url, tm.role, tm.joined_at
|
||||
FROM team_members tm
|
||||
JOIN users u ON u.id = tm.user_id
|
||||
WHERE tm.team_id = ?
|
||||
ORDER BY tm.joined_at ASC`,
|
||||
teamID,
|
||||
)
|
||||
if err != nil {
|
||||
return []domain.TeamMember{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
members := make([]domain.TeamMember, 0)
|
||||
for rows.Next() {
|
||||
var m domain.TeamMember
|
||||
if err := rows.Scan(&m.TeamID, &m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL, &m.Role, &m.JoinedAt); err == nil {
|
||||
members = append(members, m)
|
||||
}
|
||||
}
|
||||
return members, nil
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
// --- THOUGHTS & TRIAGE ---
|
||||
|
||||
func (s *Store) CreateThought(userID, content, priority, source, dueDate string) (*domain.Thought, error) {
|
||||
return s.CreateThoughtWithDetails(userID, content, priority, source, dueDate, "", "todo", "")
|
||||
}
|
||||
|
||||
func (s *Store) CreateThoughtWithDetails(userID, content, priority, source, dueDate, taskListID, kanbanStatus, notes string) (*domain.Thought, error) {
|
||||
content = strings.TrimSpace(content)
|
||||
dueDate = domain.NormalizeDue(dueDate)
|
||||
if content == "" {
|
||||
return nil, errors.New("content cannot be empty")
|
||||
}
|
||||
if priority == "" {
|
||||
priority = "normal"
|
||||
}
|
||||
if source == "" {
|
||||
source = "manual"
|
||||
}
|
||||
if kanbanStatus == "" {
|
||||
kanbanStatus = "todo"
|
||||
}
|
||||
if err := s.ensureTaskListWritable(userID, taskListID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags := domain.ExtractTags(content)
|
||||
if strings.EqualFold(source, "voice") {
|
||||
hasDiktat := false
|
||||
for _, tag := range tags {
|
||||
if strings.EqualFold(tag, "diktat") {
|
||||
hasDiktat = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasDiktat {
|
||||
tags = append(tags, "diktat")
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(source, "webhook") {
|
||||
hasWebhook := false
|
||||
for _, tag := range tags {
|
||||
if strings.EqualFold(tag, "webhook") {
|
||||
hasWebhook = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasWebhook {
|
||||
tags = append(tags, "webhook")
|
||||
}
|
||||
}
|
||||
tagsJSON, _ := json.Marshal(tags)
|
||||
|
||||
t := &domain.Thought{
|
||||
ID: domain.NewID("th"),
|
||||
UserID: userID,
|
||||
Content: content,
|
||||
Tags: tags,
|
||||
Priority: priority,
|
||||
Source: source,
|
||||
Status: domain.StatusInbox,
|
||||
DueDate: dueDate,
|
||||
TaskListID: taskListID,
|
||||
KanbanStatus: kanbanStatus,
|
||||
Notes: notes,
|
||||
CreatedAt: domain.NowISO(),
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO thoughts (id, user_id, content, tags, priority, source, status, is_completed, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, subtasks)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 0, ?, '[]')`,
|
||||
t.ID, t.UserID, t.Content, string(tagsJSON), t.Priority, t.Source, string(t.Status), t.DueDate, t.TaskListID, t.KanbanStatus, t.Notes, t.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Subtasks = []domain.SubTask{}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func scanThought(rows interface {
|
||||
Scan(dest ...any) error
|
||||
}) (*domain.Thought, error) {
|
||||
t := &domain.Thought{}
|
||||
var tagsStr, subtasksStr, statusStr string
|
||||
var isCompleted, alarmSent, sortOrder int
|
||||
err := rows.Scan(
|
||||
&t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source,
|
||||
&statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID,
|
||||
&t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent,
|
||||
&t.CreatedAt, &t.TriagedAt, &subtasksStr, &sortOrder, &t.Recurrence,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.SortOrder = sortOrder
|
||||
t.Status = domain.ThoughtStatus(statusStr)
|
||||
t.IsCompleted = isCompleted != 0
|
||||
t.AlarmSent = alarmSent != 0
|
||||
if t.KanbanStatus == "" {
|
||||
if t.IsCompleted {
|
||||
t.KanbanStatus = "done"
|
||||
} else {
|
||||
t.KanbanStatus = "todo"
|
||||
}
|
||||
}
|
||||
t.Tags = []string{}
|
||||
_ = json.Unmarshal([]byte(tagsStr), &t.Tags)
|
||||
if t.ConvertedType == "document_block" && strings.Contains(t.ConvertedRefID, ":") {
|
||||
parts := strings.SplitN(t.ConvertedRefID, ":", 2)
|
||||
t.DocID = parts[0]
|
||||
t.BlockID = parts[1]
|
||||
}
|
||||
t.Assignees = []string{}
|
||||
t.Subtasks = []domain.SubTask{}
|
||||
if subtasksStr != "" && subtasksStr != "null" {
|
||||
_ = json.Unmarshal([]byte(subtasksStr), &t.Subtasks)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (s *Store) accessibleTaskListIDsQuery() string {
|
||||
return `SELECT id FROM task_lists
|
||||
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 = ?))
|
||||
)`
|
||||
}
|
||||
|
||||
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, recurrence
|
||||
FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`,
|
||||
thoughtID, userID, userID, userID, userID,
|
||||
)
|
||||
t, err := scanThought(row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Assignees = s.getTaskAssignees(t.ID)
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Thought, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 200
|
||||
}
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
|
||||
if statusFilter != "" && statusFilter != "all" {
|
||||
rows, err = s.db.Query(
|
||||
`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, recurrence
|
||||
FROM thoughts WHERE user_id = ? AND status = ?
|
||||
ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, sort_order ASC, created_at DESC LIMIT ?`,
|
||||
userID, statusFilter, limit,
|
||||
)
|
||||
} else {
|
||||
rows, err = s.db.Query(
|
||||
`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, recurrence
|
||||
FROM thoughts WHERE user_id = ?
|
||||
ORDER BY sort_order ASC, created_at DESC LIMIT ?`,
|
||||
userID, limit,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]domain.Thought, 0)
|
||||
for rows.Next() {
|
||||
t, err := scanThought(rows)
|
||||
if err == nil {
|
||||
result = append(result, *t)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if taskListID != "" {
|
||||
rows, err = s.db.Query(
|
||||
`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, recurrence
|
||||
FROM thoughts
|
||||
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
||||
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, taskListID,
|
||||
)
|
||||
} else {
|
||||
rows, err = s.db.Query(
|
||||
`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, recurrence
|
||||
FROM thoughts
|
||||
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,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]domain.Thought, 0)
|
||||
for rows.Next() {
|
||||
t, err := scanThought(rows)
|
||||
if err == nil {
|
||||
result = append(result, *t)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
s.attachTaskAssignees(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderItem) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var completed []string // tasks that move into "done" now (repeating ones spawn their successor)
|
||||
for _, it := range items {
|
||||
if it.KanbanStatus != nil && *it.KanbanStatus != "" {
|
||||
isCompleted := 0
|
||||
if *it.KanbanStatus == "done" {
|
||||
isCompleted = 1
|
||||
var was int
|
||||
_ = tx.QueryRow(`SELECT is_completed FROM thoughts WHERE id = ?`, it.ID).Scan(&was)
|
||||
if was == 0 {
|
||||
completed = append(completed, it.ID)
|
||||
}
|
||||
}
|
||||
_, err = tx.Exec(
|
||||
`UPDATE thoughts SET sort_order = ?, kanban_status = ?, is_completed = ?,
|
||||
alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END WHERE id = ?`,
|
||||
it.SortOrder, *it.KanbanStatus, isCompleted, isCompleted, it.ID,
|
||||
)
|
||||
} else {
|
||||
_, err = tx.Exec(
|
||||
`UPDATE thoughts SET sort_order = ? WHERE id = ?`,
|
||||
it.SortOrder, it.ID,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range completed {
|
||||
s.spawnNextOccurrence(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateThoughtTriage handles swipe left (discard), swipe right (keep), restore, and conversion to task/document/list_item
|
||||
func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, targetListID, dueDate, taskListID string) (*domain.Thought, error) {
|
||||
thought, err := s.GetThoughtByID(userID, thoughtID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := domain.NowISO()
|
||||
newStatus := domain.StatusKept
|
||||
convertedType := thought.ConvertedType
|
||||
convertedRefID := thought.ConvertedRefID
|
||||
dueDate = domain.NormalizeDue(dueDate)
|
||||
if dueDate == "" {
|
||||
dueDate = thought.DueDate
|
||||
}
|
||||
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if taskListID == "" {
|
||||
taskListID = thought.TaskListID
|
||||
} else if err := s.ensureTaskListWritable(userID, taskListID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "discard":
|
||||
newStatus = domain.StatusDiscarded
|
||||
case "restore":
|
||||
newStatus = domain.StatusInbox
|
||||
case "keep":
|
||||
newStatus = domain.StatusKept
|
||||
if convertTo == "task" {
|
||||
newStatus = domain.StatusConverted
|
||||
convertedType = "task"
|
||||
} else if convertTo == "document" {
|
||||
newStatus = domain.StatusConverted
|
||||
convertedType = "document"
|
||||
title := thought.Content
|
||||
if len(title) > 72 {
|
||||
title = title[:72] + "…"
|
||||
}
|
||||
blocks := make([]domain.DocumentBlock, 0)
|
||||
|
||||
// 17. Wenn ich eine Aufgabe zu einer Notiz mache, muss die erste Zeile das "To-Do" noch mal als "To-Do" enthalten, auch wenn es schon in der Überschrift steht. Wenn es vorher schon abgehakt war, dann den Zustand natürlich übernehmen.
|
||||
blocks = append(blocks, domain.DocumentBlock{
|
||||
ID: domain.NewID("blk"),
|
||||
Type: "todo",
|
||||
Content: thought.Content,
|
||||
Checked: thought.IsCompleted,
|
||||
Level: 0,
|
||||
})
|
||||
|
||||
// 17a. Die anderen Details aus der Aufgabe sollen natürlich ebenfalls übernommen werden (Notizen, Subtasks, Fälligkeit/Priorität).
|
||||
if strings.TrimSpace(thought.Notes) != "" {
|
||||
blocks = append(blocks, domain.DocumentBlock{
|
||||
ID: domain.NewID("blk"),
|
||||
Type: "bullet",
|
||||
Content: strings.TrimSpace(thought.Notes),
|
||||
Level: 1,
|
||||
})
|
||||
}
|
||||
if len(thought.Subtasks) > 0 {
|
||||
for _, st := range thought.Subtasks {
|
||||
blocks = append(blocks, domain.DocumentBlock{
|
||||
ID: domain.NewID("blk"),
|
||||
Type: "todo",
|
||||
Content: st.Title,
|
||||
Checked: st.IsCompleted,
|
||||
Level: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
var metaItems []string
|
||||
if thought.DueDate != "" {
|
||||
metaItems = append(metaItems, "Fällig: "+domain.HumanDue(thought.DueDate))
|
||||
}
|
||||
if thought.Priority != "" && thought.Priority != "normal" {
|
||||
metaItems = append(metaItems, "Priorität: "+thought.Priority)
|
||||
}
|
||||
if len(metaItems) > 0 {
|
||||
blocks = append(blocks, domain.DocumentBlock{
|
||||
ID: domain.NewID("blk"),
|
||||
Type: "bullet",
|
||||
Content: strings.Join(metaItems, " • "),
|
||||
Level: 1,
|
||||
})
|
||||
}
|
||||
if len(blocks) == 1 {
|
||||
blocks = append(blocks, domain.DocumentBlock{
|
||||
ID: domain.NewID("blk"),
|
||||
Type: "bullet",
|
||||
Content: "",
|
||||
Level: 0,
|
||||
})
|
||||
}
|
||||
doc, err := s.CreateDocument(userID, "", title, "📄", "", blocks, thought.Tags, false)
|
||||
if err == nil && doc != nil {
|
||||
convertedRefID = doc.ID
|
||||
}
|
||||
} else if convertTo == "list_item" && targetListID != "" {
|
||||
newStatus = domain.StatusConverted
|
||||
convertedType = "list_item"
|
||||
item, err := s.CreateListItem(targetListID, thought.Content, "", "", 0, userID)
|
||||
if err == nil && item != nil {
|
||||
convertedRefID = item.ID
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid triage action: %s", action)
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(
|
||||
`UPDATE thoughts
|
||||
SET status = ?, converted_type = ?, converted_ref_id = ?, due_date = ?, task_list_id = ?, triaged_at = ?,
|
||||
alarm_sent = CASE WHEN due_date != ? THEN 0 ELSE alarm_sent END
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
string(newStatus), convertedType, convertedRefID, dueDate, taskListID, now, dueDate, thoughtID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if taskListID != thought.TaskListID {
|
||||
s.PruneTaskAssignees()
|
||||
}
|
||||
|
||||
return s.GetThoughtByID(userID, thoughtID)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string, isCompleted *bool, dueDate *string, priority *string, taskListID *string, kanbanStatus *string, notes *string, subtasks *[]domain.SubTask, tags *[]string) (*domain.Thought, error) {
|
||||
thought, err := s.GetThoughtByID(userID, thoughtID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wasCompleted := thought.IsCompleted
|
||||
oldDue := thought.DueDate
|
||||
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if taskListID != nil && *taskListID != thought.TaskListID {
|
||||
if err := s.ensureTaskListWritable(userID, *taskListID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if content != nil {
|
||||
thought.Content = strings.TrimSpace(*content)
|
||||
thought.Tags = domain.ExtractTags(thought.Content)
|
||||
}
|
||||
if tags != nil {
|
||||
thought.Tags = *tags
|
||||
}
|
||||
if isCompleted != nil {
|
||||
thought.IsCompleted = *isCompleted
|
||||
if *isCompleted {
|
||||
thought.KanbanStatus = "done"
|
||||
} else if thought.KanbanStatus == "done" {
|
||||
thought.KanbanStatus = "todo"
|
||||
}
|
||||
}
|
||||
if dueDate != nil {
|
||||
thought.DueDate = domain.NormalizeDue(*dueDate)
|
||||
}
|
||||
if priority != nil {
|
||||
p := strings.ToLower(strings.TrimSpace(*priority))
|
||||
if p == "" || p == "none" {
|
||||
p = "normal"
|
||||
}
|
||||
thought.Priority = p
|
||||
}
|
||||
if taskListID != nil {
|
||||
thought.TaskListID = *taskListID
|
||||
}
|
||||
if kanbanStatus != nil && *kanbanStatus != "" {
|
||||
thought.KanbanStatus = *kanbanStatus
|
||||
if isCompleted == nil {
|
||||
if *kanbanStatus == "done" {
|
||||
thought.IsCompleted = true
|
||||
} else {
|
||||
thought.IsCompleted = false
|
||||
}
|
||||
}
|
||||
}
|
||||
if notes != nil {
|
||||
thought.Notes = *notes
|
||||
}
|
||||
if subtasks != nil {
|
||||
thought.Subtasks = *subtasks
|
||||
}
|
||||
|
||||
tagsJSON, _ := json.Marshal(thought.Tags)
|
||||
subtasksJSON, _ := json.Marshal(thought.Subtasks)
|
||||
completedInt := 0
|
||||
if thought.IsCompleted {
|
||||
completedInt = 1
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(
|
||||
`UPDATE thoughts SET content = ?, tags = ?, is_completed = ?, due_date = ?, priority = ?, task_list_id = ?, kanban_status = ?, notes = ?, subtasks = ? WHERE id = ?`,
|
||||
thought.Content, string(tagsJSON), completedInt, thought.DueDate, thought.Priority, thought.TaskListID, thought.KanbanStatus, thought.Notes, string(subtasksJSON), thoughtID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A new due date or a reopened task gets its alarm again
|
||||
if thought.DueDate != oldDue || (wasCompleted && !thought.IsCompleted) {
|
||||
_, _ = s.db.Exec(`UPDATE thoughts SET alarm_sent = 0 WHERE id = ?`, thoughtID)
|
||||
thought.AlarmSent = false
|
||||
}
|
||||
if taskListID != nil {
|
||||
s.PruneTaskAssignees()
|
||||
thought.Assignees = s.getTaskAssignees(thoughtID)
|
||||
}
|
||||
if !wasCompleted && thought.IsCompleted {
|
||||
s.spawnNextOccurrence(thoughtID)
|
||||
}
|
||||
return thought, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteThought(userID, thoughtID string) error {
|
||||
thought, err := s.GetThoughtByID(userID, thoughtID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(`DELETE FROM thoughts WHERE id = ?`, thought.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) PurgeDiscardedThoughts(userID string) (int64, error) {
|
||||
res, err := s.db.Exec(`DELETE FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *Store) CreateTaskFromDocBlock(userID, docID, blockID, content string, checked bool) (*domain.Thought, error) {
|
||||
// Linking changes the block (its tick follows the task), so edit rights are needed
|
||||
if !CanEditDoc(s.DocRole(userID, docID)) {
|
||||
return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)")
|
||||
}
|
||||
t, err := s.CreateThoughtWithDetails(userID, content, "normal", "manual", "", "", "todo", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.ConvertedType = "document_block"
|
||||
t.ConvertedRefID = docID + ":" + blockID
|
||||
t.DocID = docID
|
||||
t.BlockID = blockID
|
||||
t.IsCompleted = checked
|
||||
t.Status = domain.StatusKept
|
||||
doneInt := 0
|
||||
if checked {
|
||||
doneInt = 1
|
||||
t.KanbanStatus = "done"
|
||||
}
|
||||
_, _ = s.db.Exec(`UPDATE thoughts SET converted_type = 'document_block', converted_ref_id = ?, is_completed = ?, kanban_status = ?, status = 'kept' WHERE id = ?`,
|
||||
t.ConvertedRefID, doneInt, t.KanbanStatus, t.ID)
|
||||
|
||||
s.SetBlockChecked(userID, docID, blockID, checked)
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// SyncDocBlockAndTask ticks the block a task was created from when the task is completed (or reopened).
|
||||
// Returns the note ID if the block changed, so the caller can notify its viewers.
|
||||
func (s *Store) SyncDocBlockAndTask(userID, taskID string, isCompleted bool) string {
|
||||
t, err := s.GetThoughtByID(userID, taskID)
|
||||
if err != nil || t == nil || t.ConvertedType != "document_block" || !strings.Contains(t.ConvertedRefID, ":") {
|
||||
return ""
|
||||
}
|
||||
docID, blockID, _ := strings.Cut(t.ConvertedRefID, ":")
|
||||
if s.SetBlockChecked(userID, docID, blockID, isCompleted) {
|
||||
return docID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// UnlinkTaskFromDocBlock removes the link between a task and its note block; both stay. The block's
|
||||
// task ID is derived from this link (docTaskLinks), so nothing in the note itself needs to change.
|
||||
func (s *Store) UnlinkTaskFromDocBlock(userID, taskID string) {
|
||||
if t, err := s.GetThoughtByID(userID, taskID); err != nil || t == nil {
|
||||
return
|
||||
}
|
||||
_, _ = s.db.Exec(`UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE id = ?`, taskID)
|
||||
}
|
||||
|
||||
func (s *Store) SyncTaskFromBlockCheck(userID, taskID string, checked bool) {
|
||||
if taskID == "" {
|
||||
return
|
||||
}
|
||||
doneInt := 0
|
||||
kanban := "todo"
|
||||
if checked {
|
||||
doneInt = 1
|
||||
kanban = "done"
|
||||
}
|
||||
res, _ := s.db.Exec(`UPDATE thoughts SET is_completed = ?, kanban_status = ?, alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END
|
||||
WHERE id = ? AND user_id = ? AND is_completed != ?`, doneInt, kanban, doneInt, taskID, userID, doneInt)
|
||||
if checked && res != nil {
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
s.spawnNextOccurrence(taskID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- ALARMS ---
|
||||
|
||||
type DueTaskAlarm struct {
|
||||
TaskID string
|
||||
UserID string
|
||||
Title string
|
||||
Notes string
|
||||
DueDate string
|
||||
Priority string
|
||||
UserEmail string
|
||||
AlarmChannels []string
|
||||
AlarmEmail string
|
||||
DiscordWebhookURL string
|
||||
CustomWebhookURL string
|
||||
NotifyBeforeMinutes int
|
||||
}
|
||||
|
||||
func (s *Store) GetDueTasksForAlarm() ([]DueTaskAlarm, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT t.id, t.user_id, t.content, t.notes, t.due_date, t.priority,
|
||||
u.email, u.alarm_channels, u.alarm_email, u.discord_webhook_url, u.custom_webhook_url, u.notify_before_minutes
|
||||
FROM thoughts t
|
||||
JOIN users u ON u.id = t.user_id
|
||||
WHERE (t.status = 'kept' OR (t.status = 'converted' AND t.converted_type = 'task'))
|
||||
AND t.is_completed = 0
|
||||
AND t.alarm_sent = 0
|
||||
AND t.due_date != ''
|
||||
AND u.is_active = 1`,
|
||||
)
|
||||
if err != nil {
|
||||
return []DueTaskAlarm{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
alarms := make([]DueTaskAlarm, 0)
|
||||
for rows.Next() {
|
||||
var a DueTaskAlarm
|
||||
var channelsRaw string
|
||||
if err := rows.Scan(
|
||||
&a.TaskID, &a.UserID, &a.Title, &a.Notes, &a.DueDate, &a.Priority,
|
||||
&a.UserEmail, &channelsRaw, &a.AlarmEmail, &a.DiscordWebhookURL, &a.CustomWebhookURL, &a.NotifyBeforeMinutes,
|
||||
); err == nil {
|
||||
a.AlarmChannels = parseAlarmChannels(channelsRaw)
|
||||
if a.AlarmEmail == "" {
|
||||
a.AlarmEmail = a.UserEmail
|
||||
}
|
||||
alarms = append(alarms, a)
|
||||
}
|
||||
}
|
||||
return alarms, nil
|
||||
}
|
||||
|
||||
func (s *Store) MarkTaskAlarmSent(taskID string) error {
|
||||
_, err := s.db.Exec(`UPDATE thoughts SET alarm_sent = 1 WHERE id = ?`, taskID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
// --- 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, organization_id, created_at`
|
||||
|
||||
func parseAlarmChannels(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
res := make([]string, 0, len(parts))
|
||||
seen := make(map[string]bool)
|
||||
for _, p := range parts {
|
||||
p = strings.ToLower(strings.TrimSpace(p))
|
||||
if (p == "email" || p == "discord" || p == "webhook") && !seen[p] {
|
||||
seen[p] = true
|
||||
res = append(res, p)
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) {
|
||||
u := &domain.User{}
|
||||
var isActiveInt int
|
||||
var channelsRaw string
|
||||
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.OrganizationID, &u.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.IsActive = isActiveInt != 0
|
||||
if u.TokenVersion <= 0 {
|
||||
u.TokenVersion = 1
|
||||
}
|
||||
if u.Role == "" {
|
||||
u.Role = "member"
|
||||
}
|
||||
u.AlarmChannels = parseAlarmChannels(channelsRaw)
|
||||
if u.AlarmEmail == "" {
|
||||
u.AlarmEmail = u.Email
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *Store) CountUsers() (int, error) {
|
||||
var count int
|
||||
err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (s *Store) CreateUser(email, passwordHash, displayName, role string) (*domain.User, error) {
|
||||
cleanEmail := strings.ToLower(strings.TrimSpace(email))
|
||||
username := strings.Split(cleanEmail, "@")[0]
|
||||
return s.CreateUserWithUsername(cleanEmail, username, passwordHash, displayName, role)
|
||||
}
|
||||
|
||||
func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayName, role string) (*domain.User, error) {
|
||||
if role != "admin" && role != "member" {
|
||||
role = "member"
|
||||
}
|
||||
cleanEmail := strings.ToLower(strings.TrimSpace(email))
|
||||
cleanUsername := strings.ToLower(strings.TrimSpace(username))
|
||||
if cleanUsername == "" {
|
||||
cleanUsername = strings.Split(cleanEmail, "@")[0]
|
||||
}
|
||||
u := &domain.User{
|
||||
ID: domain.NewID("usr"),
|
||||
Email: cleanEmail,
|
||||
Username: cleanUsername,
|
||||
PasswordHash: passwordHash,
|
||||
DisplayName: strings.TrimSpace(displayName),
|
||||
Role: role,
|
||||
IsActive: true,
|
||||
TokenVersion: 1,
|
||||
AlarmChannels: []string{},
|
||||
AlarmEmail: cleanEmail,
|
||||
DiscordWebhookURL: "",
|
||||
CustomWebhookURL: "",
|
||||
NotifyBeforeMinutes: 30,
|
||||
AvatarURL: "",
|
||||
CreatedAt: domain.NowISO(),
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO users (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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1, 1, '', ?, '', '', 30, '', ?)`,
|
||||
u.ID, u.Email, u.Username, u.PasswordHash, u.DisplayName, u.Role, u.AlarmEmail, u.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByEmail(email string) (*domain.User, error) {
|
||||
return s.GetUserByLogin(email)
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByLogin(login string) (*domain.User, error) {
|
||||
cleanLogin := strings.ToLower(strings.TrimSpace(login))
|
||||
if cleanLogin == "" {
|
||||
return nil, errors.New("benutzername oder e-mail darf nicht leer sein")
|
||||
}
|
||||
row := s.db.QueryRow(
|
||||
`SELECT `+userSelectCols+` FROM users
|
||||
WHERE LOWER(email) = ?
|
||||
OR (username != '' AND LOWER(username) = ?)
|
||||
OR LOWER(display_name) = ?
|
||||
OR (INSTR(email, '@') > 0 AND LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) = ?)
|
||||
LIMIT 1`,
|
||||
cleanLogin, cleanLogin, cleanLogin, cleanLogin,
|
||||
)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
// EnsureDemoAccounts seeds admin/admin and user/user accounts for demo evaluation
|
||||
func (s *Store) EnsureDemoAccounts() error {
|
||||
s.initMu.Lock()
|
||||
defer s.initMu.Unlock()
|
||||
|
||||
adminHash, err := auth.HashPassword("admin")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userHash, err := auth.HashPassword("user")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 1. Admin user (admin / admin)
|
||||
adminCreated := false
|
||||
adminUser, err := s.GetUserByLogin("admin")
|
||||
if err != nil || adminUser == nil {
|
||||
adminUser, err = s.CreateUserWithUsername("admin@mindsift.local", "admin", adminHash, "Admin", "admin")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create demo admin: %w", err)
|
||||
}
|
||||
adminCreated = true
|
||||
} else {
|
||||
_ = s.UpdateUserPassword(adminUser.ID, adminHash)
|
||||
_, _ = s.db.Exec(`UPDATE users SET is_active = 1, role = 'admin' WHERE id = ?`, adminUser.ID)
|
||||
}
|
||||
|
||||
// 2. Regular user (user / user)
|
||||
normalUser, err := s.GetUserByLogin("user")
|
||||
if err != nil || normalUser == nil {
|
||||
normalUser, err = s.CreateUserWithUsername("user@mindsift.local", "user", userHash, "User", "member")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create demo user: %w", err)
|
||||
}
|
||||
} else {
|
||||
_ = s.UpdateUserPassword(normalUser.ID, userHash)
|
||||
_, _ = 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). 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)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByID(id string) (*domain.User, error) {
|
||||
row := s.db.QueryRow(
|
||||
`SELECT `+userSelectCols+` FROM users WHERE id = ?`,
|
||||
id,
|
||||
)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func (s *Store) GetFirstUser() (*domain.User, error) {
|
||||
row := s.db.QueryRow(
|
||||
`SELECT ` + userSelectCols + ` FROM users ORDER BY created_at ASC LIMIT 1`,
|
||||
)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func (s *Store) GetAllUsers() ([]domain.User, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT ` + userSelectCols + ` FROM users ORDER BY created_at ASC`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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,
|
||||
isActive *bool,
|
||||
discordWebhookURL *string,
|
||||
notifyBeforeMinutes *int,
|
||||
) (*domain.User, error) {
|
||||
return s.UpdateUserFull(userID, displayName, email, role, isActive, nil, nil, discordWebhookURL, nil, notifyBeforeMinutes, nil)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateUserFull(
|
||||
userID string,
|
||||
displayName, email, role *string,
|
||||
isActive *bool,
|
||||
alarmChannels *[]string,
|
||||
alarmEmail, discordWebhookURL, customWebhookURL *string,
|
||||
notifyBeforeMinutes *int,
|
||||
avatarURL *string,
|
||||
) (*domain.User, error) {
|
||||
u, err := s.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if displayName != nil {
|
||||
u.DisplayName = strings.TrimSpace(*displayName)
|
||||
}
|
||||
if email != nil {
|
||||
u.Email = strings.ToLower(strings.TrimSpace(*email))
|
||||
}
|
||||
if role != nil && (*role == "admin" || *role == "member") {
|
||||
u.Role = *role
|
||||
}
|
||||
if isActive != nil {
|
||||
u.IsActive = *isActive
|
||||
}
|
||||
if alarmChannels != nil {
|
||||
u.AlarmChannels = parseAlarmChannels(strings.Join(*alarmChannels, ","))
|
||||
}
|
||||
if alarmEmail != nil {
|
||||
u.AlarmEmail = strings.TrimSpace(*alarmEmail)
|
||||
}
|
||||
if discordWebhookURL != nil {
|
||||
u.DiscordWebhookURL = strings.TrimSpace(*discordWebhookURL)
|
||||
}
|
||||
if customWebhookURL != nil {
|
||||
u.CustomWebhookURL = strings.TrimSpace(*customWebhookURL)
|
||||
}
|
||||
if notifyBeforeMinutes != nil {
|
||||
u.NotifyBeforeMinutes = *notifyBeforeMinutes
|
||||
}
|
||||
if avatarURL != nil {
|
||||
u.AvatarURL = strings.TrimSpace(*avatarURL)
|
||||
}
|
||||
if (role != nil && *role != u.Role) || (isActive != nil && !*isActive && u.IsActive) {
|
||||
u.TokenVersion++
|
||||
}
|
||||
activeInt := 0
|
||||
if u.IsActive {
|
||||
activeInt = 1
|
||||
}
|
||||
channelsStr := strings.Join(u.AlarmChannels, ",")
|
||||
_, err = s.db.Exec(
|
||||
`UPDATE users SET display_name = ?, email = ?, role = ?, is_active = ?, token_version = ?, alarm_channels = ?, alarm_email = ?, discord_webhook_url = ?, custom_webhook_url = ?, notify_before_minutes = ?, avatar_url = ? WHERE id = ?`,
|
||||
u.DisplayName, u.Email, u.Role, activeInt, u.TokenVersion, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, u.AvatarURL, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateUserPassword(userID, passwordHash string) error {
|
||||
_, err := s.db.Exec(`UPDATE users SET password_hash = ?, token_version = token_version + 1 WHERE id = ?`, passwordHash, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteUser(userID string) error {
|
||||
// Remove owned data properly first (other people's tasks in the user's lists stay theirs)
|
||||
if err := s.PurgeUserData(userID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = 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
|
||||
}
|
||||
|
||||
// RevokeToken adds an individual JWT to the blacklist until its expiration date
|
||||
func (s *Store) RevokeToken(tokenStr, userID string, expiresAt time.Time) error {
|
||||
hash := auth.HashAPIKey(tokenStr)
|
||||
_, err := s.db.Exec(
|
||||
`INSERT OR REPLACE INTO revoked_tokens (token_hash, user_id, expires_at, revoked_at) VALUES (?, ?, ?, ?)`,
|
||||
hash, userID, expiresAt.UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339),
|
||||
)
|
||||
// Idempotently prune old expired tokens
|
||||
_, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE expires_at < ?`, time.Now().UTC().Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
|
||||
// IsTokenRevoked checks if a specific JWT token has been explicitly blacklisted
|
||||
func (s *Store) IsTokenRevoked(tokenStr string) bool {
|
||||
hash := auth.HashAPIKey(tokenStr)
|
||||
var count int
|
||||
err := s.db.QueryRow(
|
||||
`SELECT COUNT(1) FROM revoked_tokens WHERE token_hash = ? AND expires_at >= ?`,
|
||||
hash, time.Now().UTC().Format(time.RFC3339),
|
||||
).Scan(&count)
|
||||
return err == nil && count > 0
|
||||
}
|
||||
|
||||
// RevokeAllUserTokens increments the user's token_version, instantly invalidating all existing JWTs for that user
|
||||
func (s *Store) RevokeAllUserTokens(userID string) error {
|
||||
_, err := s.db.Exec(`UPDATE users SET token_version = token_version + 1 WHERE id = ?`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- USER INVITES ---
|
||||
|
||||
func (s *Store) CreateUserInvite(createdBy, email, role, orgID string, validDays int) (*domain.UserInvite, error) {
|
||||
if role != "admin" && role != "member" {
|
||||
role = "member"
|
||||
}
|
||||
if validDays <= 0 || validDays > 365 {
|
||||
validDays = 7
|
||||
}
|
||||
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,
|
||||
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, organization_id, created_by, expires_at, used_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, '', ?)`,
|
||||
inv.ID, inv.Code, inv.Email, inv.Role, inv.OrganizationID, inv.CreatedBy, inv.ExpiresAt, inv.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetUserInvites() ([]domain.UserInvite, error) {
|
||||
rows, err := s.db.Query(
|
||||
`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 organizations o ON o.id = i.organization_id
|
||||
ORDER BY i.created_at DESC`,
|
||||
)
|
||||
if err != nil {
|
||||
return []domain.UserInvite{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
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.OrganizationID, &inv.OrganizationName, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt); err == nil {
|
||||
invites = append(invites, inv)
|
||||
}
|
||||
}
|
||||
return invites, nil
|
||||
}
|
||||
|
||||
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, 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.OrganizationID, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, errors.New("Ungültiger Einladungscode")
|
||||
}
|
||||
if inv.UsedBy != "" {
|
||||
return nil, errors.New("Dieser Einladungscode wurde bereits eingelöst")
|
||||
}
|
||||
if exp, err := time.Parse(time.RFC3339, inv.ExpiresAt); err == nil && time.Now().UTC().After(exp) {
|
||||
return nil, errors.New("Dieser Einladungscode ist abgelaufen")
|
||||
}
|
||||
return &inv, nil
|
||||
}
|
||||
|
||||
func (s *Store) MarkUserInviteUsed(inviteID, usedByUserID string) error {
|
||||
_, err := s.db.Exec(`UPDATE user_invites SET used_by = ? WHERE id = ?`, usedByUserID, inviteID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteUserInvite(inviteID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM user_invites WHERE id = ?`, inviteID)
|
||||
return err
|
||||
}
|
||||
Reference in new issue
Block a user