From 78c5151f3bf1fb4e95438d92f3cb7c5b9dadec9f Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Thu, 11 Jun 2026 13:47:34 -0700 Subject: [PATCH] implement avatar backend functionality --- go/cmd/orion/main.go | 5 +- go/internal/handler/handler.go | 72 +++++++++++++++++++++- go/internal/human/models.go | 1 + go/internal/human/repository.go | 27 ++++++-- go/internal/human/service.go | 26 +++++++- go/internal/human/service_test.go | 15 +++++ go/migrations/000017_human_avatar.down.sql | 6 ++ go/migrations/000017_human_avatar.up.sql | 6 ++ 8 files changed, 148 insertions(+), 10 deletions(-) create mode 100644 go/migrations/000017_human_avatar.down.sql create mode 100644 go/migrations/000017_human_avatar.up.sql diff --git a/go/cmd/orion/main.go b/go/cmd/orion/main.go index 09ec9fd..324980c 100644 --- a/go/cmd/orion/main.go +++ b/go/cmd/orion/main.go @@ -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)) diff --git a/go/internal/handler/handler.go b/go/internal/handler/handler.go index 0ec211a..cdf23b9 100644 --- a/go/internal/handler/handler.go +++ b/go/internal/handler/handler.go @@ -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, } } diff --git a/go/internal/human/models.go b/go/internal/human/models.go index a17f592..c264996 100644 --- a/go/internal/human/models.go +++ b/go/internal/human/models.go @@ -7,6 +7,7 @@ type Human struct { Email string EmailPrefix string EmailNotificationsEnabled bool + AvatarObjectID *string LastEmailNotificationSentAt *time.Time CreatedAt time.Time } diff --git a/go/internal/human/repository.go b/go/internal/human/repository.go index 8045cfc..8b3b7b1 100644 --- a/go/internal/human/repository.go +++ b/go/internal/human/repository.go @@ -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 +} diff --git a/go/internal/human/service.go b/go/internal/human/service.go index 1a28421..eb094b7 100644 --- a/go/internal/human/service.go +++ b/go/internal/human/service.go @@ -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 +} diff --git a/go/internal/human/service_test.go b/go/internal/human/service_test.go index 89154a6..d0a3402 100644 --- a/go/internal/human/service_test.go +++ b/go/internal/human/service_test.go @@ -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) } diff --git a/go/migrations/000017_human_avatar.down.sql b/go/migrations/000017_human_avatar.down.sql new file mode 100644 index 0000000..3eaffc5 --- /dev/null +++ b/go/migrations/000017_human_avatar.down.sql @@ -0,0 +1,6 @@ +BEGIN; + +ALTER TABLE humans + DROP COLUMN IF EXISTS avatar_object_id, + +COMMIT; diff --git a/go/migrations/000017_human_avatar.up.sql b/go/migrations/000017_human_avatar.up.sql new file mode 100644 index 0000000..5144d9b --- /dev/null +++ b/go/migrations/000017_human_avatar.up.sql @@ -0,0 +1,6 @@ +BEGIN; + +ALTER TABLE humans + ADD COLUMN IF NOT EXISTS avatar_object_id TEXT NULL; + +COMMIT;