Fix BASE_URL config loading, add tasks/projects; robust .env path resolution

- Config: try ENV_FILE, .env, ../.env for loading; trim trailing slash from BaseURL
- Log BASE_URL at server startup for verification
- .env.example: document BASE_URL
- Tasks, projects, tags, migrations and related API/handlers

Made-with: Cursor
This commit is contained in:
Michilis
2026-03-09 18:57:51 +00:00
parent 75105b8b46
commit bd24545b7b
61 changed files with 6595 additions and 90 deletions

274
internal/service/project.go Normal file
View File

@@ -0,0 +1,274 @@
package service
import (
"context"
"time"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/repository"
"github.com/calendarapi/internal/utils"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type ProjectService struct {
queries *repository.Queries
audit *AuditService
}
func NewProjectService(queries *repository.Queries, audit *AuditService) *ProjectService {
return &ProjectService{queries: queries, audit: audit}
}
type CreateProjectRequest struct {
Name string
Color *string
IsShared *bool
Deadline *time.Time
SortOrder *int
}
type UpdateProjectRequest struct {
Name *string
Color *string
IsShared *bool
Deadline *time.Time
SortOrder *int
}
func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, req CreateProjectRequest) (*models.Project, error) {
if len(req.Name) < 1 || len(req.Name) > 100 {
return nil, models.NewValidationError("project name must be 1-100 characters")
}
color := "#3B82F6"
if req.Color != nil {
if err := utils.ValidateColor(*req.Color); err != nil {
return nil, err
}
color = *req.Color
}
isShared := false
if req.IsShared != nil {
isShared = *req.IsShared
}
sortOrder := int32(0)
if req.SortOrder != nil {
sortOrder = int32(*req.SortOrder)
}
id := uuid.New()
p, err := s.queries.CreateProject(ctx, repository.CreateProjectParams{
ID: utils.ToPgUUID(id),
OwnerID: utils.ToPgUUID(userID),
Name: req.Name,
Color: color,
Column5: isShared,
Deadline: utils.ToPgTimestamptzPtr(req.Deadline),
Column7: sortOrder,
})
if err != nil {
return nil, models.ErrInternal
}
s.audit.Log(ctx, "project", id, "CREATE_PROJECT", userID)
return projectFromDB(p), nil
}
func (s *ProjectService) Get(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) (*models.Project, error) {
p, err := s.queries.GetProjectByID(ctx, repository.GetProjectByIDParams{
ID: utils.ToPgUUID(projectID),
OwnerID: utils.ToPgUUID(userID),
})
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
return projectFromDB(p), nil
}
func (s *ProjectService) List(ctx context.Context, userID uuid.UUID) ([]models.Project, error) {
rows, err := s.queries.ListProjectsForUser(ctx, utils.ToPgUUID(userID))
if err != nil {
return nil, models.ErrInternal
}
projects := make([]models.Project, 0, len(rows))
for _, r := range rows {
projects = append(projects, *projectFromDB(r))
}
return projects, nil
}
func (s *ProjectService) Update(ctx context.Context, userID uuid.UUID, projectID uuid.UUID, req UpdateProjectRequest) (*models.Project, error) {
if req.Name != nil && (len(*req.Name) < 1 || len(*req.Name) > 100) {
return nil, models.NewValidationError("project name must be 1-100 characters")
}
if req.Color != nil {
if err := utils.ValidateColor(*req.Color); err != nil {
return nil, err
}
}
updateParams := repository.UpdateProjectParams{
ID: utils.ToPgUUID(projectID),
OwnerID: utils.ToPgUUID(userID),
}
if req.Name != nil {
updateParams.Name = utils.ToPgTextPtr(req.Name)
}
if req.Color != nil {
updateParams.Color = utils.ToPgTextPtr(req.Color)
}
if req.IsShared != nil {
updateParams.IsShared = utils.ToPgBoolPtr(req.IsShared)
}
if req.Deadline != nil {
updateParams.Deadline = utils.ToPgTimestamptzPtr(req.Deadline)
}
if req.SortOrder != nil {
updateParams.SortOrder = utils.ToPgInt4Ptr(req.SortOrder)
}
p, err := s.queries.UpdateProject(ctx, updateParams)
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
s.audit.Log(ctx, "project", projectID, "UPDATE_PROJECT", userID)
return projectFromDB(p), nil
}
func (s *ProjectService) Delete(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) error {
err := s.queries.SoftDeleteProject(ctx, repository.SoftDeleteProjectParams{
ID: utils.ToPgUUID(projectID),
OwnerID: utils.ToPgUUID(userID),
})
if err != nil {
return models.ErrInternal
}
s.audit.Log(ctx, "project", projectID, "DELETE_PROJECT", userID)
return nil
}
func (s *ProjectService) Share(ctx context.Context, userID uuid.UUID, projectID uuid.UUID, targetEmail, role string) error {
_, err := s.queries.GetProjectByID(ctx, repository.GetProjectByIDParams{
ID: utils.ToPgUUID(projectID),
OwnerID: utils.ToPgUUID(userID),
})
if err != nil {
if err == pgx.ErrNoRows {
return models.ErrNotFound
}
return models.ErrInternal
}
if role != "editor" && role != "viewer" {
return models.NewValidationError("role must be editor or viewer")
}
targetUser, err := s.queries.GetUserByEmail(ctx, utils.NormalizeEmail(targetEmail))
if err != nil {
if err == pgx.ErrNoRows {
return models.NewNotFoundError("user not found")
}
return models.ErrInternal
}
targetID := utils.FromPgUUID(targetUser.ID)
if targetID == userID {
return models.NewValidationError("cannot share with yourself")
}
return s.queries.AddProjectMember(ctx, repository.AddProjectMemberParams{
ProjectID: utils.ToPgUUID(projectID),
UserID: utils.ToPgUUID(targetID),
Role: role,
})
}
func (s *ProjectService) AddMember(ctx context.Context, userID uuid.UUID, projectID uuid.UUID, targetUserID uuid.UUID, role string) error {
if role != "owner" && role != "editor" && role != "viewer" {
return models.NewValidationError("invalid role")
}
_, err := s.queries.GetProjectByID(ctx, repository.GetProjectByIDParams{
ID: utils.ToPgUUID(projectID),
OwnerID: utils.ToPgUUID(userID),
})
if err != nil {
if err == pgx.ErrNoRows {
return models.ErrNotFound
}
return models.ErrInternal
}
return s.queries.AddProjectMember(ctx, repository.AddProjectMemberParams{
ProjectID: utils.ToPgUUID(projectID),
UserID: utils.ToPgUUID(targetUserID),
Role: role,
})
}
func (s *ProjectService) RemoveMember(ctx context.Context, userID uuid.UUID, projectID uuid.UUID, targetUserID uuid.UUID) error {
_, err := s.queries.GetProjectByID(ctx, repository.GetProjectByIDParams{
ID: utils.ToPgUUID(projectID),
OwnerID: utils.ToPgUUID(userID),
})
if err != nil {
if err == pgx.ErrNoRows {
return models.ErrNotFound
}
return models.ErrInternal
}
return s.queries.RemoveProjectMember(ctx, repository.RemoveProjectMemberParams{
ProjectID: utils.ToPgUUID(projectID),
UserID: utils.ToPgUUID(targetUserID),
})
}
func (s *ProjectService) ListMembers(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) ([]models.ProjectMember, error) {
_, err := s.queries.GetProjectByID(ctx, repository.GetProjectByIDParams{
ID: utils.ToPgUUID(projectID),
OwnerID: utils.ToPgUUID(userID),
})
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
rows, err := s.queries.ListProjectMembers(ctx, utils.ToPgUUID(projectID))
if err != nil {
return nil, models.ErrInternal
}
members := make([]models.ProjectMember, 0, len(rows))
for _, r := range rows {
members = append(members, models.ProjectMember{
UserID: utils.FromPgUUID(r.UserID),
Email: r.Email,
Role: r.Role,
})
}
return members, nil
}
func projectFromDB(p repository.Project) *models.Project {
proj := &models.Project{
ID: utils.FromPgUUID(p.ID),
OwnerID: utils.FromPgUUID(p.OwnerID),
Name: p.Name,
Color: p.Color,
IsShared: p.IsShared,
SortOrder: int(p.SortOrder),
CreatedAt: utils.FromPgTimestamptz(p.CreatedAt),
UpdatedAt: utils.FromPgTimestamptz(p.UpdatedAt),
}
if p.Deadline.Valid {
proj.Deadline = &p.Deadline.Time
}
return proj
}

159
internal/service/tag.go Normal file
View File

@@ -0,0 +1,159 @@
package service
import (
"context"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/repository"
"github.com/calendarapi/internal/utils"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type TagService struct {
queries *repository.Queries
}
func NewTagService(queries *repository.Queries) *TagService {
return &TagService{queries: queries}
}
type CreateTagRequest struct {
Name string
Color *string
}
type UpdateTagRequest struct {
Name *string
Color *string
}
func (s *TagService) Create(ctx context.Context, userID uuid.UUID, req CreateTagRequest) (*models.Tag, error) {
if len(req.Name) < 1 || len(req.Name) > 50 {
return nil, models.NewValidationError("tag name must be 1-50 characters")
}
color := "#6B7280"
if req.Color != nil {
if err := utils.ValidateColor(*req.Color); err != nil {
return nil, err
}
color = *req.Color
}
id := uuid.New()
t, err := s.queries.CreateTag(ctx, repository.CreateTagParams{
ID: utils.ToPgUUID(id),
OwnerID: utils.ToPgUUID(userID),
Name: req.Name,
Column4: color,
})
if err != nil {
return nil, models.ErrInternal
}
tag := tagFromDB(t)
return &tag, nil
}
func (s *TagService) Get(ctx context.Context, userID uuid.UUID, tagID uuid.UUID) (*models.Tag, error) {
t, err := s.queries.GetTagByID(ctx, repository.GetTagByIDParams{
ID: utils.ToPgUUID(tagID),
OwnerID: utils.ToPgUUID(userID),
})
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
tag := tagFromDB(t)
return &tag, nil
}
func (s *TagService) List(ctx context.Context, userID uuid.UUID) ([]models.Tag, error) {
rows, err := s.queries.ListTagsByOwner(ctx, utils.ToPgUUID(userID))
if err != nil {
return nil, models.ErrInternal
}
tags := make([]models.Tag, 0, len(rows))
for _, r := range rows {
tags = append(tags, tagFromDB(r))
}
return tags, nil
}
func (s *TagService) Update(ctx context.Context, userID uuid.UUID, tagID uuid.UUID, req UpdateTagRequest) (*models.Tag, error) {
if req.Name != nil && (len(*req.Name) < 1 || len(*req.Name) > 50) {
return nil, models.NewValidationError("tag name must be 1-50 characters")
}
if req.Color != nil {
if err := utils.ValidateColor(*req.Color); err != nil {
return nil, err
}
}
updateParams := repository.UpdateTagParams{
ID: utils.ToPgUUID(tagID),
OwnerID: utils.ToPgUUID(userID),
}
if req.Name != nil {
updateParams.Name = utils.ToPgTextPtr(req.Name)
}
if req.Color != nil {
updateParams.Color = utils.ToPgTextPtr(req.Color)
}
t, err := s.queries.UpdateTag(ctx, updateParams)
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
tag := tagFromDB(t)
return &tag, nil
}
func (s *TagService) Delete(ctx context.Context, userID uuid.UUID, tagID uuid.UUID) error {
err := s.queries.DeleteTag(ctx, repository.DeleteTagParams{
ID: utils.ToPgUUID(tagID),
OwnerID: utils.ToPgUUID(userID),
})
if err != nil {
return models.ErrInternal
}
return nil
}
func (s *TagService) AttachToTask(ctx context.Context, userID uuid.UUID, tagID uuid.UUID, taskID uuid.UUID) error {
if _, err := s.queries.GetTagByID(ctx, repository.GetTagByIDParams{
ID: utils.ToPgUUID(tagID),
OwnerID: utils.ToPgUUID(userID),
}); err != nil {
if err == pgx.ErrNoRows {
return models.ErrNotFound
}
return models.ErrInternal
}
return s.queries.AttachTagToTask(ctx, repository.AttachTagToTaskParams{
TaskID: utils.ToPgUUID(taskID),
TagID: utils.ToPgUUID(tagID),
})
}
func (s *TagService) DetachFromTask(ctx context.Context, userID uuid.UUID, tagID uuid.UUID, taskID uuid.UUID) error {
if _, err := s.queries.GetTagByID(ctx, repository.GetTagByIDParams{
ID: utils.ToPgUUID(tagID),
OwnerID: utils.ToPgUUID(userID),
}); err != nil {
if err == pgx.ErrNoRows {
return models.ErrNotFound
}
return models.ErrInternal
}
return s.queries.DetachTagFromTask(ctx, repository.DetachTagFromTaskParams{
TaskID: utils.ToPgUUID(taskID),
TagID: utils.ToPgUUID(tagID),
})
}

436
internal/service/task.go Normal file
View File

@@ -0,0 +1,436 @@
package service
import (
"context"
"time"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/repository"
"github.com/calendarapi/internal/utils"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
type TaskService struct {
queries *repository.Queries
audit *AuditService
webhook *TaskWebhookService
}
func NewTaskService(queries *repository.Queries, audit *AuditService, webhook *TaskWebhookService) *TaskService {
return &TaskService{queries: queries, audit: audit, webhook: webhook}
}
type CreateTaskRequest struct {
Title string
Description *string
Status *string
Priority *string
DueDate *time.Time
ProjectID *uuid.UUID
ParentID *uuid.UUID
SortOrder *int
RecurrenceRule *string
}
type UpdateTaskRequest struct {
Title *string
Description *string
Status *string
Priority *string
DueDate *time.Time
ProjectID *uuid.UUID
SortOrder *int
RecurrenceRule *string
}
type ListTasksParams struct {
Status *string
Priority *string
DueFrom *time.Time
DueTo *time.Time
ProjectID *uuid.UUID
TagIDs []uuid.UUID
Sort string // created_at, due_date, priority
Order string // asc, desc
Limit int
Cursor string
}
func (s *TaskService) Create(ctx context.Context, userID uuid.UUID, req CreateTaskRequest) (*models.Task, error) {
if err := utils.ValidateTaskTitle(req.Title); err != nil {
return nil, err
}
status := "todo"
if req.Status != nil {
if err := utils.ValidateTaskStatus(*req.Status); err != nil {
return nil, err
}
status = *req.Status
}
priority := "medium"
if req.Priority != nil {
if err := utils.ValidateTaskPriority(*req.Priority); err != nil {
return nil, err
}
priority = *req.Priority
}
sortOrder := int32(0)
if req.SortOrder != nil {
sortOrder = int32(*req.SortOrder)
}
id := uuid.New()
t, err := s.queries.CreateTask(ctx, repository.CreateTaskParams{
ID: utils.ToPgUUID(id),
OwnerID: utils.ToPgUUID(userID),
Title: req.Title,
Description: utils.ToPgTextPtr(req.Description),
Status: status,
Priority: priority,
DueDate: utils.ToPgTimestamptzPtr(req.DueDate),
ProjectID: utils.ToPgUUIDPtr(req.ProjectID),
ParentID: utils.ToPgUUIDPtr(req.ParentID),
SortOrder: sortOrder,
RecurrenceRule: utils.ToPgTextPtr(req.RecurrenceRule),
})
if err != nil {
return nil, models.ErrInternal
}
s.audit.Log(ctx, "task", id, "CREATE_TASK", userID)
task := taskFromDB(t)
if s.webhook != nil {
go s.webhook.Deliver(ctx, userID, "created", task)
}
return task, nil
}
func (s *TaskService) Get(ctx context.Context, userID uuid.UUID, taskID uuid.UUID) (*models.Task, error) {
t, err := s.queries.GetTaskByID(ctx, repository.GetTaskByIDParams{
ID: utils.ToPgUUID(taskID),
OwnerID: utils.ToPgUUID(userID),
})
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
task := taskFromDB(t)
tags, _ := s.queries.ListTagsByTask(ctx, utils.ToPgUUID(taskID))
task.Tags = make([]models.Tag, 0, len(tags))
for _, tag := range tags {
task.Tags = append(task.Tags, tagFromDB(tag))
}
if count, err := s.queries.CountSubtasksByStatus(ctx, utils.ToPgUUID(taskID)); err == nil && count.TotalCount > 0 {
pct := int(100 * count.DoneCount / count.TotalCount)
task.CompletionPercentage = &pct
}
return task, nil
}
func (s *TaskService) List(ctx context.Context, userID uuid.UUID, params ListTasksParams) ([]models.Task, *string, error) {
lim := utils.ClampLimit(params.Limit)
if lim <= 0 {
lim = 50
}
var status, priority pgtype.Text
if params.Status != nil && *params.Status != "" {
status = utils.ToPgText(*params.Status)
}
if params.Priority != nil && *params.Priority != "" {
priority = utils.ToPgText(*params.Priority)
}
projectID := utils.ToPgUUIDPtr(params.ProjectID)
dueFrom := utils.ToPgTimestamptzPtr(params.DueFrom)
dueTo := utils.ToPgTimestamptzPtr(params.DueTo)
var cursorTime *time.Time
var cursorID *uuid.UUID
if params.Cursor != "" {
ct, cid, err := utils.ParseCursor(params.Cursor)
if err != nil {
return nil, nil, models.NewValidationError("invalid cursor")
}
cursorTime = ct
cursorID = cid
}
listParams := repository.ListTasksParams{
OwnerID: utils.ToPgUUID(userID),
Status: status,
Priority: priority,
ProjectID: projectID,
DueFrom: dueFrom,
DueTo: dueTo,
Lim: lim + 1,
}
if cursorTime != nil && cursorID != nil {
listParams.CursorTime = utils.ToPgTimestamptz(*cursorTime)
listParams.CursorID = utils.ToPgUUID(*cursorID)
}
rows, err := s.queries.ListTasks(ctx, listParams)
if err != nil {
return nil, nil, models.ErrInternal
}
tasks := make([]models.Task, 0, len(rows))
for _, r := range rows {
tasks = append(tasks, *taskFromDB(r))
}
var nextCursor *string
if len(tasks) > int(lim) {
tasks = tasks[:lim]
last := tasks[len(tasks)-1]
c := utils.EncodeCursor(last.CreatedAt, last.ID)
nextCursor = &c
}
return tasks, nextCursor, nil
}
func (s *TaskService) Update(ctx context.Context, userID uuid.UUID, taskID uuid.UUID, req UpdateTaskRequest) (*models.Task, error) {
if req.Title != nil {
if err := utils.ValidateTaskTitle(*req.Title); err != nil {
return nil, err
}
}
if req.Status != nil {
if err := utils.ValidateTaskStatus(*req.Status); err != nil {
return nil, err
}
}
if req.Priority != nil {
if err := utils.ValidateTaskPriority(*req.Priority); err != nil {
return nil, err
}
}
prev, err := s.queries.GetTaskByID(ctx, repository.GetTaskByIDParams{
ID: utils.ToPgUUID(taskID),
OwnerID: utils.ToPgUUID(userID),
})
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
updateParams := repository.UpdateTaskParams{
ID: utils.ToPgUUID(taskID),
OwnerID: utils.ToPgUUID(userID),
}
if req.Title != nil {
updateParams.Title = utils.ToPgTextPtr(req.Title)
}
if req.Description != nil {
updateParams.Description = utils.ToPgTextPtr(req.Description)
}
if req.Status != nil {
updateParams.Status = utils.ToPgTextPtr(req.Status)
}
if req.Priority != nil {
updateParams.Priority = utils.ToPgTextPtr(req.Priority)
}
if req.DueDate != nil {
updateParams.DueDate = utils.ToPgTimestamptzPtr(req.DueDate)
}
if req.ProjectID != nil {
updateParams.ProjectID = utils.ToPgUUIDPtr(req.ProjectID)
}
if req.SortOrder != nil {
updateParams.SortOrder = utils.ToPgInt4Ptr(req.SortOrder)
}
if req.RecurrenceRule != nil {
updateParams.RecurrenceRule = utils.ToPgTextPtr(req.RecurrenceRule)
}
t, err := s.queries.UpdateTask(ctx, updateParams)
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
task := taskFromDB(t)
s.audit.Log(ctx, "task", taskID, "UPDATE_TASK", userID)
if s.webhook != nil && req.Status != nil && *req.Status != prev.Status {
go s.webhook.Deliver(ctx, userID, "status_change", task)
if *req.Status == "done" {
go s.webhook.Deliver(ctx, userID, "completion", task)
}
}
if prev.ParentID.Valid {
s.maybeAutoCompleteParent(ctx, userID, utils.FromPgUUID(prev.ParentID))
}
return task, nil
}
func (s *TaskService) Delete(ctx context.Context, userID uuid.UUID, taskID uuid.UUID, permanent bool) error {
if permanent {
err := s.queries.HardDeleteTask(ctx, repository.HardDeleteTaskParams{
ID: utils.ToPgUUID(taskID),
OwnerID: utils.ToPgUUID(userID),
})
if err != nil {
return models.ErrInternal
}
} else {
err := s.queries.SoftDeleteTask(ctx, repository.SoftDeleteTaskParams{
ID: utils.ToPgUUID(taskID),
OwnerID: utils.ToPgUUID(userID),
})
if err != nil {
return models.ErrInternal
}
}
s.audit.Log(ctx, "task", taskID, "DELETE_TASK", userID)
return nil
}
func (s *TaskService) MarkComplete(ctx context.Context, userID uuid.UUID, taskID uuid.UUID) (*models.Task, error) {
blockers, _ := s.queries.ListTaskBlockers(ctx, utils.ToPgUUID(taskID))
for _, b := range blockers {
if b.Status != "done" {
return nil, models.NewConflictError("cannot complete: blocked by incomplete task")
}
}
t, err := s.queries.MarkTaskComplete(ctx, repository.MarkTaskCompleteParams{
ID: utils.ToPgUUID(taskID),
OwnerID: utils.ToPgUUID(userID),
})
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
task := taskFromDB(t)
s.audit.Log(ctx, "task", taskID, "COMPLETE_TASK", userID)
if s.webhook != nil {
go s.webhook.Deliver(ctx, userID, "completion", task)
}
if t.ParentID.Valid {
s.maybeAutoCompleteParent(ctx, userID, utils.FromPgUUID(t.ParentID))
}
return task, nil
}
func (s *TaskService) MarkUncomplete(ctx context.Context, userID uuid.UUID, taskID uuid.UUID, newStatus *string) (*models.Task, error) {
status := "todo"
if newStatus != nil {
if err := utils.ValidateTaskStatus(*newStatus); err != nil {
return nil, err
}
status = *newStatus
}
t, err := s.queries.MarkTaskUncomplete(ctx, repository.MarkTaskUncompleteParams{
ID: utils.ToPgUUID(taskID),
OwnerID: utils.ToPgUUID(userID),
NewStatus: utils.ToPgTextPtr(&status),
})
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
task := taskFromDB(t)
s.audit.Log(ctx, "task", taskID, "UNCOMPLETE_TASK", userID)
return task, nil
}
func (s *TaskService) ListSubtasks(ctx context.Context, userID uuid.UUID, taskID uuid.UUID) ([]models.Task, error) {
rows, err := s.queries.ListSubtasks(ctx, repository.ListSubtasksParams{
ParentID: utils.ToPgUUID(taskID),
OwnerID: utils.ToPgUUID(userID),
})
if err != nil {
return nil, models.ErrInternal
}
tasks := make([]models.Task, 0, len(rows))
for _, r := range rows {
tasks = append(tasks, *taskFromDB(r))
}
return tasks, nil
}
func (s *TaskService) maybeAutoCompleteParent(ctx context.Context, userID uuid.UUID, parentID uuid.UUID) {
count, err := s.queries.CountSubtasksByStatus(ctx, utils.ToPgUUID(parentID))
if err != nil || count.TotalCount == 0 {
return
}
if count.DoneCount == count.TotalCount {
_, _ = s.queries.MarkTaskComplete(ctx, repository.MarkTaskCompleteParams{
ID: utils.ToPgUUID(parentID),
OwnerID: utils.ToPgUUID(userID),
})
s.audit.Log(ctx, "task", parentID, "AUTO_COMPLETE_PARENT", userID)
}
}
func taskFromDB(t repository.Task) *models.Task {
task := &models.Task{
ID: utils.FromPgUUID(t.ID),
OwnerID: utils.FromPgUUID(t.OwnerID),
Title: t.Title,
Status: t.Status,
Priority: t.Priority,
CreatedAt: utils.FromPgTimestamptz(t.CreatedAt),
UpdatedAt: utils.FromPgTimestamptz(t.UpdatedAt),
SortOrder: int(t.SortOrder),
}
if t.Description.Valid {
task.Description = &t.Description.String
}
if t.DueDate.Valid {
task.DueDate = &t.DueDate.Time
}
if t.CompletedAt.Valid {
task.CompletedAt = &t.CompletedAt.Time
}
if t.ProjectID.Valid {
id := utils.FromPgUUID(t.ProjectID)
task.ProjectID = &id
}
if t.ParentID.Valid {
id := utils.FromPgUUID(t.ParentID)
task.ParentID = &id
}
if t.RecurrenceRule.Valid {
task.RecurrenceRule = &t.RecurrenceRule.String
}
return task
}
func tagFromDB(t repository.Tag) models.Tag {
return models.Tag{
ID: utils.FromPgUUID(t.ID),
OwnerID: utils.FromPgUUID(t.OwnerID),
Name: t.Name,
Color: t.Color,
CreatedAt: utils.FromPgTimestamptz(t.CreatedAt),
}
}

View File

@@ -0,0 +1,91 @@
package service
import (
"context"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/repository"
"github.com/calendarapi/internal/utils"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type TaskDependencyService struct {
queries *repository.Queries
}
func NewTaskDependencyService(queries *repository.Queries) *TaskDependencyService {
return &TaskDependencyService{queries: queries}
}
func (s *TaskDependencyService) Add(ctx context.Context, userID uuid.UUID, taskID uuid.UUID, blocksTaskID uuid.UUID) error {
if taskID == blocksTaskID {
return models.NewValidationError("task cannot depend on itself")
}
if _, err := s.queries.GetTaskByID(ctx, repository.GetTaskByIDParams{
ID: utils.ToPgUUID(taskID),
OwnerID: utils.ToPgUUID(userID),
}); err != nil {
if err == pgx.ErrNoRows {
return models.ErrNotFound
}
return models.ErrInternal
}
hasCircle, err := s.queries.CheckDirectCircularDependency(ctx, repository.CheckDirectCircularDependencyParams{
BlocksTaskID: utils.ToPgUUID(taskID),
TaskID: utils.ToPgUUID(blocksTaskID),
})
if err != nil {
return models.ErrInternal
}
if hasCircle {
return models.NewConflictError("circular dependency detected")
}
return s.queries.AddTaskDependency(ctx, repository.AddTaskDependencyParams{
TaskID: utils.ToPgUUID(taskID),
BlocksTaskID: utils.ToPgUUID(blocksTaskID),
})
}
func (s *TaskDependencyService) Remove(ctx context.Context, userID uuid.UUID, taskID uuid.UUID, blocksTaskID uuid.UUID) error {
if _, err := s.queries.GetTaskByID(ctx, repository.GetTaskByIDParams{
ID: utils.ToPgUUID(taskID),
OwnerID: utils.ToPgUUID(userID),
}); err != nil {
if err == pgx.ErrNoRows {
return models.ErrNotFound
}
return models.ErrInternal
}
return s.queries.RemoveTaskDependency(ctx, repository.RemoveTaskDependencyParams{
TaskID: utils.ToPgUUID(taskID),
BlocksTaskID: utils.ToPgUUID(blocksTaskID),
})
}
func (s *TaskDependencyService) ListBlockers(ctx context.Context, userID uuid.UUID, taskID uuid.UUID) ([]models.Task, error) {
if _, err := s.queries.GetTaskByID(ctx, repository.GetTaskByIDParams{
ID: utils.ToPgUUID(taskID),
OwnerID: utils.ToPgUUID(userID),
}); err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
rows, err := s.queries.ListTaskBlockers(ctx, utils.ToPgUUID(taskID))
if err != nil {
return nil, models.ErrInternal
}
tasks := make([]models.Task, 0, len(rows))
for _, r := range rows {
tasks = append(tasks, *taskFromDB(r))
}
return tasks, nil
}

View File

@@ -0,0 +1,123 @@
package service
import (
"context"
"encoding/json"
"time"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/repository"
"github.com/calendarapi/internal/utils"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type TaskReminderScheduler interface {
ScheduleTaskReminder(ctx context.Context, reminderID, taskID, ownerID uuid.UUID, triggerAt time.Time) error
}
type TaskReminderService struct {
queries *repository.Queries
scheduler TaskReminderScheduler
}
func NewTaskReminderService(queries *repository.Queries, scheduler TaskReminderScheduler) *TaskReminderService {
return &TaskReminderService{queries: queries, scheduler: scheduler}
}
type CreateTaskReminderRequest struct {
Type string
Config map[string]interface{}
ScheduledAt time.Time
}
func (s *TaskReminderService) Create(ctx context.Context, userID uuid.UUID, taskID uuid.UUID, req CreateTaskReminderRequest) (*models.TaskReminder, error) {
if _, err := s.queries.GetTaskByID(ctx, repository.GetTaskByIDParams{
ID: utils.ToPgUUID(taskID),
OwnerID: utils.ToPgUUID(userID),
}); err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
validTypes := map[string]bool{"push": true, "email": true, "webhook": true, "telegram": true, "nostr": true}
if !validTypes[req.Type] {
return nil, models.NewValidationError("invalid reminder type")
}
configJSON := []byte("{}")
if req.Config != nil {
configJSON, _ = json.Marshal(req.Config)
}
id := uuid.New()
rem, err := s.queries.CreateTaskReminder(ctx, repository.CreateTaskReminderParams{
ID: utils.ToPgUUID(id),
TaskID: utils.ToPgUUID(taskID),
Type: req.Type,
Column4: configJSON,
ScheduledAt: utils.ToPgTimestamptz(req.ScheduledAt),
})
if err != nil {
return nil, models.ErrInternal
}
if s.scheduler != nil {
_ = s.scheduler.ScheduleTaskReminder(ctx, id, taskID, userID, req.ScheduledAt)
}
return taskReminderFromDB(rem), nil
}
func (s *TaskReminderService) List(ctx context.Context, userID uuid.UUID, taskID uuid.UUID) ([]models.TaskReminder, error) {
if _, err := s.queries.GetTaskByID(ctx, repository.GetTaskByIDParams{
ID: utils.ToPgUUID(taskID),
OwnerID: utils.ToPgUUID(userID),
}); err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
rows, err := s.queries.ListTaskReminders(ctx, utils.ToPgUUID(taskID))
if err != nil {
return nil, models.ErrInternal
}
reminders := make([]models.TaskReminder, 0, len(rows))
for _, r := range rows {
reminders = append(reminders, *taskReminderFromDB(r))
}
return reminders, nil
}
func (s *TaskReminderService) Delete(ctx context.Context, userID uuid.UUID, taskID uuid.UUID, reminderID uuid.UUID) error {
if _, err := s.queries.GetTaskByID(ctx, repository.GetTaskByIDParams{
ID: utils.ToPgUUID(taskID),
OwnerID: utils.ToPgUUID(userID),
}); err != nil {
if err == pgx.ErrNoRows {
return models.ErrNotFound
}
return models.ErrInternal
}
return s.queries.DeleteTaskReminder(ctx, utils.ToPgUUID(reminderID))
}
func taskReminderFromDB(r repository.TaskReminder) *models.TaskReminder {
rem := &models.TaskReminder{
ID: utils.FromPgUUID(r.ID),
TaskID: utils.FromPgUUID(r.TaskID),
Type: r.Type,
ScheduledAt: utils.FromPgTimestamptz(r.ScheduledAt),
CreatedAt: utils.FromPgTimestamptz(r.CreatedAt),
}
if len(r.Config) > 0 {
json.Unmarshal(r.Config, &rem.Config)
}
return rem
}

View File

@@ -0,0 +1,73 @@
package service
import (
"bytes"
"context"
"encoding/json"
"net/http"
"time"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/repository"
"github.com/calendarapi/internal/utils"
"github.com/google/uuid"
)
type TaskWebhookService struct {
queries *repository.Queries
}
func NewTaskWebhookService(queries *repository.Queries) *TaskWebhookService {
return &TaskWebhookService{queries: queries}
}
func (s *TaskWebhookService) Deliver(ctx context.Context, ownerID uuid.UUID, event string, task *models.Task) {
webhooks, err := s.queries.ListTaskWebhooksByOwner(ctx, utils.ToPgUUID(ownerID))
if err != nil {
return
}
payload := map[string]interface{}{
"event": event,
"task": task,
"timestamp": time.Now().UTC().Format(time.RFC3339),
}
body, _ := json.Marshal(payload)
for _, wh := range webhooks {
if !s.webhookSubscribedTo(wh.Events, event) {
continue
}
secret := ""
if wh.Secret.Valid {
secret = wh.Secret.String
}
go s.postWebhook(wh.Url, body, []byte(secret))
}
}
func (s *TaskWebhookService) webhookSubscribedTo(eventsJSON []byte, event string) bool {
var events []string
if err := json.Unmarshal(eventsJSON, &events); err != nil {
return false
}
for _, e := range events {
if e == event {
return true
}
}
return false
}
func (s *TaskWebhookService) postWebhook(url string, body []byte, secret []byte) {
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
if len(secret) > 0 {
req.Header.Set("X-Webhook-Signature", string(secret))
}
client := &http.Client{Timeout: 10 * time.Second}
_, _ = client.Do(req)
}

View File

@@ -0,0 +1,78 @@
package service
import (
"context"
"encoding/json"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/repository"
"github.com/calendarapi/internal/utils"
"github.com/google/uuid"
)
type TaskWebhookCreateRequest struct {
URL string
Events []string
Secret *string
}
func (s *TaskWebhookService) Create(ctx context.Context, userID uuid.UUID, req TaskWebhookCreateRequest) (*models.TaskWebhook, error) {
if req.URL == "" {
return nil, models.NewValidationError("url is required")
}
eventsJSON, _ := json.Marshal(req.Events)
if req.Events == nil {
eventsJSON = []byte("[]")
}
id := uuid.New()
wh, err := s.queries.CreateTaskWebhook(ctx, repository.CreateTaskWebhookParams{
ID: utils.ToPgUUID(id),
OwnerID: utils.ToPgUUID(userID),
Url: req.URL,
Column4: eventsJSON,
Secret: utils.ToPgTextPtr(req.Secret),
})
if err != nil {
return nil, models.ErrInternal
}
return taskWebhookFromDB(wh), nil
}
func (s *TaskWebhookService) List(ctx context.Context, userID uuid.UUID) ([]models.TaskWebhook, error) {
rows, err := s.queries.ListTaskWebhooksByOwner(ctx, utils.ToPgUUID(userID))
if err != nil {
return nil, models.ErrInternal
}
webhooks := make([]models.TaskWebhook, 0, len(rows))
for _, r := range rows {
webhooks = append(webhooks, *taskWebhookFromDB(r))
}
return webhooks, nil
}
func (s *TaskWebhookService) Delete(ctx context.Context, userID uuid.UUID, webhookID uuid.UUID) error {
return s.queries.DeleteTaskWebhook(ctx, repository.DeleteTaskWebhookParams{
ID: utils.ToPgUUID(webhookID),
OwnerID: utils.ToPgUUID(userID),
})
}
func taskWebhookFromDB(wh repository.TaskWebhook) *models.TaskWebhook {
w := &models.TaskWebhook{
ID: utils.FromPgUUID(wh.ID),
OwnerID: utils.FromPgUUID(wh.OwnerID),
URL: wh.Url,
CreatedAt: utils.FromPgTimestamptz(wh.CreatedAt),
}
if len(wh.Events) > 0 {
json.Unmarshal(wh.Events, &w.Events)
}
if wh.Secret.Valid {
w.Secret = &wh.Secret.String
}
return w
}