diff --git a/services/identity/FlatRender.IdentitySvc/Application/Services/AdminService.cs b/services/identity/FlatRender.IdentitySvc/Application/Services/AdminService.cs
index 351cd31..d492f5f 100644
--- a/services/identity/FlatRender.IdentitySvc/Application/Services/AdminService.cs
+++ b/services/identity/FlatRender.IdentitySvc/Application/Services/AdminService.cs
@@ -182,6 +182,19 @@ public class AdminService(IdentityDbContext db)
await db.SaveChangesAsync();
}
+ ///
+ /// Set how many renders a user may run concurrently. The render service reads
+ /// this from the JWT (max_renders claim); takes effect on the user's next token
+ /// refresh. Clamped to [1, 50].
+ ///
+ public async Task SetRenderSlotsAsync(Guid userId, int ceiling)
+ {
+ var u = await RequireUser(userId);
+ u.ParallelRenderingCeiling = Math.Clamp(ceiling, 1, 50);
+ u.UpdatedAt = DateTime.UtcNow;
+ await db.SaveChangesAsync();
+ }
+
public async Task GrantPlanDaysAsync(Guid userId, Guid planId, int days)
{
var plan = await db.UserPlans
diff --git a/services/identity/FlatRender.IdentitySvc/Application/Services/TokenService.cs b/services/identity/FlatRender.IdentitySvc/Application/Services/TokenService.cs
index 0f60107..0355a56 100644
--- a/services/identity/FlatRender.IdentitySvc/Application/Services/TokenService.cs
+++ b/services/identity/FlatRender.IdentitySvc/Application/Services/TokenService.cs
@@ -33,6 +33,9 @@ public class TokenService(IConfiguration config) : ITokenService
new("is_admin", user.IsAdmin.ToString().ToLower()),
new("is_tenant_admin", user.IsTenantAdmin.ToString().ToLower()),
new("role", role),
+ // Concurrent-render ceiling — render-svc enforces "active renders < max_renders".
+ // Admin grants or gamification raise ParallelRenderingCeiling; default is 1.
+ new("max_renders", Math.Max(1, user.ParallelRenderingCeiling).ToString()),
};
if (!string.IsNullOrEmpty(user.Email))
diff --git a/services/identity/FlatRender.IdentitySvc/Controllers/AdminController.cs b/services/identity/FlatRender.IdentitySvc/Controllers/AdminController.cs
index 98117df..c3ff092 100644
--- a/services/identity/FlatRender.IdentitySvc/Controllers/AdminController.cs
+++ b/services/identity/FlatRender.IdentitySvc/Controllers/AdminController.cs
@@ -79,6 +79,14 @@ public class AdminController(AdminService svc) : ControllerBase
return Ok(new { ok = true });
}
+ // Grant a user extra concurrent render slots (takes effect on next token refresh).
+ [HttpPost("v1/users/{userId:guid}/render-slots")]
+ public async Task RenderSlots(Guid userId, [FromBody] SetRenderSlotsRequest req)
+ {
+ await svc.SetRenderSlotsAsync(userId, req.Ceiling);
+ return Ok(new { ok = true });
+ }
+
[HttpPost("v1/users/{userId:guid}/grant-plan")]
public async Task GrantPlan(Guid userId, [FromBody] GrantPlanDaysRequest req)
{
diff --git a/services/identity/FlatRender.IdentitySvc/Models/Admin.cs b/services/identity/FlatRender.IdentitySvc/Models/Admin.cs
index d5c2f7d..bb6bebe 100644
--- a/services/identity/FlatRender.IdentitySvc/Models/Admin.cs
+++ b/services/identity/FlatRender.IdentitySvc/Models/Admin.cs
@@ -37,3 +37,4 @@ public record ResetPasswordRequest(string NewPassword);
public record AddChargeRequest(int Seconds, int RenderCount); // grant render seconds / daily renders
public record GrantPlanDaysRequest(Guid PlanId, int Days);
public record SetFlagRequest(bool Enabled);
+public record SetRenderSlotsRequest(int Ceiling); // concurrent-render ceiling
diff --git a/services/render/cmd/server/main.go b/services/render/cmd/server/main.go
index c5ab376..004a6c5 100644
--- a/services/render/cmd/server/main.go
+++ b/services/render/cmd/server/main.go
@@ -94,6 +94,7 @@ func main() {
{
renders.GET("", renderH.List)
renders.POST("", renderH.Create)
+ renders.GET("/active", renderH.Active)
renders.GET("/:job_id", renderH.Get)
renders.POST("/:job_id/cancel", renderH.Cancel)
renders.POST("/:job_id/stop", admin, renderH.Stop)
diff --git a/services/render/internal/db/db.go b/services/render/internal/db/db.go
index f32c9b2..5d7c95a 100644
--- a/services/render/internal/db/db.go
+++ b/services/render/internal/db/db.go
@@ -371,6 +371,45 @@ func (s *Store) GetJobByID(ctx context.Context, id, userID uuid.UUID) (*models.R
return jobs[0], nil
}
+// CountActiveJobs returns how many non-terminal render jobs the user currently has.
+// Used to enforce the per-user concurrent-render ceiling.
+func (s *Store) CountActiveJobs(ctx context.Context, userID uuid.UUID) (int, error) {
+ var n int
+ err := s.pool.QueryRow(ctx, `
+ SELECT COUNT(*) FROM render.render_jobs
+ WHERE user_id = $1
+ AND step NOT IN ('Done'::render_step, 'Failed'::render_step, 'Cancelled'::render_step)`,
+ userID).Scan(&n)
+ return n, err
+}
+
+// ListActiveJobs returns the user's in-flight render jobs (most recent first),
+// lightweight projection for the app-wide mini progress widget.
+func (s *Store) ListActiveJobs(ctx context.Context, userID uuid.UUID) ([]*models.RenderJob, error) {
+ rows, err := s.pool.Query(ctx, `
+ SELECT id, saved_project_id, name, step, render_progress, image_preview_b64, created_at
+ FROM render.render_jobs
+ WHERE user_id = $1
+ AND step NOT IN ('Done'::render_step, 'Failed'::render_step, 'Cancelled'::render_step)
+ ORDER BY created_at DESC`,
+ userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var out []*models.RenderJob
+ for rows.Next() {
+ j := &models.RenderJob{}
+ if err := rows.Scan(&j.ID, &j.SavedProjectID, &j.Name, &j.Step,
+ &j.RenderProgress, &j.ImagePreviewB64, &j.CreatedAt); err != nil {
+ return nil, err
+ }
+ out = append(out, j)
+ }
+ return out, rows.Err()
+}
+
func (s *Store) CreateJob(ctx context.Context, userID, tenantID uuid.UUID, req *models.RenderJobCreateRequest) (*models.RenderJob, error) {
priceType := "Free"
if req.PriceType != nil {
diff --git a/services/render/internal/handlers/renders.go b/services/render/internal/handlers/renders.go
index e349f9a..192b793 100644
--- a/services/render/internal/handlers/renders.go
+++ b/services/render/internal/handlers/renders.go
@@ -54,6 +54,39 @@ func (h *RenderHandler) List(c *gin.Context) {
})
}
+// GET /v1/renders/active
+// Lightweight list of the user's in-flight renders + their ceiling — powers the
+// app-wide mini progress widget and the "can I start another render?" check.
+func (h *RenderHandler) Active(c *gin.Context) {
+ userID := middleware.GetUserID(c)
+ jobs, err := h.store.ListActiveJobs(c.Request.Context(), userID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
+ return
+ }
+ if jobs == nil {
+ jobs = []*models.RenderJob{}
+ }
+ out := make([]gin.H, 0, len(jobs))
+ for _, j := range jobs {
+ out = append(out, gin.H{
+ "id": j.ID,
+ "saved_project_id": j.SavedProjectID,
+ "name": j.Name,
+ "step": j.Step,
+ "render_progress": j.RenderProgress,
+ "preview_b64": j.ImagePreviewB64,
+ "created_at": j.CreatedAt,
+ })
+ }
+ maxRenders := middleware.GetMaxRenders(c)
+ c.JSON(http.StatusOK, gin.H{
+ "active": out,
+ "max_renders": maxRenders,
+ "can_start_new": len(out) < maxRenders,
+ })
+}
+
// POST /v1/renders
func (h *RenderHandler) Create(c *gin.Context) {
userID := middleware.GetUserID(c)
@@ -65,6 +98,20 @@ func (h *RenderHandler) Create(c *gin.Context) {
return
}
+ // Concurrent-render ceiling: a user may run only `max_renders` renders at once
+ // (default 1; raised by gamification level or an admin grant via the JWT claim).
+ maxRenders := middleware.GetMaxRenders(c)
+ active, err := h.store.CountActiveJobs(c.Request.Context(), userID)
+ if err != nil {
+ log.Printf("count active jobs failed (allowing render): %v", err)
+ } else if active >= maxRenders {
+ c.JSON(http.StatusConflict, models.APIError{
+ Code: "active_render_limit",
+ Message: "شما یک رندر در حال انجام دارید. برای شروع رندر جدید صبر کنید تا رندر فعلی کامل شود.",
+ })
+ return
+ }
+
// Daily render-limit: consume one render charge (0 max = unlimited).
allowed, err := h.identity.Consume(c.Request.Context(), userID)
if err != nil {
diff --git a/services/render/internal/middleware/auth.go b/services/render/internal/middleware/auth.go
index a266c0d..d3b8e54 100644
--- a/services/render/internal/middleware/auth.go
+++ b/services/render/internal/middleware/auth.go
@@ -3,6 +3,7 @@ package middleware
import (
"fmt"
"net/http"
+ "strconv"
"strings"
"github.com/flatrender/render-svc/internal/models"
@@ -12,10 +13,11 @@ import (
)
const (
- CtxUserID = "user_id"
- CtxTenantID = "tenant_id"
- CtxIsAdmin = "is_admin"
- CtxRole = "role"
+ CtxUserID = "user_id"
+ CtxTenantID = "tenant_id"
+ CtxIsAdmin = "is_admin"
+ CtxRole = "role"
+ CtxMaxRenders = "max_renders"
)
func JWTAuth(secret string) gin.HandlerFunc {
@@ -54,10 +56,25 @@ func JWTAuth(secret string) gin.HandlerFunc {
}
role, _ := claims["role"].(string)
+ // max_renders: concurrent-render ceiling. Identity emits it as a string;
+ // also accept a JSON number. Default 1 when absent/unparseable.
+ maxRenders := 1
+ switch v := claims["max_renders"].(type) {
+ case string:
+ if n, err := strconv.Atoi(v); err == nil && n > 0 {
+ maxRenders = n
+ }
+ case float64:
+ if v >= 1 {
+ maxRenders = int(v)
+ }
+ }
+
c.Set(CtxUserID, userID)
c.Set(CtxTenantID, tenantID)
c.Set(CtxIsAdmin, isAdmin)
c.Set(CtxRole, role)
+ c.Set(CtxMaxRenders, maxRenders)
c.Next()
}
}
@@ -111,3 +128,16 @@ func GetTenantID(c *gin.Context) uuid.UUID {
id, _ := v.(uuid.UUID)
return id
}
+
+// GetMaxRenders returns the user's concurrent-render ceiling (default 1).
+func GetMaxRenders(c *gin.Context) int {
+ v, ok := c.Get(CtxMaxRenders)
+ if !ok {
+ return 1
+ }
+ n, _ := v.(int)
+ if n < 1 {
+ return 1
+ }
+ return n
+}
diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx
index 29f5da7..0d70af1 100644
--- a/src/app/[locale]/layout.tsx
+++ b/src/app/[locale]/layout.tsx
@@ -6,6 +6,7 @@ import { NextIntlClientProvider } from "next-intl";
import { DirectionProvider } from "@/components/layout/DirectionProvider";
import { SiteChrome } from "@/components/layout/SiteChrome";
+import { GlobalRenderProgress } from "@/components/render/GlobalRenderProgress";
import { getNavUser } from "@/lib/auth/session";
import { routing } from "@/i18n/routing";
import type { Locale } from "@/i18n/routing";
@@ -115,6 +116,7 @@ export default async function LocaleLayout({
{children}
+