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

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

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

View File

@@ -11,8 +11,11 @@ JWT_SECRET=dev-secret-change-me
SERVER_PORT=8080
ENV=development
# Base URL (used for public iCal feed URLs; defaults to http://localhost:$SERVER_PORT)
# BASE_URL=https://api.example.com
# Base URL for iCal feed URLs and availability URLs.
# Defaults to http://localhost:$SERVER_PORT if unset.
# Set to your public API URL for Google Calendar and external subscriptions.
# Example: BASE_URL=https://api.example.com
BASE_URL=http://localhost:8080
# CORS (comma-separated origins; defaults to localhost:5173 for dev)
# CORS_ORIGINS=https://app.example.com,https://www.example.com

View File

@@ -25,6 +25,7 @@ import (
func main() {
cfg := config.Load()
log.Printf("BASE_URL=%s", cfg.BaseURL)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -86,19 +87,36 @@ func main() {
reminderSvc := service.NewReminderService(queries, calSvc, sched)
attendeeSvc := service.NewAttendeeService(queries, calSvc)
taskWebhookSvc := service.NewTaskWebhookService(queries)
taskSvc := service.NewTaskService(queries, auditSvc, taskWebhookSvc)
projectSvc := service.NewProjectService(queries, auditSvc)
tagSvc := service.NewTagService(queries)
taskDepSvc := service.NewTaskDependencyService(queries)
var taskReminderScheduler service.TaskReminderScheduler = scheduler.NoopScheduler{}
if realSched, ok := sched.(*scheduler.Scheduler); ok {
taskReminderScheduler = realSched
}
taskReminderSvc := service.NewTaskReminderService(queries, taskReminderScheduler)
h := api.Handlers{
Auth: handlers.NewAuthHandler(authSvc, userSvc),
User: handlers.NewUserHandler(userSvc),
Calendar: handlers.NewCalendarHandler(calSvc),
Sharing: handlers.NewSharingHandler(calSvc),
Event: handlers.NewEventHandler(eventSvc),
Reminder: handlers.NewReminderHandler(reminderSvc),
Attendee: handlers.NewAttendeeHandler(attendeeSvc),
Contact: handlers.NewContactHandler(contactSvc),
Availability: handlers.NewAvailabilityHandler(availSvc),
Booking: handlers.NewBookingHandler(bookingSvc),
APIKey: handlers.NewAPIKeyHandler(apiKeySvc),
ICS: handlers.NewICSHandler(calSvc, eventSvc, queries),
Auth: handlers.NewAuthHandler(authSvc, userSvc),
User: handlers.NewUserHandler(userSvc),
Calendar: handlers.NewCalendarHandler(calSvc),
Sharing: handlers.NewSharingHandler(calSvc),
Event: handlers.NewEventHandler(eventSvc),
Reminder: handlers.NewReminderHandler(reminderSvc),
Attendee: handlers.NewAttendeeHandler(attendeeSvc),
Contact: handlers.NewContactHandler(contactSvc),
Availability: handlers.NewAvailabilityHandler(availSvc),
Booking: handlers.NewBookingHandler(bookingSvc),
APIKey: handlers.NewAPIKeyHandler(apiKeySvc),
ICS: handlers.NewICSHandler(calSvc, eventSvc, queries),
Task: handlers.NewTaskHandler(taskSvc),
Project: handlers.NewProjectHandler(projectSvc),
Tag: handlers.NewTagHandler(tagSvc),
TaskDependency: handlers.NewTaskDependencyHandler(taskDepSvc),
TaskReminder: handlers.NewTaskReminderHandler(taskReminderSvc),
TaskWebhook: handlers.NewTaskWebhookHandler(taskWebhookSvc),
}
authMW := middleware.NewAuthMiddleware(jwtManager, queries)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

43
frontend/dist/assets/index-DXAGcZdG.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -5,10 +5,10 @@
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📅</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Calendar</title>
<script type="module" crossorigin src="/assets/index-DLhHVFKH.js"></script>
<script type="module" crossorigin src="/assets/index-DXAGcZdG.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-BI0H_s0W.js">
<link rel="modulepreload" crossorigin href="/assets/date-huy51PAD.js">
<link rel="stylesheet" crossorigin href="/assets/index-D1jwjSlk.css">
<link rel="stylesheet" crossorigin href="/assets/index-C8iK5eUL.css">
</head>
<body>
<div id="root"></div>

View File

@@ -4,6 +4,7 @@ import Login from "./pages/Login";
import Register from "./pages/Register";
import CalendarPage from "./pages/CalendarPage";
import ContactsPage from "./pages/ContactsPage";
import TodoPage from "./pages/TodoPage";
import SettingsPage from "./pages/SettingsPage";
function ProtectedRoute() {
@@ -53,6 +54,21 @@ function AppLayout() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</NavLink>
<NavLink
to="/todo"
className={({ isActive }) =>
`p-2.5 rounded-xl transition-colors ${
isActive
? "bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400"
: "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300"
}`
}
title="To-do"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
</NavLink>
<NavLink
to="/contacts"
className={({ isActive }) =>
@@ -104,6 +120,7 @@ export default function App() {
<Route element={<ProtectedRoute />}>
<Route element={<AppLayout />}>
<Route path="/" element={<CalendarPage />} />
<Route path="/todo" element={<TodoPage />} />
<Route path="/contacts" element={<ContactsPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>

View File

@@ -9,6 +9,9 @@ import type {
Contact,
ListResponse,
PageInfo,
Project,
Tag,
Task,
User,
} from "./types";
@@ -295,4 +298,93 @@ export const api = {
deleteContact: (id: string) =>
request<{ ok: boolean }>(`/contacts/${id}`, { method: "DELETE" }),
// Tasks
listTasks: (params?: {
status?: string;
priority?: string;
project_id?: string;
due_from?: string;
due_to?: string;
limit?: number;
cursor?: string;
}) => {
const sp = new URLSearchParams({ limit: "100" });
if (params?.status) sp.set("status", params.status);
if (params?.priority) sp.set("priority", params.priority);
if (params?.project_id) sp.set("project_id", params.project_id);
if (params?.due_from) sp.set("due_from", params.due_from);
if (params?.due_to) sp.set("due_to", params.due_to);
if (params?.limit) sp.set("limit", String(params.limit));
if (params?.cursor) sp.set("cursor", params.cursor);
return request<ListResponse<Task>>(`/tasks?${sp}`);
},
createTask: (data: {
title: string;
description?: string;
status?: string;
priority?: string;
due_date?: string;
project_id?: string;
parent_id?: string;
}) =>
request<{ task: Task }>("/tasks", {
method: "POST",
body: JSON.stringify(data),
}),
getTask: (id: string) => request<{ task: Task }>(`/tasks/${id}`),
updateTask: (id: string, data: Partial<Task>) =>
request<{ task: Task }>(`/tasks/${id}`, {
method: "PUT",
body: JSON.stringify(data),
}),
deleteTask: (id: string, permanent?: boolean) => {
const q = permanent ? "?permanent=true" : "";
return request<{ ok: boolean }>(`/tasks/${id}${q}`, { method: "DELETE" });
},
markTaskComplete: (id: string) =>
request<{ task: Task }>(`/tasks/${id}/complete`, { method: "POST" }),
markTaskUncomplete: (id: string, status?: string) =>
request<{ task: Task }>(`/tasks/${id}/uncomplete`, {
method: "POST",
body: JSON.stringify(status ? { status } : {}),
}),
listTaskSubtasks: (id: string) =>
request<ListResponse<Task>>(`/tasks/${id}/subtasks`),
// Projects
listProjects: () => request<ListResponse<Project>>("/projects"),
createProject: (data: { name: string; color?: string }) =>
request<{ project: Project }>("/projects", {
method: "POST",
body: JSON.stringify(data),
}),
getProject: (id: string) => request<{ project: Project }>(`/projects/${id}`),
updateProject: (id: string, data: Partial<Project>) =>
request<{ project: Project }>(`/projects/${id}`, {
method: "PUT",
body: JSON.stringify(data),
}),
deleteProject: (id: string) =>
request<{ ok: boolean }>(`/projects/${id}`, { method: "DELETE" }),
// Tags
listTags: () => request<ListResponse<Tag>>("/tags"),
createTag: (data: { name: string; color?: string }) =>
request<{ tag: Tag }>("/tags", {
method: "POST",
body: JSON.stringify(data),
}),
};

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -137,6 +137,48 @@ export type APIKeyScopes = {
calendars?: ("read" | "write")[];
events?: ("read" | "write")[];
contacts?: ("read" | "write")[];
tasks?: ("read" | "write")[];
availability?: ("read")[];
booking?: ("write")[];
};
export type TaskStatus = "todo" | "in_progress" | "done" | "archived";
export type TaskPriority = "low" | "medium" | "high" | "critical";
export interface Task {
id: string;
title: string;
description?: string;
status: TaskStatus;
priority: TaskPriority;
due_date?: string;
completed_at?: string;
created_at: string;
updated_at: string;
owner_id: string;
project_id?: string;
parent_id?: string;
subtasks?: Task[];
tags?: Tag[];
completion_percentage?: number;
}
export interface Project {
id: string;
owner_id: string;
name: string;
color: string;
is_shared: boolean;
deadline?: string;
sort_order?: number;
created_at: string;
updated_at: string;
}
export interface Tag {
id: string;
owner_id: string;
name: string;
color: string;
created_at: string;
}

View File

@@ -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"}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;

View 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

Binary file not shown.

54
sqlc/queries/projects.sql Normal file
View 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
View 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;

View 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;

View 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;

View 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
View 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;

View File

@@ -197,3 +197,112 @@ CREATE TABLE calendar_subscriptions (
);
CREATE INDEX idx_calendar_subscriptions_calendar_id ON calendar_subscriptions (calendar_id);
-- Projects (Layer 2)
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#3B82F6',
is_shared BOOLEAN NOT NULL DEFAULT false,
deadline TIMESTAMPTZ,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_projects_owner_id ON projects (owner_id);
-- Project Members (Layer 2 shared projects)
CREATE TABLE project_members (
project_id UUID NOT NULL REFERENCES projects(id),
user_id UUID NOT NULL REFERENCES users(id),
role TEXT NOT NULL CHECK (role IN ('owner', 'editor', 'viewer')),
PRIMARY KEY (project_id, user_id)
);
CREATE INDEX idx_project_members_project_id ON project_members (project_id);
-- Tags (Layer 2)
CREATE TABLE tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#6B7280',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_tags_owner_id ON tags (owner_id);
-- Tasks (Layer 1 core)
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id),
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'todo' CHECK (status IN ('todo', 'in_progress', 'done', 'archived')),
priority TEXT NOT NULL DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high', 'critical')),
due_date TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
project_id UUID REFERENCES projects(id),
parent_id UUID REFERENCES tasks(id),
sort_order INTEGER NOT NULL DEFAULT 0,
recurrence_rule TEXT
);
CREATE INDEX idx_tasks_owner_id ON tasks (owner_id);
CREATE INDEX idx_tasks_status ON tasks (owner_id, status) WHERE deleted_at IS NULL;
CREATE INDEX idx_tasks_priority ON tasks (owner_id, priority) WHERE deleted_at IS NULL;
CREATE INDEX idx_tasks_due_date ON tasks (owner_id, due_date) WHERE deleted_at IS NULL;
CREATE INDEX idx_tasks_project_id ON tasks (project_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_tasks_parent_id ON tasks (parent_id) WHERE deleted_at IS NULL;
-- Task Tags (many-to-many)
CREATE TABLE task_tags (
task_id UUID NOT NULL REFERENCES tasks(id),
tag_id UUID NOT NULL REFERENCES tags(id),
PRIMARY KEY (task_id, tag_id)
);
CREATE INDEX idx_task_tags_task_id ON task_tags (task_id);
CREATE INDEX idx_task_tags_tag_id ON task_tags (tag_id);
-- Task Dependencies (Layer 3)
CREATE TABLE task_dependencies (
task_id UUID NOT NULL REFERENCES tasks(id),
blocks_task_id UUID NOT NULL REFERENCES tasks(id),
PRIMARY KEY (task_id, blocks_task_id),
CHECK (task_id != blocks_task_id)
);
CREATE INDEX idx_task_dependencies_task_id ON task_dependencies (task_id);
CREATE INDEX idx_task_dependencies_blocks ON task_dependencies (blocks_task_id);
-- Task Reminders (Layer 2)
CREATE TABLE task_reminders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID NOT NULL REFERENCES tasks(id),
type TEXT NOT NULL CHECK (type IN ('push', 'email', 'webhook', 'telegram', 'nostr')),
config JSONB NOT NULL DEFAULT '{}',
scheduled_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_task_reminders_task_id ON task_reminders (task_id);
CREATE INDEX idx_task_reminders_scheduled ON task_reminders (scheduled_at);
-- Task Webhooks (Layer 3)
CREATE TABLE task_webhooks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id),
url TEXT NOT NULL,
events JSONB NOT NULL DEFAULT '[]',
secret TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_task_webhooks_owner_id ON task_webhooks (owner_id);