286 lines
6.4 KiB
Go
286 lines
6.4 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/auth"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
type UserResponse struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
AvatarURL *string `json:"avatar_url"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
func userToResponse(u db.User) UserResponse {
|
|
return UserResponse{
|
|
ID: uuidToString(u.ID),
|
|
Name: u.Name,
|
|
Email: u.Email,
|
|
AvatarURL: textToPtr(u.AvatarUrl),
|
|
CreatedAt: timestampToString(u.CreatedAt),
|
|
UpdatedAt: timestampToString(u.UpdatedAt),
|
|
}
|
|
}
|
|
|
|
type LoginRequest struct {
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type LoginResponse struct {
|
|
Token string `json:"token"`
|
|
User UserResponse `json:"user"`
|
|
}
|
|
|
|
func defaultWorkspaceName(user db.User) string {
|
|
name := strings.TrimSpace(user.Name)
|
|
if name == "" {
|
|
email := strings.TrimSpace(user.Email)
|
|
if at := strings.Index(email, "@"); at > 0 {
|
|
name = email[:at]
|
|
}
|
|
}
|
|
if name == "" {
|
|
name = "Personal"
|
|
}
|
|
return name + "'s Workspace"
|
|
}
|
|
|
|
func slugifyWorkspacePart(value string) string {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
var b strings.Builder
|
|
lastWasDash := false
|
|
|
|
for _, r := range value {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
|
b.WriteRune(r)
|
|
lastWasDash = false
|
|
case b.Len() > 0 && !lastWasDash:
|
|
b.WriteByte('-')
|
|
lastWasDash = true
|
|
}
|
|
}
|
|
|
|
return strings.Trim(b.String(), "-")
|
|
}
|
|
|
|
func defaultWorkspaceSlug(user db.User) string {
|
|
candidates := []string{
|
|
slugifyWorkspacePart(user.Name),
|
|
slugifyWorkspacePart(strings.Split(strings.TrimSpace(user.Email), "@")[0]),
|
|
"workspace",
|
|
}
|
|
|
|
base := "workspace"
|
|
for _, candidate := range candidates {
|
|
if candidate != "" {
|
|
base = candidate
|
|
break
|
|
}
|
|
}
|
|
|
|
userID := uuidToString(user.ID)
|
|
if len(userID) >= 8 {
|
|
return base + "-" + userID[:8]
|
|
}
|
|
return base
|
|
}
|
|
|
|
func (h *Handler) ensureUserWorkspace(ctx context.Context, user db.User) error {
|
|
workspaces, err := h.Queries.ListWorkspaces(ctx, user.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(workspaces) > 0 {
|
|
return nil
|
|
}
|
|
|
|
tx, err := h.TxStarter.Begin(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
qtx := h.Queries.WithTx(tx)
|
|
workspaces, err = qtx.ListWorkspaces(ctx, user.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(workspaces) > 0 {
|
|
return nil
|
|
}
|
|
|
|
workspace, err := qtx.CreateWorkspace(ctx, db.CreateWorkspaceParams{
|
|
Name: defaultWorkspaceName(user),
|
|
Slug: defaultWorkspaceSlug(user),
|
|
Description: pgtype.Text{},
|
|
})
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
workspaces, lookupErr := h.Queries.ListWorkspaces(ctx, user.ID)
|
|
if lookupErr == nil && len(workspaces) > 0 {
|
|
return nil
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
if _, err := qtx.CreateMember(ctx, db.CreateMemberParams{
|
|
WorkspaceID: workspace.ID,
|
|
UserID: user.ID,
|
|
Role: "owner",
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
|
var req LoginRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
|
|
if req.Email == "" {
|
|
writeError(w, http.StatusBadRequest, "email is required")
|
|
return
|
|
}
|
|
|
|
// Try to find existing user
|
|
user, err := h.Queries.GetUserByEmail(r.Context(), req.Email)
|
|
if err != nil {
|
|
if !isNotFound(err) {
|
|
writeError(w, http.StatusInternalServerError, "failed to load user")
|
|
return
|
|
}
|
|
|
|
// Create new user
|
|
name := req.Name
|
|
if name == "" {
|
|
name = req.Email
|
|
}
|
|
user, err = h.Queries.CreateUser(r.Context(), db.CreateUserParams{
|
|
Name: name,
|
|
Email: req.Email,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create user: "+err.Error())
|
|
return
|
|
}
|
|
} else if req.Name != "" && req.Name != user.Name {
|
|
user, err = h.Queries.UpdateUser(r.Context(), db.UpdateUserParams{
|
|
ID: user.ID,
|
|
Name: req.Name,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to update user")
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := h.ensureUserWorkspace(r.Context(), user); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to provision workspace")
|
|
return
|
|
}
|
|
|
|
// Generate JWT
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
|
"sub": uuidToString(user.ID),
|
|
"email": user.Email,
|
|
"name": user.Name,
|
|
"exp": time.Now().Add(72 * time.Hour).Unix(),
|
|
"iat": time.Now().Unix(),
|
|
})
|
|
|
|
tokenString, err := token.SignedString(auth.JWTSecret())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to generate token")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, LoginResponse{
|
|
Token: tokenString,
|
|
User: userToResponse(user),
|
|
})
|
|
}
|
|
|
|
func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
user, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "user not found")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, userToResponse(user))
|
|
}
|
|
|
|
type UpdateMeRequest struct {
|
|
Name *string `json:"name"`
|
|
AvatarURL *string `json:"avatar_url"`
|
|
}
|
|
|
|
func (h *Handler) UpdateMe(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req UpdateMeRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
currentUser, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "user not found")
|
|
return
|
|
}
|
|
|
|
name := currentUser.Name
|
|
if req.Name != nil {
|
|
name = strings.TrimSpace(*req.Name)
|
|
if name == "" {
|
|
writeError(w, http.StatusBadRequest, "name is required")
|
|
return
|
|
}
|
|
}
|
|
|
|
params := db.UpdateUserParams{
|
|
ID: currentUser.ID,
|
|
Name: name,
|
|
}
|
|
if req.AvatarURL != nil {
|
|
params.AvatarUrl = pgtype.Text{String: strings.TrimSpace(*req.AvatarURL), Valid: true}
|
|
}
|
|
|
|
updatedUser, err := h.Queries.UpdateUser(r.Context(), params)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to update user")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, userToResponse(updatedUser))
|
|
}
|