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 {
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 (

View File

@ -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`} />} />

View File

@ -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 });
}
}}

View File

@ -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);
}
}}
>

View File

@ -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'
* },
* });

View File

@ -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 {

View File

@ -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"`

View File

@ -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
`

View File

@ -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)

View File

@ -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 *;

View File

@ -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)
}

View File

@ -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

View File

@ -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 {

View File

@ -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!
}

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) {
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

View File

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

View File

@ -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!

View File

@ -7,8 +7,7 @@ extend type Mutation {
}
input NewProject {
userID: UUID!
teamID: UUID!
teamID: UUID
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
);