Adding a Projects Data Model to Your Next.js Supabase Application
Learn how to add a Projects data model to your Next.js Supabase application using Supabase
Team Accounts allow you to manage several members in a single account. These usually share a subscription and other common attributes. However, you may need to go deeper than that, and allow team accounts to have a set of projects that they can collaborate on.
This guide will walk you through implementing a robust project system in your Makerkit application using Supabase. We'll cover everything from setting up the necessary database tables to implementing role-based access control.
Overview of the Projects Data Model
The Projects feature allows users to create and manage projects within your application. It includes:
- Project creation and management
- Role-based access control (owner, admin, member)
- Permissions system
- Database schema and functions
Let's dive in!
Database Schema and Functions
We'll start by setting up the database schema. This involves creating tables, enums, and functions.
Enums
First, let's create two enum types:
-- Project rolesCREATE TYPE public.project_role AS ENUM ('owner', 'admin', 'member');-- Project actionsCREATE TYPE public.project_action AS ENUM ( 'view_project', 'edit_project', 'delete_project', 'invite_member', 'remove_member');
These enums define the possible roles and actions within a project.
Tables
Now, let's create the main tables.
We will create two tables: projects
and project_members
.
The projects
table stores project information, while the project_members
table manages the relationship between projects and users.
Projects Table
-- Projects tableCREATE TABLE IF NOT EXISTS public.projects ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, name VARCHAR(255) NOT NULL, description TEXT, account_id UUID NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
Let's dive into the projects
table:
id
is a unique identifier for each project.name
is the name of the project.description
is an optional description for the project.account_id
is a foreign key referencing theaccounts
table.created_at
andupdated_at
are timestamps for when the project was created and updated, respectively.
Project Members Table
The project_members
table stores the relationship between projects and users:
-- Project members tableCREATE TABLE IF NOT EXISTS public.project_members ( project_id UUID NOT NULL REFERENCES public.projects(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, role public.project_role NOT NULL DEFAULT 'member', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (project_id, user_id));
Let's clarify the project_members
table:
- The
project_id
column is a foreign key referencing theprojects
table. - The
user_id
column is a foreign key referencing theauth.users
table. - The
role
column is an enum type that can have three values:owner
,admin
, ormember
.
These columns ensure that each project has a unique owner, and that each member can have a role of owner
, admin
, or member
.
Indexes
To optimize query performance, let's add some indexes:
CREATE INDEX projects_account_id ON public.projects (account_id);CREATE INDEX project_members_project_id ON public.project_members (project_id);CREATE INDEX project_members_user_id ON public.project_members (user_id);
We also need a unique index to ensure only one owner per project:
CREATE UNIQUE INDEX projects_unique_owner ON public.project_members (project_id) WHERE role = 'owner';
Permissions
Set up default permissions. This is useful to make sure we can granularly control access to the tables.
alter table public.projects enable row level security;alter table public.project_members enable row level security;-- Revoke all permissions by defaultREVOKE ALL ON public.projects FROM public, service_role;REVOKE ALL ON public.project_members FROM public, service_role;-- Grant access to authenticated usersGRANT SELECT, INSERT, UPDATE, DELETE ON public.projects TO authenticated;GRANT SELECT, INSERT, UPDATE, DELETE ON public.project_members TO authenticated;
Functions
Now, let's create several helper functions to manage projects and permissions.
Is Project Member
This function checks if a user is a member of a specific project:
CREATE OR REPLACE FUNCTION public.is_project_member(p_project_id UUID)RETURNS BOOLEANSET search_path = ''AS $$ SELECT EXISTS ( SELECT 1 FROM public.project_members WHERE project_id = p_project_id AND user_id = (SELECT auth.uid()) );$$ LANGUAGE sql SECURITY DEFINER;GRANT EXECUTE ON FUNCTION public.is_project_member(UUID) TO authenticated;
Is Project Admin
This function checks if a user is an admin or owner of a specific project:
CREATE OR REPLACE FUNCTION public.is_project_admin(p_project_id UUID)RETURNS BOOLEANSET search_path = ''AS $$ SELECT EXISTS ( SELECT 1 FROM public.project_members WHERE project_id = p_project_id AND user_id = (SELECT auth.uid()) AND role IN ('owner', 'admin') );$$ LANGUAGE sql;GRANT EXECUTE ON FUNCTION public.is_project_admin TO authenticated;
Is Project Owner
This function checks if a user is the owner of a project:
CREATE OR REPLACE FUNCTION public.is_project_owner(project_id UUID)RETURNS BOOLEANSET search_path = ''AS $$ SELECT EXISTS ( SELECT 1 FROM public.project_members WHERE project_id = $1 AND user_id = (SELECT auth.uid()) AND role = 'owner' );$$ LANGUAGE sql;GRANT EXECUTE ON FUNCTION public.is_project_owner TO authenticated;
User Has Project Permission
This function checks if a user has the required permissions to perform a specific action on a project:
CREATE OR REPLACE FUNCTION public.user_has_project_permission( p_user_auth_id UUID, p_project_id UUID, p_action public.project_action)RETURNS BOOLEANSET search_path = ''AS $$DECLARE v_role public.project_role;BEGIN -- First, check if the user is a member of the project SELECT role INTO v_role FROM public.project_members WHERE project_id = p_project_id AND user_id = p_user_auth_id; IF v_role IS NULL THEN RETURN FALSE; END IF; -- Check permissions based on role and action CASE v_role WHEN 'owner' THEN RETURN TRUE; -- Owners can do everything WHEN 'admin' THEN RETURN p_action != 'delete_project'; -- Admins can do everything except delete the project WHEN 'member' THEN RETURN p_action IN ('view_project'); ELSE RAISE EXCEPTION 'User must be a member of the project to perform this action'; END CASE;END;$$ LANGUAGE plpgsql;GRANT EXECUTE ON FUNCTION public.user_has_project_permission TO authenticated;
In the above, we use the CASE
statement to check the role of the user and the action they are trying to perform. If the user is a member of the project and has the required permissions, we return TRUE
. Otherwise, we raise an exception.
Please feel free to modify the user_has_project_permission
function to fit your specific use case and your project's requirements.For example, you may want to add more roles or actions, or restrict the permissions based on the project's settings.
Current User Has Project Permission
This function is a wrapper around user_has_project_permission
for the current user:
CREATE OR REPLACE FUNCTION public.current_user_has_project_permission( p_project_id UUID, p_action public.project_action)RETURNS BOOLEANSET search_path = ''AS $$DECLARE v_role public.project_role;BEGIN SELECT public.user_has_project_permission((SELECT auth.uid()), p_project_id, p_action);END;$$ LANGUAGE plpgsql;GRANT EXECUTE ON FUNCTION public.current_user_has_project_permission TO authenticated;
Current User Can Manage Project Member
This function checks if the current user can manage another user in a project:
CREATE OR REPLACE FUNCTION public.current_user_can_manage_project_member( p_target_member_role public.project_role, p_project_id UUID)RETURNS BOOLEANSET search_path = ''AS $$DECLARE v_current_user_role public.project_role;BEGIN SELECT role INTO v_current_user_role FROM public.project_members WHERE project_id = p_project_id AND user_id = (SELECT auth.uid()); IF v_current_user_role IS NULL OR p_target_member_role IS NULL THEN RAISE EXCEPTION 'User not found'; END IF; -- Check if the manager has a higher role RETURN (v_current_user_role = 'owner' AND p_target_member_role != 'owner') OR (v_current_user_role = 'admin' AND p_target_member_role = 'member');END;$$ LANGUAGE plpgsql;GRANT EXECUTE ON FUNCTION public.current_user_can_manage_project_member TO authenticated;
This project is extremely useful to verify that a user can perform actions that affect another user in a project.
Update Project Member Role
This function updates the role of a project member:
CREATE OR REPLACE FUNCTION public.update_project_member_role( p_user_id UUID, p_new_role public.project_role, p_project_id UUID)RETURNS BOOLEANSET search_path = ''AS $$DECLARE v_current_role public.project_role;BEGIN -- Get the current role of the member SELECT role INTO v_current_role FROM public.project_members WHERE project_id = p_project_id AND user_id = p_user_id; -- Check if the manager can manage this member IF NOT public.current_user_can_manage_project_member(v_current_role, p_project_id) THEN RAISE EXCEPTION 'Permission denied'; END IF; IF p_new_role = 'owner' THEN RAISE EXCEPTION 'Owner cannot be updated to a different role'; END IF; -- Update the member's role UPDATE public.project_members SET role = p_new_role WHERE project_id = p_project_id AND user_id = p_user_id; RETURN TRUE;END;$$ LANGUAGE plpgsql;GRANT EXECUTE ON FUNCTION public.update_project_member_role TO authenticated;
Can Edit Project
This function checks if a user can edit a project:
CREATE OR REPLACE FUNCTION public.can_edit_project(p_user_auth_id UUID, p_project_id UUID)RETURNS BOOLEANSET search_path = ''AS $$ SELECT public.user_has_project_permission(p_user_auth_id, p_project_id, 'edit_project'::public.project_action);$$ LANGUAGE sql;GRANT EXECUTE ON FUNCTION public.can_edit_project TO authenticated;
This is a simple wrapper around the user_has_project_permission
function that checks if the current user can edit a project.
Can Delete Project
This function checks if a user can delete a project:
CREATE OR REPLACE FUNCTION public.can_delete_project(p_user_auth_id UUID, p_project_id UUID)RETURNS BOOLEANSET search_path = ''AS $$ SELECT public.user_has_project_permission(p_user_auth_id, p_project_id, 'delete_project'::public.project_action);$$ LANGUAGE sql;GRANT EXECUTE ON FUNCTION public.can_delete_project TO authenticated;
This is a simple wrapper around the user_has_project_permission
function that checks if the current user can delete a project.
Can Invite Project Member
This function checks if a user can invite a new member to the project:
CREATE OR REPLACE FUNCTION public.can_invite_project_member(p_user_auth_id UUID, p_project_id UUID)RETURNS BOOLEANSET search_path = ''AS $$ SELECT public.user_has_project_permission(p_user_auth_id, p_project_id, 'invite_member'::public.project_action);$$ LANGUAGE sql;GRANT EXECUTE ON FUNCTION public.can_invite_project_member TO authenticated;
This is also a simple wrapper around the user_has_project_permission
function that checks if the current user can invite a new member to the project.
Row Level Security (RLS) Policies
Now, let's set up RLS policies to secure our tables. RLS policies are used to control access to specific columns and rows in a table. The functions we defined earlier are used to check if a user has the required permissions to perform a specific action on a project.
Projects Table Policies
-- SELECTCREATE POLICY select_projects ON public.projects FOR SELECT TO authenticated USING ( public.is_project_member(id) );-- INSERTCREATE POLICY insert_new_project ON public.projects FOR INSERT TO authenticated WITH CHECK ( public.has_role_on_account(account_id) );-- DELETECREATE POLICY delete_project ON public.projects FOR DELETE TO authenticated USING ( public.can_delete_project((SELECT auth.uid()), id) );-- UPDATECREATE POLICY update_project ON public.projects FOR UPDATE TO authenticated USING ( public.can_edit_project((SELECT auth.uid()), id) ) WITH CHECK ( public.can_edit_project((SELECT auth.uid()), id) );
Alright, let's break down the policies:
- Select Projects: This policy allows authenticated users to select projects from the
projects
table. If a user is not a member of the project, they will not be able to see the project. - Insert New Project: This policy allows authenticated users to insert new projects into the
projects
table. It does so by checking if the current user is currently a member of the team account associated with the project. - Delete Project: This policy allows authenticated users to delete projects from the
projects
table. It verifies that the user can delete the project using thecan_delete_project
function. - Update Project: This policy allows authenticated users to update projects in the
projects
table. It does so by checking if the current user has the required permissions to edit the project using thecan_edit_project
function.
Project Members Table Policies
-- SELECTCREATE POLICY select_project_members ON public.project_members FOR SELECT TO authenticated USING ( public.is_project_member(project_id) );-- INSERTCREATE POLICY insert_project_member ON public.project_members FOR INSERT TO authenticated WITH CHECK ( public.can_invite_project_member( (SELECT auth.uid()), project_id ) );-- UPDATECREATE POLICY update_project_members ON public.project_members FOR UPDATE TO authenticated USING ( public.current_user_can_manage_project_member( role, project_id ) ) WITH CHECK ( public.current_user_can_manage_project_member( role, project_id ) );-- DELETECREATE POLICY delete_project_members ON public.project_members FOR DELETE TO authenticated USING ( public.current_user_can_manage_project_member( role, project_id ) );
Let's break down the policies:
- Select Project Members: This policy allows authenticated users to select project members from the
project_members
table. If a user is not a member of the project, they will not be able to see the project members. - Insert Project Member: This policy allows authenticated users to insert new project members into the
project_members
table. It does so by checking if the current user can invite a new member to the project using thecan_invite_project_member
function. - Update Project Members: This policy allows authenticated users to update project members in the
project_members
table. It does so by checking if the current user can manage the project member using thecurrent_user_can_manage_project_member
function. - Delete Project Members: This policy allows authenticated users to delete project members from the
project_members
table. It does so by checking if the current user can manage the project member using thecurrent_user_can_manage_project_member
function.
Additional Functions
Let's add a few more helpful functions:
Add Project Owner
This function adds the owner of the project as the first project member:
CREATE OR REPLACE FUNCTION kit.add_project_owner()RETURNS TRIGGERAS $$BEGIN INSERT INTO public.project_members (project_id, user_id, role) VALUES (NEW.id, auth.uid(), 'owner'::public.project_role); RETURN NEW;END;$$ LANGUAGE plpgsql SECURITY DEFINER;-- Trigger to add owner of the project creator as the first project memberCREATE TRIGGER add_project_owner_on_insert AFTER INSERT ON public.projects FOR EACH ROW EXECUTE PROCEDURE kit.add_project_owner();
Add Project Member
This function adds a new member to a project:
CREATE OR REPLACE FUNCTION public.add_project_member( p_project_id UUID, p_user_id UUID, p_role public.project_role DEFAULT 'member') RETURNS BOOLEANSET search_path = ''AS $$DECLARE v_account_id UUID;BEGIN -- Check if the current user has permission to add members IF NOT public.is_project_admin(p_project_id) OR p_role = 'owner' THEN RAISE EXCEPTION 'Permission denied'; END IF; -- Get the account_id for the project SELECT account_id INTO v_account_id FROM public.projects WHERE id = p_project_id; -- Check if the user is a member of the team account IF NOT EXISTS ( SELECT 1 FROM public.accounts_memberships WHERE account_id = v_account_id AND user_id = p_user_id ) THEN RAISE EXCEPTION 'User is not a member of the team account'; END IF; -- Add the new member (the trigger will enforce the team membership check) INSERT INTO public.project_members (project_id, user_id, role) VALUES (p_project_id, p_user_id, p_role) ON CONFLICT (project_id, user_id) DO UPDATE SET role = EXCLUDED.role; RETURN TRUE;END;$$ LANGUAGE plpgsql;GRANT EXECUTE ON FUNCTION public.add_project_member TO authenticated;
Check Project Member in Team
This trigger function ensures that a user being added to a project is already a member of the associated team account:
CREATE OR REPLACE FUNCTION kit.check_project_member_in_team()RETURNS TRIGGERAS $$DECLARE v_account_id UUID;BEGIN SELECT account_id FROM public.projects WHERE id = NEW.project_id INTO v_account_id; IF NOT EXISTS ( SELECT 1 FROM public.accounts_memberships WHERE account_id = v_account_id AND user_id = NEW.user_id ) THEN RAISE EXCEPTION 'User must be a member of the team account to be added to the project'; END IF; RETURN NEW;END;$$ LANGUAGE plpgsql SECURITY DEFINER;-- Create a trigger that uses the above functionCREATE TRIGGER ensure_project_member_in_teamBEFORE INSERT OR UPDATE ON public.project_membersFOR EACH ROW EXECUTE FUNCTION kit.check_project_member_in_team();
Implementing the Projects Feature
Now that we've set up all the necessary database objects, let's walk through how to use this Projects feature in your Makerkit application.
Creating a New Project
To create a new project, you'll insert a row into the public.projects
table. The kit.add_project_owner()
trigger will automatically add the creating user as the owner.
INSERT INTO public.projects (name, description, account_id)VALUES ('My New Project', 'This is a description of my project', :account_id);
Replace :account_id
with the actual account ID.
Adding Members to a Project
Use the public.add_project_member
function to add new members to a project:
SELECT public.add_project_member(:project_id, :user_id, 'member'::public.project_role);
Replace :project_id
and :user_id
with actual values. The role can be 'member' or 'admin'.
Updating a Member's Role
To change a member's role, use the public.update_project_member_role
function:
SELECT public.update_project_member_role(:user_id, 'admin'::public.project_role, :project_id);
Querying Projects
To get all projects a user is a member of:
SELECT * FROM public.projectsWHERE id IN ( SELECT project_id FROM public.project_members WHERE user_id = auth.uid());
Checking Permissions
You can use the various permission-checking functions in your application logic. For example:
-- Check if the current user can edit a projectSELECT public.can_edit_project(auth.uid(), :project_id);-- Check if the current user can delete a projectSELECT public.can_delete_project(auth.uid(), :project_id);-- Check if the current user can invite members to a projectSELECT public.can_invite_project_member(auth.uid(), :project_id);
Application-Level Logic
Now that we've covered the database schema and functions, let's move on to the application-level logic.
The ProjectsService
To provide a better API for interacting with the database, we can create a service layer (ProjectsService
) that encapsulates all the database operations related to projects. This is a great practice as it separates concerns and makes your code more maintainable.
The ProjectsService
class encapsulates all the database operations related to projects. This is a great practice as it separates concerns and makes your code more maintainable.
import { SupabaseClient } from '@supabase/supabase-js';import { Database } from '~/lib/database.types';type ProjectAction = Database['public']['Enums']['project_action'];export function createProjectsService(client: SupabaseClient<Database>) { return new ProjectsService(client);}class ProjectsService { constructor(private readonly client: SupabaseClient<Database>) {} async createProject(params: { name: string; description?: string; accountId: string; }) { const { data, error } = await this.client .from('projects') .insert({ name: params.name, description: params.description, account_id: params.accountId, }) .select('id') .single(); if (error) { throw error; } return data; } async getProjects(accountSlug: string) { const { data, error } = await this.client .from('projects') .select('*, account: account_id ! inner (slug)') .eq('account.slug', accountSlug); if (error) { throw error; } return data; } async getProjectMembers(projectId: string) { const { data, error } = await this.client .from('project_members') .select('*') .eq('project_id', projectId); if (error) { throw error; } return data; } async getProject(projectId: string) { const { data, error } = await this.client .from('projects') .select('*') .eq('id', projectId) .single(); if (error) { throw error; } return data; } async hasPermission(params: { projectId: string; action: ProjectAction }) { const { data, error } = await this.client.rpc( 'current_user_has_project_permission', { p_project_id: params.projectId, p_action: params.action, }, ); if (error) { throw error; } return data; } async addProjectMember(params: { projectId: string; userId: string; role?: 'member' | 'admin'; }) { const { error } = await this.client.rpc('add_project_member', { p_project_id: params.projectId, p_user_id: params.userId, p_role: params.role ?? 'member', }); if (error) { throw error; } return true; }}
Let's look at some key methods:
- createProject: This method inserts a new project into the
projects
table. - getProjects: Fetches all projects for a given account.
- getProjectMembers: Retrieves all members of a specific project.
- getProject: Fetches details of a single project.
- hasPermission: Checks if the current user has a specific permission for a project.
- addProjectMember: Adds a new member to a project.
These methods utilize the Supabase client to interact with the database, leveraging the SQL functions and RLS policies we set up earlier.
Using the ProjectsService in a Next.js Page
The ProjectsPage
component demonstrates how to use the ProjectsService
in a Next.js server component:
We create an instance of
ProjectsService
using the Supabase client:const client = getSupabaseServerComponentClient();const service = createProjectsService(client);We fetch the projects for the current account:
const projects = use(service.getProjects(params.account));The
use
function is a React hook that allows us to use async data in a synchronous-looking way in server components.We render the projects, showing an empty state if there are no projects:
<If condition={projects.length === 0}><EmptyState>{/* Empty state content */}</EmptyState></If><div className={'grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4'}>{projects.map((project) => (<CardButton key={project.id} asChild><Link href={`/home/${params.account}/projects/${project.id}`}>{project.name}</Link></CardButton>))}</div>
Here is a page that shows a list of projects for a given account:
import { use } from 'react';import Link from 'next/link';import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';import { Button } from '@kit/ui/button';import { CardButton } from '@kit/ui/card-button';import { EmptyState, EmptyStateButton, EmptyStateHeading, EmptyStateText,} from '@kit/ui/empty-state';import { If } from '@kit/ui/if';import { PageBody, PageHeader } from '@kit/ui/page';import { createProjectsService } from '~/lib/server/projects/projects.service';interface ProjectsPageProps { params: { account: string; };}export default function ProjectsPage({ params }: ProjectsPageProps) { const client = getSupabaseServerComponentClient(); const service = createProjectsService(client); const projects = use(service.getProjects(params.account)); return ( <> <PageHeader title="Projects" description="Manage your team's projects"> <Link href={`/home/${params.account}/projects/new`}> <Button>New Project</Button> </Link> </PageHeader> <PageBody> <If condition={projects.length === 0}> <EmptyState> <EmptyStateHeading>No projects found</EmptyStateHeading> <EmptyStateText> You still have not created any projects. Create your first project now! </EmptyStateText> <EmptyStateButton>Create Project</EmptyStateButton> </EmptyState> </If> <div className={'grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4'}> {projects.map((project) => ( <CardButton key={project.id} asChild> <Link href={`/home/${params.account}/projects/${project.id}`}> {project.name} </Link> </CardButton> ))} </div> </PageBody> </> );}
The page will display a list of projects for the current account, with a link to create a new project. If there are no projects, it will display an empty state with a button to create a new project, which is a link to the new project page.
Implementing Other Features
Based on the ProjectsService
, you can implement other features:
- Creating a New Project: You could create a form that calls
service.createProject()
when submitted. - Project Details Page: Use
service.getProject()
to fetch and display details of a single project. - Managing Project Members: Use
service.getProjectMembers()
to list members, andservice.addProjectMember()
to add new ones. - Permission Checking: Before performing actions, use
service.hasPermission()
to check if the user is allowed to do so.
Going beyond Projects
The reason why you're adding Projects is likely because yoyu want to group more entities into a projects, so to better organizate a team's work. You can extend the Projects feature to support other entities, such as Tasks, Documents, and more.
To do so, your project-related entities will need to be related to the Projects entity. This can be done using the project_id
column in the related tables.
For example, if you want to add Tasks to a Project, you can create a new table tasks
and add a foreign key to the project_id
column in the projects
table.
-- Table: public.tasksCREATE TABLE if not exists public.tasks ( id uuid DEFAULT gen_random_uuid() PRIMARY KEY, name text NOT NULL, description text, project_id uuid NOT NULL REFERENCES public.projects(id) ON DELETE CASCADE, created_at timestamptz NOT NULL DEFAULT NOW(), updated_at timestamptz NOT NULL DEFAULT NOW());
With this setup, you can now create Tasks that are related to a Project. You can also use the Supabase client to interact with the database, leveraging the SQL functions and RLS policies we set up earlier.
Remember, the database schema and functions we've created provide a solid foundation for these features. As you build out your UI, you'll be calling the methods in ProjectsService
to interact with the database in a secure, permission-controlled way.
This approach allows you to create a robust, scalable application while keeping your business logic cleanly separated from your UI components.
RLS Policies
When it comes to RLS, you can use the permissions system we've set up to control access to your database.
This allows you to create fine-grained access control for your users, ensuring that only those who should have access to certain data can do so.
create policy select_projects_tasks on public.tasks for select to authenticated using ( public.is_project_member(project_id) );
In short, if a user can view a Project, they can also view the Tasks related to that Project.
You can extend the permissions system further to allow for more granular access control based on the specific requirements of your application, such as can_edit_task
or can_delete_task
.
Best Practices
- Error Handling: The service methods throw errors when something goes wrong. Make sure to catch and handle these errors appropriately in your components.
- Type Safety: The service uses TypeScript, leveraging the
Database
type to ensure type safety when interacting with Supabase. - Separation of Concerns: By using a service layer, we keep our database logic separate from our UI components, making the code more maintainable and testable.
- Server Components: By using Next.js server components, we can fetch data on the server, reducing the amount of JavaScript sent to the client and improving initial page load times.
- Reusability: The
ProjectsService
can be used across different parts of your application, promoting code reuse.
Full Schema
Below is the full schema:
-- enums for project roles: owner, admin, membercreate type public.project_role as enum ('owner', 'admin', 'member');-- enums for project actionscreate type public.project_action as enum ( 'view_project', 'edit_project', 'delete_project', 'invite_member', 'remove_member');/*# public.projects# This table stores the projects for the account*/create table if not exists public.projects ( id uuid default gen_random_uuid() primary key, name varchar(255) not null, description text, account_id uuid not null references public.accounts(id) on delete cascade, created_at timestamptz not null default now(), updated_at timestamptz not null default now());-- revoke access by default to public.projectsrevoke all on public.projects from public, service_role;-- grant access to authenticated usersgrant select, insert, update, delete on public.projects to authenticated;-- indexes on public.projectscreate index projects_account_id on public.projects (account_id);-- RLS policies on public.projectsalter table public.projects enable row level security;/*# public.project_members# This table stores the members of a project*/create table if not exists public.project_members ( project_id uuid not null references public.projects(id) on delete cascade, user_id uuid not null references auth.users(id) on delete cascade, role public.project_role not null default 'member', created_at timestamptz not null default now(), updated_at timestamptz not null default now(), primary key (project_id, user_id));-- make sure there is only one owner per projectcreate unique index projects_unique_owner on public.project_members (project_id) where role = 'owner';-- indexes on public.project_memberscreate index project_members_project_id on public.project_members (project_id);create index project_members_user_id on public.project_members (user_id);-- revoke access by default to public.project_membersrevoke all on public.project_members from public, service_role;-- grant access to authenticated users to public.project_membersgrant select, insert, update, delete on public.project_members to authenticated;-- RLS policies on public.project_membersalter table public.project_members enable row level security;-- public.is_project_member-- this function checks if a user is a member of a specific projectcreate or replace function public.is_project_member(p_project_id uuid)returns booleanset search_path = ''as $$ select exists ( select 1 from public.project_members where project_id = p_project_id and user_id = (select auth.uid()) );$$ language sql security definer;grant execute on function public.is_project_member(uuid) to authenticated;-- public.is_project_admin-- this function checks if a user is an admin or owner of a specific projectcreate or replace function public.is_project_admin(p_project_id uuid)returns booleanset search_path = ''as $$ select exists ( select 1 from public.project_members where project_id = p_project_id and user_id = (select auth.uid()) and role in ('owner', 'admin') );$$ language sql;grant execute on function public.is_project_admin to authenticated;-- public.is_project_owner-- check if a user is the owner of a projectcreate or replace function public.is_project_owner(project_id uuid)returns booleanset search_path = ''as $$ select exists ( select 1 from public.project_members where project_id = $1 and user_id = (select auth.uid()) and role = 'owner' );$$ language sql;grant execute on function public.is_project_owner to authenticated;-- public.user_has_project_permission-- check if the current user has the required permissions to perform a specific action on a projectcreate or replace function public.user_has_project_permission( p_user_auth_id uuid, p_project_id uuid, p_action public.project_action)returns booleanset search_path = ''as $$declare v_role public.project_role;begin -- first, check if the user is a member of the project select role into v_role from public.project_members where project_id = p_project_id and user_id = p_user_auth_id; if v_role is null then return false; end if; -- check permissions based on role and action case v_role when 'owner' then return true; -- owners can do everything when 'admin' then return p_action != 'delete_project'; -- admins can do everything except delete the project when 'member' then return p_action in ('view_project'); else raise exception 'user must be a member of the project to perform this action'; end case;end;$$ language plpgsql;grant execute on function public.user_has_project_permission to authenticated;-- public.current_user_has_project_permissioncreate or replace function public.current_user_has_project_permission( p_project_id uuid, p_action public.project_action)returns booleanset search_path = ''as $$declare v_role public.project_role;begin select public.user_has_project_permission((select auth.uid()), p_project_id, p_action);end;$$ language plpgsql;grant execute on function public.current_user_has_project_permission to authenticated;-- public.current_user_can_manage_project_member-- Function to check if a user can manage another user in a projectcreate or replace function public.current_user_can_manage_project_member( p_target_member_role public.project_role, p_project_id uuid)returns booleanset search_path = ''as $$declare v_current_user_role public.project_role;begin select role into v_current_user_role from public.project_members where project_id = p_project_id and user_id = (select auth.uid()); if v_current_user_role is null or p_target_member_role is null then raise exception 'User not found'; end if; -- Check if the manager has a higher role return (v_current_user_role = 'owner' and p_target_member_role != 'owner') or (v_current_user_role = 'admin' and p_target_member_role = 'member');end;$$ language plpgsql;grant execute on function public.current_user_can_manage_project_member to authenticated;-- public.update_project_member_role-- function to update the role of a project membercreate or replace function public.update_project_member_role( p_user_id uuid, p_new_role public.project_role, p_project_id uuid)returns booleanset search_path = ''as $$declare v_current_role public.project_role;begin -- Get the current role of the member select role into v_current_role from public.project_members where project_id = p_project_id and user_id = p_user_id; -- Check if the manager can manage this member if not public.current_user_can_manage_project_member(v_current_role, p_project_id) then raise exception 'Permission denied'; end if; if p_new_role = 'owner' then raise exception 'Owner cannot be updated to a different role'; end if; -- Update the member's role update public.project_members set role = p_new_role where project_id = p_project_id and user_id = p_user_id; return true;end;$$ language plpgsql;grant execute on function public.update_project_member_role to authenticated;-- public.can_edit_project-- check if the user can edit the projectcreate or replace function public.can_edit_project(p_user_auth_id uuid, p_project_id uuid)returns booleanset search_path = ''as $$ select public.user_has_project_permission(p_user_auth_id, p_project_id, 'edit_project'::public.project_action);$$ language sql;grant execute on function public.can_edit_project to authenticated;-- public.can_delete_project-- check if the user can delete the projectcreate or replace function public.can_delete_project(p_user_auth_id uuid, p_project_id uuid)returns booleanset search_path = ''as $$ select public.user_has_project_permission(p_user_auth_id, p_project_id, 'delete_project'::public.project_action);$$ language sql;grant execute on function public.can_delete_project to authenticated;-- public.can_invite_project_member-- check if the user can invite a new member to the projectcreate or replace function public.can_invite_project_member(p_user_auth_id uuid, p_project_id uuid)returns booleanset search_path = ''as $$ select public.user_has_project_permission(p_user_auth_id, p_project_id, 'invite_member'::public.project_action);$$ language sql;grant execute on function public.can_invite_project_member to authenticated;/*RLS POLICIES*/-- SELECT(public.projects)create policy select_projects on public.projects for select to authenticated using ( public.is_project_member(id) );-- INSERT(public.projects)create policy insert_new_project on public.projects for insert to authenticated with check ( public.has_role_on_account(account_id) );-- DELETE(public.projects)create policy delete_project on public.projects for delete to authenticated using ( public.can_delete_project((select auth.uid()), id) );-- UPDATE(public.projects)create policy update_project on public.projects for update to authenticated using ( public.can_edit_project((select auth.uid()), id) ) with check ( public.can_edit_project((select auth.uid()), id) );-- SELECT(public.project_members)create policy select_project_members on public.project_members for select to authenticated using ( public.is_project_member(project_id) );-- INSERT(public.project_members)create policy insert_project_member on public.project_members for insert to authenticated with check ( public.can_invite_project_member( (select auth.uid()), project_id ) );-- UPDATE(public.project_members)create policy update_project_members on public.project_members for update to authenticated using ( public.current_user_can_manage_project_member( role, project_id ) ) with check ( public.current_user_can_manage_project_member( role, project_id ) );-- DELETE(public.project_members)create policy delete_project_members on public.project_members for delete to authenticated using ( public.current_user_can_manage_project_member( role, project_id ) );/*# FUNCTIONS*/-- function to add owner of the project creator as the first project membercreate or replace function kit.add_project_owner()returns triggeras $$begin insert into public.project_members (project_id, user_id, role) values (new.id, auth.uid(), 'owner'::public.project_role); return new;end;$$ language plpgsql security definer;-- trigger to add owner of the project creator as the first project membercreate trigger add_project_owner_on_insert after insert on public.projects for each row execute procedure kit.add_project_owner();create or replace function public.add_project_member( p_project_id uuid, p_user_id uuid, p_role public.project_role default 'member') returns booleanset search_path = ''as $$declare v_account_id uuid;begin -- check if the current user has permission to add members if not public.is_project_admin(p_project_id) or p_role = 'owner' then raise exception 'permission denied'; end if; -- get the account_id for the project select account_id into v_account_id from public.projects where id = p_project_id; -- check if the user is a member of the team account if not exists ( select 1 from public.accounts_memberships where account_id = v_account_id and user_id = p_user_id ) then raise exception 'user is not a member of the team account'; end if; -- add the new member (the trigger will enforce the team membership check) insert into public.project_members (project_id, user_id, role) values (p_project_id, p_user_id, p_role) on conflict (project_id, user_id) do update set role = excluded.role; return true;end;$$ language plpgsql;grant execute on function public.add_project_member to authenticated;-- TRIGGERS on public.project_members-- this trigger function ensures that a user being added to a project-- is already a member of the associated team accountcreate or replace function kit.check_project_member_in_team()returns triggeras $$declare v_account_id uuid;begin select account_id from public.projects where id = new.project_id into v_account_id; if not exists ( select 1 from public.accounts_memberships where account_id = v_account_id and user_id = new.user_id ) then raise exception 'user must be a member of the team account to be added to the project'; end if; return new;end;$$ language plpgsql security definer;-- we create a trigger that uses the above functioncreate trigger ensure_project_member_in_teambefore insert or update on public.project_membersfor each row execute function kit.check_project_member_in_team();
User Interface
Now that we have a data model, let's create the UI for managing projects. We will create a new page at app/dashboard/[account]/projects/page.tsx
that will display a list of projects for the current account.
Adding the Page to the Navigation
Let's update the navigation menu to add a new link to the projects page. We will create a new file at apps/web/config/team-account-navigation.config.tsx
with the following content:
{ label: 'common:routes.dashboard', path: pathsConfig.app.accountHome.replace('[account]', account), Icon: <LayoutDashboard className={iconClasses} />, end: true,},{ label: 'common:routes.projects', path: `/home/${account}/projects`, Icon: <FolderKanban className={iconClasses} />,},
NB: you need to the component FolderKanban
from lucide-react
to use the icon.
Creating the Projects List Page
We can now create the page at app/dashboard/[account]/projects/page.tsx
with the following content:
import { use } from 'react';import Link from 'next/link';import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';import { Button } from '@kit/ui/button';import { CardButton, CardButtonHeader, CardButtonTitle,} from '@kit/ui/card-button';import { EmptyState, EmptyStateButton, EmptyStateHeading, EmptyStateText,} from '@kit/ui/empty-state';import { If } from '@kit/ui/if';import { PageBody, PageHeader } from '@kit/ui/page';import { CreateProjectDialog } from '~/home/[account]/projects/_components/create-project-dialog';import { createProjectsService } from '~/lib/server/projects/projects.service';interface ProjectsPageProps { params: { account: string; };}export default function ProjectsPage({ params }: ProjectsPageProps) { const client = getSupabaseServerComponentClient(); const service = createProjectsService(client); const projects = use(service.getProjects(params.account)); return ( <> <PageHeader title="Projects" description={<AppBreadcrumbs />}> <Link href={`/home/${params.account}/projects/new`}> <CreateProjectDialog> <Button>New Project</Button> </CreateProjectDialog> </Link> </PageHeader> <PageBody> <If condition={projects.length === 0}> <EmptyState> <EmptyStateHeading>No projects found</EmptyStateHeading> <EmptyStateText> You still have not created any projects. Create your first project now! </EmptyStateText> <CreateProjectDialog> <EmptyStateButton>Create Project</EmptyStateButton> </CreateProjectDialog> </EmptyState> </If> <div className={'grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4'}> {projects.map((project) => ( <CardButton key={project.id} asChild> <Link href={`/home/${params.account}/projects/${project.id}`}> <CardButtonHeader> <CardButtonTitle>{project.name}</CardButtonTitle> </CardButtonHeader> </Link> </CardButton> ))} </div> </PageBody> </> );}
This page is pretty straightforward, it just displays a list of projects for the account. We can now build the ProjectsDataTable
component that will display the list of projects.
When we have a list of projects, we display them in the following way:
Instead, we can also display a message when there are no projects:
Creating the Dialog to Create a Project
We can now create the dialog to create a project. We will create a new file at apps/web/home/[account]/projects/_components/create-project-dialog.tsx
with the following content:
'use client';import { useState, useTransition } from 'react';import { zodResolver } from '@hookform/resolvers/zod';import { useForm } from 'react-hook-form';import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';import { Button } from '@kit/ui/button';import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger,} from '@kit/ui/dialog';import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel,} from '@kit/ui/form';import { Input } from '@kit/ui/input';import { CreateProjectSchema } from '../_lib/schema/create-project-schema';import { createProjectAction } from '../_lib/server/server-actions';export function CreateProjectDialog(props: React.PropsWithChildren) { const [isOpen, setIsOpen] = useState(false); return ( <Dialog open={isOpen} onOpenChange={setIsOpen}> <DialogTrigger asChild>{props.children}</DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>Create Project</DialogTitle> <DialogDescription> Create a new project for your team. </DialogDescription> </DialogHeader> <CreateProjectDialogForm onCancel={() => setIsOpen(false)} onCreateProject={() => setIsOpen(false)} /> </DialogContent> </Dialog> );}function CreateProjectDialogForm(props: { onCreateProject?: (name: string) => unknown; onCancel?: () => unknown;}) { const { account: { id: accountId }, } = useTeamAccountWorkspace(); const form = useForm({ resolver: zodResolver(CreateProjectSchema), defaultValues: { name: '', accountId, }, }); const [pending, startTransition] = useTransition(); return ( <Form {...form}> <form className={'flex flex-col space-y-4'} onSubmit={form.handleSubmit((data) => { startTransition(async () => { await createProjectAction(data); }); })} > <FormField name={'name'} render={({ field }) => ( <FormItem> <FormLabel>Project Name</FormLabel> <FormControl> <Input data-test={'project-name-input'} required min={3} max={50} type={'text'} placeholder={''} {...field} /> </FormControl> <FormDescription>Enter a name for your project (Ex. Accounting)</FormDescription> </FormItem> )} /> <div className={'flex justify-end space-x-2'}> <Button variant={'outline'} type={'button'} onClick={props.onCancel}> Cancel </Button> <Button disabled={pending}>Create Project</Button> </div> </form> </Form> );}
We can now create the server action to create a project. We will create a new file at apps/web/home/[account]/projects/_lib/server/server-actions.ts
with the following content:
'use server';import { revalidatePath } from 'next/cache';import { enhanceAction } from '@kit/next/actions';import { getLogger } from '@kit/shared/logger';import { getSupabaseServerClient } from '@kit/supabase/server-client';import { CreateProjectSchema } from '../schema/create-project-schema';export const createProjectAction = enhanceAction( async (data) => { const client = getSupabaseServerClient(); const logger = await getLogger(); logger.info( { accountId: data.accountId, name: data.name, }, 'Creating project...', ); const response = await client.from('projects').insert({ account_id: data.accountId, name: data.name, }); if (response.error) { logger.error( { accountId: data.accountId, name: data.name, error: response.error, }, 'Failed to create project', ); throw response.error; } logger.info( { accountId: data.accountId, name: data.name, }, 'Project created', ); revalidatePath('/home/[account]/projects', 'layout'); }, { schema: CreateProjectSchema, auth: true, },);
When the user submits the form, we call the createProjectAction
server action. This action will insert a new project into the database. To refresh the data, we use the revalidatePath
function to revalidate the /home/[account]/projects
path.
Project Detail Page
Now, we want to create a detail page for each project.
In this case, we will use an outer layout to wrap the project detail page, so that we can display an inner navigation menu that will reuse the same layout.
We will create a new file at apps/web/home/[account]/projects/[id]/layout.tsx
with the following content:
import { use } from 'react';import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';import { BorderedNavigationMenu, BorderedNavigationMenuItem,} from '@kit/ui/bordered-navigation-menu';import { PageBody, PageHeader } from '@kit/ui/page';import { getProject } from './_lib/server/get-project';interface ProjectDetailLayoutProps { params: { id: string; account: string; };}export default function ProjectDetailLayout({ params, children,}: React.PropsWithChildren<ProjectDetailLayoutProps>) { const project = use(getProject(params.id)); return ( <> <PageHeader title={project.name} description={<AppBreadcrumbs values={{ [project.id]: project.name }} />} /> <PageBody> <div className={'border-b px-4 pb-2.5'}> <BorderedNavigationMenu> <BorderedNavigationMenuItem path={`/home/${params.account}/projects/${project.id}`} label={'Documents'} /> <BorderedNavigationMenuItem path={`/home/${params.account}/projects/${project.id}/members`} label={'Members'} /> </BorderedNavigationMenu> </div> {children} </PageBody> </> );}
Navigation Menu
Just as an example, we have added a navigation menu with two links: Documents and Members. You can customize this menu to fit your needs.
Using the "getProject" function
As you may have noticed, we have a getProject
function that will fetch the project from the database.
We use this to make use of caching and reuse this function across all the pages in the Project layout, so we don't re-fetch the data from the database when avigating between pages.
import { cache } from 'react';import { getSupabaseServerClient } from '@kit/supabase/server-client';import { createProjectsService } from '~/lib/server/projects/projects.service';export const getProject = cache(projectLoader);async function projectLoader(id: string) { const client = getSupabaseServerClient(); const service = createProjectsService(client); return service.getProject(id);}
Project Detail Page
Now, we want to create a detail page for each project.
We will create a new file at apps/web/app/home/[account]/projects/[id]/page.tsx
with the following content:
import { use } from 'react';import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';import { PageBody, PageHeader } from '@kit/ui/page';import { createProjectsService } from '~/lib/server/projects/projects.service';interface ProjectDetailPageProps { params: { id: string; account: string; };}export default function ProjectDetailPage({ params }: ProjectDetailPageProps) { const client = getSupabaseServerComponentClient(); const service = createProjectsService(client); const project = use(service.getProject(params.id)); return ( <></> );}
Generally speaking, it is up to you to decide how you want to display the project data. This is the inner part of the layout, so we can use the same layout for the detail page.
Conclusion
In this tutorial, we've covered how to create a simple (yet powerful) data model for managing Projects in your Makerkit application. We've also explored how to implement this data model using Supabase and Next.js, and how to use the Supabase client to interact with the database.
By following these steps, you can create a robust, scalable application that leverages the power of Supabase and Next.js to manage your team's projects in a secure, permission-controlled way.