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

1488 lines
36 KiB
Go

package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"time"
"github.com/google/uuid"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
db "productier/apps/backend/internal/db/generated"
)
type PostgresStore struct {
db *sql.DB
queries *db.Queries
}
func NewPostgresStore(databaseURL string, mode string) (*PostgresStore, error) {
sqlDB, err := sql.Open("pgx", databaseURL)
if err != nil {
return nil, fmt.Errorf("open postgres connection: %w", err)
}
if err := sqlDB.Ping(); err != nil {
return nil, fmt.Errorf("ping postgres: %w", err)
}
if err := goose.SetDialect("postgres"); err != nil {
return nil, fmt.Errorf("set goose dialect: %w", err)
}
if err := goose.Up(sqlDB, migrationsDir()); err != nil {
return nil, fmt.Errorf("run migrations: %w", err)
}
store := &PostgresStore{
db: sqlDB,
queries: db.New(sqlDB),
}
if err := store.seedIfEmpty(mode); err != nil {
return nil, fmt.Errorf("seed postgres store: %w", err)
}
return store, nil
}
func migrationsDir() string {
if configured := filepath.Clean(os.Getenv("DB_MIGRATIONS_DIR")); configured != "" && configured != "." {
if info, err := os.Stat(configured); err == nil && info.IsDir() {
return configured
}
}
_, file, _, _ := runtime.Caller(0)
sourcePath := filepath.Join(filepath.Dir(file), "..", "db", "migrations")
if info, err := os.Stat(sourcePath); err == nil && info.IsDir() {
return sourcePath
}
if executablePath, err := os.Executable(); err == nil {
executableDir := filepath.Dir(executablePath)
executableRelative := filepath.Join(executableDir, "migrations")
if info, statErr := os.Stat(executableRelative); statErr == nil && info.IsDir() {
return executableRelative
}
}
return sourcePath
}
func (s *PostgresStore) seedIfEmpty(mode string) error {
ctx := context.Background()
count, err := s.queries.CountWorkspaces(ctx)
if err != nil {
return err
}
if count > 0 {
return nil
}
seed := NewSeededState(mode)
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
queries := s.queries.WithTx(tx)
for _, workspace := range seed.Workspaces {
err = queries.CreateWorkspace(ctx, db.CreateWorkspaceParams{
ID: workspace.ID,
Slug: workspace.Slug,
Name: workspace.Name,
Role: workspace.Role,
CreatedAt: workspace.CreatedAt,
})
if err != nil {
_ = tx.Rollback()
return err
}
}
for _, member := range seed.Members {
err = queries.CreateMember(ctx, db.CreateMemberParams{
ID: member.ID,
WorkspaceSlug: member.WorkspaceSlug,
Name: member.Name,
Email: member.Email,
Role: member.Role,
Status: member.Status,
})
if err != nil {
_ = tx.Rollback()
return err
}
}
for _, invite := range seed.Invites {
_, err = queries.CreateInvite(ctx, db.CreateInviteParams{
ID: invite.ID,
WorkspaceSlug: invite.WorkspaceSlug,
Email: invite.Email,
Role: invite.Role,
Token: invite.Token,
CreatedAt: invite.CreatedAt,
Status: invite.Status,
})
if err != nil {
_ = tx.Rollback()
return err
}
}
for _, activity := range seed.Activities {
err = queries.CreateActivity(ctx, db.CreateActivityParams{
ID: activity.ID,
WorkspaceSlug: activity.WorkspaceSlug,
Title: activity.Title,
Detail: activity.Detail,
CreatedAt: activity.CreatedAt,
})
if err != nil {
_ = tx.Rollback()
return err
}
}
for _, group := range seed.BoardGroups {
_, err = queries.CreateBoardGroup(ctx, db.CreateBoardGroupParams{
ID: group.ID,
WorkspaceSlug: group.WorkspaceSlug,
Name: group.Name,
Color: group.Color,
SortOrder: int32(group.Order),
})
if err != nil {
_ = tx.Rollback()
return err
}
}
for _, label := range seed.Labels {
_, err = queries.CreateLabel(ctx, db.CreateLabelParams{
ID: label.ID,
WorkspaceSlug: label.WorkspaceSlug,
Name: label.Name,
Color: label.Color,
})
if err != nil {
_ = tx.Rollback()
return err
}
}
for _, task := range seed.Tasks {
_, err = queries.CreateTask(ctx, db.CreateTaskParams{
ID: task.ID,
WorkspaceSlug: task.WorkspaceSlug,
BoardGroupID: task.BoardGroupID,
Title: task.Title,
Description: task.Description,
Status: task.Status,
Color: task.Color,
DueAt: nullableTime(task.DueAt),
ScheduledStart: nullableTime(task.ScheduledStart),
ScheduledEnd: nullableTime(task.ScheduledEnd),
AssigneeID: nullableString(task.AssigneeID),
LabelIds: mustJSON(task.LabelIDs),
Attachments: mustJSON(task.Attachments),
Comments: mustJSON(task.Comments),
CreatedAt: task.CreatedAt,
UpdatedAt: task.UpdatedAt,
})
if err != nil {
_ = tx.Rollback()
return err
}
}
for _, event := range seed.Events {
_, err = queries.CreateCalendarEvent(ctx, db.CreateCalendarEventParams{
ID: event.ID,
WorkspaceSlug: event.WorkspaceSlug,
Title: event.Title,
Description: event.Description,
StartsAt: event.StartsAt,
EndsAt: event.EndsAt,
Color: event.Color,
LinkedTaskID: nullableString(event.LinkedTaskID),
Attachments: mustJSON(event.Attachments),
})
if err != nil {
_ = tx.Rollback()
return err
}
}
for _, note := range seed.Notes {
_, err = queries.CreateNote(ctx, db.CreateNoteParams{
ID: note.ID,
WorkspaceSlug: note.WorkspaceSlug,
Title: note.Title,
Content: note.Content,
UpdatedAt: note.UpdatedAt,
})
if err != nil {
_ = tx.Rollback()
return err
}
}
for _, session := range seed.FocusSessions {
_, err = queries.CreateFocusSession(ctx, db.CreateFocusSessionParams{
ID: session.ID,
WorkspaceSlug: session.WorkspaceSlug,
TaskID: nullableString(session.TaskID),
Mode: session.Mode,
StartedAt: session.StartedAt,
CompletedAt: nullableTime(session.CompletedAt),
PausedAt: nullableTime(session.PausedAt),
PausedTotalSeconds: int32(session.PausedTotalSeconds),
DurationSeconds: int32(session.DurationSeconds),
})
if err != nil {
_ = tx.Rollback()
return err
}
}
return tx.Commit()
}
func (s *PostgresStore) ListWorkspaces() []Workspace {
rows, err := s.queries.ListWorkspaces(context.Background())
if err != nil {
panic(err)
}
items := make([]Workspace, 0, len(rows))
for _, row := range rows {
items = append(items, Workspace{
ID: row.ID,
Slug: row.Slug,
Name: row.Name,
Role: row.Role,
CreatedAt: row.CreatedAt,
})
}
return items
}
func (s *PostgresStore) ListMembers(workspaceSlug string) []Member {
rows, err := s.queries.ListMembers(context.Background(), workspaceSlug)
if err != nil {
panic(err)
}
items := make([]Member, 0, len(rows))
for _, row := range rows {
items = append(items, Member{
ID: row.ID,
WorkspaceSlug: row.WorkspaceSlug,
Name: row.Name,
Email: row.Email,
Role: row.Role,
Status: row.Status,
})
}
return items
}
func (s *PostgresStore) GetMemberByID(memberID string) (Member, error) {
row := s.db.QueryRowContext(context.Background(), `
SELECT id, workspace_slug, name, email, role, status
FROM members
WHERE id = $1
`, memberID)
var member Member
if err := row.Scan(&member.ID, &member.WorkspaceSlug, &member.Name, &member.Email, &member.Role, &member.Status); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Member{}, errors.New("member not found")
}
return Member{}, err
}
return member, nil
}
func (s *PostgresStore) UpdateMember(memberID string, input UpdateMemberInput) (Member, error) {
ctx := context.Background()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return Member{}, err
}
currentRow := tx.QueryRowContext(ctx, `
SELECT id, workspace_slug, name, email, role, status
FROM members
WHERE id = $1
FOR UPDATE
`, memberID)
var current Member
if err := currentRow.Scan(&current.ID, &current.WorkspaceSlug, &current.Name, &current.Email, &current.Role, &current.Status); err != nil {
_ = tx.Rollback()
if errors.Is(err, sql.ErrNoRows) {
return Member{}, errors.New("member not found")
}
return Member{}, err
}
nextRole := current.Role
if input.Role != nil {
if !isValidWorkspaceRole(*input.Role) {
_ = tx.Rollback()
return Member{}, errors.New("invalid member role")
}
nextRole = *input.Role
}
nextStatus := current.Status
if input.Status != nil {
if !isValidMemberStatus(*input.Status) {
_ = tx.Rollback()
return Member{}, errors.New("invalid member status")
}
nextStatus = *input.Status
}
if current.Role == "owner" && current.Status == "active" && (nextRole != "owner" || nextStatus != "active") {
var activeOwnerCount int64
if err := tx.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM members
WHERE workspace_slug = $1 AND role = 'owner' AND status = 'active'
`, current.WorkspaceSlug).Scan(&activeOwnerCount); err != nil {
_ = tx.Rollback()
return Member{}, err
}
if activeOwnerCount <= 1 {
_ = tx.Rollback()
return Member{}, errors.New("workspace must have at least one active owner")
}
}
updatedRow := tx.QueryRowContext(ctx, `
UPDATE members
SET role = $2,
status = $3
WHERE id = $1
RETURNING id, workspace_slug, name, email, role, status
`, memberID, nextRole, nextStatus)
var updated Member
if err := updatedRow.Scan(&updated.ID, &updated.WorkspaceSlug, &updated.Name, &updated.Email, &updated.Role, &updated.Status); err != nil {
_ = tx.Rollback()
return Member{}, err
}
queries := s.queries.WithTx(tx)
if err := appendActivity(ctx, queries, updated.WorkspaceSlug, "Member updated", fmt.Sprintf("%s membership settings changed.", updated.Email)); err != nil {
_ = tx.Rollback()
return Member{}, err
}
if err := tx.Commit(); err != nil {
return Member{}, err
}
return updated, nil
}
func (s *PostgresStore) ListInvites(workspaceSlug string) []Invite {
rows, err := s.queries.ListInvites(context.Background(), workspaceSlug)
if err != nil {
panic(err)
}
items := make([]Invite, 0, len(rows))
for _, row := range rows {
items = append(items, inviteFromDB(row))
}
return items
}
func (s *PostgresStore) GetInviteByID(inviteID string) (Invite, error) {
row := s.db.QueryRowContext(context.Background(), `
SELECT id, workspace_slug, email, role, token, created_at, status
FROM invites
WHERE id = $1
`, inviteID)
var invite Invite
if err := row.Scan(&invite.ID, &invite.WorkspaceSlug, &invite.Email, &invite.Role, &invite.Token, &invite.CreatedAt, &invite.Status); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Invite{}, errors.New("invite not found")
}
return Invite{}, err
}
return invite, nil
}
func (s *PostgresStore) GetInviteByToken(token string) (Invite, error) {
row, err := s.queries.GetInviteByToken(context.Background(), token)
if err != nil {
return Invite{}, err
}
return inviteFromDB(row), nil
}
func (s *PostgresStore) CreateInvite(input CreateInviteInput) Invite {
ctx := context.Background()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
panic(err)
}
queries := s.queries.WithTx(tx)
invite, err := queries.CreateInvite(ctx, db.CreateInviteParams{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
Email: input.Email,
Role: defaultString(input.Role, "member"),
Token: "join-" + uuid.NewString(),
CreatedAt: time.Now().UTC(),
Status: "pending",
})
if err != nil {
_ = tx.Rollback()
panic(err)
}
if err := appendActivity(ctx, queries, input.WorkspaceSlug, "Invite created", fmt.Sprintf("Shared workspace access with %s.", input.Email)); err != nil {
_ = tx.Rollback()
panic(err)
}
if err := tx.Commit(); err != nil {
panic(err)
}
return inviteFromDB(invite)
}
func (s *PostgresStore) RevokeInvite(inviteID string) error {
ctx := context.Background()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
row := tx.QueryRowContext(ctx, `
SELECT id, workspace_slug, email, role, token, created_at, status
FROM invites
WHERE id = $1
FOR UPDATE
`, inviteID)
var invite Invite
if err := row.Scan(&invite.ID, &invite.WorkspaceSlug, &invite.Email, &invite.Role, &invite.Token, &invite.CreatedAt, &invite.Status); err != nil {
_ = tx.Rollback()
if errors.Is(err, sql.ErrNoRows) {
return errors.New("invite not found")
}
return err
}
if invite.Status != "pending" {
_ = tx.Rollback()
return errors.New("only pending invites can be revoked")
}
if _, err := tx.ExecContext(ctx, `DELETE FROM invites WHERE id = $1`, inviteID); err != nil {
_ = tx.Rollback()
return err
}
queries := s.queries.WithTx(tx)
if err := appendActivity(ctx, queries, invite.WorkspaceSlug, "Invite revoked", fmt.Sprintf("Invite for %s was revoked.", invite.Email)); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
func (s *PostgresStore) AcceptInvite(token string, input AcceptInviteInput) (Invite, error) {
ctx := context.Background()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return Invite{}, err
}
queries := s.queries.WithTx(tx)
invite, err := queries.AcceptInvite(ctx, token)
if err != nil {
_ = tx.Rollback()
return Invite{}, err
}
_, err = queries.GetMemberByWorkspaceAndEmail(ctx, db.GetMemberByWorkspaceAndEmailParams{
WorkspaceSlug: invite.WorkspaceSlug,
Email: input.Email,
})
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
_ = tx.Rollback()
return Invite{}, err
}
err = queries.CreateMember(ctx, db.CreateMemberParams{
ID: uuid.NewString(),
WorkspaceSlug: invite.WorkspaceSlug,
Name: input.Name,
Email: input.Email,
Role: invite.Role,
Status: "active",
})
if err != nil {
_ = tx.Rollback()
return Invite{}, err
}
}
if err := appendActivity(ctx, queries, invite.WorkspaceSlug, "Invite accepted", fmt.Sprintf("%s joined the workspace.", input.Email)); err != nil {
_ = tx.Rollback()
return Invite{}, err
}
if err := tx.Commit(); err != nil {
return Invite{}, err
}
return inviteFromDB(invite), nil
}
func (s *PostgresStore) ListActivities(workspaceSlug string) []ActivityEntry {
rows, err := s.queries.ListActivities(context.Background(), workspaceSlug)
if err != nil {
panic(err)
}
items := make([]ActivityEntry, 0, len(rows))
for _, row := range rows {
items = append(items, ActivityEntry{
ID: row.ID,
WorkspaceSlug: row.WorkspaceSlug,
Title: row.Title,
Detail: row.Detail,
CreatedAt: row.CreatedAt,
})
}
return items
}
func (s *PostgresStore) ListBoardGroups(workspaceSlug string) []BoardGroup {
rows, err := s.queries.ListBoardGroups(context.Background(), workspaceSlug)
if err != nil {
panic(err)
}
items := make([]BoardGroup, 0, len(rows))
for _, row := range rows {
items = append(items, BoardGroup{
ID: row.ID,
WorkspaceSlug: row.WorkspaceSlug,
Name: row.Name,
Color: row.Color,
Order: int(row.SortOrder),
})
}
return items
}
func (s *PostgresStore) GetBoardGroupByID(groupID string) (BoardGroup, error) {
row, err := s.queries.GetBoardGroupByID(context.Background(), groupID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return BoardGroup{}, errors.New("board group not found")
}
panic(err)
}
return BoardGroup{
ID: row.ID,
WorkspaceSlug: row.WorkspaceSlug,
Name: row.Name,
Color: row.Color,
Order: int(row.SortOrder),
}, nil
}
func (s *PostgresStore) CreateBoardGroup(input CreateBoardGroupInput) BoardGroup {
ctx := context.Background()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
panic(err)
}
queries := s.queries.WithTx(tx)
count, err := queries.CountBoardGroupsByWorkspace(ctx, input.WorkspaceSlug)
if err != nil {
_ = tx.Rollback()
panic(err)
}
row, err := queries.CreateBoardGroup(ctx, db.CreateBoardGroupParams{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
Name: input.Name,
Color: defaultString(input.Color, "slate"),
SortOrder: int32(count),
})
if err != nil {
_ = tx.Rollback()
panic(err)
}
if err := appendActivity(ctx, queries, input.WorkspaceSlug, "Board group added", fmt.Sprintf("%s is ready for new tasks.", input.Name)); err != nil {
_ = tx.Rollback()
panic(err)
}
if err := tx.Commit(); err != nil {
panic(err)
}
return BoardGroup{
ID: row.ID,
WorkspaceSlug: row.WorkspaceSlug,
Name: row.Name,
Color: row.Color,
Order: int(row.SortOrder),
}
}
func (s *PostgresStore) UpdateBoardGroup(groupID string, input UpdateBoardGroupInput) (BoardGroup, error) {
ctx := context.Background()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
panic(err)
}
queries := s.queries.WithTx(tx)
current, err := queries.GetBoardGroupByID(ctx, groupID)
if err != nil {
_ = tx.Rollback()
if errors.Is(err, sql.ErrNoRows) {
return BoardGroup{}, errors.New("board group not found")
}
panic(err)
}
nextName := current.Name
if input.Name != nil {
nextName = *input.Name
}
nextColor := current.Color
if input.Color != nil {
nextColor = *input.Color
}
nextOrder := int(current.SortOrder)
if input.Order != nil {
nextOrder = *input.Order
if nextOrder < 0 {
nextOrder = 0
}
}
row, err := queries.UpdateBoardGroup(ctx, db.UpdateBoardGroupParams{
ID: current.ID,
Name: nextName,
Color: nextColor,
SortOrder: int32(nextOrder),
})
if err != nil {
_ = tx.Rollback()
panic(err)
}
if input.Order != nil {
rows, err := queries.ListBoardGroups(ctx, row.WorkspaceSlug)
if err != nil {
_ = tx.Rollback()
panic(err)
}
for index, group := range rows {
if int(group.SortOrder) == index {
continue
}
if _, err := queries.UpdateBoardGroup(ctx, db.UpdateBoardGroupParams{
ID: group.ID,
Name: group.Name,
Color: group.Color,
SortOrder: int32(index),
}); err != nil {
_ = tx.Rollback()
panic(err)
}
if group.ID == row.ID {
row.SortOrder = int32(index)
}
}
}
if err := appendActivity(ctx, queries, row.WorkspaceSlug, "Board group updated", fmt.Sprintf("%s settings changed.", row.Name)); err != nil {
_ = tx.Rollback()
panic(err)
}
if err := tx.Commit(); err != nil {
panic(err)
}
return BoardGroup{
ID: row.ID,
WorkspaceSlug: row.WorkspaceSlug,
Name: row.Name,
Color: row.Color,
Order: int(row.SortOrder),
}, nil
}
func (s *PostgresStore) ListLabels(workspaceSlug string) []Label {
rows, err := s.queries.ListLabels(context.Background(), workspaceSlug)
if err != nil {
panic(err)
}
items := make([]Label, 0, len(rows))
for _, row := range rows {
items = append(items, Label{
ID: row.ID,
WorkspaceSlug: row.WorkspaceSlug,
Name: row.Name,
Color: row.Color,
})
}
return items
}
func (s *PostgresStore) CreateLabel(input CreateLabelInput) Label {
ctx := context.Background()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
panic(err)
}
queries := s.queries.WithTx(tx)
row, err := queries.CreateLabel(ctx, db.CreateLabelParams{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
Name: input.Name,
Color: input.Color,
})
if err != nil {
_ = tx.Rollback()
panic(err)
}
if err := appendActivity(ctx, queries, input.WorkspaceSlug, "Label added", fmt.Sprintf("%s is now available across the workspace.", input.Name)); err != nil {
_ = tx.Rollback()
panic(err)
}
if err := tx.Commit(); err != nil {
panic(err)
}
return Label{
ID: row.ID,
WorkspaceSlug: row.WorkspaceSlug,
Name: row.Name,
Color: row.Color,
}
}
func (s *PostgresStore) ListTasks(workspaceSlug string) []Task {
rows, err := s.queries.ListTasks(context.Background(), workspaceSlug)
if err != nil {
panic(err)
}
items := make([]Task, 0, len(rows))
for _, row := range rows {
items = append(items, taskFromDB(row))
}
return items
}
func (s *PostgresStore) GetTaskByID(taskID string) (Task, error) {
row, err := s.queries.GetTaskByID(context.Background(), taskID)
if err != nil {
return Task{}, err
}
return taskFromDB(row), nil
}
func (s *PostgresStore) CreateTask(input CreateTaskInput) Task {
ctx := context.Background()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
panic(err)
}
queries := s.queries.WithTx(tx)
now := time.Now().UTC()
row, err := queries.CreateTask(ctx, db.CreateTaskParams{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
BoardGroupID: input.BoardGroupID,
Title: input.Title,
Description: input.Description,
Status: "todo",
Color: defaultString(input.Color, "slate"),
DueAt: nullableTime(input.DueAt),
ScheduledStart: sql.NullTime{},
ScheduledEnd: sql.NullTime{},
AssigneeID: sql.NullString{},
LabelIds: mustJSON([]string{}),
Attachments: mustJSON([]Attachment{}),
Comments: mustJSON([]TaskComment{}),
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
_ = tx.Rollback()
panic(err)
}
if err := appendActivity(ctx, queries, input.WorkspaceSlug, "Task created", input.Title); err != nil {
_ = tx.Rollback()
panic(err)
}
if err := tx.Commit(); err != nil {
panic(err)
}
return taskFromDB(row)
}
func (s *PostgresStore) UpdateTask(taskID string, input UpdateTaskInput) (Task, error) {
ctx := context.Background()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return Task{}, err
}
queries := s.queries.WithTx(tx)
current, err := queries.GetTaskByID(ctx, taskID)
if err != nil {
_ = tx.Rollback()
return Task{}, err
}
labelIDs, err := decodeJSONSlice[string](current.LabelIds)
if err != nil {
_ = tx.Rollback()
return Task{}, err
}
attachments, err := decodeJSONSlice[Attachment](current.Attachments)
if err != nil {
_ = tx.Rollback()
return Task{}, err
}
comments, err := decodeJSONSlice[TaskComment](current.Comments)
if err != nil {
_ = tx.Rollback()
return Task{}, err
}
title := current.Title
if input.Title != nil {
title = *input.Title
}
description := current.Description
if input.Description != nil {
description = *input.Description
}
status := current.Status
if input.Status != nil {
status = *input.Status
}
boardGroupID := current.BoardGroupID
if input.BoardGroupID != nil {
boardGroupID = *input.BoardGroupID
}
color := current.Color
if input.Color != nil {
color = *input.Color
}
dueAt := current.DueAt
if input.DueAt != nil {
dueAt = nullableTime(input.DueAt)
}
scheduledStart := current.ScheduledStart
if input.ScheduledStart != nil {
scheduledStart = nullableTime(input.ScheduledStart)
}
scheduledEnd := current.ScheduledEnd
if input.ScheduledEnd != nil {
scheduledEnd = nullableTime(input.ScheduledEnd)
}
assigneeID := current.AssigneeID
if input.AssigneeID != nil {
assigneeID = nullableString(input.AssigneeID)
}
if input.LabelIDs != nil {
labelIDs = input.LabelIDs
}
if input.Attachments != nil {
attachments = input.Attachments
}
if input.Comments != nil {
comments = input.Comments
}
row, err := queries.UpdateTask(ctx, db.UpdateTaskParams{
ID: taskID,
Title: title,
Description: description,
Status: status,
BoardGroupID: boardGroupID,
Color: color,
DueAt: dueAt,
ScheduledStart: scheduledStart,
ScheduledEnd: scheduledEnd,
AssigneeID: assigneeID,
LabelIds: mustJSON(labelIDs),
Attachments: mustJSON(attachments),
Comments: mustJSON(comments),
UpdatedAt: time.Now().UTC(),
})
if err != nil {
_ = tx.Rollback()
return Task{}, err
}
if err := appendActivity(ctx, queries, row.WorkspaceSlug, "Task updated", row.Title); err != nil {
_ = tx.Rollback()
return Task{}, err
}
if err := tx.Commit(); err != nil {
return Task{}, err
}
return taskFromDB(row), nil
}
func (s *PostgresStore) ListEvents(workspaceSlug string) []CalendarEvent {
rows, err := s.queries.ListCalendarEvents(context.Background(), workspaceSlug)
if err != nil {
panic(err)
}
items := make([]CalendarEvent, 0, len(rows))
for _, row := range rows {
items = append(items, eventFromDB(row))
}
return items
}
func (s *PostgresStore) GetEventByID(eventID string) (CalendarEvent, error) {
row, err := s.queries.GetCalendarEventByID(context.Background(), eventID)
if err != nil {
return CalendarEvent{}, err
}
return eventFromDB(row), nil
}
func (s *PostgresStore) CreateEvent(input CreateEventInput) CalendarEvent {
ctx := context.Background()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
panic(err)
}
startsAt, err := time.Parse(time.RFC3339, input.StartsAt)
if err != nil {
_ = tx.Rollback()
panic(err)
}
endsAt, err := time.Parse(time.RFC3339, input.EndsAt)
if err != nil {
_ = tx.Rollback()
panic(err)
}
queries := s.queries.WithTx(tx)
row, err := queries.CreateCalendarEvent(ctx, db.CreateCalendarEventParams{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
Title: input.Title,
Description: input.Description,
StartsAt: startsAt,
EndsAt: endsAt,
Color: defaultString(input.Color, "blue"),
LinkedTaskID: nullableString(input.LinkedTaskID),
Attachments: mustJSON(input.Attachments),
})
if err != nil {
_ = tx.Rollback()
panic(err)
}
if err := appendActivity(ctx, queries, input.WorkspaceSlug, "Event created", input.Title); err != nil {
_ = tx.Rollback()
panic(err)
}
if err := tx.Commit(); err != nil {
panic(err)
}
return eventFromDB(row)
}
func (s *PostgresStore) UpdateEvent(eventID string, input UpdateEventInput) (CalendarEvent, error) {
ctx := context.Background()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return CalendarEvent{}, err
}
queries := s.queries.WithTx(tx)
current, err := queries.GetCalendarEventByID(ctx, eventID)
if err != nil {
_ = tx.Rollback()
return CalendarEvent{}, err
}
attachments, err := decodeJSONSlice[Attachment](current.Attachments)
if err != nil {
_ = tx.Rollback()
return CalendarEvent{}, err
}
title := current.Title
if input.Title != nil {
title = *input.Title
}
description := current.Description
if input.Description != nil {
description = *input.Description
}
startsAt := current.StartsAt
if input.StartsAt != nil {
startsAt = *input.StartsAt
}
endsAt := current.EndsAt
if input.EndsAt != nil {
endsAt = *input.EndsAt
}
color := current.Color
if input.Color != nil {
color = *input.Color
}
linkedTaskID := current.LinkedTaskID
if input.LinkedTaskID != nil {
linkedTaskID = nullableString(input.LinkedTaskID)
}
if input.Attachments != nil {
attachments = input.Attachments
}
row, err := queries.UpdateCalendarEvent(ctx, db.UpdateCalendarEventParams{
ID: eventID,
Title: title,
Description: description,
StartsAt: startsAt,
EndsAt: endsAt,
Color: color,
LinkedTaskID: linkedTaskID,
Attachments: mustJSON(attachments),
})
if err != nil {
_ = tx.Rollback()
return CalendarEvent{}, err
}
if err := appendActivity(ctx, queries, row.WorkspaceSlug, "Event updated", row.Title); err != nil {
_ = tx.Rollback()
return CalendarEvent{}, err
}
if err := tx.Commit(); err != nil {
return CalendarEvent{}, err
}
return eventFromDB(row), nil
}
func (s *PostgresStore) ListNotes(workspaceSlug string) []Note {
rows, err := s.queries.ListNotes(context.Background(), workspaceSlug)
if err != nil {
panic(err)
}
items := make([]Note, 0, len(rows))
for _, row := range rows {
items = append(items, Note{
ID: row.ID,
WorkspaceSlug: row.WorkspaceSlug,
Title: row.Title,
Content: row.Content,
UpdatedAt: row.UpdatedAt,
})
}
return items
}
func (s *PostgresStore) GetNoteByID(noteID string) (Note, error) {
row, err := s.queries.GetNoteByID(context.Background(), noteID)
if err != nil {
return Note{}, err
}
return Note{
ID: row.ID,
WorkspaceSlug: row.WorkspaceSlug,
Title: row.Title,
Content: row.Content,
UpdatedAt: row.UpdatedAt,
}, nil
}
func (s *PostgresStore) CreateNote(input CreateNoteInput) Note {
ctx := context.Background()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
panic(err)
}
queries := s.queries.WithTx(tx)
row, err := queries.CreateNote(ctx, db.CreateNoteParams{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
Title: input.Title,
Content: input.Content,
UpdatedAt: time.Now().UTC(),
})
if err != nil {
_ = tx.Rollback()
panic(err)
}
if err := appendActivity(ctx, queries, input.WorkspaceSlug, "Note created", input.Title); err != nil {
_ = tx.Rollback()
panic(err)
}
if err := tx.Commit(); err != nil {
panic(err)
}
return Note{
ID: row.ID,
WorkspaceSlug: row.WorkspaceSlug,
Title: row.Title,
Content: row.Content,
UpdatedAt: row.UpdatedAt,
}
}
func (s *PostgresStore) UpdateNote(noteID string, input UpdateNoteInput) (Note, error) {
ctx := context.Background()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return Note{}, err
}
queries := s.queries.WithTx(tx)
current, err := queries.GetNoteByID(ctx, noteID)
if err != nil {
_ = tx.Rollback()
return Note{}, err
}
title := current.Title
if input.Title != nil {
title = *input.Title
}
content := current.Content
if input.Content != nil {
content = *input.Content
}
row, err := queries.UpdateNote(ctx, db.UpdateNoteParams{
ID: noteID,
Title: title,
Content: content,
UpdatedAt: time.Now().UTC(),
})
if err != nil {
_ = tx.Rollback()
return Note{}, err
}
if err := appendActivity(ctx, queries, row.WorkspaceSlug, "Note updated", row.Title); err != nil {
_ = tx.Rollback()
return Note{}, err
}
if err := tx.Commit(); err != nil {
return Note{}, err
}
return Note{
ID: row.ID,
WorkspaceSlug: row.WorkspaceSlug,
Title: row.Title,
Content: row.Content,
UpdatedAt: row.UpdatedAt,
}, nil
}
func (s *PostgresStore) ListFocusSessions(workspaceSlug string) []FocusSession {
rows, err := s.queries.ListFocusSessions(context.Background(), workspaceSlug)
if err != nil {
panic(err)
}
items := make([]FocusSession, 0, len(rows))
for _, row := range rows {
items = append(items, focusSessionFromDB(row))
}
return items
}
func (s *PostgresStore) GetFocusSessionByID(sessionID string) (FocusSession, error) {
row, err := s.queries.GetFocusSessionByID(context.Background(), sessionID)
if err != nil {
return FocusSession{}, err
}
return focusSessionFromDB(row), nil
}
func (s *PostgresStore) CreateFocusSession(input CreateFocusSessionInput) FocusSession {
ctx := context.Background()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
panic(err)
}
queries := s.queries.WithTx(tx)
row, err := queries.CreateFocusSession(ctx, db.CreateFocusSessionParams{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
TaskID: nullableString(input.TaskID),
Mode: input.Mode,
StartedAt: time.Now().UTC(),
CompletedAt: sql.NullTime{},
PausedAt: sql.NullTime{},
PausedTotalSeconds: 0,
DurationSeconds: int32(input.DurationSeconds),
})
if err != nil {
_ = tx.Rollback()
panic(err)
}
if err := appendActivity(ctx, queries, input.WorkspaceSlug, "Focus session started", input.Mode); err != nil {
_ = tx.Rollback()
panic(err)
}
if err := tx.Commit(); err != nil {
panic(err)
}
return focusSessionFromDB(row)
}
func (s *PostgresStore) UpdateFocusSession(sessionID string, input UpdateFocusSessionInput) (FocusSession, error) {
ctx := context.Background()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return FocusSession{}, err
}
queries := s.queries.WithTx(tx)
current, err := queries.GetFocusSessionByID(ctx, sessionID)
if err != nil {
_ = tx.Rollback()
return FocusSession{}, err
}
completedAt := current.CompletedAt
if input.CompletedAt != nil {
completedAt = nullableTime(input.CompletedAt)
}
pausedAt := current.PausedAt
if input.PausedAt != nil {
pausedAt = nullableTime(input.PausedAt)
}
pausedTotalSeconds := current.PausedTotalSeconds
if input.PausedTotalSeconds != nil {
pausedTotalSeconds = int32(*input.PausedTotalSeconds)
}
row, err := queries.UpdateFocusSession(ctx, db.UpdateFocusSessionParams{
ID: sessionID,
CompletedAt: completedAt,
PausedAt: pausedAt,
PausedTotalSeconds: pausedTotalSeconds,
})
if err != nil {
_ = tx.Rollback()
return FocusSession{}, err
}
if err := appendActivity(ctx, queries, row.WorkspaceSlug, "Focus session updated", row.Mode); err != nil {
_ = tx.Rollback()
return FocusSession{}, err
}
if err := tx.Commit(); err != nil {
return FocusSession{}, err
}
return focusSessionFromDB(row), nil
}
func appendActivity(ctx context.Context, queries *db.Queries, workspaceSlug string, title string, detail string) error {
err := queries.CreateActivity(ctx, db.CreateActivityParams{
ID: uuid.NewString(),
WorkspaceSlug: workspaceSlug,
Title: title,
Detail: detail,
CreatedAt: time.Now().UTC(),
})
if err != nil {
return err
}
return queries.TrimActivities(ctx, workspaceSlug)
}
func taskFromDB(row db.Task) Task {
labelIDs, err := decodeJSONSlice[string](row.LabelIds)
if err != nil {
log.Printf("decode task label ids: %v", err)
}
attachments, err := decodeJSONSlice[Attachment](row.Attachments)
if err != nil {
log.Printf("decode task attachments: %v", err)
}
comments, err := decodeJSONSlice[TaskComment](row.Comments)
if err != nil {
log.Printf("decode task comments: %v", err)
}
return Task{
ID: row.ID,
WorkspaceSlug: row.WorkspaceSlug,
BoardGroupID: row.BoardGroupID,
Title: row.Title,
Description: row.Description,
Status: row.Status,
Color: row.Color,
DueAt: timePtr(row.DueAt),
ScheduledStart: timePtr(row.ScheduledStart),
ScheduledEnd: timePtr(row.ScheduledEnd),
AssigneeID: stringPtr(row.AssigneeID),
LabelIDs: labelIDs,
Attachments: attachments,
Comments: comments,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
}
func eventFromDB(row db.CalendarEvent) CalendarEvent {
attachments, err := decodeJSONSlice[Attachment](row.Attachments)
if err != nil {
log.Printf("decode event attachments: %v", err)
}
return CalendarEvent{
ID: row.ID,
WorkspaceSlug: row.WorkspaceSlug,
Title: row.Title,
Description: row.Description,
StartsAt: row.StartsAt,
EndsAt: row.EndsAt,
Color: row.Color,
LinkedTaskID: stringPtr(row.LinkedTaskID),
Attachments: attachments,
}
}
func inviteFromDB(row db.Invite) Invite {
return Invite{
ID: row.ID,
WorkspaceSlug: row.WorkspaceSlug,
Email: row.Email,
Role: row.Role,
Token: row.Token,
CreatedAt: row.CreatedAt,
Status: row.Status,
}
}
func focusSessionFromDB(row db.FocusSession) FocusSession {
return FocusSession{
ID: row.ID,
WorkspaceSlug: row.WorkspaceSlug,
TaskID: stringPtr(row.TaskID),
Mode: row.Mode,
StartedAt: row.StartedAt,
CompletedAt: timePtr(row.CompletedAt),
PausedAt: timePtr(row.PausedAt),
PausedTotalSeconds: int(row.PausedTotalSeconds),
DurationSeconds: int(row.DurationSeconds),
}
}
func decodeJSONSlice[T any](raw []byte) ([]T, error) {
if len(raw) == 0 {
return []T{}, nil
}
var items []T
if err := json.Unmarshal(raw, &items); err != nil {
return nil, err
}
return items, nil
}
func mustJSON(value any) []byte {
payload, err := json.Marshal(value)
if err != nil {
panic(err)
}
return payload
}
func nullableString(value *string) sql.NullString {
if value == nil {
return sql.NullString{}
}
return sql.NullString{String: *value, Valid: true}
}
func stringPtr(value sql.NullString) *string {
if !value.Valid {
return nil
}
text := value.String
return &text
}
func nullableTime(value *time.Time) sql.NullTime {
if value == nil {
return sql.NullTime{}
}
return sql.NullTime{Time: *value, Valid: true}
}
func timePtr(value sql.NullTime) *time.Time {
if !value.Valid {
return nil
}
timestamp := value.Time
return &timestamp
}