diff --git a/VERSION b/VERSION index 9776e72..19b860c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.3.10 +6.4.0 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index 34a7268..1866283 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -4567,6 +4567,27 @@ func main() { protected.HandleFunc("/api/wishlist/invite/{token}", app.getBoardInviteInfoHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/wishlist/invite/{token}/join", app.joinBoardHandler).Methods("POST", "OPTIONS") + // Shopping Boards (Товары) + protected.HandleFunc("/api/shopping/boards", app.getShoppingBoardsHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/shopping/boards", app.createShoppingBoardHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/shopping/boards/{id}", app.getShoppingBoardHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/shopping/boards/{id}", app.updateShoppingBoardHandler).Methods("PUT", "OPTIONS") + protected.HandleFunc("/api/shopping/boards/{id}", app.deleteShoppingBoardHandler).Methods("DELETE", "OPTIONS") + protected.HandleFunc("/api/shopping/boards/{id}/regenerate-invite", app.regenerateShoppingBoardInviteHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/shopping/boards/{id}/members", app.getShoppingBoardMembersHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/shopping/boards/{id}/members/{userId}", app.removeShoppingBoardMemberHandler).Methods("DELETE", "OPTIONS") + protected.HandleFunc("/api/shopping/boards/{id}/leave", app.leaveShoppingBoardHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/shopping/boards/{boardId}/items", app.getShoppingItemsHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/shopping/boards/{boardId}/items", app.createShoppingItemHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/shopping/items/{id}", app.getShoppingItemHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/shopping/items/{id}", app.updateShoppingItemHandler).Methods("PUT", "OPTIONS") + protected.HandleFunc("/api/shopping/items/{id}", app.deleteShoppingItemHandler).Methods("DELETE", "OPTIONS") + protected.HandleFunc("/api/shopping/items/{id}/complete", app.completeShoppingItemHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/shopping/items/{id}/postpone", app.postponeShoppingItemHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/shopping/groups", app.getShoppingGroupSuggestionsHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/shopping/invite/{token}", app.getShoppingBoardInviteInfoHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/shopping/invite/{token}/join", app.joinShoppingBoardHandler).Methods("POST", "OPTIONS") + // Tracking protected.HandleFunc("/api/tracking/stats", app.getTrackingStatsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/tracking/invite", app.createTrackingInviteHandler).Methods("POST", "OPTIONS") @@ -17687,3 +17708,1373 @@ func (a *App) getGroupSuggestionsHandler(w http.ResponseWriter, r *http.Request) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(groups) } + +// ============================================ +// Shopping (Товары) - Board & Item handlers +// ============================================ + +type ShoppingBoard struct { + ID int `json:"id"` + OwnerID int `json:"owner_id"` + OwnerName string `json:"owner_name,omitempty"` + Name string `json:"name"` + InviteEnabled bool `json:"invite_enabled"` + InviteToken *string `json:"invite_token,omitempty"` + InviteURL *string `json:"invite_url,omitempty"` + MemberCount int `json:"member_count"` + IsOwner bool `json:"is_owner"` + CreatedAt time.Time `json:"created_at"` +} + +type ShoppingItem struct { + ID int `json:"id"` + UserID int `json:"user_id"` + BoardID int `json:"board_id"` + AuthorID int `json:"author_id"` + Name string `json:"name"` + GroupName *string `json:"group_name,omitempty"` + VolumeBase float64 `json:"volume_base"` + RepetitionPeriod *string `json:"repetition_period,omitempty"` + NextShowAt *string `json:"next_show_at,omitempty"` + Completed int `json:"completed"` + LastCompletedAt *string `json:"last_completed_at,omitempty"` + CreatedAt string `json:"created_at"` +} + +type ShoppingItemRequest struct { + Name string `json:"name"` + GroupName *string `json:"group_name,omitempty"` + VolumeBase *float64 `json:"volume_base,omitempty"` + RepetitionPeriod *string `json:"repetition_period,omitempty"` +} + +type CompleteShoppingItemRequest struct { + Volume *float64 `json:"volume,omitempty"` +} + +type ShoppingJoinBoardResponse struct { + Board ShoppingBoard `json:"board"` + Message string `json:"message"` +} + +// getShoppingBoardsHandler возвращает список досок покупок пользователя +func (a *App) getShoppingBoardsHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + boards := []ShoppingBoard{} + + rows, err := a.DB.Query(` + SELECT DISTINCT + sb.id, + sb.owner_id, + COALESCE(u.name, u.email) as owner_name, + sb.name, + sb.invite_enabled, + sb.invite_token, + sb.created_at, + (SELECT COUNT(*) FROM shopping_board_members sbm WHERE sbm.board_id = sb.id) as member_count, + (sb.owner_id = $1) as is_owner + FROM shopping_boards sb + JOIN users u ON sb.owner_id = u.id + LEFT JOIN shopping_board_members sbm ON sb.id = sbm.board_id + WHERE sb.deleted = FALSE + AND (sb.owner_id = $1 OR sbm.user_id = $1) + ORDER BY is_owner DESC, sb.created_at DESC + `, userID) + if err != nil { + log.Printf("Error getting shopping boards: %v", err) + sendErrorWithCORS(w, "Error getting boards", http.StatusInternalServerError) + return + } + defer rows.Close() + + baseURL := getEnv("WEBHOOK_BASE_URL", "") + + for rows.Next() { + var board ShoppingBoard + var inviteToken sql.NullString + err := rows.Scan( + &board.ID, + &board.OwnerID, + &board.OwnerName, + &board.Name, + &board.InviteEnabled, + &inviteToken, + &board.CreatedAt, + &board.MemberCount, + &board.IsOwner, + ) + if err != nil { + log.Printf("Error scanning shopping board: %v", err) + continue + } + + if board.IsOwner && inviteToken.Valid { + board.InviteToken = &inviteToken.String + if baseURL != "" { + url := baseURL + "/shopping-invite/" + inviteToken.String + board.InviteURL = &url + } + } + + boards = append(boards, board) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(boards) +} + +// createShoppingBoardHandler создаёт новую доску покупок +func (a *App) createShoppingBoardHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var req BoardRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + + if strings.TrimSpace(req.Name) == "" { + sendErrorWithCORS(w, "Name is required", http.StatusBadRequest) + return + } + + var boardID int + err := a.DB.QueryRow(` + INSERT INTO shopping_boards (owner_id, name) + VALUES ($1, $2) + RETURNING id + `, userID, strings.TrimSpace(req.Name)).Scan(&boardID) + + if err != nil { + log.Printf("Error creating shopping board: %v", err) + sendErrorWithCORS(w, "Error creating board", http.StatusInternalServerError) + return + } + + board := ShoppingBoard{ + ID: boardID, + OwnerID: userID, + Name: strings.TrimSpace(req.Name), + InviteEnabled: false, + MemberCount: 0, + IsOwner: true, + CreatedAt: time.Now(), + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(board) +} + +// getShoppingBoardHandler возвращает детали доски покупок +func (a *App) getShoppingBoardHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + boardID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) + return + } + + var board ShoppingBoard + var inviteToken sql.NullString + + err = a.DB.QueryRow(` + SELECT + sb.id, + sb.owner_id, + COALESCE(u.name, u.email) as owner_name, + sb.name, + sb.invite_enabled, + sb.invite_token, + sb.created_at, + (SELECT COUNT(*) FROM shopping_board_members sbm WHERE sbm.board_id = sb.id) as member_count + FROM shopping_boards sb + JOIN users u ON sb.owner_id = u.id + WHERE sb.id = $1 AND sb.deleted = FALSE + `, boardID).Scan( + &board.ID, + &board.OwnerID, + &board.OwnerName, + &board.Name, + &board.InviteEnabled, + &inviteToken, + &board.CreatedAt, + &board.MemberCount, + ) + + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Board not found", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Error getting shopping board: %v", err) + sendErrorWithCORS(w, "Error getting board", http.StatusInternalServerError) + return + } + + board.IsOwner = board.OwnerID == userID + + if !board.IsOwner { + var isMember bool + a.DB.QueryRow(` + SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2) + `, boardID, userID).Scan(&isMember) + + if !isMember { + sendErrorWithCORS(w, "Access denied", http.StatusForbidden) + return + } + } + + if board.IsOwner && inviteToken.Valid { + board.InviteToken = &inviteToken.String + baseURL := getEnv("WEBHOOK_BASE_URL", "") + if baseURL != "" { + url := baseURL + "/shopping-invite/" + inviteToken.String + board.InviteURL = &url + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(board) +} + +// updateShoppingBoardHandler обновляет доску покупок (только владелец) +func (a *App) updateShoppingBoardHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + boardID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) + return + } + + var ownerID int + err = a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Board not found", http.StatusNotFound) + return + } + if ownerID != userID { + sendErrorWithCORS(w, "Only owner can update board", http.StatusForbidden) + return + } + + var req BoardRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + + if strings.TrimSpace(req.Name) != "" { + _, err = a.DB.Exec(`UPDATE shopping_boards SET name = $1, updated_at = NOW() WHERE id = $2`, + strings.TrimSpace(req.Name), boardID) + if err != nil { + log.Printf("Error updating shopping board name: %v", err) + } + } + + if req.InviteEnabled != nil { + if *req.InviteEnabled { + var currentToken sql.NullString + a.DB.QueryRow(`SELECT invite_token FROM shopping_boards WHERE id = $1`, boardID).Scan(¤tToken) + + if !currentToken.Valid || currentToken.String == "" { + token := generateInviteToken() + _, err = a.DB.Exec(`UPDATE shopping_boards SET invite_enabled = TRUE, invite_token = $1, updated_at = NOW() WHERE id = $2`, + token, boardID) + } else { + _, err = a.DB.Exec(`UPDATE shopping_boards SET invite_enabled = TRUE, updated_at = NOW() WHERE id = $1`, boardID) + } + } else { + _, err = a.DB.Exec(`UPDATE shopping_boards SET invite_enabled = FALSE, updated_at = NOW() WHERE id = $1`, boardID) + } + if err != nil { + log.Printf("Error updating shopping board invite_enabled: %v", err) + } + } + + var board ShoppingBoard + var inviteToken sql.NullString + + a.DB.QueryRow(` + SELECT + sb.id, sb.owner_id, COALESCE(u.name, u.email), sb.name, sb.invite_enabled, sb.invite_token, sb.created_at, + (SELECT COUNT(*) FROM shopping_board_members sbm WHERE sbm.board_id = sb.id) + FROM shopping_boards sb + JOIN users u ON sb.owner_id = u.id + WHERE sb.id = $1 + `, boardID).Scan(&board.ID, &board.OwnerID, &board.OwnerName, &board.Name, &board.InviteEnabled, &inviteToken, &board.CreatedAt, &board.MemberCount) + + board.IsOwner = true + if inviteToken.Valid { + board.InviteToken = &inviteToken.String + baseURL := getEnv("WEBHOOK_BASE_URL", "") + if baseURL != "" { + url := baseURL + "/shopping-invite/" + inviteToken.String + board.InviteURL = &url + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(board) +} + +// deleteShoppingBoardHandler удаляет доску покупок (только владелец) +func (a *App) deleteShoppingBoardHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + boardID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) + return + } + + var ownerID int + err = a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Board not found", http.StatusNotFound) + return + } + if ownerID != userID { + sendErrorWithCORS(w, "Only owner can delete board", http.StatusForbidden) + return + } + + _, err = a.DB.Exec(`UPDATE shopping_boards SET deleted = TRUE, updated_at = NOW() WHERE id = $1`, boardID) + if err != nil { + log.Printf("Error deleting shopping board: %v", err) + sendErrorWithCORS(w, "Error deleting board", http.StatusInternalServerError) + return + } + + _, err = a.DB.Exec(`UPDATE shopping_items SET deleted = TRUE, updated_at = NOW() WHERE board_id = $1`, boardID) + if err != nil { + log.Printf("Error deleting shopping board items: %v", err) + } + + w.WriteHeader(http.StatusNoContent) +} + +// regenerateShoppingBoardInviteHandler перегенерирует invite token для доски покупок +func (a *App) regenerateShoppingBoardInviteHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + boardID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) + return + } + + var ownerID int + err = a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Board not found", http.StatusNotFound) + return + } + if ownerID != userID { + sendErrorWithCORS(w, "Only owner can regenerate invite", http.StatusForbidden) + return + } + + token := generateInviteToken() + _, err = a.DB.Exec(`UPDATE shopping_boards SET invite_token = $1, invite_enabled = TRUE, updated_at = NOW() WHERE id = $2`, + token, boardID) + if err != nil { + log.Printf("Error regenerating shopping invite token: %v", err) + sendErrorWithCORS(w, "Error regenerating invite", http.StatusInternalServerError) + return + } + + baseURL := getEnv("WEBHOOK_BASE_URL", "") + inviteURL := "" + if baseURL != "" { + inviteURL = baseURL + "/shopping-invite/" + token + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "invite_token": token, + "invite_url": inviteURL, + }) +} + +// getShoppingBoardMembersHandler возвращает список участников доски покупок +func (a *App) getShoppingBoardMembersHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + boardID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) + return + } + + var ownerID int + err = a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Board not found", http.StatusNotFound) + return + } + if ownerID != userID { + sendErrorWithCORS(w, "Only owner can view members", http.StatusForbidden) + return + } + + members := []BoardMember{} + rows, err := a.DB.Query(` + SELECT sbm.id, sbm.user_id, COALESCE(u.name, '') as name, u.email, sbm.joined_at + FROM shopping_board_members sbm + JOIN users u ON sbm.user_id = u.id + WHERE sbm.board_id = $1 + ORDER BY sbm.joined_at DESC + `, boardID) + if err != nil { + log.Printf("Error getting shopping board members: %v", err) + sendErrorWithCORS(w, "Error getting members", http.StatusInternalServerError) + return + } + defer rows.Close() + + for rows.Next() { + var member BoardMember + err := rows.Scan(&member.ID, &member.UserID, &member.Name, &member.Email, &member.JoinedAt) + if err != nil { + log.Printf("Error scanning shopping board member: %v", err) + continue + } + members = append(members, member) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(members) +} + +// removeShoppingBoardMemberHandler удаляет участника из доски покупок +func (a *App) removeShoppingBoardMemberHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + boardID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) + return + } + memberUserID, err := strconv.Atoi(vars["userId"]) + if err != nil { + sendErrorWithCORS(w, "Invalid user ID", http.StatusBadRequest) + return + } + + var ownerID int + err = a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Board not found", http.StatusNotFound) + return + } + if ownerID != userID { + sendErrorWithCORS(w, "Only owner can remove members", http.StatusForbidden) + return + } + + _, err = a.DB.Exec(`DELETE FROM shopping_board_members WHERE board_id = $1 AND user_id = $2`, boardID, memberUserID) + if err != nil { + log.Printf("Error removing shopping board member: %v", err) + sendErrorWithCORS(w, "Error removing member", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// leaveShoppingBoardHandler позволяет участнику выйти из доски покупок +func (a *App) leaveShoppingBoardHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + boardID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) + return + } + + var ownerID int + err = a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Board not found", http.StatusNotFound) + return + } + if ownerID == userID { + sendErrorWithCORS(w, "Owner cannot leave board, delete it instead", http.StatusBadRequest) + return + } + + _, err = a.DB.Exec(`DELETE FROM shopping_board_members WHERE board_id = $1 AND user_id = $2`, boardID, userID) + if err != nil { + log.Printf("Error leaving shopping board: %v", err) + sendErrorWithCORS(w, "Error leaving board", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// getShoppingBoardInviteInfoHandler возвращает информацию о доске покупок по invite token +func (a *App) getShoppingBoardInviteInfoHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + vars := mux.Vars(r) + token := vars["token"] + + var info BoardInviteInfo + var ownerName string + err := a.DB.QueryRow(` + SELECT + sb.id, + sb.name, + COALESCE(u.name, u.email) as owner_name, + (SELECT COUNT(*) FROM shopping_board_members sbm WHERE sbm.board_id = sb.id) as member_count + FROM shopping_boards sb + JOIN users u ON sb.owner_id = u.id + WHERE sb.invite_token = $1 AND sb.invite_enabled = TRUE AND sb.deleted = FALSE + `, token).Scan(&info.BoardID, &info.Name, &ownerName, &info.MemberCount) + + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Invalid or expired invite link", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Error getting shopping invite info: %v", err) + sendErrorWithCORS(w, "Error getting invite info", http.StatusInternalServerError) + return + } + + info.OwnerName = ownerName + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(info) +} + +// joinShoppingBoardHandler присоединяет пользователя к доске покупок по invite token +func (a *App) joinShoppingBoardHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + token := vars["token"] + + var boardID, ownerID int + var boardName, ownerName string + err := a.DB.QueryRow(` + SELECT sb.id, sb.owner_id, sb.name, COALESCE(u.name, u.email) + FROM shopping_boards sb + JOIN users u ON sb.owner_id = u.id + WHERE sb.invite_token = $1 AND sb.invite_enabled = TRUE AND sb.deleted = FALSE + `, token).Scan(&boardID, &ownerID, &boardName, &ownerName) + + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Invalid or expired invite link", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Error getting shopping board by token: %v", err) + sendErrorWithCORS(w, "Error joining board", http.StatusInternalServerError) + return + } + + if ownerID == userID { + sendErrorWithCORS(w, "You are the owner of this board", http.StatusBadRequest) + return + } + + var exists bool + a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`, + boardID, userID).Scan(&exists) + if exists { + sendErrorWithCORS(w, "You are already a member of this board", http.StatusBadRequest) + return + } + + _, err = a.DB.Exec(`INSERT INTO shopping_board_members (board_id, user_id) VALUES ($1, $2)`, boardID, userID) + if err != nil { + log.Printf("Error joining shopping board: %v", err) + sendErrorWithCORS(w, "Error joining board", http.StatusInternalServerError) + return + } + + var memberCount int + a.DB.QueryRow(`SELECT COUNT(*) FROM shopping_board_members WHERE board_id = $1`, boardID).Scan(&memberCount) + + board := ShoppingBoard{ + ID: boardID, + OwnerID: ownerID, + OwnerName: ownerName, + Name: boardName, + InviteEnabled: true, + MemberCount: memberCount, + IsOwner: false, + } + + response := ShoppingJoinBoardResponse{ + Board: board, + Message: "Вы успешно присоединились к доске!", + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(response) +} + +// getShoppingItemsHandler возвращает все товары на доске +func (a *App) getShoppingItemsHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + boardID, err := strconv.Atoi(vars["boardId"]) + if err != nil { + sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) + return + } + + // Проверяем доступ + var ownerID int + err = a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Board not found", http.StatusNotFound) + return + } + if ownerID != userID { + var isMember bool + a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`, + boardID, userID).Scan(&isMember) + if !isMember { + sendErrorWithCORS(w, "Access denied", http.StatusForbidden) + return + } + } + + items := []ShoppingItem{} + rows, err := a.DB.Query(` + SELECT + si.id, si.user_id, si.board_id, si.author_id, si.name, si.group_name, + si.volume_base, si.repetition_period::text, si.next_show_at, si.completed, + si.last_completed_at, si.created_at + FROM shopping_items si + WHERE si.board_id = $1 AND si.deleted = FALSE + ORDER BY si.created_at ASC + `, boardID) + if err != nil { + log.Printf("Error getting shopping items: %v", err) + sendErrorWithCORS(w, "Error getting items", http.StatusInternalServerError) + return + } + defer rows.Close() + + for rows.Next() { + var item ShoppingItem + var groupName sql.NullString + var repetitionPeriod sql.NullString + var nextShowAt sql.NullTime + var lastCompletedAt sql.NullTime + var createdAt time.Time + + err := rows.Scan( + &item.ID, &item.UserID, &item.BoardID, &item.AuthorID, &item.Name, &groupName, + &item.VolumeBase, &repetitionPeriod, &nextShowAt, &item.Completed, + &lastCompletedAt, &createdAt, + ) + if err != nil { + log.Printf("Error scanning shopping item: %v", err) + continue + } + + if groupName.Valid { + item.GroupName = &groupName.String + } + if repetitionPeriod.Valid { + item.RepetitionPeriod = &repetitionPeriod.String + } + if nextShowAt.Valid { + s := nextShowAt.Time.Format(time.RFC3339) + item.NextShowAt = &s + } + if lastCompletedAt.Valid { + s := lastCompletedAt.Time.Format(time.RFC3339) + item.LastCompletedAt = &s + } + item.CreatedAt = createdAt.Format(time.RFC3339) + + items = append(items, item) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(items) +} + +// createShoppingItemHandler создаёт новый товар на доске +func (a *App) createShoppingItemHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + boardID, err := strconv.Atoi(vars["boardId"]) + if err != nil { + sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) + return + } + + // Проверяем доступ + var boardOwnerID int + err = a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&boardOwnerID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Board not found", http.StatusNotFound) + return + } + if boardOwnerID != userID { + var isMember bool + a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`, + boardID, userID).Scan(&isMember) + if !isMember { + sendErrorWithCORS(w, "Access denied", http.StatusForbidden) + return + } + } + + var req ShoppingItemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + + if strings.TrimSpace(req.Name) == "" { + sendErrorWithCORS(w, "Name is required", http.StatusBadRequest) + return + } + + volumeBase := 1.0 + if req.VolumeBase != nil && *req.VolumeBase > 0 { + volumeBase = *req.VolumeBase + } + + var itemID int + err = a.DB.QueryRow(` + INSERT INTO shopping_items (user_id, board_id, author_id, name, group_name, volume_base, repetition_period) + VALUES ($1, $2, $3, $4, $5, $6, $7::interval) + RETURNING id + `, boardOwnerID, boardID, userID, strings.TrimSpace(req.Name), req.GroupName, volumeBase, req.RepetitionPeriod).Scan(&itemID) + + if err != nil { + log.Printf("Error creating shopping item: %v", err) + sendErrorWithCORS(w, "Error creating item", http.StatusInternalServerError) + return + } + + item := ShoppingItem{ + ID: itemID, + UserID: boardOwnerID, + BoardID: boardID, + AuthorID: userID, + Name: strings.TrimSpace(req.Name), + GroupName: req.GroupName, + VolumeBase: volumeBase, + RepetitionPeriod: req.RepetitionPeriod, + Completed: 0, + CreatedAt: time.Now().Format(time.RFC3339), + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(item) +} + +// getShoppingItemHandler возвращает детали товара +func (a *App) getShoppingItemHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + itemID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid item ID", http.StatusBadRequest) + return + } + + var item ShoppingItem + var groupName sql.NullString + var repetitionPeriod sql.NullString + var nextShowAt sql.NullTime + var lastCompletedAt sql.NullTime + var createdAt time.Time + + err = a.DB.QueryRow(` + SELECT + si.id, si.user_id, si.board_id, si.author_id, si.name, si.group_name, + si.volume_base, si.repetition_period::text, si.next_show_at, si.completed, + si.last_completed_at, si.created_at + FROM shopping_items si + WHERE si.id = $1 AND si.deleted = FALSE + `, itemID).Scan( + &item.ID, &item.UserID, &item.BoardID, &item.AuthorID, &item.Name, &groupName, + &item.VolumeBase, &repetitionPeriod, &nextShowAt, &item.Completed, + &lastCompletedAt, &createdAt, + ) + + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Item not found", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Error getting shopping item: %v", err) + sendErrorWithCORS(w, "Error getting item", http.StatusInternalServerError) + return + } + + // Проверяем доступ через доску + var ownerID int + a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1`, item.BoardID).Scan(&ownerID) + if ownerID != userID { + var isMember bool + a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`, + item.BoardID, userID).Scan(&isMember) + if !isMember { + sendErrorWithCORS(w, "Access denied", http.StatusForbidden) + return + } + } + + if groupName.Valid { + item.GroupName = &groupName.String + } + if repetitionPeriod.Valid { + item.RepetitionPeriod = &repetitionPeriod.String + } + if nextShowAt.Valid { + s := nextShowAt.Time.Format(time.RFC3339) + item.NextShowAt = &s + } + if lastCompletedAt.Valid { + s := lastCompletedAt.Time.Format(time.RFC3339) + item.LastCompletedAt = &s + } + item.CreatedAt = createdAt.Format(time.RFC3339) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(item) +} + +// updateShoppingItemHandler обновляет товар +func (a *App) updateShoppingItemHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + itemID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid item ID", http.StatusBadRequest) + return + } + + // Проверяем что товар существует и получаем board_id + var boardID int + err = a.DB.QueryRow(`SELECT board_id FROM shopping_items WHERE id = $1 AND deleted = FALSE`, itemID).Scan(&boardID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Item not found", http.StatusNotFound) + return + } + + // Проверяем доступ + var ownerID int + a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1`, boardID).Scan(&ownerID) + if ownerID != userID { + var isMember bool + a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`, + boardID, userID).Scan(&isMember) + if !isMember { + sendErrorWithCORS(w, "Access denied", http.StatusForbidden) + return + } + } + + var req ShoppingItemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + + if strings.TrimSpace(req.Name) == "" { + sendErrorWithCORS(w, "Name is required", http.StatusBadRequest) + return + } + + volumeBase := 1.0 + if req.VolumeBase != nil && *req.VolumeBase > 0 { + volumeBase = *req.VolumeBase + } + + _, err = a.DB.Exec(` + UPDATE shopping_items + SET name = $1, group_name = $2, volume_base = $3, repetition_period = $4::interval, updated_at = NOW() + WHERE id = $5 + `, strings.TrimSpace(req.Name), req.GroupName, volumeBase, req.RepetitionPeriod, itemID) + + if err != nil { + log.Printf("Error updating shopping item: %v", err) + sendErrorWithCORS(w, "Error updating item", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Item updated successfully", + }) +} + +// deleteShoppingItemHandler удаляет товар +func (a *App) deleteShoppingItemHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + itemID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid item ID", http.StatusBadRequest) + return + } + + var boardID int + err = a.DB.QueryRow(`SELECT board_id FROM shopping_items WHERE id = $1 AND deleted = FALSE`, itemID).Scan(&boardID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Item not found", http.StatusNotFound) + return + } + + var ownerID int + a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1`, boardID).Scan(&ownerID) + if ownerID != userID { + var isMember bool + a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`, + boardID, userID).Scan(&isMember) + if !isMember { + sendErrorWithCORS(w, "Access denied", http.StatusForbidden) + return + } + } + + _, err = a.DB.Exec(`UPDATE shopping_items SET deleted = TRUE, updated_at = NOW() WHERE id = $1`, itemID) + if err != nil { + log.Printf("Error deleting shopping item: %v", err) + sendErrorWithCORS(w, "Error deleting item", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// completeShoppingItemHandler выполняет товар (покупку) +func (a *App) completeShoppingItemHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + itemID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid item ID", http.StatusBadRequest) + return + } + + var req CompleteShoppingItemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Получаем товар + var boardID int + var volumeBase float64 + var repetitionPeriod sql.NullString + err = a.DB.QueryRow(` + SELECT board_id, volume_base, repetition_period::text + FROM shopping_items WHERE id = $1 AND deleted = FALSE + `, itemID).Scan(&boardID, &volumeBase, &repetitionPeriod) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Item not found", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Error getting shopping item for complete: %v", err) + sendErrorWithCORS(w, "Error completing item", http.StatusInternalServerError) + return + } + + // Проверяем доступ + var ownerID int + a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1`, boardID).Scan(&ownerID) + if ownerID != userID { + var isMember bool + a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`, + boardID, userID).Scan(&isMember) + if !isMember { + sendErrorWithCORS(w, "Access denied", http.StatusForbidden) + return + } + } + + actualVolume := volumeBase + if req.Volume != nil && *req.Volume > 0 { + actualVolume = *req.Volume + } + + now := time.Now() + + if repetitionPeriod.Valid && repetitionPeriod.String != "" { + // Рассчитываем next_show_at с учётом объёма + multiplier := actualVolume / volumeBase + baseNext := calculateNextShowAtFromRepetitionPeriod(repetitionPeriod.String, now) + if baseNext != nil { + // Применяем множитель: сдвигаем пропорционально объёму + baseDuration := baseNext.Sub(time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())) + adjustedDuration := time.Duration(float64(baseDuration) * multiplier) + nextShowAt := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Add(adjustedDuration) + + _, err = a.DB.Exec(` + UPDATE shopping_items + SET completed = completed + 1, last_completed_at = $1, next_show_at = $2, updated_at = NOW() + WHERE id = $3 + `, now, nextShowAt, itemID) + } else { + _, err = a.DB.Exec(` + UPDATE shopping_items + SET completed = completed + 1, last_completed_at = $1, updated_at = NOW() + WHERE id = $2 + `, now, itemID) + } + } else { + // Одноразовый товар - помечаем удалённым + _, err = a.DB.Exec(` + UPDATE shopping_items + SET completed = completed + 1, last_completed_at = $1, deleted = TRUE, updated_at = NOW() + WHERE id = $2 + `, now, itemID) + } + + if err != nil { + log.Printf("Error completing shopping item: %v", err) + sendErrorWithCORS(w, "Error completing item", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Item completed successfully", + }) +} + +// postponeShoppingItemHandler переносит товар на указанную дату +func (a *App) postponeShoppingItemHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + itemID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid item ID", http.StatusBadRequest) + return + } + + var req PostponeTaskRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Проверяем что товар существует и получаем board_id + var boardID int + err = a.DB.QueryRow(`SELECT board_id FROM shopping_items WHERE id = $1 AND deleted = FALSE`, itemID).Scan(&boardID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Item not found", http.StatusNotFound) + return + } + + // Проверяем доступ + var ownerID int + a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1`, boardID).Scan(&ownerID) + if ownerID != userID { + var isMember bool + a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`, + boardID, userID).Scan(&isMember) + if !isMember { + sendErrorWithCORS(w, "Access denied", http.StatusForbidden) + return + } + } + + var nextShowAtValue interface{} + if req.NextShowAt == nil || *req.NextShowAt == "" { + nextShowAtValue = nil + } else { + nextShowAt, err := time.Parse(time.RFC3339, *req.NextShowAt) + if err != nil { + sendErrorWithCORS(w, "Invalid date format. Use RFC3339 format", http.StatusBadRequest) + return + } + nextShowAtValue = nextShowAt + } + + _, err = a.DB.Exec(` + UPDATE shopping_items + SET next_show_at = $1, updated_at = NOW() + WHERE id = $2 + `, nextShowAtValue, itemID) + if err != nil { + log.Printf("Error postponing shopping item: %v", err) + sendErrorWithCORS(w, "Error postponing item", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Item postponed successfully", + }) +} + +// getShoppingGroupSuggestionsHandler возвращает уникальные группы товаров для автодополнения +func (a *App) getShoppingGroupSuggestionsHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + groups := []string{} + rows, err := a.DB.Query(` + SELECT DISTINCT si.group_name + FROM shopping_items si + JOIN shopping_boards sb ON si.board_id = sb.id + LEFT JOIN shopping_board_members sbm ON sb.id = sbm.board_id + WHERE si.group_name IS NOT NULL + AND si.group_name != '' + AND si.deleted = FALSE + AND sb.deleted = FALSE + AND (sb.owner_id = $1 OR sbm.user_id = $1) + ORDER BY si.group_name + `, userID) + if err != nil { + log.Printf("Error getting shopping group suggestions: %v", err) + sendErrorWithCORS(w, "Error getting groups", http.StatusInternalServerError) + return + } + defer rows.Close() + + for rows.Next() { + var groupName string + if err := rows.Scan(&groupName); err != nil { + log.Printf("Error scanning shopping group name: %v", err) + continue + } + groups = append(groups, groupName) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(groups) +} diff --git a/play-life-backend/migrations/000026_shopping_list.down.sql b/play-life-backend/migrations/000026_shopping_list.down.sql new file mode 100644 index 0000000..5e4b1e0 --- /dev/null +++ b/play-life-backend/migrations/000026_shopping_list.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS shopping_items; +DROP TABLE IF EXISTS shopping_board_members; +DROP TABLE IF EXISTS shopping_boards; diff --git a/play-life-backend/migrations/000026_shopping_list.up.sql b/play-life-backend/migrations/000026_shopping_list.up.sql new file mode 100644 index 0000000..95a80ee --- /dev/null +++ b/play-life-backend/migrations/000026_shopping_list.up.sql @@ -0,0 +1,50 @@ +-- Shopping boards (аналог wishlist_boards) +CREATE TABLE shopping_boards ( + id SERIAL PRIMARY KEY, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + invite_token VARCHAR(64) UNIQUE, + invite_enabled BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN DEFAULT FALSE +); + +CREATE INDEX idx_shopping_boards_owner_id ON shopping_boards(owner_id); +CREATE INDEX idx_shopping_boards_invite_token ON shopping_boards(invite_token) WHERE invite_token IS NOT NULL; +CREATE INDEX idx_shopping_boards_owner_deleted ON shopping_boards(owner_id, deleted); + +-- Shopping board members (аналог wishlist_board_members) +CREATE TABLE shopping_board_members ( + id SERIAL PRIMARY KEY, + board_id INTEGER NOT NULL REFERENCES shopping_boards(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + joined_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_shopping_board_member UNIQUE (board_id, user_id) +); + +CREATE INDEX idx_shopping_board_members_board_id ON shopping_board_members(board_id); +CREATE INDEX idx_shopping_board_members_user_id ON shopping_board_members(user_id); + +-- Shopping items (товары) +CREATE TABLE shopping_items ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + board_id INTEGER NOT NULL REFERENCES shopping_boards(id) ON DELETE CASCADE, + author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + group_name VARCHAR(255), + volume_base NUMERIC(10,4) NOT NULL DEFAULT 1, + repetition_period INTERVAL, + next_show_at TIMESTAMP WITH TIME ZONE, + completed INTEGER DEFAULT 0, + last_completed_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN DEFAULT FALSE +); + +CREATE INDEX idx_shopping_items_board_id ON shopping_items(board_id); +CREATE INDEX idx_shopping_items_user_id ON shopping_items(user_id); +CREATE INDEX idx_shopping_items_deleted ON shopping_items(deleted); +CREATE INDEX idx_shopping_items_next_show_at ON shopping_items(next_show_at); diff --git a/play-life-web/package.json b/play-life-web/package.json index 4022dff..00e2ab4 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "6.3.10", + "version": "6.4.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/App.jsx b/play-life-web/src/App.jsx index ceae6fa..e39b29d 100644 --- a/play-life-web/src/App.jsx +++ b/play-life-web/src/App.jsx @@ -14,6 +14,10 @@ import WishlistForm from './components/WishlistForm' import WishlistDetail from './components/WishlistDetail' import BoardForm from './components/BoardForm' import BoardJoinPreview from './components/BoardJoinPreview' +import ShoppingList from './components/ShoppingList' +import ShoppingItemForm from './components/ShoppingItemForm' +import ShoppingBoardForm from './components/ShoppingBoardForm' +import ShoppingBoardJoinPreview from './components/ShoppingBoardJoinPreview' import TodoistIntegration from './components/TodoistIntegration' import TelegramIntegration from './components/TelegramIntegration' import FitbitIntegration from './components/FitbitIntegration' @@ -29,8 +33,8 @@ const CURRENT_WEEK_API_URL = '/playlife-feed' const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b' // Определяем основные табы (без крестика) и глубокие табы (с крестиком) -const mainTabs = ['current', 'tasks', 'wishlist', 'profile'] -const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'fitbit-integration', 'full', 'priorities', 'tracking', 'tracking-access', 'tracking-invite'] +const mainTabs = ['current', 'tasks', 'wishlist', 'shopping', 'profile'] +const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'fitbit-integration', 'full', 'priorities', 'tracking', 'tracking-access', 'tracking-invite', 'shopping-item-form', 'shopping-board-form', 'shopping-board-join'] /** * Гарантирует базовую запись истории для главного экрана перед глубоким табом. @@ -77,8 +81,12 @@ function AppContent() { tracking: false, 'tracking-access': false, 'tracking-invite': false, + shopping: false, + 'shopping-item-form': false, + 'shopping-board-form': false, + 'shopping-board-join': false, }) - + // Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок) const [tabsInitialized, setTabsInitialized] = useState({ current: false, @@ -102,6 +110,10 @@ function AppContent() { tracking: false, 'tracking-access': false, 'tracking-invite': false, + shopping: false, + 'shopping-item-form': false, + 'shopping-board-form': false, + 'shopping-board-join': false, }) // Параметры для навигации между вкладками @@ -149,6 +161,7 @@ function AppContent() { const [dictionariesRefreshTrigger, setDictionariesRefreshTrigger] = useState(0) const [wordsRefreshTrigger, setWordsRefreshTrigger] = useState(0) const [wishlistRefreshTrigger, setWishlistRefreshTrigger] = useState(0) + const [shoppingRefreshTrigger, setShoppingRefreshTrigger] = useState(0) @@ -227,6 +240,20 @@ function AppContent() { } } + // Проверяем путь /shopping-invite/:token для присоединения к shopping доске + if (path.startsWith('/shopping-invite/')) { + const token = path.replace('/shopping-invite/', '') + if (token) { + const url = '/?tab=shopping-board-join&inviteToken=' + token + ensureBaseHistory('shopping-board-join', { inviteToken: token }, url) + setActiveTab('shopping-board-join') + setLoadedTabs(prev => ({ ...prev, 'shopping-board-join': true })) + setTabParams({ inviteToken: token }) + setIsInitialized(true) + return + } + } + // Проверяем путь /tracking/invite/:token if (path.startsWith('/tracking/invite/')) { const token = path.replace('/tracking/invite/', '') @@ -262,8 +289,8 @@ function AppContent() { // Проверяем URL только для глубоких табов const tabFromUrl = urlParams.get('tab') - const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'profile', 'todoist-integration', 'telegram-integration', 'fitbit-integration', 'tracking', 'tracking-access', 'tracking-invite'] - + const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'profile', 'todoist-integration', 'telegram-integration', 'fitbit-integration', 'tracking', 'tracking-access', 'tracking-invite', 'shopping', 'shopping-item-form', 'shopping-board-form', 'shopping-board-join'] + if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl) && window.history.length > 1) { // Восстанавливаем глубокий таб из URL только если есть история (не рестарт PWA) const params = {} @@ -754,7 +781,7 @@ function AppContent() { return } - const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration', 'tracking', 'tracking-access', 'tracking-invite'] + const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration', 'tracking', 'tracking-access', 'tracking-invite', 'shopping', 'shopping-item-form', 'shopping-board-form', 'shopping-board-join'] // Проверяем state текущей записи истории (куда мы вернулись) if (event.state && event.state.tab) { @@ -858,8 +885,8 @@ function AppContent() { setSelectedProject(null) setTabParams({}) updateUrl('full', {}, activeTab) - } else if (tab !== activeTab || tab === 'task-form' || tab === 'wishlist-form' || (tab === 'words' && Object.keys(params).length > 0)) { - // Для task-form и wishlist-form всегда обновляем параметры, даже если это тот же таб + } else if (tab !== activeTab || tab === 'task-form' || tab === 'wishlist-form' || tab === 'shopping-item-form' || (tab === 'words' && Object.keys(params).length > 0)) { + // Для task-form, wishlist-form и shopping-item-form всегда обновляем параметры, даже если это тот же таб markTabAsLoaded(tab) // Определяем, является ли текущий таб глубоким @@ -889,7 +916,7 @@ function AppContent() { // Проверяем, была ли последняя запись в истории от модального окна const currentState = window.history.state || {} const isFromModal = currentState.modalOpen === true - const isNavigatingToForm = tab === 'task-form' || tab === 'wishlist-form' + const isNavigatingToForm = tab === 'task-form' || tab === 'wishlist-form' || tab === 'shopping-item-form' if (isFromModal && isNavigatingToForm) { // Заменяем запись модального окна на запись формы редактирования @@ -945,7 +972,7 @@ function AppContent() { if ((tab === 'wishlist-form' || tab === 'wishlist-detail') && activeTab !== tab) { setPreviousTab(activeTab) } - + // Обновляем список желаний при возврате из экрана редактирования if (activeTab === 'wishlist-form' && tab !== 'wishlist-form') { // Сохраняем boardId из параметров или текущих tabParams @@ -958,13 +985,27 @@ function AppContent() { setWishlistRefreshTrigger(prev => prev + 1) } } - + // Обновляем список желаний при возврате из экрана детализации if (activeTab === 'wishlist-detail' && tab !== 'wishlist-detail') { if (tab === 'wishlist') { setWishlistRefreshTrigger(prev => prev + 1) } } + + // Сохраняем предыдущий таб при открытии shopping-item-form + if (tab === 'shopping-item-form' && activeTab !== tab) { + setPreviousTab(activeTab) + } + + // Обновляем список товаров при возврате из экрана редактирования + if ((activeTab === 'shopping-item-form' || activeTab === 'shopping-board-form') && tab === 'shopping') { + const savedBoardId = params.boardId || tabParams.boardId + if (savedBoardId) { + setTabParams(prev => ({ ...prev, boardId: savedBoardId })) + } + setShoppingRefreshTrigger(prev => prev + 1) + } // Загрузка данных произойдет в useEffect при изменении activeTab } } @@ -1063,7 +1104,7 @@ function AppContent() { } // Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов) - const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'fitbit-integration' || activeTab === 'full' || activeTab === 'priorities' || activeTab === 'words' || activeTab === 'dictionaries' || activeTab === 'tracking' || activeTab === 'tracking-access' || activeTab === 'tracking-invite' + const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'fitbit-integration' || activeTab === 'full' || activeTab === 'priorities' || activeTab === 'words' || activeTab === 'dictionaries' || activeTab === 'tracking' || activeTab === 'tracking-access' || activeTab === 'tracking-invite' || activeTab === 'shopping-item-form' || activeTab === 'shopping-board-form' || activeTab === 'shopping-board-join' // Функция для получения классов скролл-контейнера для каждого таба // Каждый таб имеет свой изолированный скролл-контейнер для автоматического сохранения позиции скролла @@ -1075,7 +1116,7 @@ function AppContent() { // Определяем padding для каждого таба let paddingClasses = '' - if (tabName === 'current' || tabName === 'tasks' || tabName === 'wishlist' || tabName === 'profile') { + if (tabName === 'current' || tabName === 'tasks' || tabName === 'wishlist' || tabName === 'shopping' || tabName === 'profile') { paddingClasses = 'pb-20' } else if (tabName === 'words' || tabName === 'dictionaries') { paddingClasses = 'pb-16' @@ -1086,7 +1127,7 @@ function AppContent() { // Функция для определения отступов внутреннего контейнера const getInnerContainerClasses = (tabName) => { - if (tabName === 'tasks' || tabName === 'wishlist' || tabName === 'profile') { + if (tabName === 'tasks' || tabName === 'wishlist' || tabName === 'shopping' || tabName === 'profile') { return 'max-w-7xl mx-auto p-4 md:p-8' } if (tabName === 'current') { @@ -1301,6 +1342,59 @@ function AppContent() { )} + {loadedTabs.shopping && ( +