implement avatar backend functionality
This commit is contained in:
@@ -148,6 +148,9 @@ func main() {
|
||||
// Settings
|
||||
mux.Handle("PATCH /humans/me/settings", withAuth(h.UpdateSettings))
|
||||
|
||||
// Get avatar download url
|
||||
mux.Handle("GET /humans/avatar/{id}", withAuth(h.GetObjectDownloadUrl))
|
||||
|
||||
// Push notification tokens (per-device)
|
||||
mux.Handle("POST /humans/me/push-tokens", withAuth(h.RegisterPushToken))
|
||||
mux.Handle("DELETE /humans/me/push-tokens", withAuth(h.UnregisterPushToken))
|
||||
@@ -174,7 +177,7 @@ func main() {
|
||||
mux.Handle("POST /invitations/accept", withAuth(h.AcceptInvitation))
|
||||
|
||||
// Particles
|
||||
mux.Handle("GET /particles/{id}/download", withAuth(h.DownloadParticleMedia))
|
||||
mux.Handle("GET /particles/{id}/download", withAuth(h.GetObjectDownloadUrl))
|
||||
|
||||
// Link metadata
|
||||
mux.Handle("GET /metadata", withAuth(h.GetLinkMetadata))
|
||||
|
||||
@@ -73,6 +73,7 @@ type Human struct {
|
||||
Email string `json:"email"`
|
||||
EmailPrefix string `json:"email_prefix"`
|
||||
EmailNotificationsEnabled bool `json:"email_notifications_enabled"`
|
||||
AvatarObjectID *string `json:"avatar_object_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -334,6 +335,72 @@ func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
err := h.humanSvc.DeleteAvatar(r.Context(), humanId)
|
||||
if err != nil {
|
||||
flog.Error("failed to delete avatar from human", "error", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// best-effort, gracefully continue
|
||||
human, err := h.humanSvc.GetByID(r.Context(), humanId)
|
||||
if err != nil {
|
||||
flog.Error("failed to get human by id", "error", err, "humanId", humanId)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
if human.AvatarObjectID != nil {
|
||||
err = h.depotSvc.Delete(r.Context(), utils.OptionalString(human.AvatarObjectID))
|
||||
if err != nil {
|
||||
flog.Error("failed to delete object", "error", err, "objectID", human.AvatarObjectID)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// 5MB limit = 5 * 1024 * 1024 bytes
|
||||
const maxBodySize = 5 << 20
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
|
||||
|
||||
object, err := h.depotSvc.CreateFromReader(r.Context(), depot.CreateFromReaderInput{
|
||||
Prefix: "avatars",
|
||||
Name: fmt.Sprintf("%s-avatar", humanId),
|
||||
ContentType: r.Header.Get("Content-Type"),
|
||||
}, r.Body)
|
||||
if err != nil {
|
||||
flog.Error("failed to upload avatar with depo", "error", err, "humanId", humanId)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.humanSvc.UpdateAvatar(r.Context(), humanId, object.ID)
|
||||
if err != nil {
|
||||
flog.Error("failed to update human avatar", "error", err, "humanId", humanId)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Network Handlers
|
||||
// ============================================================================
|
||||
@@ -717,8 +784,8 @@ func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// DownloadParticleMedia returns a fresh signed URL for media/file particles.
|
||||
func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request) {
|
||||
// GetObjectDownloadUrl returns a fresh signed URL for media/file particles.
|
||||
func (h *Handler) GetObjectDownloadUrl(w http.ResponseWriter, r *http.Request) {
|
||||
_, ok := middleware.EmailFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
@@ -1007,6 +1074,7 @@ func humanToDTO(h *human.Human) Human {
|
||||
Email: h.Email,
|
||||
EmailPrefix: h.EmailPrefix,
|
||||
EmailNotificationsEnabled: h.EmailNotificationsEnabled,
|
||||
AvatarObjectID: h.AvatarObjectID,
|
||||
CreatedAt: h.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ type Human struct {
|
||||
Email string
|
||||
EmailPrefix string
|
||||
EmailNotificationsEnabled bool
|
||||
AvatarObjectID *string
|
||||
LastEmailNotificationSentAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ type repository interface {
|
||||
listAll(ctx context.Context) ([]*Human, error)
|
||||
updateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error
|
||||
updateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error
|
||||
updateAvatarObjectID(ctx context.Context, id string, objectID *string) error
|
||||
}
|
||||
|
||||
type repositoryImpl struct {
|
||||
@@ -50,9 +51,9 @@ func newRepository(pool *pgxpool.Pool) repository {
|
||||
func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*Human, error) {
|
||||
var h Human
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans WHERE email = $1`,
|
||||
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at, avatar_object_id FROM humans WHERE email = $1`,
|
||||
email,
|
||||
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt)
|
||||
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt, &h.AvatarObjectID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errNotFound
|
||||
@@ -66,9 +67,9 @@ func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*Human,
|
||||
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Human, error) {
|
||||
var h Human
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans WHERE id = $1`,
|
||||
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at, avatar_object_id FROM humans WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt)
|
||||
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt, &h.AvatarObjectID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errNotFound
|
||||
@@ -113,7 +114,7 @@ func (r *repositoryImpl) exists(ctx context.Context, email string) (bool, error)
|
||||
|
||||
func (r *repositoryImpl) listAll(ctx context.Context) ([]*Human, error) {
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans`,
|
||||
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at, avatar_object_id FROM humans`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -123,7 +124,7 @@ func (r *repositoryImpl) listAll(ctx context.Context) ([]*Human, error) {
|
||||
var humans []*Human
|
||||
for rows.Next() {
|
||||
var h Human
|
||||
if err := rows.Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt); err != nil {
|
||||
if err := rows.Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt, &h.AvatarObjectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.EmailPrefix = emailPrefix(h.Email)
|
||||
@@ -159,3 +160,17 @@ func (r *repositoryImpl) updateLastEmailNotificationSentAt(ctx context.Context,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) updateAvatarObjectID(ctx context.Context, id string, objectID *string) error {
|
||||
result, err := r.pool.Exec(ctx,
|
||||
`UPDATE humans SET avatar_object_id = $2 WHERE id = $1`,
|
||||
id, objectID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return errNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ import (
|
||||
|
||||
//go:generate go tool mockgen -source ./service.go -destination ./mocks/service.go
|
||||
|
||||
var ErrNotFound = errors.New("human not found")
|
||||
var (
|
||||
ErrNotFound = errors.New("human not found")
|
||||
ErrInvalidParam = errors.New("invalid param")
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
GetOrCreateByEmail(ctx context.Context, email string) (*Human, error)
|
||||
@@ -22,6 +25,8 @@ type Service interface {
|
||||
ListAll(ctx context.Context) ([]*Human, error)
|
||||
UpdateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error
|
||||
UpdateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error
|
||||
UpdateAvatar(ctx context.Context, id string, objectID string) error
|
||||
DeleteAvatar(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
type serviceImpl struct {
|
||||
@@ -88,3 +93,22 @@ func (s *serviceImpl) UpdateLastEmailNotificationSentAt(ctx context.Context, id
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *serviceImpl) UpdateAvatar(ctx context.Context, id string, objectID string) error {
|
||||
if objectID == "" {
|
||||
return ErrInvalidParam
|
||||
}
|
||||
err := s.repo.updateAvatarObjectID(ctx, id, utils.CreateOptionalString(objectID))
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *serviceImpl) DeleteAvatar(ctx context.Context, id string) error {
|
||||
err := s.repo.updateAvatarObjectID(ctx, id, nil)
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -56,4 +56,19 @@ func TestHumanService(t *testing.T) {
|
||||
assert.NotEqual(t, createdHuman.ID, anotherHuman.ID)
|
||||
assert.Equal(t, "another@example.com", anotherHuman.Email)
|
||||
assert.Equal(t, "another", anotherHuman.EmailPrefix)
|
||||
|
||||
// Test avatar handling
|
||||
objectID := "obj_xxx"
|
||||
err = svc.UpdateAvatar(ctx, anotherHuman.ID, objectID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
anotherHuman, err = svc.GetByID(ctx, anotherHuman.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, objectID, *anotherHuman.AvatarObjectID)
|
||||
|
||||
err = svc.DeleteAvatar(ctx, anotherHuman.ID)
|
||||
assert.NoError(t, err)
|
||||
anotherHuman, err = svc.GetByID(ctx, anotherHuman.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, anotherHuman.AvatarObjectID)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE humans
|
||||
DROP COLUMN IF EXISTS avatar_object_id,
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,6 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE humans
|
||||
ADD COLUMN IF NOT EXISTS avatar_object_id TEXT NULL;
|
||||
|
||||
COMMIT;
|
||||
Reference in New Issue
Block a user