> ## Documentation Index
> Fetch the complete documentation index at: https://docs.8space.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Projects

> PostgREST endpoints for project and tenant management in 8Space

8Space uses Supabase PostgREST and RPC functions to manage projects, tenants, and memberships.

## Types

```typescript packages/app/src/domain/types.ts theme={null}
export type ProjectRole = 'owner' | 'editor' | 'viewer';
export type TenantRole = 'owner' | 'admin' | 'member';

export interface Project {
  id: string;
  tenantId: string;
  name: string;
  description?: string | null;
  createdBy: string;
  createdAt: string;
  archivedAt?: string | null;
  role: ProjectRole;
}

export interface Tenant {
  id: string;
  name: string;
  slug: string;
  role: TenantRole;
}

export interface ProjectMember {
  projectId: string;
  userId: string;
  role: ProjectRole;
  profile?: UserProfile;
}

export interface UserProfile {
  id: string;
  displayName: string;
  avatarUrl?: string | null;
}
```

## List Tenants

Query tenants for the current user.

```typescript packages/app/src/domain/repositories/supabase.ts theme={null}
async listTenants(userId: string): Promise<Tenant[]> {
  const { data, error } = await supabase
    .from('tenant_members')
    .select('role,tenant:tenants!inner(id,name,slug,archived_at)')
    .eq('user_id', userId)
    .is('tenant.archived_at', null);

  if (error) throw error;
  return data.map(row => mapTenant(unwrapOne(row.tenant), row.role));
}
```

### Query Parameters

<ParamField query="user_id" type="string" required>
  Filter by user UUID using `eq.<uuid>`
</ParamField>

<ParamField query="select" type="string">
  PostgREST select clause. Use embedded resources with `!inner` for joins.
</ParamField>

### Response

<ResponseField name="role" type="TenantRole">
  User's role in the tenant (`owner`, `admin`, `member`)
</ResponseField>

<ResponseField name="tenant" type="object">
  Embedded tenant object

  <Expandable title="properties">
    <ResponseField name="id" type="string">
      Tenant UUID
    </ResponseField>

    <ResponseField name="name" type="string">
      Tenant display name
    </ResponseField>

    <ResponseField name="slug" type="string">
      URL-safe tenant identifier
    </ResponseField>

    <ResponseField name="archived_at" type="string | null">
      Timestamp when tenant was archived
    </ResponseField>
  </Expandable>
</ResponseField>

## Create Tenant with Owner

Create a new tenant and assign the current user as owner using RPC.

```typescript packages/app/src/domain/repositories/supabase.ts theme={null}
async createTenantWithOwner(name: string, preferredSlug?: string): Promise<Tenant> {
  const { data, error } = await supabase.rpc('create_tenant_with_owner', {
    p_name: name,
    p_slug: preferredSlug ?? null,
  });

  if (error) throw error;
  return mapTenant(data as TenantRow, 'owner');
}
```

### Request Body

<ParamField body="p_name" type="string" required>
  Tenant name
</ParamField>

<ParamField body="p_slug" type="string | null">
  Preferred URL slug (auto-generated if null)
</ParamField>

### Response

Returns the created tenant row with the current user as owner.

## List Projects

Query projects for the current user within a tenant.

```typescript packages/app/src/domain/repositories/supabase.ts theme={null}
async listProjects(userId: string, tenantSlug: string): Promise<Project[]> {
  // First, get tenant by slug
  const { data: membershipData, error: membershipError } = await supabase
    .from('tenant_members')
    .select('role,tenant:tenants!inner(id,name,slug,archived_at)')
    .eq('user_id', userId)
    .eq('tenant.slug', tenantSlug)
    .is('tenant.archived_at', null)
    .maybeSingle();

  if (membershipError) throw membershipError;
  const tenant = unwrapOne(membershipData?.tenant);
  if (!tenant) return [];

  // Then, get projects
  const { data, error } = await supabase
    .from('project_members')
    .select('role,project:projects!inner(id,tenant_id,name,description,created_by,created_at,archived_at)')
    .eq('user_id', userId)
    .eq('project.tenant_id', tenant.id)
    .is('project.archived_at', null);

  if (error) throw error;
  return data.map(row => mapProject(unwrapOne(row.project), row.role));
}
```

### Query Parameters

<ParamField query="user_id" type="string" required>
  User UUID filter: `eq.<uuid>`
</ParamField>

<ParamField query="project_id" type="string">
  Project UUID filter: `eq.<uuid>`
</ParamField>

<ParamField query="select" type="string">
  PostgREST select clause with embedded resources
</ParamField>

### Response

<ResponseField name="role" type="ProjectRole">
  User's role in the project
</ResponseField>

<ResponseField name="project" type="object">
  Embedded project object

  <Expandable title="properties">
    <ResponseField name="id" type="string">
      Project UUID
    </ResponseField>

    <ResponseField name="tenant_id" type="string">
      Parent tenant UUID
    </ResponseField>

    <ResponseField name="name" type="string">
      Project name
    </ResponseField>

    <ResponseField name="description" type="string | null">
      Project description
    </ResponseField>

    <ResponseField name="created_by" type="string">
      Creator user UUID
    </ResponseField>

    <ResponseField name="created_at" type="string">
      ISO 8601 timestamp
    </ResponseField>

    <ResponseField name="archived_at" type="string | null">
      Archived timestamp
    </ResponseField>
  </Expandable>
</ResponseField>

## Create Project with Defaults

Create a new project with default workflow columns using RPC.

```typescript packages/app/src/domain/repositories/supabase.ts theme={null}
async createProjectWithDefaults(tenantSlug: string, input: CreateProjectInput): Promise<Project> {
  const { data: userResult, error: userError } = await supabase.auth.getUser();
  if (userError) throw userError;

  const userId = userResult.user?.id;
  if (!userId) throw new Error('Not authenticated');

  const { data, error } = await supabase.rpc('create_project_with_defaults', {
    p_tenant_slug: tenantSlug,
    p_name: input.name,
    p_description: input.description ?? null,
  });

  if (error) throw error;

  const projectId = data as string;
  const projects = await this.listProjects(userId, tenantSlug);
  return projects.find(p => p.id === projectId)!;
}
```

### Request Body

<ParamField body="p_tenant_slug" type="string" required>
  Tenant slug identifier
</ParamField>

<ParamField body="p_name" type="string" required>
  Project name
</ParamField>

<ParamField body="p_description" type="string | null">
  Project description
</ParamField>

### Response

Returns the created project UUID as a string.

## Get Project Members

List all members of a project with their profiles.

```typescript packages/app/src/domain/repositories/supabase.ts theme={null}
async getProjectMembers(projectId: string): Promise<ProjectMember[]> {
  const { data, error } = await supabase
    .from('project_members')
    .select('project_id,user_id,role,profile:profiles(id,display_name,avatar_url)')
    .eq('project_id', projectId);

  if (error) throw error;

  return data.map(row => ({
    projectId: row.project_id,
    userId: row.user_id,
    role: row.role,
    profile: mapProfile(unwrapOne(row.profile)),
  }));
}
```

### Query Parameters

<ParamField query="project_id" type="string" required>
  Filter by project UUID: `eq.<uuid>`
</ParamField>

<ParamField query="select" type="string">
  Include embedded profile data
</ParamField>

### Response

Returns array of project member objects.

<ResponseField name="project_id" type="string">
  Project UUID
</ResponseField>

<ResponseField name="user_id" type="string">
  User UUID
</ResponseField>

<ResponseField name="role" type="ProjectRole">
  Member role in project
</ResponseField>

<ResponseField name="profile" type="object">
  User profile data

  <Expandable title="properties">
    <ResponseField name="id" type="string">
      User UUID
    </ResponseField>

    <ResponseField name="display_name" type="string">
      Display name
    </ResponseField>

    <ResponseField name="avatar_url" type="string | null">
      Avatar URL
    </ResponseField>
  </Expandable>
</ResponseField>

## Update Project Settings

Update project name and description.

```typescript packages/app/src/domain/repositories/supabase.ts theme={null}
async updateProjectSettings(projectId: string, input: Pick<Project, 'name' | 'description'>): Promise<Project> {
  const { data: updateData, error } = await supabase
    .from('projects')
    .update({
      name: input.name,
      description: input.description ?? null,
    })
    .eq('id', projectId)
    .select('id,tenant_id,name,description,created_by,created_at,archived_at')
    .single();

  if (error) throw error;

  const updatedProject = updateData as ProjectRow;

  // Get current user's role
  const { data: roleData, error: roleError } = await supabase.rpc('current_project_role', {
    p_project_id: projectId,
  });

  if (roleError) throw roleError;

  const role = (roleData ?? 'viewer') as ProjectRole;
  return mapProject(updatedProject, role);
}
```

### Query Parameters

<ParamField query="id" type="string" required>
  Project UUID filter: `eq.<uuid>`
</ParamField>

### Request Body

<ParamField body="name" type="string">
  Updated project name
</ParamField>

<ParamField body="description" type="string | null">
  Updated project description
</ParamField>

### Response

Returns array with updated project row(s).

## Get Current Project Role

Query the current user's role in a project using RPC.

```typescript packages/app/src/domain/repositories/supabase.ts theme={null}
const { data: roleData, error: roleError } = await supabase.rpc('current_project_role', {
  p_project_id: projectId,
});

const role = (roleData ?? 'viewer') as ProjectRole;
```

### Request Body

<ParamField body="p_project_id" type="string" required>
  Project UUID
</ParamField>

### Response

Returns the user's role as a string: `owner`, `editor`, or `viewer`.

## React Hook Usage

```typescript packages/app/src/hooks/use-project-data.ts theme={null}
import { useQuery, useMutation } from '@tanstack/react-query';
import { projectRepository } from '@/domain/repositories';

// List projects for a tenant
export function useProjects(tenantSlug: string | undefined) {
  const { user } = useAuth();

  return useQuery({
    queryKey: ['tenants', tenantSlug, 'projects', user?.id],
    queryFn: async () => {
      if (!user?.id || !tenantSlug) return [];
      return projectRepository.listProjects(user.id, tenantSlug);
    },
    enabled: Boolean(user?.id && tenantSlug),
  });
}

// Create a project
export function useCreateProject(tenantSlug: string | undefined) {
  const queryClient = useQueryClient();
  const { user } = useAuth();

  return useMutation({
    mutationFn: async (input: CreateProjectInput) => {
      if (!tenantSlug) throw new Error('Tenant is required');
      return projectRepository.createProjectWithDefaults(tenantSlug, input);
    },
    onSuccess: async () => {
      if (!user?.id || !tenantSlug) return;
      await queryClient.invalidateQueries({
        queryKey: ['tenants', tenantSlug, 'projects', user.id]
      });
    },
  });
}

// Update project settings
export function useUpdateProjectSettings(projectId: string | undefined, tenantSlug: string | undefined) {
  const queryClient = useQueryClient();
  const { user } = useAuth();

  return useMutation({
    mutationFn: async (input: { name: string; description?: string | null }) => {
      if (!projectId) throw new Error('Project is required');
      return projectRepository.updateProjectSettings(projectId, {
        name: input.name,
        description: input.description ?? null,
      });
    },
    onSuccess: async () => {
      if (!projectId || !user?.id || !tenantSlug) return;
      await queryClient.invalidateQueries({
        queryKey: ['tenants', tenantSlug, 'projects', user.id]
      });
    },
  });
}
```
