Parcourir la source

feat: go-модели ModerationLog, Payment, Subscription, VisitorSession + миграция 000011

- Модели: moderation.go, payment.go, subscription.go, visitor_session.go
- Репозитории: moderation.go, payments.go, subscriptions.go, visitor_sessions.go
- ModerationLog интегрирован в PlaceService (SoftDelete, HardDelete, Moderate)
- Миграция 000011: old_status, new_status в moderation_log + расширен CHECK action
- Обновлён main.go, handlers/places.go для передачи userID в Delete/HardDelete
neyrogovnarik il y a 1 mois
Parent
commit
52c3c22f85

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

@@ -60,6 +60,7 @@ func main() {
 	bookingRepo := repository.NewBookingRepo(pool)
 	tagRepo := repository.NewTagRepo(pool)
 	featureRepo := repository.NewFeatureRepo(pool)
+	moderationLogRepo := repository.NewModerationLogRepo(pool)
 
 	// Хендлеры (сначала те, что нужны сервисам)
 	uploadHandler, err := handlers.NewUploadHandler(cfg.S3Endpoint, cfg.S3PublicEndpoint, cfg.S3AccessKey, cfg.S3SecretKey, cfg.S3Bucket, strings.HasPrefix(cfg.S3Endpoint, "https://"))
@@ -70,7 +71,7 @@ func main() {
 
 	// Сервисы
 	authSvc := services.NewAuthService(userRepo, refreshTokenRepo, cfg.JWTSecret, cfg.JWTRefreshSecret)
-	placeSvc := services.NewPlaceService(placeRepo, uploadHandler)
+	placeSvc := services.NewPlaceService(placeRepo, uploadHandler, moderationLogRepo)
 
 	// Set AppEnv for error handling (production hides internal errors)
 	handlers.AppEnv = cfg.AppEnv

+ 3 - 2
backend/internal/handlers/places.go

@@ -375,7 +375,7 @@ func (h *PlaceHandler) Delete(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	if err := h.placeSvc.Delete(r.Context(), id); err != nil {
+	if err := h.placeSvc.Delete(r.Context(), id, userID); err != nil {
 		writeError(w, http.StatusInternalServerError, "failed to delete place", err)
 		return
 	}
@@ -385,8 +385,9 @@ func (h *PlaceHandler) Delete(w http.ResponseWriter, r *http.Request) {
 
 func (h *PlaceHandler) HardDelete(w http.ResponseWriter, r *http.Request) {
 	id := chi.URLParam(r, "id")
+	userID := middleware.GetUserID(r.Context())
 
-	if err := h.placeSvc.HardDelete(r.Context(), id); err != nil {
+	if err := h.placeSvc.HardDelete(r.Context(), id, userID); err != nil {
 		writeError(w, http.StatusInternalServerError, "failed to hard-delete place", err)
 		return
 	}

+ 15 - 0
backend/internal/models/moderation.go

@@ -0,0 +1,15 @@
+package models
+
+import "time"
+
+type ModerationLog struct {
+	ID          string    `json:"id"`
+	ModeratorID string    `json:"moderator_id"`
+	TargetType  string    `json:"target_type"`
+	TargetID    string    `json:"target_id"`
+	Action      string    `json:"action"`
+	OldStatus   *string   `json:"old_status,omitempty"`
+	NewStatus   *string   `json:"new_status,omitempty"`
+	Comment     *string   `json:"comment,omitempty"`
+	CreatedAt   time.Time `json:"created_at"`
+}

+ 17 - 0
backend/internal/models/payment.go

@@ -0,0 +1,17 @@
+package models
+
+import "time"
+
+type Payment struct {
+	ID              string    `json:"id"`
+	UserID          string    `json:"user_id"`
+	Type            string    `json:"type"`
+	Provider        string    `json:"provider"`
+	ProviderPayment *string   `json:"provider_payment_id,omitempty"`
+	Amount          int       `json:"amount"`
+	Currency        string    `json:"currency"`
+	Status          string    `json:"status"`
+	Metadata        *string   `json:"metadata,omitempty"`
+	CreatedAt       time.Time `json:"created_at"`
+	UpdatedAt       time.Time `json:"updated_at"`
+}

+ 15 - 0
backend/internal/models/subscription.go

@@ -0,0 +1,15 @@
+package models
+
+import "time"
+
+type Subscription struct {
+	ID              string    `json:"id"`
+	UserID          string    `json:"user_id"`
+	Plan            string    `json:"plan"`
+	Status          string    `json:"status"`
+	PeriodStart     *time.Time `json:"current_period_start,omitempty"`
+	PeriodEnd       *time.Time `json:"current_period_end,omitempty"`
+	ProviderSubID   *string   `json:"provider_subscription_id,omitempty"`
+	CreatedAt       time.Time `json:"created_at"`
+	UpdatedAt       time.Time `json:"updated_at"`
+}

+ 12 - 0
backend/internal/models/visitor_session.go

@@ -0,0 +1,12 @@
+package models
+
+import "time"
+
+type VisitorSession struct {
+	ID          string    `json:"id"`
+	UserID      *string   `json:"user_id,omitempty"`
+	Lat         float64   `json:"lat"`
+	Lng         float64   `json:"lng"`
+	LastActiveAt time.Time `json:"last_active_at"`
+	CreatedAt   time.Time `json:"created_at"`
+}

+ 55 - 0
backend/internal/repository/moderation.go

@@ -0,0 +1,55 @@
+package repository
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/jackc/pgx/v5/pgxpool"
+	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/models"
+)
+
+type ModerationLogRepo struct {
+	pool *pgxpool.Pool
+}
+
+func NewModerationLogRepo(pool *pgxpool.Pool) *ModerationLogRepo {
+	return &ModerationLogRepo{pool: pool}
+}
+
+func (r *ModerationLogRepo) Create(ctx context.Context, entry *models.ModerationLog) error {
+	err := r.pool.QueryRow(ctx,
+		`INSERT INTO moderation_log (moderator_id, target_type, target_id, action, old_status, new_status, comment)
+		 VALUES ($1, $2, $3, $4, $5, $6, $7)
+		 RETURNING id, created_at`,
+		entry.ModeratorID, entry.TargetType, entry.TargetID,
+		entry.Action, entry.OldStatus, entry.NewStatus, entry.Comment,
+	).Scan(&entry.ID, &entry.CreatedAt)
+	if err != nil {
+		return fmt.Errorf("create moderation log: %w", err)
+	}
+	return nil
+}
+
+func (r *ModerationLogRepo) ListByTarget(ctx context.Context, targetType, targetID string) ([]*models.ModerationLog, error) {
+	rows, err := r.pool.Query(ctx,
+		`SELECT id, moderator_id, target_type, target_id, action,
+		        old_status, new_status, comment, created_at
+		 FROM moderation_log
+		 WHERE target_type = $1 AND target_id = $2
+		 ORDER BY created_at DESC`, targetType, targetID)
+	if err != nil {
+		return nil, fmt.Errorf("list moderation log: %w", err)
+	}
+	defer rows.Close()
+
+	var entries []*models.ModerationLog
+	for rows.Next() {
+		var e models.ModerationLog
+		if err := rows.Scan(&e.ID, &e.ModeratorID, &e.TargetType, &e.TargetID, &e.Action,
+			&e.OldStatus, &e.NewStatus, &e.Comment, &e.CreatedAt); err != nil {
+			return nil, err
+		}
+		entries = append(entries, &e)
+	}
+	return entries, nil
+}

+ 80 - 0
backend/internal/repository/payments.go

@@ -0,0 +1,80 @@
+package repository
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/jackc/pgx/v5"
+	"github.com/jackc/pgx/v5/pgxpool"
+	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/models"
+)
+
+type PaymentRepo struct {
+	pool *pgxpool.Pool
+}
+
+func NewPaymentRepo(pool *pgxpool.Pool) *PaymentRepo {
+	return &PaymentRepo{pool: pool}
+}
+
+func (r *PaymentRepo) Create(ctx context.Context, p *models.Payment) error {
+	err := r.pool.QueryRow(ctx,
+		`INSERT INTO payments (user_id, type, provider, provider_payment_id, amount, currency, status, metadata)
+		 VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+		 RETURNING id, created_at, updated_at`,
+		p.UserID, p.Type, p.Provider, p.ProviderPayment,
+		p.Amount, p.Currency, p.Status, p.Metadata,
+	).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt)
+	if err != nil {
+		return fmt.Errorf("create payment: %w", err)
+	}
+	return nil
+}
+
+func (r *PaymentRepo) GetByID(ctx context.Context, id string) (*models.Payment, error) {
+	row := r.pool.QueryRow(ctx,
+		`SELECT id, user_id, type, provider, provider_payment_id, amount, currency, status,
+		        metadata, created_at, updated_at
+		 FROM payments WHERE id = $1`, id)
+	return scanPayment(row)
+}
+
+func (r *PaymentRepo) ListByUser(ctx context.Context, userID string) ([]*models.Payment, error) {
+	rows, err := r.pool.Query(ctx,
+		`SELECT id, user_id, type, provider, provider_payment_id, amount, currency, status,
+		        metadata, created_at, updated_at
+		 FROM payments WHERE user_id = $1 ORDER BY created_at DESC`, userID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var payments []*models.Payment
+	for rows.Next() {
+		p, err := scanPayment(rows)
+		if err != nil {
+			return nil, err
+		}
+		payments = append(payments, p)
+	}
+	return payments, nil
+}
+
+func (r *PaymentRepo) UpdateStatus(ctx context.Context, id, status string) error {
+	_, err := r.pool.Exec(ctx,
+		`UPDATE payments SET status=$1, updated_at=now() WHERE id=$2`, status, id)
+	return err
+}
+
+func scanPayment(row interface{ Scan(dest ...any) error }) (*models.Payment, error) {
+	var p models.Payment
+	err := row.Scan(&p.ID, &p.UserID, &p.Type, &p.Provider, &p.ProviderPayment,
+		&p.Amount, &p.Currency, &p.Status, &p.Metadata, &p.CreatedAt, &p.UpdatedAt)
+	if err != nil {
+		if err == pgx.ErrNoRows {
+			return nil, nil
+		}
+		return nil, err
+	}
+	return &p, nil
+}

+ 95 - 0
backend/internal/repository/subscriptions.go

@@ -0,0 +1,95 @@
+package repository
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/jackc/pgx/v5"
+	"github.com/jackc/pgx/v5/pgxpool"
+	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/models"
+)
+
+type SubscriptionRepo struct {
+	pool *pgxpool.Pool
+}
+
+func NewSubscriptionRepo(pool *pgxpool.Pool) *SubscriptionRepo {
+	return &SubscriptionRepo{pool: pool}
+}
+
+func (r *SubscriptionRepo) Create(ctx context.Context, s *models.Subscription) error {
+	err := r.pool.QueryRow(ctx,
+		`INSERT INTO subscriptions (user_id, plan, status, current_period_start, current_period_end, provider_subscription_id)
+		 VALUES ($1, $2, $3, $4, $5, $6)
+		 RETURNING id, created_at, updated_at`,
+		s.UserID, s.Plan, s.Status, s.PeriodStart, s.PeriodEnd, s.ProviderSubID,
+	).Scan(&s.ID, &s.CreatedAt, &s.UpdatedAt)
+	if err != nil {
+		return fmt.Errorf("create subscription: %w", err)
+	}
+	return nil
+}
+
+func (r *SubscriptionRepo) GetByID(ctx context.Context, id string) (*models.Subscription, error) {
+	row := r.pool.QueryRow(ctx,
+		`SELECT id, user_id, plan, status, current_period_start, current_period_end,
+		        provider_subscription_id, created_at, updated_at
+		 FROM subscriptions WHERE id = $1`, id)
+	return scanSubscription(row)
+}
+
+func (r *SubscriptionRepo) GetActiveByUser(ctx context.Context, userID string) (*models.Subscription, error) {
+	row := r.pool.QueryRow(ctx,
+		`SELECT id, user_id, plan, status, current_period_start, current_period_end,
+		        provider_subscription_id, created_at, updated_at
+		 FROM subscriptions
+		 WHERE user_id = $1 AND status = 'active'
+		 ORDER BY created_at DESC LIMIT 1`, userID)
+	return scanSubscription(row)
+}
+
+func (r *SubscriptionRepo) ListByUser(ctx context.Context, userID string) ([]*models.Subscription, error) {
+	rows, err := r.pool.Query(ctx,
+		`SELECT id, user_id, plan, status, current_period_start, current_period_end,
+		        provider_subscription_id, created_at, updated_at
+		 FROM subscriptions WHERE user_id = $1 ORDER BY created_at DESC`, userID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var subs []*models.Subscription
+	for rows.Next() {
+		s, err := scanSubscription(rows)
+		if err != nil {
+			return nil, err
+		}
+		subs = append(subs, s)
+	}
+	return subs, nil
+}
+
+func (r *SubscriptionRepo) UpdateStatus(ctx context.Context, id, status string) error {
+	_, err := r.pool.Exec(ctx,
+		`UPDATE subscriptions SET status=$1, updated_at=now() WHERE id=$2`, status, id)
+	return err
+}
+
+func (r *SubscriptionRepo) Cancel(ctx context.Context, id string) error {
+	_, err := r.pool.Exec(ctx,
+		`UPDATE subscriptions SET status='cancelled', updated_at=now() WHERE id=$1 AND status='active'`, id)
+	return err
+}
+
+func scanSubscription(row interface{ Scan(dest ...any) error }) (*models.Subscription, error) {
+	var s models.Subscription
+	err := row.Scan(&s.ID, &s.UserID, &s.Plan, &s.Status, &s.PeriodStart,
+		&s.PeriodEnd, &s.ProviderSubID, &s.CreatedAt, &s.UpdatedAt)
+	if err != nil {
+		if err == pgx.ErrNoRows {
+			return nil, nil
+		}
+		return nil, err
+	}
+	return &s, nil
+}

+ 70 - 0
backend/internal/repository/visitor_sessions.go

@@ -0,0 +1,70 @@
+package repository
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"github.com/jackc/pgx/v5"
+	"github.com/jackc/pgx/v5/pgxpool"
+	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/models"
+)
+
+type VisitorSessionRepo struct {
+	pool *pgxpool.Pool
+}
+
+func NewVisitorSessionRepo(pool *pgxpool.Pool) *VisitorSessionRepo {
+	return &VisitorSessionRepo{pool: pool}
+}
+
+func (r *VisitorSessionRepo) Create(ctx context.Context, vs *models.VisitorSession) error {
+	err := r.pool.QueryRow(ctx,
+		`INSERT INTO visitor_sessions (user_id, coordinates, last_active_at)
+		 VALUES ($1, ST_SetSRID(ST_MakePoint($2, $3), 4326), $4)
+		 RETURNING id, created_at`,
+		vs.UserID, vs.Lng, vs.Lat, vs.LastActiveAt,
+	).Scan(&vs.ID, &vs.CreatedAt)
+	if err != nil {
+		return fmt.Errorf("create visitor session: %w", err)
+	}
+	return nil
+}
+
+func (r *VisitorSessionRepo) GetByID(ctx context.Context, id string) (*models.VisitorSession, error) {
+	row := r.pool.QueryRow(ctx,
+		`SELECT id, user_id, ST_Y(coordinates::geometry) AS lat, ST_X(coordinates::geometry) AS lng,
+		        last_active_at, created_at
+		 FROM visitor_sessions WHERE id = $1`, id)
+	return scanVisitorSession(row)
+}
+
+func (r *VisitorSessionRepo) UpdateLastActive(ctx context.Context, id string) error {
+	_, err := r.pool.Exec(ctx,
+		`UPDATE visitor_sessions SET last_active_at=$1 WHERE id=$2`,
+		time.Now(), id)
+	return err
+}
+
+func (r *VisitorSessionRepo) CountActive(ctx context.Context, since time.Duration) (int, error) {
+	var count int
+	err := r.pool.QueryRow(ctx,
+		`SELECT COUNT(*) FROM visitor_sessions WHERE last_active_at > $1`,
+		time.Now().Add(-since)).Scan(&count)
+	if err != nil {
+		return 0, fmt.Errorf("count active visitors: %w", err)
+	}
+	return count, nil
+}
+
+func scanVisitorSession(row interface{ Scan(dest ...any) error}) (*models.VisitorSession, error) {
+	var vs models.VisitorSession
+	err := row.Scan(&vs.ID, &vs.UserID, &vs.Lat, &vs.Lng, &vs.LastActiveAt, &vs.CreatedAt)
+	if err != nil {
+		if err == pgx.ErrNoRows {
+			return nil, nil
+		}
+		return nil, err
+	}
+	return &vs, nil
+}

+ 68 - 7
backend/internal/services/places.go

@@ -10,6 +10,7 @@ import (
 	"time"
 
 	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/models"
+	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/pointer"
 )
 
 func encodeCursor(p *models.Place) string {
@@ -43,9 +44,14 @@ type ObjectStorager interface {
 }
 
 var (
-	ErrNotYourPlace  = errors.New("not your place")
+	ErrNotYourPlace = errors.New("not your place")
 )
 
+type ModerationLogRepo interface {
+	Create(ctx context.Context, entry *models.ModerationLog) error
+	ListByTarget(ctx context.Context, targetType, targetID string) ([]*models.ModerationLog, error)
+}
+
 type PlaceRepo interface {
 	Create(ctx context.Context, place *models.Place) error
 	GetByID(ctx context.Context, id string) (*models.Place, error)
@@ -65,12 +71,13 @@ type PlaceRepo interface {
 }
 
 type PlaceService struct {
-	placeRepo PlaceRepo
-	storage   ObjectStorager
+	placeRepo         PlaceRepo
+	storage           ObjectStorager
+	moderationLogRepo ModerationLogRepo
 }
 
-func NewPlaceService(placeRepo PlaceRepo, storage ObjectStorager) *PlaceService {
-	return &PlaceService{placeRepo: placeRepo, storage: storage}
+func NewPlaceService(placeRepo PlaceRepo, storage ObjectStorager, moderationLogRepo ModerationLogRepo) *PlaceService {
+	return &PlaceService{placeRepo: placeRepo, storage: storage, moderationLogRepo: moderationLogRepo}
 }
 
 type CreatePlaceInput struct {
@@ -264,6 +271,14 @@ func (s *PlaceService) Update(ctx context.Context, input UpdatePlaceInput, isMod
 }
 
 func (s *PlaceService) Moderate(ctx context.Context, id, action, comment, moderatorID string) error {
+	place, err := s.placeRepo.GetByIDRaw(ctx, id)
+	if err != nil {
+		return err
+	}
+	if place == nil {
+		return nil
+	}
+
 	var status string
 	switch action {
 	case "approve":
@@ -278,14 +293,49 @@ func (s *PlaceService) Moderate(ctx context.Context, id, action, comment, modera
 		return fmt.Errorf("unknown action: %s", action)
 	}
 
+	entry := &models.ModerationLog{
+		ModeratorID: moderatorID,
+		TargetType:  "place",
+		TargetID:    id,
+		Action:      action,
+		OldStatus:   &place.Status,
+		NewStatus:   &status,
+	}
+	if comment != "" {
+		entry.Comment = &comment
+	}
+	if err := s.moderationLogRepo.Create(ctx, entry); err != nil {
+		return fmt.Errorf("log moderation: %w", err)
+	}
+
 	return s.placeRepo.UpdateStatus(ctx, id, status, comment)
 }
 
-func (s *PlaceService) Delete(ctx context.Context, id string) error {
+func (s *PlaceService) Delete(ctx context.Context, id, userID string) error {
+	place, err := s.placeRepo.GetByIDRaw(ctx, id)
+	if err != nil {
+		return err
+	}
+	if place == nil {
+		return nil
+	}
+
+	entry := &models.ModerationLog{
+		ModeratorID: userID,
+		TargetType:  "place",
+		TargetID:    id,
+		Action:      "soft_delete",
+		OldStatus:   &place.Status,
+		NewStatus:   pointer.Str("deleted"),
+	}
+	if err := s.moderationLogRepo.Create(ctx, entry); err != nil {
+		return fmt.Errorf("log soft delete: %w", err)
+	}
+
 	return s.placeRepo.SoftDelete(ctx, id)
 }
 
-func (s *PlaceService) HardDelete(ctx context.Context, id string) error {
+func (s *PlaceService) HardDelete(ctx context.Context, id, moderatorID string) error {
 	place, err := s.placeRepo.GetByIDRaw(ctx, id)
 	if err != nil {
 		return err
@@ -313,5 +363,16 @@ func (s *PlaceService) HardDelete(ctx context.Context, id string) error {
 		}
 	}
 
+	entry := &models.ModerationLog{
+		ModeratorID: moderatorID,
+		TargetType:  "place",
+		TargetID:    id,
+		Action:      "hard_delete",
+		OldStatus:   &place.Status,
+	}
+	if err := s.moderationLogRepo.Create(ctx, entry); err != nil {
+		return fmt.Errorf("log hard delete: %w", err)
+	}
+
 	return s.placeRepo.HardDelete(ctx, id)
 }

+ 10 - 0
backend/migrations/000011_add_moderation_log_columns.down.sql

@@ -0,0 +1,10 @@
+ALTER TABLE moderation_log
+    DROP CONSTRAINT IF EXISTS moderation_log_action_check;
+
+ALTER TABLE moderation_log
+    ADD CONSTRAINT moderation_log_action_check
+    CHECK (action IN ('approve', 'reject', 'request_changes', 'publish', 'archive'));
+
+ALTER TABLE moderation_log
+    DROP COLUMN IF EXISTS old_status,
+    DROP COLUMN IF EXISTS new_status;

+ 11 - 0
backend/migrations/000011_add_moderation_log_columns.up.sql

@@ -0,0 +1,11 @@
+ALTER TABLE moderation_log
+    ADD COLUMN IF NOT EXISTS old_status VARCHAR(50),
+    ADD COLUMN IF NOT EXISTS new_status VARCHAR(50);
+
+ALTER TABLE moderation_log
+    DROP CONSTRAINT IF EXISTS moderation_log_action_check;
+
+ALTER TABLE moderation_log
+    ADD CONSTRAINT moderation_log_action_check
+    CHECK (action IN ('approve', 'reject', 'request_changes', 'publish', 'archive',
+                      'soft_delete', 'hard_delete', 'restore', 'revoke', 'rework'));

+ 105 - 0
obsidian_data/Photoplaces_data/atomic-api-contract-deleted-fulldelete.md

@@ -0,0 +1,105 @@
+## API контракт: deleted-статус и FullDelete эндпоинт
+
+**Контекст:** Добавлен статус `deleted` для Place (мягкое удаление пользователем) и модальное окно с кнопкой полного удаления для админа. API контракт не документирован.
+
+## Текущее состояние
+
+```mermaid
+graph LR
+    User[Пользователь] -->|Soft Delete| PATCH["PATCH /places/{id} {status:deleted}"]
+    Admin[Администратор] -->|Full Delete| DEL["DELETE /places/{id}?hard=true"]
+    PATCH -->|SET status='deleted'| DB[(PostgreSQL)]
+    DEL -->|CASCADE + MinIO cleanup| DB
+    DEL -->|Delete files| S3[(MinIO)]
+```
+
+### Soft Delete (`PATCH /places/{id}`)
+
+- **Кто вызывает**: владелец места
+- **Тело**: `{ "status": "deleted" }` (любые другие поля игнорируются для deleted)
+- **Логика**: `UPDATE places SET status = 'deleted' WHERE id = $1 AND owner_id = $2`
+- **Ответ**: `200 OK` с обновлённым place
+- **Безопасность**: только владелец места
+- **Обратимость**: администратор может сменить статус обратно через тот же PATCH
+
+### Full Delete (`DELETE /places/{id}?hard=true`)
+
+- **Кто вызывает**: администратор (роль `admin`/`superadmin`)
+- **Параметры**: `hard=true` (обязательно)
+- **Логика**:
+  1. Удалить файлы из MinIO (связанные с place через `UploadHandler.RemoveFile`)
+  2. DELETE CASCADE в БД (place_tags, place_features, reviews, bookings)
+  3. Запись в `moderation_log` (нужна модель `ModerationLog`)
+- **Ответ**: `204 No Content`
+- **Безопасность**: middleware проверяет роль; `DELETE /places/{id}` без `hard=true` — `400 Bad Request`
+
+## Чего не хватает
+
+### 1. Логирование мягких удалений
+
+Сейчас `PATCH status=deleted` не пишет в `moderation_log`. Нужно:
+
+```go
+// services/places.go
+func (s *PlaceService) SoftDelete(ctx context.Context, placeID, userID string) error {
+    // 1. Проверить права
+    // 2. UPDATE status = 'deleted'
+    // 3. s.moderationRepo.Log(ctx, ModerationLog{
+    //        ModeratorID: userID,
+    //        TargetType:  "place",
+    //        TargetID:    placeID,
+    //        Action:      "soft_delete",
+    //        NewStatus:   "deleted",
+    //    })
+}
+```
+
+### 2. Эндпоинт восстановления
+
+Нет способа восстановить место (кроме прямого PATCH админом). Нужен отдельный эндпоинт или разрешить `PATCH /places/{id} {status:active}` владельцу, если место в `deleted`.
+
+### 3. Список удалённых мест
+
+Сейчас фильтр `?status=deleted` не реализован в `GET /places`. Нужен для админской вкладки "Удалённые".
+
+### 4. FullDelete без `ModerationLog` модели
+
+```go
+// handlers/places.go — ⚠️ заглушка
+func (h *PlaceHandler) HardDelete(w http.ResponseWriter, r *http.Request) {
+    // НЕТ записи в audit log — нужно добавить после создания ModerationLog модели
+}
+```
+
+## Тесты
+
+### Soft Delete
+```go
+// services/places_test.go
+func TestPlaceService_SoftDelete(t *testing.T) {
+    // given: создано место ownerID=userA
+    // when: SoftDelete(ctx, placeID, userA)
+    // then: статус места == "deleted"
+    // when: SoftDelete(ctx, placeID, userB) // не владелец
+    // then: ошибка (403)
+}
+```
+
+### Full Delete
+```go
+// Если админ не передал hard=true — 400
+// Если не админ — 403
+// Если успешно — 204, место не существует в БД
+// Если место уже deleted — 404 (не надо удалять дважды)
+```
+
+## Связанные заметки
+
+- [[atomic-missing-go-models]] — нужна модель ModerationLog
+- [[atomic-csrf-protection]] — DELETE требует CSRF
+
+## Источник
+
+Реализация admin/page.tsx вкладка "Удалённые" + кнопка полного удаления.
+
+#api #contract #security #golang #frontend

+ 110 - 0
obsidian_data/Photoplaces_data/atomic-handler-concrete-type-dependency.md

@@ -0,0 +1,110 @@
+## Handler'ы зависят от конкретных типов вместо интерфейсов
+
+**Контекст:** `SetupHandler` и `PlaceHandler` хранят поля конкретных типов (`*services.AuthService`, `*services.PlaceService`), а не интерфейсы. Это затрудняет юнит-тестирование изоляции handler'ов без поднятия сервисов.
+
+## Проблема
+
+```go
+// handlers/setup.go — ❌ конкретный тип
+type SetupHandler struct {
+    authService *services.AuthService
+    placeService *services.PlaceService
+}
+
+// handlers/places.go — ❌ конкретный тип + лишняя зависимость
+type PlaceHandler struct {
+    placeService *services.PlaceService
+    authService  *services.AuthService  // нужен только для WebSocket
+    uploadHandler *handlers.UploadHandler
+}
+```
+
+## Почему это плохо
+
+1. **Нельзя замокать сервис**: тест handler'a требует реальной БД
+2. **PlaceHandler хранит authService**, который нужен только в одном методе (`WSHub`) — нарушение ISP
+3. **SetupHandler дублирует конструктор**: `NewSetupHandler` принимает то же, что и router
+
+## Решение
+
+### 1. Определить интерфейсы в пакете handlers
+
+```go
+// handlers/interfaces.go
+package handlers
+
+type PlaceService interface {
+    Create(ctx context.Context, place *models.Place) error
+    GetByID(ctx context.Context, id string, fetchTags bool) (*models.Place, error)
+    List(ctx context.Context, filter models.PlaceFilter) ([]*models.Place, error)
+    Update(ctx context.Context, id, userID string, req *models.UpdatePlaceRequest) error
+    SoftDelete(ctx context.Context, id, userID string) error
+    HardDelete(ctx context.Context, id string) error
+}
+
+type AuthService interface {
+    GetUserByEmail(ctx context.Context, email string) (*models.User, error)
+    GetUserByID(ctx context.Context, id string) (*models.User, error)
+    // ...
+}
+
+type UploadService interface {
+    RemoveFile(ctx context.Context, key string) error
+}
+```
+
+### 2. Заменить конкретные типы на интерфейсы
+
+```go
+// handlers/setup.go — ✅ интерфейс
+type SetupHandler struct {
+    authService  AuthService
+    placeService PlaceService
+}
+
+// handlers/places.go — ✅ только нужные зависимости
+type PlaceHandler struct {
+    placeService PlaceService
+    uploadSvc    UploadService  // только для удаления файлов
+    // authService удалён — перенести WSHub в отдельный handler
+}
+```
+
+### 3. WebSocket вынести из PlaceHandler
+
+```go
+// handlers/websocket.go — отдельный handler
+type WSHandler struct {
+    hub     *WSHub
+    authSvc AuthService  // только здесь
+}
+```
+
+## Что это даёт
+
+| До | После |
+|---|---|
+| `PlaceHandler` тестируется только интеграционно | Можно тестировать с моками |
+| `WSHub` завязан на `PlaceHandler` | Отдельный handler, переиспользуемый |
+| `authService` тащится везде | Только где реально нужен |
+| Сломан ISP (Interface Segregation) | Каждый handler зависит только от своего интерфейса |
+
+## Порядок рефакторинга
+
+1. Создать `handlers/interfaces.go` с интерфейсами
+2. Переписать `PlaceHandler` — убрать `authService`, заменить `*PlaceService` на `PlaceService`
+3. Выделить `WSHandler` из `PlaceHandler`
+4. Обновить `main.go` — инициализация новых handler'ов
+5. Написать юнит-тест для `PlaceHandler` с моком `PlaceService`
+
+## Связанные заметки
+
+- [[atomic-dependency-inversion-go]] — Dependency Injection в сервисном слое
+- [[atomic-test-patterns-go]] — паттерны тестирования с моками
+- [[atomic-websocket-origin-check]] — WebSocket безопасность
+
+## Источник
+
+Code review PhotoPlaces 2026-06. `handlers/setup.go`, `handlers/places.go`.
+
+#golang #architecture #testing #clean-architecture #refactoring

+ 108 - 0
obsidian_data/Photoplaces_data/atomic-missing-go-models.md

@@ -0,0 +1,108 @@
+## Недостающие Go-модели для таблиц БД
+
+**Контекст:** В БД есть таблицы `moderation_log`, `payments`, `subscriptions`, `visitor_sessions`, но для них нет Go-структур в `backend/internal/models/`. Без моделей эти таблицы недоступны через сервисный слой.
+
+## Какие модели нужны
+
+```sql
+-- moderation_log — аудит действий модераторов
+CREATE TABLE moderation_log (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    moderator_id UUID NOT NULL REFERENCES users(id),
+    target_type VARCHAR(50) NOT NULL,  -- 'place' | 'user' | 'review'
+    target_id UUID NOT NULL,
+    action VARCHAR(50) NOT NULL,       -- 'soft_delete' | 'hard_delete' | 'restore' | 'ban'
+    old_status VARCHAR(50),
+    new_status VARCHAR(50),
+    comment TEXT,
+    created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- payments — платежи за бронирования/подписки
+CREATE TABLE payments (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    user_id UUID NOT NULL REFERENCES users(id),
+    amount DECIMAL(10,2) NOT NULL,
+    currency VARCHAR(3) DEFAULT 'RUB',
+    status VARCHAR(20) NOT NULL DEFAULT 'pending',  -- pending | completed | failed | refunded
+    payment_method VARCHAR(50),
+    description TEXT,
+    created_at TIMESTAMPTZ DEFAULT NOW(),
+    updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- subscriptions — подписки фотографов/студий
+CREATE TABLE subscriptions (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    user_id UUID NOT NULL REFERENCES users(id),
+    plan VARCHAR(50) NOT NULL,      -- 'basic' | 'pro' | 'enterprise'
+    status VARCHAR(20) NOT NULL,    -- 'active' | 'cancelled' | 'expired'
+    started_at TIMESTAMPTZ DEFAULT NOW(),
+    expires_at TIMESTAMPTZ,
+    created_at TIMESTAMPTZ DEFAULT NOW(),
+    updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- visitor_sessions — сессии посетителей (анонимная аналитика)
+CREATE TABLE visitor_sessions (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    session_id VARCHAR(255) NOT NULL,
+    place_id UUID REFERENCES places(id),
+    page_url TEXT,
+    referrer TEXT,
+    user_agent TEXT,
+    ip_hash VARCHAR(64),
+    entered_at TIMESTAMPTZ DEFAULT NOW(),
+    exited_at TIMESTAMPTZ
+);
+```
+
+## Go-модели (шаблон)
+
+```go
+// backend/internal/models/moderation.go
+package models
+
+import "time"
+
+type ModerationLog struct {
+    ID          string    `json:"id" validate:"required,uuid"`
+    ModeratorID string    `json:"moderator_id" validate:"required,uuid"`
+    TargetType  string    `json:"target_type" validate:"required"`
+    TargetID    string    `json:"target_id" validate:"required,uuid"`
+    Action      string    `json:"action" validate:"required"`
+    OldStatus   *string   `json:"old_status,omitempty"`
+    NewStatus   *string   `json:"new_status,omitempty"`
+    Comment     *string   `json:"comment,omitempty"`
+    CreatedAt   time.Time `json:"created_at"`
+}
+```
+
+Аналогично для `Payment`, `Subscription`, `VisitorSession`. Поля `*string` для опциональных колонок (SQL NULL).
+
+## Что понадобится кроме моделей
+
+- **Repository слой**: CRUD-методы для каждой таблицы
+- **Миграции**: проверить, что таблицы уже созданы (если нет — добавить)
+- **Связи**: `payments.user_id` → `users.id`, `visitor_sessions.place_id` → `places.id`
+- **Индексы**: `moderation_log(target_type, target_id)`, `payments(user_id)`, `subscriptions(user_id)`, `visitor_sessions(session_id)`
+
+## Приоритет
+
+| Модель | Нужна для | Приоритет |
+|---|---|---|
+| `ModerationLog` | аудит soft/hard delete | **Высокий** — нужен сразу для deleted-статуса |
+| `Payment` | оплата бронирований/подписок | Средний |
+| `Subscription` | тарифы фотографов/студий | Средний |
+| `VisitorSession` | аналитика посещений | Низкий |
+
+## Связанные заметки
+
+- [[atomic-api-contract-deleted-fulldelete]] — API для deleted статуса
+- [[database-migrations]] — существующие миграции
+
+## Источник
+
+Schema review PhotoPlaces 2026-06. Таблицы созданы миграциями, но не имеют Go-моделей.
+
+#golang #database #models #technical-debt