ops_repo_alerts.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853
  1. package repository
  2. import (
  3. "context"
  4. "database/sql"
  5. "encoding/json"
  6. "fmt"
  7. "strings"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/service"
  10. )
  11. func (r *opsRepository) ListAlertRules(ctx context.Context) ([]*service.OpsAlertRule, error) {
  12. if r == nil || r.db == nil {
  13. return nil, fmt.Errorf("nil ops repository")
  14. }
  15. q := `
  16. SELECT
  17. id,
  18. name,
  19. COALESCE(description, ''),
  20. enabled,
  21. COALESCE(severity, ''),
  22. metric_type,
  23. operator,
  24. threshold,
  25. window_minutes,
  26. sustained_minutes,
  27. cooldown_minutes,
  28. COALESCE(notify_email, true),
  29. filters,
  30. last_triggered_at,
  31. created_at,
  32. updated_at
  33. FROM ops_alert_rules
  34. ORDER BY id DESC`
  35. rows, err := r.db.QueryContext(ctx, q)
  36. if err != nil {
  37. return nil, err
  38. }
  39. defer func() { _ = rows.Close() }()
  40. out := []*service.OpsAlertRule{}
  41. for rows.Next() {
  42. var rule service.OpsAlertRule
  43. var filtersRaw []byte
  44. var lastTriggeredAt sql.NullTime
  45. if err := rows.Scan(
  46. &rule.ID,
  47. &rule.Name,
  48. &rule.Description,
  49. &rule.Enabled,
  50. &rule.Severity,
  51. &rule.MetricType,
  52. &rule.Operator,
  53. &rule.Threshold,
  54. &rule.WindowMinutes,
  55. &rule.SustainedMinutes,
  56. &rule.CooldownMinutes,
  57. &rule.NotifyEmail,
  58. &filtersRaw,
  59. &lastTriggeredAt,
  60. &rule.CreatedAt,
  61. &rule.UpdatedAt,
  62. ); err != nil {
  63. return nil, err
  64. }
  65. if lastTriggeredAt.Valid {
  66. v := lastTriggeredAt.Time
  67. rule.LastTriggeredAt = &v
  68. }
  69. if len(filtersRaw) > 0 && string(filtersRaw) != "null" {
  70. var decoded map[string]any
  71. if err := json.Unmarshal(filtersRaw, &decoded); err == nil {
  72. rule.Filters = decoded
  73. }
  74. }
  75. out = append(out, &rule)
  76. }
  77. if err := rows.Err(); err != nil {
  78. return nil, err
  79. }
  80. return out, nil
  81. }
  82. func (r *opsRepository) CreateAlertRule(ctx context.Context, input *service.OpsAlertRule) (*service.OpsAlertRule, error) {
  83. if r == nil || r.db == nil {
  84. return nil, fmt.Errorf("nil ops repository")
  85. }
  86. if input == nil {
  87. return nil, fmt.Errorf("nil input")
  88. }
  89. filtersArg, err := opsNullJSONMap(input.Filters)
  90. if err != nil {
  91. return nil, err
  92. }
  93. q := `
  94. INSERT INTO ops_alert_rules (
  95. name,
  96. description,
  97. enabled,
  98. severity,
  99. metric_type,
  100. operator,
  101. threshold,
  102. window_minutes,
  103. sustained_minutes,
  104. cooldown_minutes,
  105. notify_email,
  106. filters,
  107. created_at,
  108. updated_at
  109. ) VALUES (
  110. $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NOW(),NOW()
  111. )
  112. RETURNING
  113. id,
  114. name,
  115. COALESCE(description, ''),
  116. enabled,
  117. COALESCE(severity, ''),
  118. metric_type,
  119. operator,
  120. threshold,
  121. window_minutes,
  122. sustained_minutes,
  123. cooldown_minutes,
  124. COALESCE(notify_email, true),
  125. filters,
  126. last_triggered_at,
  127. created_at,
  128. updated_at`
  129. var out service.OpsAlertRule
  130. var filtersRaw []byte
  131. var lastTriggeredAt sql.NullTime
  132. if err := r.db.QueryRowContext(
  133. ctx,
  134. q,
  135. strings.TrimSpace(input.Name),
  136. strings.TrimSpace(input.Description),
  137. input.Enabled,
  138. strings.TrimSpace(input.Severity),
  139. strings.TrimSpace(input.MetricType),
  140. strings.TrimSpace(input.Operator),
  141. input.Threshold,
  142. input.WindowMinutes,
  143. input.SustainedMinutes,
  144. input.CooldownMinutes,
  145. input.NotifyEmail,
  146. filtersArg,
  147. ).Scan(
  148. &out.ID,
  149. &out.Name,
  150. &out.Description,
  151. &out.Enabled,
  152. &out.Severity,
  153. &out.MetricType,
  154. &out.Operator,
  155. &out.Threshold,
  156. &out.WindowMinutes,
  157. &out.SustainedMinutes,
  158. &out.CooldownMinutes,
  159. &out.NotifyEmail,
  160. &filtersRaw,
  161. &lastTriggeredAt,
  162. &out.CreatedAt,
  163. &out.UpdatedAt,
  164. ); err != nil {
  165. return nil, err
  166. }
  167. if lastTriggeredAt.Valid {
  168. v := lastTriggeredAt.Time
  169. out.LastTriggeredAt = &v
  170. }
  171. if len(filtersRaw) > 0 && string(filtersRaw) != "null" {
  172. var decoded map[string]any
  173. if err := json.Unmarshal(filtersRaw, &decoded); err == nil {
  174. out.Filters = decoded
  175. }
  176. }
  177. return &out, nil
  178. }
  179. func (r *opsRepository) UpdateAlertRule(ctx context.Context, input *service.OpsAlertRule) (*service.OpsAlertRule, error) {
  180. if r == nil || r.db == nil {
  181. return nil, fmt.Errorf("nil ops repository")
  182. }
  183. if input == nil {
  184. return nil, fmt.Errorf("nil input")
  185. }
  186. if input.ID <= 0 {
  187. return nil, fmt.Errorf("invalid id")
  188. }
  189. filtersArg, err := opsNullJSONMap(input.Filters)
  190. if err != nil {
  191. return nil, err
  192. }
  193. q := `
  194. UPDATE ops_alert_rules
  195. SET
  196. name = $2,
  197. description = $3,
  198. enabled = $4,
  199. severity = $5,
  200. metric_type = $6,
  201. operator = $7,
  202. threshold = $8,
  203. window_minutes = $9,
  204. sustained_minutes = $10,
  205. cooldown_minutes = $11,
  206. notify_email = $12,
  207. filters = $13,
  208. updated_at = NOW()
  209. WHERE id = $1
  210. RETURNING
  211. id,
  212. name,
  213. COALESCE(description, ''),
  214. enabled,
  215. COALESCE(severity, ''),
  216. metric_type,
  217. operator,
  218. threshold,
  219. window_minutes,
  220. sustained_minutes,
  221. cooldown_minutes,
  222. COALESCE(notify_email, true),
  223. filters,
  224. last_triggered_at,
  225. created_at,
  226. updated_at`
  227. var out service.OpsAlertRule
  228. var filtersRaw []byte
  229. var lastTriggeredAt sql.NullTime
  230. if err := r.db.QueryRowContext(
  231. ctx,
  232. q,
  233. input.ID,
  234. strings.TrimSpace(input.Name),
  235. strings.TrimSpace(input.Description),
  236. input.Enabled,
  237. strings.TrimSpace(input.Severity),
  238. strings.TrimSpace(input.MetricType),
  239. strings.TrimSpace(input.Operator),
  240. input.Threshold,
  241. input.WindowMinutes,
  242. input.SustainedMinutes,
  243. input.CooldownMinutes,
  244. input.NotifyEmail,
  245. filtersArg,
  246. ).Scan(
  247. &out.ID,
  248. &out.Name,
  249. &out.Description,
  250. &out.Enabled,
  251. &out.Severity,
  252. &out.MetricType,
  253. &out.Operator,
  254. &out.Threshold,
  255. &out.WindowMinutes,
  256. &out.SustainedMinutes,
  257. &out.CooldownMinutes,
  258. &out.NotifyEmail,
  259. &filtersRaw,
  260. &lastTriggeredAt,
  261. &out.CreatedAt,
  262. &out.UpdatedAt,
  263. ); err != nil {
  264. return nil, err
  265. }
  266. if lastTriggeredAt.Valid {
  267. v := lastTriggeredAt.Time
  268. out.LastTriggeredAt = &v
  269. }
  270. if len(filtersRaw) > 0 && string(filtersRaw) != "null" {
  271. var decoded map[string]any
  272. if err := json.Unmarshal(filtersRaw, &decoded); err == nil {
  273. out.Filters = decoded
  274. }
  275. }
  276. return &out, nil
  277. }
  278. func (r *opsRepository) DeleteAlertRule(ctx context.Context, id int64) error {
  279. if r == nil || r.db == nil {
  280. return fmt.Errorf("nil ops repository")
  281. }
  282. if id <= 0 {
  283. return fmt.Errorf("invalid id")
  284. }
  285. res, err := r.db.ExecContext(ctx, "DELETE FROM ops_alert_rules WHERE id = $1", id)
  286. if err != nil {
  287. return err
  288. }
  289. affected, err := res.RowsAffected()
  290. if err != nil {
  291. return err
  292. }
  293. if affected == 0 {
  294. return sql.ErrNoRows
  295. }
  296. return nil
  297. }
  298. func (r *opsRepository) ListAlertEvents(ctx context.Context, filter *service.OpsAlertEventFilter) ([]*service.OpsAlertEvent, error) {
  299. if r == nil || r.db == nil {
  300. return nil, fmt.Errorf("nil ops repository")
  301. }
  302. if filter == nil {
  303. filter = &service.OpsAlertEventFilter{}
  304. }
  305. limit := filter.Limit
  306. if limit <= 0 {
  307. limit = 100
  308. }
  309. if limit > 500 {
  310. limit = 500
  311. }
  312. where, args := buildOpsAlertEventsWhere(filter)
  313. args = append(args, limit)
  314. limitArg := "$" + itoa(len(args))
  315. q := `
  316. SELECT
  317. id,
  318. COALESCE(rule_id, 0),
  319. COALESCE(severity, ''),
  320. COALESCE(status, ''),
  321. COALESCE(title, ''),
  322. COALESCE(description, ''),
  323. metric_value,
  324. threshold_value,
  325. dimensions,
  326. fired_at,
  327. resolved_at,
  328. email_sent,
  329. created_at
  330. FROM ops_alert_events
  331. ` + where + `
  332. ORDER BY fired_at DESC, id DESC
  333. LIMIT ` + limitArg
  334. rows, err := r.db.QueryContext(ctx, q, args...)
  335. if err != nil {
  336. return nil, err
  337. }
  338. defer func() { _ = rows.Close() }()
  339. out := []*service.OpsAlertEvent{}
  340. for rows.Next() {
  341. var ev service.OpsAlertEvent
  342. var metricValue sql.NullFloat64
  343. var thresholdValue sql.NullFloat64
  344. var dimensionsRaw []byte
  345. var resolvedAt sql.NullTime
  346. if err := rows.Scan(
  347. &ev.ID,
  348. &ev.RuleID,
  349. &ev.Severity,
  350. &ev.Status,
  351. &ev.Title,
  352. &ev.Description,
  353. &metricValue,
  354. &thresholdValue,
  355. &dimensionsRaw,
  356. &ev.FiredAt,
  357. &resolvedAt,
  358. &ev.EmailSent,
  359. &ev.CreatedAt,
  360. ); err != nil {
  361. return nil, err
  362. }
  363. if metricValue.Valid {
  364. v := metricValue.Float64
  365. ev.MetricValue = &v
  366. }
  367. if thresholdValue.Valid {
  368. v := thresholdValue.Float64
  369. ev.ThresholdValue = &v
  370. }
  371. if resolvedAt.Valid {
  372. v := resolvedAt.Time
  373. ev.ResolvedAt = &v
  374. }
  375. if len(dimensionsRaw) > 0 && string(dimensionsRaw) != "null" {
  376. var decoded map[string]any
  377. if err := json.Unmarshal(dimensionsRaw, &decoded); err == nil {
  378. ev.Dimensions = decoded
  379. }
  380. }
  381. out = append(out, &ev)
  382. }
  383. if err := rows.Err(); err != nil {
  384. return nil, err
  385. }
  386. return out, nil
  387. }
  388. func (r *opsRepository) GetAlertEventByID(ctx context.Context, eventID int64) (*service.OpsAlertEvent, error) {
  389. if r == nil || r.db == nil {
  390. return nil, fmt.Errorf("nil ops repository")
  391. }
  392. if eventID <= 0 {
  393. return nil, fmt.Errorf("invalid event id")
  394. }
  395. q := `
  396. SELECT
  397. id,
  398. COALESCE(rule_id, 0),
  399. COALESCE(severity, ''),
  400. COALESCE(status, ''),
  401. COALESCE(title, ''),
  402. COALESCE(description, ''),
  403. metric_value,
  404. threshold_value,
  405. dimensions,
  406. fired_at,
  407. resolved_at,
  408. email_sent,
  409. created_at
  410. FROM ops_alert_events
  411. WHERE id = $1`
  412. row := r.db.QueryRowContext(ctx, q, eventID)
  413. ev, err := scanOpsAlertEvent(row)
  414. if err != nil {
  415. if err == sql.ErrNoRows {
  416. return nil, nil
  417. }
  418. return nil, err
  419. }
  420. return ev, nil
  421. }
  422. func (r *opsRepository) GetActiveAlertEvent(ctx context.Context, ruleID int64) (*service.OpsAlertEvent, error) {
  423. if r == nil || r.db == nil {
  424. return nil, fmt.Errorf("nil ops repository")
  425. }
  426. if ruleID <= 0 {
  427. return nil, fmt.Errorf("invalid rule id")
  428. }
  429. q := `
  430. SELECT
  431. id,
  432. COALESCE(rule_id, 0),
  433. COALESCE(severity, ''),
  434. COALESCE(status, ''),
  435. COALESCE(title, ''),
  436. COALESCE(description, ''),
  437. metric_value,
  438. threshold_value,
  439. dimensions,
  440. fired_at,
  441. resolved_at,
  442. email_sent,
  443. created_at
  444. FROM ops_alert_events
  445. WHERE rule_id = $1 AND status = $2
  446. ORDER BY fired_at DESC
  447. LIMIT 1`
  448. row := r.db.QueryRowContext(ctx, q, ruleID, service.OpsAlertStatusFiring)
  449. ev, err := scanOpsAlertEvent(row)
  450. if err != nil {
  451. if err == sql.ErrNoRows {
  452. return nil, nil
  453. }
  454. return nil, err
  455. }
  456. return ev, nil
  457. }
  458. func (r *opsRepository) GetLatestAlertEvent(ctx context.Context, ruleID int64) (*service.OpsAlertEvent, error) {
  459. if r == nil || r.db == nil {
  460. return nil, fmt.Errorf("nil ops repository")
  461. }
  462. if ruleID <= 0 {
  463. return nil, fmt.Errorf("invalid rule id")
  464. }
  465. q := `
  466. SELECT
  467. id,
  468. COALESCE(rule_id, 0),
  469. COALESCE(severity, ''),
  470. COALESCE(status, ''),
  471. COALESCE(title, ''),
  472. COALESCE(description, ''),
  473. metric_value,
  474. threshold_value,
  475. dimensions,
  476. fired_at,
  477. resolved_at,
  478. email_sent,
  479. created_at
  480. FROM ops_alert_events
  481. WHERE rule_id = $1
  482. ORDER BY fired_at DESC
  483. LIMIT 1`
  484. row := r.db.QueryRowContext(ctx, q, ruleID)
  485. ev, err := scanOpsAlertEvent(row)
  486. if err != nil {
  487. if err == sql.ErrNoRows {
  488. return nil, nil
  489. }
  490. return nil, err
  491. }
  492. return ev, nil
  493. }
  494. func (r *opsRepository) CreateAlertEvent(ctx context.Context, event *service.OpsAlertEvent) (*service.OpsAlertEvent, error) {
  495. if r == nil || r.db == nil {
  496. return nil, fmt.Errorf("nil ops repository")
  497. }
  498. if event == nil {
  499. return nil, fmt.Errorf("nil event")
  500. }
  501. dimensionsArg, err := opsNullJSONMap(event.Dimensions)
  502. if err != nil {
  503. return nil, err
  504. }
  505. q := `
  506. INSERT INTO ops_alert_events (
  507. rule_id,
  508. severity,
  509. status,
  510. title,
  511. description,
  512. metric_value,
  513. threshold_value,
  514. dimensions,
  515. fired_at,
  516. resolved_at,
  517. email_sent,
  518. created_at
  519. ) VALUES (
  520. $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,NOW()
  521. )
  522. RETURNING
  523. id,
  524. COALESCE(rule_id, 0),
  525. COALESCE(severity, ''),
  526. COALESCE(status, ''),
  527. COALESCE(title, ''),
  528. COALESCE(description, ''),
  529. metric_value,
  530. threshold_value,
  531. dimensions,
  532. fired_at,
  533. resolved_at,
  534. email_sent,
  535. created_at`
  536. row := r.db.QueryRowContext(
  537. ctx,
  538. q,
  539. opsNullInt64(&event.RuleID),
  540. opsNullString(event.Severity),
  541. opsNullString(event.Status),
  542. opsNullString(event.Title),
  543. opsNullString(event.Description),
  544. opsNullFloat64(event.MetricValue),
  545. opsNullFloat64(event.ThresholdValue),
  546. dimensionsArg,
  547. event.FiredAt,
  548. opsNullTime(event.ResolvedAt),
  549. event.EmailSent,
  550. )
  551. return scanOpsAlertEvent(row)
  552. }
  553. func (r *opsRepository) UpdateAlertEventStatus(ctx context.Context, eventID int64, status string, resolvedAt *time.Time) error {
  554. if r == nil || r.db == nil {
  555. return fmt.Errorf("nil ops repository")
  556. }
  557. if eventID <= 0 {
  558. return fmt.Errorf("invalid event id")
  559. }
  560. if strings.TrimSpace(status) == "" {
  561. return fmt.Errorf("invalid status")
  562. }
  563. q := `
  564. UPDATE ops_alert_events
  565. SET status = $2,
  566. resolved_at = $3
  567. WHERE id = $1`
  568. _, err := r.db.ExecContext(ctx, q, eventID, strings.TrimSpace(status), opsNullTime(resolvedAt))
  569. return err
  570. }
  571. func (r *opsRepository) UpdateAlertEventEmailSent(ctx context.Context, eventID int64, emailSent bool) error {
  572. if r == nil || r.db == nil {
  573. return fmt.Errorf("nil ops repository")
  574. }
  575. if eventID <= 0 {
  576. return fmt.Errorf("invalid event id")
  577. }
  578. _, err := r.db.ExecContext(ctx, "UPDATE ops_alert_events SET email_sent = $2 WHERE id = $1", eventID, emailSent)
  579. return err
  580. }
  581. type opsAlertEventRow interface {
  582. Scan(dest ...any) error
  583. }
  584. func (r *opsRepository) CreateAlertSilence(ctx context.Context, input *service.OpsAlertSilence) (*service.OpsAlertSilence, error) {
  585. if r == nil || r.db == nil {
  586. return nil, fmt.Errorf("nil ops repository")
  587. }
  588. if input == nil {
  589. return nil, fmt.Errorf("nil input")
  590. }
  591. if input.RuleID <= 0 {
  592. return nil, fmt.Errorf("invalid rule_id")
  593. }
  594. platform := strings.TrimSpace(input.Platform)
  595. if platform == "" {
  596. return nil, fmt.Errorf("invalid platform")
  597. }
  598. if input.Until.IsZero() {
  599. return nil, fmt.Errorf("invalid until")
  600. }
  601. q := `
  602. INSERT INTO ops_alert_silences (
  603. rule_id,
  604. platform,
  605. group_id,
  606. region,
  607. until,
  608. reason,
  609. created_by,
  610. created_at
  611. ) VALUES (
  612. $1,$2,$3,$4,$5,$6,$7,NOW()
  613. )
  614. RETURNING id, rule_id, platform, group_id, region, until, COALESCE(reason,''), created_by, created_at`
  615. row := r.db.QueryRowContext(
  616. ctx,
  617. q,
  618. input.RuleID,
  619. platform,
  620. opsNullInt64(input.GroupID),
  621. opsNullString(input.Region),
  622. input.Until,
  623. opsNullString(input.Reason),
  624. opsNullInt64(input.CreatedBy),
  625. )
  626. var out service.OpsAlertSilence
  627. var groupID sql.NullInt64
  628. var region sql.NullString
  629. var createdBy sql.NullInt64
  630. if err := row.Scan(
  631. &out.ID,
  632. &out.RuleID,
  633. &out.Platform,
  634. &groupID,
  635. &region,
  636. &out.Until,
  637. &out.Reason,
  638. &createdBy,
  639. &out.CreatedAt,
  640. ); err != nil {
  641. return nil, err
  642. }
  643. if groupID.Valid {
  644. v := groupID.Int64
  645. out.GroupID = &v
  646. }
  647. if region.Valid {
  648. v := strings.TrimSpace(region.String)
  649. if v != "" {
  650. out.Region = &v
  651. }
  652. }
  653. if createdBy.Valid {
  654. v := createdBy.Int64
  655. out.CreatedBy = &v
  656. }
  657. return &out, nil
  658. }
  659. func (r *opsRepository) IsAlertSilenced(ctx context.Context, ruleID int64, platform string, groupID *int64, region *string, now time.Time) (bool, error) {
  660. if r == nil || r.db == nil {
  661. return false, fmt.Errorf("nil ops repository")
  662. }
  663. if ruleID <= 0 {
  664. return false, fmt.Errorf("invalid rule id")
  665. }
  666. platform = strings.TrimSpace(platform)
  667. if platform == "" {
  668. return false, nil
  669. }
  670. if now.IsZero() {
  671. now = time.Now().UTC()
  672. }
  673. q := `
  674. SELECT 1
  675. FROM ops_alert_silences
  676. WHERE rule_id = $1
  677. AND platform = $2
  678. AND (group_id IS NOT DISTINCT FROM $3)
  679. AND (region IS NOT DISTINCT FROM $4)
  680. AND until > $5
  681. LIMIT 1`
  682. var dummy int
  683. err := r.db.QueryRowContext(ctx, q, ruleID, platform, opsNullInt64(groupID), opsNullString(region), now).Scan(&dummy)
  684. if err != nil {
  685. if err == sql.ErrNoRows {
  686. return false, nil
  687. }
  688. return false, err
  689. }
  690. return true, nil
  691. }
  692. func scanOpsAlertEvent(row opsAlertEventRow) (*service.OpsAlertEvent, error) {
  693. var ev service.OpsAlertEvent
  694. var metricValue sql.NullFloat64
  695. var thresholdValue sql.NullFloat64
  696. var dimensionsRaw []byte
  697. var resolvedAt sql.NullTime
  698. if err := row.Scan(
  699. &ev.ID,
  700. &ev.RuleID,
  701. &ev.Severity,
  702. &ev.Status,
  703. &ev.Title,
  704. &ev.Description,
  705. &metricValue,
  706. &thresholdValue,
  707. &dimensionsRaw,
  708. &ev.FiredAt,
  709. &resolvedAt,
  710. &ev.EmailSent,
  711. &ev.CreatedAt,
  712. ); err != nil {
  713. return nil, err
  714. }
  715. if metricValue.Valid {
  716. v := metricValue.Float64
  717. ev.MetricValue = &v
  718. }
  719. if thresholdValue.Valid {
  720. v := thresholdValue.Float64
  721. ev.ThresholdValue = &v
  722. }
  723. if resolvedAt.Valid {
  724. v := resolvedAt.Time
  725. ev.ResolvedAt = &v
  726. }
  727. if len(dimensionsRaw) > 0 && string(dimensionsRaw) != "null" {
  728. var decoded map[string]any
  729. if err := json.Unmarshal(dimensionsRaw, &decoded); err == nil {
  730. ev.Dimensions = decoded
  731. }
  732. }
  733. return &ev, nil
  734. }
  735. func buildOpsAlertEventsWhere(filter *service.OpsAlertEventFilter) (string, []any) {
  736. clauses := []string{"1=1"}
  737. args := []any{}
  738. if filter == nil {
  739. return "WHERE " + strings.Join(clauses, " AND "), args
  740. }
  741. if status := strings.TrimSpace(filter.Status); status != "" {
  742. args = append(args, status)
  743. clauses = append(clauses, "status = $"+itoa(len(args)))
  744. }
  745. if severity := strings.TrimSpace(filter.Severity); severity != "" {
  746. args = append(args, severity)
  747. clauses = append(clauses, "severity = $"+itoa(len(args)))
  748. }
  749. if filter.EmailSent != nil {
  750. args = append(args, *filter.EmailSent)
  751. clauses = append(clauses, "email_sent = $"+itoa(len(args)))
  752. }
  753. if filter.StartTime != nil && !filter.StartTime.IsZero() {
  754. args = append(args, *filter.StartTime)
  755. clauses = append(clauses, "fired_at >= $"+itoa(len(args)))
  756. }
  757. if filter.EndTime != nil && !filter.EndTime.IsZero() {
  758. args = append(args, *filter.EndTime)
  759. clauses = append(clauses, "fired_at < $"+itoa(len(args)))
  760. }
  761. // Cursor pagination (descending by fired_at, then id)
  762. if filter.BeforeFiredAt != nil && !filter.BeforeFiredAt.IsZero() && filter.BeforeID != nil && *filter.BeforeID > 0 {
  763. args = append(args, *filter.BeforeFiredAt)
  764. tsArg := "$" + itoa(len(args))
  765. args = append(args, *filter.BeforeID)
  766. idArg := "$" + itoa(len(args))
  767. clauses = append(clauses, fmt.Sprintf("(fired_at < %s OR (fired_at = %s AND id < %s))", tsArg, tsArg, idArg))
  768. }
  769. // Dimensions are stored in JSONB. We filter best-effort without requiring GIN indexes.
  770. if platform := strings.TrimSpace(filter.Platform); platform != "" {
  771. args = append(args, platform)
  772. clauses = append(clauses, "(dimensions->>'platform') = $"+itoa(len(args)))
  773. }
  774. if filter.GroupID != nil && *filter.GroupID > 0 {
  775. args = append(args, fmt.Sprintf("%d", *filter.GroupID))
  776. clauses = append(clauses, "(dimensions->>'group_id') = $"+itoa(len(args)))
  777. }
  778. return "WHERE " + strings.Join(clauses, " AND "), args
  779. }
  780. func opsNullJSONMap(v map[string]any) (any, error) {
  781. if v == nil {
  782. return sql.NullString{}, nil
  783. }
  784. b, err := json.Marshal(v)
  785. if err != nil {
  786. return nil, err
  787. }
  788. if len(b) == 0 {
  789. return sql.NullString{}, nil
  790. }
  791. return sql.NullString{String: string(b), Valid: true}, nil
  792. }