8Space uses Supabase PostgREST for CRUD operations on tasks, assignees, labels, checklists, attachments, and dependencies.
Types
packages/app/src/domain/types.ts
export type TaskPriority = 'p0' | 'p1' | 'p2';
export type DependencyType = 'FS';
export interface Task {
id: string;
projectId: string;
title: string;
statusColumnId: string;
assignees: UserProfile[];
dueDate?: string | null;
startDate?: string | null;
priority: TaskPriority;
orderRank: number;
description?: string | null;
tags: TaskLabel[];
checklist: TaskChecklistItem[];
attachments: TaskAttachment[];
estimate?: number | null;
completedAt?: string | null;
isMilestone: boolean;
createdAt: string;
updatedAt: string;
}
export interface TaskLabel {
id: string;
projectId: string;
name: string;
color: string;
}
export interface TaskChecklistItem {
id: string;
taskId: string;
title: string;
isDone: boolean;
position: number;
}
export interface TaskAttachment {
id: string;
taskId: string;
url: string;
title?: string | null;
createdAt: string;
}
export interface TaskDependency {
id: string;
projectId: string;
predecessorTaskId: string;
successorTaskId: string;
type: DependencyType;
}
List Tasks
Query tasks for a project with full hydration including assignees, labels, checklists, and attachments.
packages/app/src/domain/repositories/supabase.ts
async listTasks(projectId: string): Promise<Task[]> {
// Fetch task rows
const { data, error } = await supabase
.from('tasks')
.select(
'id,project_id,title,status_column_id,start_date,due_date,priority,order_rank,description,estimate,is_milestone,completed_at,created_at,updated_at'
)
.eq('project_id', projectId)
.order('order_rank', { ascending: true });
if (error) throw error;
const taskRows = (data as TaskRow[] | null) ?? [];
const taskIds = taskRows.map(task => task.id);
// Hydrate with related data
const [assigneeResp, labelResp, checklistResp, attachmentResp] = await Promise.all([
supabase
.from('task_assignees')
.select('task_id,user_id,profile:profiles(id,display_name,avatar_url)')
.in('task_id', taskIds),
supabase
.from('task_label_links')
.select('task_id,label:task_labels(id,project_id,name,color)')
.in('task_id', taskIds),
supabase.from('task_checklist_items').select('id,task_id,title,is_done,position').in('task_id', taskIds),
supabase.from('task_attachments').select('id,task_id,url,title,created_at').in('task_id', taskIds),
]);
// Map results to Task objects...
}
Query Parameters
Filter by project UUID: eq.<uuid>
Sort order: order_rank.asc or order_rank.desc
PostgREST select clause for field projection
Response
Returns array of task objects with all embedded data.
Task start date (ISO 8601)
Priority level: p0, p1, or p2
Position for sorting tasks
Whether task is a milestone
Create Task
Create a new task with assignees.
packages/app/src/domain/repositories/supabase.ts
async createTask(input: CreateTaskInput): Promise<Task> {
if (!input.startDate || !input.dueDate) {
throw new Error('Start date and due date are required.');
}
if (input.dueDate < input.startDate) {
throw new Error('Due date must be the same or later than start date.');
}
const payload = {
project_id: input.projectId,
title: input.title,
status_column_id: input.statusColumnId,
due_date: input.dueDate,
start_date: input.startDate,
priority: input.priority ?? 'p1',
order_rank: input.orderRank ?? 1000,
description: input.description ?? null,
estimate: input.estimate ?? null,
is_milestone: input.isMilestone ?? false,
};
const { data, error } = await supabase
.from('tasks')
.insert(payload)
.select(
'id,project_id,title,status_column_id,start_date,due_date,priority,order_rank,description,estimate,is_milestone,completed_at,created_at,updated_at'
)
.single();
if (error) throw error;
const createdTask = data as TaskRow;
// Assign users
if (input.assigneeIds && input.assigneeIds.length > 0) {
const links = input.assigneeIds.map(userId => ({ task_id: createdTask.id, user_id: userId }));
const { error: assigneeError } = await supabase.from('task_assignees').insert(links);
if (assigneeError) throw assigneeError;
}
return fetchTaskById(createdTask.project_id, createdTask.id);
}
Request Body
Start date (ISO 8601 date format)
Due date (ISO 8601 date format)
Position for sorting (default: 1000)
Milestone flag (default: false)
Response
Returns the created task with status 201.
Update Task
Update task fields inline.
packages/app/src/domain/repositories/supabase.ts
async updateTaskInline(input: UpdateTaskInlineInput): Promise<Task> {
const { data: currentTask, error: currentError } = await supabase
.from('tasks')
.select('id,project_id')
.eq('id', input.taskId)
.single();
if (currentError) throw currentError;
const taskRef = currentTask as { id: string; project_id: string };
const updatePayload: Record<string, unknown> = {};
if (input.title !== undefined) updatePayload.title = input.title;
if (input.statusColumnId !== undefined) updatePayload.status_column_id = input.statusColumnId;
if (input.dueDate !== undefined) updatePayload.due_date = input.dueDate;
if (input.startDate !== undefined) updatePayload.start_date = input.startDate;
if (input.priority !== undefined) updatePayload.priority = input.priority;
if (input.orderRank !== undefined) updatePayload.order_rank = input.orderRank;
if (input.description !== undefined) updatePayload.description = input.description;
if (input.estimate !== undefined) updatePayload.estimate = input.estimate;
if (input.completedAt !== undefined) updatePayload.completed_at = input.completedAt;
if (input.isMilestone !== undefined) updatePayload.is_milestone = input.isMilestone;
if (Object.keys(updatePayload).length > 0) {
const { error: updateError } = await supabase.from('tasks').update(updatePayload).eq('id', input.taskId);
if (updateError) throw updateError;
}
// Update assignees if provided
if (input.assigneeIds !== undefined) {
const { error: clearError } = await supabase.from('task_assignees').delete().eq('task_id', input.taskId);
if (clearError) throw clearError;
if (input.assigneeIds.length > 0) {
const links = input.assigneeIds.map(userId => ({ task_id: input.taskId, user_id: userId }));
const { error: assignError } = await supabase.from('task_assignees').insert(links);
if (assignError) throw assignError;
}
}
return fetchTaskById(taskRef.project_id, input.taskId);
}
Query Parameters
Task UUID filter: eq.<uuid>
Request Body
All fields are optional. Only provided fields will be updated.
Updated workflow column UUID
Response
Returns status 204 (no content) on success.
Delete Task
Delete a task by ID.
packages/app/src/domain/repositories/supabase.ts
async deleteTask(taskId: string): Promise<void> {
const { error } = await supabase.from('tasks').delete().eq('id', taskId);
if (error) throw error;
}
Query Parameters
Task UUID filter: eq.<uuid>
Response
Returns status 204 (no content) on success.
Manage Task Assignees
Query Assignees
const { data, error } = await supabase
.from('task_assignees')
.select('task_id,user_id,profile:profiles(id,display_name,avatar_url)')
.in('task_id', taskIds);
Add Assignees
const links = assigneeIds.map(userId => ({ task_id: taskId, user_id: userId }));
const { error } = await supabase.from('task_assignees').insert(links);
Remove Assignees
const { error } = await supabase.from('task_assignees').delete().eq('task_id', taskId);
Task Dependencies
List Dependencies
packages/app/src/domain/repositories/supabase.ts
async listDependencies(projectId: string): Promise<TaskDependency[]> {
const { data, error } = await supabase
.from('task_dependencies')
.select('id,project_id,predecessor_task_id,successor_task_id,type')
.eq('project_id', projectId);
if (error) throw error;
const rows = (data as TaskDependencyRow[] | null) ?? [];
return rows.map(row => ({
id: row.id,
projectId: row.project_id,
predecessorTaskId: row.predecessor_task_id,
successorTaskId: row.successor_task_id,
type: row.type,
}));
}
Set Dependencies
Replace all dependencies for a successor task.
packages/app/src/domain/repositories/supabase.ts
async setTaskDependencies(
projectId: string,
successorTaskId: string,
predecessorTaskIds: string[],
type: DependencyType = 'FS'
): Promise<void> {
// Clear existing dependencies
const { error: deleteError } = await supabase
.from('task_dependencies')
.delete()
.eq('project_id', projectId)
.eq('successor_task_id', successorTaskId);
if (deleteError) throw deleteError;
if (predecessorTaskIds.length === 0) return;
// Insert new dependencies
const rows = predecessorTaskIds.map(predecessorTaskId => ({
project_id: projectId,
predecessor_task_id: predecessorTaskId,
successor_task_id: successorTaskId,
type,
}));
const { error: insertError } = await supabase.from('task_dependencies').insert(rows);
if (insertError) throw insertError;
}
React Hook Usage
packages/app/src/hooks/use-project-data.ts
import { useQuery, useMutation } from '@tanstack/react-query';
import { taskRepository } from '@/domain/repositories';
// List tasks
export function useTasks(projectId: string | undefined) {
return useQuery({
queryKey: ['projects', projectId, 'tasks'],
queryFn: async () => {
if (!projectId) return [];
return taskRepository.listTasks(projectId);
},
enabled: Boolean(projectId),
});
}
// Create task
export function useCreateTask(projectId: string | undefined) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: Omit<CreateTaskInput, 'projectId'>) => {
if (!projectId) throw new Error('Project is required');
return taskRepository.createTask({ ...input, projectId });
},
onSettled: async () => {
if (!projectId) return;
await queryClient.invalidateQueries({
queryKey: ['projects', projectId, 'tasks']
});
},
});
}
// Update task with optimistic update
export function useUpdateTaskInline(projectId: string | undefined) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: UpdateTaskInlineInput) => taskRepository.updateTaskInline(input),
onMutate: async (input) => {
if (!projectId) return { previous: undefined };
const key = ['projects', projectId, 'tasks'];
await queryClient.cancelQueries({ queryKey: key });
const previous = queryClient.getQueryData<Task[]>(key);
queryClient.setQueryData<Task[]>(key, (current) => {
if (!current) return current;
return current.map(task =>
task.id !== input.taskId ? task : { ...task, ...input }
);
});
return { previous };
},
onError: (_error, _variables, context) => {
if (!projectId || !context?.previous) return;
queryClient.setQueryData(['projects', projectId, 'tasks'], context.previous);
},
onSettled: async () => {
if (!projectId) return;
await queryClient.invalidateQueries({
queryKey: ['projects', projectId, 'tasks']
});
},
});
}
// Delete task
export function useDeleteTask(projectId: string | undefined) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (taskId: string) => {
await taskRepository.deleteTask(taskId);
},
onSettled: async () => {
if (!projectId) return;
await queryClient.invalidateQueries({
queryKey: ['projects', projectId, 'tasks']
});
},
});
}