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:
274
internal/service/project.go
Normal file
274
internal/service/project.go
Normal 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
159
internal/service/tag.go
Normal 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
436
internal/service/task.go
Normal 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),
|
||||
}
|
||||
}
|
||||
91
internal/service/task_dependency.go
Normal file
91
internal/service/task_dependency.go
Normal 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
|
||||
}
|
||||
123
internal/service/task_reminder.go
Normal file
123
internal/service/task_reminder.go
Normal 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
|
||||
}
|
||||
73
internal/service/task_webhook.go
Normal file
73
internal/service/task_webhook.go
Normal 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)
|
||||
}
|
||||
78
internal/service/task_webhook_svc.go
Normal file
78
internal/service/task_webhook_svc.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user