Эх сурвалжийг харах

feat: статус deleted + полное удаление для модераторов

- Migration 000010: статус 'deleted' в CHECK places
- SoftDelete теперь SET status='deleted', deleted_at=now()
- HardDelete (DELETE каскад + очистка MinIO)
- UploadHandler.DeleteObjects для удаления файлов
- Админ-роут DELETE /admin/places/{id} для полного удаления
- Фронтенд: вкладка 'Удалённые' + кнопка 'Полностью удалить'
neyrogovnarik 1 сар өмнө
parent
commit
b7d0b8c4bc

+ 2 - 1
.opencode/skills/photoplaces-senior/SKILL.md

@@ -36,7 +36,8 @@ description: >
 4. Игнорируй устаревшие практики (классовые компоненты в React без нужды, callback hell, var, any в TS).
 5. Если видишь потенциальную проблему в ТЗ — предупреди вежливо, но конструктивно.
 6. Общение со мной и документация и комментарии в коде строго на русском языке.
-7. Делай push в мой репозиторий ветка main: http://192.168.88.96:10880/foxtime/photoplaces.git
+7. Делай push в репозиторий ветка main: http://192.168.88.96:10880/foxtime/photoplaces.git
+8. Делай деплой на тестовый сервер, доступ по ssh foxtime@192.168.88.129
 
 ## Obsidian — второй мозг
 

+ 9 - 6
backend/cmd/api/main.go

@@ -60,9 +60,16 @@ func main() {
 	tagRepo := repository.NewTagRepo(pool)
 	featureRepo := repository.NewFeatureRepo(pool)
 
+	// Хендлеры (сначала те, что нужны сервисам)
+	uploadHandler, err := handlers.NewUploadHandler(cfg.S3Endpoint, cfg.S3PublicEndpoint, cfg.S3AccessKey, cfg.S3SecretKey, cfg.S3Bucket, false)
+	if err != nil {
+		logger.ErrorContext(ctx, "upload handler init failed", log.WithError(err))
+		os.Exit(1)
+	}
+
 	// Сервисы
 	authSvc := services.NewAuthService(userRepo, refreshTokenRepo, cfg.JWTSecret, cfg.JWTRefreshSecret)
-	placeSvc := services.NewPlaceService(placeRepo)
+	placeSvc := services.NewPlaceService(placeRepo, uploadHandler)
 
 	// Set AppEnv for error handling (production hides internal errors)
 	handlers.AppEnv = cfg.AppEnv
@@ -94,11 +101,6 @@ func main() {
 	wsHub := handlers.NewWSHub(cfg.AllowedOrigins, cfg.AppEnv == "production", authSvc)
 	go wsHub.Run()
 	setupHandler := handlers.NewSetupHandler(userRepo, authSvc, cfg.AppEnv)
-	uploadHandler, err := handlers.NewUploadHandler(cfg.S3Endpoint, cfg.S3PublicEndpoint, cfg.S3AccessKey, cfg.S3SecretKey, cfg.S3Bucket, false)
-	if err != nil {
-		logger.ErrorContext(ctx, "upload handler init failed", log.WithError(err))
-		os.Exit(1)
-	}
 
 	// Redis для rate limiting
 	redisOpts, err := redis.ParseURL(cfg.RedisURL)
@@ -257,6 +259,7 @@ func main() {
 			r.Delete("/admin/tags", tagHandler.DeleteTag)
 			r.Post("/admin/features", tagHandler.CreateFeature)
 			r.Delete("/admin/features", tagHandler.DeleteFeature)
+			r.Delete("/admin/places/{id}", placeHandler.HardDelete)
 		})
 	})
 

+ 11 - 0
backend/internal/handlers/places.go

@@ -377,3 +377,14 @@ func (h *PlaceHandler) Delete(w http.ResponseWriter, r *http.Request) {
 
 	w.WriteHeader(http.StatusNoContent)
 }
+
+func (h *PlaceHandler) HardDelete(w http.ResponseWriter, r *http.Request) {
+	id := chi.URLParam(r, "id")
+
+	if err := h.placeSvc.HardDelete(r.Context(), id); err != nil {
+		writeError(w, http.StatusInternalServerError, "failed to hard-delete place", err)
+		return
+	}
+
+	w.WriteHeader(http.StatusNoContent)
+}

+ 16 - 0
backend/internal/handlers/upload.go

@@ -2,10 +2,12 @@
 package handlers
 
 import (
+	"context"
 	"encoding/json"
 	"fmt"
 	"net/http"
 	"net/url"
+	"strings"
 	"time"
 
 	"github.com/google/uuid"
@@ -158,3 +160,17 @@ func (h *UploadHandler) PresignedURL(w http.ResponseWriter, r *http.Request) {
 		Fields:    formData,
 	})
 }
+
+func (h *UploadHandler) DeleteObjects(ctx context.Context, urls []string) error {
+	prefix := h.publicEndpoint + "/" + h.bucket + "/"
+	for _, fileURL := range urls {
+		if !strings.HasPrefix(fileURL, prefix) {
+			continue
+		}
+		objectKey := strings.TrimPrefix(fileURL, prefix)
+		if err := h.minioClient.RemoveObject(ctx, h.bucket, objectKey, minio.RemoveObjectOptions{}); err != nil {
+			return fmt.Errorf("delete object %s: %w", objectKey, err)
+		}
+	}
+	return nil
+}

+ 42 - 2
backend/internal/repository/places.go

@@ -81,7 +81,11 @@ func (r *PlaceRepo) List(ctx context.Context, filter models.PlaceFilter) ([]*mod
 	if joins != "" {
 		q = "SELECT DISTINCT" + q[6:] // заменяем "SELECT" на "SELECT DISTINCT"
 	}
-	q += joins + ` WHERE p.deleted_at IS NULL`
+	if filter.Status != "deleted" {
+		q += ` WHERE p.deleted_at IS NULL`
+	} else {
+		q += ` WHERE p.deleted_at IS NOT NULL`
+	}
 
 	if filter.Status != "" { q += ` AND p.status = @status`; args["status"] = filter.Status }
 	if filter.Type != "" { q += ` AND p.type = @type`; args["type"] = filter.Type }
@@ -165,10 +169,46 @@ func (r *PlaceRepo) UpdateStatus(ctx context.Context, id, status, comment string
 
 func (r *PlaceRepo) SoftDelete(ctx context.Context, id string) error {
 	_, err := r.pool.Exec(ctx,
-		`UPDATE places SET deleted_at=now() WHERE id=$1`, id)
+		`UPDATE places SET status='deleted', deleted_at=now() WHERE id=$1 AND deleted_at IS NULL`, id)
 	return err
 }
 
+func (r *PlaceRepo) HardDelete(ctx context.Context, id string) error {
+	_, err := r.pool.Exec(ctx, `DELETE FROM places WHERE id=$1`, id)
+	return err
+}
+
+func (r *PlaceRepo) GetByIDRaw(ctx context.Context, id string) (*models.Place, error) {
+	row := r.pool.QueryRow(ctx,
+		`SELECT id, type, owner_id, title, description, address,
+		        ST_Y(coordinates::geometry), ST_X(coordinates::geometry),
+		        cover_image, access_info, status, moderation_comment,
+		        rating, reviews_count, hourly_rate, currency, min_hours, booking_url,
+		        created_at, updated_at, published_at, deleted_at
+		 FROM places WHERE id = $1`, id)
+	return scanPlace(row)
+}
+
+func (r *PlaceRepo) GetPlaceImages(ctx context.Context, placeID string) ([]models.PlaceImage, error) {
+	rows, err := r.pool.Query(ctx,
+		`SELECT id, place_id, url, alt, sort_order, is_cover FROM place_images WHERE place_id = $1`,
+		placeID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var images []models.PlaceImage
+	for rows.Next() {
+		var img models.PlaceImage
+		if err := rows.Scan(&img.ID, &img.PlaceID, &img.URL, &img.Alt, &img.SortOrder, &img.IsCover); err != nil {
+			return nil, err
+		}
+		images = append(images, img)
+	}
+	return images, nil
+}
+
 func (r *PlaceRepo) SetTags(ctx context.Context, placeID string, tags []models.Tag) error {
 	tx, err := r.pool.Begin(ctx)
 	if err != nil {

+ 42 - 3
backend/internal/services/places.go

@@ -38,18 +38,25 @@ func DecodeCursor(cursor string) (id string, createdAt time.Time, rating float64
 	return
 }
 
+type ObjectStorager interface {
+	DeleteObjects(ctx context.Context, urls []string) error
+}
+
 var (
-	ErrNotYourPlace = errors.New("not your place")
+	ErrNotYourPlace  = errors.New("not your place")
 	ErrPlaceNotFound = errors.New("place not found")
 )
 
 type PlaceRepo interface {
 	Create(ctx context.Context, place *models.Place) error
 	GetByID(ctx context.Context, id string) (*models.Place, error)
+	GetByIDRaw(ctx context.Context, id string) (*models.Place, error)
 	List(ctx context.Context, filter models.PlaceFilter) ([]*models.Place, error)
 	Update(ctx context.Context, place *models.Place) error
 	UpdateStatus(ctx context.Context, id, status, comment string) error
 	SoftDelete(ctx context.Context, id string) error
+	HardDelete(ctx context.Context, id string) error
+	GetPlaceImages(ctx context.Context, placeID string) ([]models.PlaceImage, error)
 	SetTags(ctx context.Context, placeID string, tags []models.Tag) error
 	SetFeatures(ctx context.Context, placeID string, features []models.Feature) error
 	GetTags(ctx context.Context, placeID string) ([]models.Tag, error)
@@ -60,10 +67,11 @@ type PlaceRepo interface {
 
 type PlaceService struct {
 	placeRepo PlaceRepo
+	storage   ObjectStorager
 }
 
-func NewPlaceService(placeRepo PlaceRepo) *PlaceService {
-	return &PlaceService{placeRepo: placeRepo}
+func NewPlaceService(placeRepo PlaceRepo, storage ObjectStorager) *PlaceService {
+	return &PlaceService{placeRepo: placeRepo, storage: storage}
 }
 
 type CreatePlaceInput struct {
@@ -277,3 +285,34 @@ func (s *PlaceService) Moderate(ctx context.Context, id, action, comment, modera
 func (s *PlaceService) Delete(ctx context.Context, id string) error {
 	return s.placeRepo.SoftDelete(ctx, id)
 }
+
+func (s *PlaceService) HardDelete(ctx context.Context, id string) error {
+	place, err := s.placeRepo.GetByIDRaw(ctx, id)
+	if err != nil {
+		return err
+	}
+	if place == nil {
+		return nil
+	}
+
+	var urls []string
+	if place.CoverImage != nil {
+		urls = append(urls, *place.CoverImage)
+	}
+
+	images, err := s.placeRepo.GetPlaceImages(ctx, id)
+	if err != nil {
+		return err
+	}
+	for _, img := range images {
+		urls = append(urls, img.URL)
+	}
+
+	if len(urls) > 0 {
+		if err := s.storage.DeleteObjects(ctx, urls); err != nil {
+			return fmt.Errorf("delete place files: %w", err)
+		}
+	}
+
+	return s.placeRepo.HardDelete(ctx, id)
+}

+ 15 - 3
backend/internal/services/places_test.go

@@ -55,6 +55,18 @@ func (m *mockPlaceRepo) SoftDelete(ctx context.Context, id string) error {
 	return nil
 }
 
+func (m *mockPlaceRepo) HardDelete(ctx context.Context, id string) error {
+	return nil
+}
+
+func (m *mockPlaceRepo) GetByIDRaw(ctx context.Context, id string) (*models.Place, error) {
+	return m.GetByID(ctx, id)
+}
+
+func (m *mockPlaceRepo) GetPlaceImages(ctx context.Context, placeID string) ([]models.PlaceImage, error) {
+	return nil, nil
+}
+
 func (m *mockPlaceRepo) GetTags(ctx context.Context, placeID string) ([]models.Tag, error) {
 	return nil, nil
 }
@@ -81,7 +93,7 @@ func (m *mockPlaceRepo) GetFeaturesBatch(ctx context.Context, placeIDs []string)
 
 func TestPlaceService_Create(t *testing.T) {
 	repo := &mockPlaceRepo{}
-	svc := NewPlaceService(repo)
+	svc := NewPlaceService(repo, nil)
 
 	input := CreatePlaceInput{
 		OwnerID: "owner-1",
@@ -104,7 +116,7 @@ func TestPlaceService_Create(t *testing.T) {
 
 func TestPlaceService_Update_NotOwner(t *testing.T) {
 	repo := &mockPlaceRepo{}
-	svc := NewPlaceService(repo)
+	svc := NewPlaceService(repo, nil)
 
 	repo.places = append(repo.places, &models.Place{
 		ID:      "place-1",
@@ -129,7 +141,7 @@ func TestPlaceService_Update_NotOwner(t *testing.T) {
 
 func TestPlaceService_Moderate_UnknownAction(t *testing.T) {
 	repo := &mockPlaceRepo{}
-	svc := NewPlaceService(repo)
+	svc := NewPlaceService(repo, nil)
 
 	err := svc.Moderate(context.Background(), "place-1", "burn", "", "mod-1")
 	if err == nil {

+ 1 - 1
backend/internal/validator/validator.go

@@ -95,7 +95,7 @@ func validateUserStatus(fl validator.FieldLevel) bool {
 func validatePlaceStatus(fl validator.FieldLevel) bool {
 	statuses := map[string]bool{
 		"draft": true, "pending_moderation": true, "published": true,
-		"rejected": true, "revision": true, "archived": true,
+		"rejected": true, "revision": true, "archived": true, "deleted": true,
 	}
 	return statuses[fl.Field().String()]
 }

+ 3 - 0
backend/migrations/000010_add_deleted_status.down.sql

@@ -0,0 +1,3 @@
+ALTER TABLE places DROP CONSTRAINT places_status_check;
+ALTER TABLE places ADD CONSTRAINT places_status_check
+  CHECK (status IN ('draft', 'pending_moderation', 'published', 'rejected', 'archived', 'revision'));

+ 3 - 0
backend/migrations/000010_add_deleted_status.up.sql

@@ -0,0 +1,3 @@
+ALTER TABLE places DROP CONSTRAINT places_status_check;
+ALTER TABLE places ADD CONSTRAINT places_status_check
+  CHECK (status IN ('draft', 'pending_moderation', 'published', 'rejected', 'archived', 'revision', 'deleted'));

+ 36 - 1
frontend/src/app/admin/page.tsx

@@ -4,13 +4,14 @@ import { useState, useEffect, useCallback } from 'react'
 import { api, ApiRequestError } from '@/lib/api'
 import type { Place } from '@/types'
 
-type FilterTab = 'pending_moderation' | 'published' | 'rejected' | 'revision'
+type FilterTab = 'pending_moderation' | 'published' | 'rejected' | 'revision' | 'deleted'
 
 const FILTERS: { key: FilterTab; label: string }[] = [
   { key: 'pending_moderation', label: 'Новые' },
   { key: 'published', label: 'Одобренные недавно' },
   { key: 'rejected', label: 'Отклонённые' },
   { key: 'revision', label: 'На доработке' },
+  { key: 'deleted', label: 'Удалённые' },
 ]
 
 function ReworkModal({ place, onClose, onConfirm }: { place: Place; onClose: () => void; onConfirm: (id: string, comment: string) => void }) {
@@ -48,6 +49,7 @@ export default function AdminModerationPage() {
   const [loading, setLoading] = useState(true)
   const [actionMsg, setActionMsg] = useState('')
   const [reworkPlace, setReworkPlace] = useState<Place | null>(null)
+  const [hardDeletePlace, setHardDeletePlace] = useState<Place | null>(null)
 
   const fetchPlaces = useCallback(async () => {
     setLoading(true)
@@ -80,6 +82,16 @@ export default function AdminModerationPage() {
     }
   }
 
+  const hardDelete = async (id: string) => {
+    try {
+      await api.delete(`/admin/places/${id}`)
+      setPlaces((prev) => prev.filter((p) => p.id !== id))
+      setActionMsg('Место полностью удалено')
+    } catch (err: any) {
+      setActionMsg(err.message || 'Ошибка при удалении')
+    }
+  }
+
   return (
     <div>
       <h1 className="mb-6 text-2xl font-bold text-white">Модерация</h1>
@@ -149,6 +161,11 @@ export default function AdminModerationPage() {
                     >На доработку</button>
                   </>
                 )}
+                {filter === 'deleted' && (
+                  <button onClick={() => setHardDeletePlace(place)}
+                    className="rounded-lg bg-red-700 px-4 py-2 text-sm text-white hover:bg-red-600 transition"
+                  >Полностью удалить</button>
+                )}
               </div>
             </div>
           ))}
@@ -162,6 +179,24 @@ export default function AdminModerationPage() {
           onConfirm={(id, comment) => moderate(id, 'rework', comment)}
         />
       )}
+
+      {hardDeletePlace && (
+        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setHardDeletePlace(null)}>
+          <div className="w-full max-w-sm rounded-xl bg-[#1e293b] p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
+            <h3 className="mb-2 text-lg font-bold text-white">Полное удаление</h3>
+            <p className="mb-2 text-sm text-white/60">{hardDeletePlace.title}</p>
+            <p className="mb-4 text-sm text-red-400">Место и все его файлы будут безвозвратно удалены.</p>
+            <div className="flex justify-end gap-3">
+              <button onClick={() => setHardDeletePlace(null)}
+                className="rounded-lg bg-white/10 px-4 py-2 text-sm text-white hover:bg-white/20 transition"
+              >Отмена</button>
+              <button onClick={() => { hardDelete(hardDeletePlace.id); setHardDeletePlace(null) }}
+                className="rounded-lg bg-red-700 px-4 py-2 text-sm text-white hover:bg-red-600 transition"
+              >Удалить навсегда</button>
+            </div>
+          </div>
+        </div>
+      )}
     </div>
   )
 }