// Package models — предметные сущности API. Только структуры и теги JSON, // без логики и без зависимостей: ими пользуются handlers, repository и docs. package models import "time" // Допустимые значения полей заявки. Это третий по счёту источник тех же // списков (после binding oneof при создании и CHECK-ограничений в 0001): // он нужен фильтрам списка, чтобы мусор в ?status= получал 400, а не // пустую страницу. Меняете значения — правьте все три места. var ( // TicketStatuses включает 'confirmed': такой статус разрешён // констрейнтом схемы, хотя боевая машина статусов его не выставляет. TicketStatuses = []string{"new", "assigned", "in_progress", "done", "confirmed", "rejected", "closed"} TicketCategories = []string{"electric", "plumbing", "climate", "elevator", "other"} TicketPriorities = []string{"low", "normal", "high"} ) // Profile — пользователь: клиент, мастер или админ. // PasswordHash помечен json:"-" и наружу никогда не уходит. type Profile struct { ID string `json:"id"` Email string `json:"email"` PasswordHash string `json:"-"` Name string `json:"name"` Phone string `json:"phone,omitempty"` Role string `json:"role"` IsActive bool `json:"is_active"` // ConfirmedAt — момент подтверждения аккаунта; nil (null в JSON) // — ожидает подтверждения, вход до этого запрещён (403 // account not confirmed). Существующие пользователи подтверждены // бэкфиллом миграции 0010. ConfirmedAt *time.Time `json:"confirmed_at"` CreatedAt time.Time `json:"created_at"` } // Project — проект клиента: контур, внутри которого живут объекты // (здания с адресами) и мастера. Проект создаёт клиент или админ. type Project struct { ID int64 `json:"id"` ClientID string `json:"client_id"` Name string `json:"name"` IsActive bool `json:"is_active"` CreatedAt time.Time `json:"created_at"` // Денормализация для UI: чей проект (админ видит все сразу). ClientName string `json:"client_name,omitempty"` } // Object — объект, входящий в проект: здание/корпус с адресом. // Адрес заявки копируется из объекта на момент создания заявки. type Object struct { ID int64 `json:"id"` ProjectID int64 `json:"project_id"` Name string `json:"name"` Address string `json:"address"` IsActive bool `json:"is_active"` CreatedAt time.Time `json:"created_at"` // Денормализации для UI. ProjectName string `json:"project_name,omitempty"` // Служебное: активен ли проект-владелец — для проверки при создании // заявки. В JSON не уходит. ProjectActive bool `json:"-"` } // Ticket — заявка на обслуживание со всей её историей в одном ряду: // какой-то статус машины состояний и таймстампы всех достигнутых этапов. type Ticket struct { ID int64 `json:"id"` ClientID string `json:"client_id"` WorkerID *string `json:"worker_id,omitempty"` AssignedBy *string `json:"assigned_by,omitempty"` Address string `json:"address"` Floor string `json:"floor,omitempty"` Category string `json:"category"` Description string `json:"description"` Priority string `json:"priority"` Status string `json:"status"` CreatedAt time.Time `json:"created_at"` AssignedAt *time.Time `json:"assigned_at,omitempty"` StartedAt *time.Time `json:"started_at,omitempty"` DoneAt *time.Time `json:"done_at,omitempty"` ConfirmedAt *time.Time `json:"confirmed_at,omitempty"` ClosedAt *time.Time `json:"closed_at,omitempty"` // Принадлежность: заявка живёт в объекте проекта клиента. // Проверяется составными FK в БД (миграция 0004). ProjectID int64 `json:"project_id"` ObjectID int64 `json:"object_id"` // Закрытие цикла: rejected — это доработка, closed — финал. RejectedAt *time.Time `json:"rejected_at,omitempty"` RejectReason string `json:"reject_reason,omitempty"` CancelReason string `json:"cancel_reason,omitempty"` ClosedBy *string `json:"closed_by,omitempty"` // Сколько раз клиент отклонял работу — чтобы доработки не бесконечны // были незаметны. ReworkCount int `json:"rework_count"` // Денормализованные поля для UI — подтягиваются JOIN'ом ClientName string `json:"client_name,omitempty"` WorkerName string `json:"worker_name,omitempty"` ProjectName string `json:"project_name,omitempty"` ObjectName string `json:"object_name,omitempty"` // Recurring — правило повторения, если заявка связана с ним: // создана вместе с правилом (чекбокс «Новая заявка») или самим // планировщиком. Заполняется только в детали (GetByID) — спискам // блок на странице заявки не нужен. Recurring *TicketRecurring `json:"recurring,omitempty"` } // TicketRecurring — что показать в блоке «Повторяющаяся заявка» на // странице заявки: расписание правила, его состояние и ближайший запуск. type TicketRecurring struct { RuleID int64 `json:"rule_id"` // Расписание: «каждые PeriodDays дней/недель от AnchorAt» — // точка отсчёта плюс период в днях. AnchorAt time.Time `json:"anchor_at"` PeriodDays int `json:"period_days"` IsActive bool `json:"is_active"` // NextRunAt — ближайший запуск, вычисляется при чтении // (см. repository.nextDue); у выключенного правила — когда включат. NextRunAt *time.Time `json:"next_run_at,omitempty"` } // RecurringRule — правило повторяющейся заявки (миграции 0014/0016): // шаблон будущей заявки (объект, категория, описание, приоритет) плюс // расписание — точка отсчёта и период в днях. Фоновый планировщик сам // создаёт заявку, когда срок наступил, — но только если прошлая по // этому правилу уже закрыта (без дублей). type RecurringRule struct { ID int64 `json:"id"` // ClientID — владелец будущих заявок (клиент проекта). ClientID string `json:"client_id"` ProjectID int64 `json:"project_id"` ObjectID int64 `json:"object_id"` // Шаблон заявки — те же поля, что у tickets. Category string `json:"category"` Description string `json:"description"` Priority string `json:"priority"` Floor string `json:"floor,omitempty"` // Расписание: «каждые PeriodDays дней/недель от AnchorAt» — // AnchorAt — дата-время первого запуска (точка отсчёта сетки // сроков), PeriodDays — период в днях (1..3650, недели кратны 7). AnchorAt time.Time `json:"anchor_at"` PeriodDays int `json:"period_days"` IsActive bool `json:"is_active"` // LastRunAt — когда последний раз срабатывали; null (в JSON — null) // означает «ещё ни разу», следующий запуск считается от created_at. LastRunAt *time.Time `json:"last_run_at"` CreatedAt time.Time `json:"created_at"` // NextRunAt — ближайший запуск после последнего срабатывания // (вычисляется при чтении; у выключенного правила — когда включат). NextRunAt *time.Time `json:"next_run_at,omitempty"` // CreatedBy — кто завёл правило (админ или клиент); в журнале // созданной заявки это же лицо будет автором события «created». CreatedBy string `json:"created_by,omitempty"` // Денормализации для UI. ProjectName string `json:"project_name,omitempty"` ObjectName string `json:"object_name,omitempty"` // Адрес объекта — снимок, как у заявки: правило показывает, куда едут. Address string `json:"address,omitempty"` ClientName string `json:"client_name,omitempty"` // Кто завёл правило (админ или сам клиент) — для аудита, в JSON идёт имя. CreatedByName string `json:"created_by_name,omitempty"` } // RecurringRun — история срабатывания правила (миграция 0014): какая // заявка создана по какому правилу и когда. Аудит «кто и когда». type RecurringRun struct { ID int64 `json:"id"` RuleID int64 `json:"rule_id"` // TicketID — созданная заявка (её номер = id); null, если её удалили // каскадом вместе с проектом. TicketID *int64 `json:"ticket_id"` RunAt time.Time `json:"run_at"` // Адрес и категория — из заявки на момент создания (для списка истории). Address string `json:"address,omitempty"` Category string `json:"category,omitempty"` } // ProjectWorker — мастер в составе проекта: профиль плюс состояние // членства (миграция 0005). invited — приглашение отправлено, но ещё // не принято: такой мастер заявок не получает. type ProjectWorker struct { Profile // invited | active | declined (см. 0005_project_invitations.sql). Status string `json:"status"` // RespondedAt — когда мастер принял/отклонил; пусто, пока не ответил. RespondedAt *time.Time `json:"responded_at,omitempty"` // ExpiresAt — крайний срок ответа; заполнен у приглашений. ExpiresAt *time.Time `json:"expires_at,omitempty"` } // ProjectInvitation — приглашение в проект, ожидающее решения мастера // (то, что видит сам мастер в GET /invitations). type ProjectInvitation struct { ProjectID int64 `json:"project_id"` ProjectName string `json:"project_name"` ClientName string `json:"client_name"` InvitedBy string `json:"invited_by"` // id пригласившего InvitedName string `json:"invited_by_name"` // его имя для UI InvitedAt time.Time `json:"invited_at"` ExpiresAt time.Time `json:"expires_at"` } // TicketEvent — запись журнала заявки (миграция 0006): кто, что сделал, // от какого статуса к какому и когда. Пишется в той же SQL-операции, // что и переход, поэтому история не может разойтись со статусом. type TicketEvent struct { ID int64 `json:"id"` TicketID int64 `json:"ticket_id"` // "" — автор удалён (событие сохранится, имя нет). ActorID string `json:"actor_id,omitempty"` ActorName string `json:"actor_name,omitempty"` // created | assigned | started | done | confirmed | rejected | // canceled | photo_added | photo_removed. Action string `json:"action"` // "" — у события «создана» не было предыдущего статуса. // У photo_added/photo_removed статус не менялся: оба равны текущему. FromStatus string `json:"from_status,omitempty"` ToStatus string `json:"to_status"` // Причина для rejected/canceled, имя файла для photo_*; иначе пусто. Comment string `json:"comment,omitempty"` CreatedAt time.Time `json:"created_at"` } // TicketPhoto — фотоотчёт к заявке (миграция 0008): в БД только // метаданные, байты живут в объектном хранилище (S3-совместимом: // локально MinIO, в проде — любой S3). Ссылка на чтение — временная // (presigned), поэтому сам объект остаётся приватным. type TicketPhoto struct { ID int64 `json:"id"` TicketID int64 `json:"ticket_id"` // "" — аккаунт загрузившего удалён, файл остаётся. UploaderID string `json:"uploader_id,omitempty"` UploaderName string `json:"uploader_name,omitempty"` ContentType string `json:"content_type"` SizeBytes int64 `json:"size_bytes"` // Имя, которое дал файл при загрузке: лежит и в журнале (comment). OriginalName string `json:"original_name"` CreatedAt time.Time `json:"created_at"` // URL — presigned GET (срок жизни ограничен); заполняется только // в ответах API. ObjectKey — внутренняя деталь, в JSON не уходит. URL string `json:"url,omitempty"` ObjectKey string `json:"-"` }