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:
Jordan Knott 2020-09-19 20:20:36 -05:00
parent 28a53f14ad
commit 4277b7b2a8
20 changed files with 327 additions and 178 deletions

View File

@ -137,7 +137,7 @@ const ProjectFinder = () => {
return { return {
id: team.id, id: team.id,
name: team.name, name: team.name,
projects: projects.filter(project => project.team.id === team.id), projects: projects.filter(project => project.team && project.team.id === team.id),
}; };
}); });
return ( return (

View File

@ -158,7 +158,7 @@ const Project = () => {
const [updateTaskName] = useUpdateTaskNameMutation(); const [updateTaskName] = useUpdateTaskNameMutation();
const { loading, data } = useFindProjectQuery({ const { loading, data, error } = useFindProjectQuery({
variables: { projectID }, variables: { projectID },
}); });
@ -224,6 +224,9 @@ const Project = () => {
</> </>
); );
} }
if (error) {
history.push('/projects');
}
if (data) { if (data) {
labelsRef.current = data.findProject.labels; labelsRef.current = data.findProject.labels;
@ -260,7 +263,7 @@ const Project = () => {
currentTab={0} currentTab={0}
projectMembers={data.findProject.members} projectMembers={data.findProject.members}
projectID={projectID} projectID={projectID}
teamID={data.findProject.team.id} teamID={data.findProject.team ? data.findProject.team.id : null}
name={data.findProject.name} name={data.findProject.name}
/> />
<Route path={`${match.path}`} exact render={() => <Redirect to={`${match.url}/board`} />} /> <Route path={`${match.path}`} exact render={() => <Redirect to={`${match.url}/board`} />} />

View File

@ -8,8 +8,6 @@ import {
useCreateProjectMutation, useCreateProjectMutation,
GetProjectsDocument, GetProjectsDocument,
GetProjectsQuery, GetProjectsQuery,
MeQuery,
MeDocument,
} from 'shared/generated/graphql'; } from 'shared/generated/graphql';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@ -23,33 +21,6 @@ import updateApolloCache from 'shared/utils/cache';
import produce from 'immer'; import produce from 'immer';
import NOOP from 'shared/utils/noop'; 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 CreateTeamData = { teamName: string };
type CreateTeamFormProps = { type CreateTeamFormProps = {
@ -58,6 +29,10 @@ type CreateTeamFormProps = {
const CreateTeamFormContainer = styled.form``; const CreateTeamFormContainer = styled.form``;
const CreateTeamButton = styled(Button)`
width: 100%;
`;
const CreateTeamForm: React.FC<CreateTeamFormProps> = ({ onCreateTeam }) => { const CreateTeamForm: React.FC<CreateTeamFormProps> = ({ onCreateTeam }) => {
const { register, handleSubmit } = useForm<CreateTeamData>(); const { register, handleSubmit } = useForm<CreateTeamData>();
const createTeam = (data: CreateTeamData) => { const createTeam = (data: CreateTeamData) => {
@ -211,13 +186,6 @@ const ProjectsContainer = styled.div`
min-width: 288px; 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)` const AddTeamButton = styled(Button)`
padding: 6px 12px; padding: 6px 12px;
position: absolute; position: absolute;
@ -225,10 +193,6 @@ const AddTeamButton = styled(Button)`
right: 12px; right: 12px;
`; `;
const CreateFirstTeam = styled(Button)`
margin-top: 8px;
`;
type ShowNewProject = { type ShowNewProject = {
open: boolean; open: boolean;
initialTeamID: null | string; initialTeamID: null | string;
@ -269,6 +233,13 @@ const Projects = () => {
if (data && user) { if (data && user) {
const { projects, teams, organizations } = data; const { projects, teams, organizations } = data;
const organizationID = organizations[0].id ?? null; 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 const projectTeams = teams
.sort((a, b) => { .sort((a, b) => {
const textA = a.name.toUpperCase(); const textA = a.name.toUpperCase();
@ -280,7 +251,7 @@ const Projects = () => {
id: team.id, id: team.id,
name: team.name, name: team.name,
projects: projects projects: projects
.filter(project => project.team.id === team.id) .filter(project => project.team && project.team.id === team.id)
.sort((a, b) => { .sort((a, b) => {
const textA = a.name.toUpperCase(); const textA = a.name.toUpperCase();
const textB = b.name.toUpperCase(); const textB = b.name.toUpperCase();
@ -321,39 +292,35 @@ const Projects = () => {
Add Team Add Team
</AddTeamButton> </AddTeamButton>
)} )}
{projectTeams.length === 0 && ( <div>
<EmptyStateContent> <ProjectSectionTitleWrapper>
<EmptyState width={425} height={425} /> <ProjectSectionTitle>Personal Projects</ProjectSectionTitle>
<EmptyStateTitle>No teams exist</EmptyStateTitle> </ProjectSectionTitleWrapper>
<EmptyStatePrompt>Create a new team to get started</EmptyStatePrompt> <ProjectList>
<CreateFirstTeam {personalProjects.map((project, idx) => (
variant="outline" <ProjectListItem key={project.id}>
onClick={$target => { <ProjectTile color={colors[idx % 5]} to={`/projects/${project.id}`}>
showPopup( <ProjectTileFade />
$target, <ProjectTileDetails>
<Popup <ProjectTileName>{project.name}</ProjectTileName>
title="Create team" </ProjectTileDetails>
tab={0} </ProjectTile>
onClose={() => { </ProjectListItem>
hidePopup(); ))}
<ProjectListItem>
<ProjectAddTile
onClick={() => {
setShowNewProject({ open: true, initialTeamID: 'no-team' });
}} }}
> >
<CreateTeamForm <ProjectTileFade />
onCreateTeam={teamName => { <ProjectAddTileDetails>
if (organizationID) { <ProjectTileName centered>Create new project</ProjectTileName>
createTeam({ variables: { name: teamName, organizationID } }); </ProjectAddTileDetails>
hidePopup(); </ProjectAddTile>
} </ProjectListItem>
}} </ProjectList>
/> </div>
</Popup>,
);
}}
>
Create new team
</CreateFirstTeam>
</EmptyStateContent>
)}
{projectTeams.map(team => { {projectTeams.map(team => {
return ( return (
<div key={team.id}> <div key={team.id}>
@ -407,7 +374,7 @@ const Projects = () => {
initialTeamID={showNewProject.initialTeamID} initialTeamID={showNewProject.initialTeamID}
onCreateProject={(name, teamID) => { onCreateProject={(name, teamID) => {
if (user) { if (user) {
createProject({ variables: { teamID, name, userID: user.id } }); createProject({ variables: { teamID, name } });
setShowNewProject({ open: false, initialTeamID: null }); setShowNewProject({ open: false, initialTeamID: null });
} }
}} }}

View File

@ -217,13 +217,13 @@ type NewProjectProps = {
initialTeamID: string | null; initialTeamID: string | null;
teams: Array<Team>; teams: Array<Team>;
onClose: () => void; 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 NewProject: React.FC<NewProjectProps> = ({ initialTeamID, teams, onClose, onCreateProject }) => {
const [projectName, setProjectName] = useState(''); const [projectName, setProjectName] = useState('');
const [team, setTeam] = useState<null | string>(initialTeamID); 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 ( return (
<Overlay> <Overlay>
<Content> <Content>
@ -271,8 +271,8 @@ const NewProject: React.FC<NewProjectProps> = ({ initialTeamID, teams, onClose,
</ProjectInfo> </ProjectInfo>
<CreateButton <CreateButton
onClick={() => { onClick={() => {
if (team && projectName !== '') { if (projectName !== '') {
onCreateProject(projectName, team); onCreateProject(projectName, team === 'no-team' ? null : team);
} }
}} }}
> >

View File

@ -126,7 +126,7 @@ export type Project = {
id: Scalars['ID']; id: Scalars['ID'];
createdAt: Scalars['Time']; createdAt: Scalars['Time'];
name: Scalars['String']; name: Scalars['String'];
team: Team; team?: Maybe<Team>;
taskGroups: Array<TaskGroup>; taskGroups: Array<TaskGroup>;
members: Array<Member>; members: Array<Member>;
labels: Array<ProjectLabel>; labels: Array<ProjectLabel>;
@ -643,8 +643,7 @@ export type Notification = {
}; };
export type NewProject = { export type NewProject = {
userID: Scalars['UUID']; teamID?: Maybe<Scalars['UUID']>;
teamID: Scalars['UUID'];
name: Scalars['String']; name: Scalars['String'];
}; };
@ -1085,8 +1084,7 @@ export type ClearProfileAvatarMutation = (
); );
export type CreateProjectMutationVariables = { export type CreateProjectMutationVariables = {
teamID: Scalars['UUID']; teamID?: Maybe<Scalars['UUID']>;
userID: Scalars['UUID'];
name: Scalars['String']; name: Scalars['String'];
}; };
@ -1096,10 +1094,10 @@ export type CreateProjectMutation = (
& { createProject: ( & { createProject: (
{ __typename?: 'Project' } { __typename?: 'Project' }
& Pick<Project, 'id' | 'name'> & Pick<Project, 'id' | 'name'>
& { team: ( & { team?: Maybe<(
{ __typename?: 'Team' } { __typename?: 'Team' }
& Pick<Team, 'id' | 'name'> & Pick<Team, 'id' | 'name'>
) } )> }
) } ) }
); );
@ -1194,10 +1192,10 @@ export type FindProjectQuery = (
& { findProject: ( & { findProject: (
{ __typename?: 'Project' } { __typename?: 'Project' }
& Pick<Project, 'name'> & Pick<Project, 'name'>
& { team: ( & { team?: Maybe<(
{ __typename?: 'Team' } { __typename?: 'Team' }
& Pick<Team, 'id'> & Pick<Team, 'id'>
), members: Array<( )>, members: Array<(
{ __typename?: 'Member' } { __typename?: 'Member' }
& Pick<Member, 'id' | 'fullName' | 'username'> & Pick<Member, 'id' | 'fullName' | 'username'>
& { role: ( & { role: (
@ -1361,10 +1359,10 @@ export type GetProjectsQuery = (
)>, projects: Array<( )>, projects: Array<(
{ __typename?: 'Project' } { __typename?: 'Project' }
& Pick<Project, 'id' | 'name'> & Pick<Project, 'id' | 'name'>
& { team: ( & { team?: Maybe<(
{ __typename?: 'Team' } { __typename?: 'Team' }
& Pick<Team, 'id' | 'name'> & Pick<Team, 'id' | 'name'>
) } )> }
)> } )> }
); );
@ -1837,10 +1835,10 @@ export type GetTeamQuery = (
), projects: Array<( ), projects: Array<(
{ __typename?: 'Project' } { __typename?: 'Project' }
& Pick<Project, 'id' | 'name'> & Pick<Project, 'id' | 'name'>
& { team: ( & { team?: Maybe<(
{ __typename?: 'Team' } { __typename?: 'Team' }
& Pick<Team, 'id' | 'name'> & Pick<Team, 'id' | 'name'>
) } )> }
)>, users: Array<( )>, users: Array<(
{ __typename?: 'UserAccount' } { __typename?: 'UserAccount' }
& Pick<UserAccount, 'id' | 'email' | 'fullName' | 'username'> & Pick<UserAccount, 'id' | 'email' | 'fullName' | 'username'>
@ -2365,8 +2363,8 @@ export type ClearProfileAvatarMutationHookResult = ReturnType<typeof useClearPro
export type ClearProfileAvatarMutationResult = ApolloReactCommon.MutationResult<ClearProfileAvatarMutation>; export type ClearProfileAvatarMutationResult = ApolloReactCommon.MutationResult<ClearProfileAvatarMutation>;
export type ClearProfileAvatarMutationOptions = ApolloReactCommon.BaseMutationOptions<ClearProfileAvatarMutation, ClearProfileAvatarMutationVariables>; export type ClearProfileAvatarMutationOptions = ApolloReactCommon.BaseMutationOptions<ClearProfileAvatarMutation, ClearProfileAvatarMutationVariables>;
export const CreateProjectDocument = gql` export const CreateProjectDocument = gql`
mutation createProject($teamID: UUID!, $userID: UUID!, $name: String!) { mutation createProject($teamID: UUID, $name: String!) {
createProject(input: {teamID: $teamID, userID: $userID, name: $name}) { createProject(input: {teamID: $teamID, name: $name}) {
id id
name name
team { team {
@ -2392,7 +2390,6 @@ export type CreateProjectMutationFn = ApolloReactCommon.MutationFunction<CreateP
* const [createProjectMutation, { data, loading, error }] = useCreateProjectMutation({ * const [createProjectMutation, { data, loading, error }] = useCreateProjectMutation({
* variables: { * variables: {
* teamID: // value for 'teamID' * teamID: // value for 'teamID'
* userID: // value for 'userID'
* name: // value for 'name' * name: // value for 'name'
* }, * },
* }); * });

View File

@ -1,5 +1,5 @@
mutation createProject($teamID: UUID!, $userID: UUID!, $name: String!) { mutation createProject($teamID: UUID, $name: String!) {
createProject(input: {teamID: $teamID, userID: $userID, name: $name}) { createProject(input: {teamID: $teamID, name: $name}) {
id id
name name
team { team {

View File

@ -38,6 +38,12 @@ type Organization struct {
Name string `json:"name"` 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 { type Project struct {
ProjectID uuid.UUID `json:"project_id"` ProjectID uuid.UUID `json:"project_id"`
TeamID uuid.UUID `json:"team_id"` TeamID uuid.UUID `json:"team_id"`

View File

@ -10,18 +10,17 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
const createProject = `-- name: CreateProject :one const createPersonalProject = `-- name: CreatePersonalProject :one
INSERT INTO project(team_id, created_at, name) VALUES ($1, $2, $3) RETURNING project_id, team_id, created_at, name INSERT INTO project(team_id, created_at, name) VALUES (null, $1, $2) RETURNING project_id, team_id, created_at, name
` `
type CreateProjectParams struct { type CreatePersonalProjectParams struct {
TeamID uuid.UUID `json:"team_id"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
Name string `json:"name"` Name string `json:"name"`
} }
func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) { func (q *Queries) CreatePersonalProject(ctx context.Context, arg CreatePersonalProjectParams) (Project, error) {
row := q.db.QueryRowContext(ctx, createProject, arg.TeamID, arg.CreatedAt, arg.Name) row := q.db.QueryRowContext(ctx, createPersonalProject, arg.CreatedAt, arg.Name)
var i Project var i Project
err := row.Scan( err := row.Scan(
&i.ProjectID, &i.ProjectID,
@ -32,6 +31,22 @@ func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (P
return i, err 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 const createProjectMember = `-- name: CreateProjectMember :one
INSERT INTO project_member (project_id, user_id, role_code, added_at) VALUES ($1, $2, $3, $4) 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 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 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 const deleteProjectByID = `-- name: DeleteProjectByID :exec
DELETE FROM project WHERE project_id = $1 DELETE FROM project WHERE project_id = $1
` `
@ -209,6 +246,40 @@ func (q *Queries) GetMemberProjectIDsForUserID(ctx context.Context, userID uuid.
return items, nil 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 const getProjectByID = `-- name: GetProjectByID :one
SELECT project_id, team_id, created_at, name FROM project WHERE project_id = $1 SELECT project_id, team_id, created_at, name FROM project WHERE project_id = $1
` `

View File

@ -13,7 +13,8 @@ type Querier interface {
CreateNotification(ctx context.Context, arg CreateNotificationParams) (Notification, error) CreateNotification(ctx context.Context, arg CreateNotificationParams) (Notification, error)
CreateNotificationObject(ctx context.Context, arg CreateNotificationObjectParams) (NotificationObject, error) CreateNotificationObject(ctx context.Context, arg CreateNotificationObjectParams) (NotificationObject, error)
CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (Organization, 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) CreateProjectLabel(ctx context.Context, arg CreateProjectLabelParams) (ProjectLabel, error)
CreateProjectMember(ctx context.Context, arg CreateProjectMemberParams) (ProjectMember, error) CreateProjectMember(ctx context.Context, arg CreateProjectMemberParams) (ProjectMember, error)
CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error) CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error)
@ -27,6 +28,7 @@ type Querier interface {
CreateTaskLabelForTask(ctx context.Context, arg CreateTaskLabelForTaskParams) (TaskLabel, error) CreateTaskLabelForTask(ctx context.Context, arg CreateTaskLabelForTaskParams) (TaskLabel, error)
CreateTeam(ctx context.Context, arg CreateTeamParams) (Team, error) CreateTeam(ctx context.Context, arg CreateTeamParams) (Team, error)
CreateTeamMember(ctx context.Context, arg CreateTeamMemberParams) (TeamMember, 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) CreateUserAccount(ctx context.Context, arg CreateUserAccountParams) (UserAccount, error)
DeleteExpiredTokens(ctx context.Context) error DeleteExpiredTokens(ctx context.Context) error
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) 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) GetMemberProjectIDsForUserID(ctx context.Context, userID uuid.UUID) ([]uuid.UUID, error)
GetMemberTeamIDsForUserID(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) 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) GetProjectByID(ctx context.Context, projectID uuid.UUID) (Project, error)
GetProjectIDForTask(ctx context.Context, taskID uuid.UUID) (uuid.UUID, error) GetProjectIDForTask(ctx context.Context, taskID uuid.UUID) (uuid.UUID, error)
GetProjectIDForTaskChecklist(ctx context.Context, taskChecklistID uuid.UUID) (uuid.UUID, error) GetProjectIDForTaskChecklist(ctx context.Context, taskChecklistID uuid.UUID) (uuid.UUID, error)

View File

@ -1,5 +1,5 @@
-- name: GetAllProjects :many -- name: GetAllTeamProjects :many
SELECT * FROM project; SELECT * FROM project WHERE team_id IS NOT null;
-- name: GetAllProjectsForTeam :many -- name: GetAllProjectsForTeam :many
SELECT * FROM project WHERE team_id = $1; SELECT * FROM project WHERE team_id = $1;
@ -7,9 +7,12 @@ SELECT * FROM project WHERE team_id = $1;
-- name: GetProjectByID :one -- name: GetProjectByID :one
SELECT * FROM project WHERE project_id = $1; 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 *; 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 -- name: UpdateProjectNameByID :one
UPDATE project SET name = $2 WHERE project_id = $1 RETURNING *; 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 SELECT project.* FROM project LEFT JOIN
project_member ON project_member.project_id = project.project_id WHERE project_member.user_id = $1; 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 -- name: GetUserRolesForProject :one
SELECT p.team_id, COALESCE(tm.role_code, '') AS team_role, COALESCE(pm.role_code, '') AS project_role SELECT p.team_id, COALESCE(tm.role_code, '') AS team_role, COALESCE(pm.role_code, '') AS project_role
FROM project AS p FROM project AS p
LEFT JOIN project_member AS pm ON pm.project_id = p.project_id AND pm.user_id = $1 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 LEFT JOIN team_member AS tm ON tm.team_id = p.team_id AND tm.user_id = $1
WHERE p.project_id = $2; WHERE p.project_id = $2;
-- name: CreatePersonalProjectLink :one
INSERT INTO personal_project (project_id, user_id) VALUES ($1, $2) RETURNING *;

View File

@ -2571,7 +2571,7 @@ type Project {
id: ID! id: ID!
createdAt: Time! createdAt: Time!
name: String! name: String!
team: Team! team: Team
taskGroups: [TaskGroup!]! taskGroups: [TaskGroup!]!
members: [Member!]! members: [Member!]!
labels: [ProjectLabel!]! labels: [ProjectLabel!]!
@ -2659,7 +2659,8 @@ type Query {
organizations: [Organization!]! organizations: [Organization!]!
users: [UserAccount!]! users: [UserAccount!]!
findUser(input: FindUser!): 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! findTask(input: FindTask!): Task!
projects(input: ProjectsFilter): [Project!]! projects(input: ProjectsFilter): [Project!]!
findTeam(input: FindTeam!): Team! findTeam(input: FindTeam!): Team!
@ -2753,8 +2754,7 @@ extend type Mutation {
} }
input NewProject { input NewProject {
userID: UUID! teamID: UUID
teamID: UUID!
name: String! name: String!
} }
@ -10193,14 +10193,11 @@ func (ec *executionContext) _Project_team(ctx context.Context, field graphql.Col
return graphql.Null return graphql.Null
} }
if resTmp == nil { if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null return graphql.Null
} }
res := resTmp.(*db.Team) res := resTmp.(*db.Team)
fc.Result = res 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) { 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 fc.Args = args
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
directive0 := func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children ctx = rctx // use context from middleware stack in children
return ec.resolvers.Query().FindProject(rctx, args["input"].(FindProject)) 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 { if err != nil {
ec.Error(ctx, err) ec.Error(ctx, err)
@ -15121,15 +15150,9 @@ func (ec *executionContext) unmarshalInputNewProject(ctx context.Context, obj in
for k, v := range asMap { for k, v := range asMap {
switch k { 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": case "teamID":
var err error 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 { if err != nil {
return it, err return it, err
} }
@ -17286,9 +17309,6 @@ func (ec *executionContext) _Project(ctx context.Context, sel ast.SelectionSet,
} }
}() }()
res = ec._Project_team(ctx, field, obj) res = ec._Project_team(ctx, field, obj)
if res == graphql.Null {
atomic.AddUint32(&invalids, 1)
}
return res return res
}) })
case "taskGroups": case "taskGroups":
@ -20927,6 +20947,17 @@ func (ec *executionContext) marshalOString2ᚖstring(ctx context.Context, sel as
return ec.marshalOString2string(ctx, sel, *v) 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) { func (ec *executionContext) unmarshalOTime2timeᚐTime(ctx context.Context, v interface{}) (time.Time, error) {
return graphql.UnmarshalTime(v) return graphql.UnmarshalTime(v)
} }

View File

@ -2,10 +2,12 @@ package graph
import ( import (
"context" "context"
"database/sql"
"errors" "errors"
"net/http" "net/http"
"os" "os"
"reflect" "reflect"
"strings"
"time" "time"
"github.com/99designs/gqlgen/graphql" "github.com/99designs/gqlgen/graphql"
@ -19,6 +21,7 @@ import (
"github.com/jordanknott/taskcafe/internal/db" "github.com/jordanknott/taskcafe/internal/db"
"github.com/jordanknott/taskcafe/internal/utils" "github.com/jordanknott/taskcafe/internal/utils"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/vektah/gqlparser/v2/gqlerror"
) )
// NewHandler returns a new graphql endpoint handler. // 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") log.Error("subject field name does not exist on input type")
return nil, errors.New("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) subjectID, ok = subjectField.Interface().(uuid.UUID)
if !ok { if !ok {
log.Error("error while casting subject UUID") log.Error("error while casting subject UUID")
@ -93,16 +101,30 @@ func NewHandler(repo db.Repository) http.Handler {
} }
projectRoles, err := GetProjectRoles(ctx, repo, subjectID) projectRoles, err := GetProjectRoles(ctx, repo, subjectID)
if err != nil { 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") log.WithError(err).Error("error while getting project roles")
return nil, err return nil, err
} }
for _, validRole := range roles { 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") log.WithFields(log.Fields{"teamRole": projectRoles.TeamRole, "projectRole": projectRoles.ProjectRole}).Info("is team or project role")
return next(ctx) 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 { } else if level == ActionLevelTeam {
userID, ok := GetUserID(ctx) userID, ok := GetUserID(ctx)
if !ok { if !ok {
@ -114,14 +136,24 @@ func NewHandler(repo db.Repository) http.Handler {
return nil, err return nil, err
} }
for _, validRole := range roles { 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 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 := handler.New(NewExecutableSchema(c))
srv.AddTransport(transport.Websocket{ 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}) return r.GetUserRolesForProject(ctx, db.GetUserRolesForProjectParams{UserID: userID, ProjectID: projectID})
} }
// GetRoleLevel converts a role level string to a RoleLevel type // CompareRoleLevel compares a string against a role level
func GetRoleLevel(r string) RoleLevel { func CompareRoleLevel(a string, b RoleLevel) bool {
if r == RoleLevelAdmin.String() { if strings.ToLower(a) == strings.ToLower(b.String()) {
return RoleLevelAdmin return true
} }
return RoleLevelMember return false
} }
// ConvertToRoleCode converts a role code string to a RoleCode type // ConvertToRoleCode converts a role code string to a RoleCode type

View File

@ -213,8 +213,7 @@ type MemberList struct {
} }
type NewProject struct { type NewProject struct {
UserID uuid.UUID `json:"userID"` TeamID *uuid.UUID `json:"teamID"`
TeamID uuid.UUID `json:"teamID"`
Name string `json:"name"` Name string `json:"name"`
} }

View File

@ -97,7 +97,7 @@ type Project {
id: ID! id: ID!
createdAt: Time! createdAt: Time!
name: String! name: String!
team: Team! team: Team
taskGroups: [TaskGroup!]! taskGroups: [TaskGroup!]!
members: [Member!]! members: [Member!]!
labels: [ProjectLabel!]! labels: [ProjectLabel!]!
@ -185,7 +185,8 @@ type Query {
organizations: [Organization!]! organizations: [Organization!]!
users: [UserAccount!]! users: [UserAccount!]!
findUser(input: FindUser!): 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! findTask(input: FindTask!): Task!
projects(input: ProjectsFilter): [Project!]! projects(input: ProjectsFilter): [Project!]!
findTeam(input: FindTeam!): Team! findTeam(input: FindTeam!): Team!
@ -279,8 +280,7 @@ extend type Mutation {
} }
input NewProject { input NewProject {
userID: UUID! teamID: UUID
teamID: UUID!
name: String! name: String!
} }

View File

@ -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) { 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() createdAt := time.Now().UTC()
log.WithFields(log.Fields{"userID": input.UserID, "name": input.Name, "teamID": input.TeamID}).Info("creating new project") log.WithFields(log.Fields{"name": input.Name, "teamID": input.TeamID}).Info("creating new project")
project, err := r.Repository.CreateProject(ctx, db.CreateProjectParams{input.TeamID, createdAt, input.Name}) var project db.Project
return &project, err 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) { 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) { func (r *projectResolver) Team(ctx context.Context, obj *db.Project) (*db.Team, error) {
team, err := r.Repository.GetTeamByID(ctx, obj.TeamID) team, err := r.Repository.GetTeamByID(ctx, obj.TeamID)
if err != nil { 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") log.WithFields(log.Fields{"teamID": obj.TeamID, "projectID": obj.ProjectID}).WithError(err).Error("issue while getting team for project")
return &team, err 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 return &project, nil
} }
@ -1021,12 +1036,14 @@ func (r *queryResolver) Projects(ctx context.Context, input *ProjectsFilter) ([]
return r.Repository.GetAllProjectsForTeam(ctx, *input.TeamID) return r.Repository.GetAllProjectsForTeam(ctx, *input.TeamID)
} }
var teams []db.Team
var err error
if orgRole == "admin" { if orgRole == "admin" {
log.Info("showing all projects for admin") teams, err = r.Repository.GetAllTeams(ctx)
return r.Repository.GetAllProjects(ctx) } else {
teams, err = r.Repository.GetTeamsForUserIDWhereAdmin(ctx, userID)
} }
teams, err := r.Repository.GetTeamsForUserIDWhereAdmin(ctx, userID)
projects := make(map[string]db.Project) projects := make(map[string]db.Project)
for _, team := range teams { for _, team := range teams {
log.WithFields(log.Fields{"teamID": team.TeamID}).Info("found team") 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") return []db.Team{}, errors.New("internal error")
} }
if orgRole == "admin" { if orgRole == "admin" {
return r.Repository.GetAllTeams(ctx) return r.Repository.GetAllTeams(ctx)
} }
teams := make(map[string]db.Team) teams := make(map[string]db.Team)
adminTeams, err := r.Repository.GetTeamsForUserIDWhereAdmin(ctx, userID) adminTeams, err := r.Repository.GetTeamsForUserIDWhereAdmin(ctx, userID)
if err != nil { if err != nil {
log.WithError(err).Error("error while getting teams for user ID")
return []db.Team{}, err 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) visibleProjects, err := r.Repository.GetAllVisibleProjectsForUserID(ctx, userID)
if err != nil { 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 return []db.Team{}, err
} }
for _, project := range visibleProjects { 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") log.WithFields(log.Fields{"projectID": project.ProjectID.String()}).Info("adding visible project")
team, err := r.Repository.GetTeamByID(ctx, project.TeamID) team, err := r.Repository.GetTeamByID(ctx, project.TeamID)
if err != nil { 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 return []db.Team{}, err
} }
teams[project.TeamID.String()] = team teams[project.TeamID.String()] = team

View File

@ -97,7 +97,7 @@ type Project {
id: ID! id: ID!
createdAt: Time! createdAt: Time!
name: String! name: String!
team: Team! team: Team
taskGroups: [TaskGroup!]! taskGroups: [TaskGroup!]!
members: [Member!]! members: [Member!]!
labels: [ProjectLabel!]! labels: [ProjectLabel!]!

View File

@ -25,7 +25,8 @@ type Query {
organizations: [Organization!]! organizations: [Organization!]!
users: [UserAccount!]! users: [UserAccount!]!
findUser(input: FindUser!): 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! findTask(input: FindTask!): Task!
projects(input: ProjectsFilter): [Project!]! projects(input: ProjectsFilter): [Project!]!
findTeam(input: FindTeam!): Team! findTeam(input: FindTeam!): Team!

View File

@ -7,8 +7,7 @@ extend type Mutation {
} }
input NewProject { input NewProject {
userID: UUID! teamID: UUID
teamID: UUID!
name: String! name: String!
} }

View File

@ -0,0 +1 @@
ALTER TABLE project ALTER COLUMN team_id DROP NOT NULL;

View 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
);