feat: V2 microservices stack — backend services, gateway, JWT auth

Add full V2 architecture: identity, content, studio (.NET 10) and file,
render, notification, gateway (Go) services with vendored deps, plus DB
migrations, event/API contracts, and an init-db script.

Wire the Next.js frontend to the gateway: server-side JWT auth routes
(login/register/refresh/logout/me), gateway fetch helper, and session/
cookie/jwt helpers under src/lib.

Containerize the stack via docker-compose.v2.yml and per-service
Dockerfiles. Base images resolve through a Nexus mirror (Docker Hub) and
MCR directly; npm/NuGet pull from Nexus groups. Self-host fonts via
next/font/local to avoid Google Fonts (geo-blocked).

Add CI workflow and ignore .env.v2, *.stackdump, and .NET bin/obj.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-29 23:29:31 +03:30
parent 53ea78a00d
commit 90ac0b81d1
7636 changed files with 3707504 additions and 240 deletions
+314
View File
@@ -0,0 +1,314 @@
package db
import (
"context"
"fmt"
"math"
"time"
"github.com/flatrender/file-svc/internal/models"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Store struct {
pool *pgxpool.Pool
}
func New(connStr string) (*Store, error) {
cfg, err := pgxpool.ParseConfig(connStr)
if err != nil {
return nil, fmt.Errorf("db config: %w", err)
}
cfg.MaxConns = 20
cfg.MinConns = 2
pool, err := pgxpool.NewWithConfig(context.Background(), cfg)
if err != nil {
return nil, fmt.Errorf("db connect: %w", err)
}
return &Store{pool: pool}, nil
}
func (s *Store) Close() { s.pool.Close() }
func (s *Store) Ping(ctx context.Context) error {
return s.pool.Ping(ctx)
}
// ── Folders ───────────────────────────────────────────────────────────────────
func (s *Store) GetFolders(ctx context.Context, userID uuid.UUID, parentID *uuid.UUID) ([]models.UserFolder, error) {
var rows pgx.Rows
var err error
if parentID == nil {
rows, err = s.pool.Query(ctx,
`SELECT id, tenant_id, user_id, name, folder_type, parent_folder_id,
file_count, total_size_bytes, sort, is_shared, share_token, created_at, updated_at
FROM file_mgr.user_folders
WHERE user_id = $1 AND parent_folder_id IS NULL AND deleted_at IS NULL
ORDER BY sort, name`, userID)
} else {
rows, err = s.pool.Query(ctx,
`SELECT id, tenant_id, user_id, name, folder_type, parent_folder_id,
file_count, total_size_bytes, sort, is_shared, share_token, created_at, updated_at
FROM file_mgr.user_folders
WHERE user_id = $1 AND parent_folder_id = $2 AND deleted_at IS NULL
ORDER BY sort, name`, userID, parentID)
}
if err != nil {
return nil, err
}
defer rows.Close()
var folders []models.UserFolder
for rows.Next() {
var f models.UserFolder
if err := rows.Scan(&f.ID, &f.TenantID, &f.UserID, &f.Name, &f.FolderType, &f.ParentFolderID,
&f.FileCount, &f.TotalSizeBytes, &f.Sort, &f.IsShared, &f.ShareToken, &f.CreatedAt, &f.UpdatedAt); err != nil {
return nil, err
}
folders = append(folders, f)
}
return folders, rows.Err()
}
func (s *Store) CreateFolder(ctx context.Context, tenantID, userID uuid.UUID, req models.CreateFolderRequest) (*models.UserFolder, error) {
var f models.UserFolder
err := s.pool.QueryRow(ctx,
`INSERT INTO file_mgr.user_folders (tenant_id, user_id, name, folder_type, parent_folder_id)
VALUES ($1, $2, $3, 'User', $4)
RETURNING id, tenant_id, user_id, name, folder_type, parent_folder_id,
file_count, total_size_bytes, sort, is_shared, share_token, created_at, updated_at`,
tenantID, userID, req.Name, req.ParentFolderID,
).Scan(&f.ID, &f.TenantID, &f.UserID, &f.Name, &f.FolderType, &f.ParentFolderID,
&f.FileCount, &f.TotalSizeBytes, &f.Sort, &f.IsShared, &f.ShareToken, &f.CreatedAt, &f.UpdatedAt)
if err != nil {
return nil, err
}
return &f, nil
}
func (s *Store) DeleteFolder(ctx context.Context, id, userID uuid.UUID) error {
ct, err := s.pool.Exec(ctx,
`UPDATE file_mgr.user_folders SET deleted_at = NOW() WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL`,
id, userID)
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
}
// ── Files ─────────────────────────────────────────────────────────────────────
func (s *Store) ListFiles(ctx context.Context, userID uuid.UUID, req models.FileListRequest) ([]models.UserFile, int64, error) {
offset := (req.Page - 1) * req.PageSize
baseQ := `FROM file_mgr.user_files WHERE user_id = $1 AND deleted_at IS NULL`
args := []any{userID}
argN := 2
if req.FolderID != nil {
baseQ += fmt.Sprintf(" AND user_folder_id = $%d", argN)
args = append(args, req.FolderID)
argN++
}
if req.FileType != nil {
baseQ += fmt.Sprintf(" AND file_type = $%d", argN)
args = append(args, req.FileType)
argN++
}
if req.Search != nil {
baseQ += fmt.Sprintf(" AND name ILIKE $%d", argN)
args = append(args, "%"+*req.Search+"%")
argN++
}
var total int64
if err := s.pool.QueryRow(ctx, "SELECT COUNT(*) "+baseQ, args...).Scan(&total); err != nil {
return nil, 0, err
}
args = append(args, req.PageSize, offset)
rows, err := s.pool.Query(ctx,
`SELECT id, tenant_id, user_id, user_folder_id, name, original_filename, file_extension, mime_type,
file_type, minio_bucket, minio_key, cdn_url, file_address, size_bytes, md5_hash,
thumbnail_url, upload_status, upload_progress, source, last_used_at, use_count,
is_public, created_at, updated_at
`+baseQ+fmt.Sprintf(` ORDER BY created_at DESC LIMIT $%d OFFSET $%d`, argN, argN+1),
args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var files []models.UserFile
for rows.Next() {
var f models.UserFile
if err := rows.Scan(&f.ID, &f.TenantID, &f.UserID, &f.UserFolderID, &f.Name, &f.OriginalFilename,
&f.FileExtension, &f.MimeType, &f.FileType, &f.MinioBucket, &f.MinioKey, &f.CdnURL,
&f.FileAddress, &f.SizeBytes, &f.Md5Hash, &f.ThumbnailURL, &f.UploadStatus, &f.UploadProgress,
&f.Source, &f.LastUsedAt, &f.UseCount, &f.IsPublic, &f.CreatedAt, &f.UpdatedAt); err != nil {
return nil, 0, err
}
files = append(files, f)
}
return files, total, rows.Err()
}
func (s *Store) GetFile(ctx context.Context, id, userID uuid.UUID) (*models.UserFile, error) {
var f models.UserFile
err := s.pool.QueryRow(ctx,
`SELECT id, tenant_id, user_id, user_folder_id, name, original_filename, file_extension, mime_type,
file_type, minio_bucket, minio_key, cdn_url, file_address, size_bytes, md5_hash,
sha256_hash, duration_sec, width, height, fps, bitrate_kbps, codec, has_audio, has_video,
thumbnail_url, waveform_data, upload_status, upload_progress, source, export_id, parent_file_id,
last_used_at, use_count, is_public, share_token, metadata, created_at, updated_at
FROM file_mgr.user_files
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL`, id, userID,
).Scan(&f.ID, &f.TenantID, &f.UserID, &f.UserFolderID, &f.Name, &f.OriginalFilename,
&f.FileExtension, &f.MimeType, &f.FileType, &f.MinioBucket, &f.MinioKey, &f.CdnURL,
&f.FileAddress, &f.SizeBytes, &f.Md5Hash, &f.Sha256Hash, &f.DurationSec, &f.Width, &f.Height,
&f.Fps, &f.BitrateKbps, &f.Codec, &f.HasAudio, &f.HasVideo, &f.ThumbnailURL, &f.WaveformData,
&f.UploadStatus, &f.UploadProgress, &f.Source, &f.ExportID, &f.ParentFileID,
&f.LastUsedAt, &f.UseCount, &f.IsPublic, &f.ShareToken, &f.Metadata, &f.CreatedAt, &f.UpdatedAt)
if err != nil {
return nil, err
}
return &f, nil
}
func (s *Store) CreateFileRecord(ctx context.Context, f *models.UserFile) error {
return s.pool.QueryRow(ctx,
`INSERT INTO file_mgr.user_files
(tenant_id, user_id, user_folder_id, name, original_filename, file_extension, mime_type,
file_type, minio_bucket, minio_key, file_address, size_bytes, upload_status, source)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'Pending',$13)
RETURNING id, created_at, updated_at`,
f.TenantID, f.UserID, f.UserFolderID, f.Name, f.OriginalFilename, f.FileExtension,
f.MimeType, f.FileType, f.MinioBucket, f.MinioKey, f.FileAddress, f.SizeBytes, f.Source,
).Scan(&f.ID, &f.CreatedAt, &f.UpdatedAt)
}
func (s *Store) MarkFileReady(ctx context.Context, id uuid.UUID, cdnURL *string) error {
_, err := s.pool.Exec(ctx,
`UPDATE file_mgr.user_files
SET upload_status = 'Ready', upload_progress = 100, cdn_url = $2, updated_at = NOW()
WHERE id = $1`,
id, cdnURL)
return err
}
func (s *Store) DeleteFile(ctx context.Context, id, userID uuid.UUID) (*models.UserFile, error) {
var f models.UserFile
err := s.pool.QueryRow(ctx,
`UPDATE file_mgr.user_files SET deleted_at = NOW()
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL
RETURNING id, minio_bucket, minio_key, size_bytes, user_folder_id`,
id, userID,
).Scan(&f.ID, &f.MinioBucket, &f.MinioKey, &f.SizeBytes, &f.UserFolderID)
if err != nil {
return nil, err
}
return &f, nil
}
// ── Storage Quota ─────────────────────────────────────────────────────────────
func (s *Store) GetQuota(ctx context.Context, userID uuid.UUID) (*models.StorageQuota, error) {
var q models.StorageQuota
err := s.pool.QueryRow(ctx,
`SELECT user_id, tenant_id, plan_quota_bytes, bonus_quota_bytes, used_bytes,
video_count, image_count, audio_count, video_bytes, image_bytes, audio_bytes,
last_90pct_notified_at, last_100pct_notified_at, updated_at
FROM file_mgr.storage_quotas WHERE user_id = $1`, userID,
).Scan(&q.UserID, &q.TenantID, &q.PlanQuotaBytes, &q.BonusQuotaBytes, &q.UsedBytes,
&q.VideoCount, &q.ImageCount, &q.AudioCount, &q.VideoBytes, &q.ImageBytes, &q.AudioBytes,
&q.Last90PctNotifiedAt, &q.Last100PctNotifiedAt, &q.UpdatedAt)
if err != nil {
return nil, err
}
return &q, nil
}
func (s *Store) EnsureQuota(ctx context.Context, userID, tenantID uuid.UUID) error {
_, err := s.pool.Exec(ctx,
`INSERT INTO file_mgr.storage_quotas (user_id, tenant_id)
VALUES ($1, $2)
ON CONFLICT (user_id) DO NOTHING`, userID, tenantID)
return err
}
func (s *Store) AddUsedBytes(ctx context.Context, userID uuid.UUID, delta int64) error {
_, err := s.pool.Exec(ctx,
`UPDATE file_mgr.storage_quotas SET used_bytes = used_bytes + $2, updated_at = NOW()
WHERE user_id = $1`, userID, delta)
return err
}
// ── Upload Sessions ───────────────────────────────────────────────────────────
func (s *Store) CreateUploadSession(ctx context.Context, sess *models.UploadSession) error {
return s.pool.QueryRow(ctx,
`INSERT INTO file_mgr.upload_sessions
(tenant_id, user_id, minio_bucket, minio_key, minio_upload_id, filename, mime_type,
total_size_bytes, chunk_size_bytes, target_folder_id, status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'Uploading')
RETURNING id, expires_at, created_at, updated_at`,
sess.TenantID, sess.UserID, sess.MinioBucket, sess.MinioKey, sess.MinioUploadID,
sess.Filename, sess.MimeType, sess.TotalSizeBytes, sess.ChunkSizeBytes, sess.TargetFolderID,
).Scan(&sess.ID, &sess.ExpiresAt, &sess.CreatedAt, &sess.UpdatedAt)
}
func (s *Store) GetUploadSession(ctx context.Context, id uuid.UUID) (*models.UploadSession, error) {
var sess models.UploadSession
err := s.pool.QueryRow(ctx,
`SELECT id, tenant_id, user_id, minio_bucket, minio_key, minio_upload_id, filename, mime_type,
total_size_bytes, chunks_received, bytes_received, chunk_size_bytes,
target_folder_id, target_file_id, status, error_message, expires_at, completed_at, created_at, updated_at
FROM file_mgr.upload_sessions WHERE id = $1`, id,
).Scan(&sess.ID, &sess.TenantID, &sess.UserID, &sess.MinioBucket, &sess.MinioKey, &sess.MinioUploadID,
&sess.Filename, &sess.MimeType, &sess.TotalSizeBytes, &sess.ChunksReceived, &sess.BytesReceived,
&sess.ChunkSizeBytes, &sess.TargetFolderID, &sess.TargetFileID, &sess.Status, &sess.ErrorMessage,
&sess.ExpiresAt, &sess.CompletedAt, &sess.CreatedAt, &sess.UpdatedAt)
if err != nil {
return nil, err
}
return &sess, nil
}
func (s *Store) CompleteUploadSession(ctx context.Context, id, fileID uuid.UUID) error {
now := time.Now()
_, err := s.pool.Exec(ctx,
`UPDATE file_mgr.upload_sessions
SET status = 'Ready', target_file_id = $2, completed_at = $3, updated_at = $3
WHERE id = $1`, id, fileID, now)
return err
}
// ── MinIO Buckets ─────────────────────────────────────────────────────────────
func (s *Store) GetBucketByPurpose(ctx context.Context, purpose string) (*models.MinioBucket, error) {
var b models.MinioBucket
err := s.pool.QueryRow(ctx,
`SELECT id, name, region, endpoint, purpose, is_public, cdn_base_url, is_active, created_at
FROM file_mgr.minio_buckets WHERE purpose = $1 AND is_active = TRUE LIMIT 1`, purpose,
).Scan(&b.ID, &b.Name, &b.Region, &b.Endpoint, &b.Purpose, &b.IsPublic, &b.CdnBaseURL, &b.IsActive, &b.CreatedAt)
if err != nil {
return nil, err
}
return &b, nil
}
// ── Helpers ───────────────────────────────────────────────────────────────────
func TotalPages(total int64, pageSize int) int {
if pageSize == 0 {
return 0
}
return int(math.Ceil(float64(total) / float64(pageSize)))
}