package api import ( "context" "database/sql" "net/http" "strconv" "time" "git.ryuvia.com/niklas/terdut-server/internal/models" ) const ( similarDefaultLimit = 5 similarMaxLimit = 20 ) // handleIncidentSimilar lists earlier, resolved incidents in the same team with // the same signature that someone left notes on, incidents with a resolution // note first. This is the "have we seen this before" answer for a responder // looking at a fresh incident; the plain notes are one timeline fetch away. func handleIncidentSimilar(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id, ok := incidentIDParam(w, r, db) if !ok { return } limit := similarDefaultLimit if v := r.URL.Query().Get("limit"); v != "" { n, err := strconv.Atoi(v) if err != nil || n < 1 { respond(w, http.StatusBadRequest, errResp("invalid limit")) return } limit = min(n, similarMaxLimit) } out, err := similarIncidents(r.Context(), db, id, limit) if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } respond(w, http.StatusOK, out) } } func similarIncidents(ctx context.Context, q querier, id int64, limit int) ([]models.SimilarIncident, error) { rows, err := q.QueryContext(ctx, ` SELECT o.id, o.title, o.triggered_at, o.resolved_at, (SELECT COUNT(*) FROM incident_events e WHERE e.incident_id = o.id AND e.type = $3) FROM incidents i JOIN incidents o ON o.team_id = i.team_id AND o.signature = i.signature WHERE i.id = $1 AND o.id <> i.id AND o.resolved_at IS NOT NULL AND EXISTS (SELECT 1 FROM incident_events e WHERE e.incident_id = o.id AND e.type IN ($3, $4)) ORDER BY EXISTS (SELECT 1 FROM incident_events e WHERE e.incident_id = o.id AND e.type = $4) DESC, o.triggered_at DESC LIMIT $2`, id, limit, evNote, evResolutionNote) if err != nil { return nil, err } defer rows.Close() out := []models.SimilarIncident{} ids := []int64{} for rows.Next() { var s models.SimilarIncident var triggered, resolved int64 if err := rows.Scan(&s.ID, &s.Title, &triggered, &resolved, &s.NoteCount); err != nil { return nil, err } s.TriggeredAt = time.Unix(triggered, 0).UTC() s.ResolvedAt = time.Unix(resolved, 0).UTC() s.ResolutionNotes = []models.IncidentEvent{} out = append(out, s) ids = append(ids, s.ID) } if err := rows.Err(); err != nil { return nil, err } if len(out) == 0 { return out, nil } nrows, err := q.QueryContext(ctx, ` SELECT e.id, e.incident_id, e.type, e.user_id, u.username, e.detail, e.created_at FROM incident_events e LEFT JOIN users u ON u.id = e.user_id WHERE e.incident_id = ANY($1) AND e.type = $2 ORDER BY e.created_at, e.id`, ids, evResolutionNote) if err != nil { return nil, err } defer nrows.Close() byID := make(map[int64]*models.SimilarIncident, len(out)) for i := range out { byID[out[i].ID] = &out[i] } for nrows.Next() { var e models.IncidentEvent var ts int64 if err := nrows.Scan(&e.ID, &e.IncidentID, &e.Type, &e.UserID, &e.Username, &e.Detail, &ts); err != nil { return nil, err } e.CreatedAt = time.Unix(ts, 0).UTC() s := byID[e.IncidentID] s.ResolutionNotes = append(s.ResolutionNotes, e) } return out, nrows.Err() }