* Refactor localization keys to use dot notation for consistency across documentation and components * chore: bump version to 3.0.1 in package.json * Remove console log from SidebarLayout and update migration documentation for AlertDialog usage within Dropdowns * Update dashboard image to improve visual assets
2760 lines
80 KiB
Plaintext
2760 lines
80 KiB
Plaintext
---
|
|
status: "published"
|
|
title: 'Adding a Projects Data Model to Your Next.js Supabase Application'
|
|
label: 'Projects Data Model'
|
|
order: 4
|
|
description: '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:
|
|
|
|
```sql
|
|
-- Project roles
|
|
CREATE TYPE public.project_role AS ENUM ('owner', 'admin', 'member');
|
|
|
|
-- Project actions
|
|
CREATE 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
|
|
|
|
```sql
|
|
-- Projects table
|
|
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()
|
|
);
|
|
```
|
|
|
|
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 the `accounts` table.
|
|
- `created_at` and `updated_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:
|
|
|
|
```sql
|
|
-- Project members table
|
|
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)
|
|
);
|
|
```
|
|
|
|
Let's clarify the `project_members` table:
|
|
|
|
- The `project_id` column is a foreign key referencing the `projects` table.
|
|
- The `user_id` column is a foreign key referencing the `auth.users` table.
|
|
- The `role` column is an enum type that can have three values: `owner`, `admin`, or `member`.
|
|
|
|
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:
|
|
|
|
```sql
|
|
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:
|
|
|
|
```sql
|
|
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.
|
|
|
|
```sql
|
|
alter table public.projects enable row level security;
|
|
alter table public.project_members enable row level security;
|
|
|
|
-- Revoke all permissions by default
|
|
REVOKE ALL ON public.projects FROM public, service_role;
|
|
REVOKE ALL ON public.project_members FROM public, service_role;
|
|
|
|
-- Grant access to authenticated users
|
|
GRANT 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:
|
|
|
|
```sql
|
|
-- public.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 boolean
|
|
set search_path = ''
|
|
as $$
|
|
select exists (
|
|
select 1 from public.project_members
|
|
where project_id = p_project_id
|
|
and account_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:
|
|
|
|
```sql
|
|
-- public.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 boolean
|
|
set search_path = ''
|
|
as $$
|
|
select exists (
|
|
select 1 from public.project_members
|
|
where project_id = p_project_id
|
|
and account_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:
|
|
|
|
```sql
|
|
-- public.is_project_owner
|
|
-- check if a user is the owner of a project
|
|
create or replace function public.is_project_owner(project_id uuid)
|
|
returns boolean
|
|
set search_path = ''
|
|
as $$
|
|
select exists (
|
|
select 1 from public.project_members
|
|
where project_id = $1
|
|
and account_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:
|
|
|
|
```sql
|
|
-- public.user_has_project_permission
|
|
-- check if the current 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 boolean
|
|
set 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 account_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:
|
|
|
|
```sql
|
|
-- public.current_user_has_project_permission
|
|
create or replace function public.current_user_has_project_permission(
|
|
p_project_id uuid,
|
|
p_action public.project_action
|
|
)
|
|
returns boolean
|
|
set search_path = ''
|
|
as $$
|
|
begin
|
|
return 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:
|
|
|
|
```sql
|
|
-- public.current_user_can_manage_project_member
|
|
-- Function to check if a 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 boolean
|
|
set 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 account_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:
|
|
|
|
```sql
|
|
-- public.update_project_member_role
|
|
-- function to update 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 boolean
|
|
set 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 account_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 account_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:
|
|
|
|
```sql
|
|
-- public.can_edit_project
|
|
-- check if the user can edit the project
|
|
create or replace function public.can_edit_project(p_user_auth_id uuid, p_project_id uuid)
|
|
returns boolean
|
|
set 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:
|
|
|
|
```sql
|
|
CREATE OR REPLACE FUNCTION public.can_delete_project(p_user_auth_id UUID, p_project_id UUID)
|
|
RETURNS BOOLEAN
|
|
SET 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:
|
|
|
|
```sql
|
|
-- public.can_invite_project_member
|
|
-- check if the 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 boolean
|
|
set 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
|
|
|
|
```sql
|
|
/*
|
|
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)
|
|
);
|
|
```
|
|
|
|
Alright, let's break down the policies:
|
|
|
|
1. **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.
|
|
2. **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.
|
|
3. **Delete Project**: This policy allows authenticated users to delete projects from the `projects` table. It verifies that the user can delete the project using the `can_delete_project` function.
|
|
4. **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 the `can_edit_project` function.
|
|
|
|
### Project Members Table Policies
|
|
|
|
```sql
|
|
|
|
-- 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
|
|
)
|
|
);
|
|
```
|
|
|
|
Let's break down the policies:
|
|
|
|
1. **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.
|
|
2. **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 the `can_invite_project_member` function.
|
|
3. **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 the `current_user_can_manage_project_member` function.
|
|
4. **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 the `current_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:
|
|
|
|
```sql
|
|
CREATE OR REPLACE FUNCTION kit.add_project_owner()
|
|
RETURNS TRIGGER
|
|
AS $$
|
|
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 member
|
|
CREATE 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:
|
|
|
|
```sql
|
|
CREATE OR REPLACE FUNCTION public.add_project_member(
|
|
p_project_id UUID,
|
|
p_user_id UUID,
|
|
p_role public.project_role DEFAULT 'member'
|
|
) RETURNS BOOLEAN
|
|
SET search_path = ''
|
|
AS $$
|
|
DECLARE
|
|
v_account_id UUID;
|
|
v_is_personal_account boolean;
|
|
BEGIN
|
|
-- 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 target user account is a personal account (not a team account)Add commentMore actions
|
|
-- Use security definer context to bypass RLS for this validation
|
|
select is_personal_account into v_is_personal_account
|
|
from public.accounts
|
|
where id = p_user_id;
|
|
|
|
if v_is_personal_account is null then
|
|
raise exception 'user account not found';
|
|
end if;
|
|
|
|
if not v_is_personal_account then
|
|
raise exception 'cannot invite team accounts to projects - only individual users can be invited';
|
|
end if;
|
|
|
|
-- 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;
|
|
|
|
-- 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:
|
|
|
|
```sql
|
|
CREATE OR REPLACE FUNCTION kit.check_project_member_in_team()
|
|
RETURNS TRIGGER
|
|
AS $$
|
|
DECLARE
|
|
v_account_id UUID;
|
|
v_is_personal_account boolean;
|
|
BEGIN
|
|
SELECT account_id FROM public.projects
|
|
WHERE id = NEW.project_id
|
|
INTO v_account_id;
|
|
|
|
-- check if the account being added is a personal account (not a team account)Add commentMore actions
|
|
select is_personal_account into v_is_personal_account
|
|
from public.accounts
|
|
where id = new.account_id;
|
|
|
|
if v_is_personal_account is null then
|
|
raise exception 'account not found';
|
|
end if;
|
|
|
|
if not v_is_personal_account then
|
|
raise exception 'cannot add team accounts to projects - only individual user accounts can be project members';
|
|
end if;
|
|
|
|
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 function
|
|
CREATE TRIGGER ensure_project_member_in_team
|
|
BEFORE INSERT OR UPDATE ON public.project_members
|
|
FOR 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.
|
|
|
|
```sql
|
|
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:
|
|
|
|
```sql
|
|
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:
|
|
|
|
```sql
|
|
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:
|
|
|
|
```sql
|
|
SELECT * FROM public.projects
|
|
WHERE 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:
|
|
|
|
```sql
|
|
-- Check if the current user can edit a project
|
|
SELECT public.can_edit_project(auth.uid(), :project_id);
|
|
|
|
-- Check if the current user can delete a project
|
|
SELECT public.can_delete_project(auth.uid(), :project_id);
|
|
|
|
-- Check if the current user can invite members to a project
|
|
SELECT 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.
|
|
|
|
```tsx {% title="apps/web/lib/server/projects/projects.service.ts" %}
|
|
import { SupabaseClient } from '@supabase/supabase-js';
|
|
|
|
import { Database, Tables } 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<
|
|
string,
|
|
Tables<'project_members'> & {
|
|
account: {
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
};
|
|
}
|
|
>('*, account: account_id ! inner (id, name, email)')
|
|
.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;
|
|
}
|
|
|
|
async removeProjectMember(params: { projectId: string; userId: string }) {
|
|
const { error } = await this.client.from('project_members').delete().match({
|
|
project_id: params.projectId,
|
|
account_id: params.userId,
|
|
});
|
|
|
|
if (error) {
|
|
throw error;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|
|
```
|
|
|
|
Let's look at some key methods:
|
|
|
|
1. **createProject**: This method inserts a new project into the `projects` table.
|
|
2. **getProjects**: Fetches all projects for a given account.
|
|
3. **getProjectMembers**: Retrieves all members of a specific project.
|
|
4. **getProject**: Fetches details of a single project.
|
|
5. **hasPermission**: Checks if the current user has a specific permission for a project.
|
|
6. **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:
|
|
|
|
```typescript
|
|
const client = getSupabaseServerClient();
|
|
const service = createProjectsService(client);
|
|
```
|
|
|
|
We fetch the projects for the current account:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```tsx
|
|
<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:
|
|
|
|
```tsx {% title="apps/web/app/[locale]/home/[account]/projects/page.tsx" %}
|
|
import { use } from 'react';
|
|
|
|
import Link from 'next/link';
|
|
|
|
import { getSupabaseServerClient } from '@kit/supabase/server-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: Promise<{
|
|
account: string;
|
|
}>;
|
|
}
|
|
|
|
export default function ProjectsPage({ params }: ProjectsPageProps) {
|
|
const client = getSupabaseServerClient();
|
|
const service = createProjectsService(client);
|
|
|
|
const { account } = use(params);
|
|
|
|
const projects = use(service.getProjects(account));
|
|
|
|
return (
|
|
<>
|
|
<PageHeader title="Projects" description={<AppBreadcrumbs />}>
|
|
<CreateProjectDialog>
|
|
<Button>New Project</Button>
|
|
</CreateProjectDialog>
|
|
</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/${account}/projects/${project.id}`}>
|
|
<CardButtonHeader>
|
|
<CardButtonTitle>{project.name}</CardButtonTitle>
|
|
</CardButtonHeader>
|
|
</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:
|
|
|
|
1. **Creating a New Project**: You could create a form that calls `service.createProject()` when submitted.
|
|
2. **Project Details Page**: Use `service.getProject()` to fetch and display details of a single project.
|
|
3. **Managing Project Members**: Use `service.getProjectMembers()` to list members, and `service.addProjectMember()` to add new ones.
|
|
4. **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.
|
|
|
|
```sql
|
|
-- Table: public.tasks
|
|
CREATE 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.
|
|
|
|
```sql
|
|
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
|
|
|
|
1. **Error Handling**: The service methods throw errors when something goes wrong. Make sure to catch and handle these errors appropriately in your components.
|
|
2. **Type Safety**: The service uses TypeScript, leveraging the `Database` type to ensure type safety when interacting with Supabase.
|
|
3. **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.
|
|
4. **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.
|
|
5. **Reusability**: The `ProjectsService` can be used across different parts of your application, promoting code reuse.
|
|
|
|
## Full Schema
|
|
|
|
Below is the full schema:
|
|
|
|
```sql
|
|
-- enums for project roles: owner, admin, member
|
|
create type public.project_role as enum ('owner', 'admin', 'member');
|
|
|
|
-- enums for project actions
|
|
create 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.projects
|
|
revoke all on public.projects from public, service_role;
|
|
-- grant access to authenticated users
|
|
grant select, insert, update, delete on public.projects to authenticated;
|
|
|
|
-- indexes on public.projects
|
|
create index projects_account_id on public.projects (account_id);
|
|
|
|
-- RLS policies on public.projects
|
|
alter 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,
|
|
account_id uuid not null references public.accounts(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, account_id)
|
|
);
|
|
|
|
-- make sure there is only one owner per project
|
|
create unique index projects_unique_owner on public.project_members (project_id) where role = 'owner';
|
|
|
|
-- indexes on public.project_members
|
|
create index project_members_project_id on public.project_members (project_id);
|
|
create index project_members_account_id on public.project_members (account_id);
|
|
|
|
-- revoke access by default to public.project_members
|
|
revoke all on public.project_members from public, service_role;
|
|
|
|
-- grant access to authenticated users to public.project_members
|
|
grant select, insert, update, delete on public.project_members to authenticated;
|
|
|
|
-- RLS policies on public.project_members
|
|
alter table public.project_members enable row level security;
|
|
|
|
-- public.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 boolean
|
|
set search_path = ''
|
|
as $$
|
|
select exists (
|
|
select 1 from public.project_members
|
|
where project_id = p_project_id
|
|
and account_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 project
|
|
create or replace function public.is_project_admin(p_project_id uuid)
|
|
returns boolean
|
|
set search_path = ''
|
|
as $$
|
|
select exists (
|
|
select 1 from public.project_members
|
|
where project_id = p_project_id
|
|
and account_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 project
|
|
create or replace function public.is_project_owner(project_id uuid)
|
|
returns boolean
|
|
set search_path = ''
|
|
as $$
|
|
select exists (
|
|
select 1 from public.project_members
|
|
where project_id = $1
|
|
and account_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 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 boolean
|
|
set 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 account_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_permission
|
|
create or replace function public.current_user_has_project_permission(
|
|
p_project_id uuid,
|
|
p_action public.project_action
|
|
)
|
|
returns boolean
|
|
set search_path = ''
|
|
as $$
|
|
begin
|
|
return 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 project
|
|
create or replace function public.current_user_can_manage_project_member(
|
|
p_target_member_role public.project_role,
|
|
p_project_id uuid
|
|
)
|
|
returns boolean
|
|
set 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 account_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 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 boolean
|
|
set 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 account_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 account_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 project
|
|
create or replace function public.can_edit_project(p_user_auth_id uuid, p_project_id uuid)
|
|
returns boolean
|
|
set 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 project
|
|
create or replace function public.can_delete_project(p_user_auth_id uuid, p_project_id uuid)
|
|
returns boolean
|
|
set 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 project
|
|
create or replace function public.can_invite_project_member(p_user_auth_id uuid, p_project_id uuid)
|
|
returns boolean
|
|
set 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 member
|
|
create or replace function kit.add_project_owner()
|
|
returns trigger
|
|
as $$
|
|
begin
|
|
insert into public.project_members (project_id, account_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 member
|
|
create 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 boolean
|
|
set search_path = ''
|
|
as $$
|
|
declare
|
|
v_account_id uuid;
|
|
v_is_personal_account boolean;
|
|
begin
|
|
-- 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 target user account is a personal account (not a team account)
|
|
-- Use security definer context to bypass RLS for this validation
|
|
select is_personal_account into v_is_personal_account
|
|
from public.accounts
|
|
where id = p_user_id;
|
|
|
|
if v_is_personal_account is null then
|
|
raise exception 'user account not found';
|
|
end if;
|
|
|
|
if not v_is_personal_account then
|
|
raise exception 'cannot invite team accounts to projects - only individual users can be invited';
|
|
end if;
|
|
|
|
-- 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;
|
|
|
|
-- 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, account_id, role)
|
|
values (p_project_id, p_user_id, p_role)
|
|
on conflict (project_id, account_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 account and is a personal account
|
|
create or replace function kit.check_project_member_in_team()
|
|
returns trigger
|
|
as $$
|
|
declare
|
|
v_account_id uuid;
|
|
v_is_personal_account boolean;
|
|
begin
|
|
select account_id from public.projects
|
|
where id = new.project_id
|
|
into v_account_id;
|
|
|
|
-- check if the account being added is a personal account (not a team account)
|
|
select is_personal_account into v_is_personal_account
|
|
from public.accounts
|
|
where id = new.account_id;
|
|
|
|
if v_is_personal_account is null then
|
|
raise exception 'account not found';
|
|
end if;
|
|
|
|
if not v_is_personal_account then
|
|
raise exception 'cannot add team accounts to projects - only individual user accounts can be project members';
|
|
end if;
|
|
|
|
if not exists (
|
|
select 1 from public.accounts_memberships
|
|
where account_id = v_account_id and new.account_id = public.accounts_memberships.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 function
|
|
create trigger ensure_project_member_in_team
|
|
before insert or update on public.project_members
|
|
for each row execute function kit.check_project_member_in_team();
|
|
|
|
create or replace function public.get_account_members_by_query(
|
|
p_account_id uuid,
|
|
p_query text
|
|
)
|
|
returns table (
|
|
account_id uuid,
|
|
user_id uuid,
|
|
id uuid,
|
|
name varchar(255),
|
|
email varchar(255),
|
|
picture_url varchar(1000)
|
|
) language plpgsql
|
|
set search_path = ''
|
|
as $$
|
|
begin
|
|
return QUERY select
|
|
am.account_id,
|
|
am.user_id,
|
|
acc.id,
|
|
acc.name,
|
|
acc.email,
|
|
acc.picture_url
|
|
from
|
|
public.accounts_memberships am
|
|
join public.accounts a on a.id = am.account_id
|
|
join public.accounts acc on acc.id = am.user_id
|
|
where
|
|
am.account_id = p_account_id and
|
|
(to_tsvector(acc.name || ' ' || acc.email) @@ plainto_tsquery(p_query))
|
|
and am.user_id != (select auth.uid())
|
|
and acc.is_personal_account = true; -- only return personal accounts, not team accounts
|
|
|
|
end
|
|
$$;
|
|
|
|
grant execute on function public.get_account_members_by_query to authenticated, service_role;
|
|
```
|
|
|
|
And here are the tests for the above schema:
|
|
|
|
```sql
|
|
BEGIN;
|
|
|
|
create extension "basejump-supabase_test_helpers" version '0.0.6';
|
|
|
|
select
|
|
no_plan ();
|
|
|
|
--- Create users which will automatically create personal accounts via triggers
|
|
select
|
|
tests.create_supabase_user ('primary_owner', 'project-test-owner@example.com');
|
|
|
|
select
|
|
tests.create_supabase_user ('admin', 'project-test-admin@example.com');
|
|
|
|
select
|
|
tests.create_supabase_user ('member', 'project-test-member@example.com');
|
|
|
|
select
|
|
tests.create_supabase_user ('custom', 'project-test-custom@example.com');
|
|
|
|
-- Authenticate as primary_owner to trigger the personal account creation
|
|
select makerkit.authenticate_as('primary_owner');
|
|
|
|
-- Wait for personal accounts to be created by the triggers
|
|
select pg_sleep(0.2);
|
|
|
|
-- Also authenticate as each user to trigger their personal account creation
|
|
select makerkit.authenticate_as('admin');
|
|
select pg_sleep(0.1);
|
|
select makerkit.authenticate_as('member');
|
|
select pg_sleep(0.1);
|
|
select makerkit.authenticate_as('custom');
|
|
select pg_sleep(0.1);
|
|
|
|
-- Switch to elevated permissions to set up team memberships
|
|
reset role;
|
|
|
|
-- Add all test users to the team account so they can be invited to projects
|
|
INSERT INTO public.accounts_memberships (account_id, user_id, account_role, created_at, updated_at)
|
|
VALUES
|
|
((select makerkit.get_account_id_by_slug('makerkit')), tests.get_supabase_uid('primary_owner'), 'owner', now(), now()),
|
|
((select makerkit.get_account_id_by_slug('makerkit')), tests.get_supabase_uid('admin'), 'member', now(), now()),
|
|
((select makerkit.get_account_id_by_slug('makerkit')), tests.get_supabase_uid('member'), 'member', now(), now()),
|
|
((select makerkit.get_account_id_by_slug('makerkit')), tests.get_supabase_uid('custom'), 'member', now(), now())
|
|
ON CONFLICT (user_id, account_id) DO UPDATE SET
|
|
account_role = EXCLUDED.account_role;
|
|
|
|
create
|
|
or replace function tests.get_project_uuid () returns uuid as $$
|
|
-- return a valid UUID
|
|
select 'd8e789e4-8425-494b-9986-ad88ef430382'::uuid;
|
|
$$ language sql immutable;
|
|
|
|
select
|
|
makerkit.authenticate_as ('primary_owner');
|
|
|
|
-- Create projects as an authenticated user so the trigger can work properly
|
|
INSERT INTO
|
|
public.projects (id, name, description, account_id)
|
|
VALUES
|
|
(
|
|
tests.get_project_uuid (),
|
|
'Test Project 1',
|
|
'Description 1',
|
|
(
|
|
select
|
|
makerkit.get_account_id_by_slug ('makerkit')
|
|
)
|
|
),
|
|
(
|
|
'd0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
|
|
'Test Project 2',
|
|
'Description 2',
|
|
(
|
|
select
|
|
makerkit.get_account_id_by_slug ('makerkit')
|
|
)
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('primary_owner');
|
|
|
|
set
|
|
local role postgres;
|
|
|
|
select
|
|
lives_ok (
|
|
$$ INSERT INTO public.project_members (project_id, account_id, role)
|
|
VALUES
|
|
(
|
|
tests.get_project_uuid (),
|
|
tests.get_supabase_uid ('member'),
|
|
'member'
|
|
); $$,
|
|
'Inserting a user should create a personal account when personal accounts are enabled'
|
|
);
|
|
|
|
select
|
|
throws_ok (
|
|
$$ INSERT INTO public.project_members (project_id, account_id, role)
|
|
values (
|
|
tests.get_project_uuid (),
|
|
tests.get_supabase_uid ('admin'),
|
|
'owner'
|
|
) $$,
|
|
'duplicate key value violates unique constraint "projects_unique_owner"',
|
|
'A project can only have one owner'
|
|
);
|
|
|
|
select
|
|
lives_ok (
|
|
$$ INSERT INTO public.project_members (project_id, account_id, role)
|
|
values (
|
|
tests.get_project_uuid (),
|
|
tests.get_supabase_uid ('admin'),
|
|
'admin'
|
|
) $$,
|
|
'A project can add an admin'
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('primary_owner');
|
|
|
|
select
|
|
is (
|
|
(
|
|
select
|
|
count(*)
|
|
from
|
|
public.project_members
|
|
where
|
|
project_id = tests.get_project_uuid ()
|
|
)::int,
|
|
3,
|
|
'The primary owner should be able to see the other members'
|
|
);
|
|
|
|
-- Test is_project_member function
|
|
SELECT
|
|
lives_ok (
|
|
$$ select public.is_project_member (tests.get_project_uuid ()); $$,
|
|
'is_project_member should return true for existing member'
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('custom');
|
|
|
|
SELECT
|
|
lives_ok (
|
|
$$ select not public.is_project_member (tests.get_project_uuid ()); $$,
|
|
'is_project_member should return false for non-member'
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('primary_owner');
|
|
|
|
-- Test is_project_admin function
|
|
SELECT
|
|
lives_ok (
|
|
$$ select public.is_project_admin (tests.get_project_uuid ()); $$,
|
|
'is_project_admin should return true for admin'
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('admin');
|
|
|
|
select
|
|
lives_ok (
|
|
$$ select public.is_project_admin (tests.get_project_uuid ()); $$,
|
|
'is_project_admin should return true for owner'
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('member');
|
|
|
|
SELECT
|
|
lives_ok (
|
|
$$
|
|
select NOT public.is_project_admin (tests.get_project_uuid ())$$,
|
|
'is_project_admin should return false for non-admin'
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('primary_owner');
|
|
|
|
-- Test user_has_project_permission function
|
|
SELECT
|
|
lives_ok (
|
|
$$ select public.user_has_project_permission (
|
|
tests.get_supabase_uid ('primary_owner'),
|
|
tests.get_project_uuid (),
|
|
'delete_project'::project_action)
|
|
$$,
|
|
'Owner should have delete_project permission'
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('admin');
|
|
|
|
SELECT
|
|
lives_ok (
|
|
$$ select public.user_has_project_permission (
|
|
tests.get_supabase_uid ('admin'),
|
|
tests.get_project_uuid (),
|
|
'edit_project'::project_action) $$,
|
|
'Admin should have edit_project permission'
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('member');
|
|
|
|
SELECT
|
|
lives_ok (
|
|
$$ select NOT public.user_has_project_permission (
|
|
tests.get_supabase_uid ('member'),
|
|
tests.get_project_uuid (),
|
|
'delete_project'::project_action)
|
|
$$,
|
|
'Member should not have delete_project permission'
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('primary_owner');
|
|
|
|
select
|
|
isnt_empty (
|
|
$$ select * from public.project_members where project_id = tests.get_project_uuid() $$,
|
|
'The primary owner should be able to see the other members'
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('primary_owner');
|
|
|
|
-- Test update_project_member_role function
|
|
SELECT
|
|
lives_ok (
|
|
$$ SELECT public.update_project_member_role(
|
|
tests.get_supabase_uid('member'),
|
|
'admin'::project_role,
|
|
tests.get_project_uuid()
|
|
) $$,
|
|
'Owner should be able to update member role to admin'
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('primary_owner');
|
|
|
|
select
|
|
isnt_empty (
|
|
$$ select * from public.project_members
|
|
where project_id = tests.get_project_uuid()
|
|
$$,
|
|
'Primary owner should be able to see members'
|
|
);
|
|
|
|
SELECT
|
|
lives_ok (
|
|
$$ delete from public.project_members
|
|
where account_id = tests.get_supabase_uid('member') and project_id = tests.get_project_uuid();
|
|
$$,
|
|
'Owner should be able to remove a member'
|
|
);
|
|
|
|
select
|
|
is_empty (
|
|
$$ select * from public.project_members where project_id = tests.get_project_uuid() and account_id = tests.get_supabase_uid('member') $$,
|
|
'The primary owner has removed the member'
|
|
);
|
|
|
|
-- reinsert member
|
|
insert into
|
|
public.project_members (project_id, account_id, role)
|
|
values
|
|
(
|
|
tests.get_project_uuid (),
|
|
tests.get_supabase_uid ('member'),
|
|
'member'
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('admin');
|
|
|
|
-- Test add_project_member function
|
|
SELECT
|
|
throws_ok (
|
|
$$SELECT public.add_project_member(tests.get_project_uuid(), tests.get_supabase_uid('member'), 'owner'::public.project_role)$$,
|
|
'permission denied',
|
|
'Should not be able to add a member with owner role'
|
|
);
|
|
|
|
SELECT
|
|
lives_ok (
|
|
$$SELECT public.add_project_member(tests.get_project_uuid(), tests.get_supabase_uid('custom'), 'member'::public.project_role)$$,
|
|
'Admin should be able to add a new member'
|
|
);
|
|
|
|
-- Additional tests for is_project_member function
|
|
SELECT
|
|
lives_ok (
|
|
$$ select NOT public.is_project_member ('d0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11') $$,
|
|
'is_project_member should return false for a project the user is not a member of'
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('primary_owner');
|
|
|
|
-- Test is_project_owner function
|
|
SELECT
|
|
lives_ok (
|
|
$$ select public.is_project_owner (tests.get_project_uuid ())$$,
|
|
'is_project_owner should return true for the project owner'
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('admin');
|
|
|
|
SELECT
|
|
lives_ok (
|
|
$$ select NOT public.is_project_owner (tests.get_project_uuid ())$$,
|
|
'is_project_owner should return false for non-owner (admin)'
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('member');
|
|
|
|
SELECT
|
|
lives_ok (
|
|
$$ select NOT public.is_project_owner (tests.get_project_uuid ())$$,
|
|
'is_project_owner should return false for non-owner (member)'
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('primary_owner');
|
|
|
|
-- Additional tests for user_has_project_permission function
|
|
SELECT
|
|
lives_ok (
|
|
$$ select public.user_has_project_permission (
|
|
tests.get_supabase_uid ('primary_owner'),
|
|
tests.get_project_uuid (),
|
|
'invite_member'::project_action
|
|
) $$,
|
|
'Owner should have invite_member permission'
|
|
);
|
|
|
|
SELECT
|
|
lives_ok (
|
|
$$ select public.user_has_project_permission (
|
|
tests.get_supabase_uid ('primary_owner'),
|
|
tests.get_project_uuid (),
|
|
'remove_member'::project_action
|
|
)$$,
|
|
'Owner should have remove_member permission'
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('admin');
|
|
|
|
SELECT
|
|
lives_ok (
|
|
$$ select public.user_has_project_permission (
|
|
tests.get_supabase_uid ('admin'),
|
|
tests.get_project_uuid (),
|
|
'invite_member'::project_action
|
|
) $$,
|
|
'Admin should have invite_member permission'
|
|
);
|
|
|
|
SELECT
|
|
lives_ok (
|
|
$$ select NOT public.user_has_project_permission (
|
|
tests.get_supabase_uid ('admin'),
|
|
tests.get_project_uuid (),
|
|
'delete_project'::project_action
|
|
)$$,
|
|
'Admin should not have delete_project permission'
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('member');
|
|
|
|
SELECT
|
|
lives_ok (
|
|
$$ select public.user_has_project_permission (
|
|
tests.get_supabase_uid ('member'),
|
|
tests.get_project_uuid (),
|
|
'view_project'::project_action
|
|
)$$,
|
|
'Member should have view_project permission'
|
|
);
|
|
|
|
SELECT
|
|
lives_ok (
|
|
$$ select NOT public.user_has_project_permission (
|
|
tests.get_supabase_uid ('member'),
|
|
tests.get_project_uuid (),
|
|
'invite_member'::project_action
|
|
)$$,
|
|
'Member should not have invite_member permission'
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('primary_owner');
|
|
|
|
-- Test add_project_member function
|
|
SELECT
|
|
lives_ok (
|
|
$$SELECT public.add_project_member(tests.get_project_uuid(), tests.get_supabase_uid('custom'), 'member'::public.project_role)$$,
|
|
'Owner should be able to add a new member'
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('admin');
|
|
|
|
SELECT
|
|
lives_ok (
|
|
$$SELECT public.add_project_member(tests.get_project_uuid(), tests.get_supabase_uid('custom'), 'member'::public.project_role)$$,
|
|
'Admin should be able to add a new member'
|
|
);
|
|
|
|
SELECT
|
|
throws_ok (
|
|
$$SELECT public.add_project_member(tests.get_project_uuid(), tests.get_supabase_uid('custom'), 'owner'::public.project_role)$$,
|
|
'permission denied',
|
|
'Admin should not be able to add a member with owner role'
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('member');
|
|
|
|
SELECT
|
|
throws_ok (
|
|
$$SELECT public.add_project_member(tests.get_project_uuid(), tests.get_supabase_uid('custom'), 'member'::public.project_role)$$,
|
|
'permission denied',
|
|
'Member should not be able to add a new member'
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('primary_owner');
|
|
|
|
-- Test RLS policies
|
|
SELECT
|
|
results_eq (
|
|
$$SELECT count(*) FROM public.projects$$,
|
|
ARRAY[2::bigint],
|
|
'Owner should see all projects'
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('admin');
|
|
|
|
SELECT
|
|
results_eq (
|
|
$$SELECT count(*) FROM public.projects$$,
|
|
ARRAY[1::bigint],
|
|
'Member should only see projects they are a member of'
|
|
);
|
|
|
|
select
|
|
tests.create_supabase_user ('foreigner', 'foreigner@test.com');
|
|
|
|
select
|
|
makerkit.authenticate_as ('foreigner');
|
|
|
|
SELECT
|
|
results_eq (
|
|
$$SELECT count(*) FROM public.projects$$,
|
|
ARRAY[0::bigint],
|
|
'Non-member should not see any projects'
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('primary_owner');
|
|
|
|
-- Testing direct updates for RLS policies
|
|
select
|
|
makerkit.authenticate_as ('primary_owner');
|
|
|
|
select
|
|
isnt_empty (
|
|
$$ select * from public.project_members where project_id = tests.get_project_uuid() $$,
|
|
'The primary owner should be able to see the other members'
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('admin');
|
|
|
|
select
|
|
isnt_empty (
|
|
$$ select * from public.project_members where project_id = tests.get_project_uuid() $$,
|
|
'The owner should be able to see the other members'
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('member');
|
|
|
|
select
|
|
isnt_empty (
|
|
$$ select * from public.project_members where project_id = tests.get_project_uuid() $$,
|
|
'The member should be able to see the other members'
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('custom');
|
|
|
|
select
|
|
isnt_empty (
|
|
$$ select * from public.project_members where project_id = tests.get_project_uuid() $$,
|
|
'The custom user should be able to see the other members'
|
|
);
|
|
|
|
select
|
|
makerkit.authenticate_as ('foreigner');
|
|
|
|
select
|
|
is_empty (
|
|
$$ select * from public.project_members where project_id = tests.get_project_uuid() $$,
|
|
'The foreigner should not be able to see the other members'
|
|
);
|
|
|
|
-- try to update a role
|
|
select
|
|
makerkit.authenticate_as ('primary_owner');
|
|
|
|
select
|
|
row_eq (
|
|
$$ select account_id, role from public.project_members where project_id = tests.get_project_uuid() $$,
|
|
row (
|
|
tests.get_supabase_uid ('primary_owner'),
|
|
'owner'::public.project_role
|
|
),
|
|
'The primary owner should be able to see the other members'
|
|
);
|
|
|
|
SELECT
|
|
lives_ok (
|
|
$$ update public.project_members set role = 'admin' where project_id = tests.get_project_uuid() and account_id = tests.get_supabase_uid('member') $$,
|
|
'Owner should be able to update a member role'
|
|
);
|
|
|
|
SELECT
|
|
row_eq (
|
|
$$ select role from public.project_members where project_id = tests.get_project_uuid() and account_id = tests.get_supabase_uid('member') $$,
|
|
row ('admin'::public.project_role),
|
|
'Owner has updated member role'
|
|
);
|
|
|
|
-- revert role to member
|
|
update public.project_members
|
|
set role = 'member'
|
|
where
|
|
project_id = tests.get_project_uuid ()
|
|
and account_id = tests.get_supabase_uid ('member');
|
|
|
|
-- Test update project
|
|
select
|
|
lives_ok (
|
|
$$ update public.projects set name = 'Updated Project' where id = tests.get_project_uuid() $$,
|
|
'Owner should be able to update a project'
|
|
);
|
|
|
|
select
|
|
row_eq (
|
|
$$ select name from public.projects where id = tests.get_project_uuid() $$,
|
|
row ('Updated Project'::varchar),
|
|
'Owner has updated project name'
|
|
);
|
|
|
|
-- Test update project as member'
|
|
select
|
|
makerkit.authenticate_as ('member');
|
|
|
|
select
|
|
lives_ok (
|
|
$$ update public.projects set name = 'Updated Project 2' where id = tests.get_project_uuid() $$,
|
|
'Failing Updates are silently ignored'
|
|
);
|
|
|
|
select
|
|
row_eq (
|
|
$$ select name from public.projects where id = tests.get_project_uuid() $$,
|
|
row ('Updated Project'::varchar),
|
|
'Member has not updated project name'
|
|
);
|
|
|
|
-- Test cascading updates
|
|
select
|
|
makerkit.authenticate_as ('primary_owner');
|
|
|
|
SELECT
|
|
lives_ok (
|
|
$$DELETE FROM public.projects WHERE id = tests.get_project_uuid()$$,
|
|
'Owner should be able to delete a project'
|
|
);
|
|
|
|
SELECT
|
|
results_eq (
|
|
$$SELECT count(*) FROM public.project_members WHERE project_id = tests.get_project_uuid()$$,
|
|
ARRAY[0::bigint],
|
|
'Project members should be deleted when project is deleted'
|
|
);
|
|
|
|
-- Additional tests for account type validation
|
|
select
|
|
makerkit.authenticate_as ('primary_owner');
|
|
|
|
-- Create a team account for testing
|
|
reset role;
|
|
|
|
INSERT INTO
|
|
public.accounts (id, name, slug, is_personal_account, primary_owner_user_id)
|
|
VALUES
|
|
(
|
|
'11111111-1111-1111-1111-111111111111',
|
|
'Test Team Account',
|
|
'test-team',
|
|
false,
|
|
tests.get_supabase_uid ('primary_owner')
|
|
);
|
|
|
|
-- Note: We don't add the team account to memberships because:
|
|
-- 1. It would violate foreign key constraints (team account UUID not in auth.users)
|
|
-- 2. Team accounts should never be members of other accounts anyway
|
|
-- The function validation will catch the account type before membership validation
|
|
|
|
-- Reset to authenticated role for testing
|
|
select
|
|
makerkit.authenticate_as ('primary_owner');
|
|
|
|
-- Try to add a team account to project members (should fail via trigger)
|
|
set
|
|
local role postgres;
|
|
SELECT
|
|
throws_ok (
|
|
$$ INSERT INTO public.project_members (project_id, account_id, role)
|
|
VALUES (
|
|
tests.get_project_uuid(),
|
|
'11111111-1111-1111-1111-111111111111',
|
|
'member'
|
|
) $$,
|
|
'cannot add team accounts to projects - only individual user accounts can be project members',
|
|
'Should not be able to add team accounts as project members via trigger'
|
|
);
|
|
|
|
-- Reset role for function testing
|
|
reset role;
|
|
select
|
|
makerkit.authenticate_as ('primary_owner');
|
|
|
|
-- Test add_project_member function with team account (should fail)
|
|
SELECT
|
|
throws_ok (
|
|
$$ SELECT public.add_project_member(
|
|
tests.get_project_uuid(),
|
|
'11111111-1111-1111-1111-111111111111',
|
|
'member'::public.project_role
|
|
) $$,
|
|
'cannot invite team accounts to projects - only individual users can be invited',
|
|
'Should not be able to add team accounts via add_project_member function'
|
|
);
|
|
|
|
-- Test with non-existent account (should fail)
|
|
SELECT
|
|
throws_ok (
|
|
$$ SELECT public.add_project_member(
|
|
tests.get_project_uuid(),
|
|
'22222222-2222-2222-2222-222222222222',
|
|
'member'::public.project_role
|
|
) $$,
|
|
'user account not found',
|
|
'Should fail when trying to add non-existent account'
|
|
);
|
|
|
|
-- Test get_account_members_by_query excludes team accounts
|
|
SELECT
|
|
is_empty (
|
|
$$ SELECT * FROM public.get_account_members_by_query(
|
|
(select makerkit.get_account_id_by_slug('makerkit')),
|
|
'Test Team Account'
|
|
) $$,
|
|
'get_account_members_by_query should not return team accounts'
|
|
);
|
|
|
|
-- Test that personal accounts appear in query results
|
|
SELECT
|
|
isnt_empty (
|
|
$$ SELECT * FROM public.get_account_members_by_query(
|
|
(select makerkit.get_account_id_by_slug('makerkit')),
|
|
'custom'
|
|
) $$,
|
|
'get_account_members_by_query should return personal accounts'
|
|
);
|
|
|
|
-- Test add_project_member validation for user not in team
|
|
select
|
|
tests.create_supabase_user ('outsider', 'outsider@test.com');
|
|
|
|
-- Authenticate as outsider to trigger personal account creation
|
|
select makerkit.authenticate_as('outsider');
|
|
|
|
-- Go back to primary_owner for the test
|
|
select makerkit.authenticate_as('primary_owner');
|
|
|
|
SELECT
|
|
throws_ilike (
|
|
$$ SELECT public.add_project_member(
|
|
tests.get_project_uuid(),
|
|
tests.get_supabase_uid('outsider'),
|
|
'member'::public.project_role
|
|
) $$,
|
|
'user account not found',
|
|
'User is not a member of the team account'
|
|
);
|
|
|
|
-- Finish the tests
|
|
SELECT
|
|
*
|
|
FROM
|
|
finish ();
|
|
|
|
ROLLBACK;
|
|
```
|
|
|
|
## User Interface
|
|
|
|
Now that we have a data model, let's create the UI for managing projects. We will create a new page at `apps/web/app/[locale]/home/[account]/projects/page.tsx` that will display a list of projects for the current account.
|
|
|
|
### Adding Translations
|
|
|
|
Before we add the navigation link, we need to add the translation for the "Projects" route. Add the following to your translation files:
|
|
|
|
In `apps/web/i18n/messages/en/common.json`, add to the `routes` section:
|
|
|
|
```json
|
|
{
|
|
"routes": {
|
|
// ... existing routes
|
|
"projects": "Projects"
|
|
}
|
|
}
|
|
```
|
|
|
|
### Adding the Page to the Navigation
|
|
|
|
Let's update the navigation menu to add a new link to the projects page. Update the team account navigation configuration at `apps/web/config/team-account-navigation.config.tsx` by adding:
|
|
|
|
```tsx {% title="apps/web/config/team-account-navigation.config.tsx" %} {7-11}
|
|
{
|
|
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} />,
|
|
},
|
|
```
|
|
|
|
**Note:** You need to import the `FolderKanban` component from `lucide-react` to use the icon.
|
|
|
|
### Creating the Projects List Page
|
|
|
|
We can now create the page at `apps/web/app/[locale]/home/[account]/projects/page.tsx` with the following content:
|
|
|
|
```tsx {% title="apps/web/app/[locale]/home/[account]/projects/page.tsx" %}
|
|
import { use } from 'react';
|
|
|
|
import Link from 'next/link';
|
|
|
|
import { getSupabaseServerClient } from '@kit/supabase/server-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: Promise<{
|
|
account: string;
|
|
}>;
|
|
}
|
|
|
|
export default function ProjectsPage({ params }: ProjectsPageProps) {
|
|
const client = getSupabaseServerClient();
|
|
const service = createProjectsService(client);
|
|
|
|
const { account } = use(params);
|
|
|
|
const projects = use(service.getProjects(account));
|
|
|
|
return (
|
|
<>
|
|
<PageHeader title="Projects" description={<AppBreadcrumbs />}>
|
|
<CreateProjectDialog>
|
|
<Button>New Project</Button>
|
|
</CreateProjectDialog>
|
|
</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/${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:
|
|
|
|
{% img src="/assets/images/docs/projects-list.webp" width="2858" height="1920" /%}
|
|
|
|
Instead, we can also display a message when there are no projects:
|
|
|
|
{% img src="/assets/images/docs/projects-empty-state.webp" width="2858" height="1920" /%}
|
|
|
|
### Creating the Schema for Project Creation
|
|
|
|
First, we need to create a schema for the project creation form. Create a new file at `apps/web/app/[locale]/home/[account]/projects/_lib/schema/create-project-schema.ts`:
|
|
|
|
```typescript {% title="apps/web/app/[locale]/home/[account]/projects/_lib/schema/create-project-schema.ts" %}
|
|
import * as z from 'zod';
|
|
|
|
export const CreateProjectSchema = z.object({
|
|
name: z.string().min(3).max(50),
|
|
accountId: z.string().uuid(),
|
|
description: z.string().optional(),
|
|
});
|
|
|
|
export type CreateProjectSchemaType = z.infer<typeof CreateProjectSchema>;
|
|
```
|
|
|
|
### 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/app/[locale]/home/[account]/projects/_components/create-project-dialog.tsx` with the following content:
|
|
|
|
```tsx {% title="apps/web/app/[locale]/home/[account]/projects/_components/create-project-dialog.tsx" %}
|
|
'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/app/[locale]/home/[account]/projects/_lib/server/server-actions.ts` with the following content:
|
|
|
|
```tsx {% title="apps/web/app/[locale]/home/[account]/projects/_lib/server/server-actions.ts" %}
|
|
'use server';
|
|
|
|
import { revalidatePath } from 'next/cache';
|
|
|
|
import { authActionClient } from '@kit/next/safe-action';
|
|
import { getLogger } from '@kit/shared/logger';
|
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
|
|
|
import { CreateProjectSchema } from '../schema/create-project-schema';
|
|
|
|
export const createProjectAction = authActionClient
|
|
.inputSchema(CreateProjectSchema)
|
|
.action(async ({ parsedInput: 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');
|
|
});
|
|
```
|
|
|
|
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/app/[locale]/home/[account]/projects/[id]/layout.tsx` with the following content:
|
|
|
|
```tsx
|
|
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: Promise<{
|
|
id: string;
|
|
account: string;
|
|
}>;
|
|
}
|
|
|
|
export default function ProjectDetailLayout({
|
|
params,
|
|
children,
|
|
}: React.PropsWithChildren<ProjectDetailLayoutProps>) {
|
|
const { id, account } = use(params);
|
|
const project = use(getProject(id));
|
|
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title={project.name}
|
|
description={<AppBreadcrumbs values={{ [project.id]: project.name }} />}
|
|
/>
|
|
|
|
<PageBody className={'space-y-4'}>
|
|
<div className={'border-b px-4 pb-2.5'}>
|
|
<BorderedNavigationMenu>
|
|
<BorderedNavigationMenuItem
|
|
path={`/home/${account}/projects/${project.id}`}
|
|
label={'Documents'}
|
|
/>
|
|
|
|
<BorderedNavigationMenuItem
|
|
path={`/home/${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.
|
|
|
|
```tsx {% title="apps/web/app/[locale]/home/[account]/projects/[id]/_lib/server/get-project.ts" %}
|
|
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/[locale]/home/[account]/projects/[id]/page.tsx` with the following content:
|
|
|
|
```tsx {% title="apps/web/app/[locale]/home/[account]/projects/[id]/page.tsx" %}
|
|
interface ProjectDetailPageProps {
|
|
params: Promise<{
|
|
id: string;
|
|
account: string;
|
|
}>;
|
|
}
|
|
|
|
export default function ProjectDetailPage(_: ProjectDetailPageProps) {
|
|
return <div className={'flex flex-col space-y-4'}>...</div>;
|
|
}
|
|
```
|
|
|
|
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.
|