feat(api): add POST /members

This commit is contained in:
Sam 2022-10-03 10:59:30 +02:00
parent f2a298da75
commit 773f20d135
6 changed files with 249 additions and 14 deletions

View file

@ -1,6 +1,6 @@
# pronouns.cc # pronouns.cc
A work-in-progress site to share your pronouns and preferred terms. A work-in-progress site to share your names, pronouns, and other preferred terms.
## Stack ## Stack
@ -8,6 +8,7 @@ A work-in-progress site to share your pronouns and preferred terms.
- Persistent data is stored in PostgreSQL - Persistent data is stored in PostgreSQL
- Temporary data is stored in Redis - Temporary data is stored in Redis
- The frontend is written in TypeScript with React, using [Next](https://nextjs.org/) for server-side rendering - The frontend is written in TypeScript with React, using [Next](https://nextjs.org/) for server-side rendering
- Avatars are stored in S3-compatible storage ([MinIO](https://github.com/minio/minio) for development)
## Development ## Development

View file

@ -164,3 +164,27 @@ func (db *DB) WriteUserAvatar(ctx context.Context,
return webpInfo.Location, jpegInfo.Location, nil return webpInfo.Location, jpegInfo.Location, nil
} }
func (db *DB) WriteMemberAvatar(ctx context.Context,
memberID xid.ID, webp io.Reader, jpeg io.Reader,
) (
webpLocation string,
jpegLocation string,
err error,
) {
webpInfo, err := db.minio.PutObject(ctx, db.minioBucket, "/members/"+memberID.String()+".webp", webp, -1, minio.PutObjectOptions{
ContentType: "image/webp",
})
if err != nil {
return "", "", errors.Wrap(err, "uploading webp avatar")
}
jpegInfo, err := db.minio.PutObject(ctx, db.minioBucket, "/members/"+memberID.String()+".jpg", jpeg, -1, minio.PutObjectOptions{
ContentType: "image/jpeg",
})
if err != nil {
return "", "", errors.Wrap(err, "uploading jpeg avatar")
}
return webpInfo.Location, jpegInfo.Location, nil
}

View file

@ -5,10 +5,13 @@ import (
"emperror.dev/errors" "emperror.dev/errors"
"github.com/georgysavva/scany/pgxscan" "github.com/georgysavva/scany/pgxscan"
"github.com/jackc/pgconn"
"github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4"
"github.com/rs/xid" "github.com/rs/xid"
) )
const MaxMemberCount = 500
type Member struct { type Member struct {
ID xid.ID ID xid.ID
UserID xid.ID UserID xid.ID
@ -18,7 +21,10 @@ type Member struct {
Links []string Links []string
} }
const ErrMemberNotFound = errors.Sentinel("member not found") const (
ErrMemberNotFound = errors.Sentinel("member not found")
ErrMemberNameInUse = errors.Sentinel("member name already in use")
)
func (db *DB) Member(ctx context.Context, id xid.ID) (m Member, err error) { func (db *DB) Member(ctx context.Context, id xid.ID) (m Member, err error) {
sql, args, err := sq.Select("*").From("members").Where("id = ?", id).ToSql() sql, args, err := sq.Select("*").From("members").Where("id = ?", id).ToSql()
@ -71,3 +77,43 @@ func (db *DB) UserMembers(ctx context.Context, userID xid.ID) (ms []Member, err
} }
return ms, nil return ms, nil
} }
// CreateMember creates a member.
func (db *DB) CreateMember(ctx context.Context, tx pgx.Tx, userID xid.ID, name, bio string, links []string) (m Member, err error) {
sql, args, err := sq.Insert("members").
Columns("user_id", "id", "name", "bio", "links").
Values(userID, xid.New(), name, bio, links).
Suffix("RETURNING *").ToSql()
if err != nil {
return m, errors.Wrap(err, "building sql")
}
err = pgxscan.Get(ctx, db, &m, sql, args...)
if err != nil {
pge := &pgconn.PgError{}
if errors.As(err, &pge) {
if pge.Code == "23505" {
return m, ErrMemberNameInUse
}
}
return m, errors.Wrap(err, "executing query")
}
return m, nil
}
// MemberCount returns the number of members that the given user has.
func (db *DB) MemberCount(ctx context.Context, userID xid.ID) (n int64, err error) {
sql, args, err := sq.Select("count(id)").From("members").Where("user_id = ?", userID).ToSql()
if err != nil {
return 0, errors.Wrap(err, "building sql")
}
err = db.QueryRow(ctx, sql, args...).Scan(&n)
if err != nil {
return 0, errors.Wrap(err, "executing query")
}
return n, nil
}

View file

@ -1,26 +1,44 @@
package member package member
import ( import (
"context" "fmt"
"net/http" "net/http"
"codeberg.org/u1f320/pronouns.cc/backend/db" "codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server" "codeberg.org/u1f320/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render" "github.com/go-chi/render"
) )
type CreateMemberRequest struct { type CreateMemberRequest struct {
Name string `json:"name"` Name string `json:"name"`
Bio *string `json:"bio"` Bio string `json:"bio"`
AvatarURL *string `json:"avatar_url"` Avatar string `json:"avatar"`
Links []string `json:"links"` Links []string `json:"links"`
Names []db.Name `json:"names"` Names []db.Name `json:"names"`
Pronouns []db.Pronoun `json:"pronouns"` Pronouns []db.Pronoun `json:"pronouns"`
Fields []db.Field `json:"fields"` Fields []db.Field `json:"fields"`
} }
func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error) { func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error) {
ctx := r.Context() ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting user")
}
memberCount, err := s.DB.MemberCount(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting member count")
}
if memberCount > db.MaxMemberCount {
return server.APIError{
Code: server.ErrMemberLimitReached,
}
}
var cmr CreateMemberRequest var cmr CreateMemberRequest
err = render.Decode(r, &cmr) err = render.Decode(r, &cmr)
@ -32,8 +50,127 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
return server.APIError{Code: server.ErrBadRequest} return server.APIError{Code: server.ErrBadRequest}
} }
ctx = context.WithValue(ctx, render.StatusCtxKey, 204) // validate everything
render.NoContent(w, r) if cmr.Name == "" {
return server.APIError{
Code: server.ErrBadRequest,
Details: "name may not be empty",
}
}
if err := validateSlicePtr("name", &cmr.Names); err != nil {
return err
}
if err := validateSlicePtr("pronoun", &cmr.Pronouns); err != nil {
return err
}
if err := validateSlicePtr("field", &cmr.Fields); err != nil {
return err
}
tx, err := s.DB.Begin(ctx)
if err != nil {
return errors.Wrap(err, "starting transaction")
}
defer tx.Rollback(ctx)
m, err := s.DB.CreateMember(ctx, tx, claims.UserID, cmr.Name, cmr.Bio, cmr.Links)
if err != nil {
return err
}
// set names, pronouns, fields
err = s.DB.SetMemberNames(ctx, tx, claims.UserID, cmr.Names)
if err != nil {
log.Errorf("setting names for user %v: %v", claims.UserID, err)
return err
}
err = s.DB.SetMemberPronouns(ctx, tx, claims.UserID, cmr.Pronouns)
if err != nil {
log.Errorf("setting pronouns for user %v: %v", claims.UserID, err)
return err
}
err = s.DB.SetMemberFields(ctx, tx, claims.UserID, cmr.Fields)
if err != nil {
log.Errorf("setting fields for user %v: %v", claims.UserID, err)
return err
}
if cmr.Avatar != "" {
webp, jpg, err := s.DB.ConvertAvatar(cmr.Avatar)
if err != nil {
if err == db.ErrInvalidDataURI {
return server.APIError{
Code: server.ErrBadRequest,
Details: "invalid avatar data URI",
}
} else if err == db.ErrInvalidContentType {
return server.APIError{
Code: server.ErrBadRequest,
Details: "invalid avatar content type",
}
}
log.Errorf("converting member avatar: %v", err)
return err
}
webpURL, jpgURL, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg)
if err != nil {
log.Errorf("uploading member avatar: %v", err)
return err
}
err = tx.QueryRow(ctx, "UPDATE members SET avatar_urls = $1 WHERE id = $2", []string{webpURL, jpgURL}, m.ID).Scan(&m.AvatarURLs)
if err != nil {
return errors.Wrap(err, "setting avatar urls in db")
}
}
err = tx.Commit(ctx)
if err != nil {
return errors.Wrap(err, "committing transaction")
}
render.JSON(w, r, dbMemberToMember(u, m, cmr.Names, cmr.Pronouns, cmr.Fields))
return nil
}
type validator interface {
Validate() string
}
// validateSlicePtr validates a slice of validators.
// If the slice is nil, a nil error is returned (assuming that the field is not required)
func validateSlicePtr[T validator](typ string, slice *[]T) *server.APIError {
if slice == nil {
return nil
}
max := db.MaxFields
if typ != "field" {
max = db.FieldEntriesLimit
}
// max 25 fields
if len(*slice) > max {
return &server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Too many %ss (max %d, current %d)", typ, max, len(*slice)),
}
}
// validate all fields
for i, pronouns := range *slice {
if s := pronouns.Validate(); s != "" {
return &server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("%s %d: %s", typ, i, s),
}
}
}
return nil return nil
} }

View file

@ -3,11 +3,36 @@ package member
import ( import (
"net/http" "net/http"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/server" "codeberg.org/u1f320/pronouns.cc/backend/server"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/rs/xid"
) )
type memberListResponse struct {
ID xid.ID `json:"id"`
Name string `json:"name"`
Bio *string `json:"bio"`
AvatarURLs []string `json:"avatar_urls"`
Links []string `json:"links"`
}
func membersToMemberList(ms []db.Member) []memberListResponse {
resps := make([]memberListResponse, len(ms))
for i := range ms {
resps[i] = memberListResponse{
ID: ms[i].ID,
Name: ms[i].Name,
Bio: ms[i].Bio,
AvatarURLs: ms[i].AvatarURLs,
Links: ms[i].Links,
}
}
return resps
}
func (s *Server) getUserMembers(w http.ResponseWriter, r *http.Request) error { func (s *Server) getUserMembers(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context() ctx := r.Context()
@ -23,7 +48,7 @@ func (s *Server) getUserMembers(w http.ResponseWriter, r *http.Request) error {
return err return err
} }
render.JSON(w, r, ms) render.JSON(w, r, membersToMemberList(ms))
return nil return nil
} }
@ -36,6 +61,6 @@ func (s *Server) getMeMembers(w http.ResponseWriter, r *http.Request) error {
return err return err
} }
render.JSON(w, r, ms) render.JSON(w, r, membersToMemberList(ms))
return nil return nil
} }

View file

@ -52,6 +52,8 @@ create table members (
links text[] links text[]
); );
create unique index members_user_name_idx on members (user_id, lower(name));
create table member_names ( create table member_names (
member_id text not null references members (id) on delete cascade, member_id text not null references members (id) on delete cascade,
id bigserial primary key, -- ID is used for sorting; when order changes, existing rows are deleted and new ones are created id bigserial primary key, -- ID is used for sorting; when order changes, existing rows are deleted and new ones are created