feat(workspace): complete production workspace and content operations
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
208a9c2661
commit
a483b47369
@ -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
|
||||||
|
|
||||||
|
|||||||
8
backend/services/workspace/Dockerfile.dev
Normal file
8
backend/services/workspace/Dockerfile.dev
Normal 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
|
||||||
11
backend/services/workspace/README.md
Normal file
11
backend/services/workspace/README.md
Normal 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.
|
||||||
29
backend/services/workspace/alembic.ini
Normal file
29
backend/services/workspace/alembic.ini
Normal 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
|
||||||
33
backend/services/workspace/alembic/env.py
Normal file
33
backend/services/workspace/alembic/env.py
Normal 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()
|
||||||
21
backend/services/workspace/alembic/script.py.mako
Normal file
21
backend/services/workspace/alembic/script.py.mako
Normal 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"}
|
||||||
17
backend/services/workspace/alembic/versions/0001_initial.py
Normal file
17
backend/services/workspace/alembic/versions/0001_initial.py
Normal 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())
|
||||||
1
backend/services/workspace/app/__init__.py
Normal file
1
backend/services/workspace/app/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
__version__ = "0.15.0.0"
|
||||||
0
backend/services/workspace/app/api/__init__.py
Normal file
0
backend/services/workspace/app/api/__init__.py
Normal file
13
backend/services/workspace/app/api/deps.py
Normal file
13
backend/services/workspace/app/api/deps.py
Normal 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))
|
||||||
39
backend/services/workspace/app/api/permissions.py
Normal file
39
backend/services/workspace/app/api/permissions.py
Normal 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")
|
||||||
6
backend/services/workspace/app/api/v1/__init__.py
Normal file
6
backend/services/workspace/app/api/v1/__init__.py
Normal 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)
|
||||||
48
backend/services/workspace/app/api/v1/health.py
Normal file
48
backend/services/workspace/app/api/v1/health.py
Normal 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",
|
||||||
|
}
|
||||||
303
backend/services/workspace/app/api/v1/routes.py
Normal file
303
backend/services/workspace/app/api/v1/routes.py
Normal 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)
|
||||||
0
backend/services/workspace/app/commands/__init__.py
Normal file
0
backend/services/workspace/app/commands/__init__.py
Normal file
679
backend/services/workspace/app/commands/workspace.py
Normal file
679
backend/services/workspace/app/commands/workspace.py
Normal 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)
|
||||||
0
backend/services/workspace/app/core/__init__.py
Normal file
0
backend/services/workspace/app/core/__init__.py
Normal file
43
backend/services/workspace/app/core/config.py
Normal file
43
backend/services/workspace/app/core/config.py
Normal 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()
|
||||||
26
backend/services/workspace/app/core/database.py
Normal file
26
backend/services/workspace/app/core/database.py
Normal 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
|
||||||
16
backend/services/workspace/app/core/security.py
Normal file
16
backend/services/workspace/app/core/security.py
Normal 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=[])
|
||||||
0
backend/services/workspace/app/events/__init__.py
Normal file
0
backend/services/workspace/app/events/__init__.py
Normal file
18
backend/services/workspace/app/events/publisher.py
Normal file
18
backend/services/workspace/app/events/publisher.py
Normal 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
|
||||||
14
backend/services/workspace/app/events/types.py
Normal file
14
backend/services/workspace/app/events/types.py
Normal 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",
|
||||||
|
)
|
||||||
40
backend/services/workspace/app/main.py
Normal file
40
backend/services/workspace/app/main.py
Normal 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()
|
||||||
16
backend/services/workspace/app/middlewares/tenant.py
Normal file
16
backend/services/workspace/app/middlewares/tenant.py
Normal 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)
|
||||||
39
backend/services/workspace/app/models/__init__.py
Normal file
39
backend/services/workspace/app/models/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
27
backend/services/workspace/app/models/base.py
Normal file
27
backend/services/workspace/app/models/base.py
Normal 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))
|
||||||
206
backend/services/workspace/app/models/domain.py
Normal file
206
backend/services/workspace/app/models/domain.py
Normal 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))
|
||||||
23
backend/services/workspace/app/models/types.py
Normal file
23
backend/services/workspace/app/models/types.py
Normal 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))
|
||||||
52
backend/services/workspace/app/permissions/definitions.py
Normal file
52
backend/services/workspace/app/permissions/definitions.py
Normal 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",
|
||||||
|
],
|
||||||
|
}
|
||||||
0
backend/services/workspace/app/policies/__init__.py
Normal file
0
backend/services/workspace/app/policies/__init__.py
Normal file
76
backend/services/workspace/app/policies/workflow.py
Normal file
76
backend/services/workspace/app/policies/workflow.py
Normal 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())
|
||||||
3
backend/services/workspace/app/providers/__init__.py
Normal file
3
backend/services/workspace/app/providers/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from app.providers.contracts import FileStorageContract, NotificationContract
|
||||||
|
|
||||||
|
__all__ = ["FileStorageContract", "NotificationContract"]
|
||||||
29
backend/services/workspace/app/providers/contracts.py
Normal file
29
backend/services/workspace/app/providers/contracts.py
Normal 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")
|
||||||
0
backend/services/workspace/app/queries/__init__.py
Normal file
0
backend/services/workspace/app/queries/__init__.py
Normal file
146
backend/services/workspace/app/queries/workspace.py
Normal file
146
backend/services/workspace/app/queries/workspace.py
Normal 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]
|
||||||
25
backend/services/workspace/app/repositories/base.py
Normal file
25
backend/services/workspace/app/repositories/base.py
Normal 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()
|
||||||
0
backend/services/workspace/app/tests/__init__.py
Normal file
0
backend/services/workspace/app/tests/__init__.py
Normal file
36
backend/services/workspace/app/tests/conftest.py
Normal file
36
backend/services/workspace/app/tests/conftest.py
Normal 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()
|
||||||
14
backend/services/workspace/app/tests/test_architecture.py
Normal file
14
backend/services/workspace/app/tests/test_architecture.py
Normal 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()
|
||||||
271
backend/services/workspace/app/tests/test_domain.py
Normal file
271
backend/services/workspace/app/tests/test_domain.py
Normal 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
|
||||||
6
backend/services/workspace/app/tests/test_migration.py
Normal file
6
backend/services/workspace/app/tests/test_migration.py
Normal 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
|
||||||
38
backend/services/workspace/app/tests/test_permissions.py
Normal file
38
backend/services/workspace/app/tests/test_permissions.py
Normal 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",
|
||||||
|
]
|
||||||
32
backend/services/workspace/app/tests/test_security.py
Normal file
32
backend/services/workspace/app/tests/test_security.py
Normal 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"])
|
||||||
@ -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
|
||||||
2
backend/services/workspace/pytest.ini
Normal file
2
backend/services/workspace/pytest.ini
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
14
backend/services/workspace/requirements.txt
Normal file
14
backend/services/workspace/requirements.txt
Normal 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
|
||||||
28
backend/services/workspace/scripts/ensure_db.py
Normal file
28
backend/services/workspace/scripts/ensure_db.py
Normal 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()
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
99
docs/architecture/adr/ADR-024.md
Normal file
99
docs/architecture/adr/ADR-024.md
Normal 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)
|
||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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`.
|
||||||
|
|||||||
@ -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. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -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` |
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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**
|
||||||
|
|||||||
41
docs/phase-handover/phase-workspace-15-8.md
Normal file
41
docs/phase-handover/phase-workspace-15-8.md
Normal 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
|
||||||
149
docs/phase-handover/phase-workspace-reg.md
Normal file
149
docs/phase-handover/phase-workspace-reg.md
Normal 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)
|
||||||
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
141
docs/service-snapshots/workspace.yaml
Normal file
141
docs/service-snapshots/workspace.yaml
Normal 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
322
docs/workspace-roadmap.md
Normal 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)
|
||||||
53
frontend/app/api/workspace/[...path]/route.ts
Normal file
53
frontend/app/api/workspace/[...path]/route.ts
Normal 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);
|
||||||
|
}
|
||||||
5
frontend/app/workspace/approvals/error.tsx
Normal file
5
frontend/app/workspace/approvals/error.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
4
frontend/app/workspace/approvals/loading.tsx
Normal file
4
frontend/app/workspace/approvals/loading.tsx
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { LoadingState } from "@/components/ds";
|
||||||
|
export default function Loading() {
|
||||||
|
return <LoadingState label="در حال بارگذاری فضای کاری…" />;
|
||||||
|
}
|
||||||
2
frontend/app/workspace/approvals/page.tsx
Normal file
2
frontend/app/workspace/approvals/page.tsx
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
"use client";
|
||||||
|
export { WorkspaceApprovals as default } from "@/modules/workspace/features/approvals";
|
||||||
5
frontend/app/workspace/assets/error.tsx
Normal file
5
frontend/app/workspace/assets/error.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
4
frontend/app/workspace/assets/loading.tsx
Normal file
4
frontend/app/workspace/assets/loading.tsx
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { LoadingState } from "@/components/ds";
|
||||||
|
export default function Loading() {
|
||||||
|
return <LoadingState label="در حال بارگذاری فضای کاری…" />;
|
||||||
|
}
|
||||||
2
frontend/app/workspace/assets/page.tsx
Normal file
2
frontend/app/workspace/assets/page.tsx
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
"use client";
|
||||||
|
export { WorkspaceAssets as default } from "@/modules/workspace/features/assets";
|
||||||
5
frontend/app/workspace/content/[id]/error.tsx
Normal file
5
frontend/app/workspace/content/[id]/error.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
4
frontend/app/workspace/content/[id]/loading.tsx
Normal file
4
frontend/app/workspace/content/[id]/loading.tsx
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { LoadingState } from "@/components/ds";
|
||||||
|
export default function Loading() {
|
||||||
|
return <LoadingState label="در حال بارگذاری فضای کاری…" />;
|
||||||
|
}
|
||||||
2
frontend/app/workspace/content/[id]/page.tsx
Normal file
2
frontend/app/workspace/content/[id]/page.tsx
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
"use client";
|
||||||
|
export { WorkspaceContentDetail as default } from "@/modules/workspace/features/contentDetail";
|
||||||
5
frontend/app/workspace/content/board/error.tsx
Normal file
5
frontend/app/workspace/content/board/error.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
4
frontend/app/workspace/content/board/loading.tsx
Normal file
4
frontend/app/workspace/content/board/loading.tsx
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { LoadingState } from "@/components/ds";
|
||||||
|
export default function Loading() {
|
||||||
|
return <LoadingState label="در حال بارگذاری فضای کاری…" />;
|
||||||
|
}
|
||||||
2
frontend/app/workspace/content/board/page.tsx
Normal file
2
frontend/app/workspace/content/board/page.tsx
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
"use client";
|
||||||
|
export { WorkspaceContentBoard as default } from "@/modules/workspace/features/contentBoard";
|
||||||
5
frontend/app/workspace/content/calendar/error.tsx
Normal file
5
frontend/app/workspace/content/calendar/error.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
4
frontend/app/workspace/content/calendar/loading.tsx
Normal file
4
frontend/app/workspace/content/calendar/loading.tsx
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { LoadingState } from "@/components/ds";
|
||||||
|
export default function Loading() {
|
||||||
|
return <LoadingState label="در حال بارگذاری فضای کاری…" />;
|
||||||
|
}
|
||||||
2
frontend/app/workspace/content/calendar/page.tsx
Normal file
2
frontend/app/workspace/content/calendar/page.tsx
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
"use client";
|
||||||
|
export { WorkspaceContentCalendar as default } from "@/modules/workspace/features/contentCalendar";
|
||||||
5
frontend/app/workspace/content/error.tsx
Normal file
5
frontend/app/workspace/content/error.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
4
frontend/app/workspace/content/loading.tsx
Normal file
4
frontend/app/workspace/content/loading.tsx
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { LoadingState } from "@/components/ds";
|
||||||
|
export default function Loading() {
|
||||||
|
return <LoadingState label="در حال بارگذاری فضای کاری…" />;
|
||||||
|
}
|
||||||
2
frontend/app/workspace/content/page.tsx
Normal file
2
frontend/app/workspace/content/page.tsx
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
"use client";
|
||||||
|
export { WorkspaceContentStudio as default } from "@/modules/workspace/features/contentStudio";
|
||||||
5
frontend/app/workspace/error.tsx
Normal file
5
frontend/app/workspace/error.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
5
frontend/app/workspace/hub/error.tsx
Normal file
5
frontend/app/workspace/hub/error.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
4
frontend/app/workspace/hub/loading.tsx
Normal file
4
frontend/app/workspace/hub/loading.tsx
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { LoadingState } from "@/components/ds";
|
||||||
|
export default function Loading() {
|
||||||
|
return <LoadingState label="در حال بارگذاری فضای کاری…" />;
|
||||||
|
}
|
||||||
2
frontend/app/workspace/hub/page.tsx
Normal file
2
frontend/app/workspace/hub/page.tsx
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
"use client";
|
||||||
|
export { WorkspaceHub as default } from "@/modules/workspace/features/hub";
|
||||||
5
frontend/app/workspace/hubs/[id]/error.tsx
Normal file
5
frontend/app/workspace/hubs/[id]/error.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
4
frontend/app/workspace/hubs/[id]/loading.tsx
Normal file
4
frontend/app/workspace/hubs/[id]/loading.tsx
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { LoadingState } from "@/components/ds";
|
||||||
|
export default function Loading() {
|
||||||
|
return <LoadingState label="در حال بارگذاری فضای کاری…" />;
|
||||||
|
}
|
||||||
2
frontend/app/workspace/hubs/[id]/page.tsx
Normal file
2
frontend/app/workspace/hubs/[id]/page.tsx
Normal 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
Loading…
Reference in New Issue
Block a user