package handlers import ( "context" "net/http" "strings" "time" "github.com/arkie/ark-database/internal/auth" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/crypto/bcrypt" ) type loginReq struct { Email string `json:"email"` Password string `json:"password"` } func AdminLogin(secret string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req loginReq if err := jsonDecode(r, &req); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } pool := poolFrom(r) var id int var hash string err := pool.QueryRow(r.Context(), `SELECT id, password_hash FROM admins WHERE lower(email) = lower($1)`, strings.TrimSpace(req.Email)).Scan(&id, &hash) if err != nil { http.Error(w, "invalid credentials", http.StatusUnauthorized) return } if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil { http.Error(w, "invalid credentials", http.StatusUnauthorized) return } tok, err := auth.SignAdmin(secret, id, req.Email, 7*24*time.Hour) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, map[string]any{"token": tok}) } } func AdminDashboard(w http.ResponseWriter, r *http.Request) { pool := poolFrom(r) ctx := r.Context() var total, published int _ = pool.QueryRow(ctx, `SELECT COUNT(*) FROM resources`).Scan(&total) _ = pool.QueryRow(ctx, `SELECT COUNT(*) FROM resources WHERE status = 'published'`).Scan(&published) var views, downloads, favs, shares int64 _ = pool.QueryRow(ctx, `SELECT COALESCE(SUM(view_count),0), COALESCE(SUM(download_count),0), COALESCE(SUM(favorite_count),0), COALESCE(SUM(share_count),0) FROM resources`).Scan(&views, &downloads, &favs, &shares) var todayNew int _ = pool.QueryRow(ctx, `SELECT COUNT(*) FROM resources WHERE created_at::date = (now() at time zone 'utc')::date`).Scan(&todayNew) rows, err := pool.Query(ctx, ` SELECT r.id, r.title, r.download_count, r.favorite_count, r.view_count FROM resources r WHERE r.status = 'published' ORDER BY (r.download_count + r.favorite_count) DESC LIMIT 8`) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() type hot struct { ID string `json:"id"` Title string `json:"title"` Download int `json:"downloads"` Favorite int `json:"favorites"` View int `json:"views"` } hotList := make([]hot, 0) for rows.Next() { var id uuid.UUID var h hot if err := rows.Scan(&id, &h.Title, &h.Download, &h.Favorite, &h.View); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } h.ID = id.String() hotList = append(hotList, h) } if err := rows.Err(); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, map[string]any{ "totalResources": total, "published": published, "todayNew": todayNew, "totalViews": views, "totalDownloads": downloads, "totalFavorites": favs, "totalShares": shares, "hotResources": hotList, }) } type adminResource struct { ID string `json:"id"` Title string `json:"title"` Description string `json:"description"` Type string `json:"type"` Language string `json:"language"` CategoryID int `json:"categoryId"` CoverImage string `json:"coverImage"` FileURL string `json:"fileUrl"` PreviewURL string `json:"previewUrl"` ExternalURL string `json:"externalUrl"` BodyText string `json:"bodyText"` BadgeLabel string `json:"badgeLabel"` IsPublic bool `json:"isPublic"` IsDownloadable bool `json:"isDownloadable"` IsRecommended bool `json:"isRecommended"` SortOrder int `json:"sortOrder"` Status string `json:"status"` PublishedAt *string `json:"publishedAt"` Tags []string `json:"tags"` ViewCount int `json:"viewCount"` DownloadCount int `json:"downloadCount"` } func AdminListResources(w http.ResponseWriter, r *http.Request) { pool := poolFrom(r) page := atoiDef(r.URL.Query().Get("page"), 1) limit := atoiDef(r.URL.Query().Get("limit"), 20) if limit > 100 { limit = 100 } offset := (page - 1) * limit var total int if err := pool.QueryRow(r.Context(), `SELECT COUNT(*) FROM resources`).Scan(&total); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } rows, err := pool.Query(r.Context(), ` SELECT r.id, r.title, COALESCE(r.description,''), r.type, r.language, r.category_id, COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.body_text,''), COALESCE(r.badge_label,''), r.is_public, r.is_downloadable, r.is_recommended, r.sort_order, r.status, r.published_at, r.view_count, r.download_count FROM resources r ORDER BY r.updated_at DESC LIMIT $1 OFFSET $2`, limit, offset) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() list := make([]adminResource, 0) for rows.Next() { var ar adminResource var id uuid.UUID var pub *time.Time if err := rows.Scan(&id, &ar.Title, &ar.Description, &ar.Type, &ar.Language, &ar.CategoryID, &ar.CoverImage, &ar.FileURL, &ar.PreviewURL, &ar.ExternalURL, &ar.BodyText, &ar.BadgeLabel, &ar.IsPublic, &ar.IsDownloadable, &ar.IsRecommended, &ar.SortOrder, &ar.Status, &pub, &ar.ViewCount, &ar.DownloadCount); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } ar.ID = id.String() if pub != nil { s := pub.UTC().Format(time.RFC3339) ar.PublishedAt = &s } tags, _ := loadTagNames(r.Context(), pool, id) ar.Tags = tags list = append(list, ar) } if err := rows.Err(); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, map[string]any{"items": list, "page": page, "limit": limit, "total": total}) } type searchLogRow struct { ID int64 `json:"id"` Query string `json:"query"` CreatedAt string `json:"createdAt"` } func AdminSearchLogs(w http.ResponseWriter, r *http.Request) { pool := poolFrom(r) limit := atoiDef(r.URL.Query().Get("limit"), 200) if limit > 500 { limit = 500 } rows, err := pool.Query(r.Context(), `SELECT id, query, created_at FROM search_logs ORDER BY id DESC LIMIT $1`, limit) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() out := make([]searchLogRow, 0) for rows.Next() { var row searchLogRow var created time.Time if err := rows.Scan(&row.ID, &row.Query, &created); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } row.CreatedAt = created.UTC().Format(time.RFC3339) out = append(out, row) } if err := rows.Err(); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, map[string]any{"items": out}) } func AdminGetResource(w http.ResponseWriter, r *http.Request) { pool := poolFrom(r) rid, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { http.Error(w, "bad id", http.StatusBadRequest) return } row := pool.QueryRow(r.Context(), ` SELECT r.id, r.title, COALESCE(r.description,''), r.type, r.language, r.category_id, COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.body_text,''), COALESCE(r.badge_label,''), r.is_public, r.is_downloadable, r.is_recommended, r.sort_order, r.status, r.published_at, r.view_count, r.download_count FROM resources r WHERE r.id = $1`, rid) var ar adminResource var rowID uuid.UUID var pub *time.Time if err := row.Scan(&rowID, &ar.Title, &ar.Description, &ar.Type, &ar.Language, &ar.CategoryID, &ar.CoverImage, &ar.FileURL, &ar.PreviewURL, &ar.ExternalURL, &ar.BodyText, &ar.BadgeLabel, &ar.IsPublic, &ar.IsDownloadable, &ar.IsRecommended, &ar.SortOrder, &ar.Status, &pub, &ar.ViewCount, &ar.DownloadCount); err != nil { if err == pgx.ErrNoRows { http.NotFound(w, r) return } http.Error(w, err.Error(), http.StatusInternalServerError) return } ar.ID = rowID.String() tags, _ := loadTagNames(r.Context(), pool, rowID) ar.Tags = tags if pub != nil { s := pub.UTC().Format(time.RFC3339) ar.PublishedAt = &s } writeJSON(w, ar) } func AdminCreateResource(w http.ResponseWriter, r *http.Request) { pool := poolFrom(r) var ar adminResource if err := jsonDecode(r, &ar); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } adminCreateResource(w, r, pool, &ar) } func AdminUpdateResource(w http.ResponseWriter, r *http.Request) { pool := poolFrom(r) var ar adminResource if err := jsonDecode(r, &ar); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { http.Error(w, "bad id", http.StatusBadRequest) return } adminUpdateResource(w, r, pool, id, &ar) } func adminCreateResource(w http.ResponseWriter, r *http.Request, pool *pgxpool.Pool, ar *adminResource) { ctx := r.Context() var pub *time.Time if ar.Status == "published" { t := time.Now().UTC() pub = &t } row := pool.QueryRow(ctx, ` INSERT INTO resources (category_id, title, description, type, language, cover_image, file_url, preview_url, external_url, body_text, badge_label, is_public, is_downloadable, is_recommended, sort_order, status, published_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) RETURNING id`, ar.CategoryID, ar.Title, nullIfEmpty(ar.Description), ar.Type, ar.Language, nullIfEmpty(ar.CoverImage), nullIfEmpty(ar.FileURL), nullIfEmpty(ar.PreviewURL), nullIfEmpty(ar.ExternalURL), nullIfEmpty(ar.BodyText), nullIfEmpty(ar.BadgeLabel), ar.IsPublic, ar.IsDownloadable, ar.IsRecommended, ar.SortOrder, ar.Status, pub, ) var newID uuid.UUID if err := row.Scan(&newID); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } _ = replaceTags(ctx, pool, newID, ar.Tags) ar.ID = newID.String() writeJSON(w, ar) } func adminUpdateResource(w http.ResponseWriter, r *http.Request, pool *pgxpool.Pool, id uuid.UUID, ar *adminResource) { ctx := r.Context() var pub *time.Time if ar.PublishedAt != nil && *ar.PublishedAt != "" { t, err := time.Parse(time.RFC3339, *ar.PublishedAt) if err == nil { pub = &t } } else if ar.Status == "published" { // keep existing published_at if already set var existing *time.Time _ = pool.QueryRow(ctx, `SELECT published_at FROM resources WHERE id = $1`, id).Scan(&existing) if existing == nil { t := time.Now().UTC() pub = &t } else { pub = existing } } cmd, err := pool.Exec(ctx, ` UPDATE resources SET category_id = $1, title = $2, description = $3, type = $4, language = $5, cover_image = $6, file_url = $7, preview_url = $8, external_url = $9, body_text = $10, badge_label = $11, is_public = $12, is_downloadable = $13, is_recommended = $14, sort_order = $15, status = $16, published_at = COALESCE($17, published_at), updated_at = NOW() WHERE id = $18`, ar.CategoryID, ar.Title, nullIfEmpty(ar.Description), ar.Type, ar.Language, nullIfEmpty(ar.CoverImage), nullIfEmpty(ar.FileURL), nullIfEmpty(ar.PreviewURL), nullIfEmpty(ar.ExternalURL), nullIfEmpty(ar.BodyText), nullIfEmpty(ar.BadgeLabel), ar.IsPublic, ar.IsDownloadable, ar.IsRecommended, ar.SortOrder, ar.Status, pub, id, ) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if cmd.RowsAffected() == 0 { http.NotFound(w, r) return } _ = replaceTags(ctx, pool, id, ar.Tags) writeJSON(w, map[string]any{"ok": true}) } func nullIfEmpty(s string) any { if strings.TrimSpace(s) == "" { return nil } return s } func replaceTags(ctx context.Context, pool *pgxpool.Pool, resourceID uuid.UUID, tags []string) error { _, err := pool.Exec(ctx, `DELETE FROM resource_tags WHERE resource_id = $1`, resourceID) if err != nil { return err } for _, t := range tags { t = strings.TrimSpace(t) if t == "" { continue } var tid int slug := tagSlug(t) err := pool.QueryRow(ctx, `INSERT INTO tags (name, slug) VALUES ($1, $2) ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name RETURNING id`, t, slug).Scan(&tid) if err != nil { _ = pool.QueryRow(ctx, `SELECT id FROM tags WHERE slug = $1`, slug).Scan(&tid) } _, _ = pool.Exec(ctx, `INSERT INTO resource_tags (resource_id, tag_id) VALUES ($1,$2) ON CONFLICT DO NOTHING`, resourceID, tid) } return nil } func AdminDeleteResource(w http.ResponseWriter, r *http.Request) { pool := poolFrom(r) id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { http.Error(w, "bad id", http.StatusBadRequest) return } cmd, err := pool.Exec(r.Context(), `DELETE FROM resources WHERE id = $1`, id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if cmd.RowsAffected() == 0 { http.NotFound(w, r) return } writeJSON(w, map[string]any{"ok": true}) }