package httpapi import ( "database/sql" "encoding/json" "net/http" "time" "github.com/wangjia/pangolin/server/internal/apierr" "github.com/wangjia/pangolin/server/internal/auth" ) // AccountAPI serves /v1/me and supporting endpoints. type AccountAPI struct { db *sql.DB } // NewAccountAPI creates an AccountAPI. func NewAccountAPI(db *sql.DB) *AccountAPI { return &AccountAPI{db: db} } // ─── GET /v1/me ────────────────────────────────────────────────────────────── type meResponse struct { UUID string `json:"uuid"` Email string `json:"email"` DpUUID string `json:"dp_uuid"` Plan string `json:"plan"` // "free" | "pro" | "team" ExpireAt *string `json:"expire_at"` // RFC3339 UTC, null = no active sub } // GetMe handles GET /v1/me. func (a *AccountAPI) GetMe(w http.ResponseWriter, r *http.Request) { uid, ok := auth.UserIDFromContext(r.Context()) if !ok { apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) return } var ( uuid string email string dpUUID string ) if err := a.db.QueryRowContext(r.Context(), `SELECT uuid, email, dp_uuid FROM users WHERE id = ? AND status = 'active'`, uid, ).Scan(&uuid, &email, &dpUUID); err == sql.ErrNoRows { apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound) return } else if err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return } // Best active subscription. var planCode string var expiresAt sql.NullTime err := a.db.QueryRowContext(r.Context(), ` SELECT p.code, s.expires_at FROM subscriptions s JOIN plans p ON p.id = s.plan_id WHERE s.user_id = ? AND s.expires_at > UTC_TIMESTAMP() ORDER BY s.expires_at DESC LIMIT 1 `, uid).Scan(&planCode, &expiresAt) if err == sql.ErrNoRows { planCode = "free" } else if err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return } resp := meResponse{ UUID: uuid, Email: email, DpUUID: dpUUID, Plan: planCode, } if expiresAt.Valid { s := expiresAt.Time.UTC().Format(time.RFC3339) resp.ExpireAt = &s } w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(resp) } // ─── GET /v1/plans ─────────────────────────────────────────────────────────── type planResponse struct { Code string `json:"code"` NameZH string `json:"name_zh"` NameEN string `json:"name_en"` DailyMinutes *int64 `json:"daily_minutes"` // null = unlimited AdGate bool `json:"ad_gate"` } // ListPlans handles GET /v1/plans. func (a *AccountAPI) ListPlans(w http.ResponseWriter, r *http.Request) { rows, err := a.db.QueryContext(r.Context(), `SELECT code, name_zh, name_en, daily_minutes, ad_gate FROM plans ORDER BY id`) if err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return } defer rows.Close() var plans []planResponse for rows.Next() { var p planResponse var dm sql.NullInt64 if err := rows.Scan(&p.Code, &p.NameZH, &p.NameEN, &dm, &p.AdGate); err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return } if dm.Valid { p.DailyMinutes = &dm.Int64 } plans = append(plans, p) } if err := rows.Err(); err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{"plans": plans}) } // ─── GET /v1/notices ───────────────────────────────────────────────────────── // ListNotices handles GET /v1/notices. Returns an empty list for the MVP. func (a *AccountAPI) ListNotices(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{"notices": []any{}}) }