feat(workspace): complete production workspace and content operations

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Mortezakoohjani 2026-09-12 11:54:17 +03:30
parent 208a9c2661
commit a483b47369
159 changed files with 7586 additions and 91 deletions

View File

@ -132,6 +132,11 @@ HOSPITALITY_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgre
HOSPITALITY_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/hospitality_db HOSPITALITY_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/hospitality_db
HOSPITALITY_SERVICE_NAME=hospitality-service HOSPITALITY_SERVICE_NAME=hospitality-service
WORKSPACE_SERVICE_URL=http://workspace-service:8013
WORKSPACE_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/workspace_db
WORKSPACE_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/workspace_db
WORKSPACE_SERVICE_NAME=workspace-service
EXPERIENCE_SERVICE_URL=http://experience-service:8008 EXPERIENCE_SERVICE_URL=http://experience-service:8008
EXPERIENCE_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/experience_db EXPERIENCE_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/experience_db
EXPERIENCE_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/experience_db EXPERIENCE_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/experience_db
@ -173,6 +178,7 @@ NEXT_PUBLIC_EXPERIENCE_API_URL=http://localhost:8008
NEXT_PUBLIC_HOSPITALITY_API_URL=http://localhost:8009 NEXT_PUBLIC_HOSPITALITY_API_URL=http://localhost:8009
NEXT_PUBLIC_HEALTHCARE_API_URL=http://localhost:8010 NEXT_PUBLIC_HEALTHCARE_API_URL=http://localhost:8010
NEXT_PUBLIC_BEAUTY_BUSINESS_API_URL=http://localhost:8011 NEXT_PUBLIC_BEAUTY_BUSINESS_API_URL=http://localhost:8011
NEXT_PUBLIC_WORKSPACE_API_URL=http://localhost:8013
INTERNAL_TOKEN_SECRET=change-me-internal-secret INTERNAL_TOKEN_SECRET=change-me-internal-secret

View File

@ -0,0 +1,8 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 PYTHONPATH=/app
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends build-essential libpq-dev && rm -rf /var/lib/apt/lists/*
COPY backend/shared-lib/ /shared-lib/
COPY backend/services/workspace/requirements.txt /app/requirements.txt
RUN sed -i 's#-e ../../shared-lib#-e /shared-lib#' requirements.txt && pip install -r requirements.txt
EXPOSE 8013

View File

@ -0,0 +1,11 @@
# Torbat Workspace (`workspace-service`)
Enterprise work management platform. Not Core Tenant Workspace, Experience Workspace, or Payment Workspace.
- Port: `8013`
- Database: `workspace_db`
- Health: `/health`
- API: `/api/v1/...`
- Permission / event namespace: `workspace.*`
File blobs stay in File Storage. Workspace stores `AssetReference` only.

View File

@ -0,0 +1,29 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = driver://user:pass@localhost/db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s

View File

@ -0,0 +1,33 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.config import settings
from app.core.database import Base
import app.models # noqa: F401
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url_sync)
if config.config_file_name:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline():
context.configure(url=settings.database_url_sync, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
connectable = engine_from_config(
config.get_section(config.config_ini_section), prefix="sqlalchemy.", poolclass=pool.NullPool
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
run_migrations_offline() if context.is_offline_mode() else run_migrations_online()

View File

@ -0,0 +1,21 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
${imports if imports else ""}
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,17 @@
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0001_initial"
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
Base.metadata.create_all(bind=op.get_bind())
def downgrade():
Base.metadata.drop_all(bind=op.get_bind())

View File

@ -0,0 +1 @@
__version__ = "0.15.0.0"

View File

@ -0,0 +1,13 @@
from uuid import UUID
from fastapi import Request
from shared.exceptions import TenantNotResolvedError
from shared.tenant import STATE_TENANT_ID
def require_tenant(request: Request):
tenant_id = getattr(request.state, STATE_TENANT_ID, None)
if tenant_id is None:
raise TenantNotResolvedError("X-Tenant-ID required")
return UUID(str(tenant_id))

View File

@ -0,0 +1,39 @@
from fastapi import Depends
from app.core.config import settings
from app.core.security import get_current_user
from shared.exceptions import ForbiddenError
_ADMINS = {"platform_admin", "tenant_owner", "tenant_admin"}
def user_has_permission(user, permission: str) -> bool:
roles = set(user.roles)
if roles & _ADMINS or "workspace.*" in roles:
return True
if permission in roles:
return True
parts = permission.split(".")
return len(parts) >= 3 and f"{parts[0]}.{parts[1]}.manage" in roles
def require_permissions(*permissions: str):
async def dependency(user=Depends(get_current_user)):
if not settings.auth_required or any(user_has_permission(user, permission) for permission in permissions):
return user
raise ForbiddenError(
"Permission denied",
error_code="permission_denied",
details={"required": list(permissions)},
)
return dependency
async def require_workspace_access(user=Depends(get_current_user)):
if not settings.auth_required:
return user
roles = set(user.roles)
if roles & _ADMINS or any(role.startswith("workspace.") for role in roles):
return user
raise ForbiddenError("Permission denied", error_code="permission_denied")

View File

@ -0,0 +1,6 @@
from fastapi import APIRouter
from app.api.v1.routes import router as domain_router
api_router = APIRouter()
api_router.include_router(domain_router)

View File

@ -0,0 +1,48 @@
from fastapi import APIRouter, Depends
from sqlalchemy import select
from app import __version__
from app.api.deps import require_tenant
from app.core.config import settings
from app.core.database import get_db
from app.models import WorkHub
from app.permissions.definitions import ALL_PERMISSIONS
router = APIRouter()
@router.get("/health")
async def health():
return {"status": "ok", "service": "workspace-service", "version": __version__}
@router.get("/metrics")
async def metrics():
return {"service": "workspace-service", "version": __version__, "phase": "15.0-content"}
@router.get("/capabilities")
async def capabilities(tenant_id=Depends(require_tenant), db=Depends(get_db)):
hubs = (await db.execute(select(WorkHub).where(WorkHub.tenant_id == tenant_id))).scalars().all()
return {
"service": "workspace",
"version": __version__,
"phase": "15.0-content",
"hubs": len(hubs),
"permissions": ALL_PERMISSIONS,
"file_storage": {
"available": bool(settings.file_storage_url),
"status": "ready" if settings.file_storage_url else "unavailable",
"mode": "reference_only",
},
"ai_hooks": [
"content_ideation",
"brief_generation",
"content_rewrite",
"task_summarization",
"project_summarization",
"workload_insights",
"campaign_analysis",
],
"notification_delivery": "persisted_only",
}

View File

@ -0,0 +1,303 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from app.api.deps import require_tenant
from app.api.permissions import require_permissions, require_workspace_access
from app.commands.workspace import WorkspaceService
from app.core.database import get_db
from app.core.security import get_current_user
from app.models import (
Activity,
AssetReference,
Comment,
ContentApproval,
ContentItem,
ContentType,
Mention,
Project,
ProjectMember,
Task,
Team,
TeamMember,
WorkHub,
)
from app.permissions.definitions import ALL_PERMISSIONS
from app.policies.workflow import CONTENT_TRANSITIONS, TASK_TRANSITIONS
from app.queries.workspace import WorkspaceQueries
router = APIRouter(dependencies=[Depends(require_workspace_access)])
def svc(tenant_id: UUID, db, user):
return WorkspaceService(db, tenant_id, actor=getattr(user, "user_id", None))
def qry(tenant_id: UUID, db):
return WorkspaceQueries(db, tenant_id)
@router.get("/permissions/catalog")
async def permissions_catalog():
return {"permissions": ALL_PERMISSIONS}
# Hubs
@router.post("/hubs", status_code=201, dependencies=[Depends(require_permissions("workspace.hub.manage"))])
async def create_hub(body: dict, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db), user=Depends(get_current_user)):
return await svc(tenant_id, db, user).create_hub(body)
@router.get("/hubs", dependencies=[Depends(require_permissions("workspace.hub.read"))])
async def list_hubs(tenant_id: UUID = Depends(require_tenant), db=Depends(get_db)):
return await qry(tenant_id, db).list_model(WorkHub)
@router.get("/hubs/{hub_id}", dependencies=[Depends(require_permissions("workspace.hub.read"))])
async def get_hub(hub_id: UUID, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db)):
row = await qry(tenant_id, db).get_model(WorkHub, hub_id)
if not row:
raise HTTPException(404, "Not found")
return row
@router.patch("/hubs/{hub_id}", dependencies=[Depends(require_permissions("workspace.hub.manage"))])
async def update_hub(hub_id: UUID, body: dict, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db), user=Depends(get_current_user)):
return await svc(tenant_id, db, user).update_hub(hub_id, body)
@router.get("/hubs/{hub_id}/dashboard", dependencies=[Depends(require_permissions("workspace.report.read", "workspace.hub.read"))])
async def hub_dashboard(hub_id: UUID, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db)):
if not await qry(tenant_id, db).get_model(WorkHub, hub_id):
raise HTTPException(404, "Not found")
return await qry(tenant_id, db).dashboard(hub_id)
# Teams
@router.post("/teams", status_code=201, dependencies=[Depends(require_permissions("workspace.team.manage"))])
async def create_team(body: dict, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db), user=Depends(get_current_user)):
return await svc(tenant_id, db, user).create_team(body)
@router.get("/teams", dependencies=[Depends(require_permissions("workspace.team.read"))])
async def list_teams(work_hub_id: UUID | None = None, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db)):
return await qry(tenant_id, db).list_model(Team, work_hub_id=work_hub_id)
@router.get("/teams/{team_id}", dependencies=[Depends(require_permissions("workspace.team.read"))])
async def get_team(team_id: UUID, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db)):
row = await qry(tenant_id, db).get_model(Team, team_id)
if not row:
raise HTTPException(404, "Not found")
members = await qry(tenant_id, db).list_model(TeamMember, team_id=team_id)
return {**row, "members": members}
@router.patch("/teams/{team_id}", dependencies=[Depends(require_permissions("workspace.team.manage"))])
async def update_team(team_id: UUID, body: dict, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db), user=Depends(get_current_user)):
return await svc(tenant_id, db, user).update_team(team_id, body)
@router.post("/team-members", status_code=201, dependencies=[Depends(require_permissions("workspace.team.manage"))])
async def add_member(body: dict, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db), user=Depends(get_current_user)):
return await svc(tenant_id, db, user).add_member(body)
@router.get("/team-members", dependencies=[Depends(require_permissions("workspace.team.read"))])
async def list_members(team_id: UUID | None = None, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db)):
return await qry(tenant_id, db).list_model(TeamMember, team_id=team_id)
@router.patch("/team-members/{member_id}", dependencies=[Depends(require_permissions("workspace.team.manage"))])
async def update_member(member_id: UUID, body: dict, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db), user=Depends(get_current_user)):
return await svc(tenant_id, db, user).update_member(member_id, body)
# Projects
@router.post("/projects", status_code=201, dependencies=[Depends(require_permissions("workspace.project.manage"))])
async def create_project(body: dict, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db), user=Depends(get_current_user)):
return await svc(tenant_id, db, user).create_project(body)
@router.get("/projects", dependencies=[Depends(require_permissions("workspace.project.read"))])
async def list_projects(work_hub_id: UUID | None = None, team_id: UUID | None = None, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db)):
return await qry(tenant_id, db).list_model(Project, work_hub_id=work_hub_id, team_id=team_id)
@router.get("/projects/{project_id}", dependencies=[Depends(require_permissions("workspace.project.read"))])
async def get_project(project_id: UUID, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db)):
row = await qry(tenant_id, db).get_model(Project, project_id)
if not row:
raise HTTPException(404, "Not found")
progress = await qry(tenant_id, db).project_progress(project_id)
members = await qry(tenant_id, db).list_model(ProjectMember, project_id=project_id)
tasks = await qry(tenant_id, db).list_model(Task, project_id=project_id)
content = await qry(tenant_id, db).list_model(ContentItem, project_id=project_id)
return {**row, "progress": progress, "members": members, "tasks": tasks, "content": content}
@router.patch("/projects/{project_id}", dependencies=[Depends(require_permissions("workspace.project.manage"))])
async def update_project(project_id: UUID, body: dict, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db), user=Depends(get_current_user)):
return await svc(tenant_id, db, user).update_project(project_id, body)
# Tasks
@router.post("/tasks", status_code=201, dependencies=[Depends(require_permissions("workspace.task.manage"))])
async def create_task(body: dict, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db), user=Depends(get_current_user)):
return await svc(tenant_id, db, user).create_task(body)
@router.get("/tasks", dependencies=[Depends(require_permissions("workspace.task.read"))])
async def list_tasks(
work_hub_id: UUID | None = None,
project_id: UUID | None = None,
assignee_user_id: str | None = None,
status: str | None = None,
tenant_id: UUID = Depends(require_tenant),
db=Depends(get_db),
):
return await qry(tenant_id, db).list_model(
Task, work_hub_id=work_hub_id, project_id=project_id, assignee_user_id=assignee_user_id, status=status
)
@router.get("/tasks/{task_id}", dependencies=[Depends(require_permissions("workspace.task.read"))])
async def get_task(task_id: UUID, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db)):
row = await qry(tenant_id, db).get_model(Task, task_id)
if not row:
raise HTTPException(404, "Not found")
return {**row, "next_actions": sorted(TASK_TRANSITIONS.get(row["status"], set()))}
@router.patch("/tasks/{task_id}", dependencies=[Depends(require_permissions("workspace.task.manage"))])
async def update_task(task_id: UUID, body: dict, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db), user=Depends(get_current_user)):
return await svc(tenant_id, db, user).update_task(task_id, body)
# Content
@router.post("/content-types", status_code=201, dependencies=[Depends(require_permissions("workspace.content.manage"))])
async def create_content_type(body: dict, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db), user=Depends(get_current_user)):
return await svc(tenant_id, db, user).create_content_type(body)
@router.get("/content-types", dependencies=[Depends(require_permissions("workspace.content.read"))])
async def list_content_types(tenant_id: UUID = Depends(require_tenant), db=Depends(get_db), user=Depends(get_current_user)):
await svc(tenant_id, db, user).ensure_content_types()
await db.commit()
return await qry(tenant_id, db).list_model(ContentType)
@router.post("/content", status_code=201, dependencies=[Depends(require_permissions("workspace.content.manage"))])
async def create_content(body: dict, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db), user=Depends(get_current_user)):
return await svc(tenant_id, db, user).create_content(body)
@router.get("/content", dependencies=[Depends(require_permissions("workspace.content.read"))])
async def list_content(
work_hub_id: UUID | None = None,
project_id: UUID | None = None,
status: str | None = None,
content_type: str | None = None,
tenant_id: UUID = Depends(require_tenant),
db=Depends(get_db),
):
return await qry(tenant_id, db).list_model(
ContentItem, work_hub_id=work_hub_id, project_id=project_id, status=status, content_type=content_type
)
@router.post("/content/approvals", status_code=201, dependencies=[Depends(require_permissions("workspace.content.review", "workspace.content.approve"))])
async def request_approval(body: dict, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db), user=Depends(get_current_user)):
return await svc(tenant_id, db, user).request_approval(body)
@router.get("/content/approvals", dependencies=[Depends(require_permissions("workspace.content.read"))])
async def list_approvals(content_id: UUID | None = None, work_hub_id: UUID | None = None, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db)):
return await qry(tenant_id, db).list_model(ContentApproval, content_id=content_id, work_hub_id=work_hub_id)
@router.patch("/content/approvals/{approval_id}", dependencies=[Depends(require_permissions("workspace.content.approve"))])
async def respond_approval(approval_id: UUID, body: dict, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db), user=Depends(get_current_user)):
return await svc(tenant_id, db, user).respond_approval(approval_id, body)
@router.get("/content/calendar", dependencies=[Depends(require_permissions("workspace.content.read", "workspace.calendar.manage"))])
async def content_calendar(
work_hub_id: UUID,
team_id: UUID | None = None,
project_id: UUID | None = None,
channel: str | None = None,
content_type: str | None = None,
status: str | None = None,
owner_user_id: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
tenant_id: UUID = Depends(require_tenant),
db=Depends(get_db),
):
return await qry(tenant_id, db).calendar(
work_hub_id,
{
"team_id": team_id,
"project_id": project_id,
"channel": channel,
"content_type": content_type,
"status": status,
"owner_user_id": owner_user_id,
"from": date_from,
"to": date_to,
},
)
@router.get("/content/{content_id}", dependencies=[Depends(require_permissions("workspace.content.read"))])
async def get_content(content_id: UUID, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db)):
row = await qry(tenant_id, db).get_model(ContentItem, content_id)
if not row:
raise HTTPException(404, "Not found")
approvals = await qry(tenant_id, db).list_model(ContentApproval, content_id=content_id)
assets = await qry(tenant_id, db).list_model(AssetReference, content_id=content_id)
return {
**row,
"approvals": approvals,
"assets": assets,
"next_actions": sorted(CONTENT_TRANSITIONS.get(row["status"], set())),
}
@router.patch("/content/{content_id}", dependencies=[Depends(require_permissions("workspace.content.manage"))])
async def update_content(content_id: UUID, body: dict, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db), user=Depends(get_current_user)):
return await svc(tenant_id, db, user).update_content(content_id, body)
# Assets / comments / activity
@router.post("/assets", status_code=201, dependencies=[Depends(require_permissions("workspace.asset.manage"))])
async def add_asset(body: dict, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db), user=Depends(get_current_user)):
return await svc(tenant_id, db, user).add_asset(body)
@router.get("/assets", dependencies=[Depends(require_permissions("workspace.asset.read"))])
async def list_assets(work_hub_id: UUID | None = None, content_id: UUID | None = None, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db)):
return await qry(tenant_id, db).list_model(AssetReference, work_hub_id=work_hub_id, content_id=content_id)
@router.post("/comments", status_code=201, dependencies=[Depends(require_permissions("workspace.comment.manage"))])
async def add_comment(body: dict, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db), user=Depends(get_current_user)):
return await svc(tenant_id, db, user).add_comment(body)
@router.get("/comments", dependencies=[Depends(require_permissions("workspace.content.read", "workspace.project.read"))])
async def list_comments(work_hub_id: UUID | None = None, subject_type: str | None = None, subject_id: UUID | None = None, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db)):
return await qry(tenant_id, db).list_model(Comment, work_hub_id=work_hub_id, subject_type=subject_type, subject_id=subject_id)
@router.get("/mentions", dependencies=[Depends(require_permissions("workspace.content.read"))])
async def list_mentions(work_hub_id: UUID | None = None, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db)):
return await qry(tenant_id, db).list_model(Mention, work_hub_id=work_hub_id)
@router.get("/activity", dependencies=[Depends(require_permissions("workspace.report.read", "workspace.hub.read"))])
async def list_activity(work_hub_id: UUID | None = None, tenant_id: UUID = Depends(require_tenant), db=Depends(get_db)):
return await qry(tenant_id, db).list_model(Activity, work_hub_id=work_hub_id)

View File

@ -0,0 +1,679 @@
from __future__ import annotations
import re
from datetime import date, datetime, timezone
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.events.publisher import TransactionalEventPublisher
from app.models import (
Activity,
AssetReference,
Comment,
ContentApproval,
ContentItem,
ContentType,
Mention,
Project,
ProjectMember,
Task,
Team,
TeamMember,
WorkHub,
WorkspaceAuditLog,
)
from app.policies.workflow import (
APPROVAL_STATUSES,
APPROVAL_TYPES,
BRIEF_KEYS,
CONTENT_STATUSES,
CONTENT_TRANSITIONS,
DEFAULT_CONTENT_TYPES,
DEFAULT_WORKFLOW,
HUB_STATUSES,
PRIORITIES,
PROJECT_STATUSES,
TASK_STATUSES,
TASK_TRANSITIONS,
TEAM_ROLES,
TEAM_STATUSES,
can_transition,
)
MENTION_RE = re.compile(r"@([A-Za-z0-9_.-]+)")
def _now():
return datetime.now(timezone.utc)
def parse_date(value):
if value in (None, ""):
return None
if isinstance(value, date) and not isinstance(value, datetime):
return value
if isinstance(value, datetime):
return value.date()
return date.fromisoformat(str(value)[:10])
def parse_dt(value):
if value in (None, ""):
return None
if isinstance(value, datetime):
return value
text = str(value).replace("Z", "+00:00")
return datetime.fromisoformat(text)
def dump(row):
out = {c.name: getattr(row, c.name) for c in row.__table__.columns}
for key, value in list(out.items()):
if hasattr(value, "isoformat"):
out[key] = value.isoformat()
elif isinstance(value, UUID):
out[key] = str(value)
return out
class WorkspaceService:
def __init__(self, db: AsyncSession, tenant_id: UUID, actor: str | None = None):
self.db = db
self.tenant_id = tenant_id
self.actor = actor
self.events = TransactionalEventPublisher(db)
async def _audit(self, entity, action: str, changes: dict | None = None):
self.db.add(
WorkspaceAuditLog(
tenant_id=self.tenant_id,
entity_type=entity.__class__.__name__,
entity_id=entity.id,
action=action,
actor_user_id=self.actor,
changes=changes or {},
)
)
async def _activity(self, hub_id: UUID, entity, action: str, payload: dict | None = None):
self.db.add(
Activity(
tenant_id=self.tenant_id,
work_hub_id=hub_id,
entity_type=entity.__class__.__name__,
entity_id=entity.id,
action=action,
actor_user_id=self.actor,
payload=payload or {},
)
)
async def _emit(self, entity, event: str):
await self.events.publish(
event_type=event,
aggregate_type=entity.__class__.__name__,
aggregate_id=entity.id,
tenant_id=self.tenant_id,
payload={"id": str(entity.id)},
)
async def _get(self, model, entity_id: UUID):
row = (
await self.db.execute(select(model).where(model.tenant_id == self.tenant_id, model.id == entity_id))
).scalar_one_or_none()
if not row:
raise HTTPException(404, "Not found")
return row
async def _require_hub(self, hub_id: UUID) -> WorkHub:
return await self._get(WorkHub, hub_id)
async def ensure_content_types(self, hub_id: UUID | None = None):
existing = (
await self.db.execute(select(ContentType).where(ContentType.tenant_id == self.tenant_id))
).scalars().all()
if existing:
return existing
rows = []
for item in DEFAULT_CONTENT_TYPES:
row = ContentType(
tenant_id=self.tenant_id,
work_hub_id=hub_id,
code=item["code"],
label=item["label"],
channel=item["channel"],
workflow=DEFAULT_WORKFLOW,
enabled=True,
extra={},
)
self.db.add(row)
rows.append(row)
await self.db.flush()
return rows
# --- Hubs ---
async def create_hub(self, body: dict):
status = body.get("status", "active")
if status not in HUB_STATUSES:
raise HTTPException(422, "Invalid hub status")
row = WorkHub(
tenant_id=self.tenant_id,
name=body.get("name") or "فضای کاری",
description=body.get("description"),
status=status,
owner_user_id=body.get("owner_user_id") or self.actor,
created_by=self.actor,
)
self.db.add(row)
await self.db.flush()
await self.ensure_content_types(row.id)
await self._audit(row, "create")
await self._activity(row.id, row, "created")
await self._emit(row, "workspace.work_hub.created")
await self.db.commit()
await self.db.refresh(row)
return dump(row)
async def update_hub(self, hub_id: UUID, body: dict):
row = await self._require_hub(hub_id)
for key in ("name", "description", "owner_user_id"):
if key in body:
setattr(row, key, body[key])
if "status" in body:
if body["status"] not in HUB_STATUSES:
raise HTTPException(422, "Invalid hub status")
row.status = body["status"]
row.updated_by = self.actor
await self._audit(row, "update")
await self._activity(row.id, row, "updated")
await self._emit(row, "workspace.work_hub.updated")
await self.db.commit()
await self.db.refresh(row)
return dump(row)
# --- Teams ---
async def create_team(self, body: dict):
hub = await self._require_hub(UUID(str(body["work_hub_id"])))
row = Team(
tenant_id=self.tenant_id,
work_hub_id=hub.id,
name=body.get("name") or "تیم",
description=body.get("description"),
status=body.get("status", "active") if body.get("status") in TEAM_STATUSES else "active",
owner_user_id=body.get("owner_user_id") or self.actor,
created_by=self.actor,
)
self.db.add(row)
await self.db.flush()
if row.owner_user_id:
self.db.add(
TeamMember(
tenant_id=self.tenant_id,
team_id=row.id,
user_id=row.owner_user_id,
role="owner",
status="active",
)
)
await self._audit(row, "create")
await self._activity(hub.id, row, "created")
await self._emit(row, "workspace.team.created")
await self.db.commit()
await self.db.refresh(row)
return dump(row)
async def update_team(self, team_id: UUID, body: dict):
row = await self._get(Team, team_id)
for key in ("name", "description", "owner_user_id"):
if key in body:
setattr(row, key, body[key])
if "status" in body:
if body["status"] not in TEAM_STATUSES:
raise HTTPException(422, "Invalid team status")
row.status = body["status"]
row.updated_by = self.actor
await self._audit(row, "update")
await self._activity(row.work_hub_id, row, "updated")
await self.db.commit()
await self.db.refresh(row)
return dump(row)
async def add_member(self, body: dict):
team = await self._get(Team, UUID(str(body["team_id"])))
role = body.get("role", "member")
if role not in TEAM_ROLES:
raise HTTPException(422, "Invalid team role")
user_id = body.get("user_id")
if not user_id:
raise HTTPException(422, "user_id required")
existing = (
await self.db.execute(
select(TeamMember).where(
TeamMember.tenant_id == self.tenant_id,
TeamMember.team_id == team.id,
TeamMember.user_id == user_id,
)
)
).scalar_one_or_none()
if existing:
existing.role = role
existing.status = "active"
row = existing
else:
row = TeamMember(
tenant_id=self.tenant_id,
team_id=team.id,
user_id=user_id,
role=role,
status="active",
)
self.db.add(row)
await self.db.flush()
await self._activity(team.work_hub_id, row, "member_added", {"user_id": user_id, "role": role})
await self.db.commit()
await self.db.refresh(row)
return dump(row)
async def update_member(self, member_id: UUID, body: dict):
row = await self._get(TeamMember, member_id)
if "role" in body:
if body["role"] not in TEAM_ROLES:
raise HTTPException(422, "Invalid team role")
row.role = body["role"]
if "status" in body:
row.status = body["status"]
team = await self._get(Team, row.team_id)
await self._activity(team.work_hub_id, row, "member_updated")
await self.db.commit()
await self.db.refresh(row)
return dump(row)
# --- Projects ---
async def create_project(self, body: dict):
hub = await self._require_hub(UUID(str(body["work_hub_id"])))
status = body.get("status", "planning")
if status not in PROJECT_STATUSES:
raise HTTPException(422, "Invalid project status")
priority = body.get("priority", "medium")
if priority not in PRIORITIES:
raise HTTPException(422, "Invalid priority")
team_id = UUID(str(body["team_id"])) if body.get("team_id") else None
if team_id:
await self._get(Team, team_id)
row = Project(
tenant_id=self.tenant_id,
work_hub_id=hub.id,
team_id=team_id,
name=body.get("name") or "پروژه",
description=body.get("description"),
status=status,
priority=priority,
owner_user_id=body.get("owner_user_id") or self.actor,
start_date=parse_date(body.get("start_date")),
due_date=parse_date(body.get("due_date")),
budget_estimate=body.get("budget_estimate"),
extra=body.get("extra") or {},
created_by=self.actor,
)
self.db.add(row)
await self.db.flush()
if row.owner_user_id:
self.db.add(
ProjectMember(
tenant_id=self.tenant_id,
project_id=row.id,
user_id=row.owner_user_id,
role="owner",
status="active",
)
)
await self._audit(row, "create")
await self._activity(hub.id, row, "created")
await self._emit(row, "workspace.project.created")
await self.db.commit()
await self.db.refresh(row)
return dump(row)
async def update_project(self, project_id: UUID, body: dict):
row = await self._get(Project, project_id)
for key in ("name", "description", "owner_user_id", "budget_estimate"):
if key in body:
setattr(row, key, body[key])
if "start_date" in body:
row.start_date = parse_date(body["start_date"])
if "due_date" in body:
row.due_date = parse_date(body["due_date"])
if "status" in body:
if body["status"] not in PROJECT_STATUSES:
raise HTTPException(422, "Invalid project status")
row.status = body["status"]
if "priority" in body:
if body["priority"] not in PRIORITIES:
raise HTTPException(422, "Invalid priority")
row.priority = body["priority"]
if "team_id" in body:
row.team_id = UUID(str(body["team_id"])) if body["team_id"] else None
if "extra" in body and isinstance(body["extra"], dict):
row.extra = {**(row.extra or {}), **body["extra"]}
row.updated_by = self.actor
await self._audit(row, "update")
await self._activity(row.work_hub_id, row, "updated")
await self.db.commit()
await self.db.refresh(row)
return dump(row)
# --- Tasks ---
async def create_task(self, body: dict):
project = await self._get(Project, UUID(str(body["project_id"])))
status = body.get("status", "backlog")
if status not in TASK_STATUSES:
raise HTTPException(422, "Invalid task status")
priority = body.get("priority", "medium")
if priority not in PRIORITIES:
raise HTTPException(422, "Invalid priority")
row = Task(
tenant_id=self.tenant_id,
work_hub_id=project.work_hub_id,
project_id=project.id,
team_id=UUID(str(body["team_id"])) if body.get("team_id") else project.team_id,
title=body.get("title") or "وظیفه",
description=body.get("description"),
status=status,
priority=priority,
assignee_user_id=body.get("assignee_user_id"),
due_date=parse_date(body.get("due_date")),
estimated_minutes=body.get("estimated_minutes"),
actual_minutes=body.get("actual_minutes"),
labels=body.get("labels") or [],
sort_order=int(body.get("sort_order") or 0),
created_by=self.actor,
)
self.db.add(row)
await self.db.flush()
await self._audit(row, "create")
await self._activity(project.work_hub_id, row, "created")
await self._emit(row, "workspace.task.created")
await self.db.commit()
await self.db.refresh(row)
return dump(row)
async def update_task(self, task_id: UUID, body: dict):
row = await self._get(Task, task_id)
old_status = row.status
for key in (
"title",
"description",
"assignee_user_id",
"estimated_minutes",
"actual_minutes",
"labels",
"sort_order",
):
if key in body:
setattr(row, key, body[key])
if "due_date" in body:
row.due_date = parse_date(body["due_date"])
if "priority" in body:
if body["priority"] not in PRIORITIES:
raise HTTPException(422, "Invalid priority")
row.priority = body["priority"]
if "status" in body and body["status"] != row.status:
if body["status"] not in TASK_STATUSES or not can_transition(row.status, body["status"], TASK_TRANSITIONS):
raise HTTPException(409, f"Invalid transition {row.status} -> {body['status']}")
row.status = body["status"]
await self._emit(row, "workspace.task.status_changed")
await self._activity(row.work_hub_id, row, "status_changed", {"from": old_status, "to": row.status})
row.updated_by = self.actor
await self._audit(row, "update")
await self.db.commit()
await self.db.refresh(row)
return dump(row)
# --- Content ---
def _normalize_brief(self, raw) -> dict:
data = dict(raw or {})
brief = {key: data.get(key) for key in BRIEF_KEYS}
extras = {key: value for key, value in data.items() if key not in BRIEF_KEYS}
if extras:
brief["extra"] = extras
return brief
async def create_content(self, body: dict):
hub = await self._require_hub(UUID(str(body["work_hub_id"])))
await self.ensure_content_types(hub.id)
content_type = body.get("content_type") or "other"
types = (
await self.db.execute(
select(ContentType).where(ContentType.tenant_id == self.tenant_id, ContentType.code == content_type)
)
).scalar_one_or_none()
if not types:
raise HTTPException(422, "Unknown content type")
status = body.get("status", "draft")
if status not in CONTENT_STATUSES:
raise HTTPException(422, "Invalid content status")
row = ContentItem(
tenant_id=self.tenant_id,
work_hub_id=hub.id,
project_id=UUID(str(body["project_id"])) if body.get("project_id") else None,
team_id=UUID(str(body["team_id"])) if body.get("team_id") else None,
title=body.get("title") or "محتوا",
content_type=content_type,
channel=body.get("channel") or types.channel,
status=status,
priority=body.get("priority", "medium") if body.get("priority") in PRIORITIES else "medium",
owner_user_id=body.get("owner_user_id") or self.actor,
creator_user_id=body.get("creator_user_id") or self.actor,
reviewer_user_id=body.get("reviewer_user_id"),
scheduled_at=parse_dt(body.get("scheduled_at")),
published_at=parse_dt(body.get("published_at")),
campaign_ref=body.get("campaign_ref"),
caption=body.get("caption"),
body=body.get("body"),
brief=self._normalize_brief(body.get("brief")),
extra=body.get("extra") or {},
created_by=self.actor,
)
self.db.add(row)
await self.db.flush()
await self._audit(row, "create")
await self._activity(hub.id, row, "created")
await self._emit(row, "workspace.content.created")
await self.db.commit()
await self.db.refresh(row)
return dump(row)
async def update_content(self, content_id: UUID, body: dict):
row = await self._get(ContentItem, content_id)
old_status = row.status
for key in (
"title",
"channel",
"owner_user_id",
"reviewer_user_id",
"campaign_ref",
"caption",
"body",
):
if key in body:
setattr(row, key, body[key])
if "scheduled_at" in body:
row.scheduled_at = parse_dt(body["scheduled_at"])
if "published_at" in body:
row.published_at = parse_dt(body["published_at"])
if "content_type" in body:
types = (
await self.db.execute(
select(ContentType).where(
ContentType.tenant_id == self.tenant_id, ContentType.code == body["content_type"]
)
)
).scalar_one_or_none()
if not types:
raise HTTPException(422, "Unknown content type")
row.content_type = body["content_type"]
if "brief" in body:
row.brief = self._normalize_brief(body["brief"])
if "extra" in body and isinstance(body["extra"], dict):
row.extra = {**(row.extra or {}), **body["extra"]}
if "priority" in body:
if body["priority"] not in PRIORITIES:
raise HTTPException(422, "Invalid priority")
row.priority = body["priority"]
if "status" in body and body["status"] != row.status:
if body["status"] not in CONTENT_STATUSES or not can_transition(
row.status, body["status"], CONTENT_TRANSITIONS
):
raise HTTPException(409, f"Invalid transition {row.status} -> {body['status']}")
row.status = body["status"]
if row.status == "scheduled" and body.get("scheduled_at"):
row.scheduled_at = parse_dt(body["scheduled_at"])
if row.status == "published" and not row.published_at:
row.published_at = _now()
await self._emit(row, "workspace.content.status_changed")
await self._activity(row.work_hub_id, row, "status_changed", {"from": old_status, "to": row.status})
if row.status == "scheduled":
await self._emit(row, "workspace.content.scheduled")
row.updated_by = self.actor
await self._audit(row, "update")
await self.db.commit()
await self.db.refresh(row)
return dump(row)
async def request_approval(self, body: dict):
content = await self._get(ContentItem, UUID(str(body["content_id"])))
approval_type = body.get("approval_type", "internal")
if approval_type not in APPROVAL_TYPES:
raise HTTPException(422, "Invalid approval type")
approver = body.get("approver_user_id") or content.reviewer_user_id
if not approver:
raise HTTPException(422, "approver_user_id required")
row = ContentApproval(
tenant_id=self.tenant_id,
content_id=content.id,
work_hub_id=content.work_hub_id,
approver_user_id=approver,
requester_user_id=self.actor,
approval_type=approval_type,
status="pending",
comment=body.get("comment"),
)
self.db.add(row)
await self.db.flush()
await self._activity(content.work_hub_id, row, "approval_requested", {"content_id": str(content.id)})
await self._emit(row, "workspace.approval.requested")
await self.db.commit()
await self.db.refresh(row)
return dump(row)
async def respond_approval(self, approval_id: UUID, body: dict):
row = await self._get(ContentApproval, approval_id)
if row.status != "pending":
raise HTTPException(409, "Approval already resolved")
status = body.get("status")
if status not in APPROVAL_STATUSES or status == "pending":
raise HTTPException(422, "Invalid approval status")
row.status = status
row.comment = body.get("comment", row.comment)
row.responded_at = _now()
content = await self._get(ContentItem, row.content_id)
if status == "approved" and content.status in {"internal_review", "client_review"}:
nxt = "approved" if can_transition(content.status, "approved", CONTENT_TRANSITIONS) else content.status
if nxt != content.status:
old = content.status
content.status = nxt
await self._activity(content.work_hub_id, content, "status_changed", {"from": old, "to": nxt})
if status == "changes_requested" and can_transition(content.status, "in_production", CONTENT_TRANSITIONS):
old = content.status
content.status = "in_production"
await self._activity(content.work_hub_id, content, "status_changed", {"from": old, "to": "in_production"})
await self._activity(content.work_hub_id, row, "approval_responded", {"status": status})
await self._emit(row, "workspace.approval.responded")
await self.db.commit()
await self.db.refresh(row)
return dump(row)
async def add_asset(self, body: dict):
hub = await self._require_hub(UUID(str(body["work_hub_id"])))
file_ref = body.get("file_ref") or body.get("asset_id")
if not file_ref:
raise HTTPException(422, "file_ref required")
row = AssetReference(
tenant_id=self.tenant_id,
work_hub_id=hub.id,
project_id=UUID(str(body["project_id"])) if body.get("project_id") else None,
content_id=UUID(str(body["content_id"])) if body.get("content_id") else None,
task_id=UUID(str(body["task_id"])) if body.get("task_id") else None,
file_ref=str(file_ref),
title=body.get("title"),
asset_type=body.get("asset_type") or body.get("type"),
role=body.get("role"),
sort_order=int(body.get("sort_order") or 0),
storage_status="reference_only",
extra=body.get("extra") or {},
)
self.db.add(row)
await self.db.flush()
await self._activity(hub.id, row, "asset_referenced")
await self.db.commit()
await self.db.refresh(row)
return dump(row)
async def add_comment(self, body: dict):
hub = await self._require_hub(UUID(str(body["work_hub_id"])))
subject_type = body.get("subject_type")
if subject_type not in {"project", "task", "content", "team"}:
raise HTTPException(422, "Invalid subject_type")
subject_id = UUID(str(body["subject_id"]))
text = (body.get("body") or "").strip()
if not text:
raise HTTPException(422, "body required")
row = Comment(
tenant_id=self.tenant_id,
work_hub_id=hub.id,
subject_type=subject_type,
subject_id=subject_id,
author_user_id=body.get("author_user_id") or self.actor or "unknown",
body=text,
)
self.db.add(row)
await self.db.flush()
for mentioned in set(MENTION_RE.findall(text)):
self.db.add(
Mention(
tenant_id=self.tenant_id,
work_hub_id=hub.id,
comment_id=row.id,
mentioned_user_id=mentioned,
subject_type=subject_type,
subject_id=subject_id,
notification_status="persisted",
)
)
await self._activity(hub.id, row, "commented", {"subject_type": subject_type})
await self._emit(row, "workspace.comment.created")
await self.db.commit()
await self.db.refresh(row)
return dump(row)
async def create_content_type(self, body: dict):
code = body.get("code")
if not code:
raise HTTPException(422, "code required")
row = ContentType(
tenant_id=self.tenant_id,
work_hub_id=UUID(str(body["work_hub_id"])) if body.get("work_hub_id") else None,
code=code,
label=body.get("label") or code,
channel=body.get("channel"),
workflow=body.get("workflow") or DEFAULT_WORKFLOW,
enabled=body.get("enabled", True),
extra=body.get("extra") or {},
)
self.db.add(row)
await self.db.commit()
await self.db.refresh(row)
return dump(row)

View File

@ -0,0 +1,43 @@
from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", case_sensitive=False, extra="ignore")
environment: str = "development"
service_name: str = "workspace-service"
api_v1_prefix: str = "/api/v1"
database_url: str = Field(
default="postgresql+asyncpg://superapp:superapp_password@localhost:5432/workspace_db",
validation_alias="WORKSPACE_DATABASE_URL",
)
database_url_sync: str = Field(
default="postgresql+psycopg://superapp:superapp_password@localhost:5432/workspace_db",
validation_alias="WORKSPACE_DATABASE_URL_SYNC",
)
core_service_url: str = "http://localhost:8000"
file_storage_url: str = ""
auth_required: bool = True
entitlement_stub: bool = True
keycloak_enabled: bool = True
keycloak_server_url: str = "http://localhost:8080"
keycloak_public_url: str = ""
keycloak_realm: str = "superapp"
jwt_algorithm: str = "RS256"
jwt_audience: str = "account"
jwt_verify_signature: bool = True
cors_origins: str = "http://localhost:3000,http://127.0.0.1:3000"
@property
def cors_origin_list(self):
return [x.strip() for x in self.cors_origins.split(",") if x.strip()]
@lru_cache
def get_settings():
return Settings()
settings = get_settings()

View File

@ -0,0 +1,26 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.pool import StaticPool
from app.core.config import settings
class Base(DeclarativeBase):
pass
kw = {"pool_pre_ping": True}
if settings.database_url.startswith("sqlite"):
kw.update(poolclass=StaticPool, connect_args={"check_same_thread": False})
engine = create_async_engine(settings.database_url, **kw)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
except Exception:
await session.rollback()
raise

View File

@ -0,0 +1,16 @@
from fastapi import Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.core.config import settings
from shared.exceptions import UnauthorizedError
from shared.security import CurrentUser
bearer = HTTPBearer(auto_error=False)
async def get_current_user(credentials: HTTPAuthorizationCredentials | None = Depends(bearer)):
if not settings.auth_required:
return CurrentUser(user_id="test-user", username="test", roles=["tenant_admin"])
if not credentials:
raise UnauthorizedError("Authentication required")
return CurrentUser(user_id="token-user", username="token", roles=[])

View File

@ -0,0 +1,18 @@
from app.models import OutboxEvent
class TransactionalEventPublisher:
def __init__(self, session):
self.session = session
async def publish(self, *, event_type, aggregate_type, aggregate_id, tenant_id, payload=None):
row = OutboxEvent(
tenant_id=tenant_id,
event_type=str(getattr(event_type, "value", event_type)),
aggregate_type=aggregate_type,
aggregate_id=str(aggregate_id),
payload=payload or {},
)
self.session.add(row)
await self.session.flush()
return row

View File

@ -0,0 +1,14 @@
WORKSPACE_EVENTS = (
"workspace.work_hub.created",
"workspace.work_hub.updated",
"workspace.team.created",
"workspace.project.created",
"workspace.task.created",
"workspace.task.status_changed",
"workspace.content.created",
"workspace.content.status_changed",
"workspace.content.scheduled",
"workspace.approval.requested",
"workspace.approval.responded",
"workspace.comment.created",
)

View File

@ -0,0 +1,40 @@
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app import __version__
from app.api.v1 import api_router
from app.api.v1 import health
from app.core.config import settings
from app.middlewares.tenant import TenantHeaderMiddleware
from shared.exceptions import AppError
def create_app():
app = FastAPI(
title="Torbat Workspace",
version=__version__,
description="Enterprise work management platform",
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(TenantHeaderMiddleware)
@app.exception_handler(AppError)
async def handle(request: Request, exc: AppError):
return JSONResponse(
status_code=exc.status_code,
content={"error": {"code": exc.error_code, "message": exc.message, "details": exc.details}},
)
app.include_router(health.router)
app.include_router(api_router, prefix=settings.api_v1_prefix)
return app
app = create_app()

View File

@ -0,0 +1,16 @@
from uuid import UUID
from starlette.middleware.base import BaseHTTPMiddleware
from shared.tenant import HEADER_TENANT_ID, STATE_TENANT_ID
class TenantHeaderMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
raw = request.headers.get(HEADER_TENANT_ID)
try:
value = UUID(raw) if raw else None
except ValueError:
value = None
setattr(request.state, STATE_TENANT_ID, value)
return await call_next(request)

View File

@ -0,0 +1,39 @@
from app.models.domain import (
Activity,
AssetReference,
Comment,
ContentApproval,
ContentItem,
ContentType,
Mention,
OutboxEvent,
Project,
ProjectMember,
Task,
Team,
TeamMember,
WorkHub,
WorkspaceAuditLog,
WorkspacePermission,
WorkspaceSetting,
)
__all__ = [
"Activity",
"AssetReference",
"Comment",
"ContentApproval",
"ContentItem",
"ContentType",
"Mention",
"OutboxEvent",
"Project",
"ProjectMember",
"Task",
"Team",
"TeamMember",
"WorkHub",
"WorkspaceAuditLog",
"WorkspacePermission",
"WorkspaceSetting",
]

View File

@ -0,0 +1,27 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.models.types import GUID
class UUIDPrimaryKeyMixin:
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
class TenantMixin:
tenant_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False, index=True)
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
class ActorAuditMixin:
created_by: Mapped[str | None] = mapped_column(String(100))
updated_by: Mapped[str | None] = mapped_column(String(100))

View File

@ -0,0 +1,206 @@
from __future__ import annotations
import uuid
from datetime import date, datetime
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import ActorAuditMixin, TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.types import GUID
class WorkHub(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, ActorAuditMixin):
__tablename__ = "work_hubs"
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(40), default="active", nullable=False, index=True)
owner_user_id: Mapped[str | None] = mapped_column(String(100), index=True)
class Team(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, ActorAuditMixin):
__tablename__ = "teams"
work_hub_id: Mapped[uuid.UUID] = mapped_column(GUID(), ForeignKey("work_hubs.id"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(40), default="active", nullable=False, index=True)
owner_user_id: Mapped[str | None] = mapped_column(String(100), index=True)
class TeamMember(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "team_members"
__table_args__ = (UniqueConstraint("tenant_id", "team_id", "user_id", name="uq_team_member"),)
team_id: Mapped[uuid.UUID] = mapped_column(GUID(), ForeignKey("teams.id"), nullable=False, index=True)
user_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
role: Mapped[str] = mapped_column(String(40), default="member", nullable=False)
status: Mapped[str] = mapped_column(String(40), default="active", nullable=False, index=True)
joined_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
class Project(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, ActorAuditMixin):
__tablename__ = "projects"
work_hub_id: Mapped[uuid.UUID] = mapped_column(GUID(), ForeignKey("work_hubs.id"), nullable=False, index=True)
team_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), ForeignKey("teams.id"), index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(40), default="planning", nullable=False, index=True)
priority: Mapped[str] = mapped_column(String(40), default="medium", nullable=False)
owner_user_id: Mapped[str | None] = mapped_column(String(100), index=True)
start_date: Mapped[date | None] = mapped_column(Date)
due_date: Mapped[date | None] = mapped_column(Date, index=True)
budget_estimate: Mapped[int | None] = mapped_column(Integer)
extra: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
class ProjectMember(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "project_members"
__table_args__ = (UniqueConstraint("tenant_id", "project_id", "user_id", name="uq_project_member"),)
project_id: Mapped[uuid.UUID] = mapped_column(GUID(), ForeignKey("projects.id"), nullable=False, index=True)
user_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
role: Mapped[str] = mapped_column(String(40), default="member", nullable=False)
status: Mapped[str] = mapped_column(String(40), default="active", nullable=False)
class Task(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, ActorAuditMixin):
__tablename__ = "tasks"
work_hub_id: Mapped[uuid.UUID] = mapped_column(GUID(), ForeignKey("work_hubs.id"), nullable=False, index=True)
project_id: Mapped[uuid.UUID] = mapped_column(GUID(), ForeignKey("projects.id"), nullable=False, index=True)
team_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), ForeignKey("teams.id"), index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(40), default="backlog", nullable=False, index=True)
priority: Mapped[str] = mapped_column(String(40), default="medium", nullable=False)
assignee_user_id: Mapped[str | None] = mapped_column(String(100), index=True)
due_date: Mapped[date | None] = mapped_column(Date, index=True)
estimated_minutes: Mapped[int | None] = mapped_column(Integer)
actual_minutes: Mapped[int | None] = mapped_column(Integer)
labels: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
class ContentType(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "content_types"
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_content_type_code"),)
work_hub_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), ForeignKey("work_hubs.id"), index=True)
code: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
label: Mapped[str] = mapped_column(String(255), nullable=False)
channel: Mapped[str | None] = mapped_column(String(80))
workflow: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
extra: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
class ContentItem(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, ActorAuditMixin):
__tablename__ = "content_items"
work_hub_id: Mapped[uuid.UUID] = mapped_column(GUID(), ForeignKey("work_hubs.id"), nullable=False, index=True)
project_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), ForeignKey("projects.id"), index=True)
team_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), ForeignKey("teams.id"), index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
content_type: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
channel: Mapped[str | None] = mapped_column(String(80), index=True)
status: Mapped[str] = mapped_column(String(40), default="draft", nullable=False, index=True)
priority: Mapped[str] = mapped_column(String(40), default="medium", nullable=False)
owner_user_id: Mapped[str | None] = mapped_column(String(100), index=True)
creator_user_id: Mapped[str | None] = mapped_column(String(100))
reviewer_user_id: Mapped[str | None] = mapped_column(String(100), index=True)
scheduled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
campaign_ref: Mapped[str | None] = mapped_column(String(255))
caption: Mapped[str | None] = mapped_column(Text)
body: Mapped[str | None] = mapped_column(Text)
brief: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
extra: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
class ContentApproval(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "content_approvals"
content_id: Mapped[uuid.UUID] = mapped_column(GUID(), ForeignKey("content_items.id"), nullable=False, index=True)
work_hub_id: Mapped[uuid.UUID] = mapped_column(GUID(), ForeignKey("work_hubs.id"), nullable=False, index=True)
approver_user_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
requester_user_id: Mapped[str | None] = mapped_column(String(100))
approval_type: Mapped[str] = mapped_column(String(40), default="internal", nullable=False)
status: Mapped[str] = mapped_column(String(40), default="pending", nullable=False, index=True)
comment: Mapped[str | None] = mapped_column(Text)
requested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
responded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class AssetReference(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "asset_references"
work_hub_id: Mapped[uuid.UUID] = mapped_column(GUID(), ForeignKey("work_hubs.id"), nullable=False, index=True)
project_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), ForeignKey("projects.id"), index=True)
content_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), ForeignKey("content_items.id"), index=True)
task_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), ForeignKey("tasks.id"), index=True)
file_ref: Mapped[str] = mapped_column(String(255), nullable=False)
title: Mapped[str | None] = mapped_column(String(255))
asset_type: Mapped[str | None] = mapped_column(String(80))
role: Mapped[str | None] = mapped_column(String(80))
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
storage_status: Mapped[str] = mapped_column(String(40), default="reference_only", nullable=False)
extra: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
class Comment(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "comments"
work_hub_id: Mapped[uuid.UUID] = mapped_column(GUID(), ForeignKey("work_hubs.id"), nullable=False, index=True)
subject_type: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
subject_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False, index=True)
author_user_id: Mapped[str] = mapped_column(String(100), nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(String(40), default="active", nullable=False)
class Mention(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "mentions"
work_hub_id: Mapped[uuid.UUID] = mapped_column(GUID(), ForeignKey("work_hubs.id"), nullable=False, index=True)
comment_id: Mapped[uuid.UUID] = mapped_column(GUID(), ForeignKey("comments.id"), nullable=False, index=True)
mentioned_user_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
subject_type: Mapped[str] = mapped_column(String(40), nullable=False)
subject_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
notification_status: Mapped[str] = mapped_column(String(40), default="persisted", nullable=False)
class Activity(Base, UUIDPrimaryKeyMixin, TenantMixin):
__tablename__ = "activities"
work_hub_id: Mapped[uuid.UUID] = mapped_column(GUID(), ForeignKey("work_hubs.id"), nullable=False, index=True)
entity_type: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
entity_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False, index=True)
action: Mapped[str] = mapped_column(String(80), nullable=False)
actor_user_id: Mapped[str | None] = mapped_column(String(100))
payload: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
class WorkspaceAuditLog(Base, UUIDPrimaryKeyMixin, TenantMixin):
__tablename__ = "workspace_audit_logs"
entity_type: Mapped[str] = mapped_column(String(100), nullable=False)
entity_id: Mapped[uuid.UUID | None] = mapped_column(GUID())
action: Mapped[str] = mapped_column(String(50), nullable=False)
actor_user_id: Mapped[str | None] = mapped_column(String(100))
changes: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
class OutboxEvent(Base, UUIDPrimaryKeyMixin, TenantMixin):
__tablename__ = "outbox_events"
event_type: Mapped[str] = mapped_column(String(150), nullable=False)
aggregate_type: Mapped[str] = mapped_column(String(100), nullable=False)
aggregate_id: Mapped[str] = mapped_column(String(100), nullable=False)
payload: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
status: Mapped[str] = mapped_column(String(30), default="pending", nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
class WorkspaceSetting(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "workspace_settings"
work_hub_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), ForeignKey("work_hubs.id"), index=True)
key: Mapped[str] = mapped_column(String(150), nullable=False)
value: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
class WorkspacePermission(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "workspace_permissions"
code: Mapped[str] = mapped_column(String(150), nullable=False)
description: Mapped[str | None] = mapped_column(String(255))

View File

@ -0,0 +1,23 @@
import uuid
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.types import CHAR, TypeDecorator
class GUID(TypeDecorator):
impl = CHAR
cache_ok = True
def load_dialect_impl(self, dialect):
return dialect.type_descriptor(PG_UUID(as_uuid=True) if dialect.name == "postgresql" else CHAR(36))
def process_bind_param(self, value, dialect):
if value is None:
return None
parsed = value if isinstance(value, uuid.UUID) else uuid.UUID(str(value))
return parsed if dialect.name == "postgresql" else str(parsed)
def process_result_value(self, value, dialect):
if value is None or isinstance(value, uuid.UUID):
return value
return uuid.UUID(str(value))

View File

@ -0,0 +1,52 @@
ALL_PERMISSIONS = [
"workspace.hub.read",
"workspace.hub.manage",
"workspace.team.read",
"workspace.team.manage",
"workspace.project.read",
"workspace.project.manage",
"workspace.task.read",
"workspace.task.manage",
"workspace.content.read",
"workspace.content.manage",
"workspace.content.review",
"workspace.content.approve",
"workspace.calendar.manage",
"workspace.asset.read",
"workspace.asset.manage",
"workspace.report.read",
"workspace.comment.manage",
"workspace.module.enabled",
]
PERMISSION_PREFIXES = ("workspace.",)
ROLE_PERMISSIONS = {
"owner": ALL_PERMISSIONS,
"manager": [p for p in ALL_PERMISSIONS if p != "workspace.hub.manage"],
"member": [
"workspace.hub.read",
"workspace.team.read",
"workspace.project.read",
"workspace.project.manage",
"workspace.task.read",
"workspace.task.manage",
"workspace.content.read",
"workspace.content.manage",
"workspace.content.review",
"workspace.calendar.manage",
"workspace.asset.read",
"workspace.asset.manage",
"workspace.report.read",
"workspace.comment.manage",
],
"viewer": [
"workspace.hub.read",
"workspace.team.read",
"workspace.project.read",
"workspace.task.read",
"workspace.content.read",
"workspace.asset.read",
"workspace.report.read",
],
}

View File

@ -0,0 +1,76 @@
TASK_STATUSES = ("backlog", "todo", "in_progress", "review", "done", "archived")
TASK_TRANSITIONS = {
"backlog": {"todo", "in_progress", "archived"},
"todo": {"backlog", "in_progress", "archived"},
"in_progress": {"todo", "review", "done", "archived"},
"review": {"in_progress", "done", "todo", "archived"},
"done": {"review", "archived"},
"archived": {"backlog", "todo"},
}
PROJECT_STATUSES = ("planning", "active", "on_hold", "completed", "archived")
HUB_STATUSES = ("active", "archived")
TEAM_STATUSES = ("active", "archived")
TEAM_ROLES = ("owner", "manager", "member", "viewer")
PRIORITIES = ("low", "medium", "high", "urgent")
CONTENT_STATUSES = (
"draft",
"brief",
"in_production",
"internal_review",
"client_review",
"approved",
"scheduled",
"published",
"archived",
)
CONTENT_TRANSITIONS = {
"draft": {"brief", "in_production", "archived"},
"brief": {"draft", "in_production", "archived"},
"in_production": {"brief", "internal_review", "archived"},
"internal_review": {"in_production", "client_review", "approved", "archived"},
"client_review": {"in_production", "approved", "archived"},
"approved": {"scheduled", "published", "in_production", "archived"},
"scheduled": {"published", "approved", "archived"},
"published": {"archived"},
"archived": {"draft"},
}
APPROVAL_TYPES = ("internal", "client", "final")
APPROVAL_STATUSES = ("pending", "approved", "rejected", "changes_requested")
DEFAULT_CONTENT_TYPES = [
{"code": "instagram_post", "label": "پست اینستاگرام", "channel": "instagram"},
{"code": "instagram_reel", "label": "ریل اینستاگرام", "channel": "instagram"},
{"code": "instagram_story", "label": "استوری اینستاگرام", "channel": "instagram"},
{"code": "blog_article", "label": "مقاله وبلاگ", "channel": "blog"},
{"code": "website_page", "label": "محتوای صفحه وب", "channel": "website"},
{"code": "video", "label": "ویدیو", "channel": "video"},
{"code": "podcast", "label": "پادکست", "channel": "audio"},
{"code": "advertisement", "label": "تبلیغات", "channel": "ads"},
{"code": "email", "label": "ایمیل", "channel": "email"},
{"code": "sms_brief", "label": "بریف پیامک", "channel": "sms"},
{"code": "creative_brief", "label": "بریف خلاق", "channel": "creative"},
{"code": "other", "label": "سایر", "channel": "other"},
]
DEFAULT_WORKFLOW = list(CONTENT_STATUSES)
BRIEF_KEYS = (
"objective",
"audience",
"key_message",
"hook",
"cta",
"tone",
"platform",
"references",
"deliverables",
"deadline",
"notes",
)
def can_transition(current: str, target: str, table: dict[str, set[str]]) -> bool:
return target in table.get(current, set())

View File

@ -0,0 +1,3 @@
from app.providers.contracts import FileStorageContract, NotificationContract
__all__ = ["FileStorageContract", "NotificationContract"]

View File

@ -0,0 +1,29 @@
"""External integration contracts. Workspace does not own blobs or notification delivery."""
from dataclasses import dataclass
@dataclass(frozen=True)
class FileStorageContract:
available: bool
status: str
mode: str = "reference_only"
@dataclass(frozen=True)
class NotificationContract:
available: bool
status: str
mode: str = "persisted_only"
def file_storage_state(base_url: str | None) -> FileStorageContract:
ready = bool(base_url)
return FileStorageContract(
available=ready,
status="ready" if ready else "unavailable",
)
def notification_state() -> NotificationContract:
return NotificationContract(available=False, status="persisted_only")

View File

@ -0,0 +1,146 @@
from __future__ import annotations
from datetime import date, datetime
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.commands.workspace import dump, parse_dt
from app.models import (
ContentApproval,
ContentItem,
Project,
Task,
Team,
TeamMember,
)
from app.policies.workflow import CONTENT_STATUSES
class WorkspaceQueries:
def __init__(self, db: AsyncSession, tenant_id: UUID):
self.db = db
self.tenant_id = tenant_id
async def list_model(self, model, **filters):
stmt = select(model).where(model.tenant_id == self.tenant_id)
for key, value in filters.items():
if value is None or not hasattr(model, key):
continue
stmt = stmt.where(getattr(model, key) == value)
return [dump(row) for row in (await self.db.execute(stmt)).scalars().all()]
async def get_model(self, model, entity_id: UUID):
row = (
await self.db.execute(select(model).where(model.tenant_id == self.tenant_id, model.id == entity_id))
).scalar_one_or_none()
return dump(row) if row else None
async def project_progress(self, project_id: UUID):
total = (
await self.db.execute(
select(func.count()).where(Task.tenant_id == self.tenant_id, Task.project_id == project_id)
)
).scalar_one()
done = (
await self.db.execute(
select(func.count()).where(
Task.tenant_id == self.tenant_id, Task.project_id == project_id, Task.status == "done"
)
)
).scalar_one()
return {"total": int(total or 0), "done": int(done or 0), "percent": int((done / total) * 100) if total else 0}
async def dashboard(self, hub_id: UUID):
today = date.today()
tasks = (
await self.db.execute(select(Task).where(Task.tenant_id == self.tenant_id, Task.work_hub_id == hub_id))
).scalars().all()
contents = (
await self.db.execute(
select(ContentItem).where(ContentItem.tenant_id == self.tenant_id, ContentItem.work_hub_id == hub_id)
)
).scalars().all()
projects = (
await self.db.execute(
select(Project).where(Project.tenant_id == self.tenant_id, Project.work_hub_id == hub_id)
)
).scalars().all()
members = (
await self.db.execute(
select(TeamMember)
.join(Team, Team.id == TeamMember.team_id)
.where(
TeamMember.tenant_id == self.tenant_id,
Team.work_hub_id == hub_id,
TeamMember.status == "active",
)
)
).scalars().all()
pending_approvals = (
await self.db.execute(
select(func.count()).where(
ContentApproval.tenant_id == self.tenant_id,
ContentApproval.work_hub_id == hub_id,
ContentApproval.status == "pending",
)
)
).scalar_one()
open_tasks = [t for t in tasks if t.status not in {"done", "archived"}]
overdue_tasks = [t for t in open_tasks if t.due_date and t.due_date < today]
content_by_status = {status: 0 for status in CONTENT_STATUSES}
for item in contents:
content_by_status[item.status] = content_by_status.get(item.status, 0) + 1
workload: dict[str, dict] = {}
for task in tasks:
key = task.assignee_user_id or "unassigned"
workload.setdefault(key, {"tasks": 0, "overdue": 0, "content": 0})
workload[key]["tasks"] += 1
if task.due_date and task.due_date < today and task.status not in {"done", "archived"}:
workload[key]["overdue"] += 1
for item in contents:
key = item.owner_user_id or "unassigned"
workload.setdefault(key, {"tasks": 0, "overdue": 0, "content": 0})
workload[key]["content"] += 1
upcoming = sorted(
[t for t in open_tasks if t.due_date],
key=lambda t: t.due_date,
)[:8]
return {
"overview": {
"active_projects": len([p for p in projects if p.status == "active"]),
"open_tasks": len(open_tasks),
"overdue_tasks": len(overdue_tasks),
"content_in_production": content_by_status.get("in_production", 0),
"content_awaiting_review": content_by_status.get("internal_review", 0)
+ content_by_status.get("client_review", 0),
"scheduled_content": content_by_status.get("scheduled", 0),
"pending_approvals": int(pending_approvals or 0),
"team_members": len({m.user_id for m in members}),
"upcoming_deadlines": len(upcoming),
},
"content": content_by_status,
"workload": [{"user_id": key, **value} for key, value in workload.items()],
"upcoming": [dump(t) for t in upcoming],
}
async def calendar(self, hub_id: UUID, filters: dict):
stmt = select(ContentItem).where(
ContentItem.tenant_id == self.tenant_id,
ContentItem.work_hub_id == hub_id,
ContentItem.scheduled_at.is_not(None),
)
for key in ("team_id", "project_id", "channel", "content_type", "status", "owner_user_id"):
if filters.get(key):
stmt = stmt.where(getattr(ContentItem, key) == filters[key])
if filters.get("from"):
stmt = stmt.where(ContentItem.scheduled_at >= parse_dt(filters["from"]))
if filters.get("to"):
stmt = stmt.where(ContentItem.scheduled_at <= parse_dt(filters["to"]))
rows = (await self.db.execute(stmt.order_by(ContentItem.scheduled_at))).scalars().all()
return [dump(row) for row in rows]

View File

@ -0,0 +1,25 @@
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
class TenantRepository:
def __init__(self, session: AsyncSession, model):
self.session = session
self.model = model
async def get(self, tenant_id: UUID, entity_id: UUID):
return (
await self.session.execute(
select(self.model).where(self.model.tenant_id == tenant_id, self.model.id == entity_id)
)
).scalar_one_or_none()
async def list(self, tenant_id: UUID, **filters):
stmt = select(self.model).where(self.model.tenant_id == tenant_id)
for key, value in filters.items():
if value is None or not hasattr(self.model, key):
continue
stmt = stmt.where(getattr(self.model, key) == value)
return (await self.session.execute(stmt)).scalars().all()

View File

@ -0,0 +1,36 @@
import os
import uuid
os.environ["ENVIRONMENT"] = "test"
os.environ["AUTH_REQUIRED"] = "false"
os.environ["WORKSPACE_DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
os.environ["WORKSPACE_DATABASE_URL_SYNC"] = "sqlite:///:memory:"
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from app.core.database import Base, engine
import app.models # noqa: F401
from app.main import app
TENANT_A = uuid.uuid4()
TENANT_B = uuid.uuid4()
def headers(tenant=TENANT_A):
return {"X-Tenant-ID": str(tenant)}
@pytest_asyncio.fixture
async def client():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
yield ac
async def seed_hub(client, tenant=TENANT_A, name="مرکز کار اصلی"):
res = await client.post("/api/v1/hubs", headers=headers(tenant), json={"name": name})
assert res.status_code == 201, res.text
return res.json()

View File

@ -0,0 +1,14 @@
from pathlib import Path
from app.core.database import Base
import app.models # noqa: F401
def test_all_owned_rows_are_tenant_scoped():
assert all("tenant_id" in table.columns for table in Base.metadata.tables.values())
def test_layers_exist():
root = Path(__file__).parents[2]
for name in ("api", "commands", "queries", "repositories", "models", "providers", "events"):
assert (root / "app" / name).exists()

View File

@ -0,0 +1,271 @@
import pytest
from app.tests.conftest import TENANT_A, headers, seed_hub
async def bootstrap(client):
hub = await seed_hub(client)
team = (
await client.post(
"/api/v1/teams",
headers=headers(),
json={"work_hub_id": hub["id"], "name": "تولید محتوا"},
)
).json()
project = (
await client.post(
"/api/v1/projects",
headers=headers(),
json={"work_hub_id": hub["id"], "team_id": team["id"], "name": "کمپین بهار", "status": "active"},
)
).json()
return hub, team, project
@pytest.mark.asyncio
async def test_hub_crud_and_dashboard(client):
hub = await seed_hub(client, name="مرکز شمال")
listed = await client.get("/api/v1/hubs", headers=headers())
assert listed.status_code == 200
assert any(item["name"] == "مرکز شمال" for item in listed.json())
updated = await client.patch(f"/api/v1/hubs/{hub['id']}", headers=headers(), json={"description": "توضیح"})
assert updated.status_code == 200
dash = await client.get(f"/api/v1/hubs/{hub['id']}/dashboard", headers=headers())
assert dash.status_code == 200
overview = dash.json()["overview"]
assert overview["active_projects"] == 0
assert overview["open_tasks"] == 0
caps = await client.get("/capabilities", headers=headers())
assert caps.json()["file_storage"]["status"] == "unavailable"
@pytest.mark.asyncio
async def test_team_membership_and_roles(client):
hub, team, _ = await bootstrap(client)
detail = await client.get(f"/api/v1/teams/{team['id']}", headers=headers())
assert detail.status_code == 200
assert any(member["role"] == "owner" for member in detail.json()["members"])
added = await client.post(
"/api/v1/team-members",
headers=headers(),
json={"team_id": team["id"], "user_id": "sara", "role": "manager"},
)
assert added.status_code == 201
changed = await client.patch(
f"/api/v1/team-members/{added.json()['id']}",
headers=headers(),
json={"role": "viewer"},
)
assert changed.json()["role"] == "viewer"
archived = await client.patch(f"/api/v1/teams/{team['id']}", headers=headers(), json={"status": "archived"})
assert archived.json()["status"] == "archived"
bad_role = await client.post(
"/api/v1/team-members",
headers=headers(),
json={"team_id": team["id"], "user_id": "ali", "role": "superuser"},
)
assert bad_role.status_code == 422
assert hub["id"]
@pytest.mark.asyncio
async def test_project_crud_and_progress(client):
hub, team, project = await bootstrap(client)
listed = await client.get("/api/v1/projects", headers=headers(), params={"work_hub_id": hub["id"]})
assert any(item["id"] == project["id"] for item in listed.json())
await client.post(
"/api/v1/tasks",
headers=headers(),
json={"project_id": project["id"], "title": "نوشتن بریف", "status": "done"},
)
await client.post(
"/api/v1/tasks",
headers=headers(),
json={"project_id": project["id"], "title": "طراحی", "team_id": team["id"]},
)
detail = await client.get(f"/api/v1/projects/{project['id']}", headers=headers())
assert detail.json()["progress"]["total"] == 2
assert detail.json()["progress"]["done"] == 1
updated = await client.patch(
f"/api/v1/projects/{project['id']}",
headers=headers(),
json={"status": "on_hold", "priority": "high"},
)
assert updated.json()["status"] == "on_hold"
@pytest.mark.asyncio
async def test_task_kanban_transitions_persist(client):
_, _, project = await bootstrap(client)
created = await client.post(
"/api/v1/tasks",
headers=headers(),
json={"project_id": project["id"], "title": "کارت کانبان", "status": "backlog"},
)
task_id = created.json()["id"]
moved = await client.patch(f"/api/v1/tasks/{task_id}", headers=headers(), json={"status": "todo"})
assert moved.status_code == 200
assert moved.json()["status"] == "todo"
invalid = await client.patch(f"/api/v1/tasks/{task_id}", headers=headers(), json={"status": "done"})
assert invalid.status_code == 409
for status in ("in_progress", "review", "done"):
res = await client.patch(f"/api/v1/tasks/{task_id}", headers=headers(), json={"status": status})
assert res.status_code == 200
assert res.json()["status"] == status
stored = await client.get(f"/api/v1/tasks/{task_id}", headers=headers())
assert stored.json()["status"] == "done"
assert "todo" in stored.json()["next_actions"] or "archived" in stored.json()["next_actions"]
@pytest.mark.asyncio
async def test_content_workflow_approval_and_calendar(client):
hub, team, project = await bootstrap(client)
types = await client.get("/api/v1/content-types", headers=headers())
assert types.status_code == 200
assert any(item["code"] == "instagram_post" for item in types.json())
created = await client.post(
"/api/v1/content",
headers=headers(),
json={
"work_hub_id": hub["id"],
"project_id": project["id"],
"team_id": team["id"],
"title": "پست معرفی",
"content_type": "instagram_post",
"reviewer_user_id": "reviewer",
"brief": {"objective": "آگاهی از برند", "audience": "مشتریان", "cta": "ثبت‌نام"},
},
)
assert created.status_code == 201
content_id = created.json()["id"]
assert created.json()["brief"]["objective"] == "آگاهی از برند"
for status in ("brief", "in_production", "internal_review"):
res = await client.patch(f"/api/v1/content/{content_id}", headers=headers(), json={"status": status})
assert res.status_code == 200, res.text
skip = await client.patch(f"/api/v1/content/{content_id}", headers=headers(), json={"status": "published"})
assert skip.status_code == 409
approval = await client.post(
"/api/v1/content/approvals",
headers=headers(),
json={"content_id": content_id, "approval_type": "internal", "approver_user_id": "reviewer"},
)
assert approval.status_code == 201
approved = await client.patch(
f"/api/v1/content/approvals/{approval.json()['id']}",
headers=headers(),
json={"status": "approved", "comment": "مناسب است"},
)
assert approved.status_code == 200
detail = await client.get(f"/api/v1/content/{content_id}", headers=headers())
assert detail.json()["status"] == "approved"
scheduled = await client.patch(
f"/api/v1/content/{content_id}",
headers=headers(),
json={"status": "scheduled", "scheduled_at": "2026-09-20T10:00:00+00:00"},
)
assert scheduled.json()["status"] == "scheduled"
calendar = await client.get(
"/api/v1/content/calendar",
headers=headers(),
params={"work_hub_id": hub["id"], "channel": "instagram"},
)
assert calendar.status_code == 200
assert any(item["id"] == content_id for item in calendar.json())
moved = await client.patch(
f"/api/v1/content/{content_id}",
headers=headers(),
json={"scheduled_at": "2026-09-22T12:00:00+00:00"},
)
assert moved.json()["scheduled_at"].startswith("2026-09-22")
@pytest.mark.asyncio
async def test_approval_reject_and_changes(client):
hub, _, project = await bootstrap(client)
content = (
await client.post(
"/api/v1/content",
headers=headers(),
json={
"work_hub_id": hub["id"],
"project_id": project["id"],
"title": "نسخه دوم",
"content_type": "blog_article",
"status": "internal_review",
"reviewer_user_id": "editor",
},
)
).json()
pending = (
await client.post(
"/api/v1/content/approvals",
headers=headers(),
json={"content_id": content["id"], "approval_type": "client", "approver_user_id": "client-1"},
)
).json()
changed = await client.patch(
f"/api/v1/content/approvals/{pending['id']}",
headers=headers(),
json={"status": "changes_requested", "comment": "هوک ضعیف است"},
)
assert changed.json()["status"] == "changes_requested"
after = await client.get(f"/api/v1/content/{content['id']}", headers=headers())
assert after.json()["status"] == "in_production"
second = (
await client.post(
"/api/v1/content/approvals",
headers=headers(),
json={"content_id": content["id"], "approval_type": "final", "approver_user_id": "editor"},
)
).json()
rejected = await client.patch(
f"/api/v1/content/approvals/{second['id']}",
headers=headers(),
json={"status": "rejected", "comment": "رد شد"},
)
assert rejected.json()["status"] == "rejected"
history = await client.get("/api/v1/content/approvals", headers=headers(), params={"content_id": content["id"]})
assert len(history.json()) == 2
@pytest.mark.asyncio
async def test_asset_reference_and_comments(client):
hub, _, project = await bootstrap(client)
content = (
await client.post(
"/api/v1/content",
headers=headers(),
json={"work_hub_id": hub["id"], "project_id": project["id"], "title": "ویدیو", "content_type": "video"},
)
).json()
missing = await client.post("/api/v1/assets", headers=headers(), json={"work_hub_id": hub["id"]})
assert missing.status_code == 422
asset = await client.post(
"/api/v1/assets",
headers=headers(),
json={
"work_hub_id": hub["id"],
"content_id": content["id"],
"file_ref": "file-storage://clip-1",
"title": "کات اول",
"role": "primary",
},
)
assert asset.status_code == 201
assert asset.json()["storage_status"] == "reference_only"
comment = await client.post(
"/api/v1/comments",
headers=headers(),
json={
"work_hub_id": hub["id"],
"subject_type": "content",
"subject_id": content["id"],
"body": "لطفا @sara هوک را کوتاه کند",
},
)
assert comment.status_code == 201
mentions = await client.get("/api/v1/mentions", headers=headers(), params={"work_hub_id": hub["id"]})
assert any(item["mentioned_user_id"] == "sara" for item in mentions.json())
activity = await client.get("/api/v1/activity", headers=headers(), params={"work_hub_id": hub["id"]})
assert len(activity.json()) >= 1
assert TENANT_A

View File

@ -0,0 +1,6 @@
from pathlib import Path
def test_initial_migration_exists():
names = {path.stem for path in (Path(__file__).parents[2] / "alembic" / "versions").glob("*.py")}
assert "0001_initial" in names

View File

@ -0,0 +1,38 @@
from app.permissions.definitions import ALL_PERMISSIONS, ROLE_PERMISSIONS
def test_workspace_permissions_registered():
required = {
"workspace.hub.read",
"workspace.hub.manage",
"workspace.team.read",
"workspace.team.manage",
"workspace.project.read",
"workspace.project.manage",
"workspace.task.read",
"workspace.task.manage",
"workspace.content.read",
"workspace.content.manage",
"workspace.content.review",
"workspace.content.approve",
"workspace.calendar.manage",
"workspace.asset.read",
"workspace.report.read",
"workspace.module.enabled",
}
assert required <= set(ALL_PERMISSIONS)
def test_team_roles_map_to_permissions():
assert "workspace.hub.manage" in ROLE_PERMISSIONS["owner"]
assert "workspace.hub.manage" not in ROLE_PERMISSIONS["manager"]
assert "workspace.content.approve" not in ROLE_PERMISSIONS["member"]
assert ROLE_PERMISSIONS["viewer"] == [
"workspace.hub.read",
"workspace.team.read",
"workspace.project.read",
"workspace.task.read",
"workspace.content.read",
"workspace.asset.read",
"workspace.report.read",
]

View File

@ -0,0 +1,32 @@
import pytest
from app.tests.conftest import headers
@pytest.mark.asyncio
async def test_tenant_header_required(client):
res = await client.get("/api/v1/hubs")
assert res.status_code in (400, 422)
@pytest.mark.asyncio
async def test_health_is_public(client):
res = await client.get("/health")
assert res.status_code == 200
assert res.json()["service"] == "workspace-service"
@pytest.mark.asyncio
async def test_auth_required_blocks_without_token(client, monkeypatch):
from app.core.config import settings
monkeypatch.setattr(settings, "auth_required", True)
res = await client.get("/api/v1/hubs", headers=headers())
assert res.status_code in (401, 403)
@pytest.mark.asyncio
async def test_catalog_lists_workspace_namespace(client):
res = await client.get("/api/v1/permissions/catalog", headers=headers())
assert res.status_code == 200
assert all(code.startswith("workspace.") for code in res.json()["permissions"])

View File

@ -0,0 +1,28 @@
import pytest
from app.tests.conftest import TENANT_A, TENANT_B, headers, seed_hub
@pytest.mark.asyncio
async def test_tenants_cannot_read_each_other_hubs(client):
hub = await seed_hub(client, TENANT_A, "هاب الف")
assert (await client.get(f"/api/v1/hubs/{hub['id']}", headers=headers(TENANT_B))).status_code == 404
assert (await client.get("/api/v1/hubs", headers=headers(TENANT_B))).json() == []
@pytest.mark.asyncio
async def test_tenants_cannot_mutate_foreign_projects(client):
hub = await seed_hub(client, TENANT_A)
project = (
await client.post(
"/api/v1/projects",
headers=headers(TENANT_A),
json={"work_hub_id": hub["id"], "name": "پروژه الف"},
)
).json()
res = await client.patch(
f"/api/v1/projects/{project['id']}",
headers=headers(TENANT_B),
json={"name": "سرقت"},
)
assert res.status_code == 404

View File

@ -0,0 +1,2 @@
[pytest]
asyncio_mode = auto

View File

@ -0,0 +1,14 @@
fastapi==0.111.0
uvicorn[standard]==0.30.1
pydantic[email]==2.7.4
pydantic-settings==2.3.4
sqlalchemy==2.0.31
alembic==1.13.2
asyncpg==0.29.0
psycopg[binary]>=3.2.2
httpx==0.27.0
pyjwt[crypto]==2.8.0
-e ../../shared-lib
pytest==8.2.2
pytest-asyncio==0.23.7
aiosqlite==0.20.0

View File

@ -0,0 +1,28 @@
import os
from urllib.parse import urlparse
def main():
url = os.getenv("WORKSPACE_DATABASE_URL_SYNC", "")
if not url:
return
import psycopg
parsed = urlparse(url.replace("+psycopg", ""))
db = (parsed.path or "/workspace_db").lstrip("/")
with psycopg.connect(
host=parsed.hostname or "localhost",
port=parsed.port or 5432,
user=parsed.username,
password=parsed.password,
dbname="postgres",
autocommit=True,
) as conn:
with conn.cursor() as cur:
cur.execute("SELECT 1 FROM pg_database WHERE datname=%s", (db,))
if not cur.fetchone():
cur.execute(f'CREATE DATABASE "{db}"')
if __name__ == "__main__":
main()

View File

@ -447,6 +447,36 @@ services:
networks: networks:
- superapp_net - superapp_net
workspace-service:
build:
context: .
dockerfile: backend/services/workspace/Dockerfile.dev
container_name: superapp_workspace_service
restart: unless-stopped
env_file:
- .env
environment:
WORKSPACE_DATABASE_URL: ${WORKSPACE_DATABASE_URL:-postgresql+asyncpg://superapp:superapp_password@postgres:5432/workspace_db}
WORKSPACE_DATABASE_URL_SYNC: ${WORKSPACE_DATABASE_URL_SYNC:-postgresql+psycopg://superapp:superapp_password@postgres:5432/workspace_db}
CORE_SERVICE_URL: ${CORE_SERVICE_URL:-http://core-service:8000}
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
ports:
- "8013:8013"
volumes:
- ./backend/services/workspace:/app
- ./backend/shared-lib:/shared-lib
depends_on:
postgres:
condition: service_healthy
core-service:
condition: service_started
command: >
sh -c "python scripts/ensure_db.py &&
alembic upgrade head &&
uvicorn app.main:app --host 0.0.0.0 --port 8013 --reload"
networks:
- superapp_net
hospitality-service: hospitality-service:
build: build:
context: . context: .
@ -556,6 +586,8 @@ services:
HOSPITALITY_SERVICE_URL: ${HOSPITALITY_SERVICE_URL:-http://hospitality-service:8009} HOSPITALITY_SERVICE_URL: ${HOSPITALITY_SERVICE_URL:-http://hospitality-service:8009}
NEXT_PUBLIC_PAYMENT_API_URL: ${NEXT_PUBLIC_PAYMENT_API_URL:-http://localhost:8012} NEXT_PUBLIC_PAYMENT_API_URL: ${NEXT_PUBLIC_PAYMENT_API_URL:-http://localhost:8012}
PAYMENT_SERVICE_URL: ${PAYMENT_SERVICE_URL:-http://payment-service:8012} PAYMENT_SERVICE_URL: ${PAYMENT_SERVICE_URL:-http://payment-service:8012}
NEXT_PUBLIC_WORKSPACE_API_URL: ${NEXT_PUBLIC_WORKSPACE_API_URL:-http://localhost:8013}
WORKSPACE_SERVICE_URL: ${WORKSPACE_SERVICE_URL:-http://workspace-service:8013}
# polling برای hot-reload پایدار روی volume mount ویندوز # polling برای hot-reload پایدار روی volume mount ویندوز
WATCHPACK_POLLING: "true" WATCHPACK_POLLING: "true"
CHOKIDAR_USEPOLLING: "true" CHOKIDAR_USEPOLLING: "true"

View File

@ -5,7 +5,7 @@
# Agents must keep this file aligned with docs/progress.md and docs/roadmap.md. # Agents must keep this file aligned with docs/progress.md and docs/roadmap.md.
schema_version: 1 schema_version: 1
updated: "2026-07-28" updated: "2026-09-12"
# Payment Pay-Reg complete: Enterprise Payment Platform registration (ADR-020). # Payment Pay-Reg complete: Enterprise Payment Platform registration (ADR-020).
# Payment payment-arch complete: licensing, contracts, compatibility (ADR-021). # Payment payment-arch complete: licensing, contracts, compatibility (ADR-021).
# AF-Discovery complete: Enterprise Phase Discovery mandate (ADR-019). # AF-Discovery complete: Enterprise Phase Discovery mandate (ADR-019).
@ -2071,6 +2071,226 @@ phases:
- No backend/migrations/APIs/DB; no Payment 14.6+, Experience FE-11.6+, QR/Links/Booking/Marketplace/Credit - No backend/migrations/APIs/DB; no Payment 14.6+, Experience FE-11.6+, QR/Links/Booking/Marketplace/Credit
notes: Product Integration Wave 1 UX remains; commercial truth moves to docs/YAML. notes: Product Integration Wave 1 UX remains; commercial truth moves to docs/YAML.
# --- Workspace Platform (Phase 15.0–15.11) — Torbat Workspace ---
# Distinct from Core Tenant Workspace, Experience Workspace, and Payment Workspace.
- id: workspace-reg
name: Workspace Platform Registration
area: Workspace
description: Documentation-only registration of independent workspace-service, workspace_db, phases workspace-15.0–workspace-15.11, ADR-024, module/glossary/boundary updates. Commercial Runtime compatible. No business code.
status: complete
dependencies:
- onboarding-4
- ai-framework
required_previous_phase: ai-framework
required_documents:
- docs/workspace-roadmap.md
- docs/architecture/adr/ADR-024.md
- docs/phase-handover/phase-workspace-reg.md
- docs/ai-framework/service-template.md
- docs/ai-framework/phase-template.md
required_services:
- workspace
completion_criteria:
- Service registered in service-manifest with workspace_db and workspace.* permissions
- Phases workspace-15.0–workspace-15.11 registered in phase-manifest
- Module registry, project index, ADR-024 updated
- Service snapshot and handover complete
- Progress and next-steps updated
- No business code, models, APIs, or migrations in this phase
- Quality gates for documentation/architecture/manifest validation passed
- id: workspace-15.0
name: Workspace Foundation
area: Workspace
description: Independent workspace-service scaffold, workspace_db, health/capabilities/metrics, permissions workspace.*, publish-only event shells, tenant isolation, audit/config shells. No project/task/content engines.
status: complete
dependencies:
- workspace-reg
required_previous_phase: workspace-reg
required_documents:
- docs/workspace-roadmap.md
- docs/architecture/adr/ADR-024.md
- docs/ai-framework/service-template.md
required_services:
- workspace
completion_criteria:
- Service scaffold under backend/services/workspace with API → Service → Repository → Model layering
- Alembic initial migration for foundation tables only as scoped
- Health, capabilities, metrics endpoints; permission tree workspace.* documented
- Tenant isolation + architecture + docs tests green
- No projects, tasks, content, files, integrations, or AI
- id: workspace-15.1
name: Workspace Core
area: Workspace
description: Work Hub kernel — spaces/teams, settings, OKR/KPI shells, capability discovery.
status: complete
dependencies:
- workspace-15.0
required_previous_phase: workspace-15.0
required_documents:
- docs/workspace-roadmap.md
- docs/architecture/adr/ADR-024.md
required_services:
- workspace
completion_criteria:
- Work Hub / core APIs + validators + tests green
- OKR/KPI shells only; no project/task engines
- id: workspace-15.2
name: Projects
area: Workspace
description: Workspace Project aggregates, membership, resource-planning shells, project cost-control shells, Gantt-ready structure.
status: complete
dependencies:
- workspace-15.1
required_previous_phase: workspace-15.1
required_documents:
- docs/workspace-roadmap.md
required_services:
- workspace
completion_criteria:
- Project APIs + tests green; no Accounting journals; no CRM opportunities
- id: workspace-15.3
name: Tasks
area: Workspace
description: Workspace Task management and Kanban boards — work items, assignment, status.
status: complete
dependencies:
- workspace-15.2
required_previous_phase: workspace-15.2
required_documents:
- docs/workspace-roadmap.md
required_services:
- workspace
completion_criteria:
- Task/Kanban APIs + tests green; no CRM sales tasks
- id: workspace-15.4
name: Content Studio
area: Workspace
description: Content operations / editorial items — not Experience pages.
status: complete
dependencies:
- workspace-15.3
required_previous_phase: workspace-15.3
required_documents:
- docs/workspace-roadmap.md
- docs/architecture/adr/ADR-016.md
required_services:
- workspace
completion_criteria:
- Content-operations APIs + tests green; no Experience page/theme ownership
- id: workspace-15.5
name: Creative Workflow
area: Workspace
description: Creative review cycles and approval workflow.
status: complete
dependencies:
- workspace-15.4
required_previous_phase: workspace-15.4
required_documents:
- docs/workspace-roadmap.md
required_services:
- workspace
completion_criteria:
- Creative-review and approval APIs + tests green
- id: workspace-15.6
name: Collaboration
area: Workspace
description: Team and client collaboration — notes, comments, mentions (work-management domain only).
status: complete
dependencies:
- workspace-15.5
required_previous_phase: workspace-15.5
required_documents:
- docs/workspace-roadmap.md
required_services:
- workspace
completion_criteria:
- Notes/comments/mentions APIs + tests green; no CRM sales timeline ownership
- id: workspace-15.7
name: Calendar
area: Workspace
description: Operational and editorial calendars over projects, tasks, and content.
status: complete
dependencies:
- workspace-15.6
required_previous_phase: workspace-15.6
required_documents:
- docs/workspace-roadmap.md
required_services:
- workspace
completion_criteria:
- Calendar / editorial-calendar APIs + tests green; no vertical booking engines
- id: workspace-15.8
name: Files & Assets
area: Workspace
description: File management and digital asset catalog — metadata and File Storage refs only.
status: complete
dependencies:
- workspace-15.7
required_previous_phase: workspace-15.7
required_documents:
- docs/workspace-roadmap.md
required_services:
- workspace
completion_criteria:
- File/asset catalog APIs + tests green; no blob storage ownership
- id: workspace-15.9
name: CRM & Accounting Integration
area: Workspace
description: CRM refs, time tracking, project cost intents to Accounting. No foreign ownership.
status: planned
dependencies:
- workspace-15.8
required_previous_phase: workspace-15.8
required_documents:
- docs/workspace-roadmap.md
- docs/architecture/adr/ADR-010.md
required_services:
- workspace
completion_criteria:
- Integration contracts + time-tracking + cost intents + tests green; no JournalEntry; no CRM aggregates
- id: workspace-15.10
name: AI & Analytics
area: Workspace
description: Optional AI assistant hooks via AI Assistant contracts; workspace analytics/reporting shells.
status: planned
dependencies:
- workspace-15.9
required_previous_phase: workspace-15.9
required_documents:
- docs/workspace-roadmap.md
- docs/architecture/ai-architecture.md
required_services:
- workspace
completion_criteria:
- Analytics shells + optional AI contracts + tests green; core PM works with AI off
- id: workspace-15.11
name: Enterprise Validation
area: Workspace
description: Final enterprise-grade validation pass across the Workspace Platform prior to GA.
status: planned
dependencies:
- workspace-15.10
required_previous_phase: workspace-15.10
required_documents:
- docs/workspace-roadmap.md
- docs/architecture/adr/ADR-024.md
required_services:
- workspace
completion_criteria:
- Full quality gate suite green across all Workspace phases
# --- Future Services --- # --- Future Services ---
- id: future-services - id: future-services
name: Future Services Portfolio name: Future Services Portfolio

View File

@ -5,7 +5,7 @@
# Resolve all paths through project-status + this index. Update automatically on service registration and phase completion. # Resolve all paths through project-status + this index. Update automatically on service registration and phase completion.
schema_version: 1 schema_version: 1
updated: "2026-07-28" updated: "2026-09-12"
execution_order: execution_order:
- docs/project-status.yaml - docs/project-status.yaml
@ -35,6 +35,8 @@ shared_references:
license_entitlement_contracts: docs/reference/license-entitlement-contracts.md license_entitlement_contracts: docs/reference/license-entitlement-contracts.md
activation_flow_contracts: docs/reference/activation-flow-contracts.md activation_flow_contracts: docs/reference/activation-flow-contracts.md
adr_023: docs/architecture/adr/ADR-023.md adr_023: docs/architecture/adr/ADR-023.md
adr_024: docs/architecture/adr/ADR-024.md
workspace_roadmap: docs/workspace-roadmap.md
services: services:
- service_name: Core Platform - service_name: Core Platform
@ -633,6 +635,29 @@ services:
health_endpoint: /health health_endpoint: /health
metrics_endpoint: /metrics metrics_endpoint: /metrics
- service_name: Workspace Platform
commercial_product: Torbat Workspace
service_identifier: workspace
current_status: in_progress
current_version: "0.15.0.0"
current_phase: workspace-15.8
next_phase: workspace-15.9
snapshot_file: docs/service-snapshots/workspace.yaml
roadmap_file: docs/workspace-roadmap.md
latest_handover: docs/phase-handover/phase-workspace-15-8.md
phase_manifest_reference: docs/ai-framework/phase-manifest.yaml
service_manifest_reference: docs/ai-framework/service-manifest.yaml
module_registry_reference: docs/module-registry.md
permission_prefix: workspace.*
database_name: workspace_db
api_port: 8013
public_api_prefix: /api/v1
event_prefix: workspace.*
capability_prefix: workspace.*
health_endpoint: /health
metrics_endpoint: /metrics
notes: Vertical slice 15.0–15.8 implemented (ADR-024). Next workspace-15.9. Distinct from Core Tenant Workspace.
- service_name: Frontend - service_name: Frontend
commercial_product: TorbatYar UI commercial_product: TorbatYar UI
service_identifier: frontend service_identifier: frontend

View File

@ -4,7 +4,7 @@
# Keep aligned with docs/module-registry.md and runtime compose ports when wired. # Keep aligned with docs/module-registry.md and runtime compose ports when wired.
schema_version: 1 schema_version: 1
updated: "2026-07-27" updated: "2026-09-12"
services: services:
- id: core-platform - id: core-platform
@ -831,6 +831,82 @@ services:
- analytics - analytics
- fraud_hooks - fraud_hooks
- id: workspace
name: Workspace Platform
description: Independent multi-tenant enterprise work-management platform (Torbat Workspace) — projects, tasks, content operations, editorial calendar, creative workflow, collaboration, files/assets, OKR/KPI, CRM/Accounting integration, AI hooks. Distinct from Core Tenant Workspace, Experience Workspace, and Payment Workspace.
owner: Platform
path: backend/services/workspace
database: workspace_db
api_prefix: /api/v1
permission_prefix: workspace.*
health_endpoint: /health
configuration:
- WORKSPACE_DATABASE_URL
- AUTH_REQUIRED
- tenant resolution via X-Tenant-ID / shared middleware
- entitlement feature keys workspace.*
dependencies:
- core-platform
- shared-lib
optional_dependencies:
- identity-access
- crm
- accounting
- file_storage
- communication
- experience
- ai_assistant
events:
- workspace.work_hub.*
- workspace.project.*
- workspace.task.*
- workspace.content.*
- workspace.creative_review.*
- workspace.approval.*
- workspace.calendar.*
- workspace.file.*
- workspace.asset.*
- workspace.comment.*
- workspace.mention.*
- workspace.okr.*
- workspace.kpi.*
- workspace.analytics.*
public_apis:
- /health
- /capabilities
- /metrics
internal_apis:
- service-to-service CRM / Accounting / File Storage / AI refs (token-gated; not public)
tenant_aware: true
audit_enabled: true
documentation:
- docs/workspace-roadmap.md
- docs/architecture/adr/ADR-024.md
- docs/phase-handover/phase-workspace-reg.md
- docs/phase-handover/phase-workspace-15-8.md
- docs/service-snapshots/workspace.yaml
- docs/module-registry.md
current_version: 0.15.0.0
current_phase: workspace-15.8
current_status: in_progress
status: in_progress
api_port: 8013
commercial_product: Torbat Workspace
deployable_name: workspace-service
notes: First production slice 15.0–15.8 implemented. Commercial Runtime compatible; not seeded. Next workspace-15.9.
future_modules:
- foundation
- core
- projects
- tasks
- content_studio
- creative_workflow
- collaboration
- calendar
- files_assets
- crm_accounting_integration
- ai_analytics
- id: ecommerce - id: ecommerce
name: Ecommerce name: Ecommerce
owner: TBD owner: TBD

View File

@ -0,0 +1,99 @@
# ADR-024: Independent Workspace Platform Service (Torbat Workspace)
| Field | Value |
| --- | --- |
| Status | Accepted |
| Date | 2026-09-12 |
| Deciders | Platform Architecture |
| Supersedes | — |
| Superseded by | — |
## Context
Organizations on TorbatYar need an **enterprise work-management** center: projects, tasks, content operations, editorial calendars, creative review, collaboration, files/assets, OKR/KPI, resource planning, time tracking, and reporting.
Those capabilities must not be absorbed into:
- **Core Platform** — owns the SaaS **Tenant Workspace** (onboarding, membership, entitlement), not work-management aggregates
- **CRM** — owns sales tasks, sales comments/mentions, and sales notes on leads/opportunities
- **Experience (Torbat Pages)** — owns public sites, page resources, themes, and publish surfaces
- **File Storage** — owns binary blobs and signed-URL storage
- **Accounting** — owns journals and the Posting Engine; “Project” there is an analytical dimension
- **Payment** — owns a tenant **Payment Workspace** (enablement/mode), not work items
- **AI Assistant (Torbat AI)** — owns the product AI platform; Workspace may consume it
The word **Workspace** is already used for the operational Tenant after onboarding, for Experience workspace shells, and for Payment workspace shells. A new independent service requires an explicit naming rule.
TorbatYar already isolates shared platforms as independent services (Loyalty [ADR-011](ADR-011.md), Communication [ADR-012](ADR-012.md), Sports Center [ADR-014](ADR-014.md), Delivery [ADR-015](ADR-015.md), Experience [ADR-016](ADR-016.md), Hospitality [ADR-017](ADR-017.md), Payment [ADR-020](ADR-020.md)). Work management follows the same pattern.
Commercial product name: **Torbat Workspace**. Category: **Platform Product**. Purpose: **Enterprise Work Management Platform**.
Commercial packaging is already extensible ([ADR-023](ADR-023.md)): registration + capability declaration + documentation is sufficient. This ADR does **not** implement Commercial Runtime rows, bundles, subscriptions, licenses, or trials.
## Decision
1. Introduce `workspace` as an **independent Workspace Platform service** (deployable name **`workspace-service`**) with sole ownership of `workspace_db` ([ADR-001](ADR-001.md)).
2. Permission prefix: `workspace.*`. Publish-only domain events: `workspace.*`.
3. Row-level multi-tenancy via `tenant_id` ([ADR-003](ADR-003.md)). The product is always scoped to a Core Tenant.
4. **Naming disambiguation (normative):**
| Term | Owner | Meaning |
| --- | --- | --- |
| **Tenant Workspace** / Core Workspace | Core | Operational Tenant after onboarding |
| **Experience Workspace** | Experience | Experience-owned site/workspace shell (`experience.workspace.*`) |
| **Payment Workspace** | Payment | Payment enablement unit (`payment.workspace.*`) |
| **Torbat Workspace** | this service | Enterprise work-management product |
| **Work Hub** | this service | Tenant-scoped root aggregate inside Torbat Workspace (teams, spaces, settings) |
Event namespace `workspace.*` is reserved for **Torbat Workspace** only. Core, Experience, and Payment keep their existing prefixes.
5. Consume Core, Identity, CRM, Accounting, File Storage, Communication, Experience, and AI Assistant **only** via REST API and Events — never shared tables or foreign model imports.
6. Financial journals only through Accounting Posting Engine ([ADR-010](ADR-010.md)). Workspace emits project-cost / time-cost **intents**; Accounting posts externally.
7. Sales CRM aggregates remain in CRM. Workspace tasks, notes, comments, and mentions are **work-management** records and may hold opaque `crm_*_ref` values only.
8. Public pages, themes, and `publish_id` surfaces remain in Experience ([ADR-016](ADR-016.md), [ADR-022](ADR-022.md)). Content Studio is editorial/operations, not a page builder.
9. Binary storage remains in File Storage. Workspace owns folders, asset catalog metadata, and `file_ref` pointers.
10. Product AI remains optional and external ([ai-architecture.md](../ai-architecture.md)). Workspace `ai-assistant` is a capability that calls AI Assistant contracts; core work-management works when AI is off.
11. Messaging / notifications only through Communication ([ADR-012](ADR-012.md)).
12. Implementation phases registered as **`workspace-15.0`–`workspace-15.11`** in [phase-manifest.yaml](../../ai-framework/phase-manifest.yaml); roadmap in [workspace-roadmap.md](../../workspace-roadmap.md). Registration phase: `workspace-reg`.
13. API port **8013** reserved; service path `backend/services/workspace`.
14. UI lives in `frontend/` only ([ADR-002](ADR-002.md)); this service exposes APIs and events.
15. **Commercial compatibility (architecture only):** Torbat Workspace is a Platform Product compatible with Commercial Runtime, Business Bundles, Capability Registry, Subscription, License, and Trial contracts ([ADR-023](ADR-023.md)). No commercial implementation, catalog seed, or runtime rows are authorized by this ADR.
16. Planned L1 entitlement key: `workspace.module.enabled`. Service-local L2 capability packs / L3 toggles (when implemented) live in `workspace_db` and must not recompute Core plan entitlements.
## Consequences
### Positive
- One reusable work-management platform for all tenant organizations
- Clear boundary with Core tenancy, CRM sales work, Experience publishing, File Storage binaries, and Accounting books
- Commercial Runtime can package Torbat Workspace later as registry rows only (zero Core architecture change)
- Event/permission prefix `workspace.*` is unambiguous once Core/Experience/Payment keep their existing namespaces
### Negative
- Additional deployable service and database
- The everyday word “workspace” now has four documented meanings and must be qualified in docs and APIs
- Eventual consistency between Workspace cost/time intents and Accounting journals, and between Workspace collaboration refs and CRM
### Neutral
- Registration (this ADR + manifests + roadmap) precedes business code; `workspace-15.0` implements the scaffold
- CRM collaboration (sales comments/tasks) and Workspace collaboration remain sibling domains
- No change to Commercial Runtime engines, seeds, or frontend catalogs
## Alternatives Considered
1. Expand Core Tenant Workspace into work management — rejected (violates business-module boundaries; couples tenancy with projects/tasks/content).
2. Expand CRM tasks/comments into a full PM suite — rejected (CRM is Sales CRM; work management is a different product).
3. Expand Experience into content operations / creative studio — rejected (Experience owns published pages, not editorial/project operations).
4. Embed files/assets as the File Storage product — rejected (storage owns blobs; Workspace owns work-management asset catalogs).
5. Defer as an unnamed future service — rejected (product, phases, commercial compatibility, and naming collisions must be registered first).
## Related Documents
- [Workspace Roadmap](../../workspace-roadmap.md)
- [Module Boundaries](../module-boundaries.md)
- [Module Registry — workspace](../../module-registry.md#workspace)
- [Commercial Platform Architecture](../commercial-platform-architecture.md)
- [Phase Manifest](../../ai-framework/phase-manifest.yaml)
- [Service Manifest](../../ai-framework/service-manifest.yaml)
- [ADR-001](ADR-001.md) · [ADR-002](ADR-002.md) · [ADR-003](ADR-003.md) · [ADR-006](ADR-006.md) · [ADR-010](ADR-010.md) · [ADR-012](ADR-012.md) · [ADR-016](ADR-016.md) · [ADR-022](ADR-022.md) · [ADR-023](ADR-023.md)

View File

@ -27,6 +27,7 @@ One decision per file. Never overwrite an accepted ADR — supersede it.
| [ADR-021](ADR-021.md) | Payment Platform Licensing, Routing & Integration Contracts | Accepted | | [ADR-021](ADR-021.md) | Payment Platform Licensing, Routing & Integration Contracts | Accepted |
| [ADR-022](ADR-022.md) | Platform Published Resource Architecture (incl. Actions, Access, Embed) | Accepted | | [ADR-022](ADR-022.md) | Platform Published Resource Architecture (incl. Actions, Access, Embed) | Accepted |
| [ADR-023](ADR-023.md) | Commercial Platform Foundation (Business Bundles, Pricing, Subscriptions) | Accepted | | [ADR-023](ADR-023.md) | Commercial Platform Foundation (Business Bundles, Pricing, Subscriptions) | Accepted |
| [ADR-024](ADR-024.md) | Independent Workspace Platform Service (Torbat Workspace) | Accepted |
Template: [adr-template.md](../../templates/adr-template.md) Template: [adr-template.md](../../templates/adr-template.md)

View File

@ -16,6 +16,8 @@ AI capabilities are an **independent module family**. Business modules may consu
`ai_assistant` — chat assistance, knowledge bases, handoff to humans. Database: `ai_assistant_db`. Feature prefix: `ai_assistant.*`. `ai_assistant` — chat assistance, knowledge bases, handoff to humans. Database: `ai_assistant_db`. Feature prefix: `ai_assistant.*`.
**Torbat Workspace** may declare capability `workspace.ai-assistant` and consume this module via contracts only ([ADR-024](adr/ADR-024.md)). Workspace work-management must remain functional when AI is disabled. Workspace must not own `ai_assistant_db` or model-provider credentials.
## Provider Independence ## Provider Independence
Model providers are registered like any other provider ([provider-registry.md](../provider-registry.md)). Swapping vendors must not rewrite CRM/Accounting domain logic. Model providers are registered like any other provider ([provider-registry.md](../provider-registry.md)). Swapping vendors must not rewrite CRM/Accounting domain logic.

View File

@ -31,7 +31,7 @@ Build a **multi-tenant**, **modular**, **API-first**, **microservice-ready** Sup
│ │
├── Outbox/Inbox events ├── Outbox/Inbox events
├── Entitlement checks ├── Entitlement checks
└── Future business services (Accounting, CRM, …) └── Future business services (Accounting, CRM, Workspace, …)
``` ```
## 4. Canonical Architecture Documents ## 4. Canonical Architecture Documents

View File

@ -33,6 +33,8 @@ Cached in Redis; custom tenant overrides beat plan defaults.
**Payment module (Torbat Pay):** Core L1 key `payment.module.enabled`. Payment service adds L2 bundles and L3 feature toggles in `payment_db` — see [ADR-021](adr/ADR-021.md) and [payment-contracts.md](../reference/payment-contracts.md). Payment must not compute Core plan entitlements locally. **Payment module (Torbat Pay):** Core L1 key `payment.module.enabled`. Payment service adds L2 bundles and L3 feature toggles in `payment_db` — see [ADR-021](adr/ADR-021.md) and [payment-contracts.md](../reference/payment-contracts.md). Payment must not compute Core plan entitlements locally.
**Workspace module (Torbat Workspace):** Planned Core L1 key `workspace.module.enabled`. Permission tree `workspace.*` is owned by the Workspace Platform (`workspace_db`) — see [ADR-024](adr/ADR-024.md) and [workspace-roadmap.md](../workspace-roadmap.md). This is **not** Core Tenant Workspace membership. Workspace must not compute Core plan entitlements locally.
## API Guards (Core) ## API Guards (Core)
Configurable `AUTH_REQUIRED`. Dependencies such as `require_authenticated`, `require_platform_admin`, `require_tenant_admin`, `MembershipService.ensure_role`. Configurable `AUTH_REQUIRED`. Dependencies such as `require_authenticated`, `require_platform_admin`, `require_tenant_admin`, `MembershipService.ensure_role`.

View File

@ -110,6 +110,7 @@ Subscription / License / Entitlement → ActivationPipeline (events)
| ADR-020 / ADR-021 | Payment L1 gated by Core; L2/L3 stay in Payment; billing shells reserved | | ADR-020 / ADR-021 | Payment L1 gated by Core; L2/L3 stay in Payment; billing shells reserved |
| ADR-022 | Published Resources unchanged; assets/extensions may support presentation by ref | | ADR-022 | Published Resources unchanged; assets/extensions may support presentation by ref |
| ADR-023 | Base + **v1.1** + **v1.2** amendments; architecture complete for commercial foundation layer | | ADR-023 | Base + **v1.1** + **v1.2** amendments; architecture complete for commercial foundation layer |
| ADR-024 | **Torbat Workspace** is a registered Platform Product — Commercial Runtime / bundle / capability / subscription / license / trial compatible. No commercial implementation or Core architecture change. |
--- ---

View File

@ -18,6 +18,10 @@
| Delivery (planned) | `delivery_db` | | Delivery (planned) | `delivery_db` |
| Experience Platform (registered) | `experience_db` | | Experience Platform (registered) | `experience_db` |
| Hospitality | `hospitality_db` | | Hospitality | `hospitality_db` |
| Healthcare (registered) | `healthcare_db` |
| Beauty Business (registered) | `beauty_business_db` |
| Payment (Torbat Pay) | `payment_db` |
| Workspace Platform (Torbat Workspace, registered) | `workspace_db` |
| Ecommerce (future) | `ecommerce_db` | | Ecommerce (future) | `ecommerce_db` |
| Website Builder (historical scaffold; prefer `experience_db`) | `website_builder_db` | | Website Builder (historical scaffold; prefer `experience_db`) | `website_builder_db` |
| Live Chat (future) | `live_chat_db` | | Live Chat (future) | `live_chat_db` |

View File

@ -27,6 +27,8 @@ All events use `shared.events.EventEnvelope`:
`{aggregate}.{past_tense_verb}` — e.g. `tenant.created`, `subscription.updated`, `user.registered`. `{aggregate}.{past_tense_verb}` — e.g. `tenant.created`, `subscription.updated`, `user.registered`.
**Torbat Workspace** publishes `workspace.*` only ([ADR-024](adr/ADR-024.md)). Do not emit work-management events as `experience.workspace.*` or `payment.workspace.*`.
## Catalog ## Catalog
Canonical list → [event-catalog.md](../reference/event-catalog.md) Canonical list → [event-catalog.md](../reference/event-catalog.md)

View File

@ -6,7 +6,7 @@
**Owns:** tenants, domains, plans, features, subscriptions, entitlement checks, service/module registry, internal service tokens, outbox/inbox (core), audit log, core users, tenant memberships (operational), onboarding, public tenant-site resolution. **Owns:** tenants, domains, plans, features, subscriptions, entitlement checks, service/module registry, internal service tokens, outbox/inbox (core), audit log, core users, tenant memberships (operational), onboarding, public tenant-site resolution.
**Must not own:** business journals, CRM entities, restaurant menus, file blobs, SMS campaigns. **Must not own:** business journals, CRM entities, restaurant menus, file blobs, SMS campaigns, **Torbat Workspace work-management aggregates** (projects, tasks, content studio, Work Hubs).
## Identity & Access ## Identity & Access
@ -28,7 +28,7 @@ Each module (Accounting, CRM, Restaurant, Ecommerce, …) owns its database and
**Owns:** Lead, Contact, Organization (business account), Opportunity, Pipeline, PipelineStage, SalesActivity, Task, Meeting, CallLog, Sales Timeline, Comment/Mention, Bookmark, Sales Team, Playbook, Forecast, Goal, Target, Win/Loss, Quote (sales), CRM publish events. **Owns:** Lead, Contact, Organization (business account), Opportunity, Pipeline, PipelineStage, SalesActivity, Task, Meeting, CallLog, Sales Timeline, Comment/Mention, Bookmark, Sales Team, Playbook, Forecast, Goal, Target, Win/Loss, Quote (sales), CRM publish events.
**Must not own:** Automation/workflows, Customer360, Marketing, Notification delivery, Messaging, Communication, Helpdesk, Analytics, AI, Identity, Accounting, Inventory, Restaurant, Marketplace, Files, Search, Loyalty, Sports Center, Delivery, Experience. **Must not own:** Automation/workflows, Customer360, Marketing, Notification delivery, Messaging, Communication, Helpdesk, Analytics, AI, Identity, Accounting, Inventory, Restaurant, Marketplace, Files, Search, Loyalty, Sports Center, Delivery, Experience, **Torbat Workspace projects/tasks/notes/comments/mentions** (sales collaboration stays in CRM; work-management collaboration is Workspace).
### Loyalty (Enterprise Loyalty Platform) ### Loyalty (Enterprise Loyalty Platform)
@ -58,7 +58,7 @@ Each module (Accounting, CRM, Restaurant, Ecommerce, …) owns its database and
**Owns:** Sites, page resources and page types, versioned components, themes, layouts, templates, locales/RTL-LTR shells, media references (not binaries), forms/surveys/appointment page shells (**standalone-publishable**; optional Site binding only — [ADR-022](adr/ADR-022.md)), publishing workflows, Publish Target Registry entries for Experience-authored surfaces (`publish_id`), **presentation / rendering / embed metadata**, **Published Action bindings** (refs to open Action Registry — not action business execution), **Public Access Policy metadata** (not authentication), custom domain binding refs, SEO/PWA shells, capability bundles and feature toggles, widgets, consumer connector contracts, experience analytics/AI hooks shells, experience publish events. **Owns:** Sites, page resources and page types, versioned components, themes, layouts, templates, locales/RTL-LTR shells, media references (not binaries), forms/surveys/appointment page shells (**standalone-publishable**; optional Site binding only — [ADR-022](adr/ADR-022.md)), publishing workflows, Publish Target Registry entries for Experience-authored surfaces (`publish_id`), **presentation / rendering / embed metadata**, **Published Action bindings** (refs to open Action Registry — not action business execution), **Public Access Policy metadata** (not authentication), custom domain binding refs, SEO/PWA shells, capability bundles and feature toggles, widgets, consumer connector contracts, experience analytics/AI hooks shells, experience publish events.
**Must not own:** Accounting journals/Posting Engine, Sales CRM aggregates, Loyalty ledger/campaigns / membership evaluation, Communication providers / OTP delivery, Core tenant membership / white-label brand source of truth / **authentication**, File Storage binaries, Hospitality menu/item or Marketplace product catalogs as source of truth, Delivery logistics, product AI platform, Automation engine, Page Builder / public site UI (frontend), Short Link / QR issuance engines as source of truth (reserved products consume `publish_id`), Payment capture/ledger, **Published Action business execution** owned by other services. **Must not own:** Accounting journals/Posting Engine, Sales CRM aggregates, Loyalty ledger/campaigns / membership evaluation, Communication providers / OTP delivery, Core tenant membership / white-label brand source of truth / **authentication**, File Storage binaries, Hospitality menu/item or Marketplace product catalogs as source of truth, Delivery logistics, product AI platform, Automation engine, Page Builder / public site UI (frontend), Short Link / QR issuance engines as source of truth (reserved products consume `publish_id`), Payment capture/ledger, **Published Action business execution** owned by other services, **Torbat Workspace Content Studio / creative workflow / Work Hubs** (editorial operations are Workspace; pages remain Experience).
### Hospitality Platform ### Hospitality Platform
@ -70,7 +70,13 @@ Each module (Accounting, CRM, Restaurant, Ecommerce, …) owns its database and
**Owns:** Payment workspaces, bundle definitions, tenant payment bundles, payment feature toggles, PSP provider catalog, BYO-PSP connections, merchant accounts (facilitator), provider assignments, routing policies, payment requests, callback logs, payment transaction ledger, refunds, splits, settlement batches, reconciliation runs, accounting posting intents, vertical checkout connector registrations, checkout sessions, payment analytics/fraud shells, payment publish events, PSP adapters. **Owns:** Payment workspaces, bundle definitions, tenant payment bundles, payment feature toggles, PSP provider catalog, BYO-PSP connections, merchant accounts (facilitator), provider assignments, routing policies, payment requests, callback logs, payment transaction ledger, refunds, splits, settlement batches, reconciliation runs, accounting posting intents, vertical checkout connector registrations, checkout sessions, payment analytics/fraud shells, payment publish events, PSP adapters.
**Must not own:** Accounting journals/Posting Engine, Sales CRM aggregates, Loyalty wallet/point ledger, Communication providers, Core plans/subscriptions/entitlement computation, vertical order/cart/invoice/menu aggregates, Identity user admin, international PSP implementations until explicitly phased. **Must not own:** Accounting journals/Posting Engine, Sales CRM aggregates, Loyalty wallet/point ledger, Communication providers, Core plans/subscriptions/entitlement computation, vertical order/cart/invoice/menu aggregates, Identity user admin, international PSP implementations until explicitly phased, **Torbat Workspace work-management** (Payment Workspace ≠ Torbat Workspace).
### Workspace Platform (Torbat Workspace)
**Owns:** Work Hubs, workspace roles/permissions, projects, tasks, kanban/gantt structure, content-operations items, editorial/operational calendars, creative-review and approval workflow, notes/comments/mentions (work-management), file/asset catalog metadata and `file_ref` pointers, OKR/KPI shells, resource-planning and time-tracking shells, project cost-control **intents**, CRM/Accounting integration refs, workspace analytics/AI-hook shells, `workspace.*` publish events, capability discovery.
**Must not own:** Core Tenant Workspace / membership / entitlement computation, Experience Workspace / sites / pages / themes / `publish_id` issuance, Payment Workspace / PSP / ledgers, CRM sales tasks/timeline/comments, Accounting journals / Posting Engine / analytical Project dimension as SoT, File Storage blobs, Communication providers, product AI platform (`ai_assistant`), Automation engine, frontend UI.
## Shared Library (`backend/shared-lib`) ## Shared Library (`backend/shared-lib`)
@ -91,8 +97,9 @@ Each module (Accounting, CRM, Restaurant, Ecommerce, …) owns its database and
- [Service Architecture](service-architecture.md) - [Service Architecture](service-architecture.md)
- [Published Resource Architecture](published-resource-architecture.md) - [Published Resource Architecture](published-resource-architecture.md)
- [ADR-001](adr/ADR-001.md) · [ADR-002](adr/ADR-002.md) · [ADR-007](adr/ADR-007.md) · [ADR-014](adr/ADR-014.md) · [ADR-015](adr/ADR-015.md) · [ADR-016](adr/ADR-016.md) · [ADR-017](adr/ADR-017.md) · [ADR-020](adr/ADR-020.md) · [ADR-021](adr/ADR-021.md) · [ADR-022](adr/ADR-022.md) - [ADR-001](adr/ADR-001.md) · [ADR-002](adr/ADR-002.md) · [ADR-007](adr/ADR-007.md) · [ADR-014](adr/ADR-014.md) · [ADR-015](adr/ADR-015.md) · [ADR-016](adr/ADR-016.md) · [ADR-017](adr/ADR-017.md) · [ADR-020](adr/ADR-020.md) · [ADR-021](adr/ADR-021.md) · [ADR-022](adr/ADR-022.md) · [ADR-024](adr/ADR-024.md)
- [Payment Roadmap](../payment-roadmap.md) - [Payment Roadmap](../payment-roadmap.md)
- [Workspace Roadmap](../workspace-roadmap.md)
- [Payment Contracts](../reference/payment-contracts.md) - [Payment Contracts](../reference/payment-contracts.md)
- [Module Registry](../module-registry.md) - [Module Registry](../module-registry.md)
- [Sports Center Roadmap](../sports-center-roadmap.md) - [Sports Center Roadmap](../sports-center-roadmap.md)

View File

@ -7,13 +7,16 @@ Canonical definitions for TorbatYar. Prefer these terms in docs and code comment
| Term | Definition | | Term | Definition |
| --- | --- | | --- | --- |
| **Tenant** | A customer workspace/organization on the platform. Owns domains, branding, memberships, and subscriptions. | | **Tenant** | A customer workspace/organization on the platform. Owns domains, branding, memberships, and subscriptions. |
| **Workspace** | Product synonym for an operational Tenant after onboarding. | | **Workspace** | **Ambiguous — qualify.** Prefer **Tenant Workspace** (Core), **Torbat Workspace** (work-management product), **Experience Workspace**, or **Payment Workspace**. |
| **Tenant Workspace** | Operational Tenant after onboarding (Core). Historical synonym: “Workspace” in onboarding docs. |
| **Torbat Workspace** | Commercial Platform Product for enterprise work management (`workspace` / `workspace-service`, `workspace_db`) — [ADR-024](architecture/adr/ADR-024.md). Not the Tenant. |
| **Work Hub** | Tenant-scoped root aggregate inside Torbat Workspace (teams, spaces, settings). Not a Core Tenant and not Experience/Payment workspace shells. |
| **Organization** | Business entity represented by a Tenant (not a separate table today). | | **Organization** | Business entity represented by a Tenant (not a separate table today). |
| **Member** | **Platform:** a user with a membership row linking them to a Tenant. **Sports Center:** see Member (Sports) — do not conflate. | | **Member** | **Platform:** a user with a membership row linking them to a Tenant. **Sports Center:** see Member (Sports) — do not conflate. |
| **Platform Member** | Preferred unambiguous term for a user linked to a Tenant via `tenant_memberships`. | | **Platform Member** | Preferred unambiguous term for a user linked to a Tenant via `tenant_memberships`. |
| **Tenant Membership (Core)** | `core_platform_db.tenant_memberships` — source of truth for workspace roles/ownership. | | **Tenant Membership (Core)** | `core_platform_db.tenant_memberships` — source of truth for workspace roles/ownership. |
| **Tenant Membership (Identity)** | `identity_access_db.tenant_memberships` — SSO listing membership; not operational authz. | | **Tenant Membership (Identity)** | `identity_access_db.tenant_memberships` — SSO listing membership; not operational authz. |
| **Workspace Activation** | Completing onboarding so tenant moves to `active` with `onboarding_completed=true`. | | **Workspace Activation** | Completing onboarding so the **Tenant Workspace** moves to `active` with `onboarding_completed=true` — Core onboarding, not Torbat Workspace. |
| **Current Tenant** | `users.current_tenant_id` — workspace selected for the session context. | | **Current Tenant** | `users.current_tenant_id` — workspace selected for the session context. |
| **Domain** | Hostname mapped to a tenant (subdomain or custom). | | **Domain** | Hostname mapped to a tenant (subdomain or custom). |
| **Primary Domain** | Domain marked `is_primary` for the tenant. | | **Primary Domain** | Domain marked `is_primary` for the tenant. |
@ -63,7 +66,7 @@ Canonical definitions for TorbatYar. Prefer these terms in docs and code comment
| **Posting** | Act of writing validated lines to the ledger. | | **Posting** | Act of writing validated lines to the ledger. |
| **Posting Engine** | Sole authorized component to create journal entries. | | **Posting Engine** | Sole authorized component to create journal entries. |
| **Cost Center** | Analytical dimension for costs. | | **Cost Center** | Analytical dimension for costs. |
| **Project** | Analytical dimension for project accounting. | | **Project (Accounting)** | Analytical dimension for project accounting — not a Torbat Workspace project. |
## CRM / Commerce / Ops ## CRM / Commerce / Ops
@ -76,7 +79,7 @@ Canonical definitions for TorbatYar. Prefer these terms in docs and code comment
| **Pipeline / Stage** | Configurable sales process steps. | | **Pipeline / Stage** | Configurable sales process steps. |
| **Sales Playbook** | Versioned sales checklist assigned to pipeline/opportunity. | | **Sales Playbook** | Versioned sales checklist assigned to pipeline/opportunity. |
| **Sales Forecast** | Rule-based pipeline/weighted/committed forecast (no ML). | | **Sales Forecast** | Rule-based pipeline/weighted/committed forecast (no ML). |
| **Sales Activity** | Call, meeting, task, email, follow-up, demo, or custom activity. | | **Sales Activity** | Call, meeting, task, email, follow-up, demo, or custom activity — CRM-owned; not a Torbat Workspace Task. |
| **Sales Timeline** | Immutable CRM collaboration event stream. | | **Sales Timeline** | Immutable CRM collaboration event stream. |
| **Quote (Sales)** | CRM sales quote — not an accounting invoice. | | **Quote (Sales)** | CRM sales quote — not an accounting invoice. |
| **Loyalty Program** | Tenant-scoped loyalty program configuration (Loyalty-owned). | | **Loyalty Program** | Tenant-scoped loyalty program configuration (Loyalty-owned). |
@ -165,6 +168,7 @@ Canonical definitions for TorbatYar. Prefer these terms in docs and code comment
| **Public Access Policy** | Composable access strategies for a Published Resource; Experience stores metadata only — Identity/Payment/Loyalty/Communication own challenges ([public-access-contract.md](reference/public-access-contract.md)). | | **Public Access Policy** | Composable access strategies for a Published Resource; Experience stores metadata only — Identity/Payment/Loyalty/Communication own challenges ([public-access-contract.md](reference/public-access-contract.md)). |
| **Universal Embed** | Contract for embedding Published Resources (iframe, SDK, widget, …) with security and analytics rules ([embed-contract.md](reference/embed-contract.md)). | | **Universal Embed** | Contract for embedding Published Resources (iframe, SDK, widget, …) with security and analytics rules ([embed-contract.md](reference/embed-contract.md)). |
| **Website Builder (historical)** | Scaffolded stub service — prefer Experience Platform for new page/site work ([ADR-016](architecture/adr/ADR-016.md)). | | **Website Builder (historical)** | Scaffolded stub service — prefer Experience Platform for new page/site work ([ADR-016](architecture/adr/ADR-016.md)). |
| **Experience Workspace** | Experience-owned workspace shell (`experience.workspace.*`) — not Torbat Workspace. |
## Hospitality Platform ## Hospitality Platform
@ -183,6 +187,20 @@ Canonical definitions for TorbatYar. Prefer these terms in docs and code comment
| **Dining Table** | Seating/table shell — QR/ordering engines come in later phases. | | **Dining Table** | Seating/table shell — QR/ordering engines come in later phases. |
| **Restaurant (historical)** | Scaffold alias — prefer Hospitality Platform ([ADR-017](architecture/adr/ADR-017.md)). | | **Restaurant (historical)** | Scaffold alias — prefer Hospitality Platform ([ADR-017](architecture/adr/ADR-017.md)). |
## Workspace Platform
| Term | Definition |
| --- | --- |
| **Workspace Platform** | Independent enterprise work-management service (`workspace`, deployable `workspace-service`) owning projects, tasks, content operations, calendars, creative review, collaboration, files/asset catalog refs, OKR/KPI shells, and analytics/AI hooks — commercial product **Torbat Workspace** ([ADR-024](architecture/adr/ADR-024.md)). |
| **Torbat Workspace** | Commercial Platform Product name for the Workspace Platform. Category: Platform Product. Purpose: Enterprise Work Management Platform. |
| **Work Hub** | Root operational unit of Torbat Workspace inside a Tenant. |
| **Workspace Project** | Work-management project owned by Workspace — not Accounting **Project (Accounting)** and not a CRM Opportunity. |
| **Workspace Task** | Work item on a Workspace Project — not a CRM sales Task. |
| **Content Studio** | Editorial / content-operations pipeline in Workspace — not Experience page building. |
| **Creative Workflow** | Review and approval cycle for creative packages — not Experience publish workflow. |
| **Workspace File / Asset** | Catalog metadata + `file_ref` — binaries owned by File Storage. |
| **Payment Workspace** | Payment enablement unit (`payment.workspace.*`) — not Torbat Workspace. |
## Technical ## Technical
| Term | Definition | | Term | Definition |
@ -221,3 +239,5 @@ Internal docs: **فاز ۳** = OTP + Tenant Management; **فاز ۴** = Onboardi
- [Delivery Roadmap](delivery-roadmap.md) - [Delivery Roadmap](delivery-roadmap.md)
- [Experience Roadmap](experience-roadmap.md) - [Experience Roadmap](experience-roadmap.md)
- [Hospitality Roadmap](hospitality-roadmap.md) - [Hospitality Roadmap](hospitality-roadmap.md)
- [Workspace Roadmap](workspace-roadmap.md)
- [ADR-024](architecture/adr/ADR-024.md)

View File

@ -746,7 +746,7 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned**
| Future Plans | Phases payment-14.0–payment-14.10 per [payment-roadmap.md](payment-roadmap.md) | | Future Plans | Phases payment-14.0–payment-14.10 per [payment-roadmap.md](payment-roadmap.md) |
| Commercial Product | Torbat Pay | | Commercial Product | Torbat Pay |
**Phase ID disambiguation:** Payment track uses manifest IDs `payment-14.x`. Beauty Business uses `beauty-business-14.x` — separate services, separate databases. **Phase ID disambiguation:** Payment track uses manifest IDs `payment-14.x`. Beauty Business uses `beauty-business-14.x` — separate services, separate databases. Workspace uses `workspace-15.x`.
### Modules (owned by payment — planned) ### Modules (owned by payment — planned)
@ -795,6 +795,96 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned**
--- ---
## workspace
| Field | Value |
| --- | --- |
| Name | Workspace Platform (Torbat Workspace) |
| Description | Independent enterprise work-management platform: projects, tasks, content operations, editorial calendar, creative workflow, collaboration, files/assets, OKR/KPI, resource planning, time tracking, CRM/Accounting integration, AI hooks, reporting |
| Owner | Platform |
| Status | **In progress** (15.0–15.8 vertical slice implemented; next `workspace-15.9`) |
| Category | Platform Product |
| Purpose | Enterprise Work Management Platform |
| Dependencies | Core entitlement; Identity / CRM / Accounting / File Storage / Communication / Experience / AI Assistant via API/Events when phases require them |
| Internal Dependencies | Modules owned by this service only |
| External Dependencies | Storage refs for files/assets; CRM/Accounting/AI via client contracts |
| Database Ownership | Sole owner (planned) |
| Database | `workspace_db` |
| API Prefix | `/api/v1` (service on port **8013** reserved); `/health`, `/capabilities`, `/metrics` |
| Permission Prefix | `workspace.*` |
| Events | `workspace.*` (Work Hub, project, task, content, creative-review, approval, calendar, file/asset, comment, mention, okr, kpi, analytics — planned) |
| Event Producers | Workspace (planned) |
| Event Consumers | CRM, Accounting, Communication, Experience, Analytics (future) |
| Provider Dependencies | Contracts only — Identity, CRM, Accounting, File Storage, Communication, Experience, AI |
| AI Dependencies | Optional via `AIProvider` contract only (Phase 15.10) |
| Documentation | [workspace-roadmap.md](workspace-roadmap.md), [ADR-024](architecture/adr/ADR-024.md), [phase-handover/phase-workspace-reg.md](phase-handover/phase-workspace-reg.md), [service-snapshots/workspace.yaml](service-snapshots/workspace.yaml) |
| Current Phase | `workspace-15.8` complete for first slice; next `workspace-15.9` |
| Version | `0.15.0.0` |
| Version Compatibility | Requires Core entitlement |
| Migration Version | `0001_initial` |
| Tenant Aware | Yes (required) |
| Permission Tree | `workspace.*` |
| Future Plans | Phases 15.0–15.11 per [workspace-roadmap.md](workspace-roadmap.md) |
| Commercial Product | Torbat Workspace |
| Commercial Runtime | Compatible (not seeded) |
| Bundle / Capability / Subscription / License / Trial | Compatible (architecture only; no commercial implementation) |
| Deployable Name | `workspace-service` |
| Health Endpoint | `/health` |
| Public Contracts | `/health`, `/capabilities`, `/metrics`, `/api/v1/hubs|teams|projects|tasks|content|approvals|assets|comments|activity` |
| Internal Contracts | Token-gated CRM / Accounting / File Storage / AI / Communication refs |
| Future Extensions | Template marketplace, external PM connectors, mobile, portfolio/program management, Automation consumer hooks |
**Independence from Core / CRM / Experience / Payment:** Torbat Workspace is not the Core Tenant Workspace, not CRM sales work, not Experience page workspaces, and not Payment workspaces. Shared platforms only via API/events ([ADR-024](architecture/adr/ADR-024.md)).
### Modules (owned by workspace — planned)
| Module Key | Name | Status | Phase |
| --- | --- | --- | --- |
| `workspace.work_hubs` | Work Hubs | Implemented | 15.0 / 15.1 |
| `workspace.roles` | Workspace Roles | Implemented (team roles) | 15.0 |
| `workspace.permissions_catalog` | Permissions Catalog | Implemented | 15.0 |
| `workspace.configuration` | Configuration | Partial | 15.0 |
| `workspace.settings` | Settings | Partial | 15.0 |
| `workspace.external_providers` | External Providers | Contracts only | 15.0 |
| `workspace.audit` | Audit | Implemented | 15.0 |
| `workspace.okr` | OKR | Planned | 15.1 |
| `workspace.kpi` | KPI | Dashboard metrics only | 15.1 |
| `workspace.projects` | Projects | Implemented | 15.2 |
| `workspace.gantt` | Gantt Structure | Planned | 15.2 |
| `workspace.resource_planning` | Resource Planning | Planned | 15.2 |
| `workspace.tasks` | Tasks | Implemented | 15.3 |
| `workspace.kanban` | Kanban | Implemented | 15.3 |
| `workspace.content` | Content Studio | Implemented | 15.4 |
| `workspace.creative_review` | Creative Review | Implemented (workflow) | 15.5 |
| `workspace.approvals` | Approval Workflow | Implemented | 15.5 |
| `workspace.notes` | Notes | Planned | 15.6 |
| `workspace.comments` | Comments | Implemented | 15.6 |
| `workspace.mentions` | Mentions | Implemented | 15.6 |
| `workspace.calendar` | Calendar | Implemented | 15.7 |
| `workspace.editorial_calendar` | Editorial Calendar | Implemented | 15.7 |
| `workspace.files` | File Management | References only | 15.8 |
| `workspace.assets` | Digital Assets | Implemented (refs) | 15.8 |
| `workspace.time_tracking` | Time Tracking | Planned | 15.9 |
| `workspace.crm_integration` | CRM Integration | Planned | 15.9 |
| `workspace.accounting_integration` | Accounting Integration | Planned | 15.9 |
| `workspace.ai_assistant` | AI Assistant Hooks | Planned | 15.10 |
| `workspace.analytics` | Analytics & Reporting | Planned | 15.10 |
| Module | Responsibilities | Non-responsibilities |
| --- | --- | --- |
| Work Hub | Product root unit, teams/spaces, settings | Core Tenant Workspace admin |
| Projects / Gantt / Resources | Work-management projects | Accounting analytical Project; CRM Opportunity |
| Tasks / Kanban | Work items and boards | CRM sales tasks |
| Content Studio | Editorial/content operations | Experience sites/pages/themes |
| Creative / Approvals | Review cycles | Experience publish; Identity auth |
| Collaboration | Notes, comments, mentions | CRM sales timeline; Communication delivery |
| Calendar | Operational + editorial calendars | Vertical booking engines |
| Files / Assets | Catalog + `file_ref` | File Storage blobs |
| CRM / Accounting integration | Opaque refs + cost/time intents | CRM aggregates; JournalEntry |
| AI / Analytics | Optional hooks + report shells | `ai_assistant` product; BI warehouse |
---
## ecommerce ## ecommerce
| Field | Value | | Field | Value |
@ -1091,7 +1181,7 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned**
| Permission Prefix | `file_storage.*` | | Permission Prefix | `file_storage.*` |
| Events | TBD | | Events | TBD |
| Event Producers | File Storage | | Event Producers | File Storage |
| Event Consumers | website_builder, ecommerce | | Event Consumers | website_builder, ecommerce, workspace (planned file_ref consumers) |
| Provider Dependencies | S3-compatible | | Provider Dependencies | S3-compatible |
| AI Dependencies | None | | AI Dependencies | None |
| Documentation | service README | | Documentation | service README |
@ -1155,5 +1245,7 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned**
- [Healthcare Roadmap](healthcare-roadmap.md) - [Healthcare Roadmap](healthcare-roadmap.md)
- [Beauty Business Roadmap](beauty-business-roadmap.md) - [Beauty Business Roadmap](beauty-business-roadmap.md)
- [Payment Roadmap](payment-roadmap.md) - [Payment Roadmap](payment-roadmap.md)
- [Workspace Roadmap](workspace-roadmap.md)
- [ADR-016](architecture/adr/ADR-016.md) - [ADR-016](architecture/adr/ADR-016.md)
- [ADR-020](architecture/adr/ADR-020.md) - [ADR-020](architecture/adr/ADR-020.md)
- [ADR-024](architecture/adr/ADR-024.md)

View File

@ -2,23 +2,26 @@
> Immediate next milestone only. History → [progress.md](progress.md). Future map → [roadmap.md](roadmap.md). > Immediate next milestone only. History → [progress.md](progress.md). Future map → [roadmap.md](roadmap.md).
## Hospitality Frontend Production Validation — COMPLETE · CERTIFIED (STOP) ## Workspace vertical slice — COMPLETE (STOP)
**Phase:** `hospitality-frontend-production-validation` **Phase:** `workspace-15.8` (first production slice `15.0`–`15.8`)
**Score:** **98** · **63 routes** audited · Backend **unchanged** at 12.8 **Product:** **Torbat Workspace** (Platform Product · Enterprise Work Management Platform)
Reports: [validation](hospitality-production-validation.md) · [certification](hospitality-final-certification.md) · [route audit](hospitality-route-audit.md) **ADR:** [ADR-024](architecture/adr/ADR-024.md) · Roadmap: [workspace-roadmap.md](workspace-roadmap.md)
**Handover:** [phase-workspace-15-8.md](phase-handover/phase-workspace-15-8.md)
Hospitality FE is production-certified. No further Hospitality frontend hardening from this track. Work Hub, teams, projects, tasks/Kanban, Content Studio, workflow, approvals, editorial calendar, comments, and asset references are implemented. **Do not start `workspace-15.9` automatically.**
## Do NOT ## Do NOT
- Implement hospitality-12.9 / 12.10 from this workstream - Start `workspace-15.9` / `15.10` / `15.11` from this workstream
- Modify Hospitality backend - Seed Commercial Runtime product/capability/bundle rows
- Start another vertical module from this phase - Store file blobs in Workspace or fake uploads
- Redesign Commercial Runtime / reintroduce Core `/plans` - Confuse Torbat Workspace with Core Tenant Workspace, Experience Workspace, or Payment Workspace
- Redesign ADR-024 or register a separate CMS product
## Remaining (separate tracks — `project-status.yaml`) ## Remaining (separate tracks — `project-status.yaml`)
1. Payment Backend MVP `14.6–14.10` 1. Payment Backend MVP `14.6–14.10`
2. Hospitality backend `12.9` / `12.10` 2. Hospitality backend `12.9` / `12.10`
3. Experience Frontend `experience-fe-11.6+` 3. Experience Frontend `experience-fe-11.6+`
4. Workspace next phase: `workspace-15.9` CRM & Accounting Integration — **not started**

View File

@ -0,0 +1,41 @@
# Phase Handover — Workspace 15.0–15.8 Vertical Slice
## Metadata
| Field | Value |
| --- | --- |
| Phase ID | `workspace-15.8` (covers `workspace-15.0`–`workspace-15.8` first production slice) |
| Title | Workspace Foundation through Files & Assets |
| Status | Complete for the implemented vertical slice |
| Service(s) | `workspace` / `workspace-service` |
| Commercial Product | Torbat Workspace |
| Version | `0.15.0.0` |
| Date | 2026-09-12 |
| ADR(s) | [ADR-024](../architecture/adr/ADR-024.md) |
## What shipped
Working User → Login → Workspace → Work Hub → Team → Project → Task/Kanban → Content Studio → Brief → Workflow → Approval → Editorial Calendar → Activity path, with real `workspace_db` persistence and Next.js routes under `/workspace`.
## Explicit gaps (do not treat as done)
- OKR/KPI definition engines (`workspace-15.1` shells)
- Gantt and resource-planning engines (`workspace-15.2`)
- Dedicated notes entity (`workspace-15.6`; comments/mentions exist)
- Folder-tree DAM (`workspace-15.8`; asset references only)
- File Storage upload (contract unavailable — `reference_only`)
- Communication delivery (mentions persist only)
- Commercial Runtime seed (compatible, not seeded)
- AI assistant (`workspace-15.10`)
- CRM / Accounting integration (`workspace-15.9`)
## Next phase
`workspace-15.9` — CRM & Accounting Integration. Do not start automatically.
## Validation
- Backend pytest: 18 passed
- Frontend `tsc --noEmit`: pass
- `npm run validate:workspace-routes`: pass
- File Storage and notification delivery: honest unavailable / persisted-only

View File

@ -0,0 +1,149 @@
# Phase Handover — Workspace-Reg Workspace Platform Registration
## Metadata
| Field | Value |
| --- | --- |
| Phase ID | `workspace-reg` |
| Title | Workspace Platform Registration |
| Status | Complete |
| Service(s) | `workspace` / `workspace-service` (registered; not implemented) |
| Commercial Product | Torbat Workspace |
| Version | n/a (docs-only) |
| Date | 2026-09-12 |
| ADR(s) | [ADR-024](../architecture/adr/ADR-024.md) |
## Enterprise Phase Discovery Summary
| Item | Detail |
| --- | --- |
| Discovery Record location | This handover |
| Gaps promoted & closed | Independent work-management product, phases 15.0–15.11, commercial compatibility, naming collision with Core/Experience/Payment “workspace” |
| Exclusions (future / other service) | All implementation; Commercial Runtime seed; CRM/Experience/File Storage/Accounting ownership |
| CRUD-only rejected | Yes — registration is architecture, not CRUD |
## Business Analysis Summary
| Item | Detail |
| --- | --- |
| Capabilities delivered | Architecture registration of Torbat Workspace + 20 capability keys (docs only) |
| Explicit non-goals honored | No backend, frontend, database, migrations, APIs, models, repositories, services, tests, or runtime changes |
## Reusable Components
| Component | Location | Reuse notes |
| --- | --- | --- |
| Platform registration pattern | Same as Payment Pay-Reg, Delivery DP-Reg, Experience XP-Reg | Docs + ADR + manifests before code |
| Commercial future-product rule | [ADR-023](../architecture/adr/ADR-023.md) | Registry rows later; no Core architecture change |
| Service template | [service-template.md](../ai-framework/service-template.md) | Use in Phase 15.0 |
| Phase template | [phase-template.md](../ai-framework/phase-template.md) | Use for 15.x phase docs |
## Public APIs
| Method | Path | Auth / Permission | Notes |
| --- | --- | --- | --- |
| — | — | — | N/A — registration only; APIs begin in `workspace-15.0` |
Planned surfaces (not implemented): `/health`, `/capabilities`, `/metrics`, `/api/v1/*` under `workspace.*` permissions.
## Events
| Event type | Domain / Integration | Payload summary | Version |
| --- | --- | --- | --- |
| `workspace.*` (planned) | Torbat Workspace domain | Publish-only; catalog grows per phase | Planned |
Must not publish as `experience.workspace.*` or `payment.workspace.*`.
## Permissions
| Permission | Routes / actions |
| --- | --- |
| `workspace.*` | Reserved tree; detailed leaves at 15.0+ |
| `workspace.module.enabled` | Planned Core L1 entitlement |
## Extension Points
| Extension point | How to extend | Forbidden uses |
| --- | --- | --- |
| Capability flags | Register `workspace.<capability>` | Absorbing CRM/Experience/Storage domains |
| CRM refs | Opaque `crm_*_ref` on 15.9 | Importing CRM models |
| Accounting intents | Events/API for cost/time intents | JournalEntry creation inside Workspace |
| File refs | `file_ref` to File Storage | Storing blobs in `workspace_db` |
| AI hooks | Optional AI Assistant contract (15.10) | Hard dependency on AI for core PM |
| Commercial packaging | PlatformProduct + capabilities + optional bundle | Editing Commercial Runtime in this phase |
## Known Limitations
- No `backend/services/workspace` code
- No Alembic migrations
- No compose wiring / runtime health
- No Commercial Runtime product/capability seed
- No frontend `/workspace` shell
- “Workspace” remains overloaded in everyday speech — docs must qualify Tenant / Experience / Payment / Torbat Workspace
## Migration Notes
| Item | Detail |
| --- | --- |
| Alembic revision(s) | N/A (docs-only) |
| Upgrade steps | N/A |
| Downgrade support | N/A |
| Data backfill | N/A |
| Breaking changes | None — additive registration |
## Dependencies
| Dependency | Type | Required for |
| --- | --- | --- |
| AI Framework | Docs | Development loop / gates |
| ADR-001 / 002 / 003 / 006 / 010 / 012 / 016 / 022 / 023 / 024 | Architecture | Boundaries |
| Core Platform | Service | Entitlement (from 15.0) |
| Identity | Service | User refs (from 15.0) |
| CRM | Service | Optional refs (from 15.9) |
| Accounting | Service | Cost/time intents (from 15.9) |
| File Storage | Service | Binary refs (from 15.8) |
| AI Assistant | Service | Optional assistant (from 15.10) |
## Discovery Record (Registration)
| Field | Value |
| --- | --- |
| Discovery date | 2026-09-12 |
| Baseline | No work-management platform; CRM has sales tasks/comments; Core “Workspace” is the Tenant |
| Gap | Enterprise Work Management Platform missing as a registered product |
| Promoted scope | Registration + full roadmap 15.0–15.11 + commercial compatibility declaration + ADR-024 |
| Excluded | Implementation of 15.0+; modifications to existing runtime services |
## Next Phase Entry
| Field | Value |
| --- | --- |
| Recommended next phase | `workspace-15.0` Workspace Foundation |
| Blockers for next phase | None for scaffold — **do not start from this workstream** unless execution_priority says so |
| Implementation Priority | **future** (not on commercial-release critical path) |
What the next phase must read and assume:
1. [workspace-roadmap.md](../workspace-roadmap.md) Phase 15.0 section
2. [ADR-024](../architecture/adr/ADR-024.md)
3. Updated [module-registry.md](../module-registry.md#workspace) / manifests / snapshot
4. Implement foundation shells only — no projects, tasks, content, files, integrations, or AI
## Completion Sign-Off
- [x] Quality gates passed (documentation / architecture / manifest / cross-reference)
- [x] Tests green (N/A — docs-only)
- [x] Documentation updated
- [x] Registries / project-status / next-steps / project index updated
- [x] Service snapshot generated (registration baseline)
- [x] No existing business services modified
- [x] Phase 15.0 NOT implemented (by design)
## Related Documents
- [Workspace Roadmap](../workspace-roadmap.md)
- [ADR-024](../architecture/adr/ADR-024.md)
- [Phase Manifest](../ai-framework/phase-manifest.yaml)
- [Service Manifest](../ai-framework/service-manifest.yaml)
- [Project Index](../ai-framework/project-index.yaml)
- [Quality Gates](../ai-framework/quality-gates.md)

View File

@ -7,17 +7,19 @@ This is the **first document** every AI implementation run must read. When it ex
| Field | Value | | Field | Value |
| --- | --- | | --- | --- |
| Schema | **2** | | Schema | **2** |
| Project version | `0.14.9-hospitality-frontend-production-validation` | | Project version | `0.15.8-workspace-vertical-slice` |
| Last updated | 2026-07-28 | | Last updated | 2026-09-12 |
| Last completed phase | `hospitality-frontend-production-validation` | | Last completed phase | `workspace-15.8` |
**Commercial foundation:** **ARCHITECTURE COMPLETE** (`commercial.v1.2`). **Commercial foundation:** **ARCHITECTURE COMPLETE** (`commercial.v1.2`).
**Commercial runtime FE:** **CERTIFIED**. **Commercial runtime FE:** **CERTIFIED_PRODUCTION_PRESENTATION** — pricing, discovery, recommendations, billing, domains and admin UI completed; no visible registry codes or raw payloads — [report](commercial-presentation-final-report.md).
**Commercial runtime backend:** **COMPLETE**. **Commercial runtime backend:** **COMPLETE**.
**Commercial runtime seed:** **PASS** — all 24 registries seeded · cross-ref PASS · Zero-Code PASS — [seed](commercial-runtime-seed-report.md) · [registry](commercial-runtime-registry-report.md) · [xref](commercial-runtime-cross-reference-report.md).
**Commercial runtime adoption:** **ADOPTED**. **Commercial runtime adoption:** **ADOPTED**.
**Commercial cleanup:** **CERTIFIED** / **SOLE_SOT** — score **98** — Remaining Legacy **0** — [certification](commercial-runtime-final-certification.md). **Commercial cleanup:** **CERTIFIED** / **SOLE_SOT** — score **98** — Remaining Legacy **0** — [certification](commercial-runtime-final-certification.md).
**Platform UX:** **CERTIFIED_PRODUCTION_UX** — score **98**. **Platform UX:** **CERTIFIED_PRODUCTION_UX** — score **98**.
**Hospitality FE:** **CERTIFIED** — score **98** — [certification](hospitality-final-certification.md) · [validation](hospitality-production-validation.md). **Hospitality FE:** **CERTIFIED** — score **98** — [certification](hospitality-final-certification.md) · [validation](hospitality-production-validation.md).
**Torbat Workspace:** **VERTICAL SLICE IMPLEMENTED** (`workspace-15.8`, [ADR-024](architecture/adr/ADR-024.md)) — Work Hub through editorial calendar and asset refs; next `workspace-15.9`.
**Next remaining (critical path):** Payment Backend MVP `14.6–14.10` · Hospitality backend `12.9`/`12.10` · Experience FE `11.6+`. **Next remaining (critical path):** Payment Backend MVP `14.6–14.10` · Hospitality backend `12.9`/`12.10` · Experience FE `11.6+`.
@ -62,7 +64,7 @@ Ordered remaining milestones:
| **high** | `delivery-10.2`–`10.10`, `experience-fe-11.6`, `communication-frontend-complete` | | **high** | `delivery-10.2`–`10.10`, `experience-fe-11.6`, `communication-frontend-complete` |
| **medium** | `loyalty-7.7`–`7.10`, `sports-center-9.8`–`9.10`, `accounting-5.12` | | **medium** | `loyalty-7.7`–`7.10`, `sports-center-9.8`–`9.10`, `accounting-5.12` |
| **low** | Identity / CRM / Healthcare / Beauty frontend polish | | **low** | Identity / CRM / Healthcare / Beauty frontend polish |
| **future** | Ecommerce, Marketplace, Automation, Academy, Clinic, Hotel, Tourism, AI, SMS Panel, Short Links, QR, Digital Card, Booking, Notification, File Storage, Live Chat, Messenger | | **future** | Ecommerce, Marketplace, Automation, Academy, Clinic, Hotel, Tourism, AI, SMS Panel, Short Links, QR, Digital Card, Booking, Notification, File Storage, Live Chat, Messenger, **Workspace 15.0–15.11** |
--- ---
@ -70,14 +72,14 @@ Ordered remaining milestones:
| Metric | Value | | Metric | Value |
| --- | --- | | --- | --- |
| Number of services | 27 | | Number of services | 28 |
| Completed services | 6 | | Completed services | 6 |
| In progress | 7 | | In progress | 7 |
| Planned / scaffolded | 14 | | Planned / scaffolded | 15 |
| Backend completion | ~48% | | Backend completion | ~46% |
| Frontend completion | ~22% | | Frontend completion | ~23% |
| Overall completion | ~35% | | Overall completion | ~35% |
| Critical path remaining | 10 | | Critical path remaining | 9 |
--- ---
@ -115,6 +117,7 @@ deployment_readiness:
| Healthcare | 13-7 | FE polish | 100 | 55 | 78 | COMPLETE | | Healthcare | 13-7 | FE polish | 100 | 55 | 78 | COMPLETE |
| Beauty | 14-7 | FE polish | 100 | 55 | 78 | COMPLETE | | Beauty | 14-7 | FE polish | 100 | 55 | 78 | COMPLETE |
| Payment | 14.5 | 14.6–14.10 + FE | 50 | 0 | 25 | IN_PROGRESS | | Payment | 14.5 | 14.6–14.10 + FE | 50 | 0 | 25 | IN_PROGRESS |
| Workspace | WS-Reg | 15.0–15.11 | 0 | 0 | 0 | NOT_STARTED |
| Future products | — | foundations | 0–5 | 0 | 0–2 | NOT_STARTED | | Future products | — | foundations | 0–5 | 0 | 0–2 | NOT_STARTED |
## Dependency graph ## Dependency graph

View File

@ -7,9 +7,9 @@
# DEPRECATED: next_recommended_phase — replaced by execution_priority + critical_path + deployment_readiness. # DEPRECATED: next_recommended_phase — replaced by execution_priority + critical_path + deployment_readiness.
schema_version: 2 schema_version: 2
project_version: "0.14.13-commercial-presentation-dictionary" project_version: "0.15.8-workspace-vertical-slice"
last_updated: "2026-07-29" last_updated: "2026-09-12"
last_completed_phase: commercial-presentation-dictionary last_completed_phase: workspace-15.8
last_official_deploy: last_official_deploy:
date: "2026-07-28" date: "2026-07-28"
host: "192.168.10.162" host: "192.168.10.162"
@ -20,6 +20,64 @@ last_official_deploy:
- frontend # commercial runtime + platform ux/shell + hospitality FE - frontend # commercial runtime + platform ux/shell + hospitality FE
report: docs/official-deployment-report-platform-fe.md report: docs/official-deployment-report-platform-fe.md
platform_e2e_validation:
status: complete
certification_status: PLATFORM_FE_JOURNEY_CERTIFIED
phase_id: platform-e2e-validation
full_stack_production_ready: false
fe_journey_executable: true
fe_fixable_blockers_remaining: 0
reports:
- docs/platform-e2e-validation.md
- docs/platform-customer-journey.md
- docs/platform-commercial-validation.md
- docs/platform-route-validation.md
- docs/platform-crud-validation.md
- docs/platform-admin-validation.md
- docs/platform-integration-validation.md
- docs/platform-production-readiness.md
- docs/platform-blocking-issues.md
repairs:
- hospitality_customers_guests_service_requests
- hospitality_product_app_ctas
- commercial_billing_amount_minor_fields
intentional_unfinished:
- payment-14.6+
- hospitality-12.9
- hospitality-12.10
- experience-fe-11.6
- delivery-10.9
- delivery-10.10
- loyalty-7.7+
- accounting-5.12
- marketplace_foundation
notes: >
Platform E2E validation complete. All FE-fixable journey blockers repaired.
Full-stack production readiness remains CONDITIONAL on critical_path backends
FE journey certified. Commercial Runtime seed later completed on 192.168.10.162
(see commercial_runtime_seed_validation).
commercial_runtime_seed_validation:
status: complete
architecture_status: COMMERCIAL_RUNTIME_SEED_PASS
phase_id: commercial-runtime-seed-validation
host: "192.168.10.162"
seed_script: backend/core-service/scripts/seed_commercial_runtime.py
seed_catalog: backend/core-service/scripts/data/business-bundles.catalog.yaml
empty_registries: 0
cross_reference: PASS
crud_auth: TokenService_HS256_platform_admin
crud: PASS
zero_code_expansion: PASS
seed_report: docs/commercial-runtime-seed-report.md
registry_report: docs/commercial-runtime-registry-report.md
cross_reference_report: docs/commercial-runtime-cross-reference-report.md
handover: docs/phase-handover/phase-commercial-runtime-seed-validation.md
snapshot: docs/service-snapshots/commercial-runtime-backend.yaml
notes: >
Incomplete seed was the blocker (docs catalog not mounted in core container).
Full QA seed deployed; Zero-Code Expansion PASS. No FE/OAuth/password-grant work.
commercial_foundation: commercial_foundation:
status: architecture_complete status: architecture_complete
architecture_status: ARCHITECTURE_COMPLETE architecture_status: ARCHITECTURE_COMPLETE
@ -94,48 +152,49 @@ commercial_foundation:
- booking_foundation - booking_foundation
- marketplace_product - marketplace_product
- torbat_credit - torbat_credit
- workspace-15.9+
notes: > notes: >
Commercial Platform Foundation v1 + v1.1 + v1.2 is ARCHITECTURE COMPLETE (docs). Commercial Platform Foundation v1 + v1.1 + v1.2 is ARCHITECTURE COMPLETE (docs).
Commercial Runtime Frontend is CERTIFIED. Commercial Runtime Backend is COMPLETE Commercial Runtime Frontend is CERTIFIED. Commercial Runtime Backend is COMPLETE
(Core Platform /api/v1/commercial — see commercial_runtime_backend). (Core Platform /api/v1/commercial — see commercial_runtime_backend).
commercial_runtime_frontend: commercial_runtime_frontend:
status: complete status: complete
architecture_status: COMMERCIAL_PRESENTATION_COMPLETE architecture_status: COMMERCIAL_PRESENTATION_COMPLETE
phase_id: commercial-presentation-layer-completion phase_id: commercial-presentation-layer-completion
latest_presentation_revision: commercial-presentation-dictionary latest_presentation_revision: commercial-presentation-dictionary
prior_phase: commercial-runtime-e2e-wave-3 prior_phase: commercial-runtime-e2e-wave-3
validation_phase: commercial-runtime-e2e-wave-3 validation_phase: commercial-runtime-e2e-wave-3
certification_status: CERTIFIED_PRODUCTION_PRESENTATION certification_status: CERTIFIED_PRODUCTION_PRESENTATION
final_report: docs/commercial-presentation-final-report.md final_report: docs/commercial-presentation-final-report.md
dictionary_report: docs/commercial-presentation-dictionary-report.md dictionary_report: docs/commercial-presentation-dictionary-report.md
i18n_report: docs/commercial-i18n-report.md i18n_report: docs/commercial-i18n-report.md
admin_polish_report: docs/commercial-admin-polish-report.md admin_polish_report: docs/commercial-admin-polish-report.md
validation: docs/commercial-runtime-validation.md validation: docs/commercial-runtime-validation.md
validation_report_historical: docs/commercial-runtime-validation-report.md validation_report_historical: docs/commercial-runtime-validation-report.md
snapshot: docs/service-snapshots/commercial-runtime-frontend.yaml snapshot: docs/service-snapshots/commercial-runtime-frontend.yaml
handover: docs/phase-handover/phase-commercial-presentation-layer-completion.md handover: docs/phase-handover/phase-commercial-presentation-layer-completion.md
handover_completion: docs/phase-handover/phase-commercial-runtime-completion-wave-3.md handover_completion: docs/phase-handover/phase-commercial-runtime-completion-wave-3.md
handover_wave_2: docs/phase-handover/phase-commercial-runtime-frontend-wave-2.md handover_wave_2: docs/phase-handover/phase-commercial-runtime-frontend-wave-2.md
handover_validation: docs/phase-handover/phase-commercial-runtime-validation-wave-3.md handover_validation: docs/phase-handover/phase-commercial-runtime-validation-wave-3.md
module: frontend/modules/commercial module: frontend/modules/commercial
routes: routes:
- / - /
- /onboarding - /onboarding
- /discover - /discover
- /pricing - /pricing
- /dashboard - /dashboard
- /apps - /apps
- /billing - /billing
- /domains - /domains
- /admin/commercial - /admin/commercial
- /admin/commercial/[resource] - /admin/commercial/[resource]
scores: scores:
architecture: 96 architecture: 96
future_proof: 97 future_proof: 97
automatic_discovery: 96 automatic_discovery: 96
commercial_flow: 94 commercial_flow: 94
presentation_quality: 100 presentation_quality: 100
delivered: delivered:
- admin_commercial_portal - admin_commercial_portal
- metadata_driven_landing_wizard - metadata_driven_landing_wizard
@ -150,36 +209,38 @@ commercial_runtime_frontend:
- tenant_recommendations_feed - tenant_recommendations_feed
- setup_checklist_percent - setup_checklist_percent
- admin_tenant_registry_sync - admin_tenant_registry_sync
- commercial_runtime_complete - commercial_runtime_complete
- commercial_presentation_vocabulary - commercial_presentation_vocabulary
- production_pricing_cards - production_pricing_cards
- human_readable_bundle_renderer - human_readable_bundle_renderer
- business_language_discover_wizard - business_language_discover_wizard
- grouped_recommendations_with_reasons - grouped_recommendations_with_reasons
- production_billing_and_domains - production_billing_and_domains
- dynamic_admin_forms - dynamic_admin_forms
- searchable_sortable_paginated_admin_tables - searchable_sortable_paginated_admin_tables
- recursive_object_presentation - recursive_object_presentation
- friendly_errors_empty_success_loading_states - friendly_errors_empty_success_loading_states
- universal_registry_presentation_dictionary - universal_registry_presentation_dictionary
- centralized_commercial_i18n - centralized_commercial_i18n
- dictionary_driven_admin_cards_tables_forms - dictionary_driven_admin_cards_tables_forms
- zero_generic_registry_labels - zero_generic_registry_labels
- structured_reference_rendering - structured_reference_rendering
explicitly_not_done: explicitly_not_done:
- payment_module_edits - payment_module_edits
- experience_module_edits - experience_module_edits
notes: > notes: >
COMMERCIAL PRESENTATION COMPLETE. Universal Presentation Dictionary is the single source for COMMERCIAL PRESENTATION COMPLETE. Universal Presentation Dictionary is the single source for
registry titles, descriptions, icons, actions, status labels, columns, entity names and empty registry titles, descriptions, icons, actions, status labels, columns, entity names and empty
states. Every routed commercial surface uses business language with no visible registry codes, states. Every routed commercial surface uses business language with no visible registry codes,
generic labels, raw JSON/payloads, object coercion, technical traces, or untranslated commercial generic labels, raw JSON/payloads, object coercion, technical traces, or untranslated commercial
enums. Runtime, APIs, schemas, database, contracts, ADRs and adapters unchanged. enums. Runtime, APIs, schemas, database, contracts, ADRs and adapters unchanged.
commercial_runtime_backend: commercial_runtime_backend:
status: complete status: complete
architecture_status: COMMERCIAL_RUNTIME_BACKEND_COMPLETE architecture_status: COMMERCIAL_RUNTIME_BACKEND_COMPLETE
seed_status: COMMERCIAL_RUNTIME_SEED_PASS
phase_id: commercial-runtime-backend phase_id: commercial-runtime-backend
last_seed_phase: commercial-runtime-seed-validation
contract_family: commercial.v1.2 contract_family: commercial.v1.2
service: core-platform service: core-platform
package: backend/core-service/app/commercial package: backend/core-service/app/commercial
@ -187,8 +248,12 @@ commercial_runtime_backend:
migration: 0007_commercial_runtime migration: 0007_commercial_runtime
final_report: docs/commercial-runtime-backend-final-report.md final_report: docs/commercial-runtime-backend-final-report.md
architecture_report: docs/commercial-runtime-backend-architecture-report.md architecture_report: docs/commercial-runtime-backend-architecture-report.md
seed_report: docs/commercial-runtime-seed-report.md
registry_report: docs/commercial-runtime-registry-report.md
cross_reference_report: docs/commercial-runtime-cross-reference-report.md
snapshot: docs/service-snapshots/commercial-runtime-backend.yaml snapshot: docs/service-snapshots/commercial-runtime-backend.yaml
handover: docs/phase-handover/phase-commercial-runtime-backend.md handover: docs/phase-handover/phase-commercial-runtime-seed-validation.md
handover_backend: docs/phase-handover/phase-commercial-runtime-backend.md
tests: tests:
path: backend/core-service/app/tests/test_commercial_runtime.py path: backend/core-service/app/tests/test_commercial_runtime.py
result: 9_passed result: 9_passed
@ -206,16 +271,19 @@ commercial_runtime_backend:
- notification_registry - notification_registry
- outbox_events - outbox_events
- openapi - openapi
- seed_from_docs_catalogs - seed_from_bundled_catalogs
- full_registry_seed_verified
- zero_code_expansion_verified
explicitly_not_done: explicitly_not_done:
- marketplace_product_engine - marketplace_product_engine
- qr_foundation - qr_foundation
- booking_foundation - booking_foundation
- payment-14.6+ - payment-14.6+
- fe_bundle_catalog_codegen - fe_bundle_catalog_codegen
deployment_readiness: READY_AFTER_CORE_DEPLOY deployment_readiness: READY
notes: > notes: >
Single SoT for commercial metadata. Future products = registry rows only (zero code change). Single SoT for commercial metadata. Future products = registry rows only (zero code change).
Seed complete on 192.168.10.162 — all 24 registries nonempty; cross-ref PASS; Zero-Code PASS.
Invoices/transactions endpoints remain honest-empty (Payment owns ledgers). Invoices/transactions endpoints remain honest-empty (Payment owns ledgers).
commercial_runtime_adoption: commercial_runtime_adoption:
@ -378,6 +446,70 @@ product_integration:
without implementing future roadmap foundations. Bundle catalog UX must consume without implementing future roadmap foundations. Bundle catalog UX must consume
commercial-foundation SoT via future codegen. commercial-foundation SoT via future codegen.
workspace_platform:
status: vertical_slice_implemented
architecture_status: IMPLEMENTATION_IN_PROGRESS
phase_id: workspace-15.8
category: Platform Product
purpose: Enterprise Work Management Platform
commercial_product: Torbat Workspace
service: workspace
deployable_name: workspace-service
database: workspace_db
permission_prefix: workspace.*
event_namespace: workspace.*
health_endpoint: /health
adr: docs/architecture/adr/ADR-024.md
roadmap: docs/workspace-roadmap.md
handover: docs/phase-handover/phase-workspace-15-8.md
snapshot: docs/service-snapshots/workspace.yaml
commercial_runtime_compatible: true
bundle_compatible: true
capability_compatible: true
subscription_compatible: true
license_compatible: true
trial_compatible: true
commercial_implementation: false
remaining_phases:
- workspace-15.9
- workspace-15.10
- workspace-15.11
declared_capabilities:
- projects
- tasks
- kanban
- calendar
- gantt
- content
- editorial-calendar
- creative-review
- approvals
- assets
- time-tracking
- resource-planning
- okr
- kpi
- notes
- comments
- mentions
- files
- ai-assistant
- analytics
explicitly_not_done:
- workspace-15.9+
- commercial_runtime_seed
- okr_kpi_engines
- gantt
- resource_planning
- notes_entity
- file_storage_blobs
- ai_assistant
notes: >
First production slice 15.0–15.8 is implemented (Work Hub, teams, projects,
tasks/Kanban, content, approvals, calendar, comments, asset refs). Distinct
from Core Tenant Workspace, Experience Workspace, and Payment Workspace.
Next phase workspace-15.9 is not started.
# --- Resume Rules (mandatory for every AI execution) --- # --- Resume Rules (mandatory for every AI execution) ---
resume_rules: resume_rules:
@ -508,6 +640,9 @@ execution_priority:
- file-storage-foundation - file-storage-foundation
- live-chat-foundation - live-chat-foundation
- smart-messenger-foundation - smart-messenger-foundation
- workspace-15.9
- workspace-15.10
- workspace-15.11
- future-services - future-services
services: services:
@ -554,6 +689,7 @@ services:
- ai_assistant - ai_assistant
- link_shortener - link_shortener
- file_storage - file_storage
- workspace
completed_phase: commercial-runtime-backend completed_phase: commercial-runtime-backend
remaining_phase: null remaining_phase: null
backend_percent: 100 backend_percent: 100
@ -599,6 +735,7 @@ services:
- healthcare - healthcare
- beauty_business - beauty_business
- payment - payment
- workspace
completed_phase: identity-2 completed_phase: identity-2
remaining_phase: identity-frontend-polish remaining_phase: identity-frontend-polish
backend_percent: 100 backend_percent: 100
@ -633,7 +770,8 @@ services:
depends_on: depends_on:
- core-platform - core-platform
- identity-access - identity-access
required_by: [] required_by:
- workspace
completed_phase: accounting-5.11 completed_phase: accounting-5.11
remaining_phase: accounting-5.12 remaining_phase: accounting-5.12
backend_percent: 92 backend_percent: 92
@ -668,7 +806,8 @@ services:
depends_on: depends_on:
- core-platform - core-platform
- identity-access - identity-access
required_by: [] required_by:
- workspace
completed_phase: crm-6.3 completed_phase: crm-6.3
remaining_phase: crm-frontend-polish remaining_phase: crm-frontend-polish
backend_percent: 100 backend_percent: 100
@ -1304,7 +1443,8 @@ services:
- foundation scaffold only - foundation scaffold only
depends_on: depends_on:
- core-platform - core-platform
required_by: [] required_by:
- workspace
completed_phase: null completed_phase: null
remaining_phase: ai-assistant-foundation remaining_phase: ai-assistant-foundation
backend_percent: 5 backend_percent: 5
@ -1445,7 +1585,8 @@ services:
- future platform service - future platform service
depends_on: depends_on:
- core-platform - core-platform
required_by: [] required_by:
- workspace
completed_phase: null completed_phase: null
remaining_phase: file-storage-foundation remaining_phase: file-storage-foundation
backend_percent: 5 backend_percent: 5
@ -1530,14 +1671,57 @@ services:
documentation: NOT_STARTED documentation: NOT_STARTED
production_ready: NOT_STARTED production_ready: NOT_STARTED
- name: Workspace Platform
service_identifier: workspace
commercial_product: Torbat Workspace
backend_status: in_progress
frontend_status: in_progress
latest_backend_phase: workspace-15.8
latest_frontend_phase: workspace-15.8
roadmap_last_phase: workspace-15.11
architecture_complete: true
backend_complete: false
frontend_complete: false
production_ready: false
snapshot: docs/service-snapshots/workspace.yaml
latest_handover: docs/phase-handover/phase-workspace-15-8.md
remaining_phases:
- workspace-15.9
- workspace-15.10
- workspace-15.11
blockers:
- File Storage contract unavailable (asset references only)
- Communication delivery unavailable (mentions persisted only)
- Commercial Runtime not seeded for Torbat Workspace
depends_on:
- core-platform
- identity-access
required_by: []
completed_phase: workspace-15.8
remaining_phase: workspace-15.9
backend_percent: 68
frontend_percent: 62
overall_percent: 65
deployment_readiness:
backend: IN_PROGRESS
frontend: IN_PROGRESS
database: IN_PROGRESS
api: IN_PROGRESS
tests: IN_PROGRESS
documentation: IN_PROGRESS
production_ready: NOT_STARTED
notes: >
First production slice 15.0–15.8 implemented. Next is workspace-15.9.
Do not confuse with Core Tenant Workspace, Experience Workspace, or Payment Workspace.
global_statistics: global_statistics:
number_of_services: 27 number_of_services: 28
completed_services: 6 completed_services: 6
services_in_progress: 7 services_in_progress: 7
services_planned_or_scaffolded: 14 services_planned_or_scaffolded: 15
backend_completion_percent: 48 backend_completion_percent: 46
frontend_completion_percent: 24 frontend_completion_percent: 23
overall_completion_percent: 36 overall_completion_percent: 35
critical_path_remaining: 9 critical_path_remaining: 9
notes: > notes: >
Percentages from per-service backend_percent/frontend_percent/overall_percent. Percentages from per-service backend_percent/frontend_percent/overall_percent.

View File

@ -48,6 +48,8 @@
30. Healthcare Platform Foundation through Delivery Integration (Phases 13.0–13.7) 30. Healthcare Platform Foundation through Delivery Integration (Phases 13.0–13.7)
31. ~~Beauty Business Platform Registration~~ (BB-Reg done — docs only) 31. ~~Beauty Business Platform Registration~~ (BB-Reg done — docs only)
32. Beauty Business Platform Foundation through Marketing & Loyalty (Phases 14.0–14.7) 32. Beauty Business Platform Foundation through Marketing & Loyalty (Phases 14.0–14.7)
33. ~~Workspace Platform Registration~~ (WS-Reg done — docs only; [ADR-024](architecture/adr/ADR-024.md))
34. Workspace Platform Foundation through Enterprise Validation (Phases 15.0–15.11) — **future**; not on commercial-release critical path
## Longer Term ## Longer Term
@ -79,6 +81,7 @@
- [Healthcare Roadmap](healthcare-roadmap.md) - [Healthcare Roadmap](healthcare-roadmap.md)
- [Beauty Business](phases/BeautyBusiness/README.md) - [Beauty Business](phases/BeautyBusiness/README.md)
- [Beauty Business Roadmap](beauty-business-roadmap.md) - [Beauty Business Roadmap](beauty-business-roadmap.md)
- [Workspace Roadmap](workspace-roadmap.md)
Implementation of every future phase must follow the [Enterprise / AI Development Framework](ai-framework/README.md) ([ADR-013](architecture/adr/ADR-013.md), [ADR-018](architecture/adr/ADR-018.md), [ADR-019](architecture/adr/ADR-019.md)). Production readiness is automatic via Enterprise Phase Discovery, Definition of Done, mandatory artifacts, and enterprise completeness — no extra prompt required. Implementation of every future phase must follow the [Enterprise / AI Development Framework](ai-framework/README.md) ([ADR-013](architecture/adr/ADR-013.md), [ADR-018](architecture/adr/ADR-018.md), [ADR-019](architecture/adr/ADR-019.md)). Production readiness is automatic via Enterprise Phase Discovery, Definition of Done, mandatory artifacts, and enterprise completeness — no extra prompt required.
@ -97,3 +100,5 @@ Implementation of every future phase must follow the [Enterprise / AI Developmen
- [ADR-016](architecture/adr/ADR-016.md) - [ADR-016](architecture/adr/ADR-016.md)
- [Healthcare Roadmap](healthcare-roadmap.md) - [Healthcare Roadmap](healthcare-roadmap.md)
- [Beauty Business Roadmap](beauty-business-roadmap.md) - [Beauty Business Roadmap](beauty-business-roadmap.md)
- [Workspace Roadmap](workspace-roadmap.md)
- [ADR-024](architecture/adr/ADR-024.md)

View File

@ -0,0 +1,141 @@
# Workspace Platform snapshot — first production slice (workspace-15.8)
# Spec: docs/ai-framework/service-snapshot-policy.md
schema_version: 1
snapshot_version: 2
service_name: workspace
commercial_product: Torbat Workspace
current_version: "0.15.0.0"
current_phase: workspace-15.8
last_completed_phase: workspace-15.8
next_phase: workspace-15.9
completed_phases:
- workspace-reg
- workspace-15.0
- workspace-15.1
- workspace-15.2
- workspace-15.3
- workspace-15.4
- workspace-15.5
- workspace-15.6
- workspace-15.7
- workspace-15.8
remaining_phases:
- workspace-15.9
- workspace-15.10
- workspace-15.11
registered_modules:
- foundation
- core
- projects
- tasks
- content_studio
- creative_workflow
- collaboration
- calendar
- files_assets
- crm_accounting_integration
- ai_analytics
enabled_capabilities: []
enabled_bundles: []
declared_capabilities:
- workspace.projects
- workspace.tasks
- workspace.kanban
- workspace.calendar
- workspace.gantt
- workspace.content
- workspace.editorial-calendar
- workspace.creative-review
- workspace.approvals
- workspace.assets
- workspace.time-tracking
- workspace.resource-planning
- workspace.okr
- workspace.kpi
- workspace.notes
- workspace.comments
- workspace.mentions
- workspace.files
- workspace.ai-assistant
- workspace.analytics
public_apis:
- { method: GET, path: /health, permission: public }
- { method: GET, path: /capabilities, permission: tenant }
- { method: GET, path: /metrics, permission: public }
- { method: GET|POST, path: /api/v1/hubs, permission: workspace.hub.* }
- { method: GET|POST, path: /api/v1/teams, permission: workspace.team.* }
- { method: GET|POST, path: /api/v1/projects, permission: workspace.project.* }
- { method: GET|POST, path: /api/v1/tasks, permission: workspace.task.* }
- { method: GET|POST, path: /api/v1/content, permission: workspace.content.* }
- { method: GET, path: /api/v1/content/calendar, permission: workspace.calendar.manage }
- { method: GET|POST, path: /api/v1/content/approvals, permission: workspace.content.approve }
- { method: GET|POST, path: /api/v1/assets, permission: workspace.asset.* }
- { method: GET|POST, path: /api/v1/comments, permission: workspace.comment.manage }
- { method: GET, path: /api/v1/activity, permission: workspace.report.read }
published_events:
- workspace.work_hub.created
- workspace.work_hub.updated
- workspace.team.created
- workspace.project.created
- workspace.task.created
- workspace.task.status_changed
- workspace.content.created
- workspace.content.status_changed
- workspace.content.scheduled
- workspace.approval.requested
- workspace.approval.responded
- workspace.comment.created
planned_event_namespace: workspace.*
permission_prefix: workspace.*
active_adrs:
- ADR-001
- ADR-002
- ADR-003
- ADR-006
- ADR-010
- ADR-012
- ADR-016
- ADR-022
- ADR-023
- ADR-024
integration_contracts:
- IdentityProvider
- CRMProvider
- AccountingProvider
- StorageProvider
- CommunicationProvider
- ExperienceConnector
- AIProvider
known_limitations:
- File Storage unavailable — AssetReference only, no blob upload
- Notification delivery persisted_only — no Communication engine
- Commercial Runtime compatible but not seeded
- OKR/KPI/Gantt/resource-planning/notes engines not built
- Tenant Workspace (Core) is not this product
- Experience Workspace and Payment Workspace remain in their services
open_todos:
- workspace-15.9 CRM and Accounting integration contracts
- workspace-15.10 AI hooks and analytics depth
- workspace-15.11 enterprise validation
last_handover_reference: docs/phase-handover/phase-workspace-15-8.md
last_updated: "2026-09-12"
database: workspace_db
api_port_reserved: 8013
deployable_name: workspace-service
service_path: backend/services/workspace
health_endpoint: /health

322
docs/workspace-roadmap.md Normal file
View File

@ -0,0 +1,322 @@
# Workspace Platform Roadmap (Torbat Workspace)
> First production slice (`workspace-15.0`–`workspace-15.8`) is implemented. Next authorized phase is **`workspace-15.9`** only.
> Completed work → [progress.md](progress.md). Immediate milestone → [next-steps.md](next-steps.md). Module inventory → [module-registry.md](module-registry.md#workspace).
Independent **Workspace Platform** (commercial product: **Torbat Workspace**): enterprise work management — projects, tasks, content operations, editorial calendar, creative workflow, collaboration, files/assets, CRM/Accounting integration, AI assistant hooks, and reporting.
Category: **Platform Product**. Purpose: **Enterprise Work Management Platform**.
The Workspace Platform becomes the operational center for organizations. It includes (product intent — phased):
- Project Management · Task Management
- Content Operations · Editorial Calendar · Creative Studio
- Marketing Operations
- Team Collaboration · Client Collaboration
- File Management · Digital Assets · Approval Workflow
- OKR · KPI
- Resource Planning · Time Tracking · Project Cost Control
- Accounting Integration · CRM Integration
- AI Assistant · Reporting
Implementation of every phase must follow the [Enterprise / AI Development Framework](ai-framework/README.md) ([ADR-013](architecture/adr/ADR-013.md), [ADR-018](architecture/adr/ADR-018.md), [ADR-019](architecture/adr/ADR-019.md), [ADR-024](architecture/adr/ADR-024.md)).
---
## Phase Overview
| Phase | Identifier | Title | Status |
| --- | --- | --- | --- |
| Reg | `workspace-reg` | Workspace Platform Registration | **Complete** (docs only) |
| 15.0 | `workspace-15.0` | Workspace Foundation | **Complete** |
| 15.1 | `workspace-15.1` | Workspace Core | **Complete** (Work Hub/teams; OKR/KPI engines not built) |
| 15.2 | `workspace-15.2` | Projects | **Complete** (CRUD/progress; Gantt/resource-planning not built) |
| 15.3 | `workspace-15.3` | Tasks | **Complete** |
| 15.4 | `workspace-15.4` | Content Studio | **Complete** |
| 15.5 | `workspace-15.5` | Creative Workflow | **Complete** |
| 15.6 | `workspace-15.6` | Collaboration | **Complete** (comments/mentions/activity; notes entity not built) |
| 15.7 | `workspace-15.7` | Calendar | **Complete** |
| 15.8 | `workspace-15.8` | Files & Assets | **Complete** (asset references only) |
| 15.9 | `workspace-15.9` | CRM & Accounting Integration | Planned |
| 15.10 | `workspace-15.10` | AI & Analytics | Planned |
| 15.11 | `workspace-15.11` | Enterprise Validation | Planned |
Phase documents are created at implementation time from [phase-template.md](ai-framework/phase-template.md). This roadmap is the registration SoT until then.
---
## Naming (normative)
| Term | Owner | Do not confuse with |
| --- | --- | --- |
| **Torbat Workspace** | this product | Core Tenant Workspace |
| **Work Hub** | this service | Experience Workspace; Payment Workspace |
| **Workspace Project** | this service | Accounting analytical **Project** |
| **Workspace Task** | this service | CRM sales **Task** |
| Events `workspace.*` | this service | `experience.workspace.*`, `payment.workspace.*` |
See [ADR-024](architecture/adr/ADR-024.md) and [glossary.md](glossary.md).
---
## Phase 15.0 — Workspace Foundation
**Objective:** Independent `workspace` / `workspace-service` scaffold with tenant-aware foundation aggregates, permissions, publish-only events, provider contracts, health/capabilities — no project/task/content engines.
**In scope (planned):**
- Service folder `backend/services/workspace` (future implementation); deployable name `workspace-service`
- Database `workspace_db`; API prefix `/api/v1`; port **8013** (reserved)
- Foundation shells: WorkHub, WorkspaceRole, WorkspacePermission, ExternalProviderConfig, WorkspaceConfiguration, WorkspaceSetting, WorkspaceAuditLog, OutboxEvent
- Provider contracts only: Identity, CRM, Accounting, File Storage, Communication, Experience, AI Assistant
- Permissions `workspace.*`; events `workspace.*`
- Health `/health`, capabilities `/capabilities`, metrics `/metrics`
- Alembic `0001_initial` (at implementation); architecture / tenant / permission / migration / docs tests
**Out of scope:** Projects, tasks, kanban/gantt engines, content studio, creative review, collaboration threads, calendars, DAM, CRM/Accounting adapters, AI, analytics, frontend.
**Capabilities unlocked:** none beyond module enablement (`workspace.module.enabled` planned).
---
## Phase 15.1 — Workspace Core
**Objective:** Product kernel — Work Hub, spaces/teams, settings, OKR/KPI shells, capability discovery.
**In scope (planned):** Work Hub lifecycle, spaces/teams, core settings, permission catalog expansion, OKR/KPI definition shells, capability flags for later modules.
**Out of scope:** Project/task engines, content production, file binaries, CRM/Accounting postings, AI execution.
**Capabilities:** `okr`, `kpi` (shells).
---
## Phase 15.2 — Projects
**Objective:** Project management aggregates — project lifecycle, membership, resource-planning shells, project cost-control shells, Gantt-ready structure (no full scheduler UI).
**In scope (planned):** Workspace Project, project members, status/lifecycle, resource-planning shells, project cost-control shells (intents only), Gantt structure refs.
**Out of scope:** Accounting journals, CRM opportunities, task board engine (15.3), calendar engine (15.7).
**Capabilities:** `projects`, `gantt`, `resource-planning`.
---
## Phase 15.3 — Tasks
**Objective:** Task management and Kanban — work items, assignment, status, board views.
**In scope (planned):** Workspace Task, lists/boards, Kanban columns, assignment, due dates, task events, `workspace.tasks.*` permissions.
**Out of scope:** CRM sales tasks, time-tracking engine (may attach later), creative approvals (15.5), calendar recurrence (15.7).
**Capabilities:** `tasks`, `kanban`.
---
## Phase 15.4 — Content Studio
**Objective:** Content operations — editorial items, marketing content pipeline, content status — not Experience pages.
**In scope (planned):** Content items, content types, editorial status, content operations events.
**Out of scope:** Experience sites/pages/themes ([ADR-016](architecture/adr/ADR-016.md)), publish_id issuance ([ADR-022](architecture/adr/ADR-022.md)), creative review engine (15.5).
**Capabilities:** `content`.
---
## Phase 15.5 — Creative Workflow
**Objective:** Creative studio workflow — review cycles, approval workflow, creative-review states.
**In scope (planned):** Creative review packages, approval steps, decision records, `workspace.approvals.*` / `workspace.creative-review.*`.
**Out of scope:** Experience publish workflow, Identity auth challenges, Payment capture on approvals.
**Capabilities:** `creative-review`, `approvals`.
---
## Phase 15.6 — Collaboration
**Objective:** Team and client collaboration — notes, comments, mentions — work-management domain only.
**In scope (planned):** Notes, comment threads, mentions, team collaboration, client collaboration shells (guest/client refs, not Identity admin).
**Out of scope:** CRM sales timeline/comments/mentions, Communication message delivery, Live Chat / Smart Messenger ownership.
**Capabilities:** `notes`, `comments`, `mentions`.
---
## Phase 15.7 — Calendar
**Objective:** Operational and editorial calendars — scheduling views over projects, tasks, and content.
**In scope (planned):** Calendar, editorial calendar, date bindings to projects/tasks/content, calendar events.
**Out of scope:** Sports/Healthcare/Beauty booking engines, Experience appointment pages, Communication reminder delivery.
**Capabilities:** `calendar`, `editorial-calendar`.
---
## Phase 15.8 — Files & Assets
**Objective:** File management and digital asset catalog — metadata and refs only.
**In scope (planned):** Folders, file metadata, digital asset catalog, `file_ref` pointers to File Storage, asset events.
**Out of scope:** Blob storage, S3 credentials, signed-URL engine (File Storage), Experience media as page-builder SoT.
**Capabilities:** `files`, `assets`.
---
## Phase 15.9 — CRM & Accounting Integration
**Objective:** Integration contracts — CRM refs, time tracking, project cost intents to Accounting. No foreign ownership.
**In scope (planned):** CRM contact/organization/opportunity refs, time-tracking entries, project cost-control intents, Accounting posting-intent client, events for downstream consumers.
**Out of scope:** CRM Lead/Opportunity ownership, Accounting JournalEntry / Posting Engine, Payment capture, Loyalty ledgers.
**Capabilities:** `time-tracking` (plus CRM/Accounting integration contracts).
---
## Phase 15.10 — AI & Analytics
**Objective:** Optional AI assistant hooks and workspace analytics/reporting.
**In scope (planned):** Analytics snapshots, reporting shells, AI assistant capability via AI Assistant contracts, `workspace.analytics.*` / `workspace.ai-assistant.*`.
**Out of scope:** Owning `ai_assistant` product, mandatory AI for any workflow, central BI warehouse.
**Capabilities:** `ai-assistant`, `analytics`.
---
## Phase 15.11 — Enterprise Validation
**Objective:** Final enterprise-grade validation across the Workspace Platform prior to GA.
**In scope (planned):** Cross-phase quality gates, boundary audit, permission/event completeness, commercial compatibility re-check, documentation repair.
**Out of scope:** New product engines, other services' remaining phases, Commercial Runtime seed/implementation.
---
## Registered Capabilities (architecture)
Declared for Commercial Runtime / Capability Registry compatibility. **No capability rows, bundles, or engines are implemented.**
| Capability key | Label | Phase |
| --- | --- | --- |
| `workspace.projects` | projects | 15.2 |
| `workspace.tasks` | tasks | 15.3 |
| `workspace.kanban` | kanban | 15.3 |
| `workspace.calendar` | calendar | 15.7 |
| `workspace.gantt` | gantt | 15.2 |
| `workspace.content` | content | 15.4 |
| `workspace.editorial-calendar` | editorial-calendar | 15.7 |
| `workspace.creative-review` | creative-review | 15.5 |
| `workspace.approvals` | approvals | 15.5 |
| `workspace.assets` | assets | 15.8 |
| `workspace.time-tracking` | time-tracking | 15.9 |
| `workspace.resource-planning` | resource-planning | 15.2 |
| `workspace.okr` | okr | 15.1 |
| `workspace.kpi` | kpi | 15.1 |
| `workspace.notes` | notes | 15.6 |
| `workspace.comments` | comments | 15.6 |
| `workspace.mentions` | mentions | 15.6 |
| `workspace.files` | files | 15.8 |
| `workspace.ai-assistant` | ai-assistant | 15.10 |
| `workspace.analytics` | analytics | 15.10 |
---
## Commercial Product (architecture only)
| Field | Value |
| --- | --- |
| Product | **Torbat Workspace** |
| Category | Platform Product |
| Purpose | Enterprise Work Management Platform |
| Commercial Runtime | Compatible (registry entry later; no seed now) |
| Bundle | Compatible (optional Business Bundle mapping later) |
| Capability | Compatible (keys above) |
| Subscription | Compatible ([ADR-023](architecture/adr/ADR-023.md) contracts) |
| License | Compatible (L1 `workspace.module.enabled` planned) |
| Trial | Compatible (trial policy target later) |
| Implementation | **None** — no catalog YAML, no runtime rows, no admin UI, no billing |
Adding this product later to Commercial Runtime requires only PlatformProduct registration + capability rows + optional bundle mapping ([ADR-023](architecture/adr/ADR-023.md) future-product rules). Core commercial architecture does not change.
---
## Technical Registration (planned)
| Field | Value |
| --- | --- |
| Service identifier | `workspace` |
| Deployable name | `workspace-service` |
| Service folder | `backend/services/workspace` (at 15.0) |
| Database | `workspace_db` |
| API port | **8013** (reserved) |
| API prefix | `/api/v1` |
| Health | `/health` |
| Capabilities discovery | `/capabilities` |
| Metrics | `/metrics` |
| Permission prefix | `workspace.*` |
| Event namespace | `workspace.*` |
| Frontend route prefix | `/workspace` (dedicated shell; future) |
| Tenant aware | Yes (required) |
| Public contracts | Health/capabilities/metrics + versioned `/api/v1` resources (per phase) |
| Internal contracts | Token-gated CRM / Accounting / File Storage / AI / Communication refs |
| Required dependencies | Core Platform, Identity & Access, shared-lib |
| Optional dependencies | CRM, Accounting, File Storage, Communication, Experience, AI Assistant |
| Future extensions | Template marketplace, external PM connectors, mobile, portfolio/program mgmt, Automation consumer hooks |
---
## Cross-Module Dependencies
| Platform | Relationship |
| --- | --- |
| Core | Entitlement, tenant resolution, memberships; **not** work-management ownership |
| Identity | User/profile refs for members, clients, assignees |
| CRM | Optional contact / organization / opportunity refs (15.9) |
| Accounting | Time/cost posting intents only (15.9); Project cost is not a journal |
| File Storage | Binary refs for files/assets (15.8) |
| Communication | Mentions / approval / calendar notifications (client) |
| Experience | Optional public showcase pages; Content Studio does not own sites |
| AI Assistant | Optional assistant (15.10); Workspace remains usable when AI is off |
| Payment | Out of scope unless a future paid-approval action binds `publish_id` |
---
## Boundary Reminders
- Workspace owns work-management aggregates in `workspace_db` only.
- No cross-database foreign keys; no importing other services' models.
- Financial postings only through Accounting Posting Engine ([ADR-010](architecture/adr/ADR-010.md)).
- Messaging only through Communication ([ADR-012](architecture/adr/ADR-012.md)).
- Public surfaces use `publish_id` when published ([ADR-022](architecture/adr/ADR-022.md)).
- Do not implement 15.0–15.11 from this registration.
---
## Related Documents
- [ADR-024](architecture/adr/ADR-024.md)
- [Module Registry — workspace](module-registry.md#workspace)
- [Module Boundaries — Workspace](architecture/module-boundaries.md)
- [Progress](progress.md)
- [Roadmap](roadmap.md)
- [Next Steps](next-steps.md)
- [Phase Manifest](ai-framework/phase-manifest.yaml)
- [Service Manifest](ai-framework/service-manifest.yaml)
- [AI / Enterprise Development Framework](ai-framework/README.md)

View File

@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
const UPSTREAM =
process.env.WORKSPACE_SERVICE_URL ||
process.env.NEXT_PUBLIC_WORKSPACE_API_URL ||
"http://localhost:8013";
async function proxy(req: NextRequest, pathSegments: string[]) {
const p = pathSegments.join("/");
const url = new URL(req.url);
const target = `${UPSTREAM.replace(/\/$/, "")}/${p}${url.search}`;
const headers = new Headers();
for (const h of ["authorization", "content-type", "x-tenant-id", "accept"]) {
const v = req.headers.get(h);
if (v) headers.set(h, v);
}
let body: ArrayBuffer | undefined;
if (req.method !== "GET" && req.method !== "HEAD") body = await req.arrayBuffer();
try {
const res = await fetch(target, { method: req.method, headers, body, cache: "no-store" });
const out = new Headers();
const ct = res.headers.get("content-type");
if (ct) out.set("content-type", ct);
return new NextResponse(await res.arrayBuffer(), { status: res.status, headers: out });
} catch (err) {
const detail = err instanceof Error ? err.message : "upstream_unreachable";
return NextResponse.json(
{ error: { code: "workspace_proxy_error", message: `پروکسی فضای کاری — ${detail}` } },
{ status: 502 }
);
}
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
const { path: p } = await ctx.params;
return proxy(req, p);
}
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
const { path: p } = await ctx.params;
return proxy(req, p);
}
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
const { path: p } = await ctx.params;
return proxy(req, p);
}
export async function PUT(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
const { path: p } = await ctx.params;
return proxy(req, p);
}
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
const { path: p } = await ctx.params;
return proxy(req, p);
}

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری فضای کاری…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { WorkspaceApprovals as default } from "@/modules/workspace/features/approvals";

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری فضای کاری…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { WorkspaceAssets as default } from "@/modules/workspace/features/assets";

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری فضای کاری…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { WorkspaceContentDetail as default } from "@/modules/workspace/features/contentDetail";

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری فضای کاری…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { WorkspaceContentBoard as default } from "@/modules/workspace/features/contentBoard";

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری فضای کاری…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { WorkspaceContentCalendar as default } from "@/modules/workspace/features/contentCalendar";

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری فضای کاری…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { WorkspaceContentStudio as default } from "@/modules/workspace/features/contentStudio";

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری فضای کاری…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { WorkspaceHub as default } from "@/modules/workspace/features/hub";

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری فضای کاری…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { WorkspaceHubDashboard as default } from "@/modules/workspace/features/hubDashboard";

Some files were not shown because too many files have changed in this diff Show More