feat(report): ship daily report v1 experience
This commit is contained in:
@@ -4,9 +4,12 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
@@ -24,7 +27,7 @@ type modelResponse struct {
|
||||
Currency string `json:"currency"`
|
||||
IsFree bool `json:"isFree"`
|
||||
Stale bool `json:"stale"`
|
||||
DataConfidence string `json:"dataConfidence"`
|
||||
DataConfidence string `json:"dataConfidence"`
|
||||
}
|
||||
|
||||
type subscriptionPlanResponse struct {
|
||||
@@ -54,6 +57,21 @@ type apiEnvelope struct {
|
||||
|
||||
type modelFetcher func(context.Context, *sql.DB) ([]modelResponse, error)
|
||||
type subscriptionPlanFetcher func(context.Context, *sql.DB) ([]subscriptionPlanResponse, error)
|
||||
type latestReportFetcher func(context.Context, *sql.DB) (*latestReportResponse, error)
|
||||
|
||||
type latestReportResponse struct {
|
||||
ReportDate string `json:"reportDate"`
|
||||
Status string `json:"status"`
|
||||
ModelCount int `json:"modelCount"`
|
||||
SummaryMD string `json:"summaryMD"`
|
||||
MarkdownPath string `json:"markdownPath"`
|
||||
HTMLPath string `json:"htmlPath"`
|
||||
ArchiveMarkdownPath string `json:"archiveMarkdownPath"`
|
||||
ArchiveHTMLPath string `json:"archiveHtmlPath"`
|
||||
MarkdownURL string `json:"markdownUrl"`
|
||||
HTMLURL string `json:"htmlUrl"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
addr := os.Getenv("PORT")
|
||||
@@ -75,7 +93,7 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
mux := newMux(db, fetchModels, fetchSubscriptionPlans)
|
||||
mux := newMux(db, fetchModels, fetchSubscriptionPlans, fetchLatestReport)
|
||||
|
||||
log.Printf("server listening on :%s", addr)
|
||||
if err := http.ListenAndServe(":"+addr, mux); err != nil {
|
||||
@@ -83,7 +101,7 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func newMux(db *sql.DB, fetchModelsFn modelFetcher, fetchPlansFn subscriptionPlanFetcher) *http.ServeMux {
|
||||
func newMux(db *sql.DB, fetchModelsFn modelFetcher, fetchPlansFn subscriptionPlanFetcher, fetchLatestReportFn latestReportFetcher) *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
if db == nil {
|
||||
@@ -122,6 +140,29 @@ func newMux(db *sql.DB, fetchModelsFn modelFetcher, fetchPlansFn subscriptionPla
|
||||
}
|
||||
writeJSON(w, http.StatusOK, apiEnvelope{Data: plans})
|
||||
})
|
||||
mux.HandleFunc("/api/v1/reports/latest/html", func(w http.ResponseWriter, r *http.Request) {
|
||||
serveLatestReportArtifact(w, r, db, fetchLatestReportFn, "html")
|
||||
})
|
||||
mux.HandleFunc("/api/v1/reports/latest/markdown", func(w http.ResponseWriter, r *http.Request) {
|
||||
serveLatestReportArtifact(w, r, db, fetchLatestReportFn, "markdown")
|
||||
})
|
||||
mux.HandleFunc("/api/v1/reports/latest", func(w http.ResponseWriter, r *http.Request) {
|
||||
if db == nil {
|
||||
http.Error(w, "database not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
report, err := fetchLatestReportFn(r.Context(), db)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
http.Error(w, "latest report not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "query failed", http.StatusInternalServerError)
|
||||
log.Printf("fetch latest report failed: %v", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, apiEnvelope{Data: report})
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
@@ -129,15 +170,16 @@ func fetchModels(ctx context.Context, db *sql.DB) ([]modelResponse, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
WITH latest_prices AS (
|
||||
SELECT
|
||||
model_id,
|
||||
input_price_per_mtok,
|
||||
output_price_per_mtok,
|
||||
currency,
|
||||
rp.model_id,
|
||||
rp.input_price_per_mtok,
|
||||
rp.output_price_per_mtok,
|
||||
rp.currency,
|
||||
rp.is_free,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY model_id
|
||||
ORDER BY effective_date DESC NULLS LAST, id DESC
|
||||
PARTITION BY rp.model_id
|
||||
ORDER BY rp.effective_date DESC NULLS LAST, rp.id DESC
|
||||
) AS rn
|
||||
FROM model_prices
|
||||
FROM region_pricing rp
|
||||
)
|
||||
SELECT
|
||||
m.external_id,
|
||||
@@ -149,7 +191,7 @@ func fetchModels(ctx context.Context, db *sql.DB) ([]modelResponse, error) {
|
||||
lp.input_price_per_mtok,
|
||||
lp.output_price_per_mtok,
|
||||
COALESCE(lp.currency, 'USD'),
|
||||
COALESCE(m.is_free, false),
|
||||
COALESCE(lp.is_free, m.is_free, false),
|
||||
COALESCE(m.data_confidence, 'official')
|
||||
FROM models m
|
||||
LEFT JOIN model_provider mp ON mp.id = m.provider_id
|
||||
@@ -196,6 +238,98 @@ func fetchModels(ctx context.Context, db *sql.DB) ([]modelResponse, error) {
|
||||
return models, rows.Err()
|
||||
}
|
||||
|
||||
func fetchLatestReport(ctx context.Context, db *sql.DB) (*latestReportResponse, error) {
|
||||
var report latestReportResponse
|
||||
var markdownPath string
|
||||
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT
|
||||
TO_CHAR(report_date, 'YYYY-MM-DD'),
|
||||
status,
|
||||
COALESCE(model_count, 0),
|
||||
COALESCE(summary_md, ''),
|
||||
COALESCE(output_path, ''),
|
||||
COALESCE(TO_CHAR(updated_at, 'YYYY-MM-DD"T"HH24:MI:SS'), '')
|
||||
FROM daily_report
|
||||
WHERE output_path IS NOT NULL
|
||||
AND output_path <> ''
|
||||
ORDER BY report_date DESC, updated_at DESC
|
||||
LIMIT 1
|
||||
`).Scan(
|
||||
&report.ReportDate,
|
||||
&report.Status,
|
||||
&report.ModelCount,
|
||||
&report.SummaryMD,
|
||||
&markdownPath,
|
||||
&report.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
report.MarkdownPath = filepath.ToSlash(markdownPath)
|
||||
report.HTMLPath = deriveReportHTMLPath(markdownPath, report.ReportDate)
|
||||
report.ArchiveMarkdownPath = deriveReportArchivePath(markdownPath, report.ReportDate)
|
||||
report.ArchiveHTMLPath = deriveReportArchivePath(report.HTMLPath, report.ReportDate)
|
||||
report.MarkdownURL = "/api/v1/reports/latest/markdown"
|
||||
report.HTMLURL = "/api/v1/reports/latest/html"
|
||||
return &report, nil
|
||||
}
|
||||
|
||||
func serveLatestReportArtifact(w http.ResponseWriter, r *http.Request, db *sql.DB, fetchLatestReportFn latestReportFetcher, artifactType string) {
|
||||
if db == nil {
|
||||
http.Error(w, "database not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
report, err := fetchLatestReportFn(r.Context(), db)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
http.Error(w, "latest report not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "query failed", http.StatusInternalServerError)
|
||||
log.Printf("fetch latest report failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
targetPath := report.MarkdownPath
|
||||
if artifactType == "html" {
|
||||
targetPath = report.HTMLPath
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
} else {
|
||||
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(targetPath); err != nil {
|
||||
http.Error(w, "report artifact not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
http.ServeFile(w, r, targetPath)
|
||||
}
|
||||
|
||||
func deriveReportHTMLPath(markdownPath, reportDate string) string {
|
||||
reportFile := filepath.Base(markdownPath)
|
||||
if reportFile == "." || reportFile == "" {
|
||||
reportFile = fmt.Sprintf("daily_report_%s.md", reportDate)
|
||||
}
|
||||
htmlFile := strings.TrimSuffix(reportFile, filepath.Ext(reportFile)) + ".html"
|
||||
reportDir := filepath.Dir(markdownPath)
|
||||
if reportDir == "." || reportDir == "" {
|
||||
reportDir = "reports/daily"
|
||||
}
|
||||
return filepath.ToSlash(filepath.Join(reportDir, "html", htmlFile))
|
||||
}
|
||||
|
||||
func deriveReportArchivePath(reportPath, reportDate string) string {
|
||||
reportFile := filepath.Base(reportPath)
|
||||
if reportFile == "." || reportFile == "" {
|
||||
reportFile = fmt.Sprintf("daily_report_%s.md", reportDate)
|
||||
}
|
||||
return filepath.ToSlash(filepath.Join("reports/daily", reportDate[:4], reportDate[5:7], reportFile))
|
||||
}
|
||||
|
||||
func fetchSubscriptionPlans(ctx context.Context, db *sql.DB) ([]subscriptionPlanResponse, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -38,6 +39,9 @@ func TestSubscriptionPlansHandlerReturnsEnvelope(t *testing.T) {
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
func(context.Context, *sql.DB) (*latestReportResponse, error) {
|
||||
return nil, sql.ErrNoRows
|
||||
},
|
||||
)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/subscription-plans", nil)
|
||||
@@ -76,3 +80,85 @@ func TestSubscriptionPlansHandlerReturnsEnvelope(t *testing.T) {
|
||||
t.Fatalf("unexpected model scope length: %d", len(got.ModelScope))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestReportHandlerReturnsEnvelope(t *testing.T) {
|
||||
mux := newMux(
|
||||
&sql.DB{},
|
||||
func(context.Context, *sql.DB) ([]modelResponse, error) {
|
||||
return nil, nil
|
||||
},
|
||||
func(context.Context, *sql.DB) ([]subscriptionPlanResponse, error) {
|
||||
return nil, nil
|
||||
},
|
||||
func(context.Context, *sql.DB) (*latestReportResponse, error) {
|
||||
return &latestReportResponse{
|
||||
ReportDate: "2026-05-13",
|
||||
Status: "generated",
|
||||
ModelCount: 504,
|
||||
MarkdownPath: "reports/daily/daily_report_2026-05-13.md",
|
||||
HTMLPath: "reports/daily/html/daily_report_2026-05-13.html",
|
||||
MarkdownURL: "/api/v1/reports/latest/markdown",
|
||||
HTMLURL: "/api/v1/reports/latest/html",
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/reports/latest", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Data latestReportResponse `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if payload.Data.ReportDate != "2026-05-13" {
|
||||
t.Fatalf("unexpected report date: %q", payload.Data.ReportDate)
|
||||
}
|
||||
if payload.Data.HTMLURL != "/api/v1/reports/latest/html" {
|
||||
t.Fatalf("unexpected html url: %q", payload.Data.HTMLURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestReportHTMLHandlerServesArtifact(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
htmlPath := tempDir + "/daily_report_2026-05-13.html"
|
||||
if err := os.WriteFile(htmlPath, []byte("<html><body>ok</body></html>"), 0644); err != nil {
|
||||
t.Fatalf("write temp html: %v", err)
|
||||
}
|
||||
|
||||
mux := newMux(
|
||||
&sql.DB{},
|
||||
func(context.Context, *sql.DB) ([]modelResponse, error) {
|
||||
return nil, nil
|
||||
},
|
||||
func(context.Context, *sql.DB) ([]subscriptionPlanResponse, error) {
|
||||
return nil, nil
|
||||
},
|
||||
func(context.Context, *sql.DB) (*latestReportResponse, error) {
|
||||
return &latestReportResponse{
|
||||
ReportDate: "2026-05-13",
|
||||
Status: "generated",
|
||||
MarkdownPath: tempDir + "/daily_report_2026-05-13.md",
|
||||
HTMLPath: htmlPath,
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/reports/latest/html", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", rec.Code)
|
||||
}
|
||||
if body := rec.Body.String(); body != "<html><body>ok</body></html>" {
|
||||
t.Fatalf("unexpected body: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user