feat(api): add POST /members
This commit is contained in:
parent
f2a298da75
commit
773f20d135
6 changed files with 249 additions and 14 deletions
|
@ -1,6 +1,6 @@
|
|||
# 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
|
||||
|
||||
|
@ -8,6 +8,7 @@ A work-in-progress site to share your pronouns and preferred terms.
|
|||
- Persistent data is stored in PostgreSQL
|
||||
- Temporary data is stored in Redis
|
||||
- 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
|
||||
|
||||
|
|
|
@ -164,3 +164,27 @@ func (db *DB) WriteUserAvatar(ctx context.Context,
|
|||
|
||||
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
|
||||
}
|
||||
|
|
|
@ -5,10 +5,13 @@ import (
|
|||
|
||||
"emperror.dev/errors"
|
||||
"github.com/georgysavva/scany/pgxscan"
|
||||
"github.com/jackc/pgconn"
|
||||
"github.com/jackc/pgx/v4"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
const MaxMemberCount = 500
|
||||
|
||||
type Member struct {
|
||||
ID xid.ID
|
||||
UserID xid.ID
|
||||
|
@ -18,7 +21,10 @@ type Member struct {
|
|||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
|
|
@ -1,26 +1,44 @@
|
|||
package member
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"codeberg.org/u1f320/pronouns.cc/backend/db"
|
||||
"codeberg.org/u1f320/pronouns.cc/backend/log"
|
||||
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
||||
"emperror.dev/errors"
|
||||
"github.com/go-chi/render"
|
||||
)
|
||||
|
||||
type CreateMemberRequest struct {
|
||||
Name string `json:"name"`
|
||||
Bio *string `json:"bio"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
Links []string `json:"links"`
|
||||
Names []db.Name `json:"names"`
|
||||
Pronouns []db.Pronoun `json:"pronouns"`
|
||||
Fields []db.Field `json:"fields"`
|
||||
Name string `json:"name"`
|
||||
Bio string `json:"bio"`
|
||||
Avatar string `json:"avatar"`
|
||||
Links []string `json:"links"`
|
||||
Names []db.Name `json:"names"`
|
||||
Pronouns []db.Pronoun `json:"pronouns"`
|
||||
Fields []db.Field `json:"fields"`
|
||||
}
|
||||
|
||||
func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error) {
|
||||
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
|
||||
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}
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, render.StatusCtxKey, 204)
|
||||
render.NoContent(w, r)
|
||||
// validate everything
|
||||
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
|
||||
}
|
||||
|
|
|
@ -3,11 +3,36 @@ package member
|
|||
import (
|
||||
"net/http"
|
||||
|
||||
"codeberg.org/u1f320/pronouns.cc/backend/db"
|
||||
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"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 {
|
||||
ctx := r.Context()
|
||||
|
||||
|
@ -23,7 +48,7 @@ func (s *Server) getUserMembers(w http.ResponseWriter, r *http.Request) error {
|
|||
return err
|
||||
}
|
||||
|
||||
render.JSON(w, r, ms)
|
||||
render.JSON(w, r, membersToMemberList(ms))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -36,6 +61,6 @@ func (s *Server) getMeMembers(w http.ResponseWriter, r *http.Request) error {
|
|||
return err
|
||||
}
|
||||
|
||||
render.JSON(w, r, ms)
|
||||
render.JSON(w, r, membersToMemberList(ms))
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -52,6 +52,8 @@ create table members (
|
|||
links text[]
|
||||
);
|
||||
|
||||
create unique index members_user_name_idx on members (user_id, lower(name));
|
||||
|
||||
create table member_names (
|
||||
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
|
||||
|
|
Loading…
Reference in a new issue