package hmndata import ( "context" "fmt" "git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/perf" ) type ProjectTypeQuery int const ( PersonalProjects ProjectTypeQuery = 1 << iota OfficialProjects ) type ProjectsQuery struct { // Available on all project queries. By default, you will get projects that // are generally visible to all users. Lifecycles []models.ProjectLifecycle // If empty, defaults to visible lifecycles. Do not conflate this with permissions; those are checked separately. Types ProjectTypeQuery // bitfield IncludeHidden bool // Ignored when using FetchProject ProjectIDs []int // if empty, all projects Slugs []string // if empty, all projects OwnerIDs []int // if empty, all projects // Ignored when using CountProjects Limit, Offset int // if empty, no pagination OrderBy string } type ProjectAndStuff struct { Project models.Project LogoLightAsset *models.Asset `db:"logolight_asset"` LogoDarkAsset *models.Asset `db:"logodark_asset"` Owners []*models.User Tag *models.Tag } func (p *ProjectAndStuff) TagText() string { if p.Tag == nil { return "" } else { return p.Tag.Text } } func FetchProjects( ctx context.Context, dbConn db.ConnOrTx, currentUser *models.User, q ProjectsQuery, ) ([]ProjectAndStuff, error) { perf := perf.ExtractPerf(ctx) perf.StartBlock("SQL", "Fetch projects") defer perf.EndBlock() tx, err := dbConn.Begin(ctx) if err != nil { return nil, oops.New(err, "failed to start transaction") } defer tx.Rollback(ctx) type projectRow struct { Project models.Project `db:"project"` LogoLightAsset *models.Asset `db:"logolight_asset"` LogoDarkAsset *models.Asset `db:"logodark_asset"` Tag *models.Tag `db:"tag"` } // Fetch all valid projects (not yet subject to user permission checks) var qb db.QueryBuilder if len(q.OrderBy) > 0 { qb.Add(`SELECT * FROM (`) } qb.Add(` SELECT DISTINCT ON (project.id) $columns FROM project LEFT JOIN asset AS logolight_asset ON logolight_asset.id = project.logolight_asset_id LEFT JOIN asset AS logodark_asset ON logodark_asset.id = project.logodark_asset_id LEFT JOIN tag ON project.tag = tag.id `) if len(q.OwnerIDs) > 0 { qb.Add( ` JOIN ( SELECT project_id, array_agg(user_id) AS owner_ids FROM user_project WHERE user_id = ANY ($?) GROUP BY project_id ) AS owner_filter ON project.id = owner_filter.project_id `, q.OwnerIDs, ) } // Filters (permissions are checked after the query, in Go) qb.Add(` WHERE TRUE `) if len(q.Lifecycles) > 0 { qb.Add(`AND project.lifecycle = ANY ($?)`, q.Lifecycles) } else { qb.Add(`AND project.lifecycle = ANY ($?)`, models.VisibleProjectLifecycles) } if q.Types != 0 { qb.Add(`AND (FALSE`) if q.Types&PersonalProjects != 0 { qb.Add(`OR project.personal`) } if q.Types&OfficialProjects != 0 { qb.Add(`OR NOT project.personal`) } qb.Add(`)`) } if !q.IncludeHidden { qb.Add(`AND NOT project.hidden`) } if len(q.ProjectIDs) > 0 { qb.Add(`AND project.id = ANY ($?)`, q.ProjectIDs) } if len(q.Slugs) > 0 { qb.Add(`AND (project.slug != '' AND project.slug = ANY ($?))`, q.Slugs) } // Output if q.Limit > 0 { qb.Add(`LIMIT $? OFFSET $?`, q.Limit, q.Offset) } if len(q.OrderBy) > 0 { qb.Add(fmt.Sprintf(`) q ORDER BY %s`, q.OrderBy)) } // Do the query projectRows, err := db.Query[projectRow](ctx, dbConn, qb.String(), qb.Args()...) if err != nil { return nil, oops.New(err, "failed to fetch projects") } // Fetch project owners to do permission checks projectIds := make([]int, len(projectRows)) for i, p := range projectRows { projectIds[i] = p.Project.ID } projectOwners, err := FetchMultipleProjectsOwners(ctx, tx, currentUser, projectIds) if err != nil { return nil, err } var res []ProjectAndStuff for i, p := range projectRows { owners := projectOwners[i].Owners /* Here's the rundown on project permissions: - In general, users can only see projects that are Generally Visible. - As an exception, users can always see projects that they own. - As an exception, staff can always see every project. A project is Generally Visible if all the following conditions are true: - The project has a "visible" lifecycle (per models.VisibleProjectLifecycles) - The project is not hidden - One of the following is true: - The project is official - The project is personal and all of the project's owners are approved As an exception, the HMN project is always generally visible. See https://www.notion.so/handmade-network/Technical-Plan-a11aaa9ea2f14d9a95f7d7780edd789c */ currentUserIsOwner := false allOwnersApproved := true for _, owner := range owners { if owner.Status != models.UserStatusApproved { allOwnersApproved = false } if currentUser != nil && owner.ID == currentUser.ID { currentUserIsOwner = true } } projectGenerallyVisible := true && p.Project.Lifecycle.In(models.VisibleProjectLifecycles) && !p.Project.Hidden && (!p.Project.Personal || allOwnersApproved || p.Project.IsHMN()) if p.Project.IsHMN() { projectGenerallyVisible = true // hard override } projectVisible := false || projectGenerallyVisible || currentUserIsOwner || (currentUser != nil && currentUser.IsStaff) if projectVisible { res = append(res, ProjectAndStuff{ Project: p.Project, LogoLightAsset: p.LogoLightAsset, LogoDarkAsset: p.LogoDarkAsset, Owners: owners, Tag: p.Tag, }) } } err = tx.Commit(ctx) if err != nil { return nil, oops.New(err, "failed to commit transaction") } return res, nil } /* Fetches a single project. A wrapper around FetchProjects. Returns db.NotFound if no result is found. */ func FetchProject( ctx context.Context, dbConn db.ConnOrTx, currentUser *models.User, projectID int, q ProjectsQuery, ) (ProjectAndStuff, error) { q.ProjectIDs = []int{projectID} q.Limit = 1 q.Offset = 0 res, err := FetchProjects(ctx, dbConn, currentUser, q) if err != nil { return ProjectAndStuff{}, oops.New(err, "failed to fetch project") } if len(res) == 0 { return ProjectAndStuff{}, db.NotFound } return res[0], nil } /* Fetches a single project by slug. A wrapper around FetchProjects. Returns db.NotFound if no result is found. */ func FetchProjectBySlug( ctx context.Context, dbConn db.ConnOrTx, currentUser *models.User, projectSlug string, q ProjectsQuery, ) (ProjectAndStuff, error) { q.Slugs = []string{projectSlug} q.Limit = 1 q.Offset = 0 res, err := FetchProjects(ctx, dbConn, currentUser, q) if err != nil { return ProjectAndStuff{}, oops.New(err, "failed to fetch project") } if len(res) == 0 { return ProjectAndStuff{}, db.NotFound } return res[0], nil } func CountProjects( ctx context.Context, dbConn db.ConnOrTx, currentUser *models.User, q ProjectsQuery, ) (int, error) { perf := perf.ExtractPerf(ctx) perf.StartBlock("SQL", "Count projects") defer perf.EndBlock() q.Limit = 0 q.Offset = 0 // I'm lazy and there probably won't ever be that many projects. projects, err := FetchProjects(ctx, dbConn, currentUser, q) if err != nil { return 0, oops.New(err, "failed to fetch projects") } return len(projects), nil } type ProjectOwners struct { ProjectID int Owners []*models.User } // Fetches all owners for multiple projects. Does NOT check permissions on the // project IDs, since the assumption is that you will check permissions on the // projects themselves before using any of this data. // // The returned slice will always have one entry for each project ID given, in // the same order as they were provided. If there are duplicate project IDs in // projectIds, the results will be wrong, so don't do that. // // This function does not verify that the requested projects do in fact exist. func FetchMultipleProjectsOwners( ctx context.Context, dbConn db.ConnOrTx, currentUser *models.User, projectIds []int, ) ([]ProjectOwners, error) { perf := perf.ExtractPerf(ctx) perf.StartBlock("SQL", "Fetch owners for multiple projects") defer perf.EndBlock() tx, err := dbConn.Begin(ctx) if err != nil { return nil, oops.New(err, "failed to start transaction") } defer tx.Rollback(ctx) // Fetch all user/project pairs for the given projects type userProject struct { UserID int `db:"user_id"` ProjectID int `db:"project_id"` } userProjects, err := db.Query[userProject](ctx, tx, ` SELECT $columns FROM user_project WHERE project_id = ANY($1) `, projectIds, ) if err != nil { return nil, oops.New(err, "failed to fetch project IDs") } // Get the unique user IDs from this set and fetch the users from the db var userIds []int for _, userProject := range userProjects { addUserId := true for _, uid := range userIds { if uid == userProject.UserID { addUserId = false } } if addUserId { userIds = append(userIds, userProject.UserID) } } users, err := FetchUsers(ctx, tx, currentUser, UsersQuery{ UserIDs: userIds, }) if err != nil { return nil, oops.New(err, "failed to fetch users for projects") } // Build the final result set with real user data res := make([]ProjectOwners, len(projectIds)) for i, pid := range projectIds { res[i] = ProjectOwners{ProjectID: pid} } for _, userProject := range userProjects { // Get a pointer to the existing record in the result var projectOwners *ProjectOwners for i := range res { if res[i].ProjectID == userProject.ProjectID { projectOwners = &res[i] } } // Get the full user record we fetched var user *models.User for _, u := range users { if u.ID == userProject.UserID { user = u } } if user == nil { panic("we apparently failed to fetch a project's owner") } // Slam 'em together projectOwners.Owners = append(projectOwners.Owners, user) } err = tx.Commit(ctx) if err != nil { return nil, oops.New(err, "failed to commit transaction") } return res, nil } // Fetches project owners for a single project. It is subject to all the same // restrictions as FetchMultipleProjectsOwners. func FetchProjectOwners( ctx context.Context, dbConn db.ConnOrTx, currentUser *models.User, projectId int, ) ([]*models.User, error) { perf := perf.ExtractPerf(ctx) perf.StartBlock("SQL", "Fetch owners for project") defer perf.EndBlock() projectOwners, err := FetchMultipleProjectsOwners(ctx, dbConn, currentUser, []int{projectId}) if err != nil { return nil, err } return projectOwners[0].Owners, nil } func UrlContextForProject(p *models.Project) *hmnurl.UrlContext { return &hmnurl.UrlContext{ PersonalProject: p.Personal, ProjectID: p.ID, ProjectSlug: p.Slug, ProjectName: p.Name, } } func SetProjectTag( ctx context.Context, dbConn db.ConnOrTx, currentUser *models.User, projectID int, tagText string, ) (*models.Tag, error) { tx, err := dbConn.Begin(ctx) if err != nil { return nil, oops.New(err, "failed to start transaction") } defer tx.Rollback(ctx) p, err := FetchProject(ctx, tx, currentUser, projectID, ProjectsQuery{ Lifecycles: models.AllProjectLifecycles, IncludeHidden: true, }) if err != nil { return nil, oops.New(err, "Failed to fetch project") } var resultTag *models.Tag if tagText == "" { // Once a project's tag is set, it cannot be unset. Return the existing tag. resultTag = p.Tag } else if p.Project.TagID == nil { // Create a tag tag, err := db.QueryOne[models.Tag](ctx, tx, ` INSERT INTO tag (text) VALUES ($1) RETURNING $columns `, tagText, ) if err != nil { return nil, oops.New(err, "failed to create new tag for project") } resultTag = tag // Attach it to the project _, err = tx.Exec(ctx, ` UPDATE project SET tag = $1 WHERE id = $2 `, resultTag.ID, projectID, ) if err != nil { return nil, oops.New(err, "failed to attach new tag to project") } } else { // Update the text of an existing one tag, err := db.QueryOne[models.Tag](ctx, tx, ` UPDATE tag SET text = $1 WHERE id = (SELECT tag FROM project WHERE id = $2) RETURNING $columns `, tagText, projectID, ) if err != nil { return nil, oops.New(err, "failed to update existing tag") } resultTag = tag } err = tx.Commit(ctx) if err != nil { return nil, oops.New(err, "failed to commit transaction") } return resultTag, nil }