6.23.0: Архивация досок желаний и товаров
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m26s

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
poignatov
2026-03-19 18:27:19 +03:00
parent f1c12fd81a
commit 101f4e27ed
15 changed files with 1381 additions and 447 deletions

View File

@@ -576,6 +576,7 @@ type WishlistBoard struct {
InviteURL *string `json:"invite_url,omitempty"`
MemberCount int `json:"member_count"`
IsOwner bool `json:"is_owner"`
IsArchived bool `json:"is_archived,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
@@ -4914,6 +4915,7 @@ func main() {
// Wishlist Boards (ВАЖНО: должны быть ПЕРЕД /api/wishlist/{id} чтобы избежать конфликта роутов!)
protected.HandleFunc("/api/wishlist/boards", app.getBoardsHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/wishlist/boards", app.createBoardHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/wishlist/boards/archived", app.getArchivedBoardsHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/wishlist/boards/{id}", app.getBoardHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/wishlist/boards/{id}", app.updateBoardHandler).Methods("PUT", "OPTIONS")
protected.HandleFunc("/api/wishlist/boards/{id}", app.deleteBoardHandler).Methods("DELETE", "OPTIONS")
@@ -4921,6 +4923,8 @@ func main() {
protected.HandleFunc("/api/wishlist/boards/{id}/members", app.getBoardMembersHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/wishlist/boards/{id}/members/{userId}", app.removeBoardMemberHandler).Methods("DELETE", "OPTIONS")
protected.HandleFunc("/api/wishlist/boards/{id}/leave", app.leaveBoardHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/wishlist/boards/{id}/archive", app.archiveBoardHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/wishlist/boards/{id}/unarchive", app.unarchiveBoardHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/wishlist/boards/{boardId}/items", app.getBoardItemsHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/wishlist/boards/{boardId}/items", app.createBoardItemHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/wishlist/boards/{boardId}/completed", app.getBoardCompletedHandler).Methods("GET", "OPTIONS")
@@ -4930,6 +4934,7 @@ func main() {
// 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/archived", app.getArchivedShoppingBoardsHandler).Methods("GET", "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")
@@ -4937,6 +4942,8 @@ func main() {
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/{id}/archive", app.archiveShoppingBoardHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/shopping/boards/{id}/unarchive", app.unarchiveShoppingBoardHandler).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")
@@ -16072,7 +16079,7 @@ func (a *App) getBoardsHandler(w http.ResponseWriter, r *http.Request) {
boards := []WishlistBoard{}
// Получаем свои доски + доски где пользователь участник
// Получаем свои доски + доски где пользователь участник (с флагом архивации)
rows, err := a.DB.Query(`
SELECT DISTINCT
wb.id,
@@ -16083,11 +16090,12 @@ func (a *App) getBoardsHandler(w http.ResponseWriter, r *http.Request) {
wb.invite_token,
wb.created_at,
(SELECT COUNT(*) FROM wishlist_board_members wbm WHERE wbm.board_id = wb.id) as member_count,
(wb.owner_id = $1) as is_owner
(wb.owner_id = $1) as is_owner,
EXISTS (SELECT 1 FROM board_archives ba WHERE ba.user_id = $1 AND ba.board_type = 'wishlist' AND ba.board_id = wb.id) as is_archived
FROM wishlist_boards wb
JOIN users u ON wb.owner_id = u.id
LEFT JOIN wishlist_board_members wbm ON wb.id = wbm.board_id
WHERE wb.deleted = FALSE
WHERE wb.deleted = FALSE
AND (wb.owner_id = $1 OR wbm.user_id = $1)
ORDER BY is_owner DESC, wb.created_at DESC
`, userID)
@@ -16113,6 +16121,7 @@ func (a *App) getBoardsHandler(w http.ResponseWriter, r *http.Request) {
&board.CreatedAt,
&board.MemberCount,
&board.IsOwner,
&board.IsArchived,
)
if err != nil {
log.Printf("Error scanning board: %v", err)
@@ -16264,6 +16273,11 @@ func (a *App) getBoardHandler(w http.ResponseWriter, r *http.Request) {
}
}
// Проверяем, архивирована ли доска для пользователя
var isArchived bool
a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM board_archives WHERE user_id = $1 AND board_type = 'wishlist' AND board_id = $2)`, userID, boardID).Scan(&isArchived)
board.IsArchived = isArchived
// Invite token и URL только для владельца
if board.IsOwner && inviteToken.Valid {
board.InviteToken = &inviteToken.String
@@ -16638,6 +16652,158 @@ func (a *App) leaveBoardHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// archiveBoardHandler архивирует доску желаний для текущего пользователя
func (a *App) archiveBoardHandler(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 wishlist_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 memberCount int
a.DB.QueryRow(`SELECT COUNT(*) FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2`, boardID, userID).Scan(&memberCount)
if memberCount == 0 {
sendErrorWithCORS(w, "Access denied", http.StatusForbidden)
return
}
}
_, err = a.DB.Exec(`
INSERT INTO board_archives (user_id, board_type, board_id)
VALUES ($1, 'wishlist', $2)
ON CONFLICT (user_id, board_type, board_id) DO NOTHING
`, userID, boardID)
if err != nil {
log.Printf("Error archiving board: %v", err)
sendErrorWithCORS(w, "Error archiving board", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// unarchiveBoardHandler разархивирует доску желаний для текущего пользователя
func (a *App) unarchiveBoardHandler(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
}
_, err = a.DB.Exec(`DELETE FROM board_archives WHERE user_id = $1 AND board_type = 'wishlist' AND board_id = $2`, userID, boardID)
if err != nil {
log.Printf("Error unarchiving board: %v", err)
sendErrorWithCORS(w, "Error unarchiving board", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// getArchivedBoardsHandler возвращает архивированные доски желаний пользователя
func (a *App) getArchivedBoardsHandler(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 := []WishlistBoard{}
rows, err := a.DB.Query(`
SELECT DISTINCT
wb.id,
wb.owner_id,
COALESCE(u.name, u.email) as owner_name,
wb.name,
wb.invite_enabled,
wb.invite_token,
wb.created_at,
(SELECT COUNT(*) FROM wishlist_board_members wbm WHERE wbm.board_id = wb.id) as member_count,
(wb.owner_id = $1) as is_owner
FROM wishlist_boards wb
JOIN users u ON wb.owner_id = u.id
LEFT JOIN wishlist_board_members wbm ON wb.id = wbm.board_id
JOIN board_archives ba ON ba.board_id = wb.id AND ba.board_type = 'wishlist' AND ba.user_id = $1
WHERE wb.deleted = FALSE
AND (wb.owner_id = $1 OR wbm.user_id = $1)
ORDER BY wb.created_at DESC
`, userID)
if err != nil {
log.Printf("Error getting archived boards: %v", err)
sendErrorWithCORS(w, "Error getting archived boards", http.StatusInternalServerError)
return
}
defer rows.Close()
for rows.Next() {
var board WishlistBoard
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 archived board: %v", err)
continue
}
boards = append(boards, board)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(boards)
}
// getBoardInviteInfoHandler возвращает информацию о доске по invite token
func (a *App) getBoardInviteInfoHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
@@ -19023,6 +19189,7 @@ type ShoppingBoard struct {
InviteURL *string `json:"invite_url,omitempty"`
MemberCount int `json:"member_count"`
IsOwner bool `json:"is_owner"`
IsArchived bool `json:"is_archived,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
@@ -19097,7 +19264,8 @@ func (a *App) getShoppingBoardsHandler(w http.ResponseWriter, r *http.Request) {
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
(sb.owner_id = $1) as is_owner,
EXISTS (SELECT 1 FROM board_archives ba WHERE ba.user_id = $1 AND ba.board_type = 'shopping' AND ba.board_id = sb.id) as is_archived
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
@@ -19127,6 +19295,7 @@ func (a *App) getShoppingBoardsHandler(w http.ResponseWriter, r *http.Request) {
&board.CreatedAt,
&board.MemberCount,
&board.IsOwner,
&board.IsArchived,
)
if err != nil {
log.Printf("Error scanning shopping board: %v", err)
@@ -19275,6 +19444,11 @@ func (a *App) getShoppingBoardHandler(w http.ResponseWriter, r *http.Request) {
}
}
// Проверяем, архивирована ли доска для пользователя
var isShoppingArchived bool
a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM board_archives WHERE user_id = $1 AND board_type = 'shopping' AND board_id = $2)`, userID, boardID).Scan(&isShoppingArchived)
board.IsArchived = isShoppingArchived
if board.IsOwner && inviteToken.Valid {
board.InviteToken = &inviteToken.String
baseURL := getEnv("WEBHOOK_BASE_URL", "")
@@ -19637,6 +19811,157 @@ func (a *App) leaveShoppingBoardHandler(w http.ResponseWriter, r *http.Request)
w.WriteHeader(http.StatusNoContent)
}
// archiveShoppingBoardHandler архивирует доску покупок для текущего пользователя
func (a *App) archiveShoppingBoardHandler(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 {
var memberCount int
a.DB.QueryRow(`SELECT COUNT(*) FROM shopping_board_members WHERE board_id = $1 AND user_id = $2`, boardID, userID).Scan(&memberCount)
if memberCount == 0 {
sendErrorWithCORS(w, "Access denied", http.StatusForbidden)
return
}
}
_, err = a.DB.Exec(`
INSERT INTO board_archives (user_id, board_type, board_id)
VALUES ($1, 'shopping', $2)
ON CONFLICT (user_id, board_type, board_id) DO NOTHING
`, userID, boardID)
if err != nil {
log.Printf("Error archiving shopping board: %v", err)
sendErrorWithCORS(w, "Error archiving board", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// unarchiveShoppingBoardHandler разархивирует доску покупок для текущего пользователя
func (a *App) unarchiveShoppingBoardHandler(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
}
_, err = a.DB.Exec(`DELETE FROM board_archives WHERE user_id = $1 AND board_type = 'shopping' AND board_id = $2`, userID, boardID)
if err != nil {
log.Printf("Error unarchiving shopping board: %v", err)
sendErrorWithCORS(w, "Error unarchiving board", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// getArchivedShoppingBoardsHandler возвращает архивированные доски покупок пользователя
func (a *App) getArchivedShoppingBoardsHandler(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
JOIN board_archives ba ON ba.board_id = sb.id AND ba.board_type = 'shopping' AND ba.user_id = $1
WHERE sb.deleted = FALSE
AND (sb.owner_id = $1 OR sbm.user_id = $1)
ORDER BY sb.created_at DESC
`, userID)
if err != nil {
log.Printf("Error getting archived shopping boards: %v", err)
sendErrorWithCORS(w, "Error getting archived boards", http.StatusInternalServerError)
return
}
defer rows.Close()
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 archived shopping board: %v", err)
continue
}
boards = append(boards, board)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(boards)
}
// getShoppingBoardInviteInfoHandler возвращает информацию о доске покупок по invite token
func (a *App) getShoppingBoardInviteInfoHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {