hmn/src/hmndata/project_helper.go

510 lines
12 KiB
Go

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
}