Skip to main content
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

project_id
string
required
Filter by project UUID: eq.<uuid>
order
string
Sort order: order_rank.asc or order_rank.desc
select
string
PostgREST select clause for field projection

Response

Returns array of task objects with all embedded data.
id
string
Task UUID
project_id
string
Parent project UUID
title
string
Task title
status_column_id
string
Workflow column UUID
start_date
string | null
Task start date (ISO 8601)
due_date
string | null
Task due date (ISO 8601)
priority
TaskPriority
Priority level: p0, p1, or p2
order_rank
number
Position for sorting tasks
description
string | null
Task description
estimate
number | null
Time estimate
is_milestone
boolean
Whether task is a milestone
completed_at
string | null
Completion timestamp
created_at
string
Creation timestamp
updated_at
string
Last update timestamp

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

project_id
string
required
Project UUID
title
string
required
Task title
status_column_id
string
required
Workflow column UUID
start_date
string
required
Start date (ISO 8601 date format)
due_date
string
required
Due date (ISO 8601 date format)
priority
TaskPriority
Priority (default: p1)
order_rank
number
Position for sorting (default: 1000)
description
string | null
Task description
estimate
number | null
Time estimate
is_milestone
boolean
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

id
string
required
Task UUID filter: eq.<uuid>

Request Body

All fields are optional. Only provided fields will be updated.
title
string
Updated task title
status_column_id
string
Updated workflow column UUID
due_date
string | null
Updated due date
start_date
string | null
Updated start date
priority
TaskPriority
Updated priority
order_rank
number
Updated position
description
string | null
Updated description
estimate
number | null
Updated estimate
completed_at
string | null
Completion timestamp
is_milestone
boolean
Milestone flag

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

id
string
required
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']
      });
    },
  });
}

Build docs developers (and LLMs) love