import { Column, CreateDateColumn, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn, PrimaryGeneratedColumn, Unique, UpdateDateColumn, } from 'typeorm'; import type { GeoJsonLineString, GeoJsonMultiPolygon, GeoJsonPoint, MediaRequirement, RoundStatus, } from '@patrol/shared'; // TypeORM сам переводит geometry в GeoJSON при чтении и обратно при записи. @Entity('organizations') export class Organization { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'char', length: 12, unique: true }) bin: string; @Column('text') name: string; @Column({ type: 'text', name: 'legal_name', nullable: true }) legalName: string | null; @Column({ type: 'text', name: 'legal_address', nullable: true }) legalAddress: string | null; @Column({ type: 'text', name: 'director_name', nullable: true }) directorName: string | null; @Column({ type: 'text', nullable: true }) phone: string | null; @Column({ type: 'text', nullable: true }) email: string | null; @Column({ type: 'jsonb', name: 'registry_data', nullable: true }) registryData: unknown | null; @Column({ type: 'timestamptz', name: 'registry_synced_at', nullable: true }) registrySyncedAt: Date | null; @Column({ type: 'text', default: 'active' }) status: 'active' | 'blocked'; @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; } @Entity('roles') export class Role { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'uuid', name: 'organization_id' }) organizationId: string; @Column('text') code: string; @Column('text') name: string; @Column({ type: 'boolean', name: 'is_system', default: false }) isSystem: boolean; @Column({ type: 'boolean', name: 'all_sites', default: false }) allSites: boolean; @Column({ type: 'text', array: true, default: () => "'{}'" }) permissions: string[]; @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; } @Entity('users') export class User { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'uuid', name: 'organization_id', nullable: true }) organizationId: string | null; // Человек из паспорта. Пароли и вход — только через паспорт / Aspans ID. @Column({ type: 'uuid', name: 'passport_uuid', unique: true, nullable: true }) passportUuid: string | null; // Логин паспорта (username) — для отображения. @Column({ type: 'text', nullable: true }) login: string | null; @Column({ type: 'text', name: 'last_name', nullable: true }) lastName: string | null; @Column({ type: 'text', name: 'first_name', nullable: true }) firstName: string | null; @Column({ type: 'text', name: 'middle_name', nullable: true }) middleName: string | null; @Column({ type: 'text', name: 'full_name' }) fullName: string; @Column({ type: 'text', nullable: true }) email: string | null; @Column({ type: 'text', nullable: true }) phone: string | null; @Column({ type: 'uuid', name: 'role_id', nullable: true }) roleId: string | null; @ManyToOne(() => Role, { nullable: true }) @JoinColumn({ name: 'role_id' }) role: Role | null; // Доступ в patrol (выключает администратор организации). Паспорт этим не управляет. @Column({ type: 'boolean', name: 'is_active', default: true }) isActive: boolean; // is_enabled из паспорта: отключили человека в паспорте — не пускаем и сюда. @Column({ type: 'boolean', name: 'passport_enabled', default: true }) passportEnabled: boolean; // Убрали с платформы patrol в паспорте (или удалили из организации здесь). Строку не удаляем: // на человека ссылаются обходы, отметки и фото в отчётах. @Column({ type: 'timestamptz', name: 'removed_at', nullable: true }) removedAt: Date | null; @Column({ type: 'boolean', name: 'is_platform_admin', default: false }) isPlatformAdmin: boolean; @Column({ type: 'timestamptz', name: 'last_login_at', nullable: true }) lastLoginAt: Date | null; @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ type: 'timestamptz', name: 'updated_at' }) updatedAt: Date; } @Entity('user_sites') export class UserSite { @PrimaryColumn({ type: 'uuid', name: 'user_id' }) userId: string; @PrimaryColumn({ type: 'uuid', name: 'site_id' }) siteId: string; } @Entity('user_sessions') export class UserSession { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'uuid', name: 'user_id' }) userId: string; @Column({ type: 'text', name: 'refresh_hash' }) refreshHash: string; @Column({ type: 'text', name: 'user_agent', nullable: true }) userAgent: string | null; @Column({ type: 'timestamptz', name: 'expires_at' }) expiresAt: Date; @Column({ type: 'timestamptz', name: 'revoked_at', nullable: true }) revokedAt: Date | null; @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; } @Entity('audit_log') export class AuditLog { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'uuid', name: 'organization_id', nullable: true }) organizationId: string | null; @Column({ type: 'uuid', name: 'user_id', nullable: true }) userId: string | null; @Column('text') action: string; @Column({ type: 'text', nullable: true }) entity: string | null; @Column({ type: 'text', name: 'entity_id', nullable: true }) entityId: string | null; @Column({ type: 'jsonb', nullable: true }) changes: unknown | null; @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; } @Entity('sites') export class Site { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'uuid', name: 'organization_id' }) organizationId: string; @Column('text') name: string; @Column({ type: 'text', nullable: true }) address: string | null; @Column({ type: 'text', nullable: true }) description: string | null; @Column({ type: 'geometry', spatialFeatureType: 'MultiPolygon', srid: 4326, nullable: true }) perimeter: GeoJsonMultiPolygon | null; @Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326, nullable: true }) center: GeoJsonPoint | null; @Column({ type: 'text', default: 'Asia/Almaty' }) timezone: string; @Column({ type: 'uuid', name: 'created_by', nullable: true }) createdBy: string | null; @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ type: 'timestamptz', name: 'updated_at' }) updatedAt: Date; @Column({ type: 'timestamptz', name: 'archived_at', nullable: true }) archivedAt: Date | null; } @Entity('routes') export class Route { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'uuid', name: 'site_id' }) siteId: string; @Column('text') name: string; @Column({ type: 'text', nullable: true }) description: string | null; @Column({ type: 'geometry', spatialFeatureType: 'LineString', srid: 4326, nullable: true }) path: GeoJsonLineString | null; @Column({ type: 'int', name: 'expected_minutes', nullable: true }) expectedMinutes: number | null; @Column({ type: 'boolean', name: 'strict_order', default: false }) strictOrder: boolean; @Column({ type: 'int', default: 1 }) version: number; @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ type: 'timestamptz', name: 'updated_at' }) updatedAt: Date; @Column({ type: 'timestamptz', name: 'archived_at', nullable: true }) archivedAt: Date | null; } @Entity('checkpoints') export class Checkpoint { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'uuid', name: 'route_id' }) routeId: string; @Column('int') position: number; @Column('text') name: string; @Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326 }) location: GeoJsonPoint; @Column({ type: 'int', name: 'radius_m', default: 30 }) radiusM: number; @Column({ type: 'text', name: 'media_required', default: 'photo' }) mediaRequired: MediaRequirement; @Column({ type: 'text', nullable: true }) instruction: string | null; @Column({ type: 'timestamptz', name: 'archived_at', nullable: true }) archivedAt: Date | null; } // Пункт чеклиста — общий для всех чеклистов компании. @Entity('checklist_items') export class ChecklistItem { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'uuid', name: 'organization_id' }) organizationId: string; @Column('text') text: string; @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; } @Entity('checklists') export class Checklist { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'uuid', name: 'organization_id' }) organizationId: string; @Column('text') name: string; @Column({ type: 'text', nullable: true }) description: string | null; @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ type: 'timestamptz', name: 'updated_at' }) updatedAt: Date; @Column({ type: 'timestamptz', name: 'archived_at', nullable: true }) archivedAt: Date | null; } @Entity('checklist_entries') export class ChecklistEntry { @PrimaryColumn({ type: 'uuid', name: 'checklist_id' }) checklistId: string; @PrimaryColumn({ type: 'uuid', name: 'item_id' }) itemId: string; @Column('int') position: number; } @Entity('checkpoint_checklists') export class CheckpointChecklist { @PrimaryColumn({ type: 'uuid', name: 'checkpoint_id' }) checkpointId: string; @PrimaryColumn({ type: 'uuid', name: 'checklist_id' }) checklistId: string; @Column('int') position: number; } @Entity('patrol_schedules') export class PatrolSchedule { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'uuid', name: 'site_id' }) siteId: string; @Column({ type: 'uuid', name: 'route_id' }) routeId: string; @Column('text') name: string; // Время старта 'HH:MM' в часовом поясе объекта @Column({ type: 'text', name: 'start_times', array: true }) startTimes: string[]; @Column({ type: 'int', name: 'window_minutes', default: 60 }) windowMinutes: number; @Column({ type: 'int', name: 'late_tolerance_minutes', default: 15 }) lateToleranceMinutes: number; @Column({ type: 'date', name: 'valid_from' }) validFrom: string; @Column({ type: 'date', name: 'valid_to', nullable: true }) validTo: string | null; @Column({ type: 'boolean', name: 'is_active', default: true }) isActive: boolean; @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; } @Entity('schedule_assignments') @Unique(['scheduleId', 'userId', 'date']) export class ScheduleAssignment { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'uuid', name: 'schedule_id' }) scheduleId: string; @Column({ type: 'uuid', name: 'user_id' }) userId: string; @Column({ type: 'date' }) date: string; @Column({ type: 'uuid', name: 'created_by', nullable: true }) createdBy: string | null; @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; } export type RouteSnapshot = { version: number; name: string; strictOrder: boolean; path: GeoJsonLineString | null; checkpoints: { id: string; position: number; name: string; location: GeoJsonPoint; radiusM: number; mediaRequired: MediaRequirement; instruction: string | null; // В снимках до появления чеклистов поля нет — читать как []. checklists?: SnapshotChecklist[]; }[]; }; export type SnapshotChecklist = { id: string; name: string; items: { id: string; text: string }[] }; // Ответ охранника по пункту. Текст копируем: отчёт читается и после правки пункта. export type ChecklistAnswer = { checklistId: string; itemId: string; text: string; checked: boolean }; @Entity('rounds') @Index(['siteId', 'plannedStart']) export class Round { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'uuid', name: 'site_id' }) siteId: string; @Column({ type: 'uuid', name: 'route_id' }) routeId: string; @Column({ type: 'uuid', name: 'schedule_id', nullable: true }) scheduleId: string | null; // Дата графика (в поясе объекта), по ней ищем дежурных @Column({ type: 'date', name: 'duty_date', nullable: true }) dutyDate: string | null; @Column({ type: 'uuid', name: 'performed_by', nullable: true }) performedBy: string | null; @Column({ type: 'uuid', name: 'created_by', nullable: true }) createdBy: string | null; @Column({ type: 'timestamptz', name: 'planned_start' }) plannedStart: Date; @Column({ type: 'timestamptz', name: 'planned_end' }) plannedEnd: Date; @Column({ type: 'int', name: 'late_tolerance_minutes', default: 15 }) lateToleranceMinutes: number; @Column({ type: 'timestamptz', name: 'started_at', nullable: true }) startedAt: Date | null; @Column({ type: 'timestamptz', name: 'finished_at', nullable: true }) finishedAt: Date | null; @Column({ type: 'text', default: 'planned' }) status: RoundStatus; @Column({ type: 'jsonb', name: 'route_snapshot' }) routeSnapshot: RouteSnapshot; @Column({ type: 'int', name: 'checkpoints_total', default: 0 }) checkpointsTotal: number; @Column({ type: 'int', name: 'checkpoints_done', default: 0 }) checkpointsDone: number; @Column({ type: 'text', nullable: true }) comment: string | null; @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ type: 'timestamptz', name: 'updated_at' }) updatedAt: Date; } @Entity('checkpoint_visits') export class CheckpointVisit { // id генерирует телефон: повтор из офлайн-очереди не создаёт дубль @PrimaryColumn('uuid') id: string; @Column({ type: 'uuid', name: 'round_id' }) roundId: string; @Column({ type: 'uuid', name: 'checkpoint_id', nullable: true }) checkpointId: string | null; @Column({ type: 'uuid', name: 'user_id' }) userId: string; @Column({ type: 'geometry', name: 'marked_location', spatialFeatureType: 'Point', srid: 4326 }) markedLocation: GeoJsonPoint; @Column({ type: 'geometry', name: 'device_location', spatialFeatureType: 'Point', srid: 4326, nullable: true }) deviceLocation: GeoJsonPoint | null; @Column({ type: 'real', name: 'device_accuracy_m', nullable: true }) deviceAccuracyM: number | null; @Column({ type: 'real', name: 'distance_to_checkpoint_m', nullable: true }) distanceToCheckpointM: number | null; @Column({ type: 'real', name: 'gps_distance_m', nullable: true }) gpsDistanceM: number | null; @Column({ type: 'text', array: true, default: () => "'{}'" }) flags: string[]; @Column({ type: 'text', nullable: true }) note: string | null; @Column({ type: 'boolean', name: 'is_issue', default: false }) isIssue: boolean; @Column({ type: 'jsonb', default: () => "'[]'" }) checklist: ChecklistAnswer[]; @Column({ type: 'timestamptz', name: 'client_created_at' }) clientCreatedAt: Date; @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; } @Entity('media') export class Media { // id тоже генерирует телефон @PrimaryColumn('uuid') id: string; @Column({ type: 'uuid', name: 'organization_id' }) organizationId: string; @Column({ type: 'uuid', name: 'uploaded_by' }) uploadedBy: string; @Column({ type: 'uuid', name: 'visit_id', nullable: true }) visitId: string | null; @Column('text') kind: 'photo' | 'video'; @Column('text') mime: string; @Column({ type: 'bigint', name: 'size_bytes' }) sizeBytes: string; @Column({ type: 'text', nullable: true }) filename: string | null; @Column({ type: 'text', name: 'storage_key' }) storageKey: string; @Column({ type: 'text', name: 'upload_id', nullable: true }) uploadId: string | null; @Column({ type: 'jsonb', default: () => "'[]'" }) parts: { partNumber: number; etag: string; size: number }[]; @Column({ type: 'text', default: 'uploading' }) status: 'uploading' | 'ready'; @Column({ type: 'timestamptz', name: 'captured_at', nullable: true }) capturedAt: Date | null; @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; } @Entity('webhooks') export class Webhook { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'uuid', name: 'organization_id' }) organizationId: string; @Column('text') url: string; @Column({ type: 'text', array: true }) events: string[]; @Column('text') secret: string; @Column({ type: 'boolean', name: 'is_active', default: true }) isActive: boolean; @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; } export const ENTITIES = [ Organization, Role, User, UserSite, UserSession, AuditLog, Site, Route, Checkpoint, ChecklistItem, Checklist, ChecklistEntry, CheckpointChecklist, PatrolSchedule, ScheduleAssignment, Round, CheckpointVisit, Media, Webhook, ];