150 lines
4.9 KiB
Go
150 lines
4.9 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/danyel/go-loose/internal/role"
|
|
)
|
|
|
|
// Membership is one tenant role held by a user.
|
|
type Membership struct {
|
|
TenantID string `json:"tenant_id"`
|
|
TenantSlug string `json:"tenant_slug"`
|
|
TenantName string `json:"tenant_name"`
|
|
Role string `json:"role"`
|
|
Permissions []string `json:"permissions"`
|
|
}
|
|
|
|
// Profile is a user's own account information. AvatarKey is the stored locator;
|
|
// the server expands it into an absolute AvatarURL.
|
|
type Profile struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
DisplayName string `json:"display_name"`
|
|
AvatarKey *string `json:"-"`
|
|
AvatarURL string `json:"avatar_url"`
|
|
Memberships []Membership `json:"memberships"`
|
|
IsSystemAdministrator bool `json:"is_system_administrator"`
|
|
NameCustomized bool `json:"name_customized"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
LastLoginAt time.Time `json:"last_login_at"`
|
|
ProfileUpdatedAt *time.Time `json:"profile_updated_at,omitempty"`
|
|
AvatarUpdatedAt *time.Time `json:"avatar_updated_at,omitempty"`
|
|
}
|
|
|
|
// ProfileUpdate carries a self-service profile change. A nil DisplayName leaves
|
|
// the display name untouched. Avatar changes are mutually exclusive: Remove
|
|
// clears the picture, otherwise Data and ContentType replace it.
|
|
type ProfileUpdate struct {
|
|
DisplayName *string
|
|
RemoveAvatar bool
|
|
AvatarKey string
|
|
AvatarData []byte
|
|
ContentType string
|
|
}
|
|
|
|
// Picture is a stored profile picture.
|
|
type Picture struct {
|
|
Data []byte
|
|
ContentType string
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// Profile loads a user together with every tenant role they hold.
|
|
func (s *Store) Profile(ctx context.Context, userID string) (Profile, error) {
|
|
var profile Profile
|
|
var avatarKey sql.NullString
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT u.id, u.email, u.display_name, u.avatar_key, u.name_customized,
|
|
u.created_at, u.last_login_at, u.profile_updated_at, u.avatar_updated_at,
|
|
EXISTS(SELECT 1 FROM system_administrators sa WHERE sa.user_id = u.id)
|
|
FROM users u
|
|
WHERE u.id = $1`, userID).
|
|
Scan(&profile.ID, &profile.Email, &profile.DisplayName, &avatarKey, &profile.NameCustomized,
|
|
&profile.CreatedAt, &profile.LastLoginAt, &profile.ProfileUpdatedAt,
|
|
&profile.AvatarUpdatedAt, &profile.IsSystemAdministrator)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Profile{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return Profile{}, err
|
|
}
|
|
if avatarKey.Valid {
|
|
profile.AvatarKey = &avatarKey.String
|
|
}
|
|
profile.Memberships = []Membership{}
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT m.tenant_id, t.slug, t.name, m.role
|
|
FROM memberships m
|
|
JOIN tenants t ON t.id = m.tenant_id
|
|
WHERE m.user_id = $1
|
|
ORDER BY t.name`, userID)
|
|
if err != nil {
|
|
return Profile{}, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var item Membership
|
|
if err := rows.Scan(&item.TenantID, &item.TenantSlug, &item.TenantName, &item.Role); err != nil {
|
|
return Profile{}, err
|
|
}
|
|
item.Permissions = role.Role(item.Role).Permissions()
|
|
profile.Memberships = append(profile.Memberships, item)
|
|
}
|
|
return profile, rows.Err()
|
|
}
|
|
|
|
// UpdateProfile applies a self-service profile change. The user is identified by
|
|
// their own id, so no membership check is required: every account may edit its
|
|
// own display name and picture regardless of role.
|
|
func (s *Store) UpdateProfile(ctx context.Context, userID string, update ProfileUpdate) error {
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
if update.DisplayName != nil {
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE users
|
|
SET display_name = $2, name_customized = true, profile_updated_at = now()
|
|
WHERE id = $1`, userID, *update.DisplayName); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
switch {
|
|
case update.RemoveAvatar:
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE users
|
|
SET avatar = NULL, avatar_key = NULL, avatar_content_type = NULL,
|
|
avatar_updated_at = NULL, profile_updated_at = now()
|
|
WHERE id = $1`, userID); err != nil {
|
|
return err
|
|
}
|
|
case len(update.AvatarData) > 0:
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE users
|
|
SET avatar = $2, avatar_key = $3, avatar_content_type = $4,
|
|
avatar_updated_at = now(), profile_updated_at = now()
|
|
WHERE id = $1`, userID, update.AvatarData, update.AvatarKey, update.ContentType); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// Picture loads a stored profile picture by its unguessable key.
|
|
func (s *Store) Picture(ctx context.Context, key string) (Picture, error) {
|
|
var picture Picture
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT avatar, avatar_content_type, avatar_updated_at
|
|
FROM users
|
|
WHERE avatar_key = $1 AND avatar IS NOT NULL`, key).
|
|
Scan(&picture.Data, &picture.ContentType, &picture.UpdatedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Picture{}, ErrNotFound
|
|
}
|
|
return picture, err
|
|
}
|