Explorar o código

feat: restore endpoint + кнопка восстановления в админке

- PlaceRepo.Restore — UPDATE status='published', deleted_at=NULL
- PlaceService.Restore — ModerationLog + вызов репозитория
- PlaceHandler.Restore — POST /places/{id}/restore
- Роут в moderator group в main.go
- Кнопка 'Восстановить' во вкладке 'Удалённые' админки
neyrogovnarik hai 1 mes
pai
achega
32d73d5eb0

+ 1 - 0
backend/cmd/api/main.go

@@ -253,6 +253,7 @@ func main() {
 				r.Use(adminLimiter.Middleware())
 				r.Use(adminLimiter.Middleware())
 			}
 			}
 			r.Post("/places/{id}/moderate", placeHandler.Moderate)
 			r.Post("/places/{id}/moderate", placeHandler.Moderate)
+			r.Post("/places/{id}/restore", placeHandler.Restore)
 			r.Get("/admin/users", userHandler.AdminListUsers)
 			r.Get("/admin/users", userHandler.AdminListUsers)
 			r.Post("/admin/users", userHandler.AdminCreateUser)
 			r.Post("/admin/users", userHandler.AdminCreateUser)
 			r.Patch("/admin/users/{id}", userHandler.AdminUpdateUser)
 			r.Patch("/admin/users/{id}", userHandler.AdminUpdateUser)

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

@@ -394,3 +394,15 @@ func (h *PlaceHandler) HardDelete(w http.ResponseWriter, r *http.Request) {
 
 
 	w.WriteHeader(http.StatusNoContent)
 	w.WriteHeader(http.StatusNoContent)
 }
 }
+
+func (h *PlaceHandler) Restore(w http.ResponseWriter, r *http.Request) {
+	id := chi.URLParam(r, "id")
+	userID := middleware.GetUserID(r.Context())
+
+	if err := h.placeSvc.Restore(r.Context(), id, userID); err != nil {
+		writeError(w, http.StatusInternalServerError, "failed to restore place", err)
+		return
+	}
+
+	writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
+}

+ 7 - 0
backend/internal/repository/places.go

@@ -178,6 +178,13 @@ func (r *PlaceRepo) HardDelete(ctx context.Context, id string) error {
 	return err
 	return err
 }
 }
 
 
+func (r *PlaceRepo) Restore(ctx context.Context, id string) error {
+	_, err := r.pool.Exec(ctx,
+		`UPDATE places SET status='published', deleted_at=NULL, published_at=now(), updated_at=now()
+		 WHERE id=$1 AND deleted_at IS NOT NULL`, id)
+	return err
+}
+
 func (r *PlaceRepo) GetByIDRaw(ctx context.Context, id string) (*models.Place, error) {
 func (r *PlaceRepo) GetByIDRaw(ctx context.Context, id string) (*models.Place, error) {
 	row := r.pool.QueryRow(ctx,
 	row := r.pool.QueryRow(ctx,
 		`SELECT id, type, owner_id, title, description, address,
 		`SELECT id, type, owner_id, title, description, address,

+ 25 - 0
backend/internal/services/places.go

@@ -61,6 +61,7 @@ type PlaceRepo interface {
 	UpdateStatus(ctx context.Context, id, status, comment string) error
 	UpdateStatus(ctx context.Context, id, status, comment string) error
 	SoftDelete(ctx context.Context, id string) error
 	SoftDelete(ctx context.Context, id string) error
 	HardDelete(ctx context.Context, id string) error
 	HardDelete(ctx context.Context, id string) error
+	Restore(ctx context.Context, id string) error
 	GetPlaceImages(ctx context.Context, placeID string) ([]models.PlaceImage, error)
 	GetPlaceImages(ctx context.Context, placeID string) ([]models.PlaceImage, error)
 	SetTags(ctx context.Context, placeID string, tags []models.Tag) error
 	SetTags(ctx context.Context, placeID string, tags []models.Tag) error
 	SetFeatures(ctx context.Context, placeID string, features []models.Feature) error
 	SetFeatures(ctx context.Context, placeID string, features []models.Feature) error
@@ -376,3 +377,27 @@ func (s *PlaceService) HardDelete(ctx context.Context, id, moderatorID string) e
 
 
 	return s.placeRepo.HardDelete(ctx, id)
 	return s.placeRepo.HardDelete(ctx, id)
 }
 }
+
+func (s *PlaceService) Restore(ctx context.Context, id, moderatorID string) error {
+	place, err := s.placeRepo.GetByIDRaw(ctx, id)
+	if err != nil {
+		return err
+	}
+	if place == nil {
+		return nil
+	}
+
+	entry := &models.ModerationLog{
+		ModeratorID: moderatorID,
+		TargetType:  "place",
+		TargetID:    id,
+		Action:      "restore",
+		OldStatus:   &place.Status,
+		NewStatus:   pointer.Str("published"),
+	}
+	if err := s.moderationLogRepo.Create(ctx, entry); err != nil {
+		return fmt.Errorf("log restore: %w", err)
+	}
+
+	return s.placeRepo.Restore(ctx, id)
+}

+ 18 - 3
frontend/src/app/admin/page.tsx

@@ -92,6 +92,16 @@ export default function AdminModerationPage() {
     }
     }
   }
   }
 
 
+  const restore = async (id: string) => {
+    try {
+      await api.post(`/places/${id}/restore`)
+      setPlaces((prev) => prev.filter((p) => p.id !== id))
+      setActionMsg('Место восстановлено')
+    } catch (err: any) {
+      setActionMsg(err.message || 'Ошибка при восстановлении')
+    }
+  }
+
   return (
   return (
     <div>
     <div>
       <h1 className="mb-6 text-2xl font-bold text-white">Модерация</h1>
       <h1 className="mb-6 text-2xl font-bold text-white">Модерация</h1>
@@ -162,9 +172,14 @@ export default function AdminModerationPage() {
                   </>
                   </>
                 )}
                 )}
                 {filter === 'deleted' && (
                 {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>
+                  <>
+                    <button onClick={() => restore(place.id)}
+                      className="rounded-lg bg-green-700 px-4 py-2 text-sm text-white hover:bg-green-600 transition"
+                    >Восстановить</button>
+                    <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>
             </div>
             </div>