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(¤t.ID, ¤t.WorkspaceSlug, ¤t.Name, ¤t.Email, ¤t.Role, ¤t.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 ×tamp }