feat: add tenant and user management with migrations, handlers, and tests
- Implemented tenant and user management features including: - Tenant creation and management with associated migrations. - User creation and management with associated migrations. - Tenant membership management with associated migrations. - Platform user roles management with associated migrations. - Quota management with associated migrations. - Article and template management with associated migrations. - Added HTTP handlers for templates and workspaces. - Created tests for protected and public routes. - Introduced a script to check tenant scope in SQL queries. - Documented task plan for backend completion and frontend foundation.
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/bootstrap"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/stream"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/app"
|
||||
)
|
||||
|
||||
type ArticleHandler struct {
|
||||
svc *app.ArticleService
|
||||
streams *stream.GenerationHub
|
||||
streamEnabled bool
|
||||
}
|
||||
|
||||
func NewArticleHandler(a *bootstrap.App) *ArticleHandler {
|
||||
return &ArticleHandler{
|
||||
svc: app.NewArticleService(a.DB),
|
||||
streams: a.GenerationStreams,
|
||||
streamEnabled: a.Config.Generation.StreamEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ArticleHandler) List(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
params := app.ArticleListParams{Page: page, PageSize: pageSize}
|
||||
if v := c.Query("generate_status"); v != "" {
|
||||
params.GenerateStatus = &v
|
||||
}
|
||||
if v := c.Query("publish_status"); v != "" {
|
||||
params.PublishStatus = &v
|
||||
}
|
||||
if v := c.Query("source_type"); v != "" {
|
||||
params.SourceType = &v
|
||||
}
|
||||
if v := c.Query("template_id"); v != "" {
|
||||
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
params.TemplateID = &id
|
||||
}
|
||||
}
|
||||
if v := c.Query("keyword"); v != "" {
|
||||
params.Keyword = &v
|
||||
}
|
||||
|
||||
data, err := h.svc.List(c.Request.Context(), params)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *ArticleHandler) Detail(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "article id must be a number"))
|
||||
return
|
||||
}
|
||||
data, err := h.svc.Detail(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *ArticleHandler) Versions(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "article id must be a number"))
|
||||
return
|
||||
}
|
||||
data, err := h.svc.Versions(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *ArticleHandler) Stream(c *gin.Context) {
|
||||
if !h.streamEnabled {
|
||||
response.Error(c, response.ErrNotFound(40412, "generation_stream_disabled", "generation stream is disabled"))
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "article id must be a number"))
|
||||
return
|
||||
}
|
||||
|
||||
detail, err := h.svc.Detail(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
response.Error(c, response.ErrInternal(50011, "stream_unsupported", "response writer does not support streaming"))
|
||||
return
|
||||
}
|
||||
|
||||
events, snapshot, cancel := h.streams.Subscribe(id)
|
||||
defer cancel()
|
||||
|
||||
if snapshot != nil {
|
||||
if err := writeSSE(c, snapshot.Type, snapshot); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
} else {
|
||||
fallback := stream.GenerationEvent{
|
||||
Type: "snapshot",
|
||||
ArticleID: detail.ID,
|
||||
Status: detail.GenerateStatus,
|
||||
Done: detail.GenerateStatus == "completed" || detail.GenerateStatus == "failed",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
if detail.Title != nil {
|
||||
fallback.Title = *detail.Title
|
||||
}
|
||||
if detail.MarkdownContent != nil {
|
||||
fallback.Content = *detail.MarkdownContent
|
||||
}
|
||||
if err := writeSSE(c, fallback.Type, fallback); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
if fallback.Done {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
heartbeat := time.NewTicker(15 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.Request.Context().Done():
|
||||
return
|
||||
case <-heartbeat.C:
|
||||
if err := writeSSE(c, "ping", gin.H{"ts": time.Now().UTC()}); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
case event, ok := <-events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := writeSSE(c, event.Type, event); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
if event.Done {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ArticleHandler) Delete(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "article id must be a number"))
|
||||
return
|
||||
}
|
||||
if err := h.svc.Delete(c.Request.Context(), id); err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, nil)
|
||||
}
|
||||
|
||||
func writeSSE(c *gin.Context, event string, payload interface{}) error {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := c.Writer.Write([]byte("event: " + event + "\n")); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := c.Writer.Write([]byte("data: " + string(data) + "\n\n")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user