feat: add persisted page insight platform
This commit is contained in:
@@ -104,6 +104,18 @@ func ValidateEvents(events []Event) error {
|
||||
if event.LeadDays < 0 || event.LagDays < 0 || event.RelevanceBPS < 0 || event.RelevanceBPS > 10_000 || event.ConfidenceBPS < 0 || event.ConfidenceBPS > 10_000 {
|
||||
return fmt.Errorf("event catalog: event %q has invalid impact metadata", event.ID)
|
||||
}
|
||||
if event.LeadDays > 90 || event.LagDays > 90 {
|
||||
return fmt.Errorf("event catalog: event %q lead or lag window exceeds 90 days", event.ID)
|
||||
}
|
||||
if event.Recurrence != "" {
|
||||
resolved, err := ResolveRecurrence(event.Recurrence, start.Year(), time.UTC)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event catalog: event %q recurrence: %w", event.ID, err)
|
||||
}
|
||||
if !resolved.Equal(start) || !end.Equal(start) {
|
||||
return fmt.Errorf("event catalog: event %q does not match its resolved recurrence", event.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
134
internal/eventcatalog/import.go
Normal file
134
internal/eventcatalog/import.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package eventcatalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Import struct {
|
||||
Version string `json:"version"`
|
||||
CoverageStartJalaliYear int `json:"coverageStartJalaliYear"`
|
||||
CoverageEndJalaliYear int `json:"coverageEndJalaliYear"`
|
||||
SourceURI string `json:"sourceUri"`
|
||||
SourceVersion string `json:"sourceVersion"`
|
||||
SourceChecksum string `json:"sourceChecksum"`
|
||||
ImportedBy string `json:"importedBy"`
|
||||
Events []Event `json:"events"`
|
||||
}
|
||||
|
||||
func ValidateImport(input Import, currentJalaliYear int) error {
|
||||
if strings.TrimSpace(input.Version) == "" || strings.TrimSpace(input.SourceURI) == "" || strings.TrimSpace(input.SourceVersion) == "" || strings.TrimSpace(input.SourceChecksum) == "" || strings.TrimSpace(input.ImportedBy) == "" {
|
||||
return errors.New("event catalog: import metadata is incomplete")
|
||||
}
|
||||
if input.CoverageStartJalaliYear > currentJalaliYear-3 || input.CoverageEndJalaliYear < currentJalaliYear+2 {
|
||||
return fmt.Errorf("event catalog: coverage must include Jalali years %d through %d", currentJalaliYear-3, currentJalaliYear+2)
|
||||
}
|
||||
if len(input.Events) == 0 {
|
||||
return errors.New("event catalog: import contains no events")
|
||||
}
|
||||
return ValidateEvents(input.Events)
|
||||
}
|
||||
|
||||
func ValidateApproval(importedBy, reviewedBy string) error {
|
||||
if strings.TrimSpace(reviewedBy) == "" {
|
||||
return errors.New("event catalog: reviewer is required")
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(importedBy), strings.TrimSpace(reviewedBy)) {
|
||||
return errors.New("event catalog: importer cannot approve the same catalog")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Repository struct{ pool *pgxpool.Pool }
|
||||
|
||||
func NewRepository(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} }
|
||||
|
||||
func (r *Repository) ImportDraft(ctx context.Context, input Import) error {
|
||||
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("starting calendar import: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
|
||||
var versionID int64
|
||||
err = tx.QueryRow(ctx, `INSERT INTO calendar_catalog_versions(version,status,coverage_start_jalali_year,coverage_end_jalali_year,source_uri,source_version,source_checksum,imported_by)
|
||||
VALUES($1,'DRAFT',$2,$3,$4,$5,$6,$7) RETURNING id`, input.Version, input.CoverageStartJalaliYear, input.CoverageEndJalaliYear, input.SourceURI, input.SourceVersion, input.SourceChecksum, input.ImportedBy).Scan(&versionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating calendar catalog version: %w", err)
|
||||
}
|
||||
for _, event := range input.Events {
|
||||
categories, err := json.Marshal(event.MerchantCategories)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encoding calendar event categories: %w", err)
|
||||
}
|
||||
channels, err := json.Marshal(event.BehavioralChannels)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encoding calendar event channels: %w", err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO calendar_events(catalog_version_id,event_key,title_fa,title_en,start_date,end_date,recurrence_rule,event_type,tone,commercial_posture,official_holiday,source_uri,source_version,source_checksum,inclusion_rationale,geographic_scope,merchant_categories,behavioral_channels,lead_days,lag_days,relevance_bps,confidence_bps)
|
||||
VALUES($1,$2,$3,nullif($4,''),$5,$6,nullif($7,''),$8,$9,$10,$11,$12,$13,nullif($14,''),$15,$16,$17,$18,$19,$20,$21,$22)`, versionID, event.ID, event.TitleFA, event.TitleEN, event.StartDate, event.EndDate, event.Recurrence, event.Type, event.Tone, event.CommercialPosture, event.OfficialHoliday, event.SourceURI, event.SourceVersion, event.SourceChecksum, event.InclusionRationale, event.GeographicScope, categories, channels, event.LeadDays, event.LagDays, event.RelevanceBPS, event.ConfidenceBPS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inserting calendar event %q: %w", event.ID, err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO calendar_catalog_audit(catalog_version_id,action,actor) VALUES($1,'IMPORTED',$2)`, versionID, input.ImportedBy); err != nil {
|
||||
return fmt.Errorf("auditing calendar import: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("committing calendar import: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) Approve(ctx context.Context, version, reviewer string) error {
|
||||
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("starting calendar approval: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
|
||||
var id int64
|
||||
var importedBy string
|
||||
if err := tx.QueryRow(ctx, `SELECT id,imported_by FROM calendar_catalog_versions WHERE version=$1 AND status='DRAFT' FOR UPDATE`, version).Scan(&id, &importedBy); err != nil {
|
||||
return fmt.Errorf("loading draft calendar catalog: %w", err)
|
||||
}
|
||||
if err := ValidateApproval(importedBy, reviewer); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE calendar_catalog_versions SET status='APPROVED',reviewed_by=$2,reviewed_at=now() WHERE id=$1`, id, reviewer); err != nil {
|
||||
return fmt.Errorf("approving calendar catalog: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO calendar_catalog_audit(catalog_version_id,action,actor) VALUES($1,'APPROVED',$2)`, id, reviewer); err != nil {
|
||||
return fmt.Errorf("auditing calendar approval: %w", err)
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *Repository) Activate(ctx context.Context, version, actor string) error {
|
||||
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("starting calendar activation: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended('calendar-catalog-activation',0))`); err != nil {
|
||||
return fmt.Errorf("locking calendar activation: %w", err)
|
||||
}
|
||||
var id int64
|
||||
if err := tx.QueryRow(ctx, `SELECT id FROM calendar_catalog_versions WHERE version=$1 AND status='APPROVED' FOR UPDATE`, version).Scan(&id); err != nil {
|
||||
return fmt.Errorf("loading approved calendar catalog: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE calendar_catalog_versions SET status='RETIRED' WHERE status='ACTIVE'`); err != nil {
|
||||
return fmt.Errorf("retiring active calendar catalog: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE calendar_catalog_versions SET status='ACTIVE',activated_at=now() WHERE id=$1`, id); err != nil {
|
||||
return fmt.Errorf("activating calendar catalog: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO calendar_catalog_audit(catalog_version_id,action,actor) VALUES($1,'ACTIVATED',$2)`, id, actor); err != nil {
|
||||
return fmt.Errorf("auditing calendar activation: %w", err)
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
28
internal/eventcatalog/import_test.go
Normal file
28
internal/eventcatalog/import_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package eventcatalog
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateImportRequiresThreePastAndTwoFutureJalaliYears(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
input := Import{Version: "1402-1407-v1", CoverageStartJalaliYear: 1402, CoverageEndJalaliYear: 1407, SourceURI: "https://calendar.ut.ac.ir/", SourceVersion: "reviewed", SourceChecksum: "abc", ImportedBy: "operator-a", Events: []Event{{ID: "nowruz", TitleFA: "نوروز", StartDate: "2026-03-21", EndDate: "2026-03-24", Type: TypeCultural, Tone: ToneCelebratory, CommercialPosture: PosturePromote, SourceURI: "https://calendar.ut.ac.ir/", SourceVersion: "1405", InclusionRationale: "تقویم رسمی", GeographicScope: "IR", RelevanceBPS: 10000, ConfidenceBPS: 10000}}}
|
||||
if err := ValidateImport(input, 1405); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
input.CoverageStartJalaliYear = 1403
|
||||
if err := ValidateImport(input, 1405); err == nil {
|
||||
t.Fatal("expected insufficient past coverage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateApprovalRequiresIndependentReviewer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if err := ValidateApproval("operator-a", "operator-a"); err == nil {
|
||||
t.Fatal("expected independent reviewer error")
|
||||
}
|
||||
if err := ValidateApproval("operator-a", "operator-b"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
14
internal/eventcatalog/recurrence_validation_test.go
Normal file
14
internal/eventcatalog/recurrence_validation_test.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package eventcatalog
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateEventsRejectsIncorrectBlackFridayDate(t *testing.T) {
|
||||
event := Event{ID: "black-friday-2026", TitleFA: "جمعه سیاه", StartDate: "2026-11-26", EndDate: "2026-11-26", Recurrence: RecurrenceBlackFriday, Type: TypeCommercial, Tone: ToneNeutral, CommercialPosture: PostureCaution, SourceURI: "https://example.test/black-friday", SourceVersion: "2026", InclusionRationale: "رفتار تجاری مرتبط", GeographicScope: "IR", RelevanceBPS: 8000, ConfidenceBPS: 8000}
|
||||
if err := ValidateEvents([]Event{event}); err == nil {
|
||||
t.Fatal("expected recurrence mismatch")
|
||||
}
|
||||
event.StartDate, event.EndDate = "2026-11-27", "2026-11-27"
|
||||
if err := ValidateEvents([]Event{event}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user