feat: implement user profile management with avatars, login, and personal data
- Add avatar upload functionality with public URL access. - Implement user login with email and password authentication. - Create endpoints for fetching and updating the authenticated user's profile. - Set up database migrations for user profiles, including email and role management. - Introduce personal data management linked to user profiles. - Add email verification process with welcome email templates.
This commit is contained in:
54
profiles/avatars.go
Normal file
54
profiles/avatars.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package profiles
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"encore.dev/beta/errs"
|
||||
"encore.dev/storage/objects"
|
||||
"encore.dev/types/uuid"
|
||||
)
|
||||
|
||||
// avatars stores user avatar images. It is public so the returned URLs can be
|
||||
// rendered directly by browsers without authentication.
|
||||
var avatars = objects.NewBucket("avatars", objects.BucketConfig{
|
||||
Public: true,
|
||||
})
|
||||
|
||||
type uploadAvatarResponse struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// UploadAvatar stores the request body as a new avatar image and returns its
|
||||
// public URL. The caller is expected to persist the URL via the profile
|
||||
// endpoints. Each upload gets a fresh key, so re-uploading never overwrites.
|
||||
//
|
||||
//encore:api auth raw method=POST path=/profiles/avatar
|
||||
func UploadAvatar(w http.ResponseWriter, req *http.Request) {
|
||||
key, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
errs.HTTPError(w, errs.WrapCode(err, errs.Internal, "failed to generate avatar key"))
|
||||
return
|
||||
}
|
||||
|
||||
contentType := req.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
writer := avatars.Upload(req.Context(), key.String(),
|
||||
objects.WithUploadAttrs(objects.UploadAttrs{ContentType: contentType}))
|
||||
if _, err := io.Copy(writer, req.Body); err != nil {
|
||||
writer.Abort(err)
|
||||
errs.HTTPError(w, errs.WrapCode(err, errs.Internal, "failed to upload avatar"))
|
||||
return
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
errs.HTTPError(w, errs.WrapCode(err, errs.Internal, "failed to store avatar"))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(uploadAvatarResponse{URL: avatars.PublicURL(key.String()).String()})
|
||||
}
|
||||
132
profiles/login.go
Normal file
132
profiles/login.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package profiles
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"encore.app/auth"
|
||||
"encore.dev/beta/errs"
|
||||
"encore.dev/pubsub"
|
||||
"encore.dev/storage/sqldb"
|
||||
"encore.dev/types/uuid"
|
||||
)
|
||||
|
||||
const loginTimeout = 10 * time.Second
|
||||
|
||||
type LoginParams struct {
|
||||
UserEmail string `json:"user_email"`
|
||||
Password string `json:"password" encore:"sensitive"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
Token uuid.UUID `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
var loginRequests = pubsub.TopicRef[pubsub.Publisher[*auth.LoginRequested]](auth.LoginRequests)
|
||||
|
||||
// Login is the public entry point for authenticating a user. It first
|
||||
// resolves the given email to a user ID via the local profiles database.
|
||||
// The actual credential check lives in the auth service; rather than calling
|
||||
// it directly, this hands the request off over Pub/Sub (publishing to
|
||||
// auth.LoginRequests) and waits for the matching auth.LoginCompleted reply
|
||||
// on auth.LoginResults before responding to the caller.
|
||||
//
|
||||
// NOTE: the wait is implemented with an in-process registry keyed by request
|
||||
// ID, so the instance that publishes the request must be the one that
|
||||
// receives the reply. That holds for local development (a single instance)
|
||||
// but not for a horizontally scaled deployment, which would need a shared
|
||||
// mechanism instead (e.g. a results table polled with retry_until).
|
||||
//
|
||||
//encore:api public method=POST path=/auth/login
|
||||
func Login(ctx context.Context, p *LoginParams) (*LoginResponse, error) {
|
||||
var userID uuid.UUID
|
||||
err := db.QueryRow(ctx, `
|
||||
SELECT user_id FROM user_profiles WHERE email = $1
|
||||
`, p.UserEmail).Scan(&userID)
|
||||
if errors.Is(err, sqldb.ErrNoRows) {
|
||||
return nil, &errs.Error{Code: errs.Unauthenticated, Message: "invalid credentials"}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to look up user")
|
||||
}
|
||||
|
||||
requestID, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to generate request id")
|
||||
}
|
||||
|
||||
wait := loginWaiters.register(requestID)
|
||||
defer loginWaiters.cancel(requestID)
|
||||
|
||||
if _, err := loginRequests.Publish(ctx, &auth.LoginRequested{
|
||||
RequestID: requestID,
|
||||
UserID: userID,
|
||||
Password: p.Password,
|
||||
}); err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to publish login request")
|
||||
}
|
||||
|
||||
select {
|
||||
case result := <-wait:
|
||||
if !result.OK {
|
||||
return nil, &errs.Error{Code: errs.Unauthenticated, Message: "invalid credentials"}
|
||||
}
|
||||
return &LoginResponse{Token: result.Token, ExpiresAt: result.ExpiresAt}, nil
|
||||
case <-time.After(loginTimeout):
|
||||
return nil, &errs.Error{Code: errs.DeadlineExceeded, Message: "login timed out"}
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
var _ = pubsub.NewSubscription(
|
||||
auth.LoginResults, "deliver-to-waiting-login",
|
||||
pubsub.SubscriptionConfig[*auth.LoginCompleted]{
|
||||
Handler: func(ctx context.Context, ev *auth.LoginCompleted) error {
|
||||
loginWaiters.deliver(ev)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// loginWaiters lets the synchronous Login handler above wait for the
|
||||
// asynchronous LoginCompleted reply that matches its RequestID.
|
||||
var loginWaiters = newWaiterRegistry()
|
||||
|
||||
type waiterRegistry struct {
|
||||
mu sync.Mutex
|
||||
waiting map[uuid.UUID]chan *auth.LoginCompleted
|
||||
}
|
||||
|
||||
func newWaiterRegistry() *waiterRegistry {
|
||||
return &waiterRegistry{waiting: make(map[uuid.UUID]chan *auth.LoginCompleted)}
|
||||
}
|
||||
|
||||
func (r *waiterRegistry) register(id uuid.UUID) <-chan *auth.LoginCompleted {
|
||||
ch := make(chan *auth.LoginCompleted, 1)
|
||||
r.mu.Lock()
|
||||
r.waiting[id] = ch
|
||||
r.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (r *waiterRegistry) cancel(id uuid.UUID) {
|
||||
r.mu.Lock()
|
||||
delete(r.waiting, id)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *waiterRegistry) deliver(ev *auth.LoginCompleted) {
|
||||
r.mu.Lock()
|
||||
ch, ok := r.waiting[ev.RequestID]
|
||||
if ok {
|
||||
delete(r.waiting, ev.RequestID)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if ok {
|
||||
ch <- ev
|
||||
}
|
||||
}
|
||||
13
profiles/me.go
Normal file
13
profiles/me.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package profiles
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// Me returns the profile of the currently authenticated caller, or nil if
|
||||
// the request is not authenticated.
|
||||
//
|
||||
//encore:api public method=GET path=/auth/me
|
||||
func Me(ctx context.Context) (*Profile, error) {
|
||||
return Get(ctx)
|
||||
}
|
||||
8
profiles/migrations/1_create_user_profiles.up.sql
Normal file
8
profiles/migrations/1_create_user_profiles.up.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE user_profiles (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
bio TEXT NOT NULL DEFAULT '',
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
10
profiles/migrations/2_user_id_uuid.up.sql
Normal file
10
profiles/migrations/2_user_id_uuid.up.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
DROP TABLE user_profiles;
|
||||
|
||||
CREATE TABLE user_profiles (
|
||||
user_id UUID PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
bio TEXT NOT NULL DEFAULT '',
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
10
profiles/migrations/3_add_auth.up.sql
Normal file
10
profiles/migrations/3_add_auth.up.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
ALTER TABLE user_profiles ADD COLUMN password_hash TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE TABLE sessions (
|
||||
token UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES user_profiles(user_id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX sessions_user_id_idx ON sessions (user_id);
|
||||
3
profiles/migrations/4_drop_auth_tables.up.sql
Normal file
3
profiles/migrations/4_drop_auth_tables.up.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
DROP TABLE sessions;
|
||||
|
||||
ALTER TABLE user_profiles DROP COLUMN password_hash;
|
||||
7
profiles/migrations/5_add_email.up.sql
Normal file
7
profiles/migrations/5_add_email.up.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE user_profiles ADD COLUMN email TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- Existing rows predate the email column; give each a unique placeholder
|
||||
-- derived from its user ID so the uniqueness constraint below can apply.
|
||||
UPDATE user_profiles SET email = user_id || '@example.invalid' WHERE email = '';
|
||||
|
||||
CREATE UNIQUE INDEX user_profiles_email_idx ON user_profiles (email);
|
||||
1
profiles/migrations/6_add_role.up.sql
Normal file
1
profiles/migrations/6_add_role.up.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE user_profiles ADD COLUMN role TEXT NOT NULL DEFAULT 'user';
|
||||
1
profiles/migrations/7_add_status.up.sql
Normal file
1
profiles/migrations/7_add_status.up.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE user_profiles ADD COLUMN status SMALLINT NOT NULL DEFAULT 0;
|
||||
2
profiles/migrations/8_artist_profile.up.sql
Normal file
2
profiles/migrations/8_artist_profile.up.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE user_profiles DROP COLUMN bio;
|
||||
ALTER TABLE user_profiles ADD COLUMN is_artist BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
10
profiles/migrations/9_personal_data.up.sql
Normal file
10
profiles/migrations/9_personal_data.up.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE personal_data (
|
||||
user_id UUID PRIMARY KEY REFERENCES user_profiles (user_id) ON DELETE CASCADE,
|
||||
first_name TEXT NOT NULL DEFAULT '',
|
||||
last_name TEXT NOT NULL DEFAULT '',
|
||||
address TEXT NOT NULL DEFAULT '',
|
||||
city TEXT NOT NULL DEFAULT '',
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
112
profiles/personal_data.go
Normal file
112
profiles/personal_data.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package profiles
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"encore.dev/beta/auth"
|
||||
"encore.dev/beta/errs"
|
||||
"encore.dev/storage/sqldb"
|
||||
"encore.dev/types/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// PersonalData holds the personal details linked one-to-one to a user profile.
|
||||
type PersonalData struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Address string `json:"address"`
|
||||
City string `json:"city"`
|
||||
Country string `json:"country"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// PersonalDataParams are the editable fields of a user's personal data.
|
||||
type PersonalDataParams struct {
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Address string `json:"address"`
|
||||
City string `json:"city"`
|
||||
Country string `json:"country"`
|
||||
}
|
||||
|
||||
// GetPersonalData returns the authenticated user's own personal data.
|
||||
//
|
||||
//encore:api auth method=GET path=/profiles/personal-data
|
||||
func GetPersonalData(ctx context.Context) (*PersonalData, error) {
|
||||
userID, err := authedUserID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pd := PersonalData{UserID: userID}
|
||||
err = db.QueryRow(ctx, `
|
||||
SELECT first_name, last_name, address, city, country, created_at, updated_at
|
||||
FROM personal_data WHERE user_id = $1
|
||||
`, userID).Scan(&pd.FirstName, &pd.LastName, &pd.Address, &pd.City, &pd.Country, &pd.CreatedAt, &pd.UpdatedAt)
|
||||
if errors.Is(err, sqldb.ErrNoRows) {
|
||||
return nil, &errs.Error{Code: errs.NotFound, Message: "personal data not found"}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to fetch personal data")
|
||||
}
|
||||
return &pd, nil
|
||||
}
|
||||
|
||||
// UpsertPersonalData creates or replaces the authenticated user's own personal data.
|
||||
//
|
||||
//encore:api auth method=PUT path=/profiles/personal-data
|
||||
func UpsertPersonalData(ctx context.Context, p *PersonalDataParams) (*PersonalData, error) {
|
||||
userID, err := authedUserID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pd := PersonalData{
|
||||
UserID: userID,
|
||||
FirstName: p.FirstName,
|
||||
LastName: p.LastName,
|
||||
Address: p.Address,
|
||||
City: p.City,
|
||||
Country: p.Country,
|
||||
}
|
||||
err = db.QueryRow(ctx, `
|
||||
INSERT INTO personal_data (user_id, first_name, last_name, address, city, country)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET first_name = EXCLUDED.first_name,
|
||||
last_name = EXCLUDED.last_name,
|
||||
address = EXCLUDED.address,
|
||||
city = EXCLUDED.city,
|
||||
country = EXCLUDED.country,
|
||||
updated_at = NOW()
|
||||
RETURNING created_at, updated_at
|
||||
`, userID, p.FirstName, p.LastName, p.Address, p.City, p.Country).Scan(&pd.CreatedAt, &pd.UpdatedAt)
|
||||
if isForeignKeyViolation(err) {
|
||||
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to save personal data")
|
||||
}
|
||||
return &pd, nil
|
||||
}
|
||||
|
||||
// authedUserID returns the UUID of the authenticated caller.
|
||||
func authedUserID() (uuid.UUID, error) {
|
||||
uid, ok := auth.UserID()
|
||||
if !ok {
|
||||
return uuid.Nil, &errs.Error{Code: errs.Unauthenticated, Message: "authentication required"}
|
||||
}
|
||||
userID, err := uuid.FromString(string(uid))
|
||||
if err != nil {
|
||||
return uuid.Nil, errs.WrapCode(err, errs.Internal, "invalid user id")
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// isForeignKeyViolation reports whether err is a Postgres foreign key constraint violation.
|
||||
func isForeignKeyViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23503"
|
||||
}
|
||||
168
profiles/profiles.go
Normal file
168
profiles/profiles.go
Normal file
@@ -0,0 +1,168 @@
|
||||
// Service profiles stores and serves user profile information.
|
||||
package profiles
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
authsvc "encore.app/auth"
|
||||
"encore.dev/beta/auth"
|
||||
"encore.dev/beta/errs"
|
||||
"encore.dev/storage/sqldb"
|
||||
"encore.dev/types/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
var db = sqldb.NewDatabase("profiles", sqldb.DatabaseConfig{
|
||||
Migrations: "./migrations",
|
||||
})
|
||||
|
||||
type Profile struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
IsArtist bool `json:"is_artist"`
|
||||
Role string `json:"role"`
|
||||
Status Status `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ProfileParams struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
type RegisterParams struct {
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
Password string `json:"password" encore:"sensitive"`
|
||||
}
|
||||
|
||||
// Insert registers a new profile with a server-generated UUID v4, delegating
|
||||
// credential setup to the auth service.
|
||||
//
|
||||
//encore:api public method=POST path=/profiles
|
||||
func Insert(ctx context.Context, p *RegisterParams) (*Profile, error) {
|
||||
userID, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to generate profile id")
|
||||
}
|
||||
profile := Profile{UserID: userID, Email: p.Email, DisplayName: p.DisplayName, AvatarURL: p.AvatarURL}
|
||||
err = db.QueryRow(ctx, `
|
||||
INSERT INTO user_profiles (user_id, email, display_name, avatar_url)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING role, status, is_artist, created_at, updated_at
|
||||
`, userID, p.Email, p.DisplayName, p.AvatarURL).Scan(&profile.Role, &profile.Status, &profile.IsArtist, &profile.CreatedAt, &profile.UpdatedAt)
|
||||
if isUniqueViolation(err) {
|
||||
return nil, &errs.Error{Code: errs.AlreadyExists, Message: "email already registered"}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to create profile")
|
||||
}
|
||||
if err := authsvc.Register(ctx, &authsvc.RegisterParams{UserID: userID, Password: p.Password}); err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to register credentials")
|
||||
}
|
||||
return &profile, nil
|
||||
}
|
||||
|
||||
// Update replaces the data of the authenticated user's own profile.
|
||||
//
|
||||
//encore:api auth method=PUT path=/profiles
|
||||
func Update(ctx context.Context, p *ProfileParams) (*Profile, error) {
|
||||
uid, ok := auth.UserID()
|
||||
if !ok {
|
||||
return nil, &errs.Error{Code: errs.PermissionDenied, Message: "cannot act on another user's profile"}
|
||||
}
|
||||
userID, err := uuid.FromString(string(uid))
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "invalid user id")
|
||||
}
|
||||
if err := requireSelf(userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profile := Profile{UserID: userID, DisplayName: p.DisplayName, AvatarURL: p.AvatarURL}
|
||||
err = db.QueryRow(ctx, `
|
||||
UPDATE user_profiles
|
||||
SET display_name = $2, avatar_url = $3, updated_at = NOW()
|
||||
WHERE user_id = $1
|
||||
RETURNING email, role, status, is_artist, created_at, updated_at
|
||||
`, userID, p.DisplayName, p.AvatarURL).Scan(&profile.Email, &profile.Role, &profile.Status, &profile.IsArtist, &profile.CreatedAt, &profile.UpdatedAt)
|
||||
if errors.Is(err, sqldb.ErrNoRows) {
|
||||
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to update profile")
|
||||
}
|
||||
return &profile, nil
|
||||
}
|
||||
|
||||
// Get returns the profile for the given user. For the caller's own profile
|
||||
// (including when not authenticated), see MyProfile.
|
||||
//
|
||||
//encore:api public method=GET path=/profiles/profile
|
||||
func Get(ctx context.Context) (*Profile, error) {
|
||||
uid, ok := auth.UserID()
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
userID, err := uuid.FromString(string(uid))
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "invalid user id")
|
||||
}
|
||||
p := Profile{UserID: userID}
|
||||
err = db.QueryRow(ctx, `
|
||||
SELECT email, display_name, avatar_url, role, status, is_artist, created_at, updated_at FROM user_profiles WHERE user_id = $1
|
||||
`, userID).Scan(&p.Email, &p.DisplayName, &p.AvatarURL, &p.Role, &p.Status, &p.IsArtist, &p.CreatedAt, &p.UpdatedAt)
|
||||
if errors.Is(err, sqldb.ErrNoRows) {
|
||||
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to fetch profile")
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// Delete removes the authenticated user's own profile.
|
||||
//
|
||||
//encore:api auth method=DELETE path=/profiles
|
||||
func Delete(ctx context.Context) error {
|
||||
uid, ok := auth.UserID()
|
||||
if !ok {
|
||||
return &errs.Error{Code: errs.PermissionDenied, Message: "cannot act on another user's profile"}
|
||||
}
|
||||
userID, err := uuid.FromString(string(uid))
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "invalid user id")
|
||||
}
|
||||
if err := requireSelf(userID); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := db.Exec(ctx, `
|
||||
DELETE FROM user_profiles WHERE user_id = $1
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to delete profile")
|
||||
}
|
||||
if res.RowsAffected() == 0 {
|
||||
return &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireSelf returns an error unless the authenticated caller is the user identified by userID.
|
||||
func requireSelf(userID uuid.UUID) error {
|
||||
if uid, ok := auth.UserID(); !ok || uid != auth.UID(userID.String()) {
|
||||
return &errs.Error{Code: errs.PermissionDenied, Message: "cannot act on another user's profile"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isUniqueViolation reports whether err is a Postgres unique constraint violation.
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||||
}
|
||||
38
profiles/status.go
Normal file
38
profiles/status.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package profiles
|
||||
|
||||
type Status int
|
||||
|
||||
const (
|
||||
StatusActive Status = iota
|
||||
StatusInactive
|
||||
StatusSuspended
|
||||
StatusDeleted
|
||||
StatusPending
|
||||
StatusWaitingDeletion
|
||||
StatusBanned
|
||||
)
|
||||
|
||||
// StatusOption describes a valid profile status for display in admin UIs.
|
||||
type StatusOption struct {
|
||||
Name string `json:"name"`
|
||||
Value Status `json:"value"`
|
||||
Updatable bool `json:"updatable"`
|
||||
}
|
||||
|
||||
// Statuses returns every valid profile status with a display name and its underlying value.
|
||||
func Statuses() []StatusOption {
|
||||
return []StatusOption{
|
||||
{Name: "Active", Value: StatusActive, Updatable: true},
|
||||
{Name: "Inactive", Value: StatusInactive, Updatable: true},
|
||||
{Name: "Suspended", Value: StatusSuspended, Updatable: true},
|
||||
{Name: "Deleted", Value: StatusDeleted, Updatable: false},
|
||||
{Name: "Pending", Value: StatusPending, Updatable: true},
|
||||
{Name: "Waiting deletion", Value: StatusWaitingDeletion, Updatable: false},
|
||||
{Name: "Banned", Value: StatusBanned, Updatable: true},
|
||||
}
|
||||
}
|
||||
|
||||
// IsValid reports whether s is one of the defined Status values.
|
||||
func (s Status) IsValid() bool {
|
||||
return s >= StatusActive && s <= StatusBanned
|
||||
}
|
||||
Reference in New Issue
Block a user