Files
Tomas Dvorak 3cb40adb23 first commit
2026-04-10 12:04:09 +02:00

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 }