package repository import ( "context" "encoding/json" "errors" "strings" "time" "evobgp/internal/store" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) func (p *Postgres) ListCDNSources(tenantID, moduleID string) ([]*store.CDNSource, error) { mod, err := p.GetModule(tenantID, moduleID) if err != nil { return nil, err } if mod.Type != "CDN_CIDRS" { return nil, store.ErrInvalidInput } ctx := context.Background() rows, err := p.pool.Query(ctx, ` SELECT id::text, source_kind, url, COALESCE(prefix_path,''), COALESCE(etag,''), refresh_interval_sec, community_id::text, last_refreshed_at FROM module_cdn_source WHERE module_id=$1 ORDER BY url`, moduleID) if err != nil { return nil, err } defer rows.Close() var out []*store.CDNSource for rows.Next() { var s store.CDNSource s.ModuleID = moduleID var ri *int32 var comm *string var last *time.Time if err := rows.Scan(&s.ID, &s.SourceKind, &s.URL, &s.PrefixPath, &s.Etag, &ri, &comm, &last); err != nil { continue } if ri != nil { v := int(*ri) s.RefreshIntervalSec = &v } s.CommunityID = strOrNil(comm) if last != nil { t := last.UTC() s.LastRefreshedAt = &t } out = append(out, &s) } return out, nil } func (p *Postgres) CreateCDNSource(tenantID, moduleID string, in *store.CDNSource) (*store.CDNSource, error) { mod, err := p.GetModule(tenantID, moduleID) if err != nil { return nil, err } if mod.Type != "CDN_CIDRS" { return nil, store.ErrInvalidInput } if in == nil || strings.TrimSpace(in.URL) == "" { return nil, store.ErrInvalidInput } ctx := context.Background() id := uuid.NewString() _, err = p.pool.Exec(ctx, ` INSERT INTO module_cdn_source (id, module_id, source_kind, url, prefix_path, etag, refresh_interval_sec, community_id, last_refreshed_at) VALUES ($1,$2,$3,$4,$5,$6,$7, NULLIF($8::uuid, '00000000-0000-0000-0000-000000000000'::uuid), $9)`, id, moduleID, in.SourceKind, strings.TrimSpace(in.URL), strings.TrimSpace(in.PrefixPath), in.Etag, nullInt32Ptr(in.RefreshIntervalSec), uuidOrNilPtr(in.CommunityID), nullTimePtr(in.LastRefreshedAt)) if err != nil { return nil, err } return p.getCDNSource(ctx, moduleID, id) } func (p *Postgres) getCDNSource(ctx context.Context, moduleID, id string) (*store.CDNSource, error) { var s store.CDNSource s.ModuleID = moduleID var ri *int32 var comm *string var last *time.Time err := p.pool.QueryRow(ctx, ` SELECT id::text, source_kind, url, COALESCE(prefix_path,''), COALESCE(etag,''), refresh_interval_sec, community_id::text, last_refreshed_at FROM module_cdn_source WHERE id=$1 AND module_id=$2`, id, moduleID).Scan(&s.ID, &s.SourceKind, &s.URL, &s.PrefixPath, &s.Etag, &ri, &comm, &last) if err != nil { return nil, err } if ri != nil { v := int(*ri) s.RefreshIntervalSec = &v } s.CommunityID = strOrNil(comm) if last != nil { t := last.UTC() s.LastRefreshedAt = &t } return &s, nil } func uuidOrNilPtr(s *string) any { if s == nil || strings.TrimSpace(*s) == "" { return nil } return strings.TrimSpace(*s) } func (p *Postgres) UpdateCDNSource(tenantID, moduleID, sourceID string, patch *store.CDNSourcePatch) (*store.CDNSource, error) { if _, err := p.GetModule(tenantID, moduleID); err != nil { return nil, err } cur, err := p.getCDNSource(context.Background(), moduleID, sourceID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, store.ErrNotFound } return nil, err } if patch.SourceKind != nil { cur.SourceKind = *patch.SourceKind } if patch.URL != nil { cur.URL = strings.TrimSpace(*patch.URL) } if patch.PrefixPath != nil { cur.PrefixPath = strings.TrimSpace(*patch.PrefixPath) } if patch.Etag != nil { cur.Etag = *patch.Etag } if patch.RefreshIntervalSec != nil { cur.RefreshIntervalSec = patch.RefreshIntervalSec } if patch.CommunityID != nil { v := strings.TrimSpace(*patch.CommunityID) if v == "" { cur.CommunityID = nil } else { cur.CommunityID = &v } } if patch.LastRefreshedAt != nil { t := patch.LastRefreshedAt.UTC() cur.LastRefreshedAt = &t } ctx := context.Background() _, err = p.pool.Exec(ctx, ` UPDATE module_cdn_source SET source_kind=$3, url=$4, prefix_path=$5, etag=$6, refresh_interval_sec=$7, community_id=NULLIF($8::uuid, '00000000-0000-0000-0000-000000000000'::uuid), last_refreshed_at=$9, updated_at=now() WHERE id=$1 AND module_id=$2`, sourceID, moduleID, cur.SourceKind, cur.URL, cur.PrefixPath, cur.Etag, nullInt32Ptr(cur.RefreshIntervalSec), uuidOrNilPtr(cur.CommunityID), nullTimePtr(cur.LastRefreshedAt)) if err != nil { return nil, err } return p.getCDNSource(ctx, moduleID, sourceID) } func (p *Postgres) DeleteCDNSource(tenantID, moduleID, sourceID string) error { if _, err := p.GetModule(tenantID, moduleID); err != nil { return err } ctx := context.Background() tag, err := p.pool.Exec(ctx, `DELETE FROM module_cdn_source WHERE id=$1 AND module_id=$2`, sourceID, moduleID) if err != nil { return err } if tag.RowsAffected() == 0 { return store.ErrNotFound } return nil } func (p *Postgres) ListASEntries(tenantID, moduleID string) ([]*store.ASEntry, error) { mod, err := p.GetModule(tenantID, moduleID) if err != nil { return nil, err } if mod.Type != "AS_PREFIXES" { return nil, store.ErrInvalidInput } ctx := context.Background() rows, err := p.pool.Query(ctx, ` SELECT id::text, asn, community_id::text, asn_name, prefix_count, asn_resolved_at FROM module_as_entry WHERE module_id=$1 ORDER BY asn`, moduleID) if err != nil { return nil, err } defer rows.Close() var out []*store.ASEntry for rows.Next() { var e store.ASEntry e.ModuleID = moduleID var comm, asnName *string var pc *int64 var at *time.Time if err := rows.Scan(&e.ID, &e.ASN, &comm, &asnName, &pc, &at); err != nil { continue } e.CommunityID = strOrNil(comm) if asnName != nil { e.ASNName = *asnName } e.PrefixCount = pc e.ASNResolvedAt = at out = append(out, &e) } return out, nil } func (p *Postgres) CreateASEntry(tenantID, moduleID string, in *store.ASEntry) (*store.ASEntry, error) { mod, err := p.GetModule(tenantID, moduleID) if err != nil { return nil, err } if mod.Type != "AS_PREFIXES" { return nil, store.ErrInvalidInput } if in == nil || !store.ValidASN(in.ASN) { return nil, store.ErrInvalidInput } ctx := context.Background() id := uuid.NewString() _, err = p.pool.Exec(ctx, ` INSERT INTO module_as_entry (id, module_id, asn, community_id) VALUES ($1,$2,$3,NULLIF($4::uuid, '00000000-0000-0000-0000-000000000000'::uuid))`, id, moduleID, in.ASN, uuidOrNilPtr(in.CommunityID)) if err != nil { return nil, err } return p.getASEntry(ctx, moduleID, id) } func (p *Postgres) getASEntry(ctx context.Context, moduleID, id string) (*store.ASEntry, error) { var e store.ASEntry e.ModuleID = moduleID var comm, asnName *string var pc *int64 var at *time.Time err := p.pool.QueryRow(ctx, ` SELECT id::text, asn, community_id::text, asn_name, prefix_count, asn_resolved_at FROM module_as_entry WHERE id=$1 AND module_id=$2`, id, moduleID).Scan( &e.ID, &e.ASN, &comm, &asnName, &pc, &at) if err != nil { return nil, err } e.CommunityID = strOrNil(comm) if asnName != nil { e.ASNName = *asnName } e.PrefixCount = pc e.ASNResolvedAt = at return &e, nil } func (p *Postgres) UpdateASEntry(tenantID, moduleID, entryID string, patch *store.ASEntryPatch) (*store.ASEntry, error) { if _, err := p.GetModule(tenantID, moduleID); err != nil { return nil, err } cur, err := p.getASEntry(context.Background(), moduleID, entryID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, store.ErrNotFound } return nil, err } prevASN := cur.ASN if patch.ASN != nil { cur.ASN = *patch.ASN } if patch.CommunityID != nil { v := strings.TrimSpace(*patch.CommunityID) if v == "" { cur.CommunityID = nil } else { cur.CommunityID = &v } } if !store.ValidASN(cur.ASN) { return nil, store.ErrInvalidInput } clearResolve := patch.ASN != nil && cur.ASN != prevASN ctx := context.Background() if clearResolve { _, err = p.pool.Exec(ctx, ` UPDATE module_as_entry SET asn=$3, community_id=NULLIF($4::uuid, '00000000-0000-0000-0000-000000000000'::uuid), asn_name=NULL, prefix_count=NULL, asn_resolved_at=NULL, updated_at=now() WHERE id=$1 AND module_id=$2`, entryID, moduleID, cur.ASN, uuidOrNilPtr(cur.CommunityID)) } else { _, err = p.pool.Exec(ctx, ` UPDATE module_as_entry SET asn=$3, community_id=NULLIF($4::uuid, '00000000-0000-0000-0000-000000000000'::uuid), updated_at=now() WHERE id=$1 AND module_id=$2`, entryID, moduleID, cur.ASN, uuidOrNilPtr(cur.CommunityID)) } if err != nil { return nil, err } return p.getASEntry(ctx, moduleID, entryID) } func (p *Postgres) UpdateASEntryResolveMeta(tenantID, moduleID, entryID string, asnName string, prefixCount int64, resolvedAt time.Time) error { if _, err := p.GetModule(tenantID, moduleID); err != nil { return err } ctx := context.Background() var nameArg any sn := strings.TrimSpace(asnName) if sn == "" { nameArg = nil } else { nameArg = sn } tag, err := p.pool.Exec(ctx, ` UPDATE module_as_entry SET asn_name=$3, prefix_count=$4, asn_resolved_at=$5, updated_at=now() WHERE id=$1 AND module_id=$2`, entryID, moduleID, nameArg, prefixCount, resolvedAt.UTC()) if err != nil { return err } if tag.RowsAffected() == 0 { return store.ErrNotFound } return nil } func (p *Postgres) UpdateASEntryResolveMetaBatch(tenantID, moduleID string, updates []store.ASEntryResolveMetaUpdate, resolvedAt time.Time) error { if len(updates) == 0 { return nil } if _, err := p.GetModule(tenantID, moduleID); err != nil { return err } ctx := context.Background() batch := &pgx.Batch{} for _, u := range updates { var nameArg any sn := strings.TrimSpace(u.ASNName) if sn == "" { nameArg = nil } else { nameArg = sn } batch.Queue(` UPDATE module_as_entry SET asn_name=$3, prefix_count=$4, asn_resolved_at=$5, updated_at=now() WHERE id=$1 AND module_id=$2`, u.EntryID, moduleID, nameArg, u.PrefixCount, resolvedAt.UTC()) } br := p.pool.SendBatch(ctx, batch) defer func() { _ = br.Close() }() for range updates { if _, err := br.Exec(); err != nil { return err } } return nil } func (p *Postgres) DeleteASEntry(tenantID, moduleID, entryID string) error { if _, err := p.GetModule(tenantID, moduleID); err != nil { return err } ctx := context.Background() tag, err := p.pool.Exec(ctx, `DELETE FROM module_as_entry WHERE id=$1 AND module_id=$2`, entryID, moduleID) if err != nil { return err } if tag.RowsAffected() == 0 { return store.ErrNotFound } return nil } func (p *Postgres) ListDomainEntries(tenantID, moduleID string) ([]*store.DomainEntry, error) { mod, err := p.GetModule(tenantID, moduleID) if err != nil { return nil, err } if mod.Type != "DOMAINS" { return nil, store.ErrInvalidInput } ctx := context.Background() rows, err := p.pool.Query(ctx, `SELECT id::text, fqdn, community_id::text FROM module_domain_entry WHERE module_id=$1`, moduleID) if err != nil { return nil, err } defer rows.Close() var out []*store.DomainEntry for rows.Next() { var e store.DomainEntry e.ModuleID = moduleID var comm *string if err := rows.Scan(&e.ID, &e.FQDN, &comm); err != nil { continue } e.CommunityID = strOrNil(comm) out = append(out, &e) } return out, nil } func (p *Postgres) CreateDomainEntry(tenantID, moduleID string, in *store.DomainEntry) (*store.DomainEntry, error) { mod, err := p.GetModule(tenantID, moduleID) if err != nil { return nil, err } if mod.Type != "DOMAINS" || in == nil || strings.TrimSpace(in.FQDN) == "" { return nil, store.ErrInvalidInput } ctx := context.Background() id := uuid.NewString() _, err = p.pool.Exec(ctx, ` INSERT INTO module_domain_entry (id, module_id, fqdn, community_id) VALUES ($1,$2,$3, NULLIF($4::uuid, '00000000-0000-0000-0000-000000000000'::uuid))`, id, moduleID, strings.TrimSpace(in.FQDN), uuidOrNilPtr(in.CommunityID)) if err != nil { return nil, err } return p.getDomainEntry(ctx, moduleID, id) } func (p *Postgres) getDomainEntry(ctx context.Context, moduleID, id string) (*store.DomainEntry, error) { var e store.DomainEntry e.ModuleID = moduleID var comm *string err := p.pool.QueryRow(ctx, `SELECT id::text, fqdn, community_id::text FROM module_domain_entry WHERE id=$1 AND module_id=$2`, id, moduleID).Scan(&e.ID, &e.FQDN, &comm) if err != nil { return nil, err } e.CommunityID = strOrNil(comm) return &e, nil } func (p *Postgres) UpdateDomainEntry(tenantID, moduleID, entryID string, patch *store.DomainEntryPatch) (*store.DomainEntry, error) { if _, err := p.GetModule(tenantID, moduleID); err != nil { return nil, err } cur, err := p.getDomainEntry(context.Background(), moduleID, entryID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, store.ErrNotFound } return nil, err } if patch.FQDN != nil { cur.FQDN = strings.TrimSpace(*patch.FQDN) } if patch.CommunityID != nil { v := strings.TrimSpace(*patch.CommunityID) if v == "" { cur.CommunityID = nil } else { cur.CommunityID = &v } } ctx := context.Background() _, err = p.pool.Exec(ctx, ` UPDATE module_domain_entry SET fqdn=$3, community_id=NULLIF($4::uuid, '00000000-0000-0000-0000-000000000000'::uuid), updated_at=now() WHERE id=$1 AND module_id=$2`, entryID, moduleID, cur.FQDN, uuidOrNilPtr(cur.CommunityID)) if err != nil { return nil, err } return p.getDomainEntry(ctx, moduleID, entryID) } func (p *Postgres) DeleteDomainEntry(tenantID, moduleID, entryID string) error { if _, err := p.GetModule(tenantID, moduleID); err != nil { return err } ctx := context.Background() tag, err := p.pool.Exec(ctx, `DELETE FROM module_domain_entry WHERE id=$1 AND module_id=$2`, entryID, moduleID) if err != nil { return err } if tag.RowsAffected() == 0 { return store.ErrNotFound } return nil } func (p *Postgres) ListIPRangeEntries(tenantID, moduleID string) ([]*store.IPRangeEntry, error) { mod, err := p.GetModule(tenantID, moduleID) if err != nil { return nil, err } if mod.Type != "IP_RANGES" { return nil, store.ErrInvalidInput } ctx := context.Background() rows, err := p.pool.Query(ctx, `SELECT id::text, prefix::text, community_id::text FROM module_ip_range_entry WHERE module_id=$1`, moduleID) if err != nil { return nil, err } defer rows.Close() var out []*store.IPRangeEntry for rows.Next() { var e store.IPRangeEntry e.ModuleID = moduleID var comm *string if err := rows.Scan(&e.ID, &e.Prefix, &comm); err != nil { continue } e.CommunityID = strOrNil(comm) out = append(out, &e) } return out, nil } func (p *Postgres) CreateIPRangeEntry(tenantID, moduleID string, in *store.IPRangeEntry) (*store.IPRangeEntry, error) { mod, err := p.GetModule(tenantID, moduleID) if err != nil { return nil, err } if mod.Type != "IP_RANGES" || in == nil || strings.TrimSpace(in.Prefix) == "" { return nil, store.ErrInvalidInput } ctx := context.Background() id := uuid.NewString() _, err = p.pool.Exec(ctx, ` INSERT INTO module_ip_range_entry (id, module_id, prefix, community_id) VALUES ($1,$2,$3::cidr, NULLIF($4::uuid, '00000000-0000-0000-0000-000000000000'::uuid))`, id, moduleID, strings.TrimSpace(in.Prefix), uuidOrNilPtr(in.CommunityID)) if err != nil { return nil, err } return p.getIPRangeEntry(ctx, moduleID, id) } func (p *Postgres) getIPRangeEntry(ctx context.Context, moduleID, id string) (*store.IPRangeEntry, error) { var e store.IPRangeEntry e.ModuleID = moduleID var comm *string err := p.pool.QueryRow(ctx, `SELECT id::text, prefix::text, community_id::text FROM module_ip_range_entry WHERE id=$1 AND module_id=$2`, id, moduleID).Scan(&e.ID, &e.Prefix, &comm) if err != nil { return nil, err } e.CommunityID = strOrNil(comm) return &e, nil } func (p *Postgres) UpdateIPRangeEntry(tenantID, moduleID, entryID string, patch *store.IPRangePatch) (*store.IPRangeEntry, error) { if _, err := p.GetModule(tenantID, moduleID); err != nil { return nil, err } cur, err := p.getIPRangeEntry(context.Background(), moduleID, entryID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, store.ErrNotFound } return nil, err } if patch.Prefix != nil { cur.Prefix = strings.TrimSpace(*patch.Prefix) } if patch.CommunityID != nil { v := strings.TrimSpace(*patch.CommunityID) if v == "" { cur.CommunityID = nil } else { cur.CommunityID = &v } } ctx := context.Background() _, err = p.pool.Exec(ctx, ` UPDATE module_ip_range_entry SET prefix=$3::cidr, community_id=NULLIF($4::uuid, '00000000-0000-0000-0000-000000000000'::uuid), updated_at=now() WHERE id=$1 AND module_id=$2`, entryID, moduleID, cur.Prefix, uuidOrNilPtr(cur.CommunityID)) if err != nil { return nil, err } return p.getIPRangeEntry(ctx, moduleID, entryID) } func (p *Postgres) DeleteIPRangeEntry(tenantID, moduleID, entryID string) error { if _, err := p.GetModule(tenantID, moduleID); err != nil { return err } ctx := context.Background() tag, err := p.pool.Exec(ctx, `DELETE FROM module_ip_range_entry WHERE id=$1 AND module_id=$2`, entryID, moduleID) if err != nil { return err } if tag.RowsAffected() == 0 { return store.ErrNotFound } return nil } func (p *Postgres) ListGlobalSettings(tenantID string) (map[string]any, error) { if _, err := uuid.Parse(tenantID); err != nil { return nil, store.ErrInvalidInput } ctx := context.Background() rows, err := p.pool.Query(ctx, `SELECT key, value_json FROM global_settings WHERE tenant_id=$1`, tenantID) if err != nil { return nil, err } defer rows.Close() out := make(map[string]any) for rows.Next() { var k string var vj []byte if err := rows.Scan(&k, &vj); err != nil { continue } var v any _ = json.Unmarshal(vj, &v) out[k] = v } return out, nil } func (p *Postgres) PatchGlobalSettings(tenantID string, patch map[string]any) error { if _, err := uuid.Parse(tenantID); err != nil { return store.ErrInvalidInput } if patch == nil { return nil } ctx := context.Background() for k, v := range patch { if strings.TrimSpace(k) == "" { continue } if v == nil { _, _ = p.pool.Exec(ctx, `DELETE FROM global_settings WHERE tenant_id=$1 AND key=$2`, tenantID, k) continue } b, err := json.Marshal(v) if err != nil { return err } _, err = p.pool.Exec(ctx, ` INSERT INTO global_settings (tenant_id, key, value_json) VALUES ($1,$2,$3::jsonb) ON CONFLICT (tenant_id, key) DO UPDATE SET value_json = EXCLUDED.value_json, updated_at = now()`, tenantID, k, string(b)) if err != nil { return err } } return nil } var _ store.Backend = (*Postgres)(nil)