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
|
SERVER_PORT=8080
|
||||||
ENV=development
|
ENV=development
|
||||||
|
|
||||||
# Base URL (used for public iCal feed URLs; defaults to http://localhost:$SERVER_PORT)
|
# Base URL for iCal feed URLs and availability URLs.
|
||||||
# BASE_URL=https://api.example.com
|
# 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 (comma-separated origins; defaults to localhost:5173 for dev)
|
||||||
# CORS_ORIGINS=https://app.example.com,https://www.example.com
|
# CORS_ORIGINS=https://app.example.com,https://www.example.com
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
log.Printf("BASE_URL=%s", cfg.BaseURL)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -86,19 +87,36 @@ func main() {
|
|||||||
reminderSvc := service.NewReminderService(queries, calSvc, sched)
|
reminderSvc := service.NewReminderService(queries, calSvc, sched)
|
||||||
attendeeSvc := service.NewAttendeeService(queries, calSvc)
|
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{
|
h := api.Handlers{
|
||||||
Auth: handlers.NewAuthHandler(authSvc, userSvc),
|
Auth: handlers.NewAuthHandler(authSvc, userSvc),
|
||||||
User: handlers.NewUserHandler(userSvc),
|
User: handlers.NewUserHandler(userSvc),
|
||||||
Calendar: handlers.NewCalendarHandler(calSvc),
|
Calendar: handlers.NewCalendarHandler(calSvc),
|
||||||
Sharing: handlers.NewSharingHandler(calSvc),
|
Sharing: handlers.NewSharingHandler(calSvc),
|
||||||
Event: handlers.NewEventHandler(eventSvc),
|
Event: handlers.NewEventHandler(eventSvc),
|
||||||
Reminder: handlers.NewReminderHandler(reminderSvc),
|
Reminder: handlers.NewReminderHandler(reminderSvc),
|
||||||
Attendee: handlers.NewAttendeeHandler(attendeeSvc),
|
Attendee: handlers.NewAttendeeHandler(attendeeSvc),
|
||||||
Contact: handlers.NewContactHandler(contactSvc),
|
Contact: handlers.NewContactHandler(contactSvc),
|
||||||
Availability: handlers.NewAvailabilityHandler(availSvc),
|
Availability: handlers.NewAvailabilityHandler(availSvc),
|
||||||
Booking: handlers.NewBookingHandler(bookingSvc),
|
Booking: handlers.NewBookingHandler(bookingSvc),
|
||||||
APIKey: handlers.NewAPIKeyHandler(apiKeySvc),
|
APIKey: handlers.NewAPIKeyHandler(apiKeySvc),
|
||||||
ICS: handlers.NewICSHandler(calSvc, eventSvc, queries),
|
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)
|
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>" />
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Calendar</title>
|
<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/vendor-BI0H_s0W.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/date-huy51PAD.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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Login from "./pages/Login";
|
|||||||
import Register from "./pages/Register";
|
import Register from "./pages/Register";
|
||||||
import CalendarPage from "./pages/CalendarPage";
|
import CalendarPage from "./pages/CalendarPage";
|
||||||
import ContactsPage from "./pages/ContactsPage";
|
import ContactsPage from "./pages/ContactsPage";
|
||||||
|
import TodoPage from "./pages/TodoPage";
|
||||||
import SettingsPage from "./pages/SettingsPage";
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
|
|
||||||
function ProtectedRoute() {
|
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" />
|
<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>
|
</svg>
|
||||||
</NavLink>
|
</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
|
<NavLink
|
||||||
to="/contacts"
|
to="/contacts"
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
@@ -104,6 +120,7 @@ export default function App() {
|
|||||||
<Route element={<ProtectedRoute />}>
|
<Route element={<ProtectedRoute />}>
|
||||||
<Route element={<AppLayout />}>
|
<Route element={<AppLayout />}>
|
||||||
<Route path="/" element={<CalendarPage />} />
|
<Route path="/" element={<CalendarPage />} />
|
||||||
|
<Route path="/todo" element={<TodoPage />} />
|
||||||
<Route path="/contacts" element={<ContactsPage />} />
|
<Route path="/contacts" element={<ContactsPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import type {
|
|||||||
Contact,
|
Contact,
|
||||||
ListResponse,
|
ListResponse,
|
||||||
PageInfo,
|
PageInfo,
|
||||||
|
Project,
|
||||||
|
Tag,
|
||||||
|
Task,
|
||||||
User,
|
User,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
@@ -295,4 +298,93 @@ export const api = {
|
|||||||
|
|
||||||
deleteContact: (id: string) =>
|
deleteContact: (id: string) =>
|
||||||
request<{ ok: boolean }>(`/contacts/${id}`, { method: "DELETE" }),
|
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")[];
|
calendars?: ("read" | "write")[];
|
||||||
events?: ("read" | "write")[];
|
events?: ("read" | "write")[];
|
||||||
contacts?: ("read" | "write")[];
|
contacts?: ("read" | "write")[];
|
||||||
|
tasks?: ("read" | "write")[];
|
||||||
availability?: ("read")[];
|
availability?: ("read")[];
|
||||||
booking?: ("write")[];
|
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": "Availability", "description": "Calendar availability queries" },
|
||||||
{ "name": "Booking", "description": "Public booking links and reservations" },
|
{ "name": "Booking", "description": "Public booking links and reservations" },
|
||||||
{ "name": "ICS", "description": "ICS calendar import and export" },
|
{ "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": [
|
"security": [
|
||||||
{ "BearerAuth": [] },
|
{ "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" },
|
"start": { "type": "string", "format": "date-time" },
|
||||||
"end": { "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 {
|
type Handlers struct {
|
||||||
Auth *handlers.AuthHandler
|
Auth *handlers.AuthHandler
|
||||||
User *handlers.UserHandler
|
User *handlers.UserHandler
|
||||||
Calendar *handlers.CalendarHandler
|
Calendar *handlers.CalendarHandler
|
||||||
Sharing *handlers.SharingHandler
|
Sharing *handlers.SharingHandler
|
||||||
Event *handlers.EventHandler
|
Event *handlers.EventHandler
|
||||||
Reminder *handlers.ReminderHandler
|
Reminder *handlers.ReminderHandler
|
||||||
Attendee *handlers.AttendeeHandler
|
Attendee *handlers.AttendeeHandler
|
||||||
Contact *handlers.ContactHandler
|
Contact *handlers.ContactHandler
|
||||||
Availability *handlers.AvailabilityHandler
|
Availability *handlers.AvailabilityHandler
|
||||||
Booking *handlers.BookingHandler
|
Booking *handlers.BookingHandler
|
||||||
APIKey *handlers.APIKeyHandler
|
APIKey *handlers.APIKeyHandler
|
||||||
ICS *handlers.ICSHandler
|
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 {
|
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
|
// Availability
|
||||||
r.With(mw.RequireScope("availability", "read")).Get("/availability", h.Availability.Get)
|
r.With(mw.RequireScope("availability", "read")).Get("/availability", h.Availability.Get)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -21,7 +22,7 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
loadEnvFile(".env")
|
loadEnvFromPath()
|
||||||
|
|
||||||
port := getEnv("SERVER_PORT", "8080")
|
port := getEnv("SERVER_PORT", "8080")
|
||||||
jwtSecret := getEnv("JWT_SECRET", "dev-secret-change-me")
|
jwtSecret := getEnv("JWT_SECRET", "dev-secret-change-me")
|
||||||
@@ -41,7 +42,7 @@ func Load() *Config {
|
|||||||
RedisAddr: os.Getenv("REDIS_ADDR"),
|
RedisAddr: os.Getenv("REDIS_ADDR"),
|
||||||
ServerPort: port,
|
ServerPort: port,
|
||||||
Env: env,
|
Env: env,
|
||||||
BaseURL: getEnv("BASE_URL", "http://localhost:"+port),
|
BaseURL: strings.TrimSuffix(getEnv("BASE_URL", "http://localhost:"+port), "/"),
|
||||||
CORSOrigins: corsOrigins,
|
CORSOrigins: corsOrigins,
|
||||||
RateLimitRPS: rateRPS,
|
RateLimitRPS: rateRPS,
|
||||||
RateLimitBurst: rateBurst,
|
RateLimitBurst: rateBurst,
|
||||||
@@ -82,6 +83,24 @@ func parseInt(s string, def int) int {
|
|||||||
return v
|
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) {
|
func loadEnvFile(path string) {
|
||||||
f, err := os.Open(path)
|
f, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -79,6 +79,70 @@ type Attachment struct {
|
|||||||
FileURL string `json:"file_url"`
|
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 {
|
type Contact struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
FirstName *string `json:"first_name"`
|
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
|
FROM calendars c
|
||||||
JOIN calendar_members cm ON cm.calendar_id = c.id
|
JOIN calendar_members cm ON cm.calendar_id = c.id
|
||||||
WHERE cm.user_id = $1 AND c.deleted_at IS NULL
|
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 {
|
type ListCalendarsByUserRow struct {
|
||||||
|
|||||||
@@ -130,6 +130,25 @@ type EventReminder struct {
|
|||||||
MinutesBefore int32 `json:"minutes_before"`
|
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 {
|
type RefreshToken struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
UserID pgtype.UUID `json:"user_id"`
|
UserID pgtype.UUID `json:"user_id"`
|
||||||
@@ -139,6 +158,60 @@ type RefreshToken struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
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 {
|
type User struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
Email string `json:"email"`
|
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
|
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
|
const listSubscriptionsDueForSync = `-- name: ListSubscriptionsDueForSync :many
|
||||||
SELECT s.id, s.calendar_id, s.source_url, c.owner_id
|
SELECT s.id, s.calendar_id, s.source_url, c.owner_id
|
||||||
FROM calendar_subscriptions s
|
FROM calendar_subscriptions s
|
||||||
@@ -139,10 +128,15 @@ func (q *Queries) ListSubscriptionsDueForSync(ctx context.Context) ([]ListSubscr
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []ListSubscriptionsDueForSyncRow
|
items := []ListSubscriptionsDueForSyncRow{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i ListSubscriptionsDueForSyncRow
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, i)
|
||||||
@@ -152,3 +146,14 @@ func (q *Queries) ListSubscriptionsDueForSync(ctx context.Context) ([]ListSubscr
|
|||||||
}
|
}
|
||||||
return items, nil
|
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 TypeReminder = "reminder:send"
|
||||||
const TypeSubscriptionSync = "subscription:sync"
|
const TypeSubscriptionSync = "subscription:sync"
|
||||||
|
const TypeTaskReminder = "task_reminder:send"
|
||||||
|
const TypeRecurringTask = "recurring_task:generate"
|
||||||
|
|
||||||
type ReminderPayload struct {
|
type ReminderPayload struct {
|
||||||
EventID uuid.UUID `json:"event_id"`
|
EventID uuid.UUID `json:"event_id"`
|
||||||
@@ -54,10 +56,49 @@ func (s *Scheduler) ScheduleReminder(_ context.Context, eventID, reminderID, use
|
|||||||
return nil
|
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 {
|
type SubscriptionSyncPayload struct {
|
||||||
SubscriptionID string `json:"subscription_id"`
|
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 {
|
func (s *Scheduler) EnqueueSubscriptionSync(ctx context.Context, subscriptionID string) error {
|
||||||
payload, err := json.Marshal(SubscriptionSyncPayload{SubscriptionID: subscriptionID})
|
payload, err := json.Marshal(SubscriptionSyncPayload{SubscriptionID: subscriptionID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -112,4 +153,8 @@ func (NoopScheduler) ScheduleReminder(_ context.Context, _, _, _ uuid.UUID, _ ti
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (NoopScheduler) ScheduleTaskReminder(_ context.Context, _, _, _ uuid.UUID, _ time.Time) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (NoopScheduler) Close() 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
|
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 {
|
type SubscriptionSyncWorker struct {
|
||||||
syncer SubscriptionSyncer
|
syncer SubscriptionSyncer
|
||||||
}
|
}
|
||||||
@@ -82,6 +120,8 @@ func StartWorker(redisAddr string, worker *ReminderWorker, subSyncWorker *Subscr
|
|||||||
|
|
||||||
mux := asynq.NewServeMux()
|
mux := asynq.NewServeMux()
|
||||||
mux.HandleFunc(TypeReminder, worker.HandleReminderTask)
|
mux.HandleFunc(TypeReminder, worker.HandleReminderTask)
|
||||||
|
mux.HandleFunc(TypeTaskReminder, worker.HandleTaskReminder)
|
||||||
|
mux.HandleFunc(TypeRecurringTask, worker.HandleRecurringTask)
|
||||||
if subSyncWorker != nil {
|
if subSyncWorker != nil {
|
||||||
mux.HandleFunc(TypeSubscriptionSync, subSyncWorker.HandleSubscriptionSync)
|
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}
|
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 {
|
func NullPgTimestamptz() pgtype.Timestamptz {
|
||||||
return pgtype.Timestamptz{Valid: false}
|
return pgtype.Timestamptz{Valid: false}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,3 +95,37 @@ func ValidateRecurrenceRangeLimit(start, end time.Time) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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);
|
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