mirror of
https://github.com/Dvorinka/Productier.git
synced 2026-06-04 12:33:01 +00:00
1071 lines
30 KiB
Go
1071 lines
30 KiB
Go
package store
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type Workspace struct {
|
|
ID string `json:"id"`
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
Role string `json:"role"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
type Member struct {
|
|
ID string `json:"id"`
|
|
WorkspaceSlug string `json:"workspaceSlug"`
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
Role string `json:"role"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type Invite struct {
|
|
ID string `json:"id"`
|
|
WorkspaceSlug string `json:"workspaceSlug"`
|
|
Email string `json:"email"`
|
|
Role string `json:"role"`
|
|
Token string `json:"token"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type ActivityEntry struct {
|
|
ID string `json:"id"`
|
|
WorkspaceSlug string `json:"workspaceSlug"`
|
|
Title string `json:"title"`
|
|
Detail string `json:"detail"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
type BoardGroup struct {
|
|
ID string `json:"id"`
|
|
WorkspaceSlug string `json:"workspaceSlug"`
|
|
Name string `json:"name"`
|
|
Color string `json:"color"`
|
|
Order int `json:"order"`
|
|
}
|
|
|
|
type Label struct {
|
|
ID string `json:"id"`
|
|
WorkspaceSlug string `json:"workspaceSlug"`
|
|
Name string `json:"name"`
|
|
Color string `json:"color"`
|
|
}
|
|
|
|
type Attachment struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
MimeType string `json:"mimeType"`
|
|
Size int `json:"size"`
|
|
DataURL string `json:"dataUrl,omitempty"`
|
|
}
|
|
|
|
type TaskComment struct {
|
|
ID string `json:"id"`
|
|
TaskID string `json:"taskId"`
|
|
Author string `json:"author"`
|
|
Content string `json:"content"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
type Task struct {
|
|
ID string `json:"id"`
|
|
WorkspaceSlug string `json:"workspaceSlug"`
|
|
BoardGroupID string `json:"boardGroupId"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description,omitempty"`
|
|
Status string `json:"status"`
|
|
Color string `json:"color"`
|
|
DueAt *time.Time `json:"dueAt,omitempty"`
|
|
ScheduledStart *time.Time `json:"scheduledStart,omitempty"`
|
|
ScheduledEnd *time.Time `json:"scheduledEnd,omitempty"`
|
|
AssigneeID *string `json:"assigneeId,omitempty"`
|
|
LabelIDs []string `json:"labelIds"`
|
|
Attachments []Attachment `json:"attachments"`
|
|
Comments []TaskComment `json:"comments"`
|
|
RecurrenceRule string `json:"recurrenceRule,omitempty"`
|
|
RecurrenceEnd *time.Time `json:"recurrenceEnd,omitempty"`
|
|
Archived bool `json:"archived"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
}
|
|
|
|
type CalendarEvent struct {
|
|
ID string `json:"id"`
|
|
WorkspaceSlug string `json:"workspaceSlug"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description,omitempty"`
|
|
StartsAt time.Time `json:"startsAt"`
|
|
EndsAt time.Time `json:"endsAt"`
|
|
Color string `json:"color"`
|
|
LinkedTaskID *string `json:"linkedTaskId,omitempty"`
|
|
Attachments []Attachment `json:"attachments"`
|
|
}
|
|
|
|
type Note struct {
|
|
ID string `json:"id"`
|
|
WorkspaceSlug string `json:"workspaceSlug"`
|
|
Title string `json:"title"`
|
|
Content string `json:"content"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
}
|
|
|
|
type FocusSession struct {
|
|
ID string `json:"id"`
|
|
WorkspaceSlug string `json:"workspaceSlug"`
|
|
TaskID *string `json:"taskId,omitempty"`
|
|
Mode string `json:"mode"`
|
|
StartedAt time.Time `json:"startedAt"`
|
|
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
|
PausedAt *time.Time `json:"pausedAt,omitempty"`
|
|
PausedTotalSeconds int `json:"pausedTotalSeconds"`
|
|
DurationSeconds int `json:"durationSeconds"`
|
|
}
|
|
|
|
type CreateBoardGroupInput struct {
|
|
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
|
Name string `json:"name" binding:"required"`
|
|
Color string `json:"color"`
|
|
}
|
|
|
|
type UpdateBoardGroupInput struct {
|
|
Name *string `json:"name"`
|
|
Color *string `json:"color"`
|
|
Order *int `json:"order"`
|
|
}
|
|
|
|
type CreateInviteInput struct {
|
|
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
|
Email string `json:"email" binding:"required"`
|
|
Role string `json:"role" binding:"required"`
|
|
}
|
|
|
|
type AcceptInviteInput struct {
|
|
Name string `json:"name" binding:"required"`
|
|
Email string `json:"email" binding:"required"`
|
|
}
|
|
|
|
type UpdateMemberInput struct {
|
|
Role *string `json:"role"`
|
|
Status *string `json:"status"`
|
|
}
|
|
|
|
type CreateLabelInput struct {
|
|
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
|
Name string `json:"name" binding:"required"`
|
|
Color string `json:"color" binding:"required"`
|
|
}
|
|
|
|
type CreateTaskInput struct {
|
|
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
|
BoardGroupID string `json:"boardGroupId" binding:"required"`
|
|
Title string `json:"title" binding:"required"`
|
|
Description string `json:"description"`
|
|
DueAt *time.Time `json:"dueAt"`
|
|
Color string `json:"color"`
|
|
}
|
|
|
|
type UpdateTaskInput struct {
|
|
Title *string `json:"title"`
|
|
Description *string `json:"description"`
|
|
Status *string `json:"status"`
|
|
BoardGroupID *string `json:"boardGroupId"`
|
|
Color *string `json:"color"`
|
|
DueAt *time.Time `json:"dueAt"`
|
|
ScheduledStart *time.Time `json:"scheduledStart"`
|
|
ScheduledEnd *time.Time `json:"scheduledEnd"`
|
|
AssigneeID *string `json:"assigneeId"`
|
|
LabelIDs []string `json:"labelIds"`
|
|
Attachments []Attachment `json:"attachments"`
|
|
Comments []TaskComment `json:"comments"`
|
|
RecurrenceRule *string `json:"recurrenceRule"`
|
|
RecurrenceEnd *time.Time `json:"recurrenceEnd"`
|
|
Archived *bool `json:"archived"`
|
|
}
|
|
|
|
type CreateEventInput struct {
|
|
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
|
Title string `json:"title" binding:"required"`
|
|
Description string `json:"description"`
|
|
StartsAt string `json:"startsAt" binding:"required"`
|
|
EndsAt string `json:"endsAt" binding:"required"`
|
|
LinkedTaskID *string `json:"linkedTaskId"`
|
|
Color string `json:"color"`
|
|
Attachments []Attachment `json:"attachments"`
|
|
}
|
|
|
|
type UpdateEventInput struct {
|
|
Title *string `json:"title"`
|
|
Description *string `json:"description"`
|
|
StartsAt *time.Time `json:"startsAt"`
|
|
EndsAt *time.Time `json:"endsAt"`
|
|
LinkedTaskID *string `json:"linkedTaskId"`
|
|
Color *string `json:"color"`
|
|
Attachments []Attachment `json:"attachments"`
|
|
}
|
|
|
|
type CreateNoteInput struct {
|
|
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
|
Title string `json:"title" binding:"required"`
|
|
Content string `json:"content" binding:"required"`
|
|
}
|
|
|
|
type UpdateNoteInput struct {
|
|
Title *string `json:"title"`
|
|
Content *string `json:"content"`
|
|
}
|
|
|
|
type CreateFocusSessionInput struct {
|
|
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
|
TaskID *string `json:"taskId"`
|
|
Mode string `json:"mode" binding:"required"`
|
|
DurationSeconds int `json:"durationSeconds" binding:"required"`
|
|
}
|
|
|
|
type UpdateFocusSessionInput struct {
|
|
CompletedAt *time.Time `json:"completedAt"`
|
|
PausedAt *time.Time `json:"pausedAt"`
|
|
PausedTotalSeconds *int `json:"pausedTotalSeconds"`
|
|
}
|
|
|
|
type State struct {
|
|
mu sync.RWMutex
|
|
Mode string
|
|
Workspaces []Workspace
|
|
Members []Member
|
|
Invites []Invite
|
|
Activities []ActivityEntry
|
|
BoardGroups []BoardGroup
|
|
Labels []Label
|
|
Tasks []Task
|
|
Events []CalendarEvent
|
|
Notes []Note
|
|
FocusSessions []FocusSession
|
|
Mailboxes []Mailbox
|
|
MailboxAuth map[string]MailboxConnection
|
|
MailMessages []MailMessage
|
|
OutgoingMails []OutgoingMail
|
|
}
|
|
|
|
func NewSeededState(mode string) *State {
|
|
now := time.Now().UTC()
|
|
workspace := Workspace{
|
|
ID: uuid.NewString(),
|
|
Slug: "personal",
|
|
Name: "Personal HQ",
|
|
Role: "owner",
|
|
CreatedAt: now.Add(-72 * time.Hour),
|
|
}
|
|
|
|
boardGroups := []BoardGroup{
|
|
{ID: "group-inbox", WorkspaceSlug: workspace.Slug, Name: "Inbox", Color: "slate", Order: 0},
|
|
{ID: "group-doing", WorkspaceSlug: workspace.Slug, Name: "Doing", Color: "rose", Order: 1},
|
|
{ID: "group-review", WorkspaceSlug: workspace.Slug, Name: "Review", Color: "amber", Order: 2},
|
|
{ID: "group-done", WorkspaceSlug: workspace.Slug, Name: "Done", Color: "emerald", Order: 3},
|
|
}
|
|
|
|
labels := []Label{
|
|
{ID: "label-launch", WorkspaceSlug: workspace.Slug, Name: "Launch", Color: "rose"},
|
|
{ID: "label-ui", WorkspaceSlug: workspace.Slug, Name: "UI", Color: "amber"},
|
|
{ID: "label-deep", WorkspaceSlug: workspace.Slug, Name: "Deep Work", Color: "emerald"},
|
|
{ID: "label-admin", WorkspaceSlug: workspace.Slug, Name: "Admin", Color: "blue"},
|
|
}
|
|
|
|
taskDue := now.Add(10 * time.Hour)
|
|
taskStart := time.Date(now.Year(), now.Month(), now.Day(), 9, 30, 0, 0, time.UTC)
|
|
taskEnd := taskStart.Add(90 * time.Minute)
|
|
linkedTaskID := uuid.NewString()
|
|
completed := now.Add(-2 * time.Hour)
|
|
assigneeID := "member-1"
|
|
members := []Member{
|
|
{ID: "member-1", WorkspaceSlug: workspace.Slug, Name: "Taylor", Email: "taylor@productier.app", Role: "owner", Status: "active"},
|
|
{ID: "member-2", WorkspaceSlug: workspace.Slug, Name: "Alex", Email: "alex@productier.app", Role: "member", Status: "active"},
|
|
}
|
|
invites := []Invite{
|
|
{
|
|
ID: "invite-1",
|
|
WorkspaceSlug: workspace.Slug,
|
|
Email: "jamie@productier.app",
|
|
Role: "member",
|
|
Token: "invite-jamie",
|
|
CreatedAt: now.Add(-12 * time.Hour),
|
|
Status: "pending",
|
|
},
|
|
}
|
|
activities := []ActivityEntry{
|
|
{
|
|
ID: "activity-1",
|
|
WorkspaceSlug: workspace.Slug,
|
|
Title: "Board updated",
|
|
Detail: "Task moved into Review.",
|
|
CreatedAt: now.Add(-4 * time.Hour),
|
|
},
|
|
{
|
|
ID: "activity-2",
|
|
WorkspaceSlug: workspace.Slug,
|
|
Title: "Invite created",
|
|
Detail: "Jamie was invited to Personal HQ.",
|
|
CreatedAt: now.Add(-10 * time.Hour),
|
|
},
|
|
}
|
|
|
|
return &State{
|
|
Mode: mode,
|
|
Workspaces: []Workspace{workspace},
|
|
Members: members,
|
|
Invites: invites,
|
|
Activities: activities,
|
|
BoardGroups: boardGroups,
|
|
Labels: labels,
|
|
Tasks: []Task{
|
|
{
|
|
ID: linkedTaskID,
|
|
WorkspaceSlug: workspace.Slug,
|
|
BoardGroupID: "group-doing",
|
|
Title: "Finalize Productier foundation",
|
|
Description: "Shape the workspace shell, polish the board, and keep the Koffan calmness without copying the layout.",
|
|
Status: "in_progress",
|
|
Color: "rose",
|
|
DueAt: &taskDue,
|
|
ScheduledStart: &taskStart,
|
|
ScheduledEnd: &taskEnd,
|
|
AssigneeID: &assigneeID,
|
|
LabelIDs: []string{"label-launch", "label-ui"},
|
|
Attachments: []Attachment{},
|
|
Comments: []TaskComment{
|
|
{
|
|
ID: uuid.NewString(),
|
|
TaskID: linkedTaskID,
|
|
Author: "Alex",
|
|
Content: "Keep the calendar dense, but not cramped.",
|
|
CreatedAt: now.Add(-5 * time.Hour),
|
|
},
|
|
},
|
|
CreatedAt: now.Add(-24 * time.Hour),
|
|
UpdatedAt: now.Add(-2 * time.Hour),
|
|
},
|
|
},
|
|
Events: []CalendarEvent{
|
|
{
|
|
ID: uuid.NewString(),
|
|
WorkspaceSlug: workspace.Slug,
|
|
Title: "Design review",
|
|
Description: "Refine the calendar density and modal patterns.",
|
|
StartsAt: time.Date(now.Year(), now.Month(), now.Day(), 13, 0, 0, 0, time.UTC),
|
|
EndsAt: time.Date(now.Year(), now.Month(), now.Day(), 14, 0, 0, 0, time.UTC),
|
|
Color: "amber",
|
|
LinkedTaskID: &linkedTaskID,
|
|
Attachments: []Attachment{},
|
|
},
|
|
},
|
|
Notes: []Note{
|
|
{
|
|
ID: uuid.NewString(),
|
|
WorkspaceSlug: workspace.Slug,
|
|
Title: "Calm UI direction",
|
|
Content: "Use soft stone surfaces, controlled color, and denser layouts than Koffan. Keep interactions obvious and low-friction.",
|
|
UpdatedAt: now.Add(-90 * time.Minute),
|
|
},
|
|
},
|
|
FocusSessions: []FocusSession{
|
|
{
|
|
ID: uuid.NewString(),
|
|
WorkspaceSlug: workspace.Slug,
|
|
TaskID: &linkedTaskID,
|
|
Mode: "focus",
|
|
StartedAt: now.Add(-55 * time.Minute),
|
|
CompletedAt: &completed,
|
|
PausedTotalSeconds: 0,
|
|
DurationSeconds: 1500,
|
|
},
|
|
},
|
|
Mailboxes: []Mailbox{},
|
|
MailboxAuth: map[string]MailboxConnection{},
|
|
MailMessages: []MailMessage{},
|
|
OutgoingMails: []OutgoingMail{},
|
|
}
|
|
}
|
|
|
|
func (s *State) ListWorkspaces() []Workspace {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return append([]Workspace(nil), s.Workspaces...)
|
|
}
|
|
|
|
func (s *State) ListMembers(workspaceSlug string) []Member {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return filterByWorkspace(s.Members, workspaceSlug)
|
|
}
|
|
|
|
func (s *State) GetMemberByID(memberID string) (Member, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
for _, member := range s.Members {
|
|
if member.ID == memberID {
|
|
return member, nil
|
|
}
|
|
}
|
|
return Member{}, errors.New("member not found")
|
|
}
|
|
|
|
func (s *State) UpdateMember(memberID string, input UpdateMemberInput) (Member, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for index, member := range s.Members {
|
|
if member.ID != memberID {
|
|
continue
|
|
}
|
|
|
|
nextRole := member.Role
|
|
if input.Role != nil {
|
|
if !isValidWorkspaceRole(*input.Role) {
|
|
return Member{}, errors.New("invalid member role")
|
|
}
|
|
nextRole = *input.Role
|
|
}
|
|
|
|
nextStatus := member.Status
|
|
if input.Status != nil {
|
|
if !isValidMemberStatus(*input.Status) {
|
|
return Member{}, errors.New("invalid member status")
|
|
}
|
|
nextStatus = *input.Status
|
|
}
|
|
|
|
if member.Role == "owner" && member.Status == "active" && (nextRole != "owner" || nextStatus != "active") {
|
|
activeOwnerCount := 0
|
|
for _, candidate := range s.Members {
|
|
if candidate.WorkspaceSlug == member.WorkspaceSlug && candidate.Role == "owner" && candidate.Status == "active" {
|
|
activeOwnerCount++
|
|
}
|
|
}
|
|
if activeOwnerCount <= 1 {
|
|
return Member{}, errors.New("workspace must have at least one active owner")
|
|
}
|
|
}
|
|
|
|
member.Role = nextRole
|
|
member.Status = nextStatus
|
|
s.Members[index] = member
|
|
s.appendActivityLocked(member.WorkspaceSlug, "Member updated", fmt.Sprintf("%s membership settings changed.", member.Email))
|
|
return member, nil
|
|
}
|
|
return Member{}, errors.New("member not found")
|
|
}
|
|
|
|
func (s *State) ListInvites(workspaceSlug string) []Invite {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return filterByWorkspace(s.Invites, workspaceSlug)
|
|
}
|
|
|
|
func (s *State) GetInviteByID(inviteID string) (Invite, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
for _, invite := range s.Invites {
|
|
if invite.ID == inviteID {
|
|
return invite, nil
|
|
}
|
|
}
|
|
return Invite{}, errors.New("invite not found")
|
|
}
|
|
|
|
func (s *State) GetInviteByToken(token string) (Invite, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
for _, invite := range s.Invites {
|
|
if invite.Token == token {
|
|
return invite, nil
|
|
}
|
|
}
|
|
|
|
return Invite{}, errors.New("invite not found")
|
|
}
|
|
|
|
func (s *State) CreateInvite(input CreateInviteInput) Invite {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
invite := Invite{
|
|
ID: uuid.NewString(),
|
|
WorkspaceSlug: input.WorkspaceSlug,
|
|
Email: input.Email,
|
|
Role: defaultString(input.Role, "member"),
|
|
Token: "join-" + uuid.NewString(),
|
|
CreatedAt: time.Now().UTC(),
|
|
Status: "pending",
|
|
}
|
|
s.Invites = append([]Invite{invite}, s.Invites...)
|
|
s.appendActivityLocked(input.WorkspaceSlug, "Invite created", fmt.Sprintf("Shared workspace access with %s.", input.Email))
|
|
return invite
|
|
}
|
|
|
|
func (s *State) RevokeInvite(inviteID string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for index, invite := range s.Invites {
|
|
if invite.ID != inviteID {
|
|
continue
|
|
}
|
|
if invite.Status != "pending" {
|
|
return errors.New("only pending invites can be revoked")
|
|
}
|
|
s.Invites = append(s.Invites[:index], s.Invites[index+1:]...)
|
|
s.appendActivityLocked(invite.WorkspaceSlug, "Invite revoked", fmt.Sprintf("Invite for %s was revoked.", invite.Email))
|
|
return nil
|
|
}
|
|
|
|
return errors.New("invite not found")
|
|
}
|
|
|
|
func (s *State) AcceptInvite(token string, input AcceptInviteInput) (Invite, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for index, invite := range s.Invites {
|
|
if invite.Token != token {
|
|
continue
|
|
}
|
|
invite.Status = "accepted"
|
|
s.Invites[index] = invite
|
|
|
|
memberExists := false
|
|
for _, member := range s.Members {
|
|
if member.WorkspaceSlug == invite.WorkspaceSlug && member.Email == input.Email {
|
|
memberExists = true
|
|
break
|
|
}
|
|
}
|
|
if !memberExists {
|
|
s.Members = append([]Member{{
|
|
ID: uuid.NewString(),
|
|
WorkspaceSlug: invite.WorkspaceSlug,
|
|
Name: input.Name,
|
|
Email: input.Email,
|
|
Role: invite.Role,
|
|
Status: "active",
|
|
}}, s.Members...)
|
|
}
|
|
|
|
s.appendActivityLocked(invite.WorkspaceSlug, "Invite accepted", fmt.Sprintf("%s joined the workspace.", input.Email))
|
|
return invite, nil
|
|
}
|
|
|
|
return Invite{}, errors.New("invite not found")
|
|
}
|
|
|
|
func (s *State) ListActivities(workspaceSlug string) []ActivityEntry {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return filterByWorkspace(s.Activities, workspaceSlug)
|
|
}
|
|
|
|
func (s *State) ListBoardGroups(workspaceSlug string) []BoardGroup {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return filterByWorkspace(s.BoardGroups, workspaceSlug)
|
|
}
|
|
|
|
func (s *State) GetBoardGroupByID(groupID string) (BoardGroup, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
for _, group := range s.BoardGroups {
|
|
if group.ID == groupID {
|
|
return group, nil
|
|
}
|
|
}
|
|
|
|
return BoardGroup{}, errors.New("board group not found")
|
|
}
|
|
|
|
func (s *State) CreateBoardGroup(input CreateBoardGroupInput) BoardGroup {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
order := 0
|
|
for _, group := range s.BoardGroups {
|
|
if group.WorkspaceSlug == input.WorkspaceSlug {
|
|
order++
|
|
}
|
|
}
|
|
|
|
group := BoardGroup{
|
|
ID: uuid.NewString(),
|
|
WorkspaceSlug: input.WorkspaceSlug,
|
|
Name: input.Name,
|
|
Color: defaultString(input.Color, "slate"),
|
|
Order: order,
|
|
}
|
|
s.BoardGroups = append(s.BoardGroups, group)
|
|
s.appendActivityLocked(input.WorkspaceSlug, "Board group added", fmt.Sprintf("%s is ready for new tasks.", input.Name))
|
|
return group
|
|
}
|
|
|
|
func (s *State) rebalanceBoardGroupOrderLocked(workspaceSlug string) {
|
|
groups := make([]BoardGroup, 0)
|
|
for _, group := range s.BoardGroups {
|
|
if group.WorkspaceSlug == workspaceSlug {
|
|
groups = append(groups, group)
|
|
}
|
|
}
|
|
|
|
sort.SliceStable(groups, func(i, j int) bool {
|
|
if groups[i].Order == groups[j].Order {
|
|
return groups[i].Name < groups[j].Name
|
|
}
|
|
return groups[i].Order < groups[j].Order
|
|
})
|
|
|
|
nextByID := make(map[string]BoardGroup, len(groups))
|
|
for index, group := range groups {
|
|
group.Order = index
|
|
nextByID[group.ID] = group
|
|
}
|
|
|
|
for index, group := range s.BoardGroups {
|
|
updated, ok := nextByID[group.ID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
s.BoardGroups[index] = updated
|
|
}
|
|
}
|
|
|
|
func (s *State) UpdateBoardGroup(groupID string, input UpdateBoardGroupInput) (BoardGroup, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for index, group := range s.BoardGroups {
|
|
if group.ID != groupID {
|
|
continue
|
|
}
|
|
|
|
if input.Name != nil {
|
|
group.Name = *input.Name
|
|
}
|
|
if input.Color != nil {
|
|
group.Color = *input.Color
|
|
}
|
|
if input.Order != nil {
|
|
nextOrder := *input.Order
|
|
if nextOrder < 0 {
|
|
nextOrder = 0
|
|
}
|
|
group.Order = nextOrder
|
|
}
|
|
|
|
s.BoardGroups[index] = group
|
|
if input.Order != nil {
|
|
s.rebalanceBoardGroupOrderLocked(group.WorkspaceSlug)
|
|
for _, current := range s.BoardGroups {
|
|
if current.ID == groupID {
|
|
group = current
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
s.appendActivityLocked(group.WorkspaceSlug, "Board group updated", fmt.Sprintf("%s settings changed.", group.Name))
|
|
return group, nil
|
|
}
|
|
|
|
return BoardGroup{}, errors.New("board group not found")
|
|
}
|
|
|
|
func (s *State) ListLabels(workspaceSlug string) []Label {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return filterByWorkspace(s.Labels, workspaceSlug)
|
|
}
|
|
|
|
func (s *State) CreateLabel(input CreateLabelInput) Label {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
label := Label{
|
|
ID: uuid.NewString(),
|
|
WorkspaceSlug: input.WorkspaceSlug,
|
|
Name: input.Name,
|
|
Color: input.Color,
|
|
}
|
|
s.Labels = append(s.Labels, label)
|
|
s.appendActivityLocked(input.WorkspaceSlug, "Label added", fmt.Sprintf("%s is now available across the workspace.", input.Name))
|
|
return label
|
|
}
|
|
|
|
func (s *State) ListTasks(workspaceSlug string) []Task {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return filterByWorkspace(s.Tasks, workspaceSlug)
|
|
}
|
|
|
|
func (s *State) GetTaskByID(taskID string) (Task, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
for _, task := range s.Tasks {
|
|
if task.ID == taskID {
|
|
return task, nil
|
|
}
|
|
}
|
|
|
|
return Task{}, errors.New("task not found")
|
|
}
|
|
|
|
func (s *State) CreateTask(input CreateTaskInput) Task {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
now := time.Now().UTC()
|
|
task := Task{
|
|
ID: uuid.NewString(),
|
|
WorkspaceSlug: input.WorkspaceSlug,
|
|
BoardGroupID: input.BoardGroupID,
|
|
Title: input.Title,
|
|
Description: input.Description,
|
|
Status: "todo",
|
|
Color: defaultString(input.Color, "slate"),
|
|
DueAt: input.DueAt,
|
|
LabelIDs: []string{},
|
|
Attachments: []Attachment{},
|
|
Comments: []TaskComment{},
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
s.Tasks = append(s.Tasks, task)
|
|
s.appendActivityLocked(input.WorkspaceSlug, "Task created", input.Title)
|
|
return task
|
|
}
|
|
|
|
func (s *State) UpdateTask(taskID string, input UpdateTaskInput) (Task, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for index, task := range s.Tasks {
|
|
if task.ID != taskID {
|
|
continue
|
|
}
|
|
|
|
if input.Title != nil {
|
|
task.Title = *input.Title
|
|
}
|
|
if input.Description != nil {
|
|
task.Description = *input.Description
|
|
}
|
|
if input.Status != nil {
|
|
task.Status = *input.Status
|
|
}
|
|
if input.BoardGroupID != nil {
|
|
task.BoardGroupID = *input.BoardGroupID
|
|
}
|
|
if input.Color != nil {
|
|
task.Color = *input.Color
|
|
}
|
|
if input.DueAt != nil {
|
|
task.DueAt = input.DueAt
|
|
}
|
|
if input.ScheduledStart != nil {
|
|
task.ScheduledStart = input.ScheduledStart
|
|
}
|
|
if input.ScheduledEnd != nil {
|
|
task.ScheduledEnd = input.ScheduledEnd
|
|
}
|
|
if input.AssigneeID != nil {
|
|
task.AssigneeID = input.AssigneeID
|
|
}
|
|
if input.LabelIDs != nil {
|
|
task.LabelIDs = input.LabelIDs
|
|
}
|
|
if input.Attachments != nil {
|
|
task.Attachments = input.Attachments
|
|
}
|
|
if input.Comments != nil {
|
|
task.Comments = input.Comments
|
|
}
|
|
task.UpdatedAt = time.Now().UTC()
|
|
s.Tasks[index] = task
|
|
s.appendActivityLocked(task.WorkspaceSlug, "Task updated", task.Title)
|
|
return task, nil
|
|
}
|
|
|
|
return Task{}, errors.New("task not found")
|
|
}
|
|
|
|
func (s *State) ListEvents(workspaceSlug string) []CalendarEvent {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return filterByWorkspace(s.Events, workspaceSlug)
|
|
}
|
|
|
|
func (s *State) GetEventByID(eventID string) (CalendarEvent, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
for _, event := range s.Events {
|
|
if event.ID == eventID {
|
|
return event, nil
|
|
}
|
|
}
|
|
|
|
return CalendarEvent{}, errors.New("event not found")
|
|
}
|
|
|
|
func (s *State) CreateEvent(input CreateEventInput) CalendarEvent {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
startsAt, _ := time.Parse(time.RFC3339, input.StartsAt)
|
|
endsAt, _ := time.Parse(time.RFC3339, input.EndsAt)
|
|
|
|
event := CalendarEvent{
|
|
ID: uuid.NewString(),
|
|
WorkspaceSlug: input.WorkspaceSlug,
|
|
Title: input.Title,
|
|
Description: input.Description,
|
|
StartsAt: startsAt,
|
|
EndsAt: endsAt,
|
|
Color: defaultString(input.Color, "blue"),
|
|
LinkedTaskID: input.LinkedTaskID,
|
|
Attachments: input.Attachments,
|
|
}
|
|
|
|
s.Events = append(s.Events, event)
|
|
s.appendActivityLocked(input.WorkspaceSlug, "Event created", input.Title)
|
|
return event
|
|
}
|
|
|
|
func (s *State) UpdateEvent(eventID string, input UpdateEventInput) (CalendarEvent, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for index, event := range s.Events {
|
|
if event.ID != eventID {
|
|
continue
|
|
}
|
|
if input.Title != nil {
|
|
event.Title = *input.Title
|
|
}
|
|
if input.Description != nil {
|
|
event.Description = *input.Description
|
|
}
|
|
if input.StartsAt != nil {
|
|
event.StartsAt = *input.StartsAt
|
|
}
|
|
if input.EndsAt != nil {
|
|
event.EndsAt = *input.EndsAt
|
|
}
|
|
if input.LinkedTaskID != nil {
|
|
event.LinkedTaskID = input.LinkedTaskID
|
|
}
|
|
if input.Color != nil {
|
|
event.Color = *input.Color
|
|
}
|
|
if input.Attachments != nil {
|
|
event.Attachments = input.Attachments
|
|
}
|
|
s.Events[index] = event
|
|
s.appendActivityLocked(event.WorkspaceSlug, "Event updated", event.Title)
|
|
return event, nil
|
|
}
|
|
|
|
return CalendarEvent{}, errors.New("event not found")
|
|
}
|
|
|
|
func (s *State) ListNotes(workspaceSlug string) []Note {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return filterByWorkspace(s.Notes, workspaceSlug)
|
|
}
|
|
|
|
func (s *State) GetNoteByID(noteID string) (Note, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
for _, note := range s.Notes {
|
|
if note.ID == noteID {
|
|
return note, nil
|
|
}
|
|
}
|
|
|
|
return Note{}, errors.New("note not found")
|
|
}
|
|
|
|
func (s *State) CreateNote(input CreateNoteInput) Note {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
note := Note{
|
|
ID: uuid.NewString(),
|
|
WorkspaceSlug: input.WorkspaceSlug,
|
|
Title: input.Title,
|
|
Content: input.Content,
|
|
UpdatedAt: time.Now().UTC(),
|
|
}
|
|
s.Notes = append(s.Notes, note)
|
|
s.appendActivityLocked(input.WorkspaceSlug, "Note created", input.Title)
|
|
return note
|
|
}
|
|
|
|
func (s *State) UpdateNote(noteID string, input UpdateNoteInput) (Note, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for index, note := range s.Notes {
|
|
if note.ID != noteID {
|
|
continue
|
|
}
|
|
if input.Title != nil {
|
|
note.Title = *input.Title
|
|
}
|
|
if input.Content != nil {
|
|
note.Content = *input.Content
|
|
}
|
|
note.UpdatedAt = time.Now().UTC()
|
|
s.Notes[index] = note
|
|
s.appendActivityLocked(note.WorkspaceSlug, "Note updated", note.Title)
|
|
return note, nil
|
|
}
|
|
|
|
return Note{}, errors.New("note not found")
|
|
}
|
|
|
|
func (s *State) ListFocusSessions(workspaceSlug string) []FocusSession {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return filterByWorkspace(s.FocusSessions, workspaceSlug)
|
|
}
|
|
|
|
func (s *State) GetFocusSessionByID(sessionID string) (FocusSession, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
for _, session := range s.FocusSessions {
|
|
if session.ID == sessionID {
|
|
return session, nil
|
|
}
|
|
}
|
|
|
|
return FocusSession{}, errors.New("focus session not found")
|
|
}
|
|
|
|
func (s *State) CreateFocusSession(input CreateFocusSessionInput) FocusSession {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
session := FocusSession{
|
|
ID: uuid.NewString(),
|
|
WorkspaceSlug: input.WorkspaceSlug,
|
|
TaskID: input.TaskID,
|
|
Mode: input.Mode,
|
|
StartedAt: time.Now().UTC(),
|
|
PausedTotalSeconds: 0,
|
|
DurationSeconds: input.DurationSeconds,
|
|
}
|
|
s.FocusSessions = append(s.FocusSessions, session)
|
|
s.appendActivityLocked(input.WorkspaceSlug, "Focus session started", input.Mode)
|
|
return session
|
|
}
|
|
|
|
func (s *State) UpdateFocusSession(sessionID string, input UpdateFocusSessionInput) (FocusSession, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for index, session := range s.FocusSessions {
|
|
if session.ID != sessionID {
|
|
continue
|
|
}
|
|
if input.CompletedAt != nil {
|
|
session.CompletedAt = input.CompletedAt
|
|
}
|
|
if input.PausedAt != nil {
|
|
session.PausedAt = input.PausedAt
|
|
}
|
|
if input.PausedTotalSeconds != nil {
|
|
session.PausedTotalSeconds = *input.PausedTotalSeconds
|
|
}
|
|
s.FocusSessions[index] = session
|
|
s.appendActivityLocked(session.WorkspaceSlug, "Focus session updated", session.Mode)
|
|
return session, nil
|
|
}
|
|
|
|
return FocusSession{}, errors.New("focus session not found")
|
|
}
|
|
|
|
func filterByWorkspace[T interface{ GetWorkspaceSlug() string }](items []T, workspaceSlug string) []T {
|
|
if workspaceSlug == "" {
|
|
return append([]T(nil), items...)
|
|
}
|
|
|
|
filtered := make([]T, 0, len(items))
|
|
for _, item := range items {
|
|
if item.GetWorkspaceSlug() == workspaceSlug {
|
|
filtered = append(filtered, item)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func defaultString(value string, fallback string) string {
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|
|
|
|
func isValidWorkspaceRole(role string) bool {
|
|
switch role {
|
|
case "owner", "admin", "member":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isValidMemberStatus(status string) bool {
|
|
switch status {
|
|
case "active", "removed":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *State) appendActivityLocked(workspaceSlug string, title string, detail string) {
|
|
entry := ActivityEntry{
|
|
ID: uuid.NewString(),
|
|
WorkspaceSlug: workspaceSlug,
|
|
Title: title,
|
|
Detail: detail,
|
|
CreatedAt: time.Now().UTC(),
|
|
}
|
|
s.Activities = append([]ActivityEntry{entry}, s.Activities...)
|
|
if len(s.Activities) > 40 {
|
|
s.Activities = s.Activities[:40]
|
|
}
|
|
}
|
|
|
|
func (item Member) GetWorkspaceSlug() string { return item.WorkspaceSlug }
|
|
func (item Invite) GetWorkspaceSlug() string { return item.WorkspaceSlug }
|
|
func (item ActivityEntry) GetWorkspaceSlug() string { return item.WorkspaceSlug }
|
|
func (item BoardGroup) GetWorkspaceSlug() string { return item.WorkspaceSlug }
|
|
func (item Label) GetWorkspaceSlug() string { return item.WorkspaceSlug }
|
|
func (item Task) GetWorkspaceSlug() string { return item.WorkspaceSlug }
|
|
func (item CalendarEvent) GetWorkspaceSlug() string { return item.WorkspaceSlug }
|
|
func (item Note) GetWorkspaceSlug() string { return item.WorkspaceSlug }
|
|
func (item FocusSession) GetWorkspaceSlug() string { return item.WorkspaceSlug }
|