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:
@@ -11,8 +11,11 @@ JWT_SECRET=dev-secret-change-me
|
||||
SERVER_PORT=8080
|
||||
ENV=development
|
||||
|
||||
# Base URL (used for public iCal feed URLs; defaults to http://localhost:$SERVER_PORT)
|
||||
# BASE_URL=https://api.example.com
|
||||
# Base URL for iCal feed URLs and availability URLs.
|
||||
# Defaults to http://localhost:$SERVER_PORT if unset.
|
||||
# Set to your public API URL for Google Calendar and external subscriptions.
|
||||
# Example: BASE_URL=https://api.example.com
|
||||
BASE_URL=http://localhost:8080
|
||||
|
||||
# CORS (comma-separated origins; defaults to localhost:5173 for dev)
|
||||
# CORS_ORIGINS=https://app.example.com,https://www.example.com
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
log.Printf("BASE_URL=%s", cfg.BaseURL)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -86,19 +87,36 @@ func main() {
|
||||
reminderSvc := service.NewReminderService(queries, calSvc, sched)
|
||||
attendeeSvc := service.NewAttendeeService(queries, calSvc)
|
||||
|
||||
taskWebhookSvc := service.NewTaskWebhookService(queries)
|
||||
taskSvc := service.NewTaskService(queries, auditSvc, taskWebhookSvc)
|
||||
projectSvc := service.NewProjectService(queries, auditSvc)
|
||||
tagSvc := service.NewTagService(queries)
|
||||
taskDepSvc := service.NewTaskDependencyService(queries)
|
||||
var taskReminderScheduler service.TaskReminderScheduler = scheduler.NoopScheduler{}
|
||||
if realSched, ok := sched.(*scheduler.Scheduler); ok {
|
||||
taskReminderScheduler = realSched
|
||||
}
|
||||
taskReminderSvc := service.NewTaskReminderService(queries, taskReminderScheduler)
|
||||
|
||||
h := api.Handlers{
|
||||
Auth: handlers.NewAuthHandler(authSvc, userSvc),
|
||||
User: handlers.NewUserHandler(userSvc),
|
||||
Calendar: handlers.NewCalendarHandler(calSvc),
|
||||
Sharing: handlers.NewSharingHandler(calSvc),
|
||||
Event: handlers.NewEventHandler(eventSvc),
|
||||
Reminder: handlers.NewReminderHandler(reminderSvc),
|
||||
Attendee: handlers.NewAttendeeHandler(attendeeSvc),
|
||||
Contact: handlers.NewContactHandler(contactSvc),
|
||||
Availability: handlers.NewAvailabilityHandler(availSvc),
|
||||
Booking: handlers.NewBookingHandler(bookingSvc),
|
||||
APIKey: handlers.NewAPIKeyHandler(apiKeySvc),
|
||||
ICS: handlers.NewICSHandler(calSvc, eventSvc, queries),
|
||||
Auth: handlers.NewAuthHandler(authSvc, userSvc),
|
||||
User: handlers.NewUserHandler(userSvc),
|
||||
Calendar: handlers.NewCalendarHandler(calSvc),
|
||||
Sharing: handlers.NewSharingHandler(calSvc),
|
||||
Event: handlers.NewEventHandler(eventSvc),
|
||||
Reminder: handlers.NewReminderHandler(reminderSvc),
|
||||
Attendee: handlers.NewAttendeeHandler(attendeeSvc),
|
||||
Contact: handlers.NewContactHandler(contactSvc),
|
||||
Availability: handlers.NewAvailabilityHandler(availSvc),
|
||||
Booking: handlers.NewBookingHandler(bookingSvc),
|
||||
APIKey: handlers.NewAPIKeyHandler(apiKeySvc),
|
||||
ICS: handlers.NewICSHandler(calSvc, eventSvc, queries),
|
||||
Task: handlers.NewTaskHandler(taskSvc),
|
||||
Project: handlers.NewProjectHandler(projectSvc),
|
||||
Tag: handlers.NewTagHandler(tagSvc),
|
||||
TaskDependency: handlers.NewTaskDependencyHandler(taskDepSvc),
|
||||
TaskReminder: handlers.NewTaskReminderHandler(taskReminderSvc),
|
||||
TaskWebhook: handlers.NewTaskWebhookHandler(taskWebhookSvc),
|
||||
}
|
||||
|
||||
authMW := middleware.NewAuthMiddleware(jwtManager, queries)
|
||||
|
||||
1
frontend/dist/assets/index-C8iK5eUL.css
vendored
Normal file
1
frontend/dist/assets/index-C8iK5eUL.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/index-D1jwjSlk.css
vendored
1
frontend/dist/assets/index-D1jwjSlk.css
vendored
File diff suppressed because one or more lines are too long
43
frontend/dist/assets/index-DLhHVFKH.js
vendored
43
frontend/dist/assets/index-DLhHVFKH.js
vendored
File diff suppressed because one or more lines are too long
43
frontend/dist/assets/index-DXAGcZdG.js
vendored
Normal file
43
frontend/dist/assets/index-DXAGcZdG.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
frontend/dist/index.html
vendored
4
frontend/dist/index.html
vendored
@@ -5,10 +5,10 @@
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📅</text></svg>" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Calendar</title>
|
||||
<script type="module" crossorigin src="/assets/index-DLhHVFKH.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DXAGcZdG.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-BI0H_s0W.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/date-huy51PAD.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D1jwjSlk.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C8iK5eUL.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import Login from "./pages/Login";
|
||||
import Register from "./pages/Register";
|
||||
import CalendarPage from "./pages/CalendarPage";
|
||||
import ContactsPage from "./pages/ContactsPage";
|
||||
import TodoPage from "./pages/TodoPage";
|
||||
import SettingsPage from "./pages/SettingsPage";
|
||||
|
||||
function ProtectedRoute() {
|
||||
@@ -53,6 +54,21 @@ function AppLayout() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/todo"
|
||||
className={({ isActive }) =>
|
||||
`p-2.5 rounded-xl transition-colors ${
|
||||
isActive
|
||||
? "bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400"
|
||||
: "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
}`
|
||||
}
|
||||
title="To-do"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/contacts"
|
||||
className={({ isActive }) =>
|
||||
@@ -104,6 +120,7 @@ export default function App() {
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/" element={<CalendarPage />} />
|
||||
<Route path="/todo" element={<TodoPage />} />
|
||||
<Route path="/contacts" element={<ContactsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -9,6 +9,9 @@ import type {
|
||||
Contact,
|
||||
ListResponse,
|
||||
PageInfo,
|
||||
Project,
|
||||
Tag,
|
||||
Task,
|
||||
User,
|
||||
} from "./types";
|
||||
|
||||
@@ -295,4 +298,93 @@ export const api = {
|
||||
|
||||
deleteContact: (id: string) =>
|
||||
request<{ ok: boolean }>(`/contacts/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Tasks
|
||||
listTasks: (params?: {
|
||||
status?: string;
|
||||
priority?: string;
|
||||
project_id?: string;
|
||||
due_from?: string;
|
||||
due_to?: string;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}) => {
|
||||
const sp = new URLSearchParams({ limit: "100" });
|
||||
if (params?.status) sp.set("status", params.status);
|
||||
if (params?.priority) sp.set("priority", params.priority);
|
||||
if (params?.project_id) sp.set("project_id", params.project_id);
|
||||
if (params?.due_from) sp.set("due_from", params.due_from);
|
||||
if (params?.due_to) sp.set("due_to", params.due_to);
|
||||
if (params?.limit) sp.set("limit", String(params.limit));
|
||||
if (params?.cursor) sp.set("cursor", params.cursor);
|
||||
return request<ListResponse<Task>>(`/tasks?${sp}`);
|
||||
},
|
||||
|
||||
createTask: (data: {
|
||||
title: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
due_date?: string;
|
||||
project_id?: string;
|
||||
parent_id?: string;
|
||||
}) =>
|
||||
request<{ task: Task }>("/tasks", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
getTask: (id: string) => request<{ task: Task }>(`/tasks/${id}`),
|
||||
|
||||
updateTask: (id: string, data: Partial<Task>) =>
|
||||
request<{ task: Task }>(`/tasks/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
deleteTask: (id: string, permanent?: boolean) => {
|
||||
const q = permanent ? "?permanent=true" : "";
|
||||
return request<{ ok: boolean }>(`/tasks/${id}${q}`, { method: "DELETE" });
|
||||
},
|
||||
|
||||
markTaskComplete: (id: string) =>
|
||||
request<{ task: Task }>(`/tasks/${id}/complete`, { method: "POST" }),
|
||||
|
||||
markTaskUncomplete: (id: string, status?: string) =>
|
||||
request<{ task: Task }>(`/tasks/${id}/uncomplete`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(status ? { status } : {}),
|
||||
}),
|
||||
|
||||
listTaskSubtasks: (id: string) =>
|
||||
request<ListResponse<Task>>(`/tasks/${id}/subtasks`),
|
||||
|
||||
// Projects
|
||||
listProjects: () => request<ListResponse<Project>>("/projects"),
|
||||
|
||||
createProject: (data: { name: string; color?: string }) =>
|
||||
request<{ project: Project }>("/projects", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
getProject: (id: string) => request<{ project: Project }>(`/projects/${id}`),
|
||||
|
||||
updateProject: (id: string, data: Partial<Project>) =>
|
||||
request<{ project: Project }>(`/projects/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
deleteProject: (id: string) =>
|
||||
request<{ ok: boolean }>(`/projects/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Tags
|
||||
listTags: () => request<ListResponse<Tag>>("/tags"),
|
||||
|
||||
createTag: (data: { name: string; color?: string }) =>
|
||||
request<{ tag: Tag }>("/tags", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
};
|
||||
|
||||
100
frontend/src/components/KanbanBoard.tsx
Normal file
100
frontend/src/components/KanbanBoard.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useState } from "react";
|
||||
import type { Task } from "../types";
|
||||
import TaskCard from "./TaskCard";
|
||||
|
||||
const KANBAN_COLUMNS = [
|
||||
{ id: "todo", title: "To Do", status: "todo" as const },
|
||||
{ id: "in_progress", title: "In Progress", status: "in_progress" as const },
|
||||
{ id: "done", title: "Done", status: "done" as const },
|
||||
];
|
||||
|
||||
const ARCHIVED_COLUMN = { id: "archived", title: "Archived", status: "archived" as const };
|
||||
|
||||
interface KanbanBoardProps {
|
||||
tasks: Task[];
|
||||
onTaskClick: (task: Task) => void;
|
||||
onStatusChange: (taskId: string, newStatus: string) => void;
|
||||
showArchived?: boolean;
|
||||
}
|
||||
|
||||
export default function KanbanBoard({
|
||||
tasks,
|
||||
onTaskClick,
|
||||
onStatusChange,
|
||||
showArchived = false,
|
||||
}: KanbanBoardProps) {
|
||||
const [draggedTask, setDraggedTask] = useState<Task | null>(null);
|
||||
const [dragOverColumn, setDragOverColumn] = useState<string | null>(null);
|
||||
|
||||
const columns = showArchived ? [ARCHIVED_COLUMN] : KANBAN_COLUMNS;
|
||||
const tasksByStatus = columns.reduce(
|
||||
(acc, col) => {
|
||||
acc[col.status] = tasks.filter((t) => t.status === col.status);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Task[]>
|
||||
);
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, task: Task) => {
|
||||
setDraggedTask(task);
|
||||
e.dataTransfer.setData("text/plain", task.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, status: string) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragOverColumn(status);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDragOverColumn(null);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, status: string) => {
|
||||
e.preventDefault();
|
||||
setDragOverColumn(null);
|
||||
const taskId = e.dataTransfer.getData("text/plain");
|
||||
if (taskId && draggedTask && draggedTask.status !== status) {
|
||||
onStatusChange(taskId, status);
|
||||
}
|
||||
setDraggedTask(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 overflow-x-auto pb-4 flex-1 min-h-0">
|
||||
{columns.map((col) => (
|
||||
<div
|
||||
key={col.id}
|
||||
onDragOver={(e) => handleDragOver(e, col.status)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, col.status)}
|
||||
className={`flex-shrink-0 w-72 flex flex-col rounded-lg border-2 transition-colors ${
|
||||
dragOverColumn === col.status
|
||||
? "border-blue-400 dark:border-blue-500 bg-blue-50/50 dark:bg-blue-900/20"
|
||||
: "border-gray-200 dark:border-gray-600"
|
||||
}`}
|
||||
>
|
||||
<div className="p-3 border-b border-gray-200 dark:border-gray-600">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{col.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{tasksByStatus[col.status]?.length ?? 0} tasks
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 p-3 overflow-y-auto space-y-2 min-h-[200px]">
|
||||
{(tasksByStatus[col.status] ?? []).map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onDragStart={handleDragStart}
|
||||
onClick={() => onTaskClick(task)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
frontend/src/components/ProjectSidebar.tsx
Normal file
67
frontend/src/components/ProjectSidebar.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { Project } from "../types";
|
||||
|
||||
interface ProjectSidebarProps {
|
||||
projects: Project[];
|
||||
selectedProjectId: string | null;
|
||||
onSelectProject: (id: string | null) => void;
|
||||
showArchived: boolean;
|
||||
onToggleArchived: () => void;
|
||||
}
|
||||
|
||||
export default function ProjectSidebar({
|
||||
projects,
|
||||
selectedProjectId,
|
||||
onSelectProject,
|
||||
showArchived,
|
||||
onToggleArchived,
|
||||
}: ProjectSidebarProps) {
|
||||
return (
|
||||
<div className="w-56 flex-shrink-0 border-r border-gray-200 dark:border-gray-700 flex flex-col">
|
||||
<div className="p-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 text-sm">
|
||||
Projects
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
<button
|
||||
onClick={() => onSelectProject(null)}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
selectedProjectId === null
|
||||
? "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300"
|
||||
: "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
All Tasks
|
||||
</button>
|
||||
{projects.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => onSelectProject(p.id)}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors flex items-center gap-2 ${
|
||||
selectedProjectId === p.id
|
||||
? "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300"
|
||||
: "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: p.color }}
|
||||
/>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<label className="flex items-center gap-2 px-3 py-2 cursor-pointer text-sm text-gray-600 dark:text-gray-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showArchived}
|
||||
onChange={onToggleArchived}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Show archived
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
frontend/src/components/TaskCard.tsx
Normal file
69
frontend/src/components/TaskCard.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { Task } from "../types";
|
||||
import { format } from "date-fns";
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
low: "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400",
|
||||
medium: "bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300",
|
||||
high: "bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300",
|
||||
critical: "bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300",
|
||||
};
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task;
|
||||
onDragStart?: (e: React.DragEvent, task: Task) => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function TaskCard({
|
||||
task,
|
||||
onDragStart,
|
||||
onClick,
|
||||
}: TaskCardProps) {
|
||||
const priorityClass = priorityColors[task.priority] || priorityColors.medium;
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable={!!onDragStart}
|
||||
onDragStart={(e) => onDragStart?.(e, task)}
|
||||
onClick={onClick}
|
||||
className="group cursor-pointer rounded-lg border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 p-3 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="mt-0.5 cursor-grab active:cursor-grabbing text-gray-400 dark:text-gray-500 group-hover:text-gray-600 dark:group-hover:text-gray-400">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M7 2a2 2 0 012 2v12a2 2 0 01-2 2h6a2 2 0 01-2-2V4a2 2 0 012-2h6z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{task.title}
|
||||
</p>
|
||||
{task.due_date && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Due {format(new Date(task.due_date), "MMM d, yyyy")}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded ${priorityClass}`}
|
||||
>
|
||||
{task.priority}
|
||||
</span>
|
||||
{task.tags?.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="text-xs px-2 py-0.5 rounded"
|
||||
style={{
|
||||
backgroundColor: `${tag.color}20`,
|
||||
color: tag.color,
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
271
frontend/src/components/TaskModal.tsx
Normal file
271
frontend/src/components/TaskModal.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { format } from "date-fns";
|
||||
import type { Task, Project, Tag } from "../types";
|
||||
import { api } from "../api";
|
||||
import Modal from "./Modal";
|
||||
|
||||
interface TaskModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
task?: Task | null;
|
||||
projects: Project[];
|
||||
tags: Tag[];
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = ["todo", "in_progress", "done"] as const;
|
||||
const PRIORITY_OPTIONS = ["low", "medium", "high", "critical"] as const;
|
||||
|
||||
export default function TaskModal({
|
||||
open,
|
||||
onClose,
|
||||
onSaved,
|
||||
task,
|
||||
projects,
|
||||
tags,
|
||||
}: TaskModalProps) {
|
||||
const isEdit = !!task;
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [status, setStatus] = useState<Task["status"]>("todo");
|
||||
const [priority, setPriority] = useState<Task["priority"]>("medium");
|
||||
const [dueDate, setDueDate] = useState("");
|
||||
const [projectId, setProjectId] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setError("");
|
||||
setShowDelete(false);
|
||||
if (task) {
|
||||
setTitle(task.title);
|
||||
setDescription(task.description || "");
|
||||
setStatus(task.status);
|
||||
setPriority(task.priority);
|
||||
setDueDate(task.due_date ? format(new Date(task.due_date), "yyyy-MM-dd") : "");
|
||||
setProjectId(task.project_id || "");
|
||||
} else {
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setStatus("todo");
|
||||
setPriority("medium");
|
||||
setDueDate("");
|
||||
setProjectId("");
|
||||
}
|
||||
}, [open, task]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
if (isEdit) {
|
||||
await api.updateTask(task!.id, {
|
||||
title: title.trim(),
|
||||
description: description || undefined,
|
||||
status,
|
||||
priority,
|
||||
due_date: dueDate || undefined,
|
||||
project_id: projectId || undefined,
|
||||
});
|
||||
} else {
|
||||
await api.createTask({
|
||||
title: title.trim(),
|
||||
description: description || undefined,
|
||||
status,
|
||||
priority,
|
||||
due_date: dueDate || undefined,
|
||||
project_id: projectId || undefined,
|
||||
});
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
setError((err as { error?: string })?.error || "Failed to save task");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!task) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.deleteTask(task.id);
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {
|
||||
setError("Failed to delete task");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={isEdit ? "Edit Task" : "New Task"}
|
||||
wide
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Task title"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Markdown supported"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as Task["status"])}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s.replace("_", " ")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Priority
|
||||
</label>
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value as Task["priority"])}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{PRIORITY_OPTIONS.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Due Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Project
|
||||
</label>
|
||||
<select
|
||||
value={projectId}
|
||||
onChange={(e) => setProjectId(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">None</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<div>
|
||||
{isEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDelete(true)}
|
||||
className="text-red-600 dark:text-red-400 hover:underline text-sm"
|
||||
>
|
||||
Delete task
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !title.trim()}
|
||||
className="px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : isEdit ? "Save" : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showDelete && (
|
||||
<div className="mt-4 p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||
<p className="text-red-700 dark:text-red-300 text-sm mb-2">
|
||||
Delete this task? This cannot be undone.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDelete(false)}
|
||||
className="px-3 py-1 rounded bg-gray-200 dark:bg-gray-600 text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={saving}
|
||||
className="px-3 py-1 rounded bg-red-600 text-white text-sm hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
137
frontend/src/pages/TodoPage.tsx
Normal file
137
frontend/src/pages/TodoPage.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { Task, Project, Tag } from "../types";
|
||||
import { api } from "../api";
|
||||
import KanbanBoard from "../components/KanbanBoard";
|
||||
import TaskModal from "../components/TaskModal";
|
||||
import ProjectSidebar from "../components/ProjectSidebar";
|
||||
|
||||
export default function TodoPage() {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [taskModalOpen, setTaskModalOpen] = useState(false);
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
|
||||
const loadTasks = useCallback(async () => {
|
||||
try {
|
||||
const params: Parameters<typeof api.listTasks>[0] = {};
|
||||
if (selectedProjectId) params.project_id = selectedProjectId;
|
||||
const data = await api.listTasks(params);
|
||||
setTasks((data.items || []) as Task[]);
|
||||
} catch {
|
||||
setTasks([]);
|
||||
}
|
||||
}, [selectedProjectId]);
|
||||
|
||||
const loadProjects = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.listProjects();
|
||||
setProjects(data.items || []);
|
||||
} catch {
|
||||
setProjects([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadTags = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.listTags();
|
||||
setTags(data.items || []);
|
||||
} catch {
|
||||
setTags([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
Promise.all([loadTasks(), loadProjects(), loadTags()]).finally(() =>
|
||||
setLoading(false)
|
||||
);
|
||||
}, [loadTasks, loadProjects, loadTags]);
|
||||
|
||||
const handleTaskClick = (task: Task) => {
|
||||
setSelectedTask(task);
|
||||
setTaskModalOpen(true);
|
||||
};
|
||||
|
||||
const handleStatusChange = async (taskId: string, newStatus: string) => {
|
||||
try {
|
||||
if (newStatus === "done") {
|
||||
await api.markTaskComplete(taskId);
|
||||
} else {
|
||||
await api.markTaskUncomplete(taskId, newStatus);
|
||||
}
|
||||
loadTasks();
|
||||
} catch {
|
||||
setTasks((prev) => prev);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaved = () => {
|
||||
loadTasks();
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setTaskModalOpen(false);
|
||||
setSelectedTask(null);
|
||||
};
|
||||
|
||||
const displayTasks = showArchived
|
||||
? tasks.filter((t) => t.status === "archived")
|
||||
: tasks.filter((t) => t.status !== "archived");
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-gray-50 dark:bg-gray-900">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
To-do List
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedTask(null);
|
||||
setTaskModalOpen(true);
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
New Task
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<ProjectSidebar
|
||||
projects={projects}
|
||||
selectedProjectId={selectedProjectId}
|
||||
onSelectProject={setSelectedProjectId}
|
||||
showArchived={showArchived}
|
||||
onToggleArchived={() => setShowArchived((s) => !s)}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-hidden p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<KanbanBoard
|
||||
tasks={displayTasks}
|
||||
onTaskClick={handleTaskClick}
|
||||
onStatusChange={handleStatusChange}
|
||||
showArchived={showArchived}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskModal
|
||||
open={taskModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
onSaved={handleSaved}
|
||||
task={selectedTask}
|
||||
projects={projects}
|
||||
tags={tags}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -137,6 +137,48 @@ export type APIKeyScopes = {
|
||||
calendars?: ("read" | "write")[];
|
||||
events?: ("read" | "write")[];
|
||||
contacts?: ("read" | "write")[];
|
||||
tasks?: ("read" | "write")[];
|
||||
availability?: ("read")[];
|
||||
booking?: ("write")[];
|
||||
};
|
||||
|
||||
export type TaskStatus = "todo" | "in_progress" | "done" | "archived";
|
||||
export type TaskPriority = "low" | "medium" | "high" | "critical";
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: TaskStatus;
|
||||
priority: TaskPriority;
|
||||
due_date?: string;
|
||||
completed_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
owner_id: string;
|
||||
project_id?: string;
|
||||
parent_id?: string;
|
||||
subtasks?: Task[];
|
||||
tags?: Tag[];
|
||||
completion_percentage?: number;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
owner_id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
is_shared: boolean;
|
||||
deadline?: string;
|
||||
sort_order?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
owner_id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/api.ts","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/AddCalendarUrlModal.tsx","./src/components/CalendarModal.tsx","./src/components/DayView.tsx","./src/components/ErrorBoundary.tsx","./src/components/EventModal.tsx","./src/components/MiniCalendar.tsx","./src/components/Modal.tsx","./src/components/MonthView.tsx","./src/components/WeekView.tsx","./src/context/AuthContext.tsx","./src/context/ThemeContext.tsx","./src/pages/CalendarPage.tsx","./src/pages/ContactsPage.tsx","./src/pages/Login.tsx","./src/pages/Register.tsx","./src/pages/SettingsPage.tsx"],"version":"5.7.3"}
|
||||
{"root":["./src/App.tsx","./src/api.ts","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/AddCalendarUrlModal.tsx","./src/components/CalendarModal.tsx","./src/components/DayView.tsx","./src/components/ErrorBoundary.tsx","./src/components/EventModal.tsx","./src/components/KanbanBoard.tsx","./src/components/MiniCalendar.tsx","./src/components/Modal.tsx","./src/components/MonthView.tsx","./src/components/ProjectSidebar.tsx","./src/components/TaskCard.tsx","./src/components/TaskModal.tsx","./src/components/WeekView.tsx","./src/context/AuthContext.tsx","./src/context/ThemeContext.tsx","./src/pages/CalendarPage.tsx","./src/pages/ContactsPage.tsx","./src/pages/Login.tsx","./src/pages/Register.tsx","./src/pages/SettingsPage.tsx","./src/pages/TodoPage.tsx"],"version":"5.7.3"}
|
||||
185
internal/api/handlers/project.go
Normal file
185
internal/api/handlers/project.go
Normal 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})
|
||||
}
|
||||
159
internal/api/handlers/tag.go
Normal file
159
internal/api/handlers/tag.go
Normal 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)
|
||||
}
|
||||
263
internal/api/handlers/task.go
Normal file
263
internal/api/handlers/task.go
Normal 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)})
|
||||
}
|
||||
88
internal/api/handlers/task_dependency.go
Normal file
88
internal/api/handlers/task_dependency.go
Normal 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)
|
||||
}
|
||||
94
internal/api/handlers/task_reminder.go
Normal file
94
internal/api/handlers/task_reminder.go
Normal 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)
|
||||
}
|
||||
73
internal/api/handlers/task_webhook.go
Normal file
73
internal/api/handlers/task_webhook.go
Normal 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)
|
||||
}
|
||||
@@ -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": [] },
|
||||
|
||||
229
internal/api/openapi/specs/projects.json
Normal file
229
internal/api/openapi/specs/projects.json
Normal 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" } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
198
internal/api/openapi/specs/tags.json
Normal file
198
internal/api/openapi/specs/tags.json
Normal 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" } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
459
internal/api/openapi/specs/tasks.json
Normal file
459
internal/api/openapi/specs/tasks.json
Normal 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" } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
96
internal/api/openapi/specs/webhooks.json
Normal file
96
internal/api/openapi/specs/webhooks.json
Normal 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" } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
309
internal/repository/projects.sql.go
Normal file
309
internal/repository/projects.sql.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
232
internal/repository/tags.sql.go
Normal file
232
internal/repository/tags.sql.go
Normal 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
|
||||
}
|
||||
147
internal/repository/task_dependencies.sql.go
Normal file
147
internal/repository/task_dependencies.sql.go
Normal 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
|
||||
}
|
||||
164
internal/repository/task_reminders.sql.go
Normal file
164
internal/repository/task_reminders.sql.go
Normal 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
|
||||
}
|
||||
117
internal/repository/task_webhooks.sql.go
Normal file
117
internal/repository/task_webhooks.sql.go
Normal 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
|
||||
}
|
||||
687
internal/repository/tasks.sql.go
Normal file
687
internal/repository/tasks.sql.go
Normal 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
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
274
internal/service/project.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/repository"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type ProjectService struct {
|
||||
queries *repository.Queries
|
||||
audit *AuditService
|
||||
}
|
||||
|
||||
func NewProjectService(queries *repository.Queries, audit *AuditService) *ProjectService {
|
||||
return &ProjectService{queries: queries, audit: audit}
|
||||
}
|
||||
|
||||
type CreateProjectRequest struct {
|
||||
Name string
|
||||
Color *string
|
||||
IsShared *bool
|
||||
Deadline *time.Time
|
||||
SortOrder *int
|
||||
}
|
||||
|
||||
type UpdateProjectRequest struct {
|
||||
Name *string
|
||||
Color *string
|
||||
IsShared *bool
|
||||
Deadline *time.Time
|
||||
SortOrder *int
|
||||
}
|
||||
|
||||
func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, req CreateProjectRequest) (*models.Project, error) {
|
||||
if len(req.Name) < 1 || len(req.Name) > 100 {
|
||||
return nil, models.NewValidationError("project name must be 1-100 characters")
|
||||
}
|
||||
color := "#3B82F6"
|
||||
if req.Color != nil {
|
||||
if err := utils.ValidateColor(*req.Color); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
color = *req.Color
|
||||
}
|
||||
isShared := false
|
||||
if req.IsShared != nil {
|
||||
isShared = *req.IsShared
|
||||
}
|
||||
sortOrder := int32(0)
|
||||
if req.SortOrder != nil {
|
||||
sortOrder = int32(*req.SortOrder)
|
||||
}
|
||||
|
||||
id := uuid.New()
|
||||
p, err := s.queries.CreateProject(ctx, repository.CreateProjectParams{
|
||||
ID: utils.ToPgUUID(id),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
Name: req.Name,
|
||||
Color: color,
|
||||
Column5: isShared,
|
||||
Deadline: utils.ToPgTimestamptzPtr(req.Deadline),
|
||||
Column7: sortOrder,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
s.audit.Log(ctx, "project", id, "CREATE_PROJECT", userID)
|
||||
return projectFromDB(p), nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) Get(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) (*models.Project, error) {
|
||||
p, err := s.queries.GetProjectByID(ctx, repository.GetProjectByIDParams{
|
||||
ID: utils.ToPgUUID(projectID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
return projectFromDB(p), nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) List(ctx context.Context, userID uuid.UUID) ([]models.Project, error) {
|
||||
rows, err := s.queries.ListProjectsForUser(ctx, utils.ToPgUUID(userID))
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
projects := make([]models.Project, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
projects = append(projects, *projectFromDB(r))
|
||||
}
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) Update(ctx context.Context, userID uuid.UUID, projectID uuid.UUID, req UpdateProjectRequest) (*models.Project, error) {
|
||||
if req.Name != nil && (len(*req.Name) < 1 || len(*req.Name) > 100) {
|
||||
return nil, models.NewValidationError("project name must be 1-100 characters")
|
||||
}
|
||||
if req.Color != nil {
|
||||
if err := utils.ValidateColor(*req.Color); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
updateParams := repository.UpdateProjectParams{
|
||||
ID: utils.ToPgUUID(projectID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
}
|
||||
if req.Name != nil {
|
||||
updateParams.Name = utils.ToPgTextPtr(req.Name)
|
||||
}
|
||||
if req.Color != nil {
|
||||
updateParams.Color = utils.ToPgTextPtr(req.Color)
|
||||
}
|
||||
if req.IsShared != nil {
|
||||
updateParams.IsShared = utils.ToPgBoolPtr(req.IsShared)
|
||||
}
|
||||
if req.Deadline != nil {
|
||||
updateParams.Deadline = utils.ToPgTimestamptzPtr(req.Deadline)
|
||||
}
|
||||
if req.SortOrder != nil {
|
||||
updateParams.SortOrder = utils.ToPgInt4Ptr(req.SortOrder)
|
||||
}
|
||||
|
||||
p, err := s.queries.UpdateProject(ctx, updateParams)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
s.audit.Log(ctx, "project", projectID, "UPDATE_PROJECT", userID)
|
||||
return projectFromDB(p), nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) Delete(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) error {
|
||||
err := s.queries.SoftDeleteProject(ctx, repository.SoftDeleteProjectParams{
|
||||
ID: utils.ToPgUUID(projectID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
return models.ErrInternal
|
||||
}
|
||||
s.audit.Log(ctx, "project", projectID, "DELETE_PROJECT", userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) Share(ctx context.Context, userID uuid.UUID, projectID uuid.UUID, targetEmail, role string) error {
|
||||
_, err := s.queries.GetProjectByID(ctx, repository.GetProjectByIDParams{
|
||||
ID: utils.ToPgUUID(projectID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return models.ErrNotFound
|
||||
}
|
||||
return models.ErrInternal
|
||||
}
|
||||
if role != "editor" && role != "viewer" {
|
||||
return models.NewValidationError("role must be editor or viewer")
|
||||
}
|
||||
|
||||
targetUser, err := s.queries.GetUserByEmail(ctx, utils.NormalizeEmail(targetEmail))
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return models.NewNotFoundError("user not found")
|
||||
}
|
||||
return models.ErrInternal
|
||||
}
|
||||
|
||||
targetID := utils.FromPgUUID(targetUser.ID)
|
||||
if targetID == userID {
|
||||
return models.NewValidationError("cannot share with yourself")
|
||||
}
|
||||
|
||||
return s.queries.AddProjectMember(ctx, repository.AddProjectMemberParams{
|
||||
ProjectID: utils.ToPgUUID(projectID),
|
||||
UserID: utils.ToPgUUID(targetID),
|
||||
Role: role,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ProjectService) AddMember(ctx context.Context, userID uuid.UUID, projectID uuid.UUID, targetUserID uuid.UUID, role string) error {
|
||||
if role != "owner" && role != "editor" && role != "viewer" {
|
||||
return models.NewValidationError("invalid role")
|
||||
}
|
||||
_, err := s.queries.GetProjectByID(ctx, repository.GetProjectByIDParams{
|
||||
ID: utils.ToPgUUID(projectID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return models.ErrNotFound
|
||||
}
|
||||
return models.ErrInternal
|
||||
}
|
||||
return s.queries.AddProjectMember(ctx, repository.AddProjectMemberParams{
|
||||
ProjectID: utils.ToPgUUID(projectID),
|
||||
UserID: utils.ToPgUUID(targetUserID),
|
||||
Role: role,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ProjectService) RemoveMember(ctx context.Context, userID uuid.UUID, projectID uuid.UUID, targetUserID uuid.UUID) error {
|
||||
_, err := s.queries.GetProjectByID(ctx, repository.GetProjectByIDParams{
|
||||
ID: utils.ToPgUUID(projectID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return models.ErrNotFound
|
||||
}
|
||||
return models.ErrInternal
|
||||
}
|
||||
return s.queries.RemoveProjectMember(ctx, repository.RemoveProjectMemberParams{
|
||||
ProjectID: utils.ToPgUUID(projectID),
|
||||
UserID: utils.ToPgUUID(targetUserID),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ProjectService) ListMembers(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) ([]models.ProjectMember, error) {
|
||||
_, err := s.queries.GetProjectByID(ctx, repository.GetProjectByIDParams{
|
||||
ID: utils.ToPgUUID(projectID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
rows, err := s.queries.ListProjectMembers(ctx, utils.ToPgUUID(projectID))
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
members := make([]models.ProjectMember, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
members = append(members, models.ProjectMember{
|
||||
UserID: utils.FromPgUUID(r.UserID),
|
||||
Email: r.Email,
|
||||
Role: r.Role,
|
||||
})
|
||||
}
|
||||
return members, nil
|
||||
}
|
||||
|
||||
func projectFromDB(p repository.Project) *models.Project {
|
||||
proj := &models.Project{
|
||||
ID: utils.FromPgUUID(p.ID),
|
||||
OwnerID: utils.FromPgUUID(p.OwnerID),
|
||||
Name: p.Name,
|
||||
Color: p.Color,
|
||||
IsShared: p.IsShared,
|
||||
SortOrder: int(p.SortOrder),
|
||||
CreatedAt: utils.FromPgTimestamptz(p.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(p.UpdatedAt),
|
||||
}
|
||||
if p.Deadline.Valid {
|
||||
proj.Deadline = &p.Deadline.Time
|
||||
}
|
||||
return proj
|
||||
}
|
||||
159
internal/service/tag.go
Normal file
159
internal/service/tag.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/repository"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type TagService struct {
|
||||
queries *repository.Queries
|
||||
}
|
||||
|
||||
func NewTagService(queries *repository.Queries) *TagService {
|
||||
return &TagService{queries: queries}
|
||||
}
|
||||
|
||||
type CreateTagRequest struct {
|
||||
Name string
|
||||
Color *string
|
||||
}
|
||||
|
||||
type UpdateTagRequest struct {
|
||||
Name *string
|
||||
Color *string
|
||||
}
|
||||
|
||||
func (s *TagService) Create(ctx context.Context, userID uuid.UUID, req CreateTagRequest) (*models.Tag, error) {
|
||||
if len(req.Name) < 1 || len(req.Name) > 50 {
|
||||
return nil, models.NewValidationError("tag name must be 1-50 characters")
|
||||
}
|
||||
color := "#6B7280"
|
||||
if req.Color != nil {
|
||||
if err := utils.ValidateColor(*req.Color); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
color = *req.Color
|
||||
}
|
||||
|
||||
id := uuid.New()
|
||||
t, err := s.queries.CreateTag(ctx, repository.CreateTagParams{
|
||||
ID: utils.ToPgUUID(id),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
Name: req.Name,
|
||||
Column4: color,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
tag := tagFromDB(t)
|
||||
return &tag, nil
|
||||
}
|
||||
|
||||
func (s *TagService) Get(ctx context.Context, userID uuid.UUID, tagID uuid.UUID) (*models.Tag, error) {
|
||||
t, err := s.queries.GetTagByID(ctx, repository.GetTagByIDParams{
|
||||
ID: utils.ToPgUUID(tagID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
tag := tagFromDB(t)
|
||||
return &tag, nil
|
||||
}
|
||||
|
||||
func (s *TagService) List(ctx context.Context, userID uuid.UUID) ([]models.Tag, error) {
|
||||
rows, err := s.queries.ListTagsByOwner(ctx, utils.ToPgUUID(userID))
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
tags := make([]models.Tag, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
tags = append(tags, tagFromDB(r))
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (s *TagService) Update(ctx context.Context, userID uuid.UUID, tagID uuid.UUID, req UpdateTagRequest) (*models.Tag, error) {
|
||||
if req.Name != nil && (len(*req.Name) < 1 || len(*req.Name) > 50) {
|
||||
return nil, models.NewValidationError("tag name must be 1-50 characters")
|
||||
}
|
||||
if req.Color != nil {
|
||||
if err := utils.ValidateColor(*req.Color); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
updateParams := repository.UpdateTagParams{
|
||||
ID: utils.ToPgUUID(tagID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
}
|
||||
if req.Name != nil {
|
||||
updateParams.Name = utils.ToPgTextPtr(req.Name)
|
||||
}
|
||||
if req.Color != nil {
|
||||
updateParams.Color = utils.ToPgTextPtr(req.Color)
|
||||
}
|
||||
|
||||
t, err := s.queries.UpdateTag(ctx, updateParams)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
tag := tagFromDB(t)
|
||||
return &tag, nil
|
||||
}
|
||||
|
||||
func (s *TagService) Delete(ctx context.Context, userID uuid.UUID, tagID uuid.UUID) error {
|
||||
err := s.queries.DeleteTag(ctx, repository.DeleteTagParams{
|
||||
ID: utils.ToPgUUID(tagID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
return models.ErrInternal
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TagService) AttachToTask(ctx context.Context, userID uuid.UUID, tagID uuid.UUID, taskID uuid.UUID) error {
|
||||
if _, err := s.queries.GetTagByID(ctx, repository.GetTagByIDParams{
|
||||
ID: utils.ToPgUUID(tagID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
}); err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return models.ErrNotFound
|
||||
}
|
||||
return models.ErrInternal
|
||||
}
|
||||
return s.queries.AttachTagToTask(ctx, repository.AttachTagToTaskParams{
|
||||
TaskID: utils.ToPgUUID(taskID),
|
||||
TagID: utils.ToPgUUID(tagID),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *TagService) DetachFromTask(ctx context.Context, userID uuid.UUID, tagID uuid.UUID, taskID uuid.UUID) error {
|
||||
if _, err := s.queries.GetTagByID(ctx, repository.GetTagByIDParams{
|
||||
ID: utils.ToPgUUID(tagID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
}); err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return models.ErrNotFound
|
||||
}
|
||||
return models.ErrInternal
|
||||
}
|
||||
return s.queries.DetachTagFromTask(ctx, repository.DetachTagFromTaskParams{
|
||||
TaskID: utils.ToPgUUID(taskID),
|
||||
TagID: utils.ToPgUUID(tagID),
|
||||
})
|
||||
}
|
||||
436
internal/service/task.go
Normal file
436
internal/service/task.go
Normal file
@@ -0,0 +1,436 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/repository"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type TaskService struct {
|
||||
queries *repository.Queries
|
||||
audit *AuditService
|
||||
webhook *TaskWebhookService
|
||||
}
|
||||
|
||||
func NewTaskService(queries *repository.Queries, audit *AuditService, webhook *TaskWebhookService) *TaskService {
|
||||
return &TaskService{queries: queries, audit: audit, webhook: webhook}
|
||||
}
|
||||
|
||||
type CreateTaskRequest struct {
|
||||
Title string
|
||||
Description *string
|
||||
Status *string
|
||||
Priority *string
|
||||
DueDate *time.Time
|
||||
ProjectID *uuid.UUID
|
||||
ParentID *uuid.UUID
|
||||
SortOrder *int
|
||||
RecurrenceRule *string
|
||||
}
|
||||
|
||||
type UpdateTaskRequest struct {
|
||||
Title *string
|
||||
Description *string
|
||||
Status *string
|
||||
Priority *string
|
||||
DueDate *time.Time
|
||||
ProjectID *uuid.UUID
|
||||
SortOrder *int
|
||||
RecurrenceRule *string
|
||||
}
|
||||
|
||||
type ListTasksParams struct {
|
||||
Status *string
|
||||
Priority *string
|
||||
DueFrom *time.Time
|
||||
DueTo *time.Time
|
||||
ProjectID *uuid.UUID
|
||||
TagIDs []uuid.UUID
|
||||
Sort string // created_at, due_date, priority
|
||||
Order string // asc, desc
|
||||
Limit int
|
||||
Cursor string
|
||||
}
|
||||
|
||||
func (s *TaskService) Create(ctx context.Context, userID uuid.UUID, req CreateTaskRequest) (*models.Task, error) {
|
||||
if err := utils.ValidateTaskTitle(req.Title); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status := "todo"
|
||||
if req.Status != nil {
|
||||
if err := utils.ValidateTaskStatus(*req.Status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status = *req.Status
|
||||
}
|
||||
priority := "medium"
|
||||
if req.Priority != nil {
|
||||
if err := utils.ValidateTaskPriority(*req.Priority); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
priority = *req.Priority
|
||||
}
|
||||
sortOrder := int32(0)
|
||||
if req.SortOrder != nil {
|
||||
sortOrder = int32(*req.SortOrder)
|
||||
}
|
||||
|
||||
id := uuid.New()
|
||||
t, err := s.queries.CreateTask(ctx, repository.CreateTaskParams{
|
||||
ID: utils.ToPgUUID(id),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
Title: req.Title,
|
||||
Description: utils.ToPgTextPtr(req.Description),
|
||||
Status: status,
|
||||
Priority: priority,
|
||||
DueDate: utils.ToPgTimestamptzPtr(req.DueDate),
|
||||
ProjectID: utils.ToPgUUIDPtr(req.ProjectID),
|
||||
ParentID: utils.ToPgUUIDPtr(req.ParentID),
|
||||
SortOrder: sortOrder,
|
||||
RecurrenceRule: utils.ToPgTextPtr(req.RecurrenceRule),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
s.audit.Log(ctx, "task", id, "CREATE_TASK", userID)
|
||||
task := taskFromDB(t)
|
||||
|
||||
if s.webhook != nil {
|
||||
go s.webhook.Deliver(ctx, userID, "created", task)
|
||||
}
|
||||
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) Get(ctx context.Context, userID uuid.UUID, taskID uuid.UUID) (*models.Task, error) {
|
||||
t, err := s.queries.GetTaskByID(ctx, repository.GetTaskByIDParams{
|
||||
ID: utils.ToPgUUID(taskID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
task := taskFromDB(t)
|
||||
|
||||
tags, _ := s.queries.ListTagsByTask(ctx, utils.ToPgUUID(taskID))
|
||||
task.Tags = make([]models.Tag, 0, len(tags))
|
||||
for _, tag := range tags {
|
||||
task.Tags = append(task.Tags, tagFromDB(tag))
|
||||
}
|
||||
|
||||
if count, err := s.queries.CountSubtasksByStatus(ctx, utils.ToPgUUID(taskID)); err == nil && count.TotalCount > 0 {
|
||||
pct := int(100 * count.DoneCount / count.TotalCount)
|
||||
task.CompletionPercentage = &pct
|
||||
}
|
||||
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) List(ctx context.Context, userID uuid.UUID, params ListTasksParams) ([]models.Task, *string, error) {
|
||||
lim := utils.ClampLimit(params.Limit)
|
||||
if lim <= 0 {
|
||||
lim = 50
|
||||
}
|
||||
|
||||
var status, priority pgtype.Text
|
||||
if params.Status != nil && *params.Status != "" {
|
||||
status = utils.ToPgText(*params.Status)
|
||||
}
|
||||
if params.Priority != nil && *params.Priority != "" {
|
||||
priority = utils.ToPgText(*params.Priority)
|
||||
}
|
||||
projectID := utils.ToPgUUIDPtr(params.ProjectID)
|
||||
dueFrom := utils.ToPgTimestamptzPtr(params.DueFrom)
|
||||
dueTo := utils.ToPgTimestamptzPtr(params.DueTo)
|
||||
|
||||
var cursorTime *time.Time
|
||||
var cursorID *uuid.UUID
|
||||
if params.Cursor != "" {
|
||||
ct, cid, err := utils.ParseCursor(params.Cursor)
|
||||
if err != nil {
|
||||
return nil, nil, models.NewValidationError("invalid cursor")
|
||||
}
|
||||
cursorTime = ct
|
||||
cursorID = cid
|
||||
}
|
||||
|
||||
listParams := repository.ListTasksParams{
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
Status: status,
|
||||
Priority: priority,
|
||||
ProjectID: projectID,
|
||||
DueFrom: dueFrom,
|
||||
DueTo: dueTo,
|
||||
Lim: lim + 1,
|
||||
}
|
||||
if cursorTime != nil && cursorID != nil {
|
||||
listParams.CursorTime = utils.ToPgTimestamptz(*cursorTime)
|
||||
listParams.CursorID = utils.ToPgUUID(*cursorID)
|
||||
}
|
||||
|
||||
rows, err := s.queries.ListTasks(ctx, listParams)
|
||||
if err != nil {
|
||||
return nil, nil, models.ErrInternal
|
||||
}
|
||||
|
||||
tasks := make([]models.Task, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
tasks = append(tasks, *taskFromDB(r))
|
||||
}
|
||||
|
||||
var nextCursor *string
|
||||
if len(tasks) > int(lim) {
|
||||
tasks = tasks[:lim]
|
||||
last := tasks[len(tasks)-1]
|
||||
c := utils.EncodeCursor(last.CreatedAt, last.ID)
|
||||
nextCursor = &c
|
||||
}
|
||||
|
||||
return tasks, nextCursor, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) Update(ctx context.Context, userID uuid.UUID, taskID uuid.UUID, req UpdateTaskRequest) (*models.Task, error) {
|
||||
if req.Title != nil {
|
||||
if err := utils.ValidateTaskTitle(*req.Title); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if req.Status != nil {
|
||||
if err := utils.ValidateTaskStatus(*req.Status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if req.Priority != nil {
|
||||
if err := utils.ValidateTaskPriority(*req.Priority); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
prev, err := s.queries.GetTaskByID(ctx, repository.GetTaskByIDParams{
|
||||
ID: utils.ToPgUUID(taskID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
updateParams := repository.UpdateTaskParams{
|
||||
ID: utils.ToPgUUID(taskID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
}
|
||||
if req.Title != nil {
|
||||
updateParams.Title = utils.ToPgTextPtr(req.Title)
|
||||
}
|
||||
if req.Description != nil {
|
||||
updateParams.Description = utils.ToPgTextPtr(req.Description)
|
||||
}
|
||||
if req.Status != nil {
|
||||
updateParams.Status = utils.ToPgTextPtr(req.Status)
|
||||
}
|
||||
if req.Priority != nil {
|
||||
updateParams.Priority = utils.ToPgTextPtr(req.Priority)
|
||||
}
|
||||
if req.DueDate != nil {
|
||||
updateParams.DueDate = utils.ToPgTimestamptzPtr(req.DueDate)
|
||||
}
|
||||
if req.ProjectID != nil {
|
||||
updateParams.ProjectID = utils.ToPgUUIDPtr(req.ProjectID)
|
||||
}
|
||||
if req.SortOrder != nil {
|
||||
updateParams.SortOrder = utils.ToPgInt4Ptr(req.SortOrder)
|
||||
}
|
||||
if req.RecurrenceRule != nil {
|
||||
updateParams.RecurrenceRule = utils.ToPgTextPtr(req.RecurrenceRule)
|
||||
}
|
||||
|
||||
t, err := s.queries.UpdateTask(ctx, updateParams)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
task := taskFromDB(t)
|
||||
s.audit.Log(ctx, "task", taskID, "UPDATE_TASK", userID)
|
||||
|
||||
if s.webhook != nil && req.Status != nil && *req.Status != prev.Status {
|
||||
go s.webhook.Deliver(ctx, userID, "status_change", task)
|
||||
if *req.Status == "done" {
|
||||
go s.webhook.Deliver(ctx, userID, "completion", task)
|
||||
}
|
||||
}
|
||||
|
||||
if prev.ParentID.Valid {
|
||||
s.maybeAutoCompleteParent(ctx, userID, utils.FromPgUUID(prev.ParentID))
|
||||
}
|
||||
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) Delete(ctx context.Context, userID uuid.UUID, taskID uuid.UUID, permanent bool) error {
|
||||
if permanent {
|
||||
err := s.queries.HardDeleteTask(ctx, repository.HardDeleteTaskParams{
|
||||
ID: utils.ToPgUUID(taskID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
return models.ErrInternal
|
||||
}
|
||||
} else {
|
||||
err := s.queries.SoftDeleteTask(ctx, repository.SoftDeleteTaskParams{
|
||||
ID: utils.ToPgUUID(taskID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
return models.ErrInternal
|
||||
}
|
||||
}
|
||||
s.audit.Log(ctx, "task", taskID, "DELETE_TASK", userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TaskService) MarkComplete(ctx context.Context, userID uuid.UUID, taskID uuid.UUID) (*models.Task, error) {
|
||||
blockers, _ := s.queries.ListTaskBlockers(ctx, utils.ToPgUUID(taskID))
|
||||
for _, b := range blockers {
|
||||
if b.Status != "done" {
|
||||
return nil, models.NewConflictError("cannot complete: blocked by incomplete task")
|
||||
}
|
||||
}
|
||||
|
||||
t, err := s.queries.MarkTaskComplete(ctx, repository.MarkTaskCompleteParams{
|
||||
ID: utils.ToPgUUID(taskID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
task := taskFromDB(t)
|
||||
s.audit.Log(ctx, "task", taskID, "COMPLETE_TASK", userID)
|
||||
if s.webhook != nil {
|
||||
go s.webhook.Deliver(ctx, userID, "completion", task)
|
||||
}
|
||||
|
||||
if t.ParentID.Valid {
|
||||
s.maybeAutoCompleteParent(ctx, userID, utils.FromPgUUID(t.ParentID))
|
||||
}
|
||||
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) MarkUncomplete(ctx context.Context, userID uuid.UUID, taskID uuid.UUID, newStatus *string) (*models.Task, error) {
|
||||
status := "todo"
|
||||
if newStatus != nil {
|
||||
if err := utils.ValidateTaskStatus(*newStatus); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status = *newStatus
|
||||
}
|
||||
|
||||
t, err := s.queries.MarkTaskUncomplete(ctx, repository.MarkTaskUncompleteParams{
|
||||
ID: utils.ToPgUUID(taskID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
NewStatus: utils.ToPgTextPtr(&status),
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
task := taskFromDB(t)
|
||||
s.audit.Log(ctx, "task", taskID, "UNCOMPLETE_TASK", userID)
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) ListSubtasks(ctx context.Context, userID uuid.UUID, taskID uuid.UUID) ([]models.Task, error) {
|
||||
rows, err := s.queries.ListSubtasks(ctx, repository.ListSubtasksParams{
|
||||
ParentID: utils.ToPgUUID(taskID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
tasks := make([]models.Task, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
tasks = append(tasks, *taskFromDB(r))
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) maybeAutoCompleteParent(ctx context.Context, userID uuid.UUID, parentID uuid.UUID) {
|
||||
count, err := s.queries.CountSubtasksByStatus(ctx, utils.ToPgUUID(parentID))
|
||||
if err != nil || count.TotalCount == 0 {
|
||||
return
|
||||
}
|
||||
if count.DoneCount == count.TotalCount {
|
||||
_, _ = s.queries.MarkTaskComplete(ctx, repository.MarkTaskCompleteParams{
|
||||
ID: utils.ToPgUUID(parentID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
})
|
||||
s.audit.Log(ctx, "task", parentID, "AUTO_COMPLETE_PARENT", userID)
|
||||
}
|
||||
}
|
||||
|
||||
func taskFromDB(t repository.Task) *models.Task {
|
||||
task := &models.Task{
|
||||
ID: utils.FromPgUUID(t.ID),
|
||||
OwnerID: utils.FromPgUUID(t.OwnerID),
|
||||
Title: t.Title,
|
||||
Status: t.Status,
|
||||
Priority: t.Priority,
|
||||
CreatedAt: utils.FromPgTimestamptz(t.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(t.UpdatedAt),
|
||||
SortOrder: int(t.SortOrder),
|
||||
}
|
||||
if t.Description.Valid {
|
||||
task.Description = &t.Description.String
|
||||
}
|
||||
if t.DueDate.Valid {
|
||||
task.DueDate = &t.DueDate.Time
|
||||
}
|
||||
if t.CompletedAt.Valid {
|
||||
task.CompletedAt = &t.CompletedAt.Time
|
||||
}
|
||||
if t.ProjectID.Valid {
|
||||
id := utils.FromPgUUID(t.ProjectID)
|
||||
task.ProjectID = &id
|
||||
}
|
||||
if t.ParentID.Valid {
|
||||
id := utils.FromPgUUID(t.ParentID)
|
||||
task.ParentID = &id
|
||||
}
|
||||
if t.RecurrenceRule.Valid {
|
||||
task.RecurrenceRule = &t.RecurrenceRule.String
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
func tagFromDB(t repository.Tag) models.Tag {
|
||||
return models.Tag{
|
||||
ID: utils.FromPgUUID(t.ID),
|
||||
OwnerID: utils.FromPgUUID(t.OwnerID),
|
||||
Name: t.Name,
|
||||
Color: t.Color,
|
||||
CreatedAt: utils.FromPgTimestamptz(t.CreatedAt),
|
||||
}
|
||||
}
|
||||
91
internal/service/task_dependency.go
Normal file
91
internal/service/task_dependency.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/repository"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type TaskDependencyService struct {
|
||||
queries *repository.Queries
|
||||
}
|
||||
|
||||
func NewTaskDependencyService(queries *repository.Queries) *TaskDependencyService {
|
||||
return &TaskDependencyService{queries: queries}
|
||||
}
|
||||
|
||||
func (s *TaskDependencyService) Add(ctx context.Context, userID uuid.UUID, taskID uuid.UUID, blocksTaskID uuid.UUID) error {
|
||||
if taskID == blocksTaskID {
|
||||
return models.NewValidationError("task cannot depend on itself")
|
||||
}
|
||||
|
||||
if _, err := s.queries.GetTaskByID(ctx, repository.GetTaskByIDParams{
|
||||
ID: utils.ToPgUUID(taskID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
}); err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return models.ErrNotFound
|
||||
}
|
||||
return models.ErrInternal
|
||||
}
|
||||
|
||||
hasCircle, err := s.queries.CheckDirectCircularDependency(ctx, repository.CheckDirectCircularDependencyParams{
|
||||
BlocksTaskID: utils.ToPgUUID(taskID),
|
||||
TaskID: utils.ToPgUUID(blocksTaskID),
|
||||
})
|
||||
if err != nil {
|
||||
return models.ErrInternal
|
||||
}
|
||||
if hasCircle {
|
||||
return models.NewConflictError("circular dependency detected")
|
||||
}
|
||||
|
||||
return s.queries.AddTaskDependency(ctx, repository.AddTaskDependencyParams{
|
||||
TaskID: utils.ToPgUUID(taskID),
|
||||
BlocksTaskID: utils.ToPgUUID(blocksTaskID),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *TaskDependencyService) Remove(ctx context.Context, userID uuid.UUID, taskID uuid.UUID, blocksTaskID uuid.UUID) error {
|
||||
if _, err := s.queries.GetTaskByID(ctx, repository.GetTaskByIDParams{
|
||||
ID: utils.ToPgUUID(taskID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
}); err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return models.ErrNotFound
|
||||
}
|
||||
return models.ErrInternal
|
||||
}
|
||||
|
||||
return s.queries.RemoveTaskDependency(ctx, repository.RemoveTaskDependencyParams{
|
||||
TaskID: utils.ToPgUUID(taskID),
|
||||
BlocksTaskID: utils.ToPgUUID(blocksTaskID),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *TaskDependencyService) ListBlockers(ctx context.Context, userID uuid.UUID, taskID uuid.UUID) ([]models.Task, error) {
|
||||
if _, err := s.queries.GetTaskByID(ctx, repository.GetTaskByIDParams{
|
||||
ID: utils.ToPgUUID(taskID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
}); err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
rows, err := s.queries.ListTaskBlockers(ctx, utils.ToPgUUID(taskID))
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
tasks := make([]models.Task, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
tasks = append(tasks, *taskFromDB(r))
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
123
internal/service/task_reminder.go
Normal file
123
internal/service/task_reminder.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/repository"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type TaskReminderScheduler interface {
|
||||
ScheduleTaskReminder(ctx context.Context, reminderID, taskID, ownerID uuid.UUID, triggerAt time.Time) error
|
||||
}
|
||||
|
||||
type TaskReminderService struct {
|
||||
queries *repository.Queries
|
||||
scheduler TaskReminderScheduler
|
||||
}
|
||||
|
||||
func NewTaskReminderService(queries *repository.Queries, scheduler TaskReminderScheduler) *TaskReminderService {
|
||||
return &TaskReminderService{queries: queries, scheduler: scheduler}
|
||||
}
|
||||
|
||||
type CreateTaskReminderRequest struct {
|
||||
Type string
|
||||
Config map[string]interface{}
|
||||
ScheduledAt time.Time
|
||||
}
|
||||
|
||||
func (s *TaskReminderService) Create(ctx context.Context, userID uuid.UUID, taskID uuid.UUID, req CreateTaskReminderRequest) (*models.TaskReminder, error) {
|
||||
if _, err := s.queries.GetTaskByID(ctx, repository.GetTaskByIDParams{
|
||||
ID: utils.ToPgUUID(taskID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
}); err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
validTypes := map[string]bool{"push": true, "email": true, "webhook": true, "telegram": true, "nostr": true}
|
||||
if !validTypes[req.Type] {
|
||||
return nil, models.NewValidationError("invalid reminder type")
|
||||
}
|
||||
|
||||
configJSON := []byte("{}")
|
||||
if req.Config != nil {
|
||||
configJSON, _ = json.Marshal(req.Config)
|
||||
}
|
||||
|
||||
id := uuid.New()
|
||||
rem, err := s.queries.CreateTaskReminder(ctx, repository.CreateTaskReminderParams{
|
||||
ID: utils.ToPgUUID(id),
|
||||
TaskID: utils.ToPgUUID(taskID),
|
||||
Type: req.Type,
|
||||
Column4: configJSON,
|
||||
ScheduledAt: utils.ToPgTimestamptz(req.ScheduledAt),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
if s.scheduler != nil {
|
||||
_ = s.scheduler.ScheduleTaskReminder(ctx, id, taskID, userID, req.ScheduledAt)
|
||||
}
|
||||
|
||||
return taskReminderFromDB(rem), nil
|
||||
}
|
||||
|
||||
func (s *TaskReminderService) List(ctx context.Context, userID uuid.UUID, taskID uuid.UUID) ([]models.TaskReminder, error) {
|
||||
if _, err := s.queries.GetTaskByID(ctx, repository.GetTaskByIDParams{
|
||||
ID: utils.ToPgUUID(taskID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
}); err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
rows, err := s.queries.ListTaskReminders(ctx, utils.ToPgUUID(taskID))
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
reminders := make([]models.TaskReminder, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
reminders = append(reminders, *taskReminderFromDB(r))
|
||||
}
|
||||
return reminders, nil
|
||||
}
|
||||
|
||||
func (s *TaskReminderService) Delete(ctx context.Context, userID uuid.UUID, taskID uuid.UUID, reminderID uuid.UUID) error {
|
||||
if _, err := s.queries.GetTaskByID(ctx, repository.GetTaskByIDParams{
|
||||
ID: utils.ToPgUUID(taskID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
}); err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return models.ErrNotFound
|
||||
}
|
||||
return models.ErrInternal
|
||||
}
|
||||
|
||||
return s.queries.DeleteTaskReminder(ctx, utils.ToPgUUID(reminderID))
|
||||
}
|
||||
|
||||
func taskReminderFromDB(r repository.TaskReminder) *models.TaskReminder {
|
||||
rem := &models.TaskReminder{
|
||||
ID: utils.FromPgUUID(r.ID),
|
||||
TaskID: utils.FromPgUUID(r.TaskID),
|
||||
Type: r.Type,
|
||||
ScheduledAt: utils.FromPgTimestamptz(r.ScheduledAt),
|
||||
CreatedAt: utils.FromPgTimestamptz(r.CreatedAt),
|
||||
}
|
||||
if len(r.Config) > 0 {
|
||||
json.Unmarshal(r.Config, &rem.Config)
|
||||
}
|
||||
return rem
|
||||
}
|
||||
73
internal/service/task_webhook.go
Normal file
73
internal/service/task_webhook.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/repository"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type TaskWebhookService struct {
|
||||
queries *repository.Queries
|
||||
}
|
||||
|
||||
func NewTaskWebhookService(queries *repository.Queries) *TaskWebhookService {
|
||||
return &TaskWebhookService{queries: queries}
|
||||
}
|
||||
|
||||
func (s *TaskWebhookService) Deliver(ctx context.Context, ownerID uuid.UUID, event string, task *models.Task) {
|
||||
webhooks, err := s.queries.ListTaskWebhooksByOwner(ctx, utils.ToPgUUID(ownerID))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"event": event,
|
||||
"task": task,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
for _, wh := range webhooks {
|
||||
if !s.webhookSubscribedTo(wh.Events, event) {
|
||||
continue
|
||||
}
|
||||
secret := ""
|
||||
if wh.Secret.Valid {
|
||||
secret = wh.Secret.String
|
||||
}
|
||||
go s.postWebhook(wh.Url, body, []byte(secret))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TaskWebhookService) webhookSubscribedTo(eventsJSON []byte, event string) bool {
|
||||
var events []string
|
||||
if err := json.Unmarshal(eventsJSON, &events); err != nil {
|
||||
return false
|
||||
}
|
||||
for _, e := range events {
|
||||
if e == event {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *TaskWebhookService) postWebhook(url string, body []byte, secret []byte) {
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if len(secret) > 0 {
|
||||
req.Header.Set("X-Webhook-Signature", string(secret))
|
||||
}
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
_, _ = client.Do(req)
|
||||
}
|
||||
78
internal/service/task_webhook_svc.go
Normal file
78
internal/service/task_webhook_svc.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/repository"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type TaskWebhookCreateRequest struct {
|
||||
URL string
|
||||
Events []string
|
||||
Secret *string
|
||||
}
|
||||
|
||||
func (s *TaskWebhookService) Create(ctx context.Context, userID uuid.UUID, req TaskWebhookCreateRequest) (*models.TaskWebhook, error) {
|
||||
if req.URL == "" {
|
||||
return nil, models.NewValidationError("url is required")
|
||||
}
|
||||
|
||||
eventsJSON, _ := json.Marshal(req.Events)
|
||||
if req.Events == nil {
|
||||
eventsJSON = []byte("[]")
|
||||
}
|
||||
|
||||
id := uuid.New()
|
||||
wh, err := s.queries.CreateTaskWebhook(ctx, repository.CreateTaskWebhookParams{
|
||||
ID: utils.ToPgUUID(id),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
Url: req.URL,
|
||||
Column4: eventsJSON,
|
||||
Secret: utils.ToPgTextPtr(req.Secret),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
return taskWebhookFromDB(wh), nil
|
||||
}
|
||||
|
||||
func (s *TaskWebhookService) List(ctx context.Context, userID uuid.UUID) ([]models.TaskWebhook, error) {
|
||||
rows, err := s.queries.ListTaskWebhooksByOwner(ctx, utils.ToPgUUID(userID))
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
webhooks := make([]models.TaskWebhook, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
webhooks = append(webhooks, *taskWebhookFromDB(r))
|
||||
}
|
||||
return webhooks, nil
|
||||
}
|
||||
|
||||
func (s *TaskWebhookService) Delete(ctx context.Context, userID uuid.UUID, webhookID uuid.UUID) error {
|
||||
return s.queries.DeleteTaskWebhook(ctx, repository.DeleteTaskWebhookParams{
|
||||
ID: utils.ToPgUUID(webhookID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
})
|
||||
}
|
||||
|
||||
func taskWebhookFromDB(wh repository.TaskWebhook) *models.TaskWebhook {
|
||||
w := &models.TaskWebhook{
|
||||
ID: utils.FromPgUUID(wh.ID),
|
||||
OwnerID: utils.FromPgUUID(wh.OwnerID),
|
||||
URL: wh.Url,
|
||||
CreatedAt: utils.FromPgTimestamptz(wh.CreatedAt),
|
||||
}
|
||||
if len(wh.Events) > 0 {
|
||||
json.Unmarshal(wh.Events, &w.Events)
|
||||
}
|
||||
if wh.Secret.Valid {
|
||||
w.Secret = &wh.Secret.String
|
||||
}
|
||||
return w
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
10
migrations/000007_tasks.down.sql
Normal file
10
migrations/000007_tasks.down.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- To-do list support rollback
|
||||
|
||||
DROP TABLE IF EXISTS task_webhooks;
|
||||
DROP TABLE IF EXISTS task_reminders;
|
||||
DROP TABLE IF EXISTS task_dependencies;
|
||||
DROP TABLE IF EXISTS task_tags;
|
||||
DROP TABLE IF EXISTS tasks;
|
||||
DROP TABLE IF EXISTS tags;
|
||||
DROP TABLE IF EXISTS project_members;
|
||||
DROP TABLE IF EXISTS projects;
|
||||
111
migrations/000007_tasks.up.sql
Normal file
111
migrations/000007_tasks.up.sql
Normal file
@@ -0,0 +1,111 @@
|
||||
-- To-do list support: projects, tags, tasks, and related tables
|
||||
|
||||
-- Projects (Layer 2)
|
||||
CREATE TABLE projects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id UUID NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#3B82F6',
|
||||
is_shared BOOLEAN NOT NULL DEFAULT false,
|
||||
deadline TIMESTAMPTZ,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_projects_owner_id ON projects (owner_id);
|
||||
|
||||
-- Project Members (Layer 2 shared projects)
|
||||
CREATE TABLE project_members (
|
||||
project_id UUID NOT NULL REFERENCES projects(id),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
role TEXT NOT NULL CHECK (role IN ('owner', 'editor', 'viewer')),
|
||||
PRIMARY KEY (project_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_project_members_project_id ON project_members (project_id);
|
||||
|
||||
-- Tags (Layer 2)
|
||||
CREATE TABLE tags (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id UUID NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#6B7280',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tags_owner_id ON tags (owner_id);
|
||||
|
||||
-- Tasks (Layer 1 core)
|
||||
CREATE TABLE tasks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id UUID NOT NULL REFERENCES users(id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'todo' CHECK (status IN ('todo', 'in_progress', 'done', 'archived')),
|
||||
priority TEXT NOT NULL DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high', 'critical')),
|
||||
due_date TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
project_id UUID REFERENCES projects(id),
|
||||
parent_id UUID REFERENCES tasks(id),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
recurrence_rule TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tasks_owner_id ON tasks (owner_id);
|
||||
CREATE INDEX idx_tasks_status ON tasks (owner_id, status) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_tasks_priority ON tasks (owner_id, priority) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_tasks_due_date ON tasks (owner_id, due_date) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_tasks_project_id ON tasks (project_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_tasks_parent_id ON tasks (parent_id) WHERE deleted_at IS NULL;
|
||||
|
||||
-- Task Tags (many-to-many)
|
||||
CREATE TABLE task_tags (
|
||||
task_id UUID NOT NULL REFERENCES tasks(id),
|
||||
tag_id UUID NOT NULL REFERENCES tags(id),
|
||||
PRIMARY KEY (task_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_task_tags_task_id ON task_tags (task_id);
|
||||
CREATE INDEX idx_task_tags_tag_id ON task_tags (tag_id);
|
||||
|
||||
-- Task Dependencies (Layer 3)
|
||||
-- task_id is blocked by blocks_task_id (blocker)
|
||||
CREATE TABLE task_dependencies (
|
||||
task_id UUID NOT NULL REFERENCES tasks(id),
|
||||
blocks_task_id UUID NOT NULL REFERENCES tasks(id),
|
||||
PRIMARY KEY (task_id, blocks_task_id),
|
||||
CHECK (task_id != blocks_task_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_task_dependencies_task_id ON task_dependencies (task_id);
|
||||
CREATE INDEX idx_task_dependencies_blocks ON task_dependencies (blocks_task_id);
|
||||
|
||||
-- Task Reminders (Layer 2)
|
||||
CREATE TABLE task_reminders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
task_id UUID NOT NULL REFERENCES tasks(id),
|
||||
type TEXT NOT NULL CHECK (type IN ('push', 'email', 'webhook', 'telegram', 'nostr')),
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
scheduled_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_task_reminders_task_id ON task_reminders (task_id);
|
||||
CREATE INDEX idx_task_reminders_scheduled ON task_reminders (scheduled_at);
|
||||
|
||||
-- Task Webhooks (Layer 3)
|
||||
CREATE TABLE task_webhooks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id UUID NOT NULL REFERENCES users(id),
|
||||
url TEXT NOT NULL,
|
||||
events JSONB NOT NULL DEFAULT '[]',
|
||||
secret TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_task_webhooks_owner_id ON task_webhooks (owner_id);
|
||||
BIN
premiumapi
Executable file
BIN
premiumapi
Executable file
Binary file not shown.
54
sqlc/queries/projects.sql
Normal file
54
sqlc/queries/projects.sql
Normal file
@@ -0,0 +1,54 @@
|
||||
-- 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 *;
|
||||
|
||||
-- name: GetProjectByID :one
|
||||
SELECT * FROM projects
|
||||
WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL;
|
||||
|
||||
-- name: ListProjects :many
|
||||
SELECT * FROM projects
|
||||
WHERE owner_id = @owner_id AND deleted_at IS NULL
|
||||
ORDER BY sort_order ASC, name ASC;
|
||||
|
||||
-- name: ListProjectsForUser :many
|
||||
SELECT p.* FROM projects p
|
||||
LEFT JOIN project_members pm ON pm.project_id = p.id AND pm.user_id = @user_id
|
||||
WHERE (p.owner_id = @user_id OR pm.user_id = @user_id)
|
||||
AND p.deleted_at IS NULL
|
||||
ORDER BY p.sort_order ASC, p.name ASC;
|
||||
|
||||
-- name: UpdateProject :one
|
||||
UPDATE projects
|
||||
SET name = COALESCE(sqlc.narg('name'), name),
|
||||
color = COALESCE(sqlc.narg('color'), color),
|
||||
is_shared = COALESCE(sqlc.narg('is_shared'), is_shared),
|
||||
deadline = sqlc.narg('deadline'),
|
||||
sort_order = COALESCE(sqlc.narg('sort_order'), sort_order),
|
||||
updated_at = now()
|
||||
WHERE id = @id AND owner_id = @owner_id AND deleted_at IS NULL
|
||||
RETURNING *;
|
||||
|
||||
-- name: SoftDeleteProject :exec
|
||||
UPDATE projects SET deleted_at = now(), updated_at = now()
|
||||
WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL;
|
||||
|
||||
-- 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;
|
||||
|
||||
-- name: RemoveProjectMember :exec
|
||||
DELETE FROM project_members
|
||||
WHERE project_id = $1 AND user_id = $2;
|
||||
|
||||
-- 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;
|
||||
|
||||
-- name: GetProjectMember :one
|
||||
SELECT * FROM project_members
|
||||
WHERE project_id = $1 AND user_id = $2;
|
||||
40
sqlc/queries/tags.sql
Normal file
40
sqlc/queries/tags.sql
Normal file
@@ -0,0 +1,40 @@
|
||||
-- name: CreateTag :one
|
||||
INSERT INTO tags (id, owner_id, name, color)
|
||||
VALUES ($1, $2, $3, COALESCE($4, '#6B7280'))
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetTagByID :one
|
||||
SELECT * FROM tags
|
||||
WHERE id = $1 AND owner_id = $2;
|
||||
|
||||
-- name: ListTagsByOwner :many
|
||||
SELECT * FROM tags
|
||||
WHERE owner_id = $1
|
||||
ORDER BY name ASC;
|
||||
|
||||
-- name: UpdateTag :one
|
||||
UPDATE tags
|
||||
SET name = COALESCE(sqlc.narg('name'), name),
|
||||
color = COALESCE(sqlc.narg('color'), color)
|
||||
WHERE id = @id AND owner_id = @owner_id
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteTag :exec
|
||||
DELETE FROM tags WHERE id = $1 AND owner_id = $2;
|
||||
|
||||
-- name: AttachTagToTask :exec
|
||||
INSERT INTO task_tags (task_id, tag_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (task_id, tag_id) DO NOTHING;
|
||||
|
||||
-- name: DetachTagFromTask :exec
|
||||
DELETE FROM task_tags
|
||||
WHERE task_id = $1 AND tag_id = $2;
|
||||
|
||||
-- name: ListTagsByTask :many
|
||||
SELECT t.* FROM tags t
|
||||
JOIN task_tags tt ON tt.tag_id = t.id
|
||||
WHERE tt.task_id = $1;
|
||||
|
||||
-- name: ListTaskIDsByTag :many
|
||||
SELECT task_id FROM task_tags WHERE tag_id = $1;
|
||||
25
sqlc/queries/task_dependencies.sql
Normal file
25
sqlc/queries/task_dependencies.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- name: AddTaskDependency :exec
|
||||
INSERT INTO task_dependencies (task_id, blocks_task_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (task_id, blocks_task_id) DO NOTHING;
|
||||
|
||||
-- name: RemoveTaskDependency :exec
|
||||
DELETE FROM task_dependencies
|
||||
WHERE task_id = $1 AND blocks_task_id = $2;
|
||||
|
||||
-- name: ListTaskBlockers :many
|
||||
SELECT t.* 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;
|
||||
|
||||
-- name: ListTasksBlockedBy :many
|
||||
SELECT t.* 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;
|
||||
|
||||
-- name: CheckDirectCircularDependency :one
|
||||
-- Direct cycle: blocks_task_id is blocked by task_id (would create A->B and B->A)
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM task_dependencies
|
||||
WHERE task_id = $2 AND blocks_task_id = $1
|
||||
) AS has_circle;
|
||||
26
sqlc/queries/task_reminders.sql
Normal file
26
sqlc/queries/task_reminders.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- name: CreateTaskReminder :one
|
||||
INSERT INTO task_reminders (id, task_id, type, config, scheduled_at)
|
||||
VALUES ($1, $2, $3, COALESCE($4, '{}'), $5)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetTaskReminderByID :one
|
||||
SELECT * FROM task_reminders
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: ListTaskReminders :many
|
||||
SELECT * FROM task_reminders
|
||||
WHERE task_id = $1
|
||||
ORDER BY scheduled_at ASC;
|
||||
|
||||
-- name: ListTaskRemindersDueBefore :many
|
||||
SELECT tr.*, 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;
|
||||
|
||||
-- name: DeleteTaskReminder :exec
|
||||
DELETE FROM task_reminders WHERE id = $1;
|
||||
|
||||
-- name: DeleteTaskRemindersByTask :exec
|
||||
DELETE FROM task_reminders WHERE task_id = $1;
|
||||
16
sqlc/queries/task_webhooks.sql
Normal file
16
sqlc/queries/task_webhooks.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- name: CreateTaskWebhook :one
|
||||
INSERT INTO task_webhooks (id, owner_id, url, events, secret)
|
||||
VALUES ($1, $2, $3, COALESCE($4, '[]'), $5)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetTaskWebhookByID :one
|
||||
SELECT * FROM task_webhooks
|
||||
WHERE id = $1 AND owner_id = $2;
|
||||
|
||||
-- name: ListTaskWebhooksByOwner :many
|
||||
SELECT * FROM task_webhooks
|
||||
WHERE owner_id = $1
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: DeleteTaskWebhook :exec
|
||||
DELETE FROM task_webhooks WHERE id = $1 AND owner_id = $2;
|
||||
125
sqlc/queries/tasks.sql
Normal file
125
sqlc/queries/tasks.sql
Normal file
@@ -0,0 +1,125 @@
|
||||
-- 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 *;
|
||||
|
||||
-- name: GetTaskByID :one
|
||||
SELECT * FROM tasks
|
||||
WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL;
|
||||
|
||||
-- name: GetTaskByIDForUpdate :one
|
||||
SELECT * FROM tasks
|
||||
WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL
|
||||
FOR UPDATE;
|
||||
|
||||
-- name: ListTasks :many
|
||||
SELECT t.* FROM tasks t
|
||||
WHERE t.owner_id = @owner_id
|
||||
AND t.deleted_at IS NULL
|
||||
AND t.parent_id IS NULL
|
||||
AND (sqlc.narg('status')::TEXT IS NULL OR t.status = sqlc.narg('status')::TEXT)
|
||||
AND (sqlc.narg('priority')::TEXT IS NULL OR t.priority = sqlc.narg('priority')::TEXT)
|
||||
AND (sqlc.narg('project_id')::UUID IS NULL OR t.project_id = sqlc.narg('project_id')::UUID)
|
||||
AND (sqlc.narg('due_from')::TIMESTAMPTZ IS NULL OR t.due_date >= sqlc.narg('due_from')::TIMESTAMPTZ)
|
||||
AND (sqlc.narg('due_to')::TIMESTAMPTZ IS NULL OR t.due_date <= sqlc.narg('due_to')::TIMESTAMPTZ)
|
||||
AND (
|
||||
sqlc.narg('cursor_time')::TIMESTAMPTZ IS NULL
|
||||
OR (t.created_at, t.id) < (sqlc.narg('cursor_time')::TIMESTAMPTZ, sqlc.narg('cursor_id')::UUID)
|
||||
)
|
||||
ORDER BY t.created_at DESC, t.id DESC
|
||||
LIMIT @lim;
|
||||
|
||||
-- name: ListTasksByDueDate :many
|
||||
SELECT t.* FROM tasks t
|
||||
WHERE t.owner_id = @owner_id
|
||||
AND t.deleted_at IS NULL
|
||||
AND t.parent_id IS NULL
|
||||
AND (sqlc.narg('status')::TEXT IS NULL OR t.status = sqlc.narg('status')::TEXT)
|
||||
AND (sqlc.narg('priority')::TEXT IS NULL OR t.priority = sqlc.narg('priority')::TEXT)
|
||||
AND (sqlc.narg('project_id')::UUID IS NULL OR t.project_id = sqlc.narg('project_id')::UUID)
|
||||
AND (sqlc.narg('due_from')::TIMESTAMPTZ IS NULL OR t.due_date >= sqlc.narg('due_from')::TIMESTAMPTZ)
|
||||
AND (sqlc.narg('due_to')::TIMESTAMPTZ IS NULL OR t.due_date <= sqlc.narg('due_to')::TIMESTAMPTZ)
|
||||
ORDER BY COALESCE(t.due_date, '9999-12-31'::timestamptz) ASC NULLS LAST, t.id ASC
|
||||
LIMIT @lim;
|
||||
|
||||
-- name: ListTasksByPriority :many
|
||||
SELECT t.* FROM tasks t
|
||||
WHERE t.owner_id = @owner_id
|
||||
AND t.deleted_at IS NULL
|
||||
AND t.parent_id IS NULL
|
||||
AND (sqlc.narg('status')::TEXT IS NULL OR t.status = sqlc.narg('status')::TEXT)
|
||||
AND (sqlc.narg('priority')::TEXT IS NULL OR t.priority = sqlc.narg('priority')::TEXT)
|
||||
AND (sqlc.narg('project_id')::UUID IS NULL OR t.project_id = sqlc.narg('project_id')::UUID)
|
||||
AND (sqlc.narg('due_from')::TIMESTAMPTZ IS NULL OR t.due_date >= sqlc.narg('due_from')::TIMESTAMPTZ)
|
||||
AND (sqlc.narg('due_to')::TIMESTAMPTZ IS NULL OR t.due_date <= sqlc.narg('due_to')::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 @lim;
|
||||
|
||||
-- name: ListTasksWithTag :many
|
||||
SELECT DISTINCT t.* FROM tasks t
|
||||
JOIN task_tags tt ON tt.task_id = t.id
|
||||
WHERE t.owner_id = @owner_id
|
||||
AND t.deleted_at IS NULL
|
||||
AND t.parent_id IS NULL
|
||||
AND tt.tag_id = ANY(@tag_ids)
|
||||
AND (sqlc.narg('status')::TEXT IS NULL OR t.status = sqlc.narg('status')::TEXT)
|
||||
AND (sqlc.narg('project_id')::UUID IS NULL OR t.project_id = sqlc.narg('project_id')::UUID)
|
||||
ORDER BY t.created_at DESC, t.id DESC
|
||||
LIMIT @lim;
|
||||
|
||||
-- name: UpdateTask :one
|
||||
UPDATE tasks
|
||||
SET title = COALESCE(sqlc.narg('title'), title),
|
||||
description = COALESCE(sqlc.narg('description'), description),
|
||||
status = COALESCE(sqlc.narg('status'), status),
|
||||
priority = COALESCE(sqlc.narg('priority'), priority),
|
||||
due_date = sqlc.narg('due_date'),
|
||||
project_id = sqlc.narg('project_id'),
|
||||
sort_order = COALESCE(sqlc.narg('sort_order'), sort_order),
|
||||
recurrence_rule = sqlc.narg('recurrence_rule'),
|
||||
updated_at = now()
|
||||
WHERE id = @id AND owner_id = @owner_id AND deleted_at IS NULL
|
||||
RETURNING *;
|
||||
|
||||
-- name: SoftDeleteTask :exec
|
||||
UPDATE tasks SET deleted_at = now(), updated_at = now()
|
||||
WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL;
|
||||
|
||||
-- name: HardDeleteTask :exec
|
||||
DELETE FROM tasks WHERE id = $1 AND owner_id = $2;
|
||||
|
||||
-- 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 *;
|
||||
|
||||
-- name: MarkTaskUncomplete :one
|
||||
UPDATE tasks
|
||||
SET status = COALESCE(sqlc.narg('new_status'), 'todo'), completed_at = NULL, updated_at = now()
|
||||
WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListSubtasks :many
|
||||
SELECT * FROM tasks
|
||||
WHERE parent_id = $1 AND owner_id = $2 AND deleted_at IS NULL
|
||||
ORDER BY sort_order ASC, created_at ASC;
|
||||
|
||||
-- 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;
|
||||
|
||||
-- name: ListTasksWithRecurrence :many
|
||||
SELECT * FROM tasks
|
||||
WHERE owner_id = $1 AND deleted_at IS NULL
|
||||
AND recurrence_rule IS NOT NULL
|
||||
AND parent_id IS NULL;
|
||||
109
sqlc/schema.sql
109
sqlc/schema.sql
@@ -197,3 +197,112 @@ CREATE TABLE calendar_subscriptions (
|
||||
);
|
||||
|
||||
CREATE INDEX idx_calendar_subscriptions_calendar_id ON calendar_subscriptions (calendar_id);
|
||||
|
||||
-- Projects (Layer 2)
|
||||
CREATE TABLE projects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id UUID NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#3B82F6',
|
||||
is_shared BOOLEAN NOT NULL DEFAULT false,
|
||||
deadline TIMESTAMPTZ,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_projects_owner_id ON projects (owner_id);
|
||||
|
||||
-- Project Members (Layer 2 shared projects)
|
||||
CREATE TABLE project_members (
|
||||
project_id UUID NOT NULL REFERENCES projects(id),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
role TEXT NOT NULL CHECK (role IN ('owner', 'editor', 'viewer')),
|
||||
PRIMARY KEY (project_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_project_members_project_id ON project_members (project_id);
|
||||
|
||||
-- Tags (Layer 2)
|
||||
CREATE TABLE tags (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id UUID NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#6B7280',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tags_owner_id ON tags (owner_id);
|
||||
|
||||
-- Tasks (Layer 1 core)
|
||||
CREATE TABLE tasks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id UUID NOT NULL REFERENCES users(id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'todo' CHECK (status IN ('todo', 'in_progress', 'done', 'archived')),
|
||||
priority TEXT NOT NULL DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high', 'critical')),
|
||||
due_date TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
project_id UUID REFERENCES projects(id),
|
||||
parent_id UUID REFERENCES tasks(id),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
recurrence_rule TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tasks_owner_id ON tasks (owner_id);
|
||||
CREATE INDEX idx_tasks_status ON tasks (owner_id, status) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_tasks_priority ON tasks (owner_id, priority) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_tasks_due_date ON tasks (owner_id, due_date) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_tasks_project_id ON tasks (project_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_tasks_parent_id ON tasks (parent_id) WHERE deleted_at IS NULL;
|
||||
|
||||
-- Task Tags (many-to-many)
|
||||
CREATE TABLE task_tags (
|
||||
task_id UUID NOT NULL REFERENCES tasks(id),
|
||||
tag_id UUID NOT NULL REFERENCES tags(id),
|
||||
PRIMARY KEY (task_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_task_tags_task_id ON task_tags (task_id);
|
||||
CREATE INDEX idx_task_tags_tag_id ON task_tags (tag_id);
|
||||
|
||||
-- Task Dependencies (Layer 3)
|
||||
CREATE TABLE task_dependencies (
|
||||
task_id UUID NOT NULL REFERENCES tasks(id),
|
||||
blocks_task_id UUID NOT NULL REFERENCES tasks(id),
|
||||
PRIMARY KEY (task_id, blocks_task_id),
|
||||
CHECK (task_id != blocks_task_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_task_dependencies_task_id ON task_dependencies (task_id);
|
||||
CREATE INDEX idx_task_dependencies_blocks ON task_dependencies (blocks_task_id);
|
||||
|
||||
-- Task Reminders (Layer 2)
|
||||
CREATE TABLE task_reminders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
task_id UUID NOT NULL REFERENCES tasks(id),
|
||||
type TEXT NOT NULL CHECK (type IN ('push', 'email', 'webhook', 'telegram', 'nostr')),
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
scheduled_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_task_reminders_task_id ON task_reminders (task_id);
|
||||
CREATE INDEX idx_task_reminders_scheduled ON task_reminders (scheduled_at);
|
||||
|
||||
-- Task Webhooks (Layer 3)
|
||||
CREATE TABLE task_webhooks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id UUID NOT NULL REFERENCES users(id),
|
||||
url TEXT NOT NULL,
|
||||
events JSONB NOT NULL DEFAULT '[]',
|
||||
secret TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_task_webhooks_owner_id ON task_webhooks (owner_id);
|
||||
|
||||
Reference in New Issue
Block a user