From ef9b186e66a3f27620295ca71becc7175f3c8070 Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 1 Apr 2023 17:20:59 +0200 Subject: [PATCH] feat(backend): add unlisted members, private member list, custom members header --- backend/db/member.go | 16 +++++++-- backend/db/user.go | 21 +++++++++--- backend/exporter/exporter.go | 2 +- backend/routes/member/create_member.go | 2 +- backend/routes/member/get_member.go | 26 ++++++++++++--- backend/routes/member/get_members.go | 13 ++++++-- backend/routes/member/patch_member.go | 6 ++-- backend/routes/user/get_user.go | 46 ++++++++++++++++++++------ backend/routes/user/patch_user.go | 13 +++++++- backend/server/errors.go | 9 +++-- frontend/src/lib/api/entities.ts | 3 ++ scripts/cleandb/main.go | 2 +- scripts/migrate/012_custom_options.sql | 8 +++++ scripts/seeddb/main.go | 2 +- 14 files changed, 135 insertions(+), 34 deletions(-) create mode 100644 scripts/migrate/012_custom_options.sql diff --git a/backend/db/member.go b/backend/db/member.go index 9a6e3de..6eb7c81 100644 --- a/backend/db/member.go +++ b/backend/db/member.go @@ -26,6 +26,7 @@ type Member struct { Links []string Names []FieldEntry Pronouns []PronounEntry + Unlisted bool } const ( @@ -68,10 +69,15 @@ func (db *DB) UserMember(ctx context.Context, userID xid.ID, memberRef string) ( } // UserMembers returns all of a user's members, sorted by name. -func (db *DB) UserMembers(ctx context.Context, userID xid.ID) (ms []Member, err error) { - sql, args, err := sq.Select("*"). +func (db *DB) UserMembers(ctx context.Context, userID xid.ID, showHidden bool) (ms []Member, err error) { + builder := sq.Select("*"). From("members").Where("user_id = ?", userID). - OrderBy("name", "id").ToSql() + OrderBy("name", "id") + if !showHidden { + builder = builder.Where("unlisted = ?", false) + } + + sql, args, err := builder.ToSql() if err != nil { return nil, errors.Wrap(err, "building sql") } @@ -148,6 +154,7 @@ func (db *DB) UpdateMember( ctx context.Context, tx pgx.Tx, id xid.ID, name, displayName, bio *string, + unlisted *bool, links *[]string, avatar *string, ) (m Member, err error) { @@ -190,6 +197,9 @@ func (db *DB) UpdateMember( if links != nil { builder = builder.Set("links", *links) } + if unlisted != nil { + builder = builder.Set("unlisted", *unlisted) + } if avatar != nil { if *avatar == "" { diff --git a/backend/db/user.go b/backend/db/user.go index 785528a..6b943e1 100644 --- a/backend/db/user.go +++ b/backend/db/user.go @@ -20,6 +20,7 @@ type User struct { Username string DisplayName *string Bio *string + MemberTitle *string Avatar *string Links []string @@ -35,8 +36,9 @@ type User struct { FediverseAppID *int64 FediverseInstance *string - MaxInvites int - IsAdmin bool + MaxInvites int + IsAdmin bool + ListPrivate bool DeletedAt *time.Time SelfDelete *bool @@ -317,10 +319,11 @@ func (db *DB) UpdateUser( ctx context.Context, tx pgx.Tx, id xid.ID, displayName, bio *string, + memberTitle *string, listPrivate *bool, links *[]string, avatar *string, ) (u User, err error) { - if displayName == nil && bio == nil && links == nil && avatar == nil { + if displayName == nil && bio == nil && links == nil && avatar == nil && memberTitle == nil && listPrivate == nil { sql, args, err := sq.Select("*").From("users").Where("id = ?", id).ToSql() if err != nil { return u, errors.Wrap(err, "building sql") @@ -349,9 +352,19 @@ func (db *DB) UpdateUser( builder = builder.Set("bio", *bio) } } + if memberTitle != nil { + if *memberTitle == "" { + builder = builder.Set("member_title", nil) + } else { + builder = builder.Set("member_title", *memberTitle) + } + } if links != nil { builder = builder.Set("links", *links) } + if listPrivate != nil { + builder = builder.Set("list_private", *listPrivate) + } if avatar != nil { if *avatar == "" { @@ -492,7 +505,7 @@ func (db *DB) CleanUser(ctx context.Context, id xid.ID) error { } } - members, err := db.UserMembers(ctx, u.ID) + members, err := db.UserMembers(ctx, u.ID, true) if err != nil { return errors.Wrap(err, "getting members") } diff --git a/backend/exporter/exporter.go b/backend/exporter/exporter.go index f475edc..590275f 100644 --- a/backend/exporter/exporter.go +++ b/backend/exporter/exporter.go @@ -175,7 +175,7 @@ func (s *server) doExport(u db.User) { log.Debugf("[%v] exported user avatar", u.ID) } - members, err := s.DB.UserMembers(ctx, u.ID) + members, err := s.DB.UserMembers(ctx, u.ID, true) if err != nil { log.Errorf("[%v] getting user members: %v", u.ID, err) return diff --git a/backend/routes/member/create_member.go b/backend/routes/member/create_member.go index fdce8ad..d3520ba 100644 --- a/backend/routes/member/create_member.go +++ b/backend/routes/member/create_member.go @@ -161,7 +161,7 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error return errors.Wrap(err, "committing transaction") } - render.JSON(w, r, dbMemberToMember(u, m, cmr.Fields)) + render.JSON(w, r, dbMemberToMember(u, m, cmr.Fields, true)) return nil } diff --git a/backend/routes/member/get_member.go b/backend/routes/member/get_member.go index 7028b4f..efe6284 100644 --- a/backend/routes/member/get_member.go +++ b/backend/routes/member/get_member.go @@ -24,10 +24,12 @@ type GetMemberResponse struct { Fields []db.Field `json:"fields"` User PartialUser `json:"user"` + + Unlisted *bool `json:"unlisted,omitempty"` } -func dbMemberToMember(u db.User, m db.Member, fields []db.Field) GetMemberResponse { - return GetMemberResponse{ +func dbMemberToMember(u db.User, m db.Member, fields []db.Field, isOwnMember bool) GetMemberResponse { + r := GetMemberResponse{ ID: m.ID, Name: m.Name, DisplayName: m.DisplayName, @@ -46,6 +48,12 @@ func dbMemberToMember(u db.User, m db.Member, fields []db.Field) GetMemberRespon Avatar: u.Avatar, }, } + + if isOwnMember { + r.Unlisted = &m.Unlisted + } + + return r } type PartialUser struct { @@ -81,12 +89,17 @@ func (s *Server) getMember(w http.ResponseWriter, r *http.Request) error { return server.APIError{Code: server.ErrMemberNotFound} } + isOwnMember := false + if claims, ok := server.ClaimsFromContext(ctx); ok && claims.UserID == u.ID { + isOwnMember = true + } + fields, err := s.DB.MemberFields(ctx, m.ID) if err != nil { return err } - render.JSON(w, r, dbMemberToMember(u, m, fields)) + render.JSON(w, r, dbMemberToMember(u, m, fields, isOwnMember)) return nil } @@ -104,6 +117,11 @@ func (s *Server) getUserMember(w http.ResponseWriter, r *http.Request) error { return server.APIError{Code: server.ErrUserNotFound} } + isOwnMember := false + if claims, ok := server.ClaimsFromContext(ctx); ok && claims.UserID == u.ID { + isOwnMember = true + } + m, err := s.DB.UserMember(ctx, u.ID, chi.URLParam(r, "memberRef")) if err != nil { return server.APIError{ @@ -116,7 +134,7 @@ func (s *Server) getUserMember(w http.ResponseWriter, r *http.Request) error { return err } - render.JSON(w, r, dbMemberToMember(u, m, fields)) + render.JSON(w, r, dbMemberToMember(u, m, fields, isOwnMember)) return nil } diff --git a/backend/routes/member/get_members.go b/backend/routes/member/get_members.go index 6f90b24..7883744 100644 --- a/backend/routes/member/get_members.go +++ b/backend/routes/member/get_members.go @@ -52,7 +52,16 @@ func (s *Server) getUserMembers(w http.ResponseWriter, r *http.Request) error { return server.APIError{Code: server.ErrUserNotFound} } - ms, err := s.DB.UserMembers(ctx, u.ID) + isSelf := false + if claims, ok := server.ClaimsFromContext(ctx); ok && claims.UserID == u.ID { + isSelf = true + } + + if u.ListPrivate && !isSelf { + return server.APIError{Code: server.ErrMemberListPrivate} + } + + ms, err := s.DB.UserMembers(ctx, u.ID, isSelf) if err != nil { return err } @@ -65,7 +74,7 @@ func (s *Server) getMeMembers(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() claims, _ := server.ClaimsFromContext(ctx) - ms, err := s.DB.UserMembers(ctx, claims.UserID) + ms, err := s.DB.UserMembers(ctx, claims.UserID, true) if err != nil { return err } diff --git a/backend/routes/member/patch_member.go b/backend/routes/member/patch_member.go index c83746d..f512c72 100644 --- a/backend/routes/member/patch_member.go +++ b/backend/routes/member/patch_member.go @@ -23,6 +23,7 @@ type PatchMemberRequest struct { Pronouns *[]db.PronounEntry `json:"pronouns"` Fields *[]db.Field `json:"fields"` Avatar *string `json:"avatar"` + Unlisted *bool `json:"unlisted"` } func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { @@ -62,6 +63,7 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { if req.DisplayName == nil && req.Name == nil && req.Bio == nil && + req.Unlisted == nil && req.Links == nil && req.Fields == nil && req.Names == nil && @@ -213,7 +215,7 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { } defer tx.Rollback(ctx) - m, err = s.DB.UpdateMember(ctx, tx, id, req.Name, req.DisplayName, req.Bio, req.Links, avatarHash) + m, err = s.DB.UpdateMember(ctx, tx, id, req.Name, req.DisplayName, req.Bio, req.Unlisted, req.Links, avatarHash) if err != nil { switch errors.Cause(err) { case db.ErrNothingToUpdate: @@ -274,6 +276,6 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { } // echo the updated member back on success - render.JSON(w, r, dbMemberToMember(u, m, fields)) + render.JSON(w, r, dbMemberToMember(u, m, fields, true)) return nil } diff --git a/backend/routes/user/get_user.go b/backend/routes/user/get_user.go index 0b36904..b8e3833 100644 --- a/backend/routes/user/get_user.go +++ b/backend/routes/user/get_user.go @@ -16,6 +16,7 @@ type GetUserResponse struct { Username string `json:"name"` DisplayName *string `json:"display_name"` Bio *string `json:"bio"` + MemberTitle *string `json:"member_title"` Avatar *string `json:"avatar"` Links []string `json:"links"` Names []db.FieldEntry `json:"names"` @@ -27,8 +28,9 @@ type GetUserResponse struct { type GetMeResponse struct { GetUserResponse - MaxInvites int `json:"max_invites"` - IsAdmin bool `json:"is_admin"` + MaxInvites int `json:"max_invites"` + IsAdmin bool `json:"is_admin"` + ListPrivate bool `json:"list_private"` Discord *string `json:"discord"` DiscordUsername *string `json:"discord_username"` @@ -55,6 +57,7 @@ func dbUserToResponse(u db.User, fields []db.Field, members []db.Member) GetUser Username: u.Username, DisplayName: u.DisplayName, Bio: u.Bio, + MemberTitle: u.MemberTitle, Avatar: u.Avatar, Links: db.NotNull(u.Links), Names: db.NotNull(u.Names), @@ -87,16 +90,28 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) error { if id, err := xid.FromString(userRef); err == nil { u, err := s.DB.User(ctx, id) if err == nil { + if u.DeletedAt != nil { + return server.APIError{Code: server.ErrUserNotFound} + } + + isSelf := false + if claims, ok := server.ClaimsFromContext(ctx); ok && claims.UserID == u.ID { + isSelf = true + } + fields, err := s.DB.UserFields(ctx, u.ID) if err != nil { log.Errorf("Error getting user fields: %v", err) return err } - members, err := s.DB.UserMembers(ctx, u.ID) - if err != nil { - log.Errorf("Error getting user members: %v", err) - return err + var members []db.Member + if !u.ListPrivate || isSelf { + members, err = s.DB.UserMembers(ctx, u.ID, isSelf) + if err != nil { + log.Errorf("Error getting user members: %v", err) + return err + } } render.JSON(w, r, dbUserToResponse(u, fields, members)) @@ -123,16 +138,24 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) error { return server.APIError{Code: server.ErrUserNotFound} } + isSelf := false + if claims, ok := server.ClaimsFromContext(ctx); ok && claims.UserID == u.ID { + isSelf = true + } + fields, err := s.DB.UserFields(ctx, u.ID) if err != nil { log.Errorf("Error getting user fields: %v", err) return err } - members, err := s.DB.UserMembers(ctx, u.ID) - if err != nil { - log.Errorf("Error getting user members: %v", err) - return err + var members []db.Member + if !u.ListPrivate || isSelf { + members, err = s.DB.UserMembers(ctx, u.ID, isSelf) + if err != nil { + log.Errorf("Error getting user members: %v", err) + return err + } } render.JSON(w, r, dbUserToResponse(u, fields, members)) @@ -155,7 +178,7 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error { return err } - members, err := s.DB.UserMembers(ctx, u.ID) + members, err := s.DB.UserMembers(ctx, u.ID, true) if err != nil { log.Errorf("Error getting user members: %v", err) return err @@ -165,6 +188,7 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error { GetUserResponse: dbUserToResponse(u, fields, members), MaxInvites: u.MaxInvites, IsAdmin: u.IsAdmin, + ListPrivate: u.ListPrivate, Discord: u.Discord, DiscordUsername: u.DiscordUsername, Fediverse: u.Fediverse, diff --git a/backend/routes/user/patch_user.go b/backend/routes/user/patch_user.go index 622621d..35901ca 100644 --- a/backend/routes/user/patch_user.go +++ b/backend/routes/user/patch_user.go @@ -15,11 +15,13 @@ type PatchUserRequest struct { Username *string `json:"username"` DisplayName *string `json:"display_name"` Bio *string `json:"bio"` + MemberTitle *string `json:"member_title"` Links *[]string `json:"links"` Names *[]db.FieldEntry `json:"names"` Pronouns *[]db.PronounEntry `json:"pronouns"` Fields *[]db.Field `json:"fields"` Avatar *string `json:"avatar"` + ListPrivate *bool `json:"list_private"` } // patchUser parses a PatchUserRequest and updates the user with the given ID. @@ -48,6 +50,8 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { if req.Username == nil && req.DisplayName == nil && req.Bio == nil && + req.MemberTitle == nil && + req.ListPrivate == nil && req.Links == nil && req.Fields == nil && req.Names == nil && @@ -72,6 +76,13 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { Details: fmt.Sprintf("Bio too long (max %d, current %d)", db.MaxUserBioLength, len(*req.Bio)), } } + // this is considered a name + if req.MemberTitle != nil && len(*req.MemberTitle) > db.MaxDisplayNameLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("Member title too long (max %d, current %d)", db.MaxDisplayNameLength, len(*req.MemberTitle)), + } + } // validate links if req.Links != nil { @@ -175,7 +186,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { } } - u, err = s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.Links, avatarHash) + u, err = s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.MemberTitle, req.ListPrivate, req.Links, avatarHash) if err != nil && errors.Cause(err) != db.ErrNothingToUpdate { log.Errorf("updating user: %v", err) return err diff --git a/backend/server/errors.go b/backend/server/errors.go index ab657a3..0b0ae3d 100644 --- a/backend/server/errors.go +++ b/backend/server/errors.go @@ -99,7 +99,8 @@ const ( ErrLastProvider = 1016 // unlinking provider would leave account with no authentication method // User-related error codes - ErrUserNotFound = 2001 + ErrUserNotFound = 2001 + ErrMemberListPrivate = 2002 // Member-related error codes ErrMemberNotFound = 3001 @@ -141,7 +142,8 @@ var errCodeMessages = map[int]string{ ErrNotLinked: "Your account is already not linked to an account of this type", ErrLastProvider: "This is your account's only authentication provider", - ErrUserNotFound: "User not found", + ErrUserNotFound: "User not found", + ErrMemberListPrivate: "This user's member list is private.", ErrMemberNotFound: "Member not found", ErrMemberLimitReached: "Member limit reached", @@ -180,7 +182,8 @@ var errCodeStatuses = map[int]int{ ErrNotLinked: http.StatusBadRequest, ErrLastProvider: http.StatusBadRequest, - ErrUserNotFound: http.StatusNotFound, + ErrUserNotFound: http.StatusNotFound, + ErrMemberListPrivate: http.StatusForbidden, ErrMemberNotFound: http.StatusNotFound, ErrMemberLimitReached: http.StatusBadRequest, diff --git a/frontend/src/lib/api/entities.ts b/frontend/src/lib/api/entities.ts index b2ba0ec..133b609 100644 --- a/frontend/src/lib/api/entities.ts +++ b/frontend/src/lib/api/entities.ts @@ -10,6 +10,7 @@ export interface User { bio: string | null; avatar: string | null; links: string[]; + member_title: string | null; names: FieldEntry[]; pronouns: Pronoun[]; @@ -24,6 +25,7 @@ export interface MeUser extends User { fediverse: string | null; fediverse_username: string | null; fediverse_instance: string | null; + list_private: boolean; } export interface Field { @@ -66,6 +68,7 @@ export interface Member extends PartialMember { fields: Field[]; user: MemberPartialUser; + unlisted?: boolean; } export interface MemberPartialUser { diff --git a/scripts/cleandb/main.go b/scripts/cleandb/main.go index 2741ed4..9a4ff10 100644 --- a/scripts/cleandb/main.go +++ b/scripts/cleandb/main.go @@ -83,7 +83,7 @@ func run(c *cli.Context) error { } for _, u := range users { - members, err := db.UserMembers(ctx, u.ID) + members, err := db.UserMembers(ctx, u.ID, true) if err != nil { fmt.Printf("error getting members for user %v: %v\n", u.ID, err) continue diff --git a/scripts/migrate/012_custom_options.sql b/scripts/migrate/012_custom_options.sql new file mode 100644 index 0000000..31c2212 --- /dev/null +++ b/scripts/migrate/012_custom_options.sql @@ -0,0 +1,8 @@ +-- +migrate Up + +-- 2023-04-01: Add a couple customization options to users and members + +alter table users add column member_title text; +alter table users add column list_private boolean not null default false; + +alter table members add column unlisted boolean not null default false; diff --git a/scripts/seeddb/main.go b/scripts/seeddb/main.go index 4cf31c2..d3d31c2 100644 --- a/scripts/seeddb/main.go +++ b/scripts/seeddb/main.go @@ -48,7 +48,7 @@ func run(c *cli.Context) error { return err } - _, err = pg.UpdateUser(ctx, tx, u.ID, ptr("testing"), ptr("This is a bio!"), &[]string{"https://pronouns.cc"}, nil) + _, err = pg.UpdateUser(ctx, tx, u.ID, ptr("testing"), ptr("This is a bio!"), nil, ptr(false), &[]string{"https://pronouns.cc"}, nil) if err != nil { fmt.Println("error setting user info:", err) return err