axe/internal/models/models.go

255 lines
14 KiB
Go
Raw Normal View History

2026-10-18 19:07:25 +00:00
// 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:"-"`
}