From 8b22e965971d65f31bef6f16629226f9a03bc362 Mon Sep 17 00:00:00 2001 From: trifonovt <87468028+TihomirTrifonov@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:53:53 +0200 Subject: [PATCH] introduced TIME-domain foundation --- docs/TIME_PHASE_T1_FOUNDATION.md | 53 ++++++ .../procon/dip/architecture/SchemaNames.java | 1 + .../dip/domain/document/DocumentFamily.java | 1 + .../dip/domain/document/DocumentType.java | 1 + .../time/config/TimeDomainProperties.java | 26 +++ .../dip/domain/time/entity/TimeEntry.java | 117 +++++++++++++ .../time/entity/TimeEntrySourceLink.java | 90 ++++++++++ .../domain/time/entity/TimeSourceSystem.java | 9 + .../dip/domain/time/entity/TimeSyncRun.java | 116 ++++++++++++ .../domain/time/entity/TimeSyncRunStatus.java | 10 ++ .../domain/time/entity/TimeSyncRunType.java | 11 ++ .../dip/domain/time/entity/TimeSyncState.java | 93 ++++++++++ .../time/repository/TimeEntryRepository.java | 14 ++ .../TimeEntrySourceLinkRepository.java | 16 ++ .../repository/TimeSyncRunRepository.java | 15 ++ .../repository/TimeSyncStateRepository.java | 12 ++ .../ingestion/util/DocumentImportSupport.java | 1 + .../db/migration/V24__time_t1_foundation.sql | 165 ++++++++++++++++++ 18 files changed, 751 insertions(+) create mode 100644 docs/TIME_PHASE_T1_FOUNDATION.md create mode 100644 src/main/java/at/procon/dip/domain/time/config/TimeDomainProperties.java create mode 100644 src/main/java/at/procon/dip/domain/time/entity/TimeEntry.java create mode 100644 src/main/java/at/procon/dip/domain/time/entity/TimeEntrySourceLink.java create mode 100644 src/main/java/at/procon/dip/domain/time/entity/TimeSourceSystem.java create mode 100644 src/main/java/at/procon/dip/domain/time/entity/TimeSyncRun.java create mode 100644 src/main/java/at/procon/dip/domain/time/entity/TimeSyncRunStatus.java create mode 100644 src/main/java/at/procon/dip/domain/time/entity/TimeSyncRunType.java create mode 100644 src/main/java/at/procon/dip/domain/time/entity/TimeSyncState.java create mode 100644 src/main/java/at/procon/dip/domain/time/repository/TimeEntryRepository.java create mode 100644 src/main/java/at/procon/dip/domain/time/repository/TimeEntrySourceLinkRepository.java create mode 100644 src/main/java/at/procon/dip/domain/time/repository/TimeSyncRunRepository.java create mode 100644 src/main/java/at/procon/dip/domain/time/repository/TimeSyncStateRepository.java create mode 100644 src/main/resources/db/migration/V24__time_t1_foundation.sql diff --git a/docs/TIME_PHASE_T1_FOUNDATION.md b/docs/TIME_PHASE_T1_FOUNDATION.md new file mode 100644 index 0000000..7a46bbc --- /dev/null +++ b/docs/TIME_PHASE_T1_FOUNDATION.md @@ -0,0 +1,53 @@ +# TIME Phase T1 Foundation + +This phase introduces only the **shared TIME-domain foundation** in the NEW runtime. + +## Included + +- `TIME` schema +- common root fact table: `TIME.time_entry` +- reverse-link helper table: `TIME.time_entry_source_link` +- synchronization audit/state tables: + - `TIME.time_sync_run` + - `TIME.time_sync_state` +- NEW runtime configuration scaffold under `dip.time.*` +- new canonical document markers: + - `DocumentType.TIME_ENTRY` + - `DocumentFamily.TIME` + +## Not included yet + +- Leitstand import +- Toggl import +- search projection +- text representations +- embeddings +- structured search + +## Design intent + +The TIME domain uses: + +- shared generic canonical documents in `DOC.*` +- source-specific imported tables later (`TIME.ls_*`, `TIME.toggl_*`) +- one shared canonical root fact per time entry in `TIME.time_entry` + +This allows later source-specific import without forcing all external systems into one prematurely normalized source model. + +## Tables + +### `TIME.time_entry` +Shared canonical time-entry fact, linked 1:1 to `DOC.doc_document`. + +### `TIME.time_entry_source_link` +Maps a time entry to one or more source-system entity ids such as root entry id, task id, cost-unit id, etc. + +### `TIME.time_sync_run` +Stores audit information for one sync/import run. + +### `TIME.time_sync_state` +Stores mutable cursor/watermark state per source system and scope. + +## Next phase + +T2 adds Leitstand source import tables and importer logic. diff --git a/src/main/java/at/procon/dip/architecture/SchemaNames.java b/src/main/java/at/procon/dip/architecture/SchemaNames.java index a7f2b61..eb70f26 100644 --- a/src/main/java/at/procon/dip/architecture/SchemaNames.java +++ b/src/main/java/at/procon/dip/architecture/SchemaNames.java @@ -7,6 +7,7 @@ public final class SchemaNames { public static final String DOC = "DOC"; public static final String TED = "TED"; + public static final String TIME = "TIME"; private SchemaNames() { } diff --git a/src/main/java/at/procon/dip/domain/document/DocumentFamily.java b/src/main/java/at/procon/dip/domain/document/DocumentFamily.java index 790fe33..b260ceb 100644 --- a/src/main/java/at/procon/dip/domain/document/DocumentFamily.java +++ b/src/main/java/at/procon/dip/domain/document/DocumentFamily.java @@ -5,6 +5,7 @@ package at.procon.dip.domain.document; */ public enum DocumentFamily { PROCUREMENT, + TIME, MAIL, ATTACHMENT, KNOWLEDGE, diff --git a/src/main/java/at/procon/dip/domain/document/DocumentType.java b/src/main/java/at/procon/dip/domain/document/DocumentType.java index 1ff0720..4f8c1dc 100644 --- a/src/main/java/at/procon/dip/domain/document/DocumentType.java +++ b/src/main/java/at/procon/dip/domain/document/DocumentType.java @@ -6,6 +6,7 @@ package at.procon.dip.domain.document; public enum DocumentType { TED_PACKAGE, TED_NOTICE, + TIME_ENTRY, EMAIL, MIME_MESSAGE, PDF, diff --git a/src/main/java/at/procon/dip/domain/time/config/TimeDomainProperties.java b/src/main/java/at/procon/dip/domain/time/config/TimeDomainProperties.java new file mode 100644 index 0000000..46cc0f8 --- /dev/null +++ b/src/main/java/at/procon/dip/domain/time/config/TimeDomainProperties.java @@ -0,0 +1,26 @@ +package at.procon.dip.domain.time.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * NEW-runtime configuration scaffold for the TIME domain. + * T1 only introduces schema and persistence foundations; source import starts in T2. + */ +@Configuration +@ConfigurationProperties(prefix = "dip.time") +@Data +public class TimeDomainProperties { + + private boolean enabled = false; + private SourceProperties leitstand = new SourceProperties(); + private SourceProperties togglTrack = new SourceProperties(); + + @Data + public static class SourceProperties { + private boolean enabled = false; + private String importBatchId; + private int reconcileLookbackDays = 7; + } +} diff --git a/src/main/java/at/procon/dip/domain/time/entity/TimeEntry.java b/src/main/java/at/procon/dip/domain/time/entity/TimeEntry.java new file mode 100644 index 0000000..d4487c4 --- /dev/null +++ b/src/main/java/at/procon/dip/domain/time/entity/TimeEntry.java @@ -0,0 +1,117 @@ +package at.procon.dip.domain.time.entity; + +import at.procon.dip.architecture.SchemaNames; +import at.procon.dip.domain.document.entity.Document; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.time.OffsetDateTime; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Shared canonical time-entry fact that can be sourced from multiple external systems. + */ +@Entity +@Table(schema = SchemaNames.TIME, name = "time_entry", indexes = { + @Index(name = "idx_time_entry_source_system", columnList = "source_system"), + @Index(name = "idx_time_entry_external_id", columnList = "external_id"), + @Index(name = "idx_time_entry_start", columnList = "entry_start"), + @Index(name = "idx_time_entry_end", columnList = "entry_end"), + @Index(name = "idx_time_entry_source_updated", columnList = "source_updated_at") +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TimeEntry { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @OneToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "document_id", nullable = false, unique = true) + private Document document; + + @Enumerated(EnumType.STRING) + @Column(name = "source_system", nullable = false, length = 32) + private TimeSourceSystem sourceSystem; + + @Column(name = "external_id", nullable = false, length = 255) + private String externalId; + + @Column(name = "person_external_id", length = 255) + private String personExternalId; + + @Column(name = "person_display_name", length = 255) + private String personDisplayName; + + @Column(name = "entry_start") + private OffsetDateTime entryStart; + + @Column(name = "entry_end") + private OffsetDateTime entryEnd; + + @Column(name = "duration_seconds") + private Long durationSeconds; + + @Column(name = "description_short", length = 1000) + private String descriptionShort; + + @Column(name = "description_long", columnDefinition = "TEXT") + private String descriptionLong; + + @Column(name = "billable") + private Boolean billable; + + @Column(name = "source_created_at") + private OffsetDateTime sourceCreatedAt; + + @Column(name = "source_updated_at") + private OffsetDateTime sourceUpdatedAt; + + @Column(name = "source_deleted_at") + private OffsetDateTime sourceDeletedAt; + + @Column(name = "raw_status", length = 120) + private String rawStatus; + + @Column(name = "search_anchor_label", length = 500) + private String searchAnchorLabel; + + @Builder.Default + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt = OffsetDateTime.now(); + + @Builder.Default + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt = OffsetDateTime.now(); + + @PrePersist + protected void onCreate() { + createdAt = OffsetDateTime.now(); + updatedAt = OffsetDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = OffsetDateTime.now(); + } +} diff --git a/src/main/java/at/procon/dip/domain/time/entity/TimeEntrySourceLink.java b/src/main/java/at/procon/dip/domain/time/entity/TimeEntrySourceLink.java new file mode 100644 index 0000000..57de34c --- /dev/null +++ b/src/main/java/at/procon/dip/domain/time/entity/TimeEntrySourceLink.java @@ -0,0 +1,90 @@ +package at.procon.dip.domain.time.entity; + +import at.procon.dip.architecture.SchemaNames; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.time.OffsetDateTime; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Reverse-link helper that maps a canonical time entry to one or more source-system entity identifiers. + */ +@Entity +@Table(schema = SchemaNames.TIME, name = "time_entry_source_link", indexes = { + @Index(name = "idx_time_entry_link_entry", columnList = "time_entry_id"), + @Index(name = "idx_time_entry_link_source_lookup", columnList = "source_system, source_entity_type, source_external_id"), + @Index(name = "idx_time_entry_link_role", columnList = "linked_role") +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TimeEntrySourceLink { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "time_entry_id", nullable = false) + private TimeEntry timeEntry; + + @Enumerated(EnumType.STRING) + @Column(name = "source_system", nullable = false, length = 32) + private TimeSourceSystem sourceSystem; + + @Column(name = "source_entity_type", nullable = false, length = 120) + private String sourceEntityType; + + @Column(name = "source_external_id", nullable = false, length = 255) + private String sourceExternalId; + + @Column(name = "linked_role", length = 120) + private String linkedRole; + + @Column(name = "parent_source_external_id", length = 255) + private String parentSourceExternalId; + + @Column(name = "source_updated_at") + private OffsetDateTime sourceUpdatedAt; + + @Column(name = "source_deleted_at") + private OffsetDateTime sourceDeletedAt; + + @Builder.Default + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt = OffsetDateTime.now(); + + @Builder.Default + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt = OffsetDateTime.now(); + + @PrePersist + protected void onCreate() { + createdAt = OffsetDateTime.now(); + updatedAt = OffsetDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = OffsetDateTime.now(); + } +} diff --git a/src/main/java/at/procon/dip/domain/time/entity/TimeSourceSystem.java b/src/main/java/at/procon/dip/domain/time/entity/TimeSourceSystem.java new file mode 100644 index 0000000..eddef6b --- /dev/null +++ b/src/main/java/at/procon/dip/domain/time/entity/TimeSourceSystem.java @@ -0,0 +1,9 @@ +package at.procon.dip.domain.time.entity; + +/** + * External source systems for imported time-entry data. + */ +public enum TimeSourceSystem { + LEITSTAND, + TOGGL_TRACK +} diff --git a/src/main/java/at/procon/dip/domain/time/entity/TimeSyncRun.java b/src/main/java/at/procon/dip/domain/time/entity/TimeSyncRun.java new file mode 100644 index 0000000..f69a923 --- /dev/null +++ b/src/main/java/at/procon/dip/domain/time/entity/TimeSyncRun.java @@ -0,0 +1,116 @@ +package at.procon.dip.domain.time.entity; + +import at.procon.dip.architecture.SchemaNames; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.time.OffsetDateTime; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Audit row for one synchronization run of a time-entry source system. + */ +@Entity +@Table(schema = SchemaNames.TIME, name = "time_sync_run", indexes = { + @Index(name = "idx_time_sync_run_source_system", columnList = "source_system"), + @Index(name = "idx_time_sync_run_scope_key", columnList = "scope_key"), + @Index(name = "idx_time_sync_run_status", columnList = "status"), + @Index(name = "idx_time_sync_run_started_at", columnList = "started_at") +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TimeSyncRun { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Enumerated(EnumType.STRING) + @Column(name = "source_system", nullable = false, length = 32) + private TimeSourceSystem sourceSystem; + + @Enumerated(EnumType.STRING) + @Column(name = "run_type", nullable = false, length = 32) + private TimeSyncRunType runType; + + @Column(name = "scope_key", nullable = false, length = 255) + private String scopeKey; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 32) + private TimeSyncRunStatus status; + + @Builder.Default + @Column(name = "started_at", nullable = false) + private OffsetDateTime startedAt = OffsetDateTime.now(); + + @Column(name = "finished_at") + private OffsetDateTime finishedAt; + + @Column(name = "watermark_from", length = 255) + private String watermarkFrom; + + @Column(name = "watermark_to", length = 255) + private String watermarkTo; + + @Builder.Default + @Column(name = "rows_read", nullable = false) + private Integer rowsRead = 0; + + @Builder.Default + @Column(name = "rows_created", nullable = false) + private Integer rowsCreated = 0; + + @Builder.Default + @Column(name = "rows_updated", nullable = false) + private Integer rowsUpdated = 0; + + @Builder.Default + @Column(name = "rows_deactivated", nullable = false) + private Integer rowsDeactivated = 0; + + @Builder.Default + @Column(name = "rows_failed", nullable = false) + private Integer rowsFailed = 0; + + @Column(name = "error_message", columnDefinition = "TEXT") + private String errorMessage; + + @Column(name = "initiated_by", length = 120) + private String initiatedBy; + + @Builder.Default + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt = OffsetDateTime.now(); + + @PrePersist + protected void onCreate() { + createdAt = OffsetDateTime.now(); + if (startedAt == null) { + startedAt = createdAt; + } + } + + @PreUpdate + protected void onUpdate() { + if (finishedAt == null && status != TimeSyncRunStatus.RUNNING) { + finishedAt = OffsetDateTime.now(); + } + } +} diff --git a/src/main/java/at/procon/dip/domain/time/entity/TimeSyncRunStatus.java b/src/main/java/at/procon/dip/domain/time/entity/TimeSyncRunStatus.java new file mode 100644 index 0000000..b815130 --- /dev/null +++ b/src/main/java/at/procon/dip/domain/time/entity/TimeSyncRunStatus.java @@ -0,0 +1,10 @@ +package at.procon.dip.domain.time.entity; + +/** + * Lifecycle state of a time-domain synchronization run. + */ +public enum TimeSyncRunStatus { + RUNNING, + COMPLETED, + FAILED +} diff --git a/src/main/java/at/procon/dip/domain/time/entity/TimeSyncRunType.java b/src/main/java/at/procon/dip/domain/time/entity/TimeSyncRunType.java new file mode 100644 index 0000000..357888f --- /dev/null +++ b/src/main/java/at/procon/dip/domain/time/entity/TimeSyncRunType.java @@ -0,0 +1,11 @@ +package at.procon.dip.domain.time.entity; + +/** + * Type of synchronization run for imported time-entry source systems. + */ +public enum TimeSyncRunType { + INITIAL, + INCREMENTAL, + RECONCILE, + MANUAL +} diff --git a/src/main/java/at/procon/dip/domain/time/entity/TimeSyncState.java b/src/main/java/at/procon/dip/domain/time/entity/TimeSyncState.java new file mode 100644 index 0000000..80d5ee2 --- /dev/null +++ b/src/main/java/at/procon/dip/domain/time/entity/TimeSyncState.java @@ -0,0 +1,93 @@ +package at.procon.dip.domain.time.entity; + +import at.procon.dip.architecture.SchemaNames; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.time.OffsetDateTime; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Mutable synchronization cursor/state per source system and scope. + */ +@Entity +@Table(schema = SchemaNames.TIME, name = "time_sync_state", indexes = { + @Index(name = "idx_time_sync_state_source_system", columnList = "source_system"), + @Index(name = "idx_time_sync_state_scope_key", columnList = "scope_key") +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TimeSyncState { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Enumerated(EnumType.STRING) + @Column(name = "source_system", nullable = false, length = 32) + private TimeSourceSystem sourceSystem; + + @Column(name = "scope_key", nullable = false, length = 255) + private String scopeKey; + + @Builder.Default + @Column(name = "enabled", nullable = false) + private boolean enabled = true; + + @Column(name = "last_successful_sync_at") + private OffsetDateTime lastSuccessfulSyncAt; + + @Column(name = "last_attempted_sync_at") + private OffsetDateTime lastAttemptedSyncAt; + + @Column(name = "last_seen_watermark", length = 255) + private String lastSeenWatermark; + + @Column(name = "last_seen_external_id", length = 255) + private String lastSeenExternalId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "last_run_id") + private TimeSyncRun lastRun; + + @Column(name = "notes", columnDefinition = "TEXT") + private String notes; + + @Builder.Default + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt = OffsetDateTime.now(); + + @Builder.Default + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt = OffsetDateTime.now(); + + @PrePersist + protected void onCreate() { + createdAt = OffsetDateTime.now(); + updatedAt = OffsetDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = OffsetDateTime.now(); + } +} diff --git a/src/main/java/at/procon/dip/domain/time/repository/TimeEntryRepository.java b/src/main/java/at/procon/dip/domain/time/repository/TimeEntryRepository.java new file mode 100644 index 0000000..9d69bca --- /dev/null +++ b/src/main/java/at/procon/dip/domain/time/repository/TimeEntryRepository.java @@ -0,0 +1,14 @@ +package at.procon.dip.domain.time.repository; + +import at.procon.dip.domain.time.entity.TimeEntry; +import at.procon.dip.domain.time.entity.TimeSourceSystem; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TimeEntryRepository extends JpaRepository { + + Optional findByDocument_Id(UUID documentId); + + Optional findBySourceSystemAndExternalId(TimeSourceSystem sourceSystem, String externalId); +} diff --git a/src/main/java/at/procon/dip/domain/time/repository/TimeEntrySourceLinkRepository.java b/src/main/java/at/procon/dip/domain/time/repository/TimeEntrySourceLinkRepository.java new file mode 100644 index 0000000..fafb696 --- /dev/null +++ b/src/main/java/at/procon/dip/domain/time/repository/TimeEntrySourceLinkRepository.java @@ -0,0 +1,16 @@ +package at.procon.dip.domain.time.repository; + +import at.procon.dip.domain.time.entity.TimeEntrySourceLink; +import at.procon.dip.domain.time.entity.TimeSourceSystem; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TimeEntrySourceLinkRepository extends JpaRepository { + + List findByTimeEntry_Id(UUID timeEntryId); + + Optional findBySourceSystemAndSourceEntityTypeAndSourceExternalId( + TimeSourceSystem sourceSystem, String sourceEntityType, String sourceExternalId); +} diff --git a/src/main/java/at/procon/dip/domain/time/repository/TimeSyncRunRepository.java b/src/main/java/at/procon/dip/domain/time/repository/TimeSyncRunRepository.java new file mode 100644 index 0000000..801f7e0 --- /dev/null +++ b/src/main/java/at/procon/dip/domain/time/repository/TimeSyncRunRepository.java @@ -0,0 +1,15 @@ +package at.procon.dip.domain.time.repository; + +import at.procon.dip.domain.time.entity.TimeSourceSystem; +import at.procon.dip.domain.time.entity.TimeSyncRun; +import at.procon.dip.domain.time.entity.TimeSyncRunStatus; +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TimeSyncRunRepository extends JpaRepository { + + List findTop20BySourceSystemOrderByStartedAtDesc(TimeSourceSystem sourceSystem); + + List findBySourceSystemAndStatus(TimeSourceSystem sourceSystem, TimeSyncRunStatus status); +} diff --git a/src/main/java/at/procon/dip/domain/time/repository/TimeSyncStateRepository.java b/src/main/java/at/procon/dip/domain/time/repository/TimeSyncStateRepository.java new file mode 100644 index 0000000..c4fde1c --- /dev/null +++ b/src/main/java/at/procon/dip/domain/time/repository/TimeSyncStateRepository.java @@ -0,0 +1,12 @@ +package at.procon.dip.domain.time.repository; + +import at.procon.dip.domain.time.entity.TimeSourceSystem; +import at.procon.dip.domain.time.entity.TimeSyncState; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TimeSyncStateRepository extends JpaRepository { + + Optional findBySourceSystemAndScopeKey(TimeSourceSystem sourceSystem, String scopeKey); +} diff --git a/src/main/java/at/procon/dip/ingestion/util/DocumentImportSupport.java b/src/main/java/at/procon/dip/ingestion/util/DocumentImportSupport.java index 3052ea2..0adffd3 100644 --- a/src/main/java/at/procon/dip/ingestion/util/DocumentImportSupport.java +++ b/src/main/java/at/procon/dip/ingestion/util/DocumentImportSupport.java @@ -175,6 +175,7 @@ public final class DocumentImportSupport { public static DocumentFamily familyFor(DocumentType documentType) { return switch (documentType) { case TED_PACKAGE, TED_NOTICE -> DocumentFamily.PROCUREMENT; + case TIME_ENTRY -> DocumentFamily.TIME; case EMAIL, MIME_MESSAGE -> DocumentFamily.MAIL; case PDF, DOCX, HTML, XML_GENERIC, TEXT, MARKDOWN, ZIP_ARCHIVE, GENERIC_BINARY, UNKNOWN -> DocumentFamily.GENERIC; diff --git a/src/main/resources/db/migration/V24__time_t1_foundation.sql b/src/main/resources/db/migration/V24__time_t1_foundation.sql new file mode 100644 index 0000000..bb04f95 --- /dev/null +++ b/src/main/resources/db/migration/V24__time_t1_foundation.sql @@ -0,0 +1,165 @@ +-- TIME Phase T1 foundation for shared time-entry structures in the NEW runtime. +-- No source import logic yet; this migration only creates the shared TIME schema and base state tables. + +CREATE SCHEMA IF NOT EXISTS TIME; + +SET search_path TO TIME, DOC, public; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE t.typname = 'time_source_system' AND n.nspname = 'time' + ) THEN + CREATE TYPE TIME.time_source_system AS ENUM ('LEITSTAND', 'TOGGL_TRACK'); + END IF; +END +$$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE t.typname = 'time_sync_run_status' AND n.nspname = 'time' + ) THEN + CREATE TYPE TIME.time_sync_run_status AS ENUM ('RUNNING', 'COMPLETED', 'FAILED'); + END IF; +END +$$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE t.typname = 'time_sync_run_type' AND n.nspname = 'time' + ) THEN + CREATE TYPE TIME.time_sync_run_type AS ENUM ('INITIAL', 'INCREMENTAL', 'RECONCILE', 'MANUAL'); + END IF; +END +$$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_enum e + JOIN pg_type t ON t.oid = e.enumtypid + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'doc' AND t.typname = 'doc_document_type' AND e.enumlabel = 'TIME_ENTRY' + ) THEN + ALTER TYPE DOC.doc_document_type ADD VALUE 'TIME_ENTRY'; + END IF; +END +$$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_enum e + JOIN pg_type t ON t.oid = e.enumtypid + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'doc' AND t.typname = 'doc_document_family' AND e.enumlabel = 'TIME' + ) THEN + ALTER TYPE DOC.doc_document_family ADD VALUE 'TIME'; + END IF; +END +$$; + +CREATE TABLE IF NOT EXISTS TIME.time_entry ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + document_id UUID NOT NULL UNIQUE REFERENCES DOC.doc_document(id) ON DELETE CASCADE, + source_system TIME.time_source_system NOT NULL, + external_id VARCHAR(255) NOT NULL, + person_external_id VARCHAR(255), + person_display_name VARCHAR(255), + entry_start TIMESTAMP WITH TIME ZONE, + entry_end TIMESTAMP WITH TIME ZONE, + duration_seconds BIGINT, + description_short VARCHAR(1000), + description_long TEXT, + billable BOOLEAN, + source_created_at TIMESTAMP WITH TIME ZONE, + source_updated_at TIMESTAMP WITH TIME ZONE, + source_deleted_at TIMESTAMP WITH TIME ZONE, + raw_status VARCHAR(120), + search_anchor_label VARCHAR(500), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_time_entry_source UNIQUE (source_system, external_id) +); + +CREATE TABLE IF NOT EXISTS TIME.time_entry_source_link ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + time_entry_id UUID NOT NULL REFERENCES TIME.time_entry(id) ON DELETE CASCADE, + source_system TIME.time_source_system NOT NULL, + source_entity_type VARCHAR(120) NOT NULL, + source_external_id VARCHAR(255) NOT NULL, + linked_role VARCHAR(120), + parent_source_external_id VARCHAR(255), + source_updated_at TIMESTAMP WITH TIME ZONE, + source_deleted_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_time_entry_source_link UNIQUE (time_entry_id, source_system, source_entity_type, source_external_id) +); + +CREATE TABLE IF NOT EXISTS TIME.time_sync_run ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source_system TIME.time_source_system NOT NULL, + run_type TIME.time_sync_run_type NOT NULL, + scope_key VARCHAR(255) NOT NULL, + status TIME.time_sync_run_status NOT NULL, + started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + finished_at TIMESTAMP WITH TIME ZONE, + watermark_from VARCHAR(255), + watermark_to VARCHAR(255), + rows_read INTEGER NOT NULL DEFAULT 0, + rows_created INTEGER NOT NULL DEFAULT 0, + rows_updated INTEGER NOT NULL DEFAULT 0, + rows_deactivated INTEGER NOT NULL DEFAULT 0, + rows_failed INTEGER NOT NULL DEFAULT 0, + error_message TEXT, + initiated_by VARCHAR(120), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS TIME.time_sync_state ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source_system TIME.time_source_system NOT NULL, + scope_key VARCHAR(255) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + last_successful_sync_at TIMESTAMP WITH TIME ZONE, + last_attempted_sync_at TIMESTAMP WITH TIME ZONE, + last_seen_watermark VARCHAR(255), + last_seen_external_id VARCHAR(255), + last_run_id UUID REFERENCES TIME.time_sync_run(id) ON DELETE SET NULL, + notes TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_time_sync_state_scope UNIQUE (source_system, scope_key) +); + +CREATE INDEX IF NOT EXISTS idx_time_entry_source_system ON TIME.time_entry(source_system); +CREATE INDEX IF NOT EXISTS idx_time_entry_external_id ON TIME.time_entry(external_id); +CREATE INDEX IF NOT EXISTS idx_time_entry_start ON TIME.time_entry(entry_start DESC); +CREATE INDEX IF NOT EXISTS idx_time_entry_end ON TIME.time_entry(entry_end DESC); +CREATE INDEX IF NOT EXISTS idx_time_entry_source_updated ON TIME.time_entry(source_updated_at DESC); + +CREATE INDEX IF NOT EXISTS idx_time_entry_link_entry ON TIME.time_entry_source_link(time_entry_id); +CREATE INDEX IF NOT EXISTS idx_time_entry_link_source_lookup ON TIME.time_entry_source_link(source_system, source_entity_type, source_external_id); +CREATE INDEX IF NOT EXISTS idx_time_entry_link_role ON TIME.time_entry_source_link(linked_role); + +CREATE INDEX IF NOT EXISTS idx_time_sync_run_source_system ON TIME.time_sync_run(source_system); +CREATE INDEX IF NOT EXISTS idx_time_sync_run_scope_key ON TIME.time_sync_run(scope_key); +CREATE INDEX IF NOT EXISTS idx_time_sync_run_status ON TIME.time_sync_run(status); +CREATE INDEX IF NOT EXISTS idx_time_sync_run_started_at ON TIME.time_sync_run(started_at DESC); + +CREATE INDEX IF NOT EXISTS idx_time_sync_state_source_system ON TIME.time_sync_state(source_system); +CREATE INDEX IF NOT EXISTS idx_time_sync_state_scope_key ON TIME.time_sync_state(scope_key);