mirror of
https://github.com/Sosokker/go-chi-oapi-codegen-todolist.git
synced 2025-12-19 05:54:07 +01:00
930 lines
27 KiB
Go
930 lines
27 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Sosokker/todolist-backend/internal/api/models" // Generated models
|
|
"github.com/Sosokker/todolist-backend/internal/auth"
|
|
"github.com/Sosokker/todolist-backend/internal/config"
|
|
"github.com/Sosokker/todolist-backend/internal/domain"
|
|
"github.com/Sosokker/todolist-backend/internal/service"
|
|
"github.com/google/uuid"
|
|
openapi_types "github.com/oapi-codegen/runtime/types"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
// Compile-time check to ensure ApiHandler implements the interface
|
|
var _ ServerInterface = (*ApiHandler)(nil)
|
|
|
|
// ApiHandler holds dependencies for API handlers
|
|
type ApiHandler struct {
|
|
services *service.ServiceRegistry
|
|
cfg *config.Config
|
|
logger *slog.Logger
|
|
// Add other dependencies like cache if needed directly by handlers
|
|
}
|
|
|
|
// NewApiHandler creates a new handler instance
|
|
func NewApiHandler(services *service.ServiceRegistry, cfg *config.Config, logger *slog.Logger) *ApiHandler {
|
|
return &ApiHandler{
|
|
services: services,
|
|
cfg: cfg,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// --- Helper Functions ---
|
|
|
|
// SendJSONResponse sends a JSON response with a status code
|
|
func SendJSONResponse(w http.ResponseWriter, statusCode int, payload interface{}, logger *slog.Logger) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(statusCode)
|
|
if payload != nil {
|
|
err := json.NewEncoder(w).Encode(payload)
|
|
if err != nil { // Log the error but response header is already sent
|
|
logger.Error("Failed to encode JSON response", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// SendJSONError sends a standardized JSON error response
|
|
func SendJSONError(w http.ResponseWriter, err error, defaultStatusCode int, logger *slog.Logger) {
|
|
logger.Warn("API Error", "error", err)
|
|
|
|
respErr := models.Error{
|
|
Message: err.Error(),
|
|
}
|
|
statusCode := defaultStatusCode
|
|
|
|
// Map domain errors to HTTP status codes
|
|
switch {
|
|
case errors.Is(err, domain.ErrNotFound):
|
|
statusCode = http.StatusNotFound
|
|
case errors.Is(err, domain.ErrForbidden):
|
|
statusCode = http.StatusForbidden
|
|
case errors.Is(err, domain.ErrUnauthorized):
|
|
statusCode = http.StatusUnauthorized
|
|
case errors.Is(err, domain.ErrConflict):
|
|
statusCode = http.StatusConflict
|
|
case errors.Is(err, domain.ErrValidation), errors.Is(err, domain.ErrBadRequest):
|
|
statusCode = http.StatusBadRequest
|
|
case errors.Is(err, domain.ErrInternalServer):
|
|
statusCode = http.StatusInternalServerError
|
|
respErr.Message = "An internal error occurred."
|
|
default:
|
|
if statusCode < 500 {
|
|
logger.Error("Unhandled error type in API mapping", "error", err, "defaultStatus", defaultStatusCode)
|
|
statusCode = http.StatusInternalServerError
|
|
respErr.Message = "An unexpected error occurred."
|
|
}
|
|
}
|
|
|
|
respErr.Code = int32(statusCode)
|
|
SendJSONResponse(w, statusCode, respErr, logger)
|
|
}
|
|
|
|
// parseAndValidateBody decodes JSON body and logs/sends error on failure.
|
|
func parseAndValidateBody(w http.ResponseWriter, r *http.Request, body interface{}, logger *slog.Logger) bool {
|
|
if err := json.NewDecoder(r.Body).Decode(body); err != nil {
|
|
SendJSONError(w, fmt.Errorf("invalid request body: %w", domain.ErrBadRequest), http.StatusBadRequest, logger)
|
|
return false
|
|
}
|
|
// TODO: Add struct validation here if needed (e.g., using go-Code Playground/validator)
|
|
return true
|
|
}
|
|
|
|
// --- Mappers (Domain <-> Generated API Models) ---
|
|
|
|
func mapDomainUserToApi(user *domain.User) *models.User {
|
|
if user == nil {
|
|
return nil
|
|
}
|
|
userID := openapi_types.UUID(user.ID)
|
|
email := openapi_types.Email(user.Email)
|
|
emailVerified := user.EmailVerified
|
|
createdAt := user.CreatedAt
|
|
updatedAt := user.UpdatedAt
|
|
|
|
return &models.User{
|
|
Id: &userID,
|
|
Username: user.Username,
|
|
Email: email,
|
|
EmailVerified: &emailVerified,
|
|
CreatedAt: &createdAt,
|
|
UpdatedAt: &updatedAt}
|
|
}
|
|
|
|
func mapDomainTagToApi(tag *domain.Tag) *models.Tag {
|
|
if tag == nil {
|
|
return nil
|
|
}
|
|
tagID := openapi_types.UUID(tag.ID)
|
|
userID := openapi_types.UUID(tag.UserID)
|
|
createdAt := tag.CreatedAt
|
|
updatedAt := tag.UpdatedAt
|
|
return &models.Tag{
|
|
Id: &tagID,
|
|
UserId: &userID,
|
|
Name: tag.Name,
|
|
Color: tag.Color,
|
|
Icon: tag.Icon,
|
|
CreatedAt: &createdAt,
|
|
UpdatedAt: &updatedAt}
|
|
}
|
|
|
|
func mapDomainTodoToApi(todo *domain.Todo, attachmentInfos []models.AttachmentInfo) *models.Todo { // Takes AttachmentInfo now
|
|
if todo == nil {
|
|
return nil
|
|
}
|
|
apiSubtasks := make([]models.Subtask, len(todo.Subtasks))
|
|
for i, st := range todo.Subtasks {
|
|
mappedSubtask := mapDomainSubtaskToApi(&st)
|
|
if mappedSubtask != nil {
|
|
apiSubtasks[i] = *mappedSubtask
|
|
}
|
|
}
|
|
|
|
tagIDs := make([]openapi_types.UUID, len(todo.TagIDs))
|
|
for i, domainID := range todo.TagIDs {
|
|
tagIDs[i] = openapi_types.UUID(domainID)
|
|
}
|
|
|
|
todoID := openapi_types.UUID(todo.ID)
|
|
userID := openapi_types.UUID(todo.UserID)
|
|
createdAt := todo.CreatedAt
|
|
updatedAt := todo.UpdatedAt
|
|
|
|
return &models.Todo{
|
|
Id: &todoID,
|
|
UserId: &userID,
|
|
Title: todo.Title,
|
|
Description: todo.Description,
|
|
Status: models.TodoStatus(todo.Status),
|
|
Deadline: todo.Deadline,
|
|
TagIds: tagIDs,
|
|
AttachmentUrl: todo.AttachmentUrl,
|
|
Subtasks: &apiSubtasks,
|
|
CreatedAt: &createdAt,
|
|
UpdatedAt: &updatedAt,
|
|
}
|
|
}
|
|
|
|
func mapDomainSubtaskToApi(subtask *domain.Subtask) *models.Subtask {
|
|
if subtask == nil {
|
|
return nil
|
|
}
|
|
subtaskID := openapi_types.UUID(subtask.ID)
|
|
todoID := openapi_types.UUID(subtask.TodoID)
|
|
createdAt := subtask.CreatedAt
|
|
updatedAt := subtask.UpdatedAt
|
|
|
|
return &models.Subtask{
|
|
Id: &subtaskID,
|
|
TodoId: &todoID,
|
|
Description: subtask.Description,
|
|
Completed: subtask.Completed,
|
|
CreatedAt: &createdAt,
|
|
UpdatedAt: &updatedAt}
|
|
}
|
|
|
|
func mapDomainAttachmentInfoToApi(info *domain.AttachmentInfo) *models.AttachmentInfo {
|
|
if info == nil {
|
|
return nil
|
|
}
|
|
return &models.AttachmentInfo{
|
|
FileId: info.FileID,
|
|
FileName: info.FileName,
|
|
FileUrl: info.FileURL,
|
|
ContentType: info.ContentType,
|
|
Size: info.Size,
|
|
}
|
|
}
|
|
|
|
// --- Auth Handlers ---
|
|
|
|
func (h *ApiHandler) SignupUserApi(w http.ResponseWriter, r *http.Request) {
|
|
var body models.SignupRequest
|
|
if !parseAndValidateBody(w, r, &body, h.logger) {
|
|
return
|
|
}
|
|
|
|
creds := service.SignupCredentials{
|
|
Username: body.Username,
|
|
Email: string(body.Email),
|
|
Password: *body.Password,
|
|
}
|
|
|
|
user, err := h.services.Auth.Signup(r.Context(), creds)
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
|
|
SendJSONResponse(w, http.StatusCreated, mapDomainUserToApi(user), h.logger)
|
|
}
|
|
|
|
func (h *ApiHandler) LoginUserApi(w http.ResponseWriter, r *http.Request) {
|
|
var body models.LoginRequest
|
|
if !parseAndValidateBody(w, r, &body, h.logger) {
|
|
return
|
|
}
|
|
|
|
creds := service.LoginCredentials{
|
|
Email: string(body.Email),
|
|
Password: *body.Password,
|
|
}
|
|
|
|
token, _, err := h.services.Auth.Login(r.Context(), creds)
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusUnauthorized, h.logger)
|
|
return
|
|
}
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: h.cfg.JWT.CookieName,
|
|
Value: token,
|
|
Path: h.cfg.JWT.CookiePath,
|
|
Domain: h.cfg.JWT.CookieDomain,
|
|
Expires: time.Now().Add(time.Duration(h.cfg.JWT.ExpiryMinutes) * time.Minute),
|
|
HttpOnly: h.cfg.JWT.CookieHttpOnly,
|
|
Secure: h.cfg.JWT.CookieSecure,
|
|
SameSite: parseSameSite(h.cfg.JWT.CookieSameSite),
|
|
})
|
|
|
|
resp := models.LoginResponse{
|
|
AccessToken: token,
|
|
TokenType: "Bearer",
|
|
}
|
|
SendJSONResponse(w, http.StatusOK, resp, h.logger)
|
|
}
|
|
|
|
func (h *ApiHandler) LogoutUser(w http.ResponseWriter, r *http.Request) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: h.cfg.JWT.CookieName,
|
|
Value: "",
|
|
Path: h.cfg.JWT.CookiePath,
|
|
Domain: h.cfg.JWT.CookieDomain,
|
|
Expires: time.Unix(0, 0),
|
|
MaxAge: -1,
|
|
HttpOnly: h.cfg.JWT.CookieHttpOnly,
|
|
Secure: h.cfg.JWT.CookieSecure,
|
|
SameSite: parseSameSite(h.cfg.JWT.CookieSameSite),
|
|
})
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// Helper to parse SameSite string to http.SameSite type
|
|
func parseSameSite(s string) http.SameSite {
|
|
switch strings.ToLower(s) {
|
|
case "lax":
|
|
return http.SameSiteLaxMode
|
|
case "strict":
|
|
return http.SameSiteStrictMode
|
|
case "none":
|
|
return http.SameSiteNoneMode
|
|
default:
|
|
return http.SameSiteDefaultMode
|
|
}
|
|
}
|
|
|
|
// --- Google OAuth Handlers ---
|
|
|
|
func (h *ApiHandler) InitiateGoogleLogin(w http.ResponseWriter, r *http.Request) {
|
|
oauthCfg := h.services.Auth.GetGoogleAuthConfig()
|
|
state := uuid.NewString()
|
|
|
|
signedState := auth.SignState(state, []byte(h.cfg.OAuth.Google.StateSecret))
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: auth.StateCookieName,
|
|
Value: signedState,
|
|
Path: "/",
|
|
Expires: time.Now().Add(auth.StateExpiry + 1*time.Minute),
|
|
HttpOnly: true,
|
|
Secure: h.cfg.JWT.CookieSecure,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
|
|
redirectURL := oauthCfg.AuthCodeURL(state, oauth2.AccessTypeOffline)
|
|
h.logger.Debug("Redirecting to Google OAuth", "url", redirectURL)
|
|
http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)
|
|
}
|
|
|
|
func (h *ApiHandler) HandleGoogleCallback(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
receivedCode := r.URL.Query().Get("code")
|
|
receivedState := r.URL.Query().Get("state")
|
|
|
|
stateCookie, err := r.Cookie(auth.StateCookieName)
|
|
if err != nil {
|
|
h.logger.WarnContext(ctx, "OAuth state cookie missing or error", "error", err)
|
|
http.Redirect(w, r, "/login?error=state_missing", http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: auth.StateCookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
Expires: time.Unix(0, 0),
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
Secure: h.cfg.JWT.CookieSecure,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
|
|
originalState, err := auth.VerifyAndExtractState(stateCookie.Value, []byte(h.cfg.OAuth.Google.StateSecret))
|
|
if err != nil {
|
|
h.logger.WarnContext(ctx, "OAuth state verification failed", "error", err, "receivedState", receivedState)
|
|
errorParam := "state_invalid"
|
|
if errors.Is(err, auth.ErrStateExpired) {
|
|
errorParam = "state_expired"
|
|
}
|
|
http.Redirect(w, r, "/login?error="+errorParam, http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
|
|
if receivedState == "" || receivedState != originalState {
|
|
h.logger.WarnContext(ctx, "OAuth state mismatch", "received", receivedState, "expected", originalState)
|
|
http.Redirect(w, r, "/login?error=state_mismatch", http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
|
|
if receivedCode == "" {
|
|
errorDesc := r.URL.Query().Get("error_description")
|
|
h.logger.WarnContext(ctx, "Missing OAuth code parameter in callback", "error_desc", errorDesc)
|
|
errorParam := url.QueryEscape(r.URL.Query().Get("error"))
|
|
if errorParam == "" {
|
|
errorParam = "missing_code"
|
|
}
|
|
http.Redirect(w, r, "/login?error="+errorParam, http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
|
|
token, user, err := h.services.Auth.HandleGoogleCallback(ctx, receivedCode)
|
|
if err != nil {
|
|
h.logger.ErrorContext(ctx, "Google callback handling failed in service", "error", err)
|
|
errorParam := "auth_failed"
|
|
if errors.Is(err, domain.ErrConflict) {
|
|
errorParam = "auth_conflict"
|
|
}
|
|
http.Redirect(w, r, "/login?error="+errorParam, http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: h.cfg.JWT.CookieName,
|
|
Value: token,
|
|
Path: h.cfg.JWT.CookiePath,
|
|
Domain: h.cfg.JWT.CookieDomain,
|
|
Expires: time.Now().Add(time.Duration(h.cfg.JWT.ExpiryMinutes) * time.Minute),
|
|
HttpOnly: h.cfg.JWT.CookieHttpOnly,
|
|
Secure: h.cfg.JWT.CookieSecure,
|
|
SameSite: parseSameSite(h.cfg.JWT.CookieSameSite),
|
|
})
|
|
|
|
redirectURL := fmt.Sprintf("%s/oauth/callback#access_token=%s", h.cfg.Frontend.Url, url.QueryEscape(token))
|
|
h.logger.InfoContext(ctx, "Google OAuth login successful", "userId", user.ID, "email", user.Email, "redirectingTo", redirectURL)
|
|
http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)
|
|
}
|
|
|
|
// --- User Handlers ---
|
|
|
|
func (h *ApiHandler) GetCurrentUser(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
logger := h.logger.With(slog.String("handler", "GetCurrentUser"))
|
|
userID, err := GetUserIDFromContext(ctx)
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, logger)
|
|
return
|
|
}
|
|
logger = logger.With(slog.String("userId", userID.String()))
|
|
|
|
user, err := h.services.User.GetUserByID(ctx, userID)
|
|
if err != nil {
|
|
logger.ErrorContext(ctx, "Failed to fetch user from service", "error", err)
|
|
SendJSONError(w, err, http.StatusInternalServerError, logger)
|
|
return
|
|
}
|
|
|
|
apiUser := mapDomainUserToApi(user)
|
|
if apiUser == nil {
|
|
logger.ErrorContext(ctx, "Failed to map domain user to API model")
|
|
SendJSONError(w, domain.ErrInternalServer, http.StatusInternalServerError, logger)
|
|
return
|
|
}
|
|
|
|
logger.DebugContext(ctx, "Successfully retrieved current user")
|
|
SendJSONResponse(w, http.StatusOK, apiUser, logger)
|
|
}
|
|
|
|
func (h *ApiHandler) UpdateCurrentUser(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
logger := h.logger.With(slog.String("handler", "UpdateCurrentUser"))
|
|
|
|
userID, err := GetUserIDFromContext(ctx)
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, logger)
|
|
return
|
|
}
|
|
logger = logger.With(slog.String("userId", userID.String()))
|
|
|
|
var body models.UpdateUserRequest
|
|
if !parseAndValidateBody(w, r, &body, logger) {
|
|
return
|
|
}
|
|
|
|
updateInput := service.UpdateUserInput{
|
|
Username: body.Username,
|
|
}
|
|
|
|
updatedUser, err := h.services.User.UpdateUser(ctx, userID, updateInput)
|
|
if err != nil {
|
|
logger.ErrorContext(ctx, "Failed to update user in service", "error", err)
|
|
SendJSONError(w, err, http.StatusInternalServerError, logger)
|
|
return
|
|
}
|
|
|
|
apiUser := mapDomainUserToApi(updatedUser)
|
|
if apiUser == nil {
|
|
logger.ErrorContext(ctx, "Failed to map updated domain user to API model")
|
|
SendJSONError(w, domain.ErrInternalServer, http.StatusInternalServerError, logger)
|
|
return
|
|
}
|
|
|
|
logger.InfoContext(ctx, "Successfully updated current user")
|
|
SendJSONResponse(w, http.StatusOK, apiUser, logger)
|
|
}
|
|
|
|
// --- Tag Handlers ---
|
|
|
|
func (h *ApiHandler) CreateTag(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := GetUserIDFromContext(r.Context())
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
|
|
var body models.CreateTagRequest
|
|
if !parseAndValidateBody(w, r, &body, h.logger) {
|
|
// TODO: Add specific field validation checks here or rely on service layer
|
|
return
|
|
}
|
|
|
|
input := service.CreateTagInput{
|
|
Name: body.Name,
|
|
Color: body.Color,
|
|
Icon: body.Icon,
|
|
}
|
|
|
|
tag, err := h.services.Tag.CreateTag(r.Context(), userID, input)
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
|
|
SendJSONResponse(w, http.StatusCreated, mapDomainTagToApi(tag), h.logger)
|
|
}
|
|
|
|
func (h *ApiHandler) ListUserTags(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := GetUserIDFromContext(r.Context())
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
|
|
tags, err := h.services.Tag.ListUserTags(r.Context(), userID)
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
|
|
apiTags := make([]models.Tag, len(tags))
|
|
for i, tag := range tags {
|
|
apiTags[i] = *mapDomainTagToApi(&tag)
|
|
}
|
|
|
|
SendJSONResponse(w, http.StatusOK, apiTags, h.logger)
|
|
}
|
|
|
|
func (h *ApiHandler) GetTagById(w http.ResponseWriter, r *http.Request, tagId openapi_types.UUID) {
|
|
userID, err := GetUserIDFromContext(r.Context())
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
domainTagID := uuid.UUID(tagId)
|
|
|
|
tag, err := h.services.Tag.GetTagByID(r.Context(), domainTagID, userID)
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
SendJSONResponse(w, http.StatusOK, mapDomainTagToApi(tag), h.logger)
|
|
}
|
|
|
|
func (h *ApiHandler) UpdateTagById(w http.ResponseWriter, r *http.Request, tagId openapi_types.UUID) {
|
|
userID, err := GetUserIDFromContext(r.Context())
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
domainTagID := uuid.UUID(tagId)
|
|
|
|
var body models.UpdateTagRequest
|
|
if !parseAndValidateBody(w, r, &body, h.logger) {
|
|
return
|
|
}
|
|
|
|
input := service.UpdateTagInput{
|
|
Name: body.Name,
|
|
Color: body.Color,
|
|
Icon: body.Icon,
|
|
}
|
|
|
|
tag, err := h.services.Tag.UpdateTag(r.Context(), domainTagID, userID, input)
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
|
|
SendJSONResponse(w, http.StatusOK, mapDomainTagToApi(tag), h.logger)
|
|
}
|
|
|
|
func (h *ApiHandler) DeleteTagById(w http.ResponseWriter, r *http.Request, tagId openapi_types.UUID) {
|
|
userID, err := GetUserIDFromContext(r.Context())
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
domainTagID := uuid.UUID(tagId)
|
|
|
|
err = h.services.Tag.DeleteTag(r.Context(), domainTagID, userID)
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// --- Todo Handlers ---
|
|
|
|
// CreateTodo remains the same
|
|
|
|
func (h *ApiHandler) CreateTodo(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := GetUserIDFromContext(r.Context())
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
|
|
var body models.CreateTodoRequest
|
|
if !parseAndValidateBody(w, r, &body, h.logger) {
|
|
return
|
|
}
|
|
|
|
var domainTagIDs []uuid.UUID
|
|
if body.TagIds != nil {
|
|
domainTagIDs = make([]uuid.UUID, len(*body.TagIds))
|
|
for i, apiID := range *body.TagIds {
|
|
domainTagIDs[i] = uuid.UUID(apiID)
|
|
}
|
|
} else {
|
|
domainTagIDs = []uuid.UUID{}
|
|
}
|
|
|
|
input := service.CreateTodoInput{
|
|
Title: body.Title,
|
|
Description: body.Description,
|
|
Deadline: body.Deadline,
|
|
TagIDs: domainTagIDs,
|
|
}
|
|
if body.Status != nil {
|
|
domainStatus := domain.TodoStatus(*body.Status)
|
|
input.Status = &domainStatus
|
|
}
|
|
|
|
todo, err := h.services.Todo.CreateTodo(r.Context(), userID, input)
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
// Newly created todo won't have attachments yet
|
|
apiTodo := mapDomainTodoToApi(todo, []models.AttachmentInfo{})
|
|
SendJSONResponse(w, http.StatusCreated, apiTodo, h.logger)
|
|
}
|
|
|
|
// ListTodos remains the same, doesn't include full attachment details for performance
|
|
func (h *ApiHandler) ListTodos(w http.ResponseWriter, r *http.Request, params ListTodosParams) {
|
|
userID, err := GetUserIDFromContext(r.Context())
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
|
|
input := service.ListTodosInput{
|
|
Limit: 20, // Default limit
|
|
Offset: 0,
|
|
}
|
|
if params.Limit != nil {
|
|
input.Limit = *params.Limit
|
|
}
|
|
if params.Offset != nil {
|
|
input.Offset = *params.Offset
|
|
}
|
|
if params.Status != nil {
|
|
domainStatus := domain.TodoStatus(*params.Status)
|
|
input.Status = &domainStatus
|
|
}
|
|
if params.TagId != nil {
|
|
domainTagID := uuid.UUID(*params.TagId)
|
|
input.TagID = &domainTagID
|
|
}
|
|
|
|
todos, err := h.services.Todo.ListUserTodos(r.Context(), userID, input)
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
|
|
apiTodos := make([]models.Todo, len(todos))
|
|
for i, todo := range todos {
|
|
// For list view, if there is an attachmentUrl, include it as a single-item array
|
|
var attachmentInfos []models.AttachmentInfo
|
|
if todo.AttachmentUrl != nil && *todo.AttachmentUrl != "" {
|
|
attachmentInfos = []models.AttachmentInfo{{FileId: *todo.AttachmentUrl}}
|
|
} else {
|
|
attachmentInfos = []models.AttachmentInfo{}
|
|
}
|
|
mappedTodo := mapDomainTodoToApi(&todo, attachmentInfos)
|
|
if mappedTodo != nil {
|
|
apiTodos[i] = *mappedTodo
|
|
}
|
|
}
|
|
|
|
SendJSONResponse(w, http.StatusOK, apiTodos, h.logger)
|
|
}
|
|
|
|
// GetTodoById updated for single attachmentUrl
|
|
func (h *ApiHandler) GetTodoById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
|
|
ctx := r.Context()
|
|
userID, err := GetUserIDFromContext(ctx)
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
domainTodoID := uuid.UUID(todoId)
|
|
|
|
todo, err := h.services.Todo.GetTodoByID(ctx, domainTodoID, userID)
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
|
|
// Map attachmentUrl to API model as a single-item array if present
|
|
var apiAttachmentInfos []models.AttachmentInfo
|
|
if todo.AttachmentUrl != nil && *todo.AttachmentUrl != "" {
|
|
apiAttachmentInfos = []models.AttachmentInfo{{FileId: *todo.AttachmentUrl}}
|
|
} else {
|
|
apiAttachmentInfos = []models.AttachmentInfo{}
|
|
}
|
|
|
|
apiTodo := mapDomainTodoToApi(todo, apiAttachmentInfos)
|
|
SendJSONResponse(w, http.StatusOK, apiTodo, h.logger)
|
|
}
|
|
|
|
// UpdateTodoById remains the same=
|
|
|
|
func (h *ApiHandler) UpdateTodoById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
|
|
userID, err := GetUserIDFromContext(r.Context())
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
domainTodoID := uuid.UUID(todoId)
|
|
|
|
var body models.UpdateTodoRequest
|
|
if !parseAndValidateBody(w, r, &body, h.logger) {
|
|
return
|
|
}
|
|
|
|
input := service.UpdateTodoInput{
|
|
Title: body.Title,
|
|
Description: body.Description,
|
|
Deadline: body.Deadline,
|
|
}
|
|
|
|
if body.Status != nil {
|
|
domainStatus := domain.TodoStatus(*body.Status)
|
|
input.Status = &domainStatus
|
|
}
|
|
|
|
if body.TagIds != nil {
|
|
domainTagIDs := make([]uuid.UUID, len(*body.TagIds))
|
|
for i, apiID := range *body.TagIds {
|
|
domainTagIDs[i] = uuid.UUID(apiID)
|
|
}
|
|
input.TagIDs = &domainTagIDs
|
|
}
|
|
// Note: Attachments are NOT updated via this endpoint in this design
|
|
|
|
todo, err := h.services.Todo.UpdateTodo(r.Context(), domainTodoID, userID, input)
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
|
|
// Prepare attachment info for API response using AttachmentUrl field
|
|
var apiAttachmentInfos []models.AttachmentInfo
|
|
if todo.AttachmentUrl != nil && *todo.AttachmentUrl != "" {
|
|
apiAttachmentInfos = []models.AttachmentInfo{{FileId: *todo.AttachmentUrl}}
|
|
} else {
|
|
apiAttachmentInfos = []models.AttachmentInfo{}
|
|
}
|
|
|
|
apiTodo := mapDomainTodoToApi(todo, apiAttachmentInfos)
|
|
SendJSONResponse(w, http.StatusOK, apiTodo, h.logger)
|
|
}
|
|
|
|
// DeleteTodoById remains the same (service layer handles attachment deletion)
|
|
func (h *ApiHandler) DeleteTodoById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
|
|
userID, err := GetUserIDFromContext(r.Context())
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
domainTodoID := uuid.UUID(todoId)
|
|
|
|
err = h.services.Todo.DeleteTodo(r.Context(), domainTodoID, userID)
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// --- Attachment Handlers ---
|
|
|
|
func (h *ApiHandler) DeleteTodoAttachment(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
|
|
ctx := r.Context()
|
|
userID, err := GetUserIDFromContext(ctx)
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
domainTodoID := uuid.UUID(todoId)
|
|
|
|
h.logger.DebugContext(ctx, "Request to delete attachment", "todoId", todoId)
|
|
|
|
err = h.services.Todo.DeleteAttachment(ctx, domainTodoID, userID)
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
|
|
h.logger.InfoContext(ctx, "Attachment deleted successfully", "todoId", todoId)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *ApiHandler) UploadOrReplaceTodoAttachment(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
|
|
userID, err := GetUserIDFromContext(r.Context())
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
domainTodoID := uuid.UUID(todoId)
|
|
|
|
// Parse multipart form (limit to 10 MB)
|
|
err = r.ParseMultipartForm(10 << 20)
|
|
if err != nil {
|
|
SendJSONError(w, fmt.Errorf("failed to parse multipart form: %w", err), http.StatusBadRequest, h.logger)
|
|
return
|
|
}
|
|
|
|
file, fileHeader, err := r.FormFile("file")
|
|
if err != nil {
|
|
SendJSONError(w, fmt.Errorf("missing or invalid file: %w", err), http.StatusBadRequest, h.logger)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
fileName := fileHeader.Filename
|
|
fileSize := fileHeader.Size
|
|
|
|
todo, err := h.services.Todo.AddAttachment(r.Context(), domainTodoID, userID, fileName, fileSize, file)
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
|
|
// Prepare attachment info for API response using AttachmentUrl field
|
|
var apiAttachmentInfos []models.AttachmentInfo
|
|
if todo.AttachmentUrl != nil && *todo.AttachmentUrl != "" {
|
|
apiAttachmentInfos = []models.AttachmentInfo{{FileId: *todo.AttachmentUrl}}
|
|
} else {
|
|
apiAttachmentInfos = []models.AttachmentInfo{}
|
|
}
|
|
|
|
apiTodo := mapDomainTodoToApi(todo, apiAttachmentInfos)
|
|
SendJSONResponse(w, http.StatusOK, apiTodo, h.logger)
|
|
}
|
|
|
|
// --- Subtask Handlers ---
|
|
|
|
func (h *ApiHandler) CreateSubtaskForTodo(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
|
|
userID, err := GetUserIDFromContext(r.Context())
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
|
|
var body models.CreateSubtaskRequest
|
|
if !parseAndValidateBody(w, r, &body, h.logger) {
|
|
return
|
|
}
|
|
input := service.CreateSubtaskInput{
|
|
Description: body.Description,
|
|
}
|
|
|
|
subtask, err := h.services.Todo.CreateSubtask(r.Context(), todoId, userID, input)
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
|
|
SendJSONResponse(w, http.StatusCreated, mapDomainSubtaskToApi(subtask), h.logger)
|
|
}
|
|
|
|
func (h *ApiHandler) ListSubtasksForTodo(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
|
|
userID, err := GetUserIDFromContext(r.Context())
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
|
|
subtasks, err := h.services.Todo.ListSubtasks(r.Context(), todoId, userID)
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
|
|
apiSubtasks := make([]models.Subtask, len(subtasks))
|
|
for i, st := range subtasks {
|
|
apiSubtasks[i] = *mapDomainSubtaskToApi(&st)
|
|
}
|
|
|
|
SendJSONResponse(w, http.StatusOK, apiSubtasks, h.logger)
|
|
}
|
|
|
|
func (h *ApiHandler) UpdateSubtaskById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID, subtaskId openapi_types.UUID) {
|
|
userID, err := GetUserIDFromContext(r.Context())
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
|
|
var body models.UpdateSubtaskRequest
|
|
if !parseAndValidateBody(w, r, &body, h.logger) {
|
|
return
|
|
}
|
|
|
|
input := service.UpdateSubtaskInput{
|
|
Description: body.Description,
|
|
Completed: body.Completed,
|
|
}
|
|
|
|
subtask, err := h.services.Todo.UpdateSubtask(r.Context(), todoId, subtaskId, userID, input)
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
|
|
SendJSONResponse(w, http.StatusOK, mapDomainSubtaskToApi(subtask), h.logger)
|
|
}
|
|
|
|
func (h *ApiHandler) DeleteSubtaskById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID, subtaskId openapi_types.UUID) {
|
|
userID, err := GetUserIDFromContext(r.Context())
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
|
|
err = h.services.Todo.DeleteSubtask(r.Context(), todoId, subtaskId, userID)
|
|
if err != nil {
|
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|