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:
2026-04-01 00:58:42 +08:00
commit de30497f59
210 changed files with 23733 additions and 0 deletions
@@ -0,0 +1,269 @@
package app
import (
"context"
"encoding/json"
"strconv"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
type ArticleService struct {
pool *pgxpool.Pool
}
func NewArticleService(pool *pgxpool.Pool) *ArticleService {
return &ArticleService{pool: pool}
}
type ArticleListParams struct {
Page int
PageSize int
GenerateStatus *string
PublishStatus *string
SourceType *string
TemplateID *int64
Keyword *string
}
type ArticleListItem struct {
ID int64 `json:"id"`
SourceType string `json:"source_type"`
TemplateName *string `json:"template_name"`
GenerateStatus string `json:"generate_status"`
PublishStatus string `json:"publish_status"`
Title *string `json:"title"`
WordCount int `json:"word_count"`
SourceLabel *string `json:"source_label"`
CreatedAt time.Time `json:"created_at"`
}
type ArticleListResponse struct {
Items []ArticleListItem `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*ArticleListResponse, error) {
actor := auth.MustActor(ctx)
if params.Page < 1 {
params.Page = 1
}
if params.PageSize < 1 || params.PageSize > 100 {
params.PageSize = 20
}
offset := (params.Page - 1) * params.PageSize
// Count
var total int64
countArgs := []interface{}{actor.TenantID}
countQuery := `SELECT COUNT(*) FROM articles a LEFT JOIN article_versions av ON av.id = a.current_version_id WHERE a.tenant_id = $1 AND a.deleted_at IS NULL`
argIdx := 2
if params.GenerateStatus != nil {
countQuery += ` AND a.generate_status = $` + strconv.Itoa(argIdx)
countArgs = append(countArgs, *params.GenerateStatus)
argIdx++
}
if params.PublishStatus != nil {
countQuery += ` AND a.publish_status = $` + strconv.Itoa(argIdx)
countArgs = append(countArgs, *params.PublishStatus)
argIdx++
}
if params.SourceType != nil {
countQuery += ` AND a.source_type = $` + strconv.Itoa(argIdx)
countArgs = append(countArgs, *params.SourceType)
argIdx++
}
if params.TemplateID != nil {
countQuery += ` AND a.template_id = $` + strconv.Itoa(argIdx)
countArgs = append(countArgs, *params.TemplateID)
argIdx++
}
if params.Keyword != nil {
countQuery += ` AND av.title ILIKE '%' || $` + strconv.Itoa(argIdx) + ` || '%'`
countArgs = append(countArgs, *params.Keyword)
}
if err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total); err != nil {
return nil, response.ErrInternal(50010, "query_failed", "failed to count articles")
}
// List
listQuery := `SELECT a.id, a.source_type, a.generate_status, a.publish_status, a.created_at,
av.title, COALESCE(av.word_count, 0), av.source_label, t.template_name
FROM articles a
LEFT JOIN article_versions av ON av.id = a.current_version_id
LEFT JOIN article_templates t ON t.id = a.template_id
WHERE a.tenant_id = $1 AND a.deleted_at IS NULL`
listArgs := []interface{}{actor.TenantID}
listIdx := 2
if params.GenerateStatus != nil {
listQuery += ` AND a.generate_status = $` + strconv.Itoa(listIdx)
listArgs = append(listArgs, *params.GenerateStatus)
listIdx++
}
if params.PublishStatus != nil {
listQuery += ` AND a.publish_status = $` + strconv.Itoa(listIdx)
listArgs = append(listArgs, *params.PublishStatus)
listIdx++
}
if params.SourceType != nil {
listQuery += ` AND a.source_type = $` + strconv.Itoa(listIdx)
listArgs = append(listArgs, *params.SourceType)
listIdx++
}
if params.TemplateID != nil {
listQuery += ` AND a.template_id = $` + strconv.Itoa(listIdx)
listArgs = append(listArgs, *params.TemplateID)
listIdx++
}
if params.Keyword != nil {
listQuery += ` AND av.title ILIKE '%' || $` + strconv.Itoa(listIdx) + ` || '%'`
listArgs = append(listArgs, *params.Keyword)
listIdx++
}
listQuery += ` ORDER BY a.created_at DESC LIMIT $` + strconv.Itoa(listIdx) + ` OFFSET $` + strconv.Itoa(listIdx+1)
listArgs = append(listArgs, params.PageSize, offset)
rows, err := s.pool.Query(ctx, listQuery, listArgs...)
if err != nil {
return nil, response.ErrInternal(50010, "query_failed", "failed to list articles")
}
defer rows.Close()
var items []ArticleListItem
for rows.Next() {
var item ArticleListItem
if err := rows.Scan(&item.ID, &item.SourceType, &item.GenerateStatus, &item.PublishStatus, &item.CreatedAt,
&item.Title, &item.WordCount, &item.SourceLabel, &item.TemplateName); err != nil {
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
}
items = append(items, item)
}
if items == nil {
items = []ArticleListItem{}
}
return &ArticleListResponse{Items: items, Total: total, Page: params.Page, PageSize: params.PageSize}, nil
}
type ArticleDetailResponse struct {
ID int64 `json:"id"`
SourceType string `json:"source_type"`
TemplateID *int64 `json:"template_id"`
TemplateName *string `json:"template_name"`
GenerateStatus string `json:"generate_status"`
PublishStatus string `json:"publish_status"`
Title *string `json:"title"`
HTMLContent *string `json:"html_content"`
MarkdownContent *string `json:"markdown_content"`
WordCount int `json:"word_count"`
SourceLabel *string `json:"source_label"`
VersionNo *int `json:"version_no"`
GenerationErrorMessage *string `json:"generation_error_message"`
CreatedAt time.Time `json:"created_at"`
}
func (s *ArticleService) Detail(ctx context.Context, id int64) (*ArticleDetailResponse, error) {
actor := auth.MustActor(ctx)
var d ArticleDetailResponse
err := s.pool.QueryRow(ctx, `
SELECT a.id, a.source_type, a.template_id, a.generate_status, a.publish_status, a.created_at,
av.title, av.html_content, av.markdown_content, COALESCE(av.word_count, 0), av.source_label, av.version_no,
t.template_name, gt.error_message
FROM articles a
LEFT JOIN article_versions av ON av.id = a.current_version_id
LEFT JOIN article_templates t ON t.id = a.template_id
LEFT JOIN LATERAL (
SELECT error_message
FROM generation_tasks
WHERE article_id = a.id
ORDER BY created_at DESC
LIMIT 1
) gt ON true
WHERE a.id = $1 AND a.tenant_id = $2 AND a.deleted_at IS NULL
`, id, actor.TenantID).Scan(&d.ID, &d.SourceType, &d.TemplateID, &d.GenerateStatus, &d.PublishStatus, &d.CreatedAt,
&d.Title, &d.HTMLContent, &d.MarkdownContent, &d.WordCount, &d.SourceLabel, &d.VersionNo, &d.TemplateName, &d.GenerationErrorMessage)
if err != nil {
return nil, response.ErrNotFound(40411, "article_not_found", "article not found")
}
return &d, nil
}
type VersionItem struct {
ID int64 `json:"id"`
VersionNo int `json:"version_no"`
Title *string `json:"title"`
WordCount int `json:"word_count"`
SourceLabel *string `json:"source_label"`
CreatedAt time.Time `json:"created_at"`
}
func (s *ArticleService) Versions(ctx context.Context, articleID int64) ([]VersionItem, error) {
actor := auth.MustActor(ctx)
// Verify ownership
var exists bool
_ = s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM articles WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL)`,
articleID, actor.TenantID).Scan(&exists)
if !exists {
return nil, response.ErrNotFound(40411, "article_not_found", "article not found")
}
rows, err := s.pool.Query(ctx, `
SELECT id, version_no, title, word_count, source_label, created_at
FROM article_versions WHERE article_id = $1 ORDER BY version_no DESC
`, articleID)
if err != nil {
return nil, response.ErrInternal(50010, "query_failed", "failed to list versions")
}
defer rows.Close()
var versions []VersionItem
for rows.Next() {
var v VersionItem
if err := rows.Scan(&v.ID, &v.VersionNo, &v.Title, &v.WordCount, &v.SourceLabel, &v.CreatedAt); err != nil {
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
}
versions = append(versions, v)
}
if versions == nil {
versions = []VersionItem{}
}
return versions, nil
}
func (s *ArticleService) Delete(ctx context.Context, id int64) error {
actor := auth.MustActor(ctx)
// Read before_json for audit
var beforeJSON []byte
_ = s.pool.QueryRow(ctx, `SELECT row_to_json(a) FROM articles a WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`,
id, actor.TenantID).Scan(&beforeJSON)
tag, err := s.pool.Exec(ctx, `
UPDATE articles SET deleted_at = NOW(), updated_at = NOW()
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
`, id, actor.TenantID)
if err != nil || tag.RowsAffected() == 0 {
return response.ErrNotFound(40411, "article_not_found", "article not found")
}
// Audit log
_, _ = s.pool.Exec(ctx, `
INSERT INTO audit_logs (operator_id, tenant_id, module, action, before_json, result)
VALUES ($1, $2, 'article', 'delete', $3, 'success')
`, actor.UserID, actor.TenantID, json.RawMessage(beforeJSON))
return nil
}