data layer

This commit is contained in:
2026-05-25 08:38:26 +07:00
parent 4e8c11d545
commit a428170fef
81 changed files with 3941 additions and 0 deletions

View File

View File

@@ -0,0 +1,132 @@
package handler
import (
"net/http"
"time"
"go.uber.org/zap"
"github.com/dbiz/cdp/data-layer/api/internal/middleware"
"github.com/dbiz/cdp/data-layer/api/internal/repo"
"github.com/dbiz/cdp/data-layer/api/internal/service"
)
type AnalyticsHandler struct {
svc *service.QueryService
log *zap.Logger
}
func NewAnalyticsHandler(svc *service.QueryService, log *zap.Logger) *AnalyticsHandler {
return &AnalyticsHandler{svc: svc, log: log}
}
// ---------------------------------------------------------------------------
// Funnel
// ---------------------------------------------------------------------------
type funnelRequest struct {
Steps []string `json:"steps" validate:"required,min=2,max=10,dive,min=1"`
From *time.Time `json:"from" validate:"required"`
To *time.Time `json:"to" validate:"required,gtfield=From"`
WindowSeconds uint32 `json:"window_seconds" validate:"required,min=1,max=2592000"` // up to 30d
}
func (h *AnalyticsHandler) Funnel(w http.ResponseWriter, r *http.Request) {
var req funnelRequest
if err := decodeAndValidate(r, &req); err != nil {
writeError(w, err)
return
}
ws := middleware.WorkspaceFromCtx(r.Context())
res, err := h.svc.Funnel(r.Context(), repo.FunnelQuery{
WorkspaceID: ws,
Steps: req.Steps,
From: *req.From,
To: *req.To,
WindowSeconds: req.WindowSeconds,
})
if err != nil {
h.log.Error("funnel", zap.String("workspace_id", ws), zap.Error(err))
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, res)
}
// ---------------------------------------------------------------------------
// Retention
// ---------------------------------------------------------------------------
type retentionRequest struct {
InitialEvent string `json:"initial_event" validate:"required,min=1"`
ReturnEvent string `json:"return_event" validate:"required,min=1"`
From *time.Time `json:"from" validate:"required"`
To *time.Time `json:"to" validate:"required,gtfield=From"`
Periods int `json:"periods" validate:"omitempty,min=1,max=90"`
}
func (h *AnalyticsHandler) Retention(w http.ResponseWriter, r *http.Request) {
var req retentionRequest
if err := decodeAndValidate(r, &req); err != nil {
writeError(w, err)
return
}
ws := middleware.WorkspaceFromCtx(r.Context())
res, err := h.svc.Retention(r.Context(), repo.RetentionQuery{
WorkspaceID: ws,
InitialEvent: req.InitialEvent,
ReturnEvent: req.ReturnEvent,
From: *req.From,
To: *req.To,
Periods: req.Periods,
})
if err != nil {
h.log.Error("retention", zap.String("workspace_id", ws), zap.Error(err))
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, res)
}
// ---------------------------------------------------------------------------
// Session
// ---------------------------------------------------------------------------
type sessionRequest struct {
From *time.Time `json:"from" validate:"required"`
To *time.Time `json:"to" validate:"required,gtfield=From"`
TimeoutSeconds uint32 `json:"timeout_seconds" validate:"omitempty,min=60,max=86400"`
UserID string `json:"user_id"`
Limit int `json:"limit" validate:"omitempty,min=1,max=1000"`
Offset int `json:"offset" validate:"omitempty,min=0"`
}
func (h *AnalyticsHandler) Session(w http.ResponseWriter, r *http.Request) {
var req sessionRequest
if err := decodeAndValidate(r, &req); err != nil {
writeError(w, err)
return
}
if req.TimeoutSeconds == 0 {
req.TimeoutSeconds = 30 * 60
}
if req.Limit == 0 {
req.Limit = 100
}
ws := middleware.WorkspaceFromCtx(r.Context())
res, err := h.svc.Sessions(r.Context(), repo.SessionQuery{
WorkspaceID: ws,
UserID: req.UserID,
From: *req.From,
To: *req.To,
TimeoutSeconds: req.TimeoutSeconds,
Limit: req.Limit,
Offset: req.Offset,
})
if err != nil {
h.log.Error("session", zap.String("workspace_id", ws), zap.Error(err))
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, res)
}

View File

@@ -0,0 +1,36 @@
package handler
import (
"encoding/json"
"errors"
"io"
"net/http"
"github.com/go-playground/validator/v10"
"github.com/dbiz/cdp/data-layer/api/internal/apperr"
)
var validate = validator.New(validator.WithRequiredStructEnabled())
// decodeAndValidate reads JSON into `dst`, then runs validator tags. Returns
// a wrapped AppError so handlers can pass it straight to writeError.
func decodeAndValidate(r *http.Request, dst any) error {
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
if errors.Is(err, io.EOF) {
return apperr.BadRequest("request body is empty", "", err)
}
return apperr.BadRequest("invalid JSON: "+err.Error(), "", err)
}
if err := validate.Struct(dst); err != nil {
var verrs validator.ValidationErrors
if errors.As(err, &verrs) && len(verrs) > 0 {
ve := verrs[0]
return apperr.BadRequest("validation failed on "+ve.Field()+": "+ve.Tag(), ve.Field(), err)
}
return apperr.BadRequest("validation failed: "+err.Error(), "", err)
}
return nil
}

View File

@@ -0,0 +1,74 @@
package handler
import (
"net/http"
"time"
"go.uber.org/zap"
"github.com/dbiz/cdp/data-layer/api/internal/middleware"
"github.com/dbiz/cdp/data-layer/api/internal/model"
"github.com/dbiz/cdp/data-layer/api/internal/service"
)
type EventHandler struct {
svc *service.QueryService
log *zap.Logger
}
func NewEventHandler(svc *service.QueryService, log *zap.Logger) *EventHandler {
return &EventHandler{svc: svc, log: log}
}
type queryEventsRequest struct {
Table string `json:"table" validate:"required,oneof=events_track events_identify events_page events_group"`
From *time.Time `json:"from" validate:"required"`
To *time.Time `json:"to" validate:"required,gtfield=From"`
UserID string `json:"user_id"`
AnonymousID string `json:"anonymous_id"`
EventName string `json:"event"`
Limit int `json:"limit" validate:"omitempty,min=1,max=1000"`
Offset int `json:"offset" validate:"omitempty,min=0"`
}
// QueryEvents handles POST /query/events.
func (h *EventHandler) QueryEvents(w http.ResponseWriter, r *http.Request) {
var req queryEventsRequest
if err := decodeAndValidate(r, &req); err != nil {
writeError(w, err)
return
}
if req.Limit == 0 {
req.Limit = 100
}
ws := middleware.WorkspaceFromCtx(r.Context())
res, err := h.svc.Events(r.Context(), model.EventQuery{
WorkspaceID: ws,
Table: model.EventTable(req.Table),
From: *req.From,
To: *req.To,
UserID: req.UserID,
AnonymousID: req.AnonymousID,
EventName: req.EventName,
Limit: req.Limit,
Offset: req.Offset,
})
if err != nil {
h.log.Error("query events",
zap.String("workspace_id", ws),
zap.String("table", req.Table),
zap.Error(err))
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, res)
}
// Health / Ready -- shared between all handlers but parked here for now.
func (h *EventHandler) Health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (h *EventHandler) Ready(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}

View File

@@ -0,0 +1,85 @@
package handler
import (
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/dbiz/cdp/data-layer/api/internal/apperr"
"github.com/dbiz/cdp/data-layer/api/internal/middleware"
"github.com/dbiz/cdp/data-layer/api/internal/service"
)
type ProfileHandler struct {
svc *service.ProfileService
log *zap.Logger
}
func NewProfileHandler(svc *service.ProfileService, log *zap.Logger) *ProfileHandler {
return &ProfileHandler{svc: svc, log: log}
}
// Get handles GET /profiles/:id.
func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
id, err := parseProfileID(r)
if err != nil {
writeError(w, err)
return
}
ws := middleware.WorkspaceFromCtx(r.Context())
p, err := h.svc.Get(r.Context(), ws, id)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, p)
}
// Timeline handles GET /profiles/:id/events.
func (h *ProfileHandler) Timeline(w http.ResponseWriter, r *http.Request) {
id, err := parseProfileID(r)
if err != nil {
writeError(w, err)
return
}
limit, offset := parsePagination(r, 100, 1000)
ws := middleware.WorkspaceFromCtx(r.Context())
res, err := h.svc.Timeline(r.Context(), ws, id, limit, offset)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, res)
}
func parseProfileID(r *http.Request) (string, error) {
raw := chi.URLParam(r, "id")
if raw == "" {
return "", apperr.BadRequest("missing profile id", "id", nil)
}
if _, err := uuid.Parse(raw); err != nil {
return "", apperr.BadRequest("profile id must be uuid", "id", err)
}
return raw, nil
}
// parsePagination reads ?limit & ?offset with bounds. Invalid values fall back
// to the defaults rather than erroring -- the endpoints are GET, not strict.
func parsePagination(r *http.Request, def, max int) (limit, offset int) {
limit = def
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= max {
limit = n
}
}
if v := r.URL.Query().Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
offset = n
}
}
return
}

View File

@@ -0,0 +1,47 @@
// Package handler holds HTTP handlers. Handlers parse the request, call into
// service, and translate the result (or error) into an HTTP response.
package handler
import (
"encoding/json"
"net/http"
"github.com/dbiz/cdp/data-layer/api/internal/apperr"
)
type errorResponse struct {
Error string `json:"error"`
Field string `json:"field,omitempty"`
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func writeError(w http.ResponseWriter, err error) {
if ae, ok := apperr.As(err); ok {
if ae.RetryAfter > 0 {
w.Header().Set("Retry-After", itoa(ae.RetryAfter))
}
writeJSON(w, ae.Code, errorResponse{Error: ae.Message, Field: ae.Field})
return
}
writeJSON(w, http.StatusInternalServerError, errorResponse{Error: "internal server error"})
}
func itoa(i int) string {
const digits = "0123456789"
if i == 0 {
return "0"
}
var buf [20]byte
pos := len(buf)
for i > 0 {
pos--
buf[pos] = digits[i%10]
i /= 10
}
return string(buf[pos:])
}

View File

@@ -0,0 +1,125 @@
package handler
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/dbiz/cdp/data-layer/api/internal/apperr"
"github.com/dbiz/cdp/data-layer/api/internal/middleware"
"github.com/dbiz/cdp/data-layer/api/internal/model"
"github.com/dbiz/cdp/data-layer/api/internal/repo"
)
type SavedQueryHandler struct {
repo *repo.SavedQueryRepo
log *zap.Logger
}
func NewSavedQueryHandler(r *repo.SavedQueryRepo, log *zap.Logger) *SavedQueryHandler {
return &SavedQueryHandler{repo: r, log: log}
}
type createSavedQueryRequest struct {
Name string `json:"name" validate:"required,min=1,max=200"`
Kind string `json:"kind" validate:"required,oneof=events sql funnel retention session"`
Spec map[string]any `json:"spec" validate:"required"`
}
type updateSavedQueryRequest struct {
Name string `json:"name" validate:"required,min=1,max=200"`
Spec map[string]any `json:"spec" validate:"required"`
}
func (h *SavedQueryHandler) Create(w http.ResponseWriter, r *http.Request) {
var req createSavedQueryRequest
if err := decodeAndValidate(r, &req); err != nil {
writeError(w, err)
return
}
ws := middleware.WorkspaceFromCtx(r.Context())
q, err := h.repo.Create(r.Context(), model.SavedQuery{
WorkspaceID: ws,
Name: req.Name,
Kind: req.Kind,
Spec: req.Spec,
})
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusCreated, q)
}
func (h *SavedQueryHandler) List(w http.ResponseWriter, r *http.Request) {
limit, offset := parsePagination(r, 50, 500)
ws := middleware.WorkspaceFromCtx(r.Context())
qs, err := h.repo.List(r.Context(), ws, limit, offset)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": qs, "limit": limit, "offset": offset})
}
func (h *SavedQueryHandler) Get(w http.ResponseWriter, r *http.Request) {
id, err := parseSavedQueryID(r)
if err != nil {
writeError(w, err)
return
}
ws := middleware.WorkspaceFromCtx(r.Context())
q, err := h.repo.Get(r.Context(), ws, id)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, q)
}
func (h *SavedQueryHandler) Update(w http.ResponseWriter, r *http.Request) {
id, err := parseSavedQueryID(r)
if err != nil {
writeError(w, err)
return
}
var req updateSavedQueryRequest
if err := decodeAndValidate(r, &req); err != nil {
writeError(w, err)
return
}
ws := middleware.WorkspaceFromCtx(r.Context())
q, err := h.repo.Update(r.Context(), ws, id, req.Name, req.Spec)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, q)
}
func (h *SavedQueryHandler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := parseSavedQueryID(r)
if err != nil {
writeError(w, err)
return
}
ws := middleware.WorkspaceFromCtx(r.Context())
if err := h.repo.Delete(r.Context(), ws, id); err != nil {
writeError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func parseSavedQueryID(r *http.Request) (string, error) {
raw := chi.URLParam(r, "id")
if raw == "" {
return "", apperr.BadRequest("missing query id", "id", nil)
}
if _, err := uuid.Parse(raw); err != nil {
return "", apperr.BadRequest("query id must be uuid", "id", err)
}
return raw, nil
}

View File

@@ -0,0 +1,47 @@
package handler
import (
"net/http"
"go.uber.org/zap"
"github.com/dbiz/cdp/data-layer/api/internal/middleware"
"github.com/dbiz/cdp/data-layer/api/internal/service"
)
type SQLHandler struct {
svc *service.SQLService
log *zap.Logger
}
func NewSQLHandler(svc *service.SQLService, log *zap.Logger) *SQLHandler {
return &SQLHandler{svc: svc, log: log}
}
type customSQLRequest struct {
SQL string `json:"sql" validate:"required,min=1,max=20000"`
}
// CustomSQL handles POST /query/sql.
func (h *SQLHandler) CustomSQL(w http.ResponseWriter, r *http.Request) {
var req customSQLRequest
if err := decodeAndValidate(r, &req); err != nil {
writeError(w, err)
return
}
ws := middleware.WorkspaceFromCtx(r.Context())
res, err := h.svc.Run(r.Context(), req.SQL)
if err != nil {
h.log.Warn("custom sql rejected",
zap.String("workspace_id", ws),
zap.Error(err))
writeError(w, err)
return
}
h.log.Info("custom sql ok",
zap.String("workspace_id", ws),
zap.Int("rows", res.RowCount),
zap.Int64("duration_ms", res.DurationMS))
writeJSON(w, http.StatusOK, res)
}