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

View File

@@ -0,0 +1,185 @@
package handlers
import (
"net/http"
"github.com/calendarapi/internal/middleware"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/service"
"github.com/calendarapi/internal/utils"
"github.com/go-chi/chi/v5"
)
type ProjectHandler struct {
projectSvc *service.ProjectService
}
func NewProjectHandler(projectSvc *service.ProjectService) *ProjectHandler {
return &ProjectHandler{projectSvc: projectSvc}
}
func (h *ProjectHandler) List(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
projects, err := h.projectSvc.List(r.Context(), userID)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteList(w, projects, models.PageInfo{Limit: len(projects)})
}
func (h *ProjectHandler) Create(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
var req struct {
Name string `json:"name"`
Color *string `json:"color"`
IsShared *bool `json:"is_shared"`
Deadline *string `json:"deadline"`
SortOrder *int `json:"sort_order"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
createReq := service.CreateProjectRequest{
Name: req.Name,
Color: req.Color,
IsShared: req.IsShared,
SortOrder: req.SortOrder,
}
if req.Deadline != nil {
if t, err := utils.ParseTime(*req.Deadline); err == nil {
createReq.Deadline = &t
}
}
project, err := h.projectSvc.Create(r.Context(), userID, createReq)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"project": project})
}
func (h *ProjectHandler) Get(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
projectID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
project, err := h.projectSvc.Get(r.Context(), userID, projectID)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"project": project})
}
func (h *ProjectHandler) Update(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
projectID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
var req struct {
Name *string `json:"name"`
Color *string `json:"color"`
IsShared *bool `json:"is_shared"`
Deadline *string `json:"deadline"`
SortOrder *int `json:"sort_order"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
updateReq := service.UpdateProjectRequest{
Name: req.Name,
Color: req.Color,
IsShared: req.IsShared,
SortOrder: req.SortOrder,
}
if req.Deadline != nil {
if t, err := utils.ParseTime(*req.Deadline); err == nil {
updateReq.Deadline = &t
}
}
project, err := h.projectSvc.Update(r.Context(), userID, projectID, updateReq)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"project": project})
}
func (h *ProjectHandler) Delete(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
projectID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
if err := h.projectSvc.Delete(r.Context(), userID, projectID); err != nil {
utils.WriteError(w, err)
return
}
utils.WriteOK(w)
}
func (h *ProjectHandler) Share(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
projectID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
var req struct {
Target struct {
Email string `json:"email"`
} `json:"target"`
Role string `json:"role"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
if err := h.projectSvc.Share(r.Context(), userID, projectID, req.Target.Email, req.Role); err != nil {
utils.WriteError(w, err)
return
}
utils.WriteOK(w)
}
func (h *ProjectHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
projectID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
members, err := h.projectSvc.ListMembers(r.Context(), userID, projectID)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"members": members})
}

View File

@@ -0,0 +1,159 @@
package handlers
import (
"net/http"
"github.com/calendarapi/internal/middleware"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/service"
"github.com/calendarapi/internal/utils"
"github.com/go-chi/chi/v5"
)
type TagHandler struct {
tagSvc *service.TagService
}
func NewTagHandler(tagSvc *service.TagService) *TagHandler {
return &TagHandler{tagSvc: tagSvc}
}
func (h *TagHandler) List(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
tags, err := h.tagSvc.List(r.Context(), userID)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteList(w, tags, models.PageInfo{Limit: len(tags)})
}
func (h *TagHandler) Create(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
var req struct {
Name string `json:"name"`
Color *string `json:"color"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
tag, err := h.tagSvc.Create(r.Context(), userID, service.CreateTagRequest{
Name: req.Name,
Color: req.Color,
})
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"tag": tag})
}
func (h *TagHandler) Get(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
tagID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
tag, err := h.tagSvc.Get(r.Context(), userID, tagID)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"tag": tag})
}
func (h *TagHandler) Update(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
tagID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
var req struct {
Name *string `json:"name"`
Color *string `json:"color"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
tag, err := h.tagSvc.Update(r.Context(), userID, tagID, service.UpdateTagRequest{
Name: req.Name,
Color: req.Color,
})
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"tag": tag})
}
func (h *TagHandler) Delete(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
tagID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
if err := h.tagSvc.Delete(r.Context(), userID, tagID); err != nil {
utils.WriteError(w, err)
return
}
utils.WriteOK(w)
}
func (h *TagHandler) AttachToTask(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
tagID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
taskID, err := utils.ValidateUUID(chi.URLParam(r, "taskId"))
if err != nil {
utils.WriteError(w, err)
return
}
if err := h.tagSvc.AttachToTask(r.Context(), userID, tagID, taskID); err != nil {
utils.WriteError(w, err)
return
}
utils.WriteOK(w)
}
func (h *TagHandler) DetachFromTask(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
tagID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
taskID, err := utils.ValidateUUID(chi.URLParam(r, "taskId"))
if err != nil {
utils.WriteError(w, err)
return
}
if err := h.tagSvc.DetachFromTask(r.Context(), userID, tagID, taskID); err != nil {
utils.WriteError(w, err)
return
}
utils.WriteOK(w)
}

View File

@@ -0,0 +1,263 @@
package handlers
import (
"net/http"
"strconv"
"github.com/calendarapi/internal/middleware"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/service"
"github.com/calendarapi/internal/utils"
"github.com/go-chi/chi/v5"
)
type TaskHandler struct {
taskSvc *service.TaskService
}
func NewTaskHandler(taskSvc *service.TaskService) *TaskHandler {
return &TaskHandler{taskSvc: taskSvc}
}
func (h *TaskHandler) List(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
q := r.URL.Query()
params := service.ListTasksParams{
Limit: 50,
}
if s := q.Get("status"); s != "" {
params.Status = &s
}
if p := q.Get("priority"); p != "" {
params.Priority = &p
}
if pid := q.Get("project_id"); pid != "" {
if id, err := utils.ValidateUUID(pid); err == nil {
params.ProjectID = &id
}
}
if df := q.Get("due_from"); df != "" {
if t, err := utils.ParseTime(df); err == nil {
params.DueFrom = &t
}
}
if dt := q.Get("due_to"); dt != "" {
if t, err := utils.ParseTime(dt); err == nil {
params.DueTo = &t
}
}
if l := q.Get("limit"); l != "" {
if v, err := strconv.Atoi(l); err == nil {
params.Limit = v
}
}
params.Cursor = q.Get("cursor")
tasks, cursor, err := h.taskSvc.List(r.Context(), userID, params)
if err != nil {
utils.WriteError(w, err)
return
}
limit := int(utils.ClampLimit(params.Limit))
page := models.PageInfo{Limit: limit}
if cursor != nil {
page.NextCursor = cursor
}
utils.WriteList(w, tasks, page)
}
func (h *TaskHandler) Create(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
var req struct {
Title string `json:"title"`
Description *string `json:"description"`
Status *string `json:"status"`
Priority *string `json:"priority"`
DueDate *string `json:"due_date"`
ProjectID *string `json:"project_id"`
ParentID *string `json:"parent_id"`
SortOrder *int `json:"sort_order"`
RecurrenceRule *string `json:"recurrence_rule"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
createReq := service.CreateTaskRequest{
Title: req.Title,
Description: req.Description,
Status: req.Status,
Priority: req.Priority,
SortOrder: req.SortOrder,
RecurrenceRule: req.RecurrenceRule,
}
if req.DueDate != nil {
if t, err := utils.ParseTime(*req.DueDate); err == nil {
createReq.DueDate = &t
}
}
if req.ProjectID != nil {
if id, err := utils.ValidateUUID(*req.ProjectID); err == nil {
createReq.ProjectID = &id
}
}
if req.ParentID != nil {
if id, err := utils.ValidateUUID(*req.ParentID); err == nil {
createReq.ParentID = &id
}
}
task, err := h.taskSvc.Create(r.Context(), userID, createReq)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"task": task})
}
func (h *TaskHandler) Get(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
taskID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
task, err := h.taskSvc.Get(r.Context(), userID, taskID)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"task": task})
}
func (h *TaskHandler) Update(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
taskID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
var req struct {
Title *string `json:"title"`
Description *string `json:"description"`
Status *string `json:"status"`
Priority *string `json:"priority"`
DueDate *string `json:"due_date"`
ProjectID *string `json:"project_id"`
SortOrder *int `json:"sort_order"`
RecurrenceRule *string `json:"recurrence_rule"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
updateReq := service.UpdateTaskRequest{
Title: req.Title,
Description: req.Description,
Status: req.Status,
Priority: req.Priority,
SortOrder: req.SortOrder,
RecurrenceRule: req.RecurrenceRule,
}
if req.DueDate != nil {
if t, err := utils.ParseTime(*req.DueDate); err == nil {
updateReq.DueDate = &t
}
}
if req.ProjectID != nil {
if id, err := utils.ValidateUUID(*req.ProjectID); err == nil {
updateReq.ProjectID = &id
}
}
task, err := h.taskSvc.Update(r.Context(), userID, taskID, updateReq)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"task": task})
}
func (h *TaskHandler) Delete(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
taskID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
permanent := r.URL.Query().Get("permanent") == "true"
if err := h.taskSvc.Delete(r.Context(), userID, taskID, permanent); err != nil {
utils.WriteError(w, err)
return
}
utils.WriteOK(w)
}
func (h *TaskHandler) MarkComplete(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
taskID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
task, err := h.taskSvc.MarkComplete(r.Context(), userID, taskID)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"task": task})
}
func (h *TaskHandler) MarkUncomplete(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
taskID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
var req struct {
Status *string `json:"status"`
}
_ = utils.DecodeJSON(r, &req)
task, err := h.taskSvc.MarkUncomplete(r.Context(), userID, taskID, req.Status)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"task": task})
}
func (h *TaskHandler) ListSubtasks(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
taskID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
tasks, err := h.taskSvc.ListSubtasks(r.Context(), userID, taskID)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteList(w, tasks, models.PageInfo{Limit: len(tasks)})
}

View File

@@ -0,0 +1,88 @@
package handlers
import (
"net/http"
"github.com/calendarapi/internal/middleware"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/service"
"github.com/calendarapi/internal/utils"
"github.com/go-chi/chi/v5"
)
type TaskDependencyHandler struct {
depSvc *service.TaskDependencyService
}
func NewTaskDependencyHandler(depSvc *service.TaskDependencyService) *TaskDependencyHandler {
return &TaskDependencyHandler{depSvc: depSvc}
}
func (h *TaskDependencyHandler) ListBlockers(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
taskID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
tasks, err := h.depSvc.ListBlockers(r.Context(), userID, taskID)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteList(w, tasks, models.PageInfo{Limit: len(tasks)})
}
func (h *TaskDependencyHandler) Add(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
taskID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
var req struct {
BlocksTaskID string `json:"blocks_task_id"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
blocksTaskID, err := utils.ValidateUUID(req.BlocksTaskID)
if err != nil {
utils.WriteError(w, err)
return
}
if err := h.depSvc.Add(r.Context(), userID, taskID, blocksTaskID); err != nil {
utils.WriteError(w, err)
return
}
utils.WriteOK(w)
}
func (h *TaskDependencyHandler) Remove(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
taskID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
blocksTaskID, err := utils.ValidateUUID(chi.URLParam(r, "blocksTaskId"))
if err != nil {
utils.WriteError(w, err)
return
}
if err := h.depSvc.Remove(r.Context(), userID, taskID, blocksTaskID); err != nil {
utils.WriteError(w, err)
return
}
utils.WriteOK(w)
}

View File

@@ -0,0 +1,94 @@
package handlers
import (
"net/http"
"github.com/calendarapi/internal/middleware"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/service"
"github.com/calendarapi/internal/utils"
"github.com/go-chi/chi/v5"
)
type TaskReminderHandler struct {
reminderSvc *service.TaskReminderService
}
func NewTaskReminderHandler(reminderSvc *service.TaskReminderService) *TaskReminderHandler {
return &TaskReminderHandler{reminderSvc: reminderSvc}
}
func (h *TaskReminderHandler) List(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
taskID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
reminders, err := h.reminderSvc.List(r.Context(), userID, taskID)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteList(w, reminders, models.PageInfo{Limit: len(reminders)})
}
func (h *TaskReminderHandler) Add(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
taskID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
var req struct {
Type string `json:"type"`
Config map[string]interface{} `json:"config"`
ScheduledAt string `json:"scheduled_at"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
t, err := utils.ParseTime(req.ScheduledAt)
if err != nil {
utils.WriteError(w, models.NewValidationError("invalid scheduled_at"))
return
}
reminder, err := h.reminderSvc.Create(r.Context(), userID, taskID, service.CreateTaskReminderRequest{
Type: req.Type,
Config: req.Config,
ScheduledAt: t,
})
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"reminder": reminder})
}
func (h *TaskReminderHandler) Delete(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
taskID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
reminderID, err := utils.ValidateUUID(chi.URLParam(r, "reminderId"))
if err != nil {
utils.WriteError(w, err)
return
}
if err := h.reminderSvc.Delete(r.Context(), userID, taskID, reminderID); err != nil {
utils.WriteError(w, err)
return
}
utils.WriteOK(w)
}

View File

@@ -0,0 +1,73 @@
package handlers
import (
"net/http"
"github.com/calendarapi/internal/middleware"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/service"
"github.com/calendarapi/internal/utils"
"github.com/go-chi/chi/v5"
)
type TaskWebhookHandler struct {
webhookSvc *service.TaskWebhookService
}
func NewTaskWebhookHandler(webhookSvc *service.TaskWebhookService) *TaskWebhookHandler {
return &TaskWebhookHandler{webhookSvc: webhookSvc}
}
func (h *TaskWebhookHandler) List(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
webhooks, err := h.webhookSvc.List(r.Context(), userID)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteList(w, webhooks, models.PageInfo{Limit: len(webhooks)})
}
func (h *TaskWebhookHandler) Create(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
var req struct {
URL string `json:"url"`
Events []string `json:"events"`
Secret *string `json:"secret"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
webhook, err := h.webhookSvc.Create(r.Context(), userID, service.TaskWebhookCreateRequest{
URL: req.URL,
Events: req.Events,
Secret: req.Secret,
})
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"webhook": webhook})
}
func (h *TaskWebhookHandler) Delete(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
webhookID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
if err := h.webhookSvc.Delete(r.Context(), userID, webhookID); err != nil {
utils.WriteError(w, err)
return
}
utils.WriteOK(w)
}

View File

@@ -33,7 +33,11 @@
{ "name": "Availability", "description": "Calendar availability queries" },
{ "name": "Booking", "description": "Public booking links and reservations" },
{ "name": "ICS", "description": "ICS calendar import and export" },
{ "name": "Subscriptions", "description": "Calendar subscriptions (external iCal feeds)" }
{ "name": "Subscriptions", "description": "Calendar subscriptions (external iCal feeds)" },
{ "name": "Tasks", "description": "Task and to-do management" },
{ "name": "Projects", "description": "Project/list grouping for tasks" },
{ "name": "Tags", "description": "Tag management for tasks" },
{ "name": "Webhooks", "description": "Task webhook configuration" }
],
"security": [
{ "BearerAuth": [] },

View File

@@ -0,0 +1,229 @@
{
"paths": {
"/projects": {
"get": {
"tags": ["Projects"],
"summary": "List projects",
"description": "Returns the authenticated user's projects (owned and shared). Requires `tasks:read` scope.",
"operationId": "listProjects",
"parameters": [
{ "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 } },
{ "name": "cursor", "in": "query", "schema": { "type": "string" }, "description": "Pagination cursor" }
],
"responses": {
"200": {
"description": "List of projects",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["items", "page"],
"properties": {
"items": { "type": "array", "items": { "$ref": "#/components/schemas/Project" } },
"page": { "$ref": "#/components/schemas/PageInfo" }
}
}
}
}
},
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
"post": {
"tags": ["Projects"],
"summary": "Create a project",
"description": "Creates a new project for the authenticated user. Requires `tasks:write` scope.",
"operationId": "createProject",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["name"],
"properties": {
"name": { "type": "string", "minLength": 1, "maxLength": 100 },
"color": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$", "example": "#3B82F6" },
"deadline": { "type": "string", "format": "date-time" },
"sort_order": { "type": "integer" }
}
}
}
}
},
"responses": {
"200": {
"description": "Project created",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["project"],
"properties": {
"project": { "$ref": "#/components/schemas/Project" }
}
}
}
}
},
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/projects/{id}": {
"get": {
"tags": ["Projects"],
"summary": "Get a project",
"description": "Returns a single project by ID. User must be owner or member. Requires `tasks:read` scope.",
"operationId": "getProject",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
],
"responses": {
"200": {
"description": "Project details",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["project"],
"properties": {
"project": { "$ref": "#/components/schemas/Project" }
}
}
}
}
},
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Project not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
"put": {
"tags": ["Projects"],
"summary": "Update a project",
"description": "Updates a project. Requires `tasks:write` scope and owner/editor role.",
"operationId": "updateProject",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": { "type": "string", "minLength": 1, "maxLength": 100 },
"color": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$" },
"deadline": { "type": "string", "format": "date-time", "nullable": true },
"sort_order": { "type": "integer" }
}
}
}
}
},
"responses": {
"200": {
"description": "Project updated",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["project"],
"properties": {
"project": { "$ref": "#/components/schemas/Project" }
}
}
}
}
},
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Project not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
"delete": {
"tags": ["Projects"],
"summary": "Delete a project",
"description": "Deletes a project. Requires `tasks:write` scope and owner role.",
"operationId": "deleteProject",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
],
"responses": {
"200": { "description": "Project deleted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Project not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/projects/{id}/share": {
"post": {
"tags": ["Projects"],
"summary": "Share project",
"description": "Shares a project with a user by email. Requires `tasks:write` scope and owner/editor role.",
"operationId": "shareProject",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["email", "role"],
"properties": {
"email": { "type": "string", "format": "email" },
"role": { "type": "string", "enum": ["editor", "viewer"] }
}
}
}
}
},
"responses": {
"200": { "description": "Project shared", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } },
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Project not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/projects/{id}/members": {
"get": {
"tags": ["Projects"],
"summary": "List project members",
"description": "Returns members of a project. Requires `tasks:read` scope.",
"operationId": "listProjectMembers",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
],
"responses": {
"200": {
"description": "List of project members",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["items"],
"properties": {
"items": { "type": "array", "items": { "$ref": "#/components/schemas/ProjectMember" } }
}
}
}
}
},
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Project not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
}
}
}

View File

@@ -188,6 +188,86 @@
"start": { "type": "string", "format": "date-time" },
"end": { "type": "string", "format": "date-time" }
}
},
"Task": {
"type": "object",
"required": ["id", "title", "status", "priority", "owner_id", "created_at", "updated_at"],
"properties": {
"id": { "type": "string", "format": "uuid" },
"title": { "type": "string", "minLength": 1, "maxLength": 500 },
"description": { "type": "string", "nullable": true, "description": "Markdown supported" },
"status": { "type": "string", "enum": ["todo", "in_progress", "done", "archived"] },
"priority": { "type": "string", "enum": ["low", "medium", "high", "critical"] },
"due_date": { "type": "string", "format": "date-time", "nullable": true },
"completed_at": { "type": "string", "format": "date-time", "nullable": true },
"created_at": { "type": "string", "format": "date-time" },
"updated_at": { "type": "string", "format": "date-time" },
"owner_id": { "type": "string", "format": "uuid" },
"project_id": { "type": "string", "format": "uuid", "nullable": true },
"parent_id": { "type": "string", "format": "uuid", "nullable": true },
"subtasks": { "type": "array", "items": { "$ref": "#/components/schemas/Task" } },
"tags": { "type": "array", "items": { "$ref": "#/components/schemas/Tag" } },
"completion_percentage": { "type": "integer", "nullable": true }
}
},
"Project": {
"type": "object",
"required": ["id", "owner_id", "name", "color", "is_shared", "created_at", "updated_at"],
"properties": {
"id": { "type": "string", "format": "uuid" },
"owner_id": { "type": "string", "format": "uuid" },
"name": { "type": "string", "minLength": 1, "maxLength": 100 },
"color": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$", "example": "#3B82F6" },
"is_shared": { "type": "boolean" },
"deadline": { "type": "string", "format": "date-time", "nullable": true },
"sort_order": { "type": "integer" },
"created_at": { "type": "string", "format": "date-time" },
"updated_at": { "type": "string", "format": "date-time" }
}
},
"Tag": {
"type": "object",
"required": ["id", "owner_id", "name", "color", "created_at"],
"properties": {
"id": { "type": "string", "format": "uuid" },
"owner_id": { "type": "string", "format": "uuid" },
"name": { "type": "string", "minLength": 1, "maxLength": 50 },
"color": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$", "example": "#6B7280" },
"created_at": { "type": "string", "format": "date-time" }
}
},
"ProjectMember": {
"type": "object",
"required": ["user_id", "email", "role"],
"properties": {
"user_id": { "type": "string", "format": "uuid" },
"email": { "type": "string", "format": "email" },
"role": { "type": "string", "enum": ["owner", "editor", "viewer"] }
}
},
"TaskReminder": {
"type": "object",
"required": ["id", "task_id", "type", "scheduled_at", "created_at"],
"properties": {
"id": { "type": "string", "format": "uuid" },
"task_id": { "type": "string", "format": "uuid" },
"type": { "type": "string", "enum": ["push", "email", "webhook", "telegram", "nostr"] },
"config": { "type": "object", "additionalProperties": true },
"scheduled_at": { "type": "string", "format": "date-time" },
"created_at": { "type": "string", "format": "date-time" }
}
},
"TaskWebhook": {
"type": "object",
"required": ["id", "owner_id", "url", "events", "created_at"],
"properties": {
"id": { "type": "string", "format": "uuid" },
"owner_id": { "type": "string", "format": "uuid" },
"url": { "type": "string", "format": "uri" },
"events": { "type": "array", "items": { "type": "string", "enum": ["created", "status_change", "completion"] } },
"secret": { "type": "string", "nullable": true },
"created_at": { "type": "string", "format": "date-time" }
}
}
}
}

View File

@@ -0,0 +1,198 @@
{
"paths": {
"/tags": {
"get": {
"tags": ["Tags"],
"summary": "List tags",
"description": "Returns the authenticated user's tags. Requires `tasks:read` scope.",
"operationId": "listTags",
"parameters": [
{ "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 } },
{ "name": "cursor", "in": "query", "schema": { "type": "string" }, "description": "Pagination cursor" }
],
"responses": {
"200": {
"description": "List of tags",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["items", "page"],
"properties": {
"items": { "type": "array", "items": { "$ref": "#/components/schemas/Tag" } },
"page": { "$ref": "#/components/schemas/PageInfo" }
}
}
}
}
},
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
"post": {
"tags": ["Tags"],
"summary": "Create a tag",
"description": "Creates a new tag for the authenticated user. Requires `tasks:write` scope.",
"operationId": "createTag",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["name"],
"properties": {
"name": { "type": "string", "minLength": 1, "maxLength": 50 },
"color": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$", "example": "#6B7280" }
}
}
}
}
},
"responses": {
"200": {
"description": "Tag created",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["tag"],
"properties": {
"tag": { "$ref": "#/components/schemas/Tag" }
}
}
}
}
},
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/tags/{id}": {
"get": {
"tags": ["Tags"],
"summary": "Get a tag",
"description": "Returns a single tag by ID. Requires `tasks:read` scope.",
"operationId": "getTag",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
],
"responses": {
"200": {
"description": "Tag details",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["tag"],
"properties": {
"tag": { "$ref": "#/components/schemas/Tag" }
}
}
}
}
},
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Tag not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
"put": {
"tags": ["Tags"],
"summary": "Update a tag",
"description": "Updates a tag. Requires `tasks:write` scope.",
"operationId": "updateTag",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": { "type": "string", "minLength": 1, "maxLength": 50 },
"color": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$" }
}
}
}
}
},
"responses": {
"200": {
"description": "Tag updated",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["tag"],
"properties": {
"tag": { "$ref": "#/components/schemas/Tag" }
}
}
}
}
},
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Tag not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
"delete": {
"tags": ["Tags"],
"summary": "Delete a tag",
"description": "Deletes a tag. Requires `tasks:write` scope.",
"operationId": "deleteTag",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
],
"responses": {
"200": { "description": "Tag deleted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Tag not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/tags/{id}/attach/{taskId}": {
"post": {
"tags": ["Tags"],
"summary": "Attach tag to task",
"description": "Attaches a tag to a task. Requires `tasks:write` scope.",
"operationId": "attachTagToTask",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "Tag ID" },
{ "name": "taskId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "Task ID" }
],
"responses": {
"200": { "description": "Tag attached", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Tag or task not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/tags/{id}/detach/{taskId}": {
"delete": {
"tags": ["Tags"],
"summary": "Detach tag from task",
"description": "Removes a tag from a task. Requires `tasks:write` scope.",
"operationId": "detachTagFromTask",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "Tag ID" },
{ "name": "taskId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "Task ID" }
],
"responses": {
"200": { "description": "Tag detached", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Tag or task not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
}
}
}

View File

@@ -0,0 +1,459 @@
{
"paths": {
"/tasks": {
"get": {
"tags": ["Tasks"],
"summary": "List tasks",
"description": "Returns the authenticated user's tasks with optional filters. Supports status, priority, due date range, project, and cursor-based pagination. Requires `tasks:read` scope.",
"operationId": "listTasks",
"parameters": [
{ "name": "status", "in": "query", "schema": { "type": "string", "enum": ["todo", "in_progress", "done", "archived"] }, "description": "Filter by status" },
{ "name": "priority", "in": "query", "schema": { "type": "string", "enum": ["low", "medium", "high", "critical"] }, "description": "Filter by priority" },
{ "name": "project_id", "in": "query", "schema": { "type": "string", "format": "uuid" }, "description": "Filter by project" },
{ "name": "due_from", "in": "query", "schema": { "type": "string", "format": "date-time" }, "description": "Filter tasks due on or after" },
{ "name": "due_to", "in": "query", "schema": { "type": "string", "format": "date-time" }, "description": "Filter tasks due on or before" },
{ "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 } },
{ "name": "cursor", "in": "query", "schema": { "type": "string" }, "description": "Pagination cursor" }
],
"responses": {
"200": {
"description": "List of tasks",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["items", "page"],
"properties": {
"items": { "type": "array", "items": { "$ref": "#/components/schemas/Task" } },
"page": { "$ref": "#/components/schemas/PageInfo" }
}
}
}
}
},
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
"post": {
"tags": ["Tasks"],
"summary": "Create a task",
"description": "Creates a new task for the authenticated user. Requires `tasks:write` scope.",
"operationId": "createTask",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["title"],
"properties": {
"title": { "type": "string", "minLength": 1, "maxLength": 500 },
"description": { "type": "string" },
"status": { "type": "string", "enum": ["todo", "in_progress", "done", "archived"], "default": "todo" },
"priority": { "type": "string", "enum": ["low", "medium", "high", "critical"], "default": "medium" },
"due_date": { "type": "string", "format": "date-time" },
"project_id": { "type": "string", "format": "uuid" },
"parent_id": { "type": "string", "format": "uuid" },
"sort_order": { "type": "integer" },
"recurrence_rule": { "type": "string" }
}
}
}
}
},
"responses": {
"200": {
"description": "Task created",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["task"],
"properties": {
"task": { "$ref": "#/components/schemas/Task" }
}
}
}
}
},
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/tasks/{id}": {
"get": {
"tags": ["Tasks"],
"summary": "Get a task",
"description": "Returns a single task by ID. Requires `tasks:read` scope.",
"operationId": "getTask",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
],
"responses": {
"200": {
"description": "Task details",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["task"],
"properties": {
"task": { "$ref": "#/components/schemas/Task" }
}
}
}
}
},
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Task not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
"put": {
"tags": ["Tasks"],
"summary": "Update a task",
"description": "Updates a task's fields. Requires `tasks:write` scope.",
"operationId": "updateTask",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"title": { "type": "string", "minLength": 1, "maxLength": 500 },
"description": { "type": "string" },
"status": { "type": "string", "enum": ["todo", "in_progress", "done", "archived"] },
"priority": { "type": "string", "enum": ["low", "medium", "high", "critical"] },
"due_date": { "type": "string", "format": "date-time" },
"project_id": { "type": "string", "format": "uuid", "nullable": true },
"sort_order": { "type": "integer" },
"recurrence_rule": { "type": "string", "nullable": true }
}
}
}
}
},
"responses": {
"200": {
"description": "Task updated",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["task"],
"properties": {
"task": { "$ref": "#/components/schemas/Task" }
}
}
}
}
},
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Task not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
"delete": {
"tags": ["Tasks"],
"summary": "Delete a task",
"description": "Soft-deletes a task by default. Use `?permanent=true` for hard delete. Requires `tasks:write` scope.",
"operationId": "deleteTask",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } },
{ "name": "permanent", "in": "query", "schema": { "type": "boolean", "default": false }, "description": "If true, permanently delete (hard delete)" }
],
"responses": {
"200": { "description": "Task deleted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Task not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/tasks/{id}/complete": {
"post": {
"tags": ["Tasks"],
"summary": "Mark task complete",
"description": "Marks a task as done. Fails if blocked by incomplete dependencies. Requires `tasks:write` scope.",
"operationId": "markTaskComplete",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
],
"responses": {
"200": {
"description": "Task marked complete",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["task"],
"properties": {
"task": { "$ref": "#/components/schemas/Task" }
}
}
}
}
},
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Task not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"409": { "description": "Blocked by incomplete dependencies", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/tasks/{id}/uncomplete": {
"post": {
"tags": ["Tasks"],
"summary": "Mark task uncomplete",
"description": "Reverts a completed task to todo (or specified status). Requires `tasks:write` scope.",
"operationId": "markTaskUncomplete",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": { "type": "string", "enum": ["todo", "in_progress"], "default": "todo" }
}
}
}
}
},
"responses": {
"200": {
"description": "Task marked uncomplete",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["task"],
"properties": {
"task": { "$ref": "#/components/schemas/Task" }
}
}
}
}
},
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Task not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/tasks/{id}/subtasks": {
"get": {
"tags": ["Tasks"],
"summary": "List subtasks",
"description": "Returns the subtasks of a task. Requires `tasks:read` scope.",
"operationId": "listTaskSubtasks",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
],
"responses": {
"200": {
"description": "List of subtasks",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["items", "page"],
"properties": {
"items": { "type": "array", "items": { "$ref": "#/components/schemas/Task" } },
"page": { "$ref": "#/components/schemas/PageInfo" }
}
}
}
}
},
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Task not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/tasks/{id}/dependencies": {
"get": {
"tags": ["Tasks"],
"summary": "List task blockers",
"description": "Returns tasks that block this task from being completed. Requires `tasks:read` scope.",
"operationId": "listTaskBlockers",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
],
"responses": {
"200": {
"description": "List of blocking tasks",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["items", "page"],
"properties": {
"items": { "type": "array", "items": { "$ref": "#/components/schemas/Task" } },
"page": { "$ref": "#/components/schemas/PageInfo" }
}
}
}
}
},
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Task not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
"post": {
"tags": ["Tasks"],
"summary": "Add dependency",
"description": "Adds a dependency: the specified task blocks this task. Requires `tasks:write` scope.",
"operationId": "addTaskDependency",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "Task ID (blocked task)" }
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["blocks_task_id"],
"properties": {
"blocks_task_id": { "type": "string", "format": "uuid", "description": "Task that blocks this one" }
}
}
}
}
},
"responses": {
"200": { "description": "Dependency added", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } },
"400": { "description": "Validation error (e.g. self-dependency)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Task not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"409": { "description": "Circular dependency detected", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/tasks/{id}/dependencies/{blocksTaskId}": {
"delete": {
"tags": ["Tasks"],
"summary": "Remove dependency",
"description": "Removes a dependency. Requires `tasks:write` scope.",
"operationId": "removeTaskDependency",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "Task ID (blocked task)" },
{ "name": "blocksTaskId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "Blocker task ID" }
],
"responses": {
"200": { "description": "Dependency removed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Task not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/tasks/{id}/reminders": {
"get": {
"tags": ["Tasks"],
"summary": "List task reminders",
"description": "Returns reminders for a task. Requires `tasks:read` scope.",
"operationId": "listTaskReminders",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
],
"responses": {
"200": {
"description": "List of reminders",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["items", "page"],
"properties": {
"items": { "type": "array", "items": { "$ref": "#/components/schemas/TaskReminder" } },
"page": { "$ref": "#/components/schemas/PageInfo" }
}
}
}
}
},
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Task not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
"post": {
"tags": ["Tasks"],
"summary": "Add task reminder",
"description": "Creates a reminder for a task. Requires `tasks:write` scope.",
"operationId": "addTaskReminder",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["type", "scheduled_at"],
"properties": {
"type": { "type": "string", "enum": ["push", "email", "webhook", "telegram", "nostr"] },
"config": { "type": "object", "additionalProperties": true },
"scheduled_at": { "type": "string", "format": "date-time" }
}
}
}
}
},
"responses": {
"200": {
"description": "Reminder created",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["reminder"],
"properties": {
"reminder": { "$ref": "#/components/schemas/TaskReminder" }
}
}
}
}
},
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Task not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/tasks/{id}/reminders/{reminderId}": {
"delete": {
"tags": ["Tasks"],
"summary": "Delete task reminder",
"description": "Deletes a reminder. Requires `tasks:write` scope.",
"operationId": "deleteTaskReminder",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "Task ID" },
{ "name": "reminderId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "Reminder ID" }
],
"responses": {
"200": { "description": "Reminder deleted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Task or reminder not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
}
}
}

View File

@@ -0,0 +1,96 @@
{
"paths": {
"/webhooks": {
"get": {
"tags": ["Webhooks"],
"summary": "List task webhooks",
"description": "Returns the authenticated user's task webhooks. Requires `tasks:read` scope.",
"operationId": "listTaskWebhooks",
"parameters": [
{ "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 } },
{ "name": "cursor", "in": "query", "schema": { "type": "string" }, "description": "Pagination cursor" }
],
"responses": {
"200": {
"description": "List of webhooks",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["items", "page"],
"properties": {
"items": { "type": "array", "items": { "$ref": "#/components/schemas/TaskWebhook" } },
"page": { "$ref": "#/components/schemas/PageInfo" }
}
}
}
}
},
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
"post": {
"tags": ["Webhooks"],
"summary": "Create task webhook",
"description": "Creates a webhook that receives task events (created, status_change, completion). Requires `tasks:write` scope.",
"operationId": "createTaskWebhook",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["url", "events"],
"properties": {
"url": { "type": "string", "format": "uri", "description": "Webhook endpoint URL" },
"events": {
"type": "array",
"items": { "type": "string", "enum": ["created", "status_change", "completion"] },
"description": "Events to subscribe to"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Webhook created",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["webhook"],
"properties": {
"webhook": { "$ref": "#/components/schemas/TaskWebhook" }
}
}
}
}
},
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/webhooks/{id}": {
"delete": {
"tags": ["Webhooks"],
"summary": "Delete task webhook",
"description": "Deletes a task webhook. Requires `tasks:write` scope.",
"operationId": "deleteTaskWebhook",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
],
"responses": {
"200": { "description": "Webhook deleted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Webhook not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
}
}
}

View File

@@ -13,18 +13,24 @@ import (
)
type Handlers struct {
Auth *handlers.AuthHandler
User *handlers.UserHandler
Calendar *handlers.CalendarHandler
Sharing *handlers.SharingHandler
Event *handlers.EventHandler
Reminder *handlers.ReminderHandler
Attendee *handlers.AttendeeHandler
Contact *handlers.ContactHandler
Availability *handlers.AvailabilityHandler
Booking *handlers.BookingHandler
APIKey *handlers.APIKeyHandler
ICS *handlers.ICSHandler
Auth *handlers.AuthHandler
User *handlers.UserHandler
Calendar *handlers.CalendarHandler
Sharing *handlers.SharingHandler
Event *handlers.EventHandler
Reminder *handlers.ReminderHandler
Attendee *handlers.AttendeeHandler
Contact *handlers.ContactHandler
Availability *handlers.AvailabilityHandler
Booking *handlers.BookingHandler
APIKey *handlers.APIKeyHandler
ICS *handlers.ICSHandler
Task *handlers.TaskHandler
Project *handlers.ProjectHandler
Tag *handlers.TagHandler
TaskDependency *handlers.TaskDependencyHandler
TaskReminder *handlers.TaskReminderHandler
TaskWebhook *handlers.TaskWebhookHandler
}
func NewRouter(h Handlers, authMW *mw.AuthMiddleware, rateLimiter *mw.RateLimiter, cfg *config.Config) *chi.Mux {
@@ -152,6 +158,68 @@ func NewRouter(h Handlers, authMW *mw.AuthMiddleware, rateLimiter *mw.RateLimite
})
})
// Tasks
r.Route("/tasks", func(r chi.Router) {
r.With(mw.RequireScope("tasks", "read")).Get("/", h.Task.List)
r.With(mw.RequireScope("tasks", "write")).Post("/", h.Task.Create)
r.Route("/{id}", func(r chi.Router) {
r.With(mw.RequireScope("tasks", "read")).Get("/", h.Task.Get)
r.With(mw.RequireScope("tasks", "write")).Put("/", h.Task.Update)
r.With(mw.RequireScope("tasks", "write")).Delete("/", h.Task.Delete)
r.With(mw.RequireScope("tasks", "write")).Post("/complete", h.Task.MarkComplete)
r.With(mw.RequireScope("tasks", "write")).Post("/uncomplete", h.Task.MarkUncomplete)
r.With(mw.RequireScope("tasks", "read")).Get("/subtasks", h.Task.ListSubtasks)
r.Route("/dependencies", func(r chi.Router) {
r.With(mw.RequireScope("tasks", "read")).Get("/", h.TaskDependency.ListBlockers)
r.With(mw.RequireScope("tasks", "write")).Post("/", h.TaskDependency.Add)
r.With(mw.RequireScope("tasks", "write")).Delete("/{blocksTaskId}", h.TaskDependency.Remove)
})
r.Route("/reminders", func(r chi.Router) {
r.With(mw.RequireScope("tasks", "read")).Get("/", h.TaskReminder.List)
r.With(mw.RequireScope("tasks", "write")).Post("/", h.TaskReminder.Add)
r.With(mw.RequireScope("tasks", "write")).Delete("/{reminderId}", h.TaskReminder.Delete)
})
})
})
// Projects
r.Route("/projects", func(r chi.Router) {
r.With(mw.RequireScope("tasks", "read")).Get("/", h.Project.List)
r.With(mw.RequireScope("tasks", "write")).Post("/", h.Project.Create)
r.Route("/{id}", func(r chi.Router) {
r.With(mw.RequireScope("tasks", "read")).Get("/", h.Project.Get)
r.With(mw.RequireScope("tasks", "write")).Put("/", h.Project.Update)
r.With(mw.RequireScope("tasks", "write")).Delete("/", h.Project.Delete)
r.With(mw.RequireScope("tasks", "write")).Post("/share", h.Project.Share)
r.With(mw.RequireScope("tasks", "read")).Get("/members", h.Project.ListMembers)
})
})
// Tags
r.Route("/tags", func(r chi.Router) {
r.With(mw.RequireScope("tasks", "read")).Get("/", h.Tag.List)
r.With(mw.RequireScope("tasks", "write")).Post("/", h.Tag.Create)
r.Route("/{id}", func(r chi.Router) {
r.With(mw.RequireScope("tasks", "read")).Get("/", h.Tag.Get)
r.With(mw.RequireScope("tasks", "write")).Put("/", h.Tag.Update)
r.With(mw.RequireScope("tasks", "write")).Delete("/", h.Tag.Delete)
r.With(mw.RequireScope("tasks", "write")).Post("/attach/{taskId}", h.Tag.AttachToTask)
r.With(mw.RequireScope("tasks", "write")).Delete("/detach/{taskId}", h.Tag.DetachFromTask)
})
})
// Task Webhooks
r.Route("/webhooks", func(r chi.Router) {
r.With(mw.RequireScope("tasks", "read")).Get("/", h.TaskWebhook.List)
r.With(mw.RequireScope("tasks", "write")).Post("/", h.TaskWebhook.Create)
r.With(mw.RequireScope("tasks", "write")).Delete("/{id}", h.TaskWebhook.Delete)
})
// Availability
r.With(mw.RequireScope("availability", "read")).Get("/availability", h.Availability.Get)
})

View File

@@ -4,6 +4,7 @@ import (
"bufio"
"log"
"os"
"path/filepath"
"strconv"
"strings"
)
@@ -21,7 +22,7 @@ type Config struct {
}
func Load() *Config {
loadEnvFile(".env")
loadEnvFromPath()
port := getEnv("SERVER_PORT", "8080")
jwtSecret := getEnv("JWT_SECRET", "dev-secret-change-me")
@@ -41,7 +42,7 @@ func Load() *Config {
RedisAddr: os.Getenv("REDIS_ADDR"),
ServerPort: port,
Env: env,
BaseURL: getEnv("BASE_URL", "http://localhost:"+port),
BaseURL: strings.TrimSuffix(getEnv("BASE_URL", "http://localhost:"+port), "/"),
CORSOrigins: corsOrigins,
RateLimitRPS: rateRPS,
RateLimitBurst: rateBurst,
@@ -82,6 +83,24 @@ func parseInt(s string, def int) int {
return v
}
func loadEnvFromPath() {
if p := os.Getenv("ENV_FILE"); p != "" {
loadEnvFile(p)
return
}
cwd, _ := os.Getwd()
for _, p := range []string{".env", "../.env"} {
full := filepath.Join(cwd, p)
if f, err := os.Open(full); err == nil {
f.Close()
loadEnvFile(full)
return
}
}
// Fallback: try current dir as-is (for relative paths)
loadEnvFile(".env")
}
func loadEnvFile(path string) {
f, err := os.Open(path)
if err != nil {

View File

@@ -79,6 +79,70 @@ type Attachment struct {
FileURL string `json:"file_url"`
}
type Task struct {
ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"owner_id"`
Title string `json:"title"`
Description *string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
DueDate *time.Time `json:"due_date"`
CompletedAt *time.Time `json:"completed_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ProjectID *uuid.UUID `json:"project_id"`
ParentID *uuid.UUID `json:"parent_id"`
SortOrder int `json:"sort_order"`
RecurrenceRule *string `json:"recurrence_rule"`
Subtasks []Task `json:"subtasks,omitempty"`
Tags []Tag `json:"tags,omitempty"`
CompletionPercentage *int `json:"completion_percentage,omitempty"`
}
type Project struct {
ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"owner_id"`
Name string `json:"name"`
Color string `json:"color"`
IsShared bool `json:"is_shared"`
Deadline *time.Time `json:"deadline"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ProjectMember struct {
UserID uuid.UUID `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
}
type TaskWebhook struct {
ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"owner_id"`
URL string `json:"url"`
Events []string `json:"events"`
Secret *string `json:"secret,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type TaskReminder struct {
ID uuid.UUID `json:"id"`
TaskID uuid.UUID `json:"task_id"`
Type string `json:"type"`
Config map[string]interface{} `json:"config"`
ScheduledAt time.Time `json:"scheduled_at"`
CreatedAt time.Time `json:"created_at"`
}
type Tag struct {
ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"owner_id"`
Name string `json:"name"`
Color string `json:"color"`
CreatedAt time.Time `json:"created_at"`
}
type Contact struct {
ID uuid.UUID `json:"id"`
FirstName *string `json:"first_name"`

View File

@@ -199,7 +199,7 @@ SELECT c.id, c.owner_id, c.name, c.color, c.is_public, c.count_for_availability,
FROM calendars c
JOIN calendar_members cm ON cm.calendar_id = c.id
WHERE cm.user_id = $1 AND c.deleted_at IS NULL
ORDER BY c.created_at ASC
ORDER BY c.sort_order ASC, c.created_at ASC
`
type ListCalendarsByUserRow struct {

View File

@@ -130,6 +130,25 @@ type EventReminder struct {
MinutesBefore int32 `json:"minutes_before"`
}
type Project struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
Name string `json:"name"`
Color string `json:"color"`
IsShared bool `json:"is_shared"`
Deadline pgtype.Timestamptz `json:"deadline"`
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
}
type ProjectMember struct {
ProjectID pgtype.UUID `json:"project_id"`
UserID pgtype.UUID `json:"user_id"`
Role string `json:"role"`
}
type RefreshToken struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
@@ -139,6 +158,60 @@ type RefreshToken struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Tag struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
Name string `json:"name"`
Color string `json:"color"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Task struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
DueDate pgtype.Timestamptz `json:"due_date"`
CompletedAt pgtype.Timestamptz `json:"completed_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
ProjectID pgtype.UUID `json:"project_id"`
ParentID pgtype.UUID `json:"parent_id"`
SortOrder int32 `json:"sort_order"`
RecurrenceRule pgtype.Text `json:"recurrence_rule"`
}
type TaskDependency struct {
TaskID pgtype.UUID `json:"task_id"`
BlocksTaskID pgtype.UUID `json:"blocks_task_id"`
}
type TaskReminder struct {
ID pgtype.UUID `json:"id"`
TaskID pgtype.UUID `json:"task_id"`
Type string `json:"type"`
Config []byte `json:"config"`
ScheduledAt pgtype.Timestamptz `json:"scheduled_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type TaskTag struct {
TaskID pgtype.UUID `json:"task_id"`
TagID pgtype.UUID `json:"tag_id"`
}
type TaskWebhook struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
Url string `json:"url"`
Events []byte `json:"events"`
Secret pgtype.Text `json:"secret"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type User struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`

View File

@@ -0,0 +1,309 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: projects.sql
package repository
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const addProjectMember = `-- name: AddProjectMember :exec
INSERT INTO project_members (project_id, user_id, role)
VALUES ($1, $2, $3)
ON CONFLICT (project_id, user_id) DO UPDATE SET role = EXCLUDED.role
`
type AddProjectMemberParams struct {
ProjectID pgtype.UUID `json:"project_id"`
UserID pgtype.UUID `json:"user_id"`
Role string `json:"role"`
}
func (q *Queries) AddProjectMember(ctx context.Context, arg AddProjectMemberParams) error {
_, err := q.db.Exec(ctx, addProjectMember, arg.ProjectID, arg.UserID, arg.Role)
return err
}
const createProject = `-- name: CreateProject :one
INSERT INTO projects (id, owner_id, name, color, is_shared, deadline, sort_order)
VALUES ($1, $2, $3, $4, COALESCE($5, false), $6, COALESCE($7, 0))
RETURNING id, owner_id, name, color, is_shared, deadline, sort_order, created_at, updated_at, deleted_at
`
type CreateProjectParams struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
Name string `json:"name"`
Color string `json:"color"`
Column5 interface{} `json:"column_5"`
Deadline pgtype.Timestamptz `json:"deadline"`
Column7 interface{} `json:"column_7"`
}
func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) {
row := q.db.QueryRow(ctx, createProject,
arg.ID,
arg.OwnerID,
arg.Name,
arg.Color,
arg.Column5,
arg.Deadline,
arg.Column7,
)
var i Project
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.Name,
&i.Color,
&i.IsShared,
&i.Deadline,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
)
return i, err
}
const getProjectByID = `-- name: GetProjectByID :one
SELECT id, owner_id, name, color, is_shared, deadline, sort_order, created_at, updated_at, deleted_at FROM projects
WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL
`
type GetProjectByIDParams struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
}
func (q *Queries) GetProjectByID(ctx context.Context, arg GetProjectByIDParams) (Project, error) {
row := q.db.QueryRow(ctx, getProjectByID, arg.ID, arg.OwnerID)
var i Project
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.Name,
&i.Color,
&i.IsShared,
&i.Deadline,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
)
return i, err
}
const getProjectMember = `-- name: GetProjectMember :one
SELECT project_id, user_id, role FROM project_members
WHERE project_id = $1 AND user_id = $2
`
type GetProjectMemberParams struct {
ProjectID pgtype.UUID `json:"project_id"`
UserID pgtype.UUID `json:"user_id"`
}
func (q *Queries) GetProjectMember(ctx context.Context, arg GetProjectMemberParams) (ProjectMember, error) {
row := q.db.QueryRow(ctx, getProjectMember, arg.ProjectID, arg.UserID)
var i ProjectMember
err := row.Scan(&i.ProjectID, &i.UserID, &i.Role)
return i, err
}
const listProjectMembers = `-- name: ListProjectMembers :many
SELECT pm.project_id, pm.user_id, pm.role, u.email
FROM project_members pm
JOIN users u ON u.id = pm.user_id
WHERE pm.project_id = $1
`
type ListProjectMembersRow struct {
ProjectID pgtype.UUID `json:"project_id"`
UserID pgtype.UUID `json:"user_id"`
Role string `json:"role"`
Email string `json:"email"`
}
func (q *Queries) ListProjectMembers(ctx context.Context, projectID pgtype.UUID) ([]ListProjectMembersRow, error) {
rows, err := q.db.Query(ctx, listProjectMembers, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListProjectMembersRow{}
for rows.Next() {
var i ListProjectMembersRow
if err := rows.Scan(
&i.ProjectID,
&i.UserID,
&i.Role,
&i.Email,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listProjects = `-- name: ListProjects :many
SELECT id, owner_id, name, color, is_shared, deadline, sort_order, created_at, updated_at, deleted_at FROM projects
WHERE owner_id = $1 AND deleted_at IS NULL
ORDER BY sort_order ASC, name ASC
`
func (q *Queries) ListProjects(ctx context.Context, ownerID pgtype.UUID) ([]Project, error) {
rows, err := q.db.Query(ctx, listProjects, ownerID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Project{}
for rows.Next() {
var i Project
if err := rows.Scan(
&i.ID,
&i.OwnerID,
&i.Name,
&i.Color,
&i.IsShared,
&i.Deadline,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listProjectsForUser = `-- name: ListProjectsForUser :many
SELECT p.id, p.owner_id, p.name, p.color, p.is_shared, p.deadline, p.sort_order, p.created_at, p.updated_at, p.deleted_at FROM projects p
LEFT JOIN project_members pm ON pm.project_id = p.id AND pm.user_id = $1
WHERE (p.owner_id = $1 OR pm.user_id = $1)
AND p.deleted_at IS NULL
ORDER BY p.sort_order ASC, p.name ASC
`
func (q *Queries) ListProjectsForUser(ctx context.Context, userID pgtype.UUID) ([]Project, error) {
rows, err := q.db.Query(ctx, listProjectsForUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Project{}
for rows.Next() {
var i Project
if err := rows.Scan(
&i.ID,
&i.OwnerID,
&i.Name,
&i.Color,
&i.IsShared,
&i.Deadline,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const removeProjectMember = `-- name: RemoveProjectMember :exec
DELETE FROM project_members
WHERE project_id = $1 AND user_id = $2
`
type RemoveProjectMemberParams struct {
ProjectID pgtype.UUID `json:"project_id"`
UserID pgtype.UUID `json:"user_id"`
}
func (q *Queries) RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) error {
_, err := q.db.Exec(ctx, removeProjectMember, arg.ProjectID, arg.UserID)
return err
}
const softDeleteProject = `-- name: SoftDeleteProject :exec
UPDATE projects SET deleted_at = now(), updated_at = now()
WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL
`
type SoftDeleteProjectParams struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
}
func (q *Queries) SoftDeleteProject(ctx context.Context, arg SoftDeleteProjectParams) error {
_, err := q.db.Exec(ctx, softDeleteProject, arg.ID, arg.OwnerID)
return err
}
const updateProject = `-- name: UpdateProject :one
UPDATE projects
SET name = COALESCE($1, name),
color = COALESCE($2, color),
is_shared = COALESCE($3, is_shared),
deadline = $4,
sort_order = COALESCE($5, sort_order),
updated_at = now()
WHERE id = $6 AND owner_id = $7 AND deleted_at IS NULL
RETURNING id, owner_id, name, color, is_shared, deadline, sort_order, created_at, updated_at, deleted_at
`
type UpdateProjectParams struct {
Name pgtype.Text `json:"name"`
Color pgtype.Text `json:"color"`
IsShared pgtype.Bool `json:"is_shared"`
Deadline pgtype.Timestamptz `json:"deadline"`
SortOrder pgtype.Int4 `json:"sort_order"`
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
}
func (q *Queries) UpdateProject(ctx context.Context, arg UpdateProjectParams) (Project, error) {
row := q.db.QueryRow(ctx, updateProject,
arg.Name,
arg.Color,
arg.IsShared,
arg.Deadline,
arg.SortOrder,
arg.ID,
arg.OwnerID,
)
var i Project
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.Name,
&i.Color,
&i.IsShared,
&i.Deadline,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
)
return i, err
}

View File

@@ -105,17 +105,6 @@ func (q *Queries) ListSubscriptionsByCalendar(ctx context.Context, calendarID pg
return items, nil
}
const updateSubscriptionLastSynced = `-- name: UpdateSubscriptionLastSynced :exec
UPDATE calendar_subscriptions
SET last_synced_at = now()
WHERE id = $1
`
func (q *Queries) UpdateSubscriptionLastSynced(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, updateSubscriptionLastSynced, id)
return err
}
const listSubscriptionsDueForSync = `-- name: ListSubscriptionsDueForSync :many
SELECT s.id, s.calendar_id, s.source_url, c.owner_id
FROM calendar_subscriptions s
@@ -139,10 +128,15 @@ func (q *Queries) ListSubscriptionsDueForSync(ctx context.Context) ([]ListSubscr
return nil, err
}
defer rows.Close()
var items []ListSubscriptionsDueForSyncRow
items := []ListSubscriptionsDueForSyncRow{}
for rows.Next() {
var i ListSubscriptionsDueForSyncRow
if err := rows.Scan(&i.ID, &i.CalendarID, &i.SourceUrl, &i.OwnerID); err != nil {
if err := rows.Scan(
&i.ID,
&i.CalendarID,
&i.SourceUrl,
&i.OwnerID,
); err != nil {
return nil, err
}
items = append(items, i)
@@ -152,3 +146,14 @@ func (q *Queries) ListSubscriptionsDueForSync(ctx context.Context) ([]ListSubscr
}
return items, nil
}
const updateSubscriptionLastSynced = `-- name: UpdateSubscriptionLastSynced :exec
UPDATE calendar_subscriptions
SET last_synced_at = now()
WHERE id = $1
`
func (q *Queries) UpdateSubscriptionLastSynced(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, updateSubscriptionLastSynced, id)
return err
}

View File

@@ -0,0 +1,232 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: tags.sql
package repository
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const attachTagToTask = `-- name: AttachTagToTask :exec
INSERT INTO task_tags (task_id, tag_id)
VALUES ($1, $2)
ON CONFLICT (task_id, tag_id) DO NOTHING
`
type AttachTagToTaskParams struct {
TaskID pgtype.UUID `json:"task_id"`
TagID pgtype.UUID `json:"tag_id"`
}
func (q *Queries) AttachTagToTask(ctx context.Context, arg AttachTagToTaskParams) error {
_, err := q.db.Exec(ctx, attachTagToTask, arg.TaskID, arg.TagID)
return err
}
const createTag = `-- name: CreateTag :one
INSERT INTO tags (id, owner_id, name, color)
VALUES ($1, $2, $3, COALESCE($4, '#6B7280'))
RETURNING id, owner_id, name, color, created_at
`
type CreateTagParams struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
Name string `json:"name"`
Column4 interface{} `json:"column_4"`
}
func (q *Queries) CreateTag(ctx context.Context, arg CreateTagParams) (Tag, error) {
row := q.db.QueryRow(ctx, createTag,
arg.ID,
arg.OwnerID,
arg.Name,
arg.Column4,
)
var i Tag
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.Name,
&i.Color,
&i.CreatedAt,
)
return i, err
}
const deleteTag = `-- name: DeleteTag :exec
DELETE FROM tags WHERE id = $1 AND owner_id = $2
`
type DeleteTagParams struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
}
func (q *Queries) DeleteTag(ctx context.Context, arg DeleteTagParams) error {
_, err := q.db.Exec(ctx, deleteTag, arg.ID, arg.OwnerID)
return err
}
const detachTagFromTask = `-- name: DetachTagFromTask :exec
DELETE FROM task_tags
WHERE task_id = $1 AND tag_id = $2
`
type DetachTagFromTaskParams struct {
TaskID pgtype.UUID `json:"task_id"`
TagID pgtype.UUID `json:"tag_id"`
}
func (q *Queries) DetachTagFromTask(ctx context.Context, arg DetachTagFromTaskParams) error {
_, err := q.db.Exec(ctx, detachTagFromTask, arg.TaskID, arg.TagID)
return err
}
const getTagByID = `-- name: GetTagByID :one
SELECT id, owner_id, name, color, created_at FROM tags
WHERE id = $1 AND owner_id = $2
`
type GetTagByIDParams struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
}
func (q *Queries) GetTagByID(ctx context.Context, arg GetTagByIDParams) (Tag, error) {
row := q.db.QueryRow(ctx, getTagByID, arg.ID, arg.OwnerID)
var i Tag
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.Name,
&i.Color,
&i.CreatedAt,
)
return i, err
}
const listTagsByOwner = `-- name: ListTagsByOwner :many
SELECT id, owner_id, name, color, created_at FROM tags
WHERE owner_id = $1
ORDER BY name ASC
`
func (q *Queries) ListTagsByOwner(ctx context.Context, ownerID pgtype.UUID) ([]Tag, error) {
rows, err := q.db.Query(ctx, listTagsByOwner, ownerID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Tag{}
for rows.Next() {
var i Tag
if err := rows.Scan(
&i.ID,
&i.OwnerID,
&i.Name,
&i.Color,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTagsByTask = `-- name: ListTagsByTask :many
SELECT t.id, t.owner_id, t.name, t.color, t.created_at FROM tags t
JOIN task_tags tt ON tt.tag_id = t.id
WHERE tt.task_id = $1
`
func (q *Queries) ListTagsByTask(ctx context.Context, taskID pgtype.UUID) ([]Tag, error) {
rows, err := q.db.Query(ctx, listTagsByTask, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Tag{}
for rows.Next() {
var i Tag
if err := rows.Scan(
&i.ID,
&i.OwnerID,
&i.Name,
&i.Color,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTaskIDsByTag = `-- name: ListTaskIDsByTag :many
SELECT task_id FROM task_tags WHERE tag_id = $1
`
func (q *Queries) ListTaskIDsByTag(ctx context.Context, tagID pgtype.UUID) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, listTaskIDsByTag, tagID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []pgtype.UUID{}
for rows.Next() {
var task_id pgtype.UUID
if err := rows.Scan(&task_id); err != nil {
return nil, err
}
items = append(items, task_id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateTag = `-- name: UpdateTag :one
UPDATE tags
SET name = COALESCE($1, name),
color = COALESCE($2, color)
WHERE id = $3 AND owner_id = $4
RETURNING id, owner_id, name, color, created_at
`
type UpdateTagParams struct {
Name pgtype.Text `json:"name"`
Color pgtype.Text `json:"color"`
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
}
func (q *Queries) UpdateTag(ctx context.Context, arg UpdateTagParams) (Tag, error) {
row := q.db.QueryRow(ctx, updateTag,
arg.Name,
arg.Color,
arg.ID,
arg.OwnerID,
)
var i Tag
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.Name,
&i.Color,
&i.CreatedAt,
)
return i, err
}

View File

@@ -0,0 +1,147 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: task_dependencies.sql
package repository
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const addTaskDependency = `-- name: AddTaskDependency :exec
INSERT INTO task_dependencies (task_id, blocks_task_id)
VALUES ($1, $2)
ON CONFLICT (task_id, blocks_task_id) DO NOTHING
`
type AddTaskDependencyParams struct {
TaskID pgtype.UUID `json:"task_id"`
BlocksTaskID pgtype.UUID `json:"blocks_task_id"`
}
func (q *Queries) AddTaskDependency(ctx context.Context, arg AddTaskDependencyParams) error {
_, err := q.db.Exec(ctx, addTaskDependency, arg.TaskID, arg.BlocksTaskID)
return err
}
const checkDirectCircularDependency = `-- name: CheckDirectCircularDependency :one
SELECT EXISTS(
SELECT 1 FROM task_dependencies
WHERE task_id = $2 AND blocks_task_id = $1
) AS has_circle
`
type CheckDirectCircularDependencyParams struct {
BlocksTaskID pgtype.UUID `json:"blocks_task_id"`
TaskID pgtype.UUID `json:"task_id"`
}
// Direct cycle: blocks_task_id is blocked by task_id (would create A->B and B->A)
func (q *Queries) CheckDirectCircularDependency(ctx context.Context, arg CheckDirectCircularDependencyParams) (bool, error) {
row := q.db.QueryRow(ctx, checkDirectCircularDependency, arg.BlocksTaskID, arg.TaskID)
var has_circle bool
err := row.Scan(&has_circle)
return has_circle, err
}
const listTaskBlockers = `-- name: ListTaskBlockers :many
SELECT t.id, t.owner_id, t.title, t.description, t.status, t.priority, t.due_date, t.completed_at, t.created_at, t.updated_at, t.deleted_at, t.project_id, t.parent_id, t.sort_order, t.recurrence_rule FROM tasks t
JOIN task_dependencies td ON td.blocks_task_id = t.id
WHERE td.task_id = $1 AND t.deleted_at IS NULL
`
func (q *Queries) ListTaskBlockers(ctx context.Context, taskID pgtype.UUID) ([]Task, error) {
rows, err := q.db.Query(ctx, listTaskBlockers, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Task{}
for rows.Next() {
var i Task
if err := rows.Scan(
&i.ID,
&i.OwnerID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.DueDate,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
&i.ProjectID,
&i.ParentID,
&i.SortOrder,
&i.RecurrenceRule,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTasksBlockedBy = `-- name: ListTasksBlockedBy :many
SELECT t.id, t.owner_id, t.title, t.description, t.status, t.priority, t.due_date, t.completed_at, t.created_at, t.updated_at, t.deleted_at, t.project_id, t.parent_id, t.sort_order, t.recurrence_rule FROM tasks t
JOIN task_dependencies td ON td.task_id = t.id
WHERE td.blocks_task_id = $1 AND t.deleted_at IS NULL
`
func (q *Queries) ListTasksBlockedBy(ctx context.Context, blocksTaskID pgtype.UUID) ([]Task, error) {
rows, err := q.db.Query(ctx, listTasksBlockedBy, blocksTaskID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Task{}
for rows.Next() {
var i Task
if err := rows.Scan(
&i.ID,
&i.OwnerID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.DueDate,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
&i.ProjectID,
&i.ParentID,
&i.SortOrder,
&i.RecurrenceRule,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const removeTaskDependency = `-- name: RemoveTaskDependency :exec
DELETE FROM task_dependencies
WHERE task_id = $1 AND blocks_task_id = $2
`
type RemoveTaskDependencyParams struct {
TaskID pgtype.UUID `json:"task_id"`
BlocksTaskID pgtype.UUID `json:"blocks_task_id"`
}
func (q *Queries) RemoveTaskDependency(ctx context.Context, arg RemoveTaskDependencyParams) error {
_, err := q.db.Exec(ctx, removeTaskDependency, arg.TaskID, arg.BlocksTaskID)
return err
}

View File

@@ -0,0 +1,164 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: task_reminders.sql
package repository
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createTaskReminder = `-- name: CreateTaskReminder :one
INSERT INTO task_reminders (id, task_id, type, config, scheduled_at)
VALUES ($1, $2, $3, COALESCE($4, '{}'), $5)
RETURNING id, task_id, type, config, scheduled_at, created_at
`
type CreateTaskReminderParams struct {
ID pgtype.UUID `json:"id"`
TaskID pgtype.UUID `json:"task_id"`
Type string `json:"type"`
Column4 interface{} `json:"column_4"`
ScheduledAt pgtype.Timestamptz `json:"scheduled_at"`
}
func (q *Queries) CreateTaskReminder(ctx context.Context, arg CreateTaskReminderParams) (TaskReminder, error) {
row := q.db.QueryRow(ctx, createTaskReminder,
arg.ID,
arg.TaskID,
arg.Type,
arg.Column4,
arg.ScheduledAt,
)
var i TaskReminder
err := row.Scan(
&i.ID,
&i.TaskID,
&i.Type,
&i.Config,
&i.ScheduledAt,
&i.CreatedAt,
)
return i, err
}
const deleteTaskReminder = `-- name: DeleteTaskReminder :exec
DELETE FROM task_reminders WHERE id = $1
`
func (q *Queries) DeleteTaskReminder(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteTaskReminder, id)
return err
}
const deleteTaskRemindersByTask = `-- name: DeleteTaskRemindersByTask :exec
DELETE FROM task_reminders WHERE task_id = $1
`
func (q *Queries) DeleteTaskRemindersByTask(ctx context.Context, taskID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteTaskRemindersByTask, taskID)
return err
}
const getTaskReminderByID = `-- name: GetTaskReminderByID :one
SELECT id, task_id, type, config, scheduled_at, created_at FROM task_reminders
WHERE id = $1
`
func (q *Queries) GetTaskReminderByID(ctx context.Context, id pgtype.UUID) (TaskReminder, error) {
row := q.db.QueryRow(ctx, getTaskReminderByID, id)
var i TaskReminder
err := row.Scan(
&i.ID,
&i.TaskID,
&i.Type,
&i.Config,
&i.ScheduledAt,
&i.CreatedAt,
)
return i, err
}
const listTaskReminders = `-- name: ListTaskReminders :many
SELECT id, task_id, type, config, scheduled_at, created_at FROM task_reminders
WHERE task_id = $1
ORDER BY scheduled_at ASC
`
func (q *Queries) ListTaskReminders(ctx context.Context, taskID pgtype.UUID) ([]TaskReminder, error) {
rows, err := q.db.Query(ctx, listTaskReminders, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []TaskReminder{}
for rows.Next() {
var i TaskReminder
if err := rows.Scan(
&i.ID,
&i.TaskID,
&i.Type,
&i.Config,
&i.ScheduledAt,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTaskRemindersDueBefore = `-- name: ListTaskRemindersDueBefore :many
SELECT tr.id, tr.task_id, tr.type, tr.config, tr.scheduled_at, tr.created_at, t.owner_id, t.title
FROM task_reminders tr
JOIN tasks t ON t.id = tr.task_id
WHERE tr.scheduled_at <= $1
AND t.deleted_at IS NULL
`
type ListTaskRemindersDueBeforeRow struct {
ID pgtype.UUID `json:"id"`
TaskID pgtype.UUID `json:"task_id"`
Type string `json:"type"`
Config []byte `json:"config"`
ScheduledAt pgtype.Timestamptz `json:"scheduled_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
OwnerID pgtype.UUID `json:"owner_id"`
Title string `json:"title"`
}
func (q *Queries) ListTaskRemindersDueBefore(ctx context.Context, scheduledAt pgtype.Timestamptz) ([]ListTaskRemindersDueBeforeRow, error) {
rows, err := q.db.Query(ctx, listTaskRemindersDueBefore, scheduledAt)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListTaskRemindersDueBeforeRow{}
for rows.Next() {
var i ListTaskRemindersDueBeforeRow
if err := rows.Scan(
&i.ID,
&i.TaskID,
&i.Type,
&i.Config,
&i.ScheduledAt,
&i.CreatedAt,
&i.OwnerID,
&i.Title,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@@ -0,0 +1,117 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: task_webhooks.sql
package repository
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createTaskWebhook = `-- name: CreateTaskWebhook :one
INSERT INTO task_webhooks (id, owner_id, url, events, secret)
VALUES ($1, $2, $3, COALESCE($4, '[]'), $5)
RETURNING id, owner_id, url, events, secret, created_at
`
type CreateTaskWebhookParams struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
Url string `json:"url"`
Column4 interface{} `json:"column_4"`
Secret pgtype.Text `json:"secret"`
}
func (q *Queries) CreateTaskWebhook(ctx context.Context, arg CreateTaskWebhookParams) (TaskWebhook, error) {
row := q.db.QueryRow(ctx, createTaskWebhook,
arg.ID,
arg.OwnerID,
arg.Url,
arg.Column4,
arg.Secret,
)
var i TaskWebhook
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.Url,
&i.Events,
&i.Secret,
&i.CreatedAt,
)
return i, err
}
const deleteTaskWebhook = `-- name: DeleteTaskWebhook :exec
DELETE FROM task_webhooks WHERE id = $1 AND owner_id = $2
`
type DeleteTaskWebhookParams struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
}
func (q *Queries) DeleteTaskWebhook(ctx context.Context, arg DeleteTaskWebhookParams) error {
_, err := q.db.Exec(ctx, deleteTaskWebhook, arg.ID, arg.OwnerID)
return err
}
const getTaskWebhookByID = `-- name: GetTaskWebhookByID :one
SELECT id, owner_id, url, events, secret, created_at FROM task_webhooks
WHERE id = $1 AND owner_id = $2
`
type GetTaskWebhookByIDParams struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
}
func (q *Queries) GetTaskWebhookByID(ctx context.Context, arg GetTaskWebhookByIDParams) (TaskWebhook, error) {
row := q.db.QueryRow(ctx, getTaskWebhookByID, arg.ID, arg.OwnerID)
var i TaskWebhook
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.Url,
&i.Events,
&i.Secret,
&i.CreatedAt,
)
return i, err
}
const listTaskWebhooksByOwner = `-- name: ListTaskWebhooksByOwner :many
SELECT id, owner_id, url, events, secret, created_at FROM task_webhooks
WHERE owner_id = $1
ORDER BY created_at DESC
`
func (q *Queries) ListTaskWebhooksByOwner(ctx context.Context, ownerID pgtype.UUID) ([]TaskWebhook, error) {
rows, err := q.db.Query(ctx, listTaskWebhooksByOwner, ownerID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []TaskWebhook{}
for rows.Next() {
var i TaskWebhook
if err := rows.Scan(
&i.ID,
&i.OwnerID,
&i.Url,
&i.Events,
&i.Secret,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@@ -0,0 +1,687 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: tasks.sql
package repository
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const countSubtasksByStatus = `-- name: CountSubtasksByStatus :one
SELECT
COUNT(*) FILTER (WHERE status = 'done') AS done_count,
COUNT(*) AS total_count
FROM tasks
WHERE parent_id = $1 AND deleted_at IS NULL
`
type CountSubtasksByStatusRow struct {
DoneCount int64 `json:"done_count"`
TotalCount int64 `json:"total_count"`
}
func (q *Queries) CountSubtasksByStatus(ctx context.Context, parentID pgtype.UUID) (CountSubtasksByStatusRow, error) {
row := q.db.QueryRow(ctx, countSubtasksByStatus, parentID)
var i CountSubtasksByStatusRow
err := row.Scan(&i.DoneCount, &i.TotalCount)
return i, err
}
const createTask = `-- name: CreateTask :one
INSERT INTO tasks (id, owner_id, title, description, status, priority, due_date, project_id, parent_id, sort_order, recurrence_rule)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, owner_id, title, description, status, priority, due_date, completed_at, created_at, updated_at, deleted_at, project_id, parent_id, sort_order, recurrence_rule
`
type CreateTaskParams struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
DueDate pgtype.Timestamptz `json:"due_date"`
ProjectID pgtype.UUID `json:"project_id"`
ParentID pgtype.UUID `json:"parent_id"`
SortOrder int32 `json:"sort_order"`
RecurrenceRule pgtype.Text `json:"recurrence_rule"`
}
func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error) {
row := q.db.QueryRow(ctx, createTask,
arg.ID,
arg.OwnerID,
arg.Title,
arg.Description,
arg.Status,
arg.Priority,
arg.DueDate,
arg.ProjectID,
arg.ParentID,
arg.SortOrder,
arg.RecurrenceRule,
)
var i Task
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.DueDate,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
&i.ProjectID,
&i.ParentID,
&i.SortOrder,
&i.RecurrenceRule,
)
return i, err
}
const getTaskByID = `-- name: GetTaskByID :one
SELECT id, owner_id, title, description, status, priority, due_date, completed_at, created_at, updated_at, deleted_at, project_id, parent_id, sort_order, recurrence_rule FROM tasks
WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL
`
type GetTaskByIDParams struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
}
func (q *Queries) GetTaskByID(ctx context.Context, arg GetTaskByIDParams) (Task, error) {
row := q.db.QueryRow(ctx, getTaskByID, arg.ID, arg.OwnerID)
var i Task
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.DueDate,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
&i.ProjectID,
&i.ParentID,
&i.SortOrder,
&i.RecurrenceRule,
)
return i, err
}
const getTaskByIDForUpdate = `-- name: GetTaskByIDForUpdate :one
SELECT id, owner_id, title, description, status, priority, due_date, completed_at, created_at, updated_at, deleted_at, project_id, parent_id, sort_order, recurrence_rule FROM tasks
WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL
FOR UPDATE
`
type GetTaskByIDForUpdateParams struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
}
func (q *Queries) GetTaskByIDForUpdate(ctx context.Context, arg GetTaskByIDForUpdateParams) (Task, error) {
row := q.db.QueryRow(ctx, getTaskByIDForUpdate, arg.ID, arg.OwnerID)
var i Task
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.DueDate,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
&i.ProjectID,
&i.ParentID,
&i.SortOrder,
&i.RecurrenceRule,
)
return i, err
}
const hardDeleteTask = `-- name: HardDeleteTask :exec
DELETE FROM tasks WHERE id = $1 AND owner_id = $2
`
type HardDeleteTaskParams struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
}
func (q *Queries) HardDeleteTask(ctx context.Context, arg HardDeleteTaskParams) error {
_, err := q.db.Exec(ctx, hardDeleteTask, arg.ID, arg.OwnerID)
return err
}
const listSubtasks = `-- name: ListSubtasks :many
SELECT id, owner_id, title, description, status, priority, due_date, completed_at, created_at, updated_at, deleted_at, project_id, parent_id, sort_order, recurrence_rule FROM tasks
WHERE parent_id = $1 AND owner_id = $2 AND deleted_at IS NULL
ORDER BY sort_order ASC, created_at ASC
`
type ListSubtasksParams struct {
ParentID pgtype.UUID `json:"parent_id"`
OwnerID pgtype.UUID `json:"owner_id"`
}
func (q *Queries) ListSubtasks(ctx context.Context, arg ListSubtasksParams) ([]Task, error) {
rows, err := q.db.Query(ctx, listSubtasks, arg.ParentID, arg.OwnerID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Task{}
for rows.Next() {
var i Task
if err := rows.Scan(
&i.ID,
&i.OwnerID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.DueDate,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
&i.ProjectID,
&i.ParentID,
&i.SortOrder,
&i.RecurrenceRule,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTasks = `-- name: ListTasks :many
SELECT t.id, t.owner_id, t.title, t.description, t.status, t.priority, t.due_date, t.completed_at, t.created_at, t.updated_at, t.deleted_at, t.project_id, t.parent_id, t.sort_order, t.recurrence_rule FROM tasks t
WHERE t.owner_id = $1
AND t.deleted_at IS NULL
AND t.parent_id IS NULL
AND ($2::TEXT IS NULL OR t.status = $2::TEXT)
AND ($3::TEXT IS NULL OR t.priority = $3::TEXT)
AND ($4::UUID IS NULL OR t.project_id = $4::UUID)
AND ($5::TIMESTAMPTZ IS NULL OR t.due_date >= $5::TIMESTAMPTZ)
AND ($6::TIMESTAMPTZ IS NULL OR t.due_date <= $6::TIMESTAMPTZ)
AND (
$7::TIMESTAMPTZ IS NULL
OR (t.created_at, t.id) < ($7::TIMESTAMPTZ, $8::UUID)
)
ORDER BY t.created_at DESC, t.id DESC
LIMIT $9
`
type ListTasksParams struct {
OwnerID pgtype.UUID `json:"owner_id"`
Status pgtype.Text `json:"status"`
Priority pgtype.Text `json:"priority"`
ProjectID pgtype.UUID `json:"project_id"`
DueFrom pgtype.Timestamptz `json:"due_from"`
DueTo pgtype.Timestamptz `json:"due_to"`
CursorTime pgtype.Timestamptz `json:"cursor_time"`
CursorID pgtype.UUID `json:"cursor_id"`
Lim int32 `json:"lim"`
}
func (q *Queries) ListTasks(ctx context.Context, arg ListTasksParams) ([]Task, error) {
rows, err := q.db.Query(ctx, listTasks,
arg.OwnerID,
arg.Status,
arg.Priority,
arg.ProjectID,
arg.DueFrom,
arg.DueTo,
arg.CursorTime,
arg.CursorID,
arg.Lim,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Task{}
for rows.Next() {
var i Task
if err := rows.Scan(
&i.ID,
&i.OwnerID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.DueDate,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
&i.ProjectID,
&i.ParentID,
&i.SortOrder,
&i.RecurrenceRule,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTasksByDueDate = `-- name: ListTasksByDueDate :many
SELECT t.id, t.owner_id, t.title, t.description, t.status, t.priority, t.due_date, t.completed_at, t.created_at, t.updated_at, t.deleted_at, t.project_id, t.parent_id, t.sort_order, t.recurrence_rule FROM tasks t
WHERE t.owner_id = $1
AND t.deleted_at IS NULL
AND t.parent_id IS NULL
AND ($2::TEXT IS NULL OR t.status = $2::TEXT)
AND ($3::TEXT IS NULL OR t.priority = $3::TEXT)
AND ($4::UUID IS NULL OR t.project_id = $4::UUID)
AND ($5::TIMESTAMPTZ IS NULL OR t.due_date >= $5::TIMESTAMPTZ)
AND ($6::TIMESTAMPTZ IS NULL OR t.due_date <= $6::TIMESTAMPTZ)
ORDER BY COALESCE(t.due_date, '9999-12-31'::timestamptz) ASC NULLS LAST, t.id ASC
LIMIT $7
`
type ListTasksByDueDateParams struct {
OwnerID pgtype.UUID `json:"owner_id"`
Status pgtype.Text `json:"status"`
Priority pgtype.Text `json:"priority"`
ProjectID pgtype.UUID `json:"project_id"`
DueFrom pgtype.Timestamptz `json:"due_from"`
DueTo pgtype.Timestamptz `json:"due_to"`
Lim int32 `json:"lim"`
}
func (q *Queries) ListTasksByDueDate(ctx context.Context, arg ListTasksByDueDateParams) ([]Task, error) {
rows, err := q.db.Query(ctx, listTasksByDueDate,
arg.OwnerID,
arg.Status,
arg.Priority,
arg.ProjectID,
arg.DueFrom,
arg.DueTo,
arg.Lim,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Task{}
for rows.Next() {
var i Task
if err := rows.Scan(
&i.ID,
&i.OwnerID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.DueDate,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
&i.ProjectID,
&i.ParentID,
&i.SortOrder,
&i.RecurrenceRule,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTasksByPriority = `-- name: ListTasksByPriority :many
SELECT t.id, t.owner_id, t.title, t.description, t.status, t.priority, t.due_date, t.completed_at, t.created_at, t.updated_at, t.deleted_at, t.project_id, t.parent_id, t.sort_order, t.recurrence_rule FROM tasks t
WHERE t.owner_id = $1
AND t.deleted_at IS NULL
AND t.parent_id IS NULL
AND ($2::TEXT IS NULL OR t.status = $2::TEXT)
AND ($3::TEXT IS NULL OR t.priority = $3::TEXT)
AND ($4::UUID IS NULL OR t.project_id = $4::UUID)
AND ($5::TIMESTAMPTZ IS NULL OR t.due_date >= $5::TIMESTAMPTZ)
AND ($6::TIMESTAMPTZ IS NULL OR t.due_date <= $6::TIMESTAMPTZ)
ORDER BY CASE t.priority
WHEN 'critical' THEN 1
WHEN 'high' THEN 2
WHEN 'medium' THEN 3
WHEN 'low' THEN 4
ELSE 5
END ASC, t.created_at DESC, t.id DESC
LIMIT $7
`
type ListTasksByPriorityParams struct {
OwnerID pgtype.UUID `json:"owner_id"`
Status pgtype.Text `json:"status"`
Priority pgtype.Text `json:"priority"`
ProjectID pgtype.UUID `json:"project_id"`
DueFrom pgtype.Timestamptz `json:"due_from"`
DueTo pgtype.Timestamptz `json:"due_to"`
Lim int32 `json:"lim"`
}
func (q *Queries) ListTasksByPriority(ctx context.Context, arg ListTasksByPriorityParams) ([]Task, error) {
rows, err := q.db.Query(ctx, listTasksByPriority,
arg.OwnerID,
arg.Status,
arg.Priority,
arg.ProjectID,
arg.DueFrom,
arg.DueTo,
arg.Lim,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Task{}
for rows.Next() {
var i Task
if err := rows.Scan(
&i.ID,
&i.OwnerID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.DueDate,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
&i.ProjectID,
&i.ParentID,
&i.SortOrder,
&i.RecurrenceRule,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTasksWithRecurrence = `-- name: ListTasksWithRecurrence :many
SELECT id, owner_id, title, description, status, priority, due_date, completed_at, created_at, updated_at, deleted_at, project_id, parent_id, sort_order, recurrence_rule FROM tasks
WHERE owner_id = $1 AND deleted_at IS NULL
AND recurrence_rule IS NOT NULL
AND parent_id IS NULL
`
func (q *Queries) ListTasksWithRecurrence(ctx context.Context, ownerID pgtype.UUID) ([]Task, error) {
rows, err := q.db.Query(ctx, listTasksWithRecurrence, ownerID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Task{}
for rows.Next() {
var i Task
if err := rows.Scan(
&i.ID,
&i.OwnerID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.DueDate,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
&i.ProjectID,
&i.ParentID,
&i.SortOrder,
&i.RecurrenceRule,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTasksWithTag = `-- name: ListTasksWithTag :many
SELECT DISTINCT t.id, t.owner_id, t.title, t.description, t.status, t.priority, t.due_date, t.completed_at, t.created_at, t.updated_at, t.deleted_at, t.project_id, t.parent_id, t.sort_order, t.recurrence_rule FROM tasks t
JOIN task_tags tt ON tt.task_id = t.id
WHERE t.owner_id = $1
AND t.deleted_at IS NULL
AND t.parent_id IS NULL
AND tt.tag_id = ANY($2)
AND ($3::TEXT IS NULL OR t.status = $3::TEXT)
AND ($4::UUID IS NULL OR t.project_id = $4::UUID)
ORDER BY t.created_at DESC, t.id DESC
LIMIT $5
`
type ListTasksWithTagParams struct {
OwnerID pgtype.UUID `json:"owner_id"`
TagIds pgtype.UUID `json:"tag_ids"`
Status pgtype.Text `json:"status"`
ProjectID pgtype.UUID `json:"project_id"`
Lim int32 `json:"lim"`
}
func (q *Queries) ListTasksWithTag(ctx context.Context, arg ListTasksWithTagParams) ([]Task, error) {
rows, err := q.db.Query(ctx, listTasksWithTag,
arg.OwnerID,
arg.TagIds,
arg.Status,
arg.ProjectID,
arg.Lim,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Task{}
for rows.Next() {
var i Task
if err := rows.Scan(
&i.ID,
&i.OwnerID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.DueDate,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
&i.ProjectID,
&i.ParentID,
&i.SortOrder,
&i.RecurrenceRule,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markTaskComplete = `-- name: MarkTaskComplete :one
UPDATE tasks
SET status = 'done', completed_at = now(), updated_at = now()
WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL
RETURNING id, owner_id, title, description, status, priority, due_date, completed_at, created_at, updated_at, deleted_at, project_id, parent_id, sort_order, recurrence_rule
`
type MarkTaskCompleteParams struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
}
func (q *Queries) MarkTaskComplete(ctx context.Context, arg MarkTaskCompleteParams) (Task, error) {
row := q.db.QueryRow(ctx, markTaskComplete, arg.ID, arg.OwnerID)
var i Task
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.DueDate,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
&i.ProjectID,
&i.ParentID,
&i.SortOrder,
&i.RecurrenceRule,
)
return i, err
}
const markTaskUncomplete = `-- name: MarkTaskUncomplete :one
UPDATE tasks
SET status = COALESCE($3, 'todo'), completed_at = NULL, updated_at = now()
WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL
RETURNING id, owner_id, title, description, status, priority, due_date, completed_at, created_at, updated_at, deleted_at, project_id, parent_id, sort_order, recurrence_rule
`
type MarkTaskUncompleteParams struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
NewStatus pgtype.Text `json:"new_status"`
}
func (q *Queries) MarkTaskUncomplete(ctx context.Context, arg MarkTaskUncompleteParams) (Task, error) {
row := q.db.QueryRow(ctx, markTaskUncomplete, arg.ID, arg.OwnerID, arg.NewStatus)
var i Task
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.DueDate,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
&i.ProjectID,
&i.ParentID,
&i.SortOrder,
&i.RecurrenceRule,
)
return i, err
}
const softDeleteTask = `-- name: SoftDeleteTask :exec
UPDATE tasks SET deleted_at = now(), updated_at = now()
WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL
`
type SoftDeleteTaskParams struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
}
func (q *Queries) SoftDeleteTask(ctx context.Context, arg SoftDeleteTaskParams) error {
_, err := q.db.Exec(ctx, softDeleteTask, arg.ID, arg.OwnerID)
return err
}
const updateTask = `-- name: UpdateTask :one
UPDATE tasks
SET title = COALESCE($1, title),
description = COALESCE($2, description),
status = COALESCE($3, status),
priority = COALESCE($4, priority),
due_date = $5,
project_id = $6,
sort_order = COALESCE($7, sort_order),
recurrence_rule = $8,
updated_at = now()
WHERE id = $9 AND owner_id = $10 AND deleted_at IS NULL
RETURNING id, owner_id, title, description, status, priority, due_date, completed_at, created_at, updated_at, deleted_at, project_id, parent_id, sort_order, recurrence_rule
`
type UpdateTaskParams struct {
Title pgtype.Text `json:"title"`
Description pgtype.Text `json:"description"`
Status pgtype.Text `json:"status"`
Priority pgtype.Text `json:"priority"`
DueDate pgtype.Timestamptz `json:"due_date"`
ProjectID pgtype.UUID `json:"project_id"`
SortOrder pgtype.Int4 `json:"sort_order"`
RecurrenceRule pgtype.Text `json:"recurrence_rule"`
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
}
func (q *Queries) UpdateTask(ctx context.Context, arg UpdateTaskParams) (Task, error) {
row := q.db.QueryRow(ctx, updateTask,
arg.Title,
arg.Description,
arg.Status,
arg.Priority,
arg.DueDate,
arg.ProjectID,
arg.SortOrder,
arg.RecurrenceRule,
arg.ID,
arg.OwnerID,
)
var i Task
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.DueDate,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
&i.ProjectID,
&i.ParentID,
&i.SortOrder,
&i.RecurrenceRule,
)
return i, err
}

View File

@@ -15,6 +15,8 @@ import (
const TypeReminder = "reminder:send"
const TypeSubscriptionSync = "subscription:sync"
const TypeTaskReminder = "task_reminder:send"
const TypeRecurringTask = "recurring_task:generate"
type ReminderPayload struct {
EventID uuid.UUID `json:"event_id"`
@@ -54,10 +56,49 @@ func (s *Scheduler) ScheduleReminder(_ context.Context, eventID, reminderID, use
return nil
}
type TaskReminderPayload struct {
TaskReminderID uuid.UUID `json:"task_reminder_id"`
TaskID uuid.UUID `json:"task_id"`
OwnerID uuid.UUID `json:"owner_id"`
}
type RecurringTaskPayload struct {
TaskID uuid.UUID `json:"task_id"`
OwnerID uuid.UUID `json:"owner_id"`
}
type SubscriptionSyncPayload struct {
SubscriptionID string `json:"subscription_id"`
}
func (s *Scheduler) ScheduleTaskReminder(_ context.Context, reminderID, taskID, ownerID uuid.UUID, triggerAt time.Time) error {
payload, err := json.Marshal(TaskReminderPayload{
TaskReminderID: reminderID,
TaskID: taskID,
OwnerID: ownerID,
})
if err != nil {
return fmt.Errorf("marshal task reminder payload: %w", err)
}
task := asynq.NewTask(TypeTaskReminder, payload)
_, err = s.client.Enqueue(task,
asynq.ProcessAt(triggerAt),
asynq.MaxRetry(3),
asynq.Queue("reminders"),
)
return err
}
func (s *Scheduler) EnqueueRecurringTask(ctx context.Context, taskID, ownerID uuid.UUID) error {
payload, err := json.Marshal(RecurringTaskPayload{TaskID: taskID, OwnerID: ownerID})
if err != nil {
return err
}
task := asynq.NewTask(TypeRecurringTask, payload)
_, err = s.client.Enqueue(task, asynq.MaxRetry(3), asynq.Queue("default"))
return err
}
func (s *Scheduler) EnqueueSubscriptionSync(ctx context.Context, subscriptionID string) error {
payload, err := json.Marshal(SubscriptionSyncPayload{SubscriptionID: subscriptionID})
if err != nil {
@@ -112,4 +153,8 @@ func (NoopScheduler) ScheduleReminder(_ context.Context, _, _, _ uuid.UUID, _ ti
return nil
}
func (NoopScheduler) ScheduleTaskReminder(_ context.Context, _, _, _ uuid.UUID, _ time.Time) error {
return nil
}
func (NoopScheduler) Close() error { return nil }

View File

@@ -46,6 +46,44 @@ func (w *ReminderWorker) HandleReminderTask(ctx context.Context, t *asynq.Task)
return nil
}
func (w *ReminderWorker) HandleTaskReminder(ctx context.Context, t *asynq.Task) error {
var payload TaskReminderPayload
if err := json.Unmarshal(t.Payload(), &payload); err != nil {
return fmt.Errorf("unmarshal task reminder payload: %w", err)
}
task, err := w.queries.GetTaskByID(ctx, repository.GetTaskByIDParams{
ID: utils.ToPgUUID(payload.TaskID),
OwnerID: utils.ToPgUUID(payload.OwnerID),
})
if err != nil {
return fmt.Errorf("get task: %w", err)
}
if task.DeletedAt.Valid {
return nil
}
log.Printf("task reminder triggered: task=%s title=%s", payload.TaskID, task.Title)
return nil
}
func (w *ReminderWorker) HandleRecurringTask(ctx context.Context, t *asynq.Task) error {
var payload RecurringTaskPayload
if err := json.Unmarshal(t.Payload(), &payload); err != nil {
return fmt.Errorf("unmarshal recurring task payload: %w", err)
}
task, err := w.queries.GetTaskByID(ctx, repository.GetTaskByIDParams{
ID: utils.ToPgUUID(payload.TaskID),
OwnerID: utils.ToPgUUID(payload.OwnerID),
})
if err != nil {
return nil
}
if !task.RecurrenceRule.Valid || task.RecurrenceRule.String == "" {
return nil
}
log.Printf("recurring task: task=%s (generation stub)", payload.TaskID)
return nil
}
type SubscriptionSyncWorker struct {
syncer SubscriptionSyncer
}
@@ -82,6 +120,8 @@ func StartWorker(redisAddr string, worker *ReminderWorker, subSyncWorker *Subscr
mux := asynq.NewServeMux()
mux.HandleFunc(TypeReminder, worker.HandleReminderTask)
mux.HandleFunc(TypeTaskReminder, worker.HandleTaskReminder)
mux.HandleFunc(TypeRecurringTask, worker.HandleRecurringTask)
if subSyncWorker != nil {
mux.HandleFunc(TypeSubscriptionSync, subSyncWorker.HandleSubscriptionSync)
}

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
}

View File

@@ -100,6 +100,13 @@ func NullPgUUID() pgtype.UUID {
return pgtype.UUID{Valid: false}
}
func ToPgUUIDPtr(id *uuid.UUID) pgtype.UUID {
if id == nil {
return pgtype.UUID{Valid: false}
}
return pgtype.UUID{Bytes: *id, Valid: true}
}
func NullPgTimestamptz() pgtype.Timestamptz {
return pgtype.Timestamptz{Valid: false}
}

View File

@@ -95,3 +95,37 @@ func ValidateRecurrenceRangeLimit(start, end time.Time) error {
}
return nil
}
func ValidateTaskTitle(title string) error {
if len(title) < 1 || len(title) > 500 {
return models.NewValidationError("task title must be 1-500 characters")
}
return nil
}
func ValidateTaskStatus(status string) error {
valid := map[string]bool{"todo": true, "in_progress": true, "done": true, "archived": true}
if !valid[status] {
return models.NewValidationError("invalid task status")
}
return nil
}
func ParseTime(s string) (time.Time, error) {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
t, err = time.Parse("2006-01-02T15:04:05Z07:00", s)
}
if err != nil {
t, err = time.Parse("2006-01-02", s)
}
return t, err
}
func ValidateTaskPriority(priority string) error {
valid := map[string]bool{"low": true, "medium": true, "high": true, "critical": true}
if !valid[priority] {
return models.NewValidationError("invalid task priority")
}
return nil
}