feat(api): add POST /members
This commit is contained in:
parent
f2a298da75
commit
773f20d135
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue