feat: add personal projects
personal projects are projects that have no team. they can only seen by the project members (one of which is whoever first creates the project).
This commit is contained in:
parent
28a53f14ad
commit
4277b7b2a8
@ -137,7 +137,7 @@ const ProjectFinder = () => {
|
||||
return {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
projects: projects.filter(project => project.team.id === team.id),
|
||||
projects: projects.filter(project => project.team && project.team.id === team.id),
|
||||
};
|
||||
});
|
||||
return (
|
||||
|
@ -158,7 +158,7 @@ const Project = () => {
|
||||
|
||||
const [updateTaskName] = useUpdateTaskNameMutation();
|
||||
|
||||
const { loading, data } = useFindProjectQuery({
|
||||
const { loading, data, error } = useFindProjectQuery({
|
||||
variables: { projectID },
|
||||
});
|
||||
|
||||
@ -224,6 +224,9 @@ const Project = () => {
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
history.push('/projects');
|
||||
}
|
||||
if (data) {
|
||||
labelsRef.current = data.findProject.labels;
|
||||
|
||||
@ -260,7 +263,7 @@ const Project = () => {
|
||||
currentTab={0}
|
||||
projectMembers={data.findProject.members}
|
||||
projectID={projectID}
|
||||
teamID={data.findProject.team.id}
|
||||
teamID={data.findProject.team ? data.findProject.team.id : null}
|
||||
name={data.findProject.name}
|
||||
/>
|
||||
<Route path={`${match.path}`} exact render={() => <Redirect to={`${match.url}/board`} />} />
|
||||
|
@ -8,8 +8,6 @@ import {
|
||||
useCreateProjectMutation,
|
||||
GetProjectsDocument,
|
||||
GetProjectsQuery,
|
||||
MeQuery,
|
||||
MeDocument,
|
||||
} from 'shared/generated/graphql';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
@ -23,33 +21,6 @@ import updateApolloCache from 'shared/utils/cache';
|
||||
import produce from 'immer';
|
||||
import NOOP from 'shared/utils/noop';
|
||||
|
||||
const EmptyStateContent = styled.div`
|
||||
display: flex;
|
||||
justy-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const EmptyStateTitle = styled.h3`
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
`;
|
||||
|
||||
const EmptyStatePrompt = styled.span`
|
||||
color: rgba(${props => props.theme.colors.text.primary});
|
||||
font-size: 16px;
|
||||
margin-top: 8px;
|
||||
`;
|
||||
|
||||
const EmptyState = styled(Empty)`
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
`;
|
||||
|
||||
const CreateTeamButton = styled(Button)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
type CreateTeamData = { teamName: string };
|
||||
|
||||
type CreateTeamFormProps = {
|
||||
@ -58,6 +29,10 @@ type CreateTeamFormProps = {
|
||||
|
||||
const CreateTeamFormContainer = styled.form``;
|
||||
|
||||
const CreateTeamButton = styled(Button)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const CreateTeamForm: React.FC<CreateTeamFormProps> = ({ onCreateTeam }) => {
|
||||
const { register, handleSubmit } = useForm<CreateTeamData>();
|
||||
const createTeam = (data: CreateTeamData) => {
|
||||
@ -211,13 +186,6 @@ const ProjectsContainer = styled.div`
|
||||
min-width: 288px;
|
||||
`;
|
||||
|
||||
const ProjectGrid = styled.div`
|
||||
max-width: 780px;
|
||||
display: grid;
|
||||
grid-template-columns: 240px 240px 240px;
|
||||
gap: 20px 10px;
|
||||
`;
|
||||
|
||||
const AddTeamButton = styled(Button)`
|
||||
padding: 6px 12px;
|
||||
position: absolute;
|
||||
@ -225,10 +193,6 @@ const AddTeamButton = styled(Button)`
|
||||
right: 12px;
|
||||
`;
|
||||
|
||||
const CreateFirstTeam = styled(Button)`
|
||||
margin-top: 8px;
|
||||
`;
|
||||
|
||||
type ShowNewProject = {
|
||||
open: boolean;
|
||||
initialTeamID: null | string;
|
||||
@ -269,6 +233,13 @@ const Projects = () => {
|
||||
if (data && user) {
|
||||
const { projects, teams, organizations } = data;
|
||||
const organizationID = organizations[0].id ?? null;
|
||||
const personalProjects = projects
|
||||
.filter(p => p.team === null)
|
||||
.sort((a, b) => {
|
||||
const textA = a.name.toUpperCase();
|
||||
const textB = b.name.toUpperCase();
|
||||
return textA < textB ? -1 : textA > textB ? 1 : 0; // eslint-disable-line no-nested-ternary
|
||||
});
|
||||
const projectTeams = teams
|
||||
.sort((a, b) => {
|
||||
const textA = a.name.toUpperCase();
|
||||
@ -280,7 +251,7 @@ const Projects = () => {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
projects: projects
|
||||
.filter(project => project.team.id === team.id)
|
||||
.filter(project => project.team && project.team.id === team.id)
|
||||
.sort((a, b) => {
|
||||
const textA = a.name.toUpperCase();
|
||||
const textB = b.name.toUpperCase();
|
||||
@ -321,39 +292,35 @@ const Projects = () => {
|
||||
Add Team
|
||||
</AddTeamButton>
|
||||
)}
|
||||
{projectTeams.length === 0 && (
|
||||
<EmptyStateContent>
|
||||
<EmptyState width={425} height={425} />
|
||||
<EmptyStateTitle>No teams exist</EmptyStateTitle>
|
||||
<EmptyStatePrompt>Create a new team to get started</EmptyStatePrompt>
|
||||
<CreateFirstTeam
|
||||
variant="outline"
|
||||
onClick={$target => {
|
||||
showPopup(
|
||||
$target,
|
||||
<Popup
|
||||
title="Create team"
|
||||
tab={0}
|
||||
onClose={() => {
|
||||
hidePopup();
|
||||
}}
|
||||
>
|
||||
<CreateTeamForm
|
||||
onCreateTeam={teamName => {
|
||||
if (organizationID) {
|
||||
createTeam({ variables: { name: teamName, organizationID } });
|
||||
hidePopup();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Popup>,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Create new team
|
||||
</CreateFirstTeam>
|
||||
</EmptyStateContent>
|
||||
)}
|
||||
<div>
|
||||
<ProjectSectionTitleWrapper>
|
||||
<ProjectSectionTitle>Personal Projects</ProjectSectionTitle>
|
||||
</ProjectSectionTitleWrapper>
|
||||
<ProjectList>
|
||||
{personalProjects.map((project, idx) => (
|
||||
<ProjectListItem key={project.id}>
|
||||
<ProjectTile color={colors[idx % 5]} to={`/projects/${project.id}`}>
|
||||
<ProjectTileFade />
|
||||
<ProjectTileDetails>
|
||||
<ProjectTileName>{project.name}</ProjectTileName>
|
||||
</ProjectTileDetails>
|
||||
</ProjectTile>
|
||||
</ProjectListItem>
|
||||
))}
|
||||
<ProjectListItem>
|
||||
<ProjectAddTile
|
||||
onClick={() => {
|
||||
setShowNewProject({ open: true, initialTeamID: 'no-team' });
|
||||
}}
|
||||
>
|
||||
<ProjectTileFade />
|
||||
<ProjectAddTileDetails>
|
||||
<ProjectTileName centered>Create new project</ProjectTileName>
|
||||
</ProjectAddTileDetails>
|
||||
</ProjectAddTile>
|
||||
</ProjectListItem>
|
||||
</ProjectList>
|
||||
</div>
|
||||
{projectTeams.map(team => {
|
||||
return (
|
||||
<div key={team.id}>
|
||||
@ -407,7 +374,7 @@ const Projects = () => {
|
||||
initialTeamID={showNewProject.initialTeamID}
|
||||
onCreateProject={(name, teamID) => {
|
||||
if (user) {
|
||||
createProject({ variables: { teamID, name, userID: user.id } });
|
||||
createProject({ variables: { teamID, name } });
|
||||
setShowNewProject({ open: false, initialTeamID: null });
|
||||
}
|
||||
}}
|
||||
|
@ -217,13 +217,13 @@ type NewProjectProps = {
|
||||
initialTeamID: string | null;
|
||||
teams: Array<Team>;
|
||||
onClose: () => void;
|
||||
onCreateProject: (projectName: string, teamID: string) => void;
|
||||
onCreateProject: (projectName: string, teamID: string | null) => void;
|
||||
};
|
||||
|
||||
const NewProject: React.FC<NewProjectProps> = ({ initialTeamID, teams, onClose, onCreateProject }) => {
|
||||
const [projectName, setProjectName] = useState('');
|
||||
const [team, setTeam] = useState<null | string>(initialTeamID);
|
||||
const options = teams.map(t => ({ label: t.name, value: t.id }));
|
||||
const options = [{ label: 'No team', value: 'no-team' }, ...teams.map(t => ({ label: t.name, value: t.id }))];
|
||||
return (
|
||||
<Overlay>
|
||||
<Content>
|
||||
@ -271,8 +271,8 @@ const NewProject: React.FC<NewProjectProps> = ({ initialTeamID, teams, onClose,
|
||||
</ProjectInfo>
|
||||
<CreateButton
|
||||
onClick={() => {
|
||||
if (team && projectName !== '') {
|
||||
onCreateProject(projectName, team);
|
||||
if (projectName !== '') {
|
||||
onCreateProject(projectName, team === 'no-team' ? null : team);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
@ -126,7 +126,7 @@ export type Project = {
|
||||
id: Scalars['ID'];
|
||||
createdAt: Scalars['Time'];
|
||||
name: Scalars['String'];
|
||||
team: Team;
|
||||
team?: Maybe<Team>;
|
||||
taskGroups: Array<TaskGroup>;
|
||||
members: Array<Member>;
|
||||
labels: Array<ProjectLabel>;
|
||||
@ -643,8 +643,7 @@ export type Notification = {
|
||||
};
|
||||
|
||||
export type NewProject = {
|
||||
userID: Scalars['UUID'];
|
||||
teamID: Scalars['UUID'];
|
||||
teamID?: Maybe<Scalars['UUID']>;
|
||||
name: Scalars['String'];
|
||||
};
|
||||
|
||||
@ -1085,8 +1084,7 @@ export type ClearProfileAvatarMutation = (
|
||||
);
|
||||
|
||||
export type CreateProjectMutationVariables = {
|
||||
teamID: Scalars['UUID'];
|
||||
userID: Scalars['UUID'];
|
||||
teamID?: Maybe<Scalars['UUID']>;
|
||||
name: Scalars['String'];
|
||||
};
|
||||
|
||||
@ -1096,10 +1094,10 @@ export type CreateProjectMutation = (
|
||||
& { createProject: (
|
||||
{ __typename?: 'Project' }
|
||||
& Pick<Project, 'id' | 'name'>
|
||||
& { team: (
|
||||
& { team?: Maybe<(
|
||||
{ __typename?: 'Team' }
|
||||
& Pick<Team, 'id' | 'name'>
|
||||
) }
|
||||
)> }
|
||||
) }
|
||||
);
|
||||
|
||||
@ -1194,10 +1192,10 @@ export type FindProjectQuery = (
|
||||
& { findProject: (
|
||||
{ __typename?: 'Project' }
|
||||
& Pick<Project, 'name'>
|
||||
& { team: (
|
||||
& { team?: Maybe<(
|
||||
{ __typename?: 'Team' }
|
||||
& Pick<Team, 'id'>
|
||||
), members: Array<(
|
||||
)>, members: Array<(
|
||||
{ __typename?: 'Member' }
|
||||
& Pick<Member, 'id' | 'fullName' | 'username'>
|
||||
& { role: (
|
||||
@ -1361,10 +1359,10 @@ export type GetProjectsQuery = (
|
||||
)>, projects: Array<(
|
||||
{ __typename?: 'Project' }
|
||||
& Pick<Project, 'id' | 'name'>
|
||||
& { team: (
|
||||
& { team?: Maybe<(
|
||||
{ __typename?: 'Team' }
|
||||
& Pick<Team, 'id' | 'name'>
|
||||
) }
|
||||
)> }
|
||||
)> }
|
||||
);
|
||||
|
||||
@ -1837,10 +1835,10 @@ export type GetTeamQuery = (
|
||||
), projects: Array<(
|
||||
{ __typename?: 'Project' }
|
||||
& Pick<Project, 'id' | 'name'>
|
||||
& { team: (
|
||||
& { team?: Maybe<(
|
||||
{ __typename?: 'Team' }
|
||||
& Pick<Team, 'id' | 'name'>
|
||||
) }
|
||||
)> }
|
||||
)>, users: Array<(
|
||||
{ __typename?: 'UserAccount' }
|
||||
& Pick<UserAccount, 'id' | 'email' | 'fullName' | 'username'>
|
||||
@ -2365,8 +2363,8 @@ export type ClearProfileAvatarMutationHookResult = ReturnType<typeof useClearPro
|
||||
export type ClearProfileAvatarMutationResult = ApolloReactCommon.MutationResult<ClearProfileAvatarMutation>;
|
||||
export type ClearProfileAvatarMutationOptions = ApolloReactCommon.BaseMutationOptions<ClearProfileAvatarMutation, ClearProfileAvatarMutationVariables>;
|
||||
export const CreateProjectDocument = gql`
|
||||
mutation createProject($teamID: UUID!, $userID: UUID!, $name: String!) {
|
||||
createProject(input: {teamID: $teamID, userID: $userID, name: $name}) {
|
||||
mutation createProject($teamID: UUID, $name: String!) {
|
||||
createProject(input: {teamID: $teamID, name: $name}) {
|
||||
id
|
||||
name
|
||||
team {
|
||||
@ -2392,7 +2390,6 @@ export type CreateProjectMutationFn = ApolloReactCommon.MutationFunction<CreateP
|
||||
* const [createProjectMutation, { data, loading, error }] = useCreateProjectMutation({
|
||||
* variables: {
|
||||
* teamID: // value for 'teamID'
|
||||
* userID: // value for 'userID'
|
||||
* name: // value for 'name'
|
||||
* },
|
||||
* });
|
||||
|
@ -1,5 +1,5 @@
|
||||
mutation createProject($teamID: UUID!, $userID: UUID!, $name: String!) {
|
||||
createProject(input: {teamID: $teamID, userID: $userID, name: $name}) {
|
||||
mutation createProject($teamID: UUID, $name: String!) {
|
||||
createProject(input: {teamID: $teamID, name: $name}) {
|
||||
id
|
||||
name
|
||||
team {
|
||||
|
@ -38,6 +38,12 @@ type Organization struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type PersonalProject struct {
|
||||
PersonalProjectID uuid.UUID `json:"personal_project_id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
TeamID uuid.UUID `json:"team_id"`
|
||||
|
@ -10,18 +10,17 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const createProject = `-- name: CreateProject :one
|
||||
INSERT INTO project(team_id, created_at, name) VALUES ($1, $2, $3) RETURNING project_id, team_id, created_at, name
|
||||
const createPersonalProject = `-- name: CreatePersonalProject :one
|
||||
INSERT INTO project(team_id, created_at, name) VALUES (null, $1, $2) RETURNING project_id, team_id, created_at, name
|
||||
`
|
||||
|
||||
type CreateProjectParams struct {
|
||||
TeamID uuid.UUID `json:"team_id"`
|
||||
type CreatePersonalProjectParams struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) {
|
||||
row := q.db.QueryRowContext(ctx, createProject, arg.TeamID, arg.CreatedAt, arg.Name)
|
||||
func (q *Queries) CreatePersonalProject(ctx context.Context, arg CreatePersonalProjectParams) (Project, error) {
|
||||
row := q.db.QueryRowContext(ctx, createPersonalProject, arg.CreatedAt, arg.Name)
|
||||
var i Project
|
||||
err := row.Scan(
|
||||
&i.ProjectID,
|
||||
@ -32,6 +31,22 @@ func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (P
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createPersonalProjectLink = `-- name: CreatePersonalProjectLink :one
|
||||
INSERT INTO personal_project (project_id, user_id) VALUES ($1, $2) RETURNING personal_project_id, project_id, user_id
|
||||
`
|
||||
|
||||
type CreatePersonalProjectLinkParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreatePersonalProjectLink(ctx context.Context, arg CreatePersonalProjectLinkParams) (PersonalProject, error) {
|
||||
row := q.db.QueryRowContext(ctx, createPersonalProjectLink, arg.ProjectID, arg.UserID)
|
||||
var i PersonalProject
|
||||
err := row.Scan(&i.PersonalProjectID, &i.ProjectID, &i.UserID)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createProjectMember = `-- name: CreateProjectMember :one
|
||||
INSERT INTO project_member (project_id, user_id, role_code, added_at) VALUES ($1, $2, $3, $4)
|
||||
RETURNING project_member_id, project_id, user_id, added_at, role_code
|
||||
@ -62,6 +77,28 @@ func (q *Queries) CreateProjectMember(ctx context.Context, arg CreateProjectMemb
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createTeamProject = `-- name: CreateTeamProject :one
|
||||
INSERT INTO project(team_id, created_at, name) VALUES ($1, $2, $3) RETURNING project_id, team_id, created_at, name
|
||||
`
|
||||
|
||||
type CreateTeamProjectParams struct {
|
||||
TeamID uuid.UUID `json:"team_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateTeamProject(ctx context.Context, arg CreateTeamProjectParams) (Project, error) {
|
||||
row := q.db.QueryRowContext(ctx, createTeamProject, arg.TeamID, arg.CreatedAt, arg.Name)
|
||||
var i Project
|
||||
err := row.Scan(
|
||||
&i.ProjectID,
|
||||
&i.TeamID,
|
||||
&i.CreatedAt,
|
||||
&i.Name,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteProjectByID = `-- name: DeleteProjectByID :exec
|
||||
DELETE FROM project WHERE project_id = $1
|
||||
`
|
||||
@ -209,6 +246,40 @@ func (q *Queries) GetMemberProjectIDsForUserID(ctx context.Context, userID uuid.
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getPersonalProjectsForUserID = `-- name: GetPersonalProjectsForUserID :many
|
||||
SELECT project.project_id, project.team_id, project.created_at, project.name FROM project
|
||||
LEFT JOIN personal_project ON personal_project.project_id = project.project_id
|
||||
WHERE personal_project.user_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetPersonalProjectsForUserID(ctx context.Context, userID uuid.UUID) ([]Project, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getPersonalProjectsForUserID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Project
|
||||
for rows.Next() {
|
||||
var i Project
|
||||
if err := rows.Scan(
|
||||
&i.ProjectID,
|
||||
&i.TeamID,
|
||||
&i.CreatedAt,
|
||||
&i.Name,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getProjectByID = `-- name: GetProjectByID :one
|
||||
SELECT project_id, team_id, created_at, name FROM project WHERE project_id = $1
|
||||
`
|
||||
|
@ -13,7 +13,8 @@ type Querier interface {
|
||||
CreateNotification(ctx context.Context, arg CreateNotificationParams) (Notification, error)
|
||||
CreateNotificationObject(ctx context.Context, arg CreateNotificationObjectParams) (NotificationObject, error)
|
||||
CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (Organization, error)
|
||||
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
|
||||
CreatePersonalProject(ctx context.Context, arg CreatePersonalProjectParams) (Project, error)
|
||||
CreatePersonalProjectLink(ctx context.Context, arg CreatePersonalProjectLinkParams) (PersonalProject, error)
|
||||
CreateProjectLabel(ctx context.Context, arg CreateProjectLabelParams) (ProjectLabel, error)
|
||||
CreateProjectMember(ctx context.Context, arg CreateProjectMemberParams) (ProjectMember, error)
|
||||
CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error)
|
||||
@ -27,6 +28,7 @@ type Querier interface {
|
||||
CreateTaskLabelForTask(ctx context.Context, arg CreateTaskLabelForTaskParams) (TaskLabel, error)
|
||||
CreateTeam(ctx context.Context, arg CreateTeamParams) (Team, error)
|
||||
CreateTeamMember(ctx context.Context, arg CreateTeamMemberParams) (TeamMember, error)
|
||||
CreateTeamProject(ctx context.Context, arg CreateTeamProjectParams) (Project, error)
|
||||
CreateUserAccount(ctx context.Context, arg CreateUserAccountParams) (UserAccount, error)
|
||||
DeleteExpiredTokens(ctx context.Context) error
|
||||
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) error
|
||||
@ -62,6 +64,7 @@ type Querier interface {
|
||||
GetMemberProjectIDsForUserID(ctx context.Context, userID uuid.UUID) ([]uuid.UUID, error)
|
||||
GetMemberTeamIDsForUserID(ctx context.Context, userID uuid.UUID) ([]uuid.UUID, error)
|
||||
GetNotificationForNotificationID(ctx context.Context, notificationID uuid.UUID) (GetNotificationForNotificationIDRow, error)
|
||||
GetPersonalProjectsForUserID(ctx context.Context, userID uuid.UUID) ([]Project, error)
|
||||
GetProjectByID(ctx context.Context, projectID uuid.UUID) (Project, error)
|
||||
GetProjectIDForTask(ctx context.Context, taskID uuid.UUID) (uuid.UUID, error)
|
||||
GetProjectIDForTaskChecklist(ctx context.Context, taskChecklistID uuid.UUID) (uuid.UUID, error)
|
||||
|
@ -1,5 +1,5 @@
|
||||
-- name: GetAllProjects :many
|
||||
SELECT * FROM project;
|
||||
-- name: GetAllTeamProjects :many
|
||||
SELECT * FROM project WHERE team_id IS NOT null;
|
||||
|
||||
-- name: GetAllProjectsForTeam :many
|
||||
SELECT * FROM project WHERE team_id = $1;
|
||||
@ -7,9 +7,12 @@ SELECT * FROM project WHERE team_id = $1;
|
||||
-- name: GetProjectByID :one
|
||||
SELECT * FROM project WHERE project_id = $1;
|
||||
|
||||
-- name: CreateProject :one
|
||||
-- name: CreateTeamProject :one
|
||||
INSERT INTO project(team_id, created_at, name) VALUES ($1, $2, $3) RETURNING *;
|
||||
|
||||
-- name: CreatePersonalProject :one
|
||||
INSERT INTO project(team_id, created_at, name) VALUES (null, $1, $2) RETURNING *;
|
||||
|
||||
-- name: UpdateProjectNameByID :one
|
||||
UPDATE project SET name = $2 WHERE project_id = $1 RETURNING *;
|
||||
|
||||
@ -44,9 +47,18 @@ SELECT project_id FROM project_member WHERE user_id = $1;
|
||||
SELECT project.* FROM project LEFT JOIN
|
||||
project_member ON project_member.project_id = project.project_id WHERE project_member.user_id = $1;
|
||||
|
||||
-- name: GetPersonalProjectsForUserID :many
|
||||
SELECT project.* FROM project
|
||||
LEFT JOIN personal_project ON personal_project.project_id = project.project_id
|
||||
WHERE personal_project.user_id = $1;
|
||||
|
||||
|
||||
-- name: GetUserRolesForProject :one
|
||||
SELECT p.team_id, COALESCE(tm.role_code, '') AS team_role, COALESCE(pm.role_code, '') AS project_role
|
||||
FROM project AS p
|
||||
LEFT JOIN project_member AS pm ON pm.project_id = p.project_id AND pm.user_id = $1
|
||||
LEFT JOIN team_member AS tm ON tm.team_id = p.team_id AND tm.user_id = $1
|
||||
WHERE p.project_id = $2;
|
||||
|
||||
-- name: CreatePersonalProjectLink :one
|
||||
INSERT INTO personal_project (project_id, user_id) VALUES ($1, $2) RETURNING *;
|
||||
|
@ -2571,7 +2571,7 @@ type Project {
|
||||
id: ID!
|
||||
createdAt: Time!
|
||||
name: String!
|
||||
team: Team!
|
||||
team: Team
|
||||
taskGroups: [TaskGroup!]!
|
||||
members: [Member!]!
|
||||
labels: [ProjectLabel!]!
|
||||
@ -2659,7 +2659,8 @@ type Query {
|
||||
organizations: [Organization!]!
|
||||
users: [UserAccount!]!
|
||||
findUser(input: FindUser!): UserAccount!
|
||||
findProject(input: FindProject!): Project!
|
||||
findProject(input: FindProject!):
|
||||
Project! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: PROJECT)
|
||||
findTask(input: FindTask!): Task!
|
||||
projects(input: ProjectsFilter): [Project!]!
|
||||
findTeam(input: FindTeam!): Team!
|
||||
@ -2753,8 +2754,7 @@ extend type Mutation {
|
||||
}
|
||||
|
||||
input NewProject {
|
||||
userID: UUID!
|
||||
teamID: UUID!
|
||||
teamID: UUID
|
||||
name: String!
|
||||
}
|
||||
|
||||
@ -10193,14 +10193,11 @@ func (ec *executionContext) _Project_team(ctx context.Context, field graphql.Col
|
||||
return graphql.Null
|
||||
}
|
||||
if resTmp == nil {
|
||||
if !graphql.HasFieldError(ctx, fc) {
|
||||
ec.Errorf(ctx, "must not be null")
|
||||
}
|
||||
return graphql.Null
|
||||
}
|
||||
res := resTmp.(*db.Team)
|
||||
fc.Result = res
|
||||
return ec.marshalNTeam2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋdbᚐTeam(ctx, field.Selections, res)
|
||||
return ec.marshalOTeam2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋdbᚐTeam(ctx, field.Selections, res)
|
||||
}
|
||||
|
||||
func (ec *executionContext) _Project_taskGroups(ctx context.Context, field graphql.CollectedField, obj *db.Project) (ret graphql.Marshaler) {
|
||||
@ -10638,8 +10635,40 @@ func (ec *executionContext) _Query_findProject(ctx context.Context, field graphq
|
||||
}
|
||||
fc.Args = args
|
||||
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
|
||||
ctx = rctx // use context from middleware stack in children
|
||||
return ec.resolvers.Query().FindProject(rctx, args["input"].(FindProject))
|
||||
directive0 := func(rctx context.Context) (interface{}, error) {
|
||||
ctx = rctx // use context from middleware stack in children
|
||||
return ec.resolvers.Query().FindProject(rctx, args["input"].(FindProject))
|
||||
}
|
||||
directive1 := func(ctx context.Context) (interface{}, error) {
|
||||
roles, err := ec.unmarshalNRoleLevel2ᚕgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐRoleLevelᚄ(ctx, []interface{}{"ADMIN", "MEMBER"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
level, err := ec.unmarshalNActionLevel2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐActionLevel(ctx, "PROJECT")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
typeArg, err := ec.unmarshalNObjectType2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐObjectType(ctx, "PROJECT")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ec.directives.HasRole == nil {
|
||||
return nil, errors.New("directive hasRole is not implemented")
|
||||
}
|
||||
return ec.directives.HasRole(ctx, nil, directive0, roles, level, typeArg)
|
||||
}
|
||||
|
||||
tmp, err := directive1(rctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tmp == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if data, ok := tmp.(*db.Project); ok {
|
||||
return data, nil
|
||||
}
|
||||
return nil, fmt.Errorf(`unexpected type %T from directive, should be *github.com/jordanknott/taskcafe/internal/db.Project`, tmp)
|
||||
})
|
||||
if err != nil {
|
||||
ec.Error(ctx, err)
|
||||
@ -15121,15 +15150,9 @@ func (ec *executionContext) unmarshalInputNewProject(ctx context.Context, obj in
|
||||
|
||||
for k, v := range asMap {
|
||||
switch k {
|
||||
case "userID":
|
||||
var err error
|
||||
it.UserID, err = ec.unmarshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, v)
|
||||
if err != nil {
|
||||
return it, err
|
||||
}
|
||||
case "teamID":
|
||||
var err error
|
||||
it.TeamID, err = ec.unmarshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, v)
|
||||
it.TeamID, err = ec.unmarshalOUUID2ᚖgithubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, v)
|
||||
if err != nil {
|
||||
return it, err
|
||||
}
|
||||
@ -17286,9 +17309,6 @@ func (ec *executionContext) _Project(ctx context.Context, sel ast.SelectionSet,
|
||||
}
|
||||
}()
|
||||
res = ec._Project_team(ctx, field, obj)
|
||||
if res == graphql.Null {
|
||||
atomic.AddUint32(&invalids, 1)
|
||||
}
|
||||
return res
|
||||
})
|
||||
case "taskGroups":
|
||||
@ -20927,6 +20947,17 @@ func (ec *executionContext) marshalOString2ᚖstring(ctx context.Context, sel as
|
||||
return ec.marshalOString2string(ctx, sel, *v)
|
||||
}
|
||||
|
||||
func (ec *executionContext) marshalOTeam2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋdbᚐTeam(ctx context.Context, sel ast.SelectionSet, v db.Team) graphql.Marshaler {
|
||||
return ec._Team(ctx, sel, &v)
|
||||
}
|
||||
|
||||
func (ec *executionContext) marshalOTeam2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋdbᚐTeam(ctx context.Context, sel ast.SelectionSet, v *db.Team) graphql.Marshaler {
|
||||
if v == nil {
|
||||
return graphql.Null
|
||||
}
|
||||
return ec._Team(ctx, sel, v)
|
||||
}
|
||||
|
||||
func (ec *executionContext) unmarshalOTime2timeᚐTime(ctx context.Context, v interface{}) (time.Time, error) {
|
||||
return graphql.UnmarshalTime(v)
|
||||
}
|
||||
|
@ -2,10 +2,12 @@ package graph
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql"
|
||||
@ -19,6 +21,7 @@ import (
|
||||
"github.com/jordanknott/taskcafe/internal/db"
|
||||
"github.com/jordanknott/taskcafe/internal/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/vektah/gqlparser/v2/gqlerror"
|
||||
)
|
||||
|
||||
// NewHandler returns a new graphql endpoint handler.
|
||||
@ -66,6 +69,11 @@ func NewHandler(repo db.Repository) http.Handler {
|
||||
log.Error("subject field name does not exist on input type")
|
||||
return nil, errors.New("subject field name does not exist on input type")
|
||||
}
|
||||
if fieldName == "TeamID" && subjectField.IsNil() {
|
||||
// Is a personal project, no check
|
||||
// TODO: add config setting to disable personal projects
|
||||
return next(ctx)
|
||||
}
|
||||
subjectID, ok = subjectField.Interface().(uuid.UUID)
|
||||
if !ok {
|
||||
log.Error("error while casting subject UUID")
|
||||
@ -93,16 +101,30 @@ func NewHandler(repo db.Repository) http.Handler {
|
||||
}
|
||||
projectRoles, err := GetProjectRoles(ctx, repo, subjectID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, &gqlerror.Error{
|
||||
Message: "not authorized",
|
||||
Extensions: map[string]interface{}{
|
||||
"code": "401",
|
||||
},
|
||||
}
|
||||
}
|
||||
log.WithError(err).Error("error while getting project roles")
|
||||
return nil, err
|
||||
}
|
||||
for _, validRole := range roles {
|
||||
if GetRoleLevel(projectRoles.TeamRole) == validRole || GetRoleLevel(projectRoles.ProjectRole) == validRole {
|
||||
log.WithFields(log.Fields{"validRole": validRole}).Info("checking role")
|
||||
if CompareRoleLevel(projectRoles.TeamRole, validRole) || CompareRoleLevel(projectRoles.ProjectRole, validRole) {
|
||||
log.WithFields(log.Fields{"teamRole": projectRoles.TeamRole, "projectRole": projectRoles.ProjectRole}).Info("is team or project role")
|
||||
return next(ctx)
|
||||
}
|
||||
}
|
||||
return nil, errors.New("must be a team or project admin")
|
||||
return nil, &gqlerror.Error{
|
||||
Message: "not authorized",
|
||||
Extensions: map[string]interface{}{
|
||||
"code": "401",
|
||||
},
|
||||
}
|
||||
} else if level == ActionLevelTeam {
|
||||
userID, ok := GetUserID(ctx)
|
||||
if !ok {
|
||||
@ -114,14 +136,24 @@ func NewHandler(repo db.Repository) http.Handler {
|
||||
return nil, err
|
||||
}
|
||||
for _, validRole := range roles {
|
||||
if GetRoleLevel(role.RoleCode) == validRole || GetRoleLevel(role.RoleCode) == validRole {
|
||||
if CompareRoleLevel(role.RoleCode, validRole) || CompareRoleLevel(role.RoleCode, validRole) {
|
||||
return next(ctx)
|
||||
}
|
||||
}
|
||||
return nil, errors.New("must be a team admin")
|
||||
return nil, &gqlerror.Error{
|
||||
Message: "not authorized",
|
||||
Extensions: map[string]interface{}{
|
||||
"code": "401",
|
||||
},
|
||||
}
|
||||
|
||||
}
|
||||
return nil, errors.New("invalid path")
|
||||
return nil, &gqlerror.Error{
|
||||
Message: "bad path",
|
||||
Extensions: map[string]interface{}{
|
||||
"code": "500",
|
||||
},
|
||||
}
|
||||
}
|
||||
srv := handler.New(NewExecutableSchema(c))
|
||||
srv.AddTransport(transport.Websocket{
|
||||
@ -184,12 +216,12 @@ func GetProjectRoles(ctx context.Context, r db.Repository, projectID uuid.UUID)
|
||||
return r.GetUserRolesForProject(ctx, db.GetUserRolesForProjectParams{UserID: userID, ProjectID: projectID})
|
||||
}
|
||||
|
||||
// GetRoleLevel converts a role level string to a RoleLevel type
|
||||
func GetRoleLevel(r string) RoleLevel {
|
||||
if r == RoleLevelAdmin.String() {
|
||||
return RoleLevelAdmin
|
||||
// CompareRoleLevel compares a string against a role level
|
||||
func CompareRoleLevel(a string, b RoleLevel) bool {
|
||||
if strings.ToLower(a) == strings.ToLower(b.String()) {
|
||||
return true
|
||||
}
|
||||
return RoleLevelMember
|
||||
return false
|
||||
}
|
||||
|
||||
// ConvertToRoleCode converts a role code string to a RoleCode type
|
||||
|
@ -213,9 +213,8 @@ type MemberList struct {
|
||||
}
|
||||
|
||||
type NewProject struct {
|
||||
UserID uuid.UUID `json:"userID"`
|
||||
TeamID uuid.UUID `json:"teamID"`
|
||||
Name string `json:"name"`
|
||||
TeamID *uuid.UUID `json:"teamID"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type NewProjectLabel struct {
|
||||
|
@ -97,7 +97,7 @@ type Project {
|
||||
id: ID!
|
||||
createdAt: Time!
|
||||
name: String!
|
||||
team: Team!
|
||||
team: Team
|
||||
taskGroups: [TaskGroup!]!
|
||||
members: [Member!]!
|
||||
labels: [ProjectLabel!]!
|
||||
@ -185,7 +185,8 @@ type Query {
|
||||
organizations: [Organization!]!
|
||||
users: [UserAccount!]!
|
||||
findUser(input: FindUser!): UserAccount!
|
||||
findProject(input: FindProject!): Project!
|
||||
findProject(input: FindProject!):
|
||||
Project! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: PROJECT)
|
||||
findTask(input: FindTask!): Task!
|
||||
projects(input: ProjectsFilter): [Project!]!
|
||||
findTeam(input: FindTeam!): Team!
|
||||
@ -279,8 +280,7 @@ extend type Mutation {
|
||||
}
|
||||
|
||||
input NewProject {
|
||||
userID: UUID!
|
||||
teamID: UUID!
|
||||
teamID: UUID
|
||||
name: String!
|
||||
}
|
||||
|
||||
|
@ -23,10 +23,41 @@ func (r *labelColorResolver) ID(ctx context.Context, obj *db.LabelColor) (uuid.U
|
||||
}
|
||||
|
||||
func (r *mutationResolver) CreateProject(ctx context.Context, input NewProject) (*db.Project, error) {
|
||||
userID, ok := GetUserID(ctx)
|
||||
if !ok {
|
||||
return &db.Project{}, errors.New("user id is missing")
|
||||
}
|
||||
createdAt := time.Now().UTC()
|
||||
log.WithFields(log.Fields{"userID": input.UserID, "name": input.Name, "teamID": input.TeamID}).Info("creating new project")
|
||||
project, err := r.Repository.CreateProject(ctx, db.CreateProjectParams{input.TeamID, createdAt, input.Name})
|
||||
return &project, err
|
||||
log.WithFields(log.Fields{"name": input.Name, "teamID": input.TeamID}).Info("creating new project")
|
||||
var project db.Project
|
||||
var err error
|
||||
if input.TeamID == nil {
|
||||
project, err = r.Repository.CreatePersonalProject(ctx, db.CreatePersonalProjectParams{
|
||||
CreatedAt: createdAt,
|
||||
Name: input.Name,
|
||||
})
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error while creating project")
|
||||
return &db.Project{}, err
|
||||
}
|
||||
log.WithFields(log.Fields{"userID": userID, "projectID": project.ProjectID}).Info("creating personal project link")
|
||||
} else {
|
||||
project, err = r.Repository.CreateTeamProject(ctx, db.CreateTeamProjectParams{
|
||||
CreatedAt: createdAt,
|
||||
Name: input.Name,
|
||||
TeamID: *input.TeamID,
|
||||
})
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error while creating project")
|
||||
return &db.Project{}, err
|
||||
}
|
||||
}
|
||||
_, err = r.Repository.CreateProjectMember(ctx, db.CreateProjectMemberParams{ProjectID: project.ProjectID, UserID: userID, AddedAt: createdAt, RoleCode: "admin"})
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error while creating initial project member")
|
||||
return &db.Project{}, err
|
||||
}
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) DeleteProject(ctx context.Context, input DeleteProject) (*DeleteProjectPayload, error) {
|
||||
@ -880,6 +911,9 @@ func (r *projectResolver) ID(ctx context.Context, obj *db.Project) (uuid.UUID, e
|
||||
func (r *projectResolver) Team(ctx context.Context, obj *db.Project) (*db.Team, error) {
|
||||
team, err := r.Repository.GetTeamByID(ctx, obj.TeamID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
log.WithFields(log.Fields{"teamID": obj.TeamID, "projectID": obj.ProjectID}).WithError(err).Error("issue while getting team for project")
|
||||
return &team, err
|
||||
}
|
||||
@ -982,25 +1016,6 @@ func (r *queryResolver) FindProject(ctx context.Context, input FindProject) (*db
|
||||
},
|
||||
}
|
||||
}
|
||||
if role == auth.RoleAdmin {
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
projectRoles, err := GetProjectRoles(ctx, r.Repository, input.ProjectID)
|
||||
log.WithFields(log.Fields{"projectID": input.ProjectID, "teamRole": projectRoles.TeamRole, "projectRole": projectRoles.ProjectRole}).Info("get project roles ")
|
||||
if err != nil {
|
||||
return &project, err
|
||||
}
|
||||
|
||||
if projectRoles.TeamRole == "" && projectRoles.ProjectRole == "" {
|
||||
return &db.Project{}, &gqlerror.Error{
|
||||
Message: "project not accessible",
|
||||
Extensions: map[string]interface{}{
|
||||
"code": "11-400",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
@ -1021,12 +1036,14 @@ func (r *queryResolver) Projects(ctx context.Context, input *ProjectsFilter) ([]
|
||||
return r.Repository.GetAllProjectsForTeam(ctx, *input.TeamID)
|
||||
}
|
||||
|
||||
var teams []db.Team
|
||||
var err error
|
||||
if orgRole == "admin" {
|
||||
log.Info("showing all projects for admin")
|
||||
return r.Repository.GetAllProjects(ctx)
|
||||
teams, err = r.Repository.GetAllTeams(ctx)
|
||||
} else {
|
||||
teams, err = r.Repository.GetTeamsForUserIDWhereAdmin(ctx, userID)
|
||||
}
|
||||
|
||||
teams, err := r.Repository.GetTeamsForUserIDWhereAdmin(ctx, userID)
|
||||
projects := make(map[string]db.Project)
|
||||
for _, team := range teams {
|
||||
log.WithFields(log.Fields{"teamID": team.TeamID}).Info("found team")
|
||||
@ -1078,12 +1095,14 @@ func (r *queryResolver) Teams(ctx context.Context) ([]db.Team, error) {
|
||||
return []db.Team{}, errors.New("internal error")
|
||||
}
|
||||
if orgRole == "admin" {
|
||||
|
||||
return r.Repository.GetAllTeams(ctx)
|
||||
}
|
||||
|
||||
teams := make(map[string]db.Team)
|
||||
adminTeams, err := r.Repository.GetTeamsForUserIDWhereAdmin(ctx, userID)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error while getting teams for user ID")
|
||||
return []db.Team{}, err
|
||||
}
|
||||
|
||||
@ -1093,7 +1112,7 @@ func (r *queryResolver) Teams(ctx context.Context) ([]db.Team, error) {
|
||||
|
||||
visibleProjects, err := r.Repository.GetAllVisibleProjectsForUserID(ctx, userID)
|
||||
if err != nil {
|
||||
log.WithField("userID", userID).Info("error while getting visible projects")
|
||||
log.WithField("userID", userID).WithError(err).Error("error while getting visible projects for user ID")
|
||||
return []db.Team{}, err
|
||||
}
|
||||
for _, project := range visibleProjects {
|
||||
@ -1102,7 +1121,10 @@ func (r *queryResolver) Teams(ctx context.Context) ([]db.Team, error) {
|
||||
log.WithFields(log.Fields{"projectID": project.ProjectID.String()}).Info("adding visible project")
|
||||
team, err := r.Repository.GetTeamByID(ctx, project.TeamID)
|
||||
if err != nil {
|
||||
log.WithField("teamID", project.TeamID).Info("error getting team by id")
|
||||
if err == sql.ErrNoRows {
|
||||
continue
|
||||
}
|
||||
log.WithField("teamID", project.TeamID).WithError(err).Error("error getting team by id")
|
||||
return []db.Team{}, err
|
||||
}
|
||||
teams[project.TeamID.String()] = team
|
||||
|
@ -97,7 +97,7 @@ type Project {
|
||||
id: ID!
|
||||
createdAt: Time!
|
||||
name: String!
|
||||
team: Team!
|
||||
team: Team
|
||||
taskGroups: [TaskGroup!]!
|
||||
members: [Member!]!
|
||||
labels: [ProjectLabel!]!
|
||||
|
@ -25,7 +25,8 @@ type Query {
|
||||
organizations: [Organization!]!
|
||||
users: [UserAccount!]!
|
||||
findUser(input: FindUser!): UserAccount!
|
||||
findProject(input: FindProject!): Project!
|
||||
findProject(input: FindProject!):
|
||||
Project! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: PROJECT)
|
||||
findTask(input: FindTask!): Task!
|
||||
projects(input: ProjectsFilter): [Project!]!
|
||||
findTeam(input: FindTeam!): Team!
|
||||
|
@ -7,8 +7,7 @@ extend type Mutation {
|
||||
}
|
||||
|
||||
input NewProject {
|
||||
userID: UUID!
|
||||
teamID: UUID!
|
||||
teamID: UUID
|
||||
name: String!
|
||||
}
|
||||
|
||||
|
@ -0,0 +1 @@
|
||||
ALTER TABLE project ALTER COLUMN team_id DROP NOT NULL;
|
5
migrations/0055_add-personal_project_table.up.sql
Normal file
5
migrations/0055_add-personal_project_table.up.sql
Normal file
@ -0,0 +1,5 @@
|
||||
CREATE TABLE personal_project (
|
||||
personal_project_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
project_id uuid NOT NULL REFERENCES project(project_id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES user_account(user_id) ON DELETE CASCADE
|
||||
);
|
Loading…
Reference in New Issue
Block a user