diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c72a0ff..2de6a02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ main ] + branches: [ main, develop ] pull_request: - branches: [ main ] + branches: [ main, develop ] jobs: lint: diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml new file mode 100644 index 0000000..229dea4 --- /dev/null +++ b/.github/workflows/tag.yml @@ -0,0 +1,42 @@ +# Whenever there is a pull request merged into the branches, create tag +name: Update Environment Tag +on: + pull_request: + types: [closed] + branches: + - develop + - staging + - production +jobs: + update-tag: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 1 + - name: Get the current branch name + id: get_branch + run: | + TARGET_BRANCH="${{ github.event.pull_request.base.ref }}" + + if [ "$TARGET_BRANCH" = "develop" ]; then + echo "ENV_TAG=dev" >> $GITHUB_ENV + elif [ "$TARGET_BRANCH" = "staging" ]; then + echo "ENV_TAG=stage" >> $GITHUB_ENV + elif [ "$TARGET_BRANCH" = "production" ]; then + echo "ENV_TAG=prod" >> $GITHUB_ENV + else + echo "ENV_TAG=" >> $GITHUB_ENV + fi + - name: Force update tag + if: ${{ env.ENV_TAG != '' }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + echo "Targeting tag: ${{ env.ENV_TAG }}" + git tag -f $ENV_TAG + git push origin $ENV_TAG --force diff --git a/.gitignore b/.gitignore index 42694c4..4516af1 100644 --- a/.gitignore +++ b/.gitignore @@ -163,4 +163,10 @@ alembic/versions/.DS_Store /workspaces-openstreetmap-website pg_user_cache.sqlite -.env** \ No newline at end of file +.env** +integration-report.html +docs/tasking-mvp/tasking-mvp.postman_collection.json +docs/tasking-mvp/tasking-mvp.postman_environment.json +docs/tasking-mvp/_enrich_postman.py +docs/tasking-mvp/feature-coverage.md +.idea/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..778fd59 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "ms-python.python", + "ms-python.vscode-pylance" + ] +} diff --git a/alembic_osm/versions/9221408912dd_add_user_role_table.py b/alembic_osm/versions/9221408912dd_add_user_role_table.py index d34f97e..7d7fa57 100644 --- a/alembic_osm/versions/9221408912dd_add_user_role_table.py +++ b/alembic_osm/versions/9221408912dd_add_user_role_table.py @@ -11,6 +11,7 @@ import sqlalchemy as sa from alembic import op from sqlalchemy import inspect, text +from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. revision: str = "9221408912dd" @@ -24,6 +25,25 @@ def upgrade() -> None: assert bind is not None insp = inspect(bind) + # `users` is normally owned and migrated by the OSM Rails app. When + # running against a fresh database without Rails (CI/testcontainers, + # or a dev box without the osm-rails service), create a minimal stub + # so the FK from `tasking_project_roles`/`user_workspace_roles` can + # be added below. Guarded by has_table so the production-owned + # schema wins when it is already present. + if not insp.has_table("users"): + op.create_table( + "users", + # `id` matches the Rails `users.id` numeric PK so the FK + # from `team_user.user_id` in the next migration can attach. + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("auth_uid", sa.String(), nullable=False), + sa.Column("email", sa.String(), nullable=True), + sa.Column("display_name", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("auth_uid", name="auth_uid_unique"), + ) + # Add unique constraint on users.auth_uid (if not already present) constraint_exists = bind.execute( text("SELECT 1 FROM pg_constraint WHERE conname = 'auth_uid_unique'") @@ -49,7 +69,7 @@ def upgrade() -> None: sa.Column("workspace_id", sa.BigInteger(), nullable=False), sa.Column( "role", - sa.Enum( + postgresql.ENUM( "lead", "validator", "contributor", diff --git a/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py b/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py new file mode 100644 index 0000000..068d28d --- /dev/null +++ b/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py @@ -0,0 +1,551 @@ +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect, text +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "a1b2c3d4e5f6" +down_revision: Union[str, None] = "9221408912dd" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +# Enum names + values, declared once and reused on up/down. +TASKING_PROJECT_STATUS = ("draft", "open", "done") +TASKING_TASK_STATUS = ("to_map", "to_review", "to_remap", "completed") +TASKING_TASK_BOUNDARY_TYPE = ("grid", "import") +TASKING_LOCK_RELEASE_REASON = ( + "auto_unlock", + "manual", + "lead_release", + "stale_timeout", + "reset", +) +TASKING_FEEDBACK_REASON = ( + "incomplete_mapping", + "data_quality_issue", + "wrong_area", + "other", +) + + +def _create_enum_if_absent(bind, name: str, values: tuple[str, ...]) -> None: + exists = bind.execute( + text("SELECT 1 FROM pg_type WHERE typname = :n"), {"n": name} + ).scalar() + if not exists: + sa.Enum(*values, name=name).create(bind) + + +def _drop_enum_if_present(bind, name: str) -> None: + exists = bind.execute( + text("SELECT 1 FROM pg_type WHERE typname = :n"), {"n": name} + ).scalar() + if exists: + bind.execute(text(f'DROP TYPE IF EXISTS "{name}"')) + + +def _assert_postgis_installed(bind) -> None: + """Require the postgis extension to be installed in this database.""" + installed = bool( + bind.execute( + text("SELECT 1 FROM pg_extension WHERE extname = 'postgis'") + ).scalar() + ) + if not installed: + raise RuntimeError( + "postgis extension is not installed in this database. " + "Run `CREATE EXTENSION IF NOT EXISTS postgis;` before migrations." + ) + + +def upgrade() -> None: + bind = op.get_bind() + assert bind is not None + insp = inspect(bind) + + _assert_postgis_installed(bind) + + # ---- teams / team_user ------------------------------------------- + # + # Created here so the OSM tree owns every table that references + # `users.id`. The `has_table` guards keep this idempotent in both + # production (shared TASK/OSM database) and fresh test installs. + + if not insp.has_table("teams"): + op.create_table( + "teams", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("workspace_id", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_teams_workspace_id", "teams", ["workspace_id"]) + + if not insp.has_table("team_user"): + op.create_table( + "team_user", + sa.Column("team_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["team_id"], ["teams.id"]), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.PrimaryKeyConstraint("team_id", "user_id"), + ) + + # ---- Enums -------------------------------------------------------- + + _create_enum_if_absent(bind, "tasking_project_status", TASKING_PROJECT_STATUS) + _create_enum_if_absent(bind, "tasking_task_status", TASKING_TASK_STATUS) + _create_enum_if_absent( + bind, "tasking_task_boundary_type", TASKING_TASK_BOUNDARY_TYPE + ) + _create_enum_if_absent( + bind, "tasking_lock_release_reason", TASKING_LOCK_RELEASE_REASON + ) + _create_enum_if_absent(bind, "tasking_feedback_reason", TASKING_FEEDBACK_REASON) + + # ---- tasking_projects -------------------------------------------- + + if not insp.has_table("tasking_projects"): + op.create_table( + "tasking_projects", + sa.Column( + "id", + sa.BigInteger(), + primary_key=True, + autoincrement=True, + ), + # Cross-DB ref to workspaces.id — no FK by design (matches + # user_workspace_roles convention). + sa.Column("workspace_id", sa.BigInteger(), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("instructions", sa.Text(), nullable=True), + sa.Column( + "status", + postgresql.ENUM( + *TASKING_PROJECT_STATUS, + name="tasking_project_status", + create_type=False, + ), + nullable=False, + server_default="draft", + ), + sa.Column( + "review_required", + sa.Boolean(), + nullable=False, + server_default=sa.true(), + ), + sa.Column( + "lock_timeout_hours", + sa.Integer(), + nullable=False, + server_default="8", + ), + sa.Column( + "task_boundary_type", + postgresql.ENUM( + *TASKING_TASK_BOUNDARY_TYPE, + name="tasking_task_boundary_type", + create_type=False, + ), + nullable=True, + ), + # AOI is MultiPolygon in EPSG:4326. Polygon inputs are + # upcast to single-member MultiPolygons in the app layer. + sa.Column( + "aoi", + sa.dialects.postgresql.BYTEA(), # placeholder; replaced below + nullable=True, + ), + sa.Column("created_by", sa.Uuid(), nullable=False), + sa.Column("created_by_name", sa.String(length=255), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + ) + + op.execute("ALTER TABLE tasking_projects DROP COLUMN aoi") + op.execute( + "ALTER TABLE tasking_projects " + "ADD COLUMN aoi GEOMETRY(MultiPolygon, 4326)" + ) + + # Unique project name per workspace among non-deleted rows. + op.execute( + "CREATE UNIQUE INDEX tasking_projects_workspace_name_unique " + "ON tasking_projects (workspace_id, lower(name)) " + "WHERE deleted_at IS NULL" + ) + + op.create_index( + "tasking_projects_workspace_idx", + "tasking_projects", + ["workspace_id"], + ) + op.create_index( + "tasking_projects_status_idx", + "tasking_projects", + ["status"], + ) + + # ---- tasking_project_roles --------------------------------------- + + if not insp.has_table("tasking_project_roles"): + op.create_table( + "tasking_project_roles", + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("user_auth_uid", sa.String(), nullable=False), + sa.Column( + "role", + postgresql.ENUM( + "lead", + "validator", + "contributor", + name="workspace_role", + create_type=False, + ), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint( + ["project_id"], ["tasking_projects.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["user_auth_uid"], ["users.auth_uid"]), + sa.PrimaryKeyConstraint("project_id", "user_auth_uid"), + ) + + # ---- tasking_tasks ------------------------------------------------ + + if not insp.has_table("tasking_tasks"): + op.create_table( + "tasking_tasks", + sa.Column( + "id", + sa.BigInteger(), + primary_key=True, + autoincrement=True, + ), + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("task_number", sa.Integer(), nullable=False), + sa.Column("area_sqkm", sa.Numeric(precision=10, scale=4), nullable=False), + sa.Column( + "status", + postgresql.ENUM( + *TASKING_TASK_STATUS, + name="tasking_task_status", + create_type=False, + ), + nullable=False, + server_default="to_map", + ), + sa.Column("last_mapper_id", sa.String(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint( + ["project_id"], ["tasking_projects.id"], ondelete="CASCADE" + ), + sa.UniqueConstraint( + "project_id", "task_number", name="tasking_tasks_pn_unique" + ), + ) + op.execute( + "ALTER TABLE tasking_tasks " + "ADD COLUMN geometry GEOMETRY(Polygon, 4326) NOT NULL" + ) + op.execute( + "CREATE INDEX tasking_tasks_geometry_idx " + "ON tasking_tasks USING GIST (geometry)" + ) + op.create_index("tasking_tasks_project_idx", "tasking_tasks", ["project_id"]) + + # ---- tasking_locks ------------------------------------------------ + + if not insp.has_table("tasking_locks"): + op.create_table( + "tasking_locks", + sa.Column( + "id", + sa.BigInteger(), + primary_key=True, + autoincrement=True, + ), + sa.Column("task_id", sa.BigInteger(), nullable=False), + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("user_auth_uid", sa.String(), nullable=False), + sa.Column( + "task_status_at_lock", + postgresql.ENUM( + *TASKING_TASK_STATUS, + name="tasking_task_status", + create_type=False, + ), + nullable=False, + ), + sa.Column( + "locked_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("released_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "release_reason", + postgresql.ENUM( + *TASKING_LOCK_RELEASE_REASON, + name="tasking_lock_release_reason", + create_type=False, + ), + nullable=True, + ), + sa.ForeignKeyConstraint( + ["task_id"], ["tasking_tasks.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["project_id"], ["tasking_projects.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["user_auth_uid"], ["users.auth_uid"]), + ) + # One active lock per task; one active lock per (project, user). + op.execute( + "CREATE UNIQUE INDEX tasking_locks_one_active_per_task " + "ON tasking_locks (task_id) WHERE released_at IS NULL" + ) + op.execute( + "CREATE UNIQUE INDEX tasking_locks_one_active_per_user_project " + "ON tasking_locks (project_id, user_auth_uid) " + "WHERE released_at IS NULL" + ) + op.execute( + "CREATE INDEX tasking_locks_expiry_idx " + "ON tasking_locks (expires_at) WHERE released_at IS NULL" + ) + + # ---- tasking_changesets ------------------------------------------ + + if not insp.has_table("tasking_changesets"): + op.create_table( + "tasking_changesets", + sa.Column( + "id", + sa.BigInteger(), + primary_key=True, + autoincrement=True, + ), + sa.Column("task_id", sa.BigInteger(), nullable=False), + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("lock_id", sa.BigInteger(), nullable=False), + sa.Column("user_auth_uid", sa.String(), nullable=False), + sa.Column("osm_changeset_id", sa.BigInteger(), nullable=False), + sa.Column( + "submitted_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint( + ["task_id"], ["tasking_tasks.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["project_id"], ["tasking_projects.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["lock_id"], ["tasking_locks.id"]), + sa.ForeignKeyConstraint(["user_auth_uid"], ["users.auth_uid"]), + ) + op.create_index( + "tasking_changesets_task_idx", "tasking_changesets", ["task_id"] + ) + + # ---- tasking_feedback -------------------------------------------- + # + # Generic per-task feedback table. Covers validator remap rejections + # (originally the only use case) plus any other free-form notes a + # contributor / validator / lead may attach to a task — approval + # comments, follow-up reminders, etc. + # + # `reason_category` is nullable: required only when the feedback is + # used to drive a `to_review → to_remap` transition; left NULL for + # generic notes. `notes` is required so a row always has at least + # one of (category, free text) — usually both. + + if not insp.has_table("tasking_feedback"): + op.create_table( + "tasking_feedback", + sa.Column( + "id", + sa.BigInteger(), + primary_key=True, + autoincrement=True, + ), + sa.Column("task_id", sa.BigInteger(), nullable=False), + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("author_user_auth_uid", sa.String(), nullable=False), + sa.Column( + "reason_category", + postgresql.ENUM( + *TASKING_FEEDBACK_REASON, + name="tasking_feedback_reason", + create_type=False, + ), + nullable=True, + ), + sa.Column("notes", sa.Text(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint( + ["task_id"], ["tasking_tasks.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["project_id"], ["tasking_projects.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["author_user_auth_uid"], ["users.auth_uid"]), + ) + op.create_index("tasking_feedback_task_idx", "tasking_feedback", ["task_id"]) + op.create_index( + "tasking_feedback_project_idx", "tasking_feedback", ["project_id"] + ) + + # ---- tasking_audit_events ---------------------------------------- + + if not insp.has_table("tasking_audit_events"): + op.create_table( + "tasking_audit_events", + sa.Column( + "id", + sa.BigInteger(), + primary_key=True, + autoincrement=True, + ), + sa.Column("event_type", sa.String(length=64), nullable=False), + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("task_id", sa.BigInteger(), nullable=True), + sa.Column("actor_user_auth_uid", sa.Uuid(), nullable=False), + sa.Column( + "occurred_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "details", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'{}'::jsonb"), + ), + sa.Column( + "project_deleted", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + # No FK to tasking_projects so audit survives the + # project's hard-delete of children + soft-delete itself. + ) + op.create_index( + "tasking_audit_project_idx", "tasking_audit_events", ["project_id"] + ) + op.create_index( + "tasking_audit_project_task_idx", + "tasking_audit_events", + ["project_id", "task_id"], + ) + op.create_index( + "tasking_audit_occurred_idx", + "tasking_audit_events", + ["occurred_at"], + ) + + # ---- tasking_task_save_idempotency ------------------------------- + + if not insp.has_table("tasking_task_save_idempotency"): + op.create_table( + "tasking_task_save_idempotency", + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("key", sa.String(length=128), nullable=False), + sa.Column("body_hash", sa.String(length=128), nullable=False), + sa.Column( + "response_json", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint( + ["project_id"], ["tasking_projects.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("project_id", "key"), + ) + op.create_index( + "tasking_task_save_idempotency_created_idx", + "tasking_task_save_idempotency", + ["created_at"], + ) + + +def downgrade() -> None: + bind = op.get_bind() + assert bind is not None + insp = inspect(bind) + + # Drop tables in reverse FK order. + for table in ( + "tasking_task_save_idempotency", + "tasking_audit_events", + "tasking_feedback", + "tasking_changesets", + "tasking_locks", + "tasking_tasks", + "tasking_project_roles", + "tasking_projects", + "team_user", + "teams", + ): + if insp.has_table(table): + op.drop_table(table) + + # Drop tasking-specific enums (workspace_role is owned by an earlier + # revision and stays). + for enum_name in ( + "tasking_feedback_reason", + "tasking_lock_release_reason", + "tasking_task_boundary_type", + "tasking_task_status", + "tasking_project_status", + ): + _drop_enum_if_present(bind, enum_name) diff --git a/alembic_task/versions/add6266277c7_.py b/alembic_task/versions/add6266277c7_.py index ed80db5..cd27884 100644 --- a/alembic_task/versions/add6266277c7_.py +++ b/alembic_task/versions/add6266277c7_.py @@ -13,7 +13,7 @@ revision = "add6266277c7" branch_labels = None depends_on = None -down_revision = None +down_revision = "c5121cbba124" def upgrade(): diff --git a/alembic_task/versions/c5121cbba124_initial_task_schema.py b/alembic_task/versions/c5121cbba124_initial_task_schema.py new file mode 100644 index 0000000..a112007 --- /dev/null +++ b/alembic_task/versions/c5121cbba124_initial_task_schema.py @@ -0,0 +1,112 @@ +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from geoalchemy2 import Geometry +from sqlalchemy import inspect, text +from sqlalchemy.dialects import postgresql + +revision: str = "c5121cbba124" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _assert_postgis_installed(bind) -> None: + """Require the postgis extension to be installed in this database.""" + installed = bool( + bind.execute( + text("SELECT 1 FROM pg_extension WHERE extname = 'postgis'") + ).scalar() + ) + if not installed: + raise RuntimeError( + "postgis extension is not installed in this database. " + "Run `CREATE EXTENSION IF NOT EXISTS postgis;` before migrations." + ) + + +def upgrade() -> None: + bind = op.get_bind() + assert bind is not None + insp = inspect(bind) + + _assert_postgis_installed(bind) + + geometry_column = sa.Column( + "geometry", + Geometry(geometry_type="MULTIPOLYGON", srid=4326), + nullable=True, + ) + + # The TASK tree owns `workspaces` and `workspaces_*` only. + # `users`, `teams`, `team_user`, `user_workspace_roles`, and the + # `tasking_*` tables are owned by the OSM tree. + + if not insp.has_table("workspaces"): + op.create_table( + "workspaces", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("type", sa.String(), nullable=False), + sa.Column("title", sa.String(), nullable=False), + sa.Column("description", sa.String(), nullable=True), + sa.Column("tdeiProjectGroupId", sa.Uuid(), nullable=False), + sa.Column("tdeiRecordId", sa.Uuid(), nullable=True), + sa.Column("tdeiServiceId", sa.Uuid(), nullable=True), + sa.Column( + "tdeiMetadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True + ), + sa.Column("createdAt", sa.DateTime(), nullable=False), + sa.Column("createdBy", sa.Uuid(), nullable=False), + sa.Column("createdByName", sa.String(), nullable=False), + geometry_column, + sa.Column( + "externalAppAccess", + sa.SmallInteger(), + nullable=False, + server_default="0", + ), + sa.Column("kartaViewToken", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + + if not insp.has_table("workspaces_long_quests"): + op.create_table( + "workspaces_long_quests", + sa.Column("workspace_id", sa.Integer(), nullable=False), + sa.Column("definition", sa.String(), nullable=True), + sa.Column("modifiedAt", sa.DateTime(), nullable=False), + sa.Column("modifiedBy", sa.Uuid(), nullable=False), + sa.Column("modifiedByName", sa.String(), nullable=False), + sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"]), + sa.PrimaryKeyConstraint("workspace_id"), + ) + + if not insp.has_table("workspaces_imagery"): + op.create_table( + "workspaces_imagery", + sa.Column("workspace_id", sa.Integer(), nullable=False), + sa.Column( + "definition", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + ), + sa.Column("modifiedAt", sa.DateTime(), nullable=False), + sa.Column("modifiedBy", sa.Uuid(), nullable=False), + sa.Column("modifiedByName", sa.String(), nullable=False), + sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"]), + sa.PrimaryKeyConstraint("workspace_id"), + ) + + +def downgrade() -> None: + bind = op.get_bind() + assert bind is not None + insp = inspect(bind) + + if insp.has_table("workspaces_imagery"): + op.drop_table("workspaces_imagery") + if insp.has_table("workspaces_long_quests"): + op.drop_table("workspaces_long_quests") + if insp.has_table("workspaces"): + op.drop_table("workspaces") diff --git a/api/core/security.py b/api/core/security.py index c283af4..6a8f350 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -43,6 +43,104 @@ async def close_tdei_client() -> None: _tdei_client = None +class TdeiProjectGroupUser: + """One member of a TDEI project group, as returned by + ``GET /project-group/{pg_id}/users``. + + Field names are normalised here because the upstream JSON shape varies + across deployments (`user_id` vs `id`, `username` vs `display_name`). + """ + + def __init__( + self, + *, + auth_uid: str, + email: str | None, + display_name: str | None, + ) -> None: + self.auth_uid = auth_uid + self.email = email + self.display_name = display_name + + +async def fetch_project_group_users( + project_group_id: str, + bearer_token: str, +) -> list[TdeiProjectGroupUser]: + """Page through ``GET /project-group/{pg_id}/users`` on TDEI. + + Returns every member of the project group. The endpoint is paginated + server-side; we fetch all pages so the caller can look up an + arbitrary user UUID without guessing page numbers. Raises 502 if + TDEI is unreachable, 401 if the token is rejected. + """ + if _tdei_client is None: # pragma: no cover — lifespan should init this + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="TDEI client is not initialised", + ) + + headers = {"Authorization": f"Bearer {bearer_token}"} + page_no = 1 + page_size = 200 + out: list[TdeiProjectGroupUser] = [] + while True: + try: + response = await _tdei_client.get( + f"project-group/{project_group_id}/users", + headers=headers, + params={"page_no": page_no, "page_size": page_size}, + ) + except httpx.RequestError: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Could not reach TDEI backend to fetch project group users", + ) from None + + if response.status_code == 401: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="TDEI rejected the bearer token", + ) + if response.status_code != 200: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=( + f"TDEI returned {response.status_code} when listing " + f"users for project group {project_group_id}" + ), + ) + + try: + page = response.json() + except Exception: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="TDEI returned a non-JSON body", + ) from None + + if not isinstance(page, list) or not page: + break + + for row in page: + uid = row.get("user_id") + if not uid: + continue + out.append( + TdeiProjectGroupUser( + auth_uid=str(uid), + email=row.get("email"), + display_name=(row.get("username")), + ) + ) + + if len(page) < page_size: + break + page_no += 1 + + return out + + def evict_user_from_cache(auth_uid: UUID) -> None: """ Evict a user's cached UserInfo object so that their next request re-fetches diff --git a/api/main.py b/api/main.py index dd82fcc..fcff505 100644 --- a/api/main.py +++ b/api/main.py @@ -22,6 +22,10 @@ init_tdei_client, validate_token, ) +from api.src.tasking.audit.routes import router as tasking_audit_router +from api.src.tasking.projects.routes import me_router as tasking_me_router +from api.src.tasking.projects.routes import router as tasking_projects_router +from api.src.tasking.tasks.routes import router as tasking_tasks_router from api.src.teams.routes import router as teams_router from api.src.users.routes import router as users_router from api.src.workspaces.repository import WorkspaceRepository @@ -91,6 +95,10 @@ async def lifespan(_app: FastAPI): app.include_router(teams_router, prefix="/api/v1") app.include_router(users_router, prefix="/api/v1") app.include_router(workspaces_router, prefix="/api/v1") +app.include_router(tasking_projects_router, prefix="/api/v1") +app.include_router(tasking_me_router, prefix="/api/v1") +app.include_router(tasking_tasks_router, prefix="/api/v1") +app.include_router(tasking_audit_router, prefix="/api/v1") @app.get("/health") @@ -208,6 +216,20 @@ async def catch_all( Catch-all route to proxy requests to the OSM service. """ + # `/api/v1/...` paths belong to the FastAPI routers (workspaces, + # users, teams, tasking-projects, tasking-tasks). If none matched, + # the URL is wrong or the method is unsupported — surface that as + # a clean 404 instead of letting the OSM proxy swallow it with a + # misleading "No X-Workspace header supplied". + if request.url.path.startswith("/api/v1/"): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=( + f"No handler for {request.method} {request.url.path}. " + "Check the method and path; this URL is not proxied to OSM." + ), + ) + if request.headers.get("X-Workspace") is not None: try: workspace_id = int(request.headers.get("X-Workspace") or "-1") diff --git a/api/src/tasking/__init__.py b/api/src/tasking/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/tasking/audit/__init__.py b/api/src/tasking/audit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/tasking/audit/dtos.py b/api/src/tasking/audit/dtos.py new file mode 100644 index 0000000..c8003de --- /dev/null +++ b/api/src/tasking/audit/dtos.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Optional +from uuid import UUID + +from api.src.tasking.audit.schemas import AuditEventType +from api.src.tasking.projects.dtos import Pagination, WireModel + + +class ActorRef(WireModel): + """Resolved actor for an audit event.""" + + user_id: UUID + display_name: Optional[str] = None + + +class AuditEvent(WireModel): + """One row in `tasking_audit_events`, with `actor` joined for display.""" + + id: int + event_type: AuditEventType + project_id: int + actor: ActorRef + occurred_at: datetime + details: dict[str, Any] + project_deleted: bool = False + + +class AuditEventListResponse(WireModel): + results: list[AuditEvent] + pagination: Pagination + + +__all__ = [ + "ActorRef", + "AuditEvent", + "AuditEventListResponse", +] diff --git a/api/src/tasking/audit/repository.py b/api/src/tasking/audit/repository.py new file mode 100644 index 0000000..0234f0f --- /dev/null +++ b/api/src/tasking/audit/repository.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional +from uuid import UUID + +from sqlalchemy import text +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.exceptions import NotFoundException +from api.src.tasking.audit.dtos import ActorRef, AuditEvent, AuditEventListResponse +from api.src.tasking.audit.schemas import AuditEventType +from api.src.tasking.projects.dtos import Pagination + +_ALLOWED_ORDER_DIR = {"ASC", "DESC"} + + +def _normalise_dir(order_dir: str) -> str: + return order_dir.upper() if order_dir.upper() in _ALLOWED_ORDER_DIR else "DESC" + + +def _clamp_page(page: int, page_size: int, max_page_size: int) -> tuple[int, int]: + return max(page, 1), max(min(page_size, max_page_size), 1) + + +class TaskingAuditRepository: + """Read-side queries for `tasking_audit_events`. + + Writes are owned by the task and project repositories — see + `_audit()` in `api/src/tasking/tasks/repository.py`. This module + only reads. + """ + + def __init__(self, session: AsyncSession): + self.session = session + + # ---- internal helpers ------------------------------------------------- + + async def _assert_project_visible( + self, workspace_id: int, project_id: int, *, include_deleted: bool + ) -> None: + """Confirm the project lives in the workspace; honour soft-delete + unless the caller explicitly opted into deleted projects. + """ + clause = ( + "SELECT 1 FROM tasking_projects " "WHERE id = :pid AND workspace_id = :wid" + ) + if not include_deleted: + clause += " AND deleted_at IS NULL" + + result = await self.session.execute( + text(clause), {"pid": project_id, "wid": workspace_id} + ) + if result.scalar() is None: + raise NotFoundException(f"Project {project_id} not found") + + async def _task_id_from_number(self, project_id: int, task_number: int) -> int: + result = await self.session.execute( + text( + "SELECT id FROM tasking_tasks " + "WHERE project_id = :pid AND task_number = :tn" + ), + {"pid": project_id, "tn": task_number}, + ) + task_id = result.scalar() + if task_id is None: + raise NotFoundException( + f"Task {task_number} not found on project {project_id}" + ) + return int(task_id) + + async def _resolve_actor_names( + self, auth_uids: set[str] + ) -> dict[str, Optional[str]]: + """Batch-load `users.display_name` for every distinct actor. + + Avoids the N+1 fetch when rendering long event lists. UIDs with + no `users` row map to None (the field is optional). + """ + if not auth_uids: + return {} + rows = await self.session.execute( + text( + "SELECT auth_uid, display_name FROM users " + "WHERE auth_uid = ANY(:uids)" + ), + {"uids": list(auth_uids)}, + ) + mapping = {row[0]: row[1] for row in rows.all()} + # Unknown actors still need a None entry so callers can `.get(uid)`. + for uid in auth_uids: + mapping.setdefault(uid, None) + return mapping + + @staticmethod + def _row_to_event( + row, + names: dict[str, Optional[str]], + ) -> AuditEvent: + ( + event_id, + event_type, + project_id, + task_id, + actor_uid, + occurred_at, + details, + project_deleted, + ) = row + details = details or {} + return AuditEvent( + id=event_id, + event_type=AuditEventType(event_type), + project_id=project_id, + actor=ActorRef( + user_id=UUID(str(actor_uid)), + display_name=names.get(str(actor_uid)), + ), + occurred_at=occurred_at, + details=details, + project_deleted=bool(project_deleted), + ) + + # ---- listing queries -------------------------------------------------- + + async def list_project_events( + self, + workspace_id: int, + project_id: int, + *, + event_type: Optional[AuditEventType] = None, + task_number: Optional[int] = None, + actor_user_id: Optional[UUID] = None, + occurred_from: Optional[datetime] = None, + occurred_to: Optional[datetime] = None, + include_deleted: bool = False, + page: int = 1, + page_size: int = 50, + order_dir: str = "DESC", + ) -> AuditEventListResponse: + await self._assert_project_visible( + workspace_id, project_id, include_deleted=include_deleted + ) + + page, page_size = _clamp_page(page, page_size, max_page_size=200) + order_dir = _normalise_dir(order_dir) + + # `task_number` is stored inside `details` JSONB rather than as a + # column, so filter via the typed accessor. + where = ["project_id = :pid"] + params: dict = {"pid": project_id} + if event_type is not None: + where.append("event_type = :et") + params["et"] = event_type.value + if task_number is not None: + where.append("(details->>'task_number')::int = :tn") + params["tn"] = task_number + if actor_user_id is not None: + where.append("actor_user_auth_uid = :au") + params["au"] = str(actor_user_id) + if occurred_from is not None: + where.append("occurred_at >= :from_ts") + params["from_ts"] = occurred_from + if occurred_to is not None: + where.append("occurred_at <= :to_ts") + params["to_ts"] = occurred_to + where_sql = " AND ".join(where) + + total_q = await self.session.execute( + text(f"SELECT COUNT(*) FROM tasking_audit_events WHERE {where_sql}"), + params, + ) + total = int(total_q.scalar() or 0) + + rows = await self.session.execute( + text( + "SELECT id, event_type, project_id, task_id, " + " actor_user_auth_uid, occurred_at, details, project_deleted " + "FROM tasking_audit_events " + f"WHERE {where_sql} " + f"ORDER BY occurred_at {order_dir}, id {order_dir} " + "LIMIT :limit OFFSET :offset" + ), + {**params, "limit": page_size, "offset": (page - 1) * page_size}, + ) + raw_rows = list(rows.all()) + actor_uids = {str(r[4]) for r in raw_rows} + names = await self._resolve_actor_names(actor_uids) + + return AuditEventListResponse( + results=[self._row_to_event(r, names) for r in raw_rows], + pagination=Pagination(page=page, page_size=page_size, total=total), + ) + + async def list_task_events( + self, + workspace_id: int, + project_id: int, + task_number: int, + *, + event_type: Optional[AuditEventType] = None, + actor_user_id: Optional[UUID] = None, + occurred_from: Optional[datetime] = None, + occurred_to: Optional[datetime] = None, + page: int = 1, + page_size: int = 25, + order_dir: str = "DESC", + ) -> AuditEventListResponse: + # Task-level listings only ever surface live projects. + await self._assert_project_visible( + workspace_id, project_id, include_deleted=False + ) + task_id = await self._task_id_from_number(project_id, task_number) + + page, page_size = _clamp_page(page, page_size, max_page_size=200) + order_dir = _normalise_dir(order_dir) + + # Match either `task_id = :tid` or the `task_number` in `details` + # — early audit rows for task creation set task_id but later + # rows that reference a task (e.g. project-level lock-extensions) + # may only persist the task_number. Both forms point at the + # same task, so OR them. + where = [ + "project_id = :pid", + "(task_id = :tid OR (details->>'task_number')::int = :tn)", + ] + params: dict = {"pid": project_id, "tid": task_id, "tn": task_number} + if event_type is not None: + where.append("event_type = :et") + params["et"] = event_type.value + if actor_user_id is not None: + where.append("actor_user_auth_uid = :au") + params["au"] = str(actor_user_id) + if occurred_from is not None: + where.append("occurred_at >= :from_ts") + params["from_ts"] = occurred_from + if occurred_to is not None: + where.append("occurred_at <= :to_ts") + params["to_ts"] = occurred_to + where_sql = " AND ".join(where) + + total_q = await self.session.execute( + text(f"SELECT COUNT(*) FROM tasking_audit_events WHERE {where_sql}"), + params, + ) + total = int(total_q.scalar() or 0) + + rows = await self.session.execute( + text( + "SELECT id, event_type, project_id, task_id, " + " actor_user_auth_uid, occurred_at, details, project_deleted " + "FROM tasking_audit_events " + f"WHERE {where_sql} " + f"ORDER BY occurred_at {order_dir}, id {order_dir} " + "LIMIT :limit OFFSET :offset" + ), + {**params, "limit": page_size, "offset": (page - 1) * page_size}, + ) + raw_rows = list(rows.all()) + actor_uids = {str(r[4]) for r in raw_rows} + names = await self._resolve_actor_names(actor_uids) + + return AuditEventListResponse( + results=[self._row_to_event(r, names) for r in raw_rows], + pagination=Pagination(page=page, page_size=page_size, total=total), + ) + + +__all__ = ["TaskingAuditRepository"] diff --git a/api/src/tasking/audit/routes.py b/api/src/tasking/audit/routes.py new file mode 100644 index 0000000..e6ab06f --- /dev/null +++ b/api/src/tasking/audit/routes.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status # noqa: F401 +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.database import get_osm_session, get_task_session +from api.core.security import UserInfo, validate_token +from api.src.tasking.audit.dtos import AuditEventListResponse +from api.src.tasking.audit.repository import TaskingAuditRepository +from api.src.tasking.audit.schemas import AuditEventType +from api.src.workspaces.repository import WorkspaceRepository + +router = APIRouter( + prefix="/workspaces/{workspace_id}/tasking/projects", + tags=["tasking-audit"], +) + + +# --------------------------------------------------------------------------- +# Dependencies +# --------------------------------------------------------------------------- + + +def get_audit_repo( + session: AsyncSession = Depends(get_osm_session), +) -> TaskingAuditRepository: + return TaskingAuditRepository(session) + + +def get_workspace_repo( + session: AsyncSession = Depends(get_task_session), +) -> WorkspaceRepository: + return WorkspaceRepository(session) + + +async def assert_workspace_visible( + workspace_id: int, + current_user: UserInfo, + workspace_repo: WorkspaceRepository, +) -> None: + """Tenancy gate: 404 if the caller's project groups don't own the + workspace (matches `WorkspaceRepository.getById`'s convention). + """ + await workspace_repo.getById(current_user, workspace_id) + + +# --------------------------------------------------------------------------- +# Project-level audit +# --------------------------------------------------------------------------- + + +@router.get( + "/{project_id}/audit", + response_model=AuditEventListResponse, +) +async def list_project_audit( + workspace_id: int, + project_id: int, + event_type: Optional[AuditEventType] = Query(default=None), + task_number: Optional[int] = Query(default=None, ge=1), + actor_user_id: Optional[UUID] = Query(default=None), + occurred_from: Optional[datetime] = Query(default=None), + occurred_to: Optional[datetime] = Query(default=None), + include_deleted: bool = Query(default=False), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=50, ge=1, le=200), + order_by_type: str = Query(default="DESC"), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + audit_repo: TaskingAuditRepository = Depends(get_audit_repo), +): + """Paginated project-level audit listing, newest first.""" + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await audit_repo.list_project_events( + workspace_id, + project_id, + event_type=event_type, + task_number=task_number, + actor_user_id=actor_user_id, + occurred_from=occurred_from, + occurred_to=occurred_to, + include_deleted=include_deleted, + page=page, + page_size=page_size, + order_dir=order_by_type, + ) + + +# --------------------------------------------------------------------------- +# Task-level audit +# --------------------------------------------------------------------------- + + +@router.get( + "/{project_id}/tasks/{task_number}/audit", + response_model=AuditEventListResponse, +) +async def list_task_audit( + workspace_id: int, + project_id: int, + task_number: int, + event_type: Optional[AuditEventType] = Query(default=None), + actor_user_id: Optional[UUID] = Query(default=None), + occurred_from: Optional[datetime] = Query(default=None), + occurred_to: Optional[datetime] = Query(default=None), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=25, ge=1, le=200), + order_by_type: str = Query(default="DESC"), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + audit_repo: TaskingAuditRepository = Depends(get_audit_repo), +): + """Paginated audit listing for a single task, newest first.""" + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await audit_repo.list_task_events( + workspace_id, + project_id, + task_number, + event_type=event_type, + actor_user_id=actor_user_id, + occurred_from=occurred_from, + occurred_to=occurred_to, + page=page, + page_size=page_size, + order_dir=order_by_type, + ) + + +__all__ = ["router"] diff --git a/api/src/tasking/audit/schemas.py b/api/src/tasking/audit/schemas.py new file mode 100644 index 0000000..9dc27be --- /dev/null +++ b/api/src/tasking/audit/schemas.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from typing import Any, Optional +from uuid import UUID + +from sqlalchemy import Column +from sqlalchemy.dialects.postgresql import JSONB +from sqlmodel import Field, SQLModel + + +class AuditEventType(StrEnum): + """Closed set of event types written to `tasking_audit_events`. + + Mirrors the spec's `AuditEventType` enum; values match the literal + strings persisted in the `event_type` column. + """ + + PROJECT_CREATED = "project_created" + PROJECT_ACTIVATED = "project_activated" + PROJECT_CLOSED = "project_closed" + PROJECT_EDITED = "project_edited" + PROJECT_DELETED = "project_deleted" + PROJECT_RESET = "project_reset" + AOI_UPLOADED = "aoi_uploaded" + AOI_DELETED = "aoi_deleted" + TASKS_CREATED = "tasks_created" + TASK_STATE_CHANGED = "task_state_changed" + TASK_LOCKED = "task_locked" + TASK_LOCK_EXTENDED = "task_lock_extended" + TASK_LOCK_RENEWED = "task_lock_renewed" + TASK_UNLOCKED = "task_unlocked" + TASK_RESET = "task_reset" + CHANGESET_SUBMITTED = "changeset_submitted" + FEEDBACK_SUBMITTED = "feedback_submitted" + + +class TaskingAuditEvent(SQLModel, table=True): + """Read-only mirror of `tasking_audit_events`. + + Writes happen via raw SQL in the task/project repositories so the + insert path stays decoupled from this module. The table is FK-free + by design (it must survive a project soft-delete + child hard-delete). + """ + + __tablename__ = "tasking_audit_events" # type: ignore[assignment] + + id: Optional[int] = Field(default=None, primary_key=True) + event_type: str = Field(max_length=64, nullable=False) + project_id: int = Field(nullable=False, index=True) + task_id: Optional[int] = Field(default=None, nullable=True) + actor_user_auth_uid: UUID = Field(nullable=False) + occurred_at: datetime = Field(nullable=False) + details: dict[str, Any] = Field( + default_factory=dict, + sa_column=Column(JSONB, nullable=False), + ) + project_deleted: bool = Field(default=False, nullable=False) + + +__all__ = ["AuditEventType", "TaskingAuditEvent"] diff --git a/api/src/tasking/projects/__init__.py b/api/src/tasking/projects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/tasking/projects/dtos.py b/api/src/tasking/projects/dtos.py new file mode 100644 index 0000000..488e981 --- /dev/null +++ b/api/src/tasking/projects/dtos.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal, Optional +from uuid import UUID + +from pydantic import BaseModel, ConfigDict +from pydantic import Field as PydField +from pydantic import field_validator + +from api.src.tasking.projects.schemas import ( + AoiInput, + ProjectRoleType, + ProjectStatus, + TaskBoundaryType, + _MultiPolygon, +) + +# --------------------------------------------------------------------------- +# Shared DTO base. +# --------------------------------------------------------------------------- + + +class WireModel(BaseModel): + + model_config = ConfigDict(extra="forbid") + + +# --------------------------------------------------------------------------- +# Project DTOs +# --------------------------------------------------------------------------- + + +class ProjectRoleAssignment(WireModel): + """Seed entry for `tasking_project_roles` at create time.""" + + user_id: UUID + role: Literal["lead", "validator", "contributor"] + + +class ProjectCreateRequest(WireModel): + """Body for `POST /workspaces/{wid}/tasking/projects`.""" + + name: str = PydField(min_length=1, max_length=255) + instructions: Optional[str] = PydField(default=None, max_length=10_000) + review_required: bool = True + lock_timeout_hours: int = PydField(default=8, ge=1, le=720) + aoi: Optional[AoiInput] = None + role_assignments: list[ProjectRoleAssignment] = PydField(default_factory=list) + + @field_validator("name") + @classmethod + def _name_not_blank(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("name cannot be blank") + return v + + +class ProjectUpdateRequest(WireModel): + """Body for `PATCH /workspaces/{wid}/tasking/projects/{pid}`. + + All fields optional; per-status mutability is enforced in the + repository layer. + """ + + name: Optional[str] = PydField(default=None, min_length=1, max_length=255) + instructions: Optional[str] = PydField(default=None, max_length=10_000) + lock_timeout_hours: Optional[int] = PydField(default=None, ge=1, le=720) + review_required: Optional[bool] = None + + +class ProjectResponse(WireModel): + """Project detail returned by GET/POST/PATCH/activate/close/reset.""" + + id: int + workspace_id: int + name: str + instructions: Optional[str] = None + status: ProjectStatus + review_required: bool + lock_timeout_hours: int + task_boundary_type: Optional[TaskBoundaryType] = None + has_aoi: bool + task_count: int + created_by: UUID + created_by_name: Optional[str] = None + created_at: datetime + updated_at: datetime + + +class ProjectListItem(WireModel): + """Row for `GET /workspaces/{wid}/tasking/projects`.""" + + id: int + name: str + status: ProjectStatus + task_count: int + percent_completed: int + created_by: UUID + created_by_name: Optional[str] = None + created_at: datetime + updated_at: datetime + + +class Pagination(WireModel): + page: int + page_size: int + total: int + + +class ProjectListResponse(WireModel): + results: list[ProjectListItem] + pagination: Pagination + + +# --------------------------------------------------------------------------- +# AOI response shape (canonical Feature wrapping a MultiPolygon) +# --------------------------------------------------------------------------- + + +class AoiFeature(WireModel): + type: Literal["Feature"] = "Feature" + geometry: _MultiPolygon + properties: dict[str, Any] = PydField(default_factory=dict) + + +# --------------------------------------------------------------------------- +# Project-role management DTOs +# --------------------------------------------------------------------------- + + +class ProjectRoleItem(WireModel): + """One row of `GET /projects/{pid}/roles`.""" + + user_id: UUID + user_name: Optional[str] = None + role: ProjectRoleType + updated_at: datetime + + +class ProjectRoleListResponse(WireModel): + """Paginated-but-flat list of role assignments for a project.""" + + results: list[ProjectRoleItem] + + +class ProjectRoleAddRequest(WireModel): + """Body for `POST /projects/{pid}/roles`.""" + + user_id: UUID + role: ProjectRoleType + + +class ProjectRoleUpdateRequest(WireModel): + """Body for `PATCH /projects/{pid}/roles/{user_id}`.""" + + role: ProjectRoleType + + +# --------------------------------------------------------------------------- +# Self project-roles — `GET /me/workspaces/{wid}/tasking/projects/roles`. +# One row per project in the workspace with the caller's effective role on +# that project: explicit `tasking_project_roles` row if present, else the +# workspace-level role (which is the implicit fallback). +# --------------------------------------------------------------------------- + + +class SelfProjectRoleItem(WireModel): + project_id: int + project_name: str + project_status: ProjectStatus + role: ProjectRoleType + + +class SelfProjectRolesResponse(WireModel): + workspace_role: ProjectRoleType + projects: list[SelfProjectRoleItem] + + +__all__ = [ + "AoiFeature", + "Pagination", + "ProjectCreateRequest", + "ProjectListItem", + "ProjectListResponse", + "ProjectResponse", + "ProjectRoleAddRequest", + "ProjectRoleAssignment", + "ProjectRoleItem", + "ProjectRoleListResponse", + "ProjectRoleUpdateRequest", + "ProjectUpdateRequest", + "SelfProjectRoleItem", + "SelfProjectRolesResponse", + "WireModel", +] diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py new file mode 100644 index 0000000..eaa81eb --- /dev/null +++ b/api/src/tasking/projects/repository.py @@ -0,0 +1,1323 @@ +from __future__ import annotations + +import json +from datetime import datetime +from typing import Any +from uuid import UUID + +from fastapi import HTTPException, status +from geoalchemy2.shape import from_shape, to_shape +from shapely.geometry import MultiPolygon as ShapelyMultiPolygon +from shapely.geometry import Polygon as ShapelyPolygon +from shapely.geometry import shape as shapely_shape +from sqlalchemy import func, or_, select, text, update +from sqlalchemy.exc import IntegrityError +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.exceptions import ( + AlreadyExistsException, + ForbiddenException, + NotFoundException, +) +from api.core.security import UserInfo +from api.src.tasking.audit.schemas import AuditEventType +from api.src.tasking.projects.dtos import ( + AoiFeature, + Pagination, + ProjectCreateRequest, + ProjectListItem, + ProjectListResponse, + ProjectResponse, + ProjectRoleAddRequest, + ProjectRoleItem, + ProjectRoleListResponse, + ProjectRoleUpdateRequest, + ProjectUpdateRequest, + SelfProjectRoleItem, + SelfProjectRolesResponse, +) +from api.src.tasking.projects.schemas import ( + AoiInput, + ProjectRoleType, + ProjectStatus, + TaskingProject, + _Feature, + _FeatureCollection, + _MultiPolygon, + _Polygon, +) + +# --------------------------------------------------------------------------- +# AOI helpers +# --------------------------------------------------------------------------- + + +def _aoi_to_shapely(aoi: AoiInput) -> ShapelyMultiPolygon: + """Normalise any of the accepted GeoJSON shapes to a Shapely + MultiPolygon. Bare Polygons are upcast to a single-member + MultiPolygon — storage column is always MULTIPOLYGON(4326). + """ + if isinstance(aoi, _FeatureCollection): + geom_dict = aoi.features[0].geometry.model_dump() + elif isinstance(aoi, _Feature): + geom_dict = aoi.geometry.model_dump() + elif isinstance(aoi, (_Polygon, _MultiPolygon)): + geom_dict = aoi.model_dump() + else: # pragma: no cover — Pydantic guards against this + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Unsupported AOI shape", + ) + + try: + geom = shapely_shape(geom_dict) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Invalid AOI geometry: {e}", + ) from None + + if isinstance(geom, ShapelyPolygon): + geom = ShapelyMultiPolygon([geom]) + elif not isinstance(geom, ShapelyMultiPolygon): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="AOI must be a Polygon or MultiPolygon", + ) + + if not geom.is_valid: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"AOI is not a valid polygon: {geom.is_valid_reason if hasattr(geom, 'is_valid_reason') else 'self-intersection or invalid ring'}", + ) + + return geom + + +def _shapely_to_aoi_feature(geom: ShapelyMultiPolygon) -> AoiFeature: + """Build the GeoJSON Feature wrapper returned by the AOI GET endpoint.""" + raw = geom.__geo_interface__ + return AoiFeature( + type="Feature", + geometry=_MultiPolygon( + type="MultiPolygon", + coordinates=raw["coordinates"], + ), + properties={}, + ) + + +# --------------------------------------------------------------------------- +# Constraint translation — map Postgres `IntegrityError` to a precise +# HTTPException keyed by constraint name. Avoids the generic +# "everything is 409: name already exists" message. +# --------------------------------------------------------------------------- + + +def _constraint_name(e: IntegrityError) -> str | None: + """Return the PG constraint name from an `IntegrityError`, or None.""" + orig = getattr(e, "orig", None) + name = getattr(orig, "constraint_name", None) + if name: + return name + inner = getattr(orig, "__cause__", None) + return getattr(inner, "constraint_name", None) + + +def _translate_integrity_error(e: IntegrityError) -> HTTPException: + """Convert a Postgres constraint violation into an HTTPException.""" + name = _constraint_name(e) or "" + + if name == "tasking_projects_workspace_name_unique": + return AlreadyExistsException( + "A project with this name already exists in the workspace" + ) + + if name == "tasking_project_roles_user_auth_uid_fkey": + return HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=( + "One or more `role_assignments[].user_id` values refer " + "to a user that has not signed in to Workspaces yet." + ), + ) + + if name == "tasking_tasks_project_id_fkey": + return HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Cannot insert tasks: parent project does not exist.", + ) + + # NOT NULL violations surface with no constraint_name on asyncpg. + orig_class = type(getattr(e, "orig", None)).__name__ + if orig_class == "NotNullViolationError": + return HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Required field is missing.", + ) + + if name: + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Database constraint violated: {name}", + ) + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Database constraint violated.", + ) + + +# --------------------------------------------------------------------------- +# Repository +# --------------------------------------------------------------------------- + + +class TaskingProjectRepository: + """CRUD and lifecycle for tasking projects. + + Methods assume the caller has already passed the workspace tenancy + gate (`WorkspaceRepository.getById`); that check is performed at + the route layer. + """ + + def __init__(self, session: AsyncSession): + self.session = session + + async def _audit( + self, + *, + event_type: AuditEventType, + project_id: int, + actor_uuid: UUID, + details: dict[str, Any] | None = None, + ) -> None: + await self.session.execute( + text( + "INSERT INTO tasking_audit_events " + "(event_type, project_id, task_id, actor_user_auth_uid, details) " + "VALUES (:et, :pid, NULL, :uid, CAST(:dt AS jsonb))" + ), + { + "et": event_type, + "pid": project_id, + "uid": str(actor_uuid), + "dt": json.dumps(details or {}), + }, + ) + + # ---- internal helpers -------------------------------------------- + + async def _get_active(self, workspace_id: int, project_id: int) -> TaskingProject: + """Fetch a non-deleted project scoped to a workspace; raise 404 otherwise.""" + result = await self.session.execute( + select(TaskingProject).where( + (TaskingProject.id == project_id) + & (TaskingProject.workspace_id == workspace_id) + & (TaskingProject.deleted_at.is_(None)) + ) + ) + project = result.scalar_one_or_none() + if project is None: + raise NotFoundException(f"Project {project_id} not found") + return project + + @staticmethod + def _to_response(project: TaskingProject, task_count: int = 0) -> ProjectResponse: + return ProjectResponse( + id=project.id, # type: ignore[arg-type] + workspace_id=project.workspace_id, + name=project.name, + instructions=project.instructions, + status=project.status, + review_required=project.review_required, + lock_timeout_hours=project.lock_timeout_hours, + task_boundary_type=project.task_boundary_type, + has_aoi=project.aoi is not None, + task_count=task_count, + created_by=project.created_by, + created_by_name=project.created_by_name, + created_at=project.created_at, + updated_at=project.updated_at, + ) + + async def _provision_users_from_tdei( + self, + missing_uuids: list[str], + project_group_id: str, + bearer_token: str, + ) -> list[str]: + """Look up `missing_uuids` against TDEI's project-group members + and insert matching rows into `users`. Return the UUIDs that are + still unknown (not members of the project group at all). + + This is the auto-provisioning fallback for `role_assignments[]`: + a user who is known to TDEI but has not yet performed any action + that would write them into `users` (e.g. first-time sign-in). + """ + from sqlalchemy import text + + from api.core.security import fetch_project_group_users + + try: + members = await fetch_project_group_users(project_group_id, bearer_token) + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Failed to fetch project group users from TDEI: {e}", + ) from e + + by_uid = {m.auth_uid: m for m in members} + + resolved: set[str] = set() + for uid in missing_uuids: + member = by_uid.get(uid) + if member is None: + continue + await self.session.execute( + text( + "INSERT INTO users (auth_uid, email, display_name) " + "VALUES (:uid, :email, :name) " + "ON CONFLICT (auth_uid) DO NOTHING" + ), + { + "uid": uid, + "email": member.email, + "name": member.display_name, + }, + ) + resolved.add(uid) + + if resolved: + # Commit the new `users` rows in their own transaction so the + # subsequent FK insert into `tasking_project_roles` resolves. + await self.session.commit() + + return [u for u in missing_uuids if u not in resolved] + + async def _missing_user_auth_uids(self, uuids: list[UUID]) -> list[str]: + """Return the subset of `uuids` without a matching `users` row. + + Preflight for the `tasking_project_roles.user_auth_uid` FK so + downstream inserts produce a clean 422 with the offending ids + instead of a 23503 foreign-key-violation. + """ + if not uuids: + return [] + + from sqlalchemy import text + + rows = await self.session.execute( + text("SELECT auth_uid FROM users WHERE auth_uid = ANY(:uids)"), + {"uids": [str(u) for u in uuids]}, + ) + existing = {row[0] for row in rows.all()} + return [str(u) for u in uuids if str(u) not in existing] + + async def _task_count(self, project_id: int) -> int: + """Read-only task count for a project; raw SQL to keep this + module independent of the tasks sub-module's ORM.""" + from sqlalchemy import text + + result = await self.session.execute( + text("SELECT COUNT(*) FROM tasking_tasks WHERE project_id = :pid"), + {"pid": project_id}, + ) + return int(result.scalar() or 0) + + # ---- create / list / get / patch / delete ------------------------ + + async def list_projects( + self, + workspace_id: int, + *, + status_filter: ProjectStatus | None = None, + text_search: str | None = None, + page: int = 1, + page_size: int = 20, + order_by: str = "created_at", + order_dir: str = "DESC", + ) -> ProjectListResponse: + valid_order = { + "created_at": TaskingProject.created_at, + "updated_at": TaskingProject.updated_at, + "name": TaskingProject.name, + } + col = valid_order.get(order_by, TaskingProject.created_at) + col = col.desc() if order_dir.upper() == "DESC" else col.asc() + + where = (TaskingProject.workspace_id == workspace_id) & ( + TaskingProject.deleted_at.is_(None) + ) + if status_filter is not None: + where = where & (TaskingProject.status == status_filter) + if text_search: + where = where & ( + func.lower(TaskingProject.name).contains(text_search.lower()) + ) + + total_q = await self.session.execute( + select(func.count()).select_from(TaskingProject).where(where) + ) + total = int(total_q.scalar() or 0) + + page = max(page, 1) + page_size = max(min(page_size, 200), 1) + offset = (page - 1) * page_size + + rows = await self.session.execute( + select(TaskingProject) + .where(where) + .order_by(col) + .limit(page_size) + .offset(offset) + ) + projects = list(rows.scalars().all()) + + # task counts in one round trip + counts: dict[int, int] = {} + if projects: + from sqlalchemy import text + + ids = [p.id for p in projects] + cnt_rows = await self.session.execute( + text( + "SELECT project_id, COUNT(*) FROM tasking_tasks " + "WHERE project_id = ANY(:ids) GROUP BY project_id" + ), + {"ids": ids}, + ) + counts = {pid: int(c) for pid, c in cnt_rows.all()} + + items: list[ProjectListItem] = [] + for p in projects: + tc = counts.get(p.id, 0) # type: ignore[arg-type] + completed = 0 + if tc > 0: + from sqlalchemy import text + + done_q = await self.session.execute( + text( + "SELECT COUNT(*) FROM tasking_tasks " + "WHERE project_id = :pid AND status = 'completed'" + ), + {"pid": p.id}, + ) + completed = int(done_q.scalar() or 0) + pct = int(round((completed / tc) * 100)) if tc > 0 else 0 + items.append( + ProjectListItem( + id=p.id, # type: ignore[arg-type] + name=p.name, + status=p.status, + task_count=tc, + percent_completed=pct, + created_by=p.created_by, + created_by_name=p.created_by_name, + created_at=p.created_at, + updated_at=p.updated_at, + ) + ) + + return ProjectListResponse( + results=items, + pagination=Pagination(page=page, page_size=page_size, total=total), + ) + + async def create( + self, + workspace_id: int, + current_user: UserInfo, + body: ProjectCreateRequest, + tdei_project_group_id: str, + ) -> ProjectResponse: + # Preflight every user_auth_uid that will be inserted into + # `tasking_project_roles` — the creator's auto-LEAD seed plus + # any explicit role_assignments. Any UUID missing from `users` + # is looked up against TDEI's project-group member list and + # auto-provisioned; only UUIDs that are not members at all + # surface as a 422 to the caller. + candidate_uuids: list[UUID] = [current_user.user_uuid] + candidate_uuids.extend(ra.user_id for ra in body.role_assignments or []) + missing = await self._missing_user_auth_uids(candidate_uuids) + + if missing: + creator_uid = str(current_user.user_uuid) + if creator_uid in missing: + # The signed-in caller is not in `users`. Pull them + # from TDEI just like any other role-assignment user; + # if even TDEI doesn't know them, surface 409 because + # the request can never succeed. + pass + + # Auto-provision via TDEI for the members of this project group. + still_missing = await self._provision_users_from_tdei( + missing, + project_group_id=tdei_project_group_id, + bearer_token=current_user.credentials, + ) + + if creator_uid in still_missing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=( + "Your user record could not be provisioned: TDEI " + "does not list you as a member of this project " + "group. Sign in to Workspaces once, then retry." + ), + ) + if still_missing: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "message": ( + "One or more `role_assignments[].user_id` " + "values are not members of this workspace's " + "project group in TDEI." + ), + "missing_user_ids": still_missing, + }, + ) + + project = TaskingProject( + workspace_id=workspace_id, + name=body.name, + instructions=body.instructions, + review_required=body.review_required, + lock_timeout_hours=body.lock_timeout_hours, + created_by=current_user.user_uuid, + created_by_name=current_user.user_name, + ) + if body.aoi is not None: + geom = _aoi_to_shapely(body.aoi) + project.aoi = from_shape(geom, srid=4326) + + try: + self.session.add(project) + await self.session.flush() # need project.id for role rows + + # Seed project-level role overrides. + if body.role_assignments: + from sqlalchemy import text + + for ra in body.role_assignments: + await self.session.execute( + text( + "INSERT INTO tasking_project_roles " + "(project_id, user_auth_uid, role) " + "VALUES (:pid, :uid, :role) " + "ON CONFLICT (project_id, user_auth_uid) " + "DO UPDATE SET role = EXCLUDED.role, " + " updated_at = NOW()" + ), + { + "pid": project.id, + "uid": str(ra.user_id), + "role": ra.role, + }, + ) + + # Creator is auto-assigned the LEAD role on the project, + # mirroring the workspace-creator auto-LEAD convention. + from sqlalchemy import text + + await self.session.execute( + text( + "INSERT INTO tasking_project_roles " + "(project_id, user_auth_uid, role) " + "VALUES (:pid, :uid, 'lead') " + "ON CONFLICT DO NOTHING" + ), + { + "pid": project.id, + "uid": str(current_user.user_uuid), + }, + ) + + await self._audit( + event_type=AuditEventType.PROJECT_CREATED, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + details={"name": project.name}, + ) + await self.session.commit() + await self.session.refresh(project) + except IntegrityError as e: + await self.session.rollback() + raise _translate_integrity_error(e) from e + + return self._to_response(project) + + async def get(self, workspace_id: int, project_id: int) -> ProjectResponse: + project = await self._get_active(workspace_id, project_id) + tc = await self._task_count(project.id) # type: ignore[arg-type] + return self._to_response(project, task_count=tc) + + async def patch( + self, + workspace_id: int, + project_id: int, + body: ProjectUpdateRequest, + current_user: UserInfo, + ) -> ProjectResponse: + project = await self._get_active(workspace_id, project_id) + + if project.status == ProjectStatus.DONE: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="A closed project cannot be edited", + ) + + # `review_required` immutable after activation + if ( + body.review_required is not None + and project.status != ProjectStatus.DRAFT + and body.review_required != project.review_required + ): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="review_required is immutable after activation", + ) + + updates: dict[str, Any] = {} + if body.name is not None: + updates["name"] = body.name.strip() + if body.instructions is not None: + updates["instructions"] = body.instructions + if body.lock_timeout_hours is not None: + updates["lock_timeout_hours"] = body.lock_timeout_hours + if body.review_required is not None: + updates["review_required"] = body.review_required + + if updates: + updates["updated_at"] = datetime.now() + try: + await self.session.execute( + update(TaskingProject) + .where(TaskingProject.id == project.id) + .values(**updates) + ) + await self._audit( + event_type=AuditEventType.PROJECT_EDITED, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + details={ + "updatedFields": [k for k in updates if k != "updated_at"] + }, + ) + await self.session.commit() + except IntegrityError as e: + await self.session.rollback() + raise _translate_integrity_error(e) from e + await self.session.refresh(project) + + tc = await self._task_count(project.id) # type: ignore[arg-type] + return self._to_response(project, task_count=tc) + + async def soft_delete( + self, workspace_id: int, project_id: int, current_user: UserInfo + ) -> None: + project = await self._get_active(workspace_id, project_id) + + # Refuse if any active task locks remain. + from sqlalchemy import text + + active = await self.session.execute( + text( + "SELECT 1 FROM tasking_locks " + "WHERE project_id = :pid AND released_at IS NULL LIMIT 1" + ), + {"pid": project.id}, + ) + if active.scalar() is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Project has active task locks; force-release first", + ) + + # Soft-delete the project, hard-delete its tasks, flag audit rows. + await self.session.execute( + update(TaskingProject) + .where(TaskingProject.id == project.id) + .values(deleted_at=datetime.now()) + ) + await self.session.execute( + text("DELETE FROM tasking_tasks WHERE project_id = :pid"), + {"pid": project.id}, + ) + # Insert the deletion event before flagging so this row is caught too. + await self._audit( + event_type=AuditEventType.PROJECT_DELETED, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + ) + await self.session.execute( + text( + "UPDATE tasking_audit_events SET project_deleted = TRUE " + "WHERE project_id = :pid" + ), + {"pid": project.id}, + ) + await self.session.commit() + + # ---- lifecycle transitions --------------------------------------- + + async def activate( + self, workspace_id: int, project_id: int, current_user: UserInfo + ) -> ProjectResponse: + project = await self._get_active(workspace_id, project_id) + if project.status != ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Only draft projects can be activated", + ) + if not project.name.strip(): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Project name is required", + ) + if project.aoi is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Project AOI is required", + ) + tc = await self._task_count(project.id) # type: ignore[arg-type] + if tc == 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Project must have at least one task", + ) + + # Activation requires at least one explicit contributor or + # validator allocation (creator's auto-LEAD does not count). + from sqlalchemy import text + + worker_q = await self.session.execute( + text( + "SELECT 1 FROM tasking_project_roles " + "WHERE project_id = :pid AND role IN ('contributor', 'validator') " + "LIMIT 1" + ), + {"pid": project.id}, + ) + if worker_q.scalar() is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="At least one contributor or validator must be allocated to the project", + ) + + await self.session.execute( + update(TaskingProject) + .where(TaskingProject.id == project.id) + .values(status=ProjectStatus.OPEN, updated_at=datetime.now()) + ) + await self._audit( + event_type=AuditEventType.PROJECT_ACTIVATED, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + ) + await self.session.commit() + await self.session.refresh(project) + return self._to_response(project, task_count=tc) + + async def close( + self, workspace_id: int, project_id: int, current_user: UserInfo + ) -> ProjectResponse: + project = await self._get_active(workspace_id, project_id) + if project.status != ProjectStatus.OPEN: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Only open projects can be closed", + ) + + from sqlalchemy import text + + not_done = await self.session.execute( + text( + "SELECT 1 FROM tasking_tasks " + "WHERE project_id = :pid AND status <> 'completed' LIMIT 1" + ), + {"pid": project.id}, + ) + if not_done.scalar() is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Project has tasks that are not yet completed", + ) + active_lock = await self.session.execute( + text( + "SELECT 1 FROM tasking_locks " + "WHERE project_id = :pid AND released_at IS NULL LIMIT 1" + ), + {"pid": project.id}, + ) + if active_lock.scalar() is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Project has active task locks", + ) + + await self.session.execute( + update(TaskingProject) + .where(TaskingProject.id == project.id) + .values(status=ProjectStatus.DONE, updated_at=datetime.now()) + ) + await self._audit( + event_type=AuditEventType.PROJECT_CLOSED, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + ) + await self.session.commit() + await self.session.refresh(project) + tc = await self._task_count(project.id) # type: ignore[arg-type] + return self._to_response(project, task_count=tc) + + async def reset( + self, workspace_id: int, project_id: int, current_user: UserInfo + ) -> ProjectResponse: + """LEAD reset — see spec §projects.""" + project = await self._get_active(workspace_id, project_id) + if project.status == ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Cannot reset a draft project (nothing to reset)", + ) + + from sqlalchemy import text + + # Release every active lock with release_reason='reset'. + await self.session.execute( + text( + "UPDATE tasking_locks " + "SET released_at = NOW(), release_reason = 'reset' " + "WHERE project_id = :pid AND released_at IS NULL" + ), + {"pid": project.id}, + ) + # Wind tasks back to to_map; clear last_mapper_id. + await self.session.execute( + text( + "UPDATE tasking_tasks " + "SET status = 'to_map', last_mapper_id = NULL, updated_at = NOW() " + "WHERE project_id = :pid AND status <> 'to_map'" + ), + {"pid": project.id}, + ) + # Project reopens if it was done. + if project.status == ProjectStatus.DONE: + await self.session.execute( + update(TaskingProject) + .where(TaskingProject.id == project.id) + .values(status=ProjectStatus.OPEN, updated_at=datetime.now()) + ) + + await self._audit( + event_type=AuditEventType.PROJECT_RESET, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + ) + await self.session.commit() + await self.session.refresh(project) + tc = await self._task_count(project.id) # type: ignore[arg-type] + return self._to_response(project, task_count=tc) + + # ---- AOI --------------------------------------------------------- + + async def get_aoi(self, workspace_id: int, project_id: int) -> AoiFeature: + project = await self._get_active(workspace_id, project_id) + if project.aoi is None: + raise NotFoundException("AOI is not set on this project") + geom = to_shape(project.aoi) + if isinstance(geom, ShapelyPolygon): # defensive + geom = ShapelyMultiPolygon([geom]) + return _shapely_to_aoi_feature(geom) + + async def upload_aoi( + self, workspace_id: int, project_id: int, aoi: AoiInput, current_user: UserInfo + ) -> AoiFeature: + project = await self._get_active(workspace_id, project_id) + if project.status != ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="AOI can only be set or replaced while the project is in draft", + ) + + geom = _aoi_to_shapely(aoi) + from sqlalchemy import text + + # Replacing AOI hard-deletes any saved tasks and clears the + # boundary type (per spec). + await self.session.execute( + text("DELETE FROM tasking_tasks WHERE project_id = :pid"), + {"pid": project.id}, + ) + await self.session.execute( + update(TaskingProject) + .where(TaskingProject.id == project.id) + .values( + aoi=from_shape(geom, srid=4326), + task_boundary_type=None, + updated_at=datetime.now(), + ) + ) + await self._audit( + event_type=AuditEventType.AOI_UPLOADED, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + ) + await self.session.commit() + return _shapely_to_aoi_feature(geom) + + # ---- Project roles ------------------------------------------------ + # + # Schema lives in `tasking_project_roles (project_id, user_auth_uid, + # role, updated_at)` with PK on (project_id, user_auth_uid) and a FK + # to `users.auth_uid`. The `add` path inserts via raw SQL so duplicate + # rows raise the PK uniqueness violation translated into a 409; FK + # violations on `user_auth_uid` are caught with a preflight so the + # caller gets a 422 listing the missing user id. + + async def _is_project_lead(self, project_id: int, user_uuid: UUID) -> bool: + """True if the user holds a `lead` role on the given project.""" + from sqlalchemy import text + + result = await self.session.execute( + text( + "SELECT 1 FROM tasking_project_roles " + "WHERE project_id = :pid AND user_auth_uid = :uid " + "AND role = 'lead' LIMIT 1" + ), + {"pid": project_id, "uid": str(user_uuid)}, + ) + return result.scalar() is not None + + async def assert_can_manage_roles( + self, + workspace_id: int, + project_id: int, + current_user: UserInfo, + ) -> None: + """Permission gate for role-management endpoints. + + Allows either a workspace-level LEAD (per `UserInfo.isWorkspaceLead`) + or a project-level LEAD recorded in `tasking_project_roles`. The + project must already exist; otherwise the underlying `_get_active` + call raises 404. + """ + await self._get_active(workspace_id, project_id) + if current_user.isWorkspaceLead(workspace_id): + return + if await self._is_project_lead(project_id, current_user.user_uuid): + return + raise ForbiddenException( + "Only a workspace lead or project lead can manage roles " "on this project." + ) + + async def _lead_count(self, project_id: int) -> int: + from sqlalchemy import text + + result = await self.session.execute( + text( + "SELECT COUNT(*) FROM tasking_project_roles " + "WHERE project_id = :pid AND role = 'lead'" + ), + {"pid": project_id}, + ) + return int(result.scalar() or 0) + + async def list_roles( + self, workspace_id: int, project_id: int + ) -> ProjectRoleListResponse: + """Return every role assignment on the project, newest first.""" + await self._get_active(workspace_id, project_id) + from sqlalchemy import text + + rows = await self.session.execute( + text( + "SELECT r.user_auth_uid, u.display_name, r.role, r.updated_at " + "FROM tasking_project_roles r " + "LEFT JOIN users u ON u.auth_uid = r.user_auth_uid " + "WHERE r.project_id = :pid " + "ORDER BY r.updated_at DESC" + ), + {"pid": project_id}, + ) + items = [ + ProjectRoleItem( + user_id=UUID(uid), + user_name=name, + role=ProjectRoleType(role), + updated_at=updated, + ) + for uid, name, role, updated in rows.all() + ] + return ProjectRoleListResponse(results=items) + + async def add_role( + self, + workspace_id: int, + project_id: int, + body: ProjectRoleAddRequest, + ) -> ProjectRoleItem: + """Insert a new role row. 422 on missing user; 409 on duplicate.""" + await self._get_active(workspace_id, project_id) + + missing = await self._missing_user_auth_uids([body.user_id]) + if missing: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "message": ( + "`user_id` refers to a user that has not signed in " + "to Workspaces yet — no `users` row exists." + ), + "missing_user_ids": missing, + }, + ) + + from sqlalchemy import text + + existing = await self.session.execute( + text( + "SELECT 1 FROM tasking_project_roles " + "WHERE project_id = :pid AND user_auth_uid = :uid" + ), + {"pid": project_id, "uid": str(body.user_id)}, + ) + if existing.scalar() is not None: + raise AlreadyExistsException( + "This user is already assigned a role on the project. " + "Use PATCH to change their role." + ) + + try: + await self.session.execute( + text( + "INSERT INTO tasking_project_roles " + "(project_id, user_auth_uid, role, updated_at) " + "VALUES (:pid, :uid, :role, NOW())" + ), + { + "pid": project_id, + "uid": str(body.user_id), + "role": body.role.value, + }, + ) + await self.session.commit() + except IntegrityError as e: + await self.session.rollback() + raise _translate_integrity_error(e) from e + + # Re-read so the response carries server-set `updated_at` and the + # `display_name` join with `users`. + return await self._get_role(project_id, body.user_id) + + async def update_role( + self, + workspace_id: int, + project_id: int, + user_id: UUID, + body: ProjectRoleUpdateRequest, + ) -> ProjectRoleItem: + """Change a user's role. 404 if not assigned. 422 if it would + remove the last LEAD (per spec — projects must always have one).""" + await self._get_active(workspace_id, project_id) + + current = await self._get_role_or_none(project_id, user_id) + if current is None: + raise NotFoundException( + f"User {user_id} has no role on project {project_id}" + ) + + # Guard: demoting the only LEAD would orphan the project. + if ( + current.role == ProjectRoleType.LEAD + and body.role != ProjectRoleType.LEAD + and await self._lead_count(project_id) <= 1 + ): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=( + "Cannot demote the last LEAD on this project. Assign " + "another LEAD first." + ), + ) + + from sqlalchemy import text + + await self.session.execute( + text( + "UPDATE tasking_project_roles " + "SET role = :role, updated_at = NOW() " + "WHERE project_id = :pid AND user_auth_uid = :uid" + ), + { + "pid": project_id, + "uid": str(user_id), + "role": body.role.value, + }, + ) + await self.session.commit() + return await self._get_role(project_id, user_id) + + async def list_self_project_roles( + self, + workspace_id: int, + current_user: UserInfo, + ) -> SelfProjectRolesResponse: + """One row per non-deleted project in the workspace with the + caller's effective role on that project. + + Resolution rule: + 1. If `tasking_project_roles` has an explicit row for this + user on the project, that wins. + 2. Otherwise the caller's workspace-level role applies (the + implicit fallback that grants `contributor` access to + every project in the workspace). + + Workspace-level role is mapped to the same `ProjectRoleType` + enum the project rows use. + """ + # Workspace-level role first — used as the fallback for projects + # without an explicit override. + from api.src.users.schemas import WorkspaceUserRoleType + + ws_role_raw = current_user.effective_role(workspace_id) + ws_role = ProjectRoleType(ws_role_raw.value) + + from sqlalchemy import text + + rows = await self.session.execute( + text( + "SELECT p.id, p.name, p.status, r.role " + "FROM tasking_projects p " + "LEFT JOIN tasking_project_roles r " + " ON r.project_id = p.id " + " AND r.user_auth_uid = :uid " + "WHERE p.workspace_id = :wid " + " AND p.deleted_at IS NULL " + "ORDER BY p.created_at DESC" + ), + { + "wid": workspace_id, + "uid": str(current_user.user_uuid), + }, + ) + + items = [ + SelfProjectRoleItem( + project_id=pid, + project_name=name, + project_status=ProjectStatus(status_value), + role=( + ProjectRoleType(explicit_role) + if explicit_role is not None + else ws_role + ), + ) + for pid, name, status_value, explicit_role in rows.all() + ] + return SelfProjectRolesResponse( + workspace_role=ws_role, + projects=items, + ) + + async def get_role( + self, + workspace_id: int, + project_id: int, + user_id: UUID, + ) -> ProjectRoleItem: + """Single-user role read. 404 if no assignment exists.""" + await self._get_active(workspace_id, project_id) + item = await self._get_role_or_none(project_id, user_id) + if item is None: + raise NotFoundException( + f"User {user_id} has no role on project {project_id}" + ) + return item + + async def upsert_role( + self, + workspace_id: int, + project_id: int, + user_id: UUID, + body: ProjectRoleUpdateRequest, + ) -> tuple[ProjectRoleItem, bool]: + """PUT semantics: idempotent insert-or-update on a role row. + + Returns ``(item, created)`` where ``created`` is True iff the + row did not exist before this call. The route layer uses that + flag to pick 201 vs 200. + + Honours the last-LEAD guard: a PUT that would demote the only + remaining LEAD returns 422 — assign another LEAD first. + """ + await self._get_active(workspace_id, project_id) + + current = await self._get_role_or_none(project_id, user_id) + + if current is not None: + # Update path — guard against orphaning the project. + if ( + current.role == ProjectRoleType.LEAD + and body.role != ProjectRoleType.LEAD + and await self._lead_count(project_id) <= 1 + ): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=( + "Cannot demote the last LEAD on this project. " + "Assign another LEAD first." + ), + ) + else: + # Insert path — `user_id` must exist in `users`. Preflight so + # the caller gets a 422 with `missing_user_ids` rather than + # a generic 23503 FK violation. + missing = await self._missing_user_auth_uids([user_id]) + if missing: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "message": ( + "`user_id` refers to a user that has not signed " + "in to Workspaces yet — no `users` row exists." + ), + "missing_user_ids": missing, + }, + ) + + from sqlalchemy import text + + try: + await self.session.execute( + text( + "INSERT INTO tasking_project_roles " + "(project_id, user_auth_uid, role, updated_at) " + "VALUES (:pid, :uid, :role, NOW()) " + "ON CONFLICT (project_id, user_auth_uid) DO UPDATE " + "SET role = EXCLUDED.role, updated_at = NOW()" + ), + { + "pid": project_id, + "uid": str(user_id), + "role": body.role.value, + }, + ) + await self.session.commit() + except IntegrityError as e: + await self.session.rollback() + raise _translate_integrity_error(e) from e + + item = await self._get_role(project_id, user_id) + return item, current is None + + async def remove_role( + self, + workspace_id: int, + project_id: int, + user_id: UUID, + ) -> None: + """Drop a role assignment. 404 if absent. 422 on last-LEAD removal.""" + await self._get_active(workspace_id, project_id) + + current = await self._get_role_or_none(project_id, user_id) + if current is None: + raise NotFoundException( + f"User {user_id} has no role on project {project_id}" + ) + + if ( + current.role == ProjectRoleType.LEAD + and await self._lead_count(project_id) <= 1 + ): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=( + "Cannot remove the last LEAD on this project. Assign " + "another LEAD first." + ), + ) + + from sqlalchemy import text + + await self.session.execute( + text( + "DELETE FROM tasking_project_roles " + "WHERE project_id = :pid AND user_auth_uid = :uid" + ), + {"pid": project_id, "uid": str(user_id)}, + ) + await self.session.commit() + + async def _get_role(self, project_id: int, user_id: UUID) -> ProjectRoleItem: + item = await self._get_role_or_none(project_id, user_id) + if item is None: # pragma: no cover — only called after insert/update + raise NotFoundException( + f"User {user_id} has no role on project {project_id}" + ) + return item + + async def _get_role_or_none( + self, project_id: int, user_id: UUID + ) -> ProjectRoleItem | None: + from sqlalchemy import text + + row = await self.session.execute( + text( + "SELECT r.user_auth_uid, u.display_name, r.role, r.updated_at " + "FROM tasking_project_roles r " + "LEFT JOIN users u ON u.auth_uid = r.user_auth_uid " + "WHERE r.project_id = :pid AND r.user_auth_uid = :uid" + ), + {"pid": project_id, "uid": str(user_id)}, + ) + result = row.first() + if result is None: + return None + uid, name, role, updated = result + return ProjectRoleItem( + user_id=UUID(uid), + user_name=name, + role=ProjectRoleType(role), + updated_at=updated, + ) + + async def delete_aoi( + self, workspace_id: int, project_id: int, current_user: UserInfo + ) -> None: + project = await self._get_active(workspace_id, project_id) + if project.status != ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="AOI can only be deleted while the project is in draft", + ) + if project.aoi is None: + raise NotFoundException("AOI is not set on this project") + + from sqlalchemy import text + + await self.session.execute( + text("DELETE FROM tasking_tasks WHERE project_id = :pid"), + {"pid": project.id}, + ) + await self.session.execute( + update(TaskingProject) + .where(TaskingProject.id == project.id) + .values( + aoi=None, + task_boundary_type=None, + updated_at=datetime.now(), + ) + ) + await self._audit( + event_type=AuditEventType.AOI_DELETED, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + ) + await self.session.commit() + + +__all__ = ["TaskingProjectRepository"] diff --git a/api/src/tasking/projects/routes.py b/api/src/tasking/projects/routes.py new file mode 100644 index 0000000..7389246 --- /dev/null +++ b/api/src/tasking/projects/routes.py @@ -0,0 +1,397 @@ +from __future__ import annotations + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response, status +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.database import get_osm_session, get_task_session +from api.core.security import UserInfo, validate_token +from api.src.tasking.projects.dtos import ( + AoiFeature, + ProjectCreateRequest, + ProjectListResponse, + ProjectResponse, + ProjectRoleAddRequest, + ProjectRoleItem, + ProjectRoleListResponse, + ProjectRoleUpdateRequest, + ProjectUpdateRequest, + SelfProjectRolesResponse, +) +from api.src.tasking.projects.repository import TaskingProjectRepository +from api.src.tasking.projects.schemas import AoiInput, ProjectStatus +from api.src.workspaces.repository import WorkspaceRepository + +router = APIRouter( + prefix="/workspaces/{workspace_id}/tasking/projects", + tags=["tasking-projects"], +) + +# Sibling router for caller-scoped (`/me/...`) tasking endpoints. Kept on +# its own prefix so the project-scoped router above can stay focused. +me_router = APIRouter( + prefix="/me/workspaces/{workspace_id}/tasking/projects", + tags=["tasking-projects"], +) + + +# --------------------------------------------------------------------------- +# Dependencies +# --------------------------------------------------------------------------- + + +def get_project_repo( + session: AsyncSession = Depends(get_osm_session), +) -> TaskingProjectRepository: + return TaskingProjectRepository(session) + + +def get_workspace_repo( + session: AsyncSession = Depends(get_task_session), +) -> WorkspaceRepository: + return WorkspaceRepository(session) + + +async def assert_workspace_visible( + workspace_id: int, + current_user: UserInfo, + workspace_repo: WorkspaceRepository, +) -> None: + """Tenancy gate: 404 if the caller's project groups don't own the + workspace (matches `WorkspaceRepository.getById`'s convention). + """ + await workspace_repo.getById(current_user, workspace_id) + + +def assert_workspace_lead(workspace_id: int, current_user: UserInfo) -> None: + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User does not have permission to edit this workspace", + ) + + +# --------------------------------------------------------------------------- +# Projects — CRUD + lifecycle +# --------------------------------------------------------------------------- + + +@router.get("", response_model=ProjectListResponse) +async def list_projects( + workspace_id: int, + status_filter: Annotated[ProjectStatus | None, Query(alias="status")] = None, + text_search: str | None = Query(default=None, max_length=255), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=200), + order_by: str = Query("created_at"), + order_by_type: str = Query("DESC"), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await project_repo.list_projects( + workspace_id, + status_filter=status_filter, + text_search=text_search, + page=page, + page_size=page_size, + order_by=order_by, + order_dir=order_by_type, + ) + + +@router.post( + "", + response_model=ProjectResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_project( + workspace_id: int, + body: ProjectCreateRequest, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + # `getById` enforces the tenancy gate AND returns the workspace + # row, whose `tdeiProjectGroupId` we hand to the repository so it + # can auto-provision `role_assignments[]` users from TDEI. + workspace = await workspace_repo.getById(current_user, workspace_id) + assert_workspace_lead(workspace_id, current_user) + return await project_repo.create( + workspace_id, + current_user, + body, + tdei_project_group_id=str(workspace.tdeiProjectGroupId), + ) + + +@router.get("/{project_id}", response_model=ProjectResponse) +async def get_project( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await project_repo.get(workspace_id, project_id) + + +@router.patch("/{project_id}", response_model=ProjectResponse) +async def update_project( + workspace_id: int, + project_id: int, + body: ProjectUpdateRequest, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await project_repo.patch(workspace_id, project_id, body, current_user) + + +@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_project( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + await project_repo.soft_delete(workspace_id, project_id, current_user) + + +@router.post("/{project_id}/activate", response_model=ProjectResponse) +async def activate_project( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await project_repo.activate(workspace_id, project_id, current_user) + + +@router.post("/{project_id}/close", response_model=ProjectResponse) +async def close_project( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await project_repo.close(workspace_id, project_id, current_user) + + +@router.post("/{project_id}/reset", response_model=ProjectResponse) +async def reset_project( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await project_repo.reset(workspace_id, project_id, current_user) + + +# --------------------------------------------------------------------------- +# AOI +# --------------------------------------------------------------------------- + + +@router.get("/{project_id}/aoi", response_model=AoiFeature) +async def get_project_aoi( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await project_repo.get_aoi(workspace_id, project_id) + + +@router.post("/{project_id}/aoi", response_model=AoiFeature) +async def upload_project_aoi( + workspace_id: int, + project_id: int, + body: AoiInput = Body(...), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await project_repo.upload_aoi(workspace_id, project_id, body, current_user) + + +# --------------------------------------------------------------------------- +# Project role management +# +# Writes require either workspace LEAD or project LEAD (delegated to the +# repository so the project-LEAD check can hit `tasking_project_roles`). +# Reads are open to any caller who passes the workspace tenancy gate. +# --------------------------------------------------------------------------- + + +@router.get( + "/{project_id}/roles", + response_model=ProjectRoleListResponse, +) +async def list_project_roles( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await project_repo.list_roles(workspace_id, project_id) + + +@router.post( + "/{project_id}/roles", + response_model=ProjectRoleItem, + status_code=status.HTTP_201_CREATED, +) +async def add_project_role( + workspace_id: int, + project_id: int, + body: ProjectRoleAddRequest, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + await project_repo.assert_can_manage_roles(workspace_id, project_id, current_user) + return await project_repo.add_role(workspace_id, project_id, body) + + +@router.get( + "/{project_id}/roles/{user_id}", + response_model=ProjectRoleItem, +) +async def get_project_role( + workspace_id: int, + project_id: int, + user_id: UUID, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await project_repo.get_role(workspace_id, project_id, user_id) + + +@router.put( + "/{project_id}/roles/{user_id}", + response_model=ProjectRoleItem, +) +async def put_project_role( + workspace_id: int, + project_id: int, + user_id: UUID, + body: ProjectRoleUpdateRequest, + response: Response, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + """Idempotent upsert. 201 on insert, 200 on update. Last-LEAD guarded.""" + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + await project_repo.assert_can_manage_roles(workspace_id, project_id, current_user) + item, created = await project_repo.upsert_role( + workspace_id, project_id, user_id, body + ) + if created: + response.status_code = status.HTTP_201_CREATED + return item + + +@router.patch( + "/{project_id}/roles/{user_id}", + response_model=ProjectRoleItem, +) +async def update_project_role( + workspace_id: int, + project_id: int, + user_id: UUID, + body: ProjectRoleUpdateRequest, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + await project_repo.assert_can_manage_roles(workspace_id, project_id, current_user) + return await project_repo.update_role(workspace_id, project_id, user_id, body) + + +@router.delete( + "/{project_id}/roles/{user_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def remove_project_role( + workspace_id: int, + project_id: int, + user_id: UUID, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + await project_repo.assert_can_manage_roles(workspace_id, project_id, current_user) + await project_repo.remove_role(workspace_id, project_id, user_id) + + +@router.delete("/{project_id}/aoi", status_code=status.HTTP_204_NO_CONTENT) +async def delete_project_aoi( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + await project_repo.delete_aoi(workspace_id, project_id, current_user) + + +# --------------------------------------------------------------------------- +# /me/* — caller-scoped views. +# +# Mounted on `me_router` so the path prefix is `/me/workspaces/{wid}/...` +# instead of the project-scoped router's `/workspaces/{wid}/...`. Reads +# only; no writes here. +# --------------------------------------------------------------------------- + + +@me_router.get( + "/roles", + response_model=SelfProjectRolesResponse, +) +async def list_self_project_roles( + workspace_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + """Per-project role for the caller across every project in the workspace. + + Returns the workspace-level role and a per-project list with the + project-LEVEL role override where one exists, else the workspace role. + Single round-trip for the project-list page. + """ + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await project_repo.list_self_project_roles(workspace_id, current_user) diff --git a/api/src/tasking/projects/schemas.py b/api/src/tasking/projects/schemas.py new file mode 100644 index 0000000..901d5f6 --- /dev/null +++ b/api/src/tasking/projects/schemas.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from typing import Any, Literal, Optional +from uuid import UUID + +from geoalchemy2 import Geometry +from pydantic import BaseModel +from pydantic import Field as PydField +from sqlalchemy import Column +from sqlalchemy import Enum as SAEnum +from sqlmodel import Field, SQLModel + +# --------------------------------------------------------------------------- +# Enums (mirrors of postgres enums in the migration) +# --------------------------------------------------------------------------- + + +class ProjectStatus(StrEnum): + DRAFT = "draft" + OPEN = "open" + DONE = "done" + + +class TaskBoundaryType(StrEnum): + GRID = "grid" + IMPORT = "import" + + +# --------------------------------------------------------------------------- +# Table model +# --------------------------------------------------------------------------- + + +class TaskingProject(SQLModel, table=True): + """Tasking project — lifecycle, AOI, settings.""" + + __tablename__ = "tasking_projects" # type: ignore[assignment] + + id: Optional[int] = Field(default=None, primary_key=True) + + workspace_id: int = Field(nullable=False, index=True) + + name: str = Field(max_length=255, nullable=False) + instructions: Optional[str] = None + + status: ProjectStatus = Field( + default=ProjectStatus.DRAFT, + sa_column=Column( + SAEnum( + ProjectStatus, + name="tasking_project_status", + create_type=False, + values_callable=lambda enum: [m.value for m in enum], + ), + nullable=False, + ), + ) + + review_required: bool = Field(default=True, nullable=False) + lock_timeout_hours: int = Field(default=8, nullable=False) + + task_boundary_type: Optional[TaskBoundaryType] = Field( + default=None, + sa_column=Column( + SAEnum( + TaskBoundaryType, + name="tasking_task_boundary_type", + create_type=False, + values_callable=lambda enum: [m.value for m in enum], + ), + nullable=True, + ), + ) + + # PostGIS MultiPolygon in EPSG:4326. Stored as WKB and converted + # to / from GeoJSON in the repository layer. + aoi: Optional[Any] = Field( + default=None, + sa_column=Column(Geometry(geometry_type="MULTIPOLYGON", srid=4326)), + ) + + created_by: UUID = Field(nullable=False) + created_by_name: Optional[str] = None + + created_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False}, + ) + updated_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False, "onupdate": datetime.now}, + ) + deleted_at: Optional[datetime] = None + + +# --------------------------------------------------------------------------- +# Project role enum + table +# --------------------------------------------------------------------------- + + +class ProjectRoleType(StrEnum): + LEAD = "lead" + VALIDATOR = "validator" + CONTRIBUTOR = "contributor" + + +class TaskingProjectRole(SQLModel, table=True): + """Per-project role override stored in ``tasking_project_roles``. + + Maps a user (`users.auth_uid`) to a role within a single tasking + project. Created at project-seed time and managed via the + ``/projects/{pid}/roles`` endpoints thereafter. Cascades on project + delete; the user FK is intentionally non-cascading so user records + are not destroyed by project deletions. + """ + + __tablename__ = "tasking_project_roles" # type: ignore[assignment] + + project_id: int = Field(primary_key=True, nullable=False) + user_auth_uid: str = Field(primary_key=True, nullable=False) + role: ProjectRoleType = Field( + sa_column=Column( + SAEnum( + ProjectRoleType, + name="workspace_role", + create_type=False, + values_callable=lambda enum: [m.value for m in enum], + ), + nullable=False, + ), + ) + updated_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False, "onupdate": datetime.now}, + ) + + +# --------------------------------------------------------------------------- +# GeoJSON input shapes — accepted by the AOI endpoints. Polygon inputs +# are upcast to single-member MultiPolygon at the repository layer. +# --------------------------------------------------------------------------- + + +class _Polygon(BaseModel): + type: Literal["Polygon"] + coordinates: list[list[list[float]]] + + +class _MultiPolygon(BaseModel): + type: Literal["MultiPolygon"] + coordinates: list[list[list[list[float]]]] + + +class _Feature(BaseModel): + type: Literal["Feature"] + geometry: _Polygon | _MultiPolygon + properties: Optional[dict[str, Any]] = None + + +class _FeatureCollection(BaseModel): + type: Literal["FeatureCollection"] + features: list[_Feature] = PydField(min_length=1, max_length=1) + + +AoiInput = _Polygon | _MultiPolygon | _Feature | _FeatureCollection + + +__all__ = [ + "AoiInput", + "ProjectRoleType", + "ProjectStatus", + "TaskBoundaryType", + "TaskingProject", + "TaskingProjectRole", + "_Feature", + "_FeatureCollection", + "_MultiPolygon", + "_Polygon", +] diff --git a/api/src/tasking/tasks/__init__.py b/api/src/tasking/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/tasking/tasks/dtos.py b/api/src/tasking/tasks/dtos.py new file mode 100644 index 0000000..79a8bdb --- /dev/null +++ b/api/src/tasking/tasks/dtos.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal, Optional +from uuid import UUID + +from pydantic import Field as PydField + +from api.src.tasking.projects.dtos import Pagination, WireModel +from api.src.tasking.tasks.schemas import FeedbackReason, TaskStatus + +# --------------------------------------------------------------------------- +# Task boundary GeoJSON (input for /tasks/validate and /tasks/save) +# --------------------------------------------------------------------------- + + +class TaskBoundaryPolygon(WireModel): + type: Literal["Polygon"] + coordinates: list[list[list[float]]] + + +class TaskBoundaryFeature(WireModel): + type: Literal["Feature"] + geometry: TaskBoundaryPolygon + properties: Optional[dict[str, Any]] = None + + +class TaskBoundariesFeatureCollection(WireModel): + type: Literal["FeatureCollection"] + features: list[TaskBoundaryFeature] = PydField(min_length=1) + + +GridSource = Literal["grid", "import"] + + +# --------------------------------------------------------------------------- +# Task detail / list +# --------------------------------------------------------------------------- + + +class TaskLockSummary(WireModel): + user_id: UUID + user_name: Optional[str] = None + locked_at: datetime + expires_at: datetime + + +class LastMapper(WireModel): + user_id: UUID + user_name: Optional[str] = None + + +class TaskResponse(WireModel): + id: int + task_number: int + status: TaskStatus + geometry: TaskBoundaryPolygon + area_sqkm: float + lock: Optional[TaskLockSummary] = None + last_mapper: Optional[LastMapper] = None + created_at: datetime + updated_at: datetime + + +class TaskListResponse(WireModel): + tasks: list[TaskResponse] + pagination: Pagination + + +# --------------------------------------------------------------------------- +# Validate / Save +# --------------------------------------------------------------------------- + + +class ValidateWarning(WireModel): + task_index: int + issue: Literal["polygon_exceeds_grid_size"] + area_sqkm: Optional[float] = None + + +class ValidatePreviewResponse(WireModel): + valid: bool + warnings: list[ValidateWarning] = PydField(default_factory=list) + source: GridSource = "import" + feature_collection: TaskBoundariesFeatureCollection + + +class SaveTasksRequest(WireModel): + source: GridSource + feature_collection: TaskBoundariesFeatureCollection + + +class SaveTasksResponse(WireModel): + project_id: int + task_boundary_type: GridSource + task_count: int + tasks: list[TaskResponse] + idempotency_key: Optional[str] = None + replayed: bool = False + + +# --------------------------------------------------------------------------- +# Submit / lock +# --------------------------------------------------------------------------- + + +class FeedbackInput(WireModel): + reason_category: Optional[FeedbackReason] = None + notes: str = PydField(min_length=1, max_length=4000) + + +class SubmitRequest(WireModel): + # osm_changeset_id: int = PydField(ge=1) + done: bool + feedback: Optional[FeedbackInput] = None + + +class SubmitTaskChangeset(WireModel): + osm_changeset_id: int = PydField(ge=1) + + +class SubmitTaskChangesetResponse(WireModel): + osm_changeset_id: int = PydField(ge=1) + task_number: int + project_id: int + workspace_id: int + inserted_id: Optional[int] = ( + None # ID of the newly inserted changeset row, if applicable + ) + + +class ExistingLockSummary(WireModel): + task_number: int + task_status: TaskStatus + locked_at: datetime + expires_at: datetime + + +__all__ = [ + "ExistingLockSummary", + "FeedbackInput", + "GridSource", + "LastMapper", + "SaveTasksRequest", + "SaveTasksResponse", + "SubmitRequest", + "TaskBoundariesFeatureCollection", + "TaskBoundaryFeature", + "TaskBoundaryPolygon", + "TaskListResponse", + "TaskLockSummary", + "TaskResponse", + "ValidatePreviewResponse", + "ValidateWarning", +] diff --git a/api/src/tasking/tasks/repository.py b/api/src/tasking/tasks/repository.py new file mode 100644 index 0000000..ad99c12 --- /dev/null +++ b/api/src/tasking/tasks/repository.py @@ -0,0 +1,1159 @@ +from __future__ import annotations + +import hashlib +import json +import math +from datetime import datetime, timedelta +from typing import Any, Optional +from uuid import UUID + +from fastapi import HTTPException, status +from geoalchemy2.shape import from_shape, to_shape +from shapely.geometry import MultiPolygon as ShapelyMultiPolygon +from shapely.geometry import Polygon as ShapelyPolygon +from shapely.geometry import shape as shapely_shape +from sqlalchemy import func, select, text, update +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.exc import IntegrityError +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.exceptions import ( + AlreadyExistsException, + ForbiddenException, + NotFoundException, +) +from api.core.security import UserInfo +from api.src.tasking.audit.schemas import AuditEventType +from api.src.tasking.projects.dtos import Pagination +from api.src.tasking.projects.schemas import ProjectStatus, TaskingProject +from api.src.tasking.tasks.dtos import ( + ExistingLockSummary, + FeedbackInput, + LastMapper, + SaveTasksRequest, + SaveTasksResponse, + SubmitRequest, + TaskBoundariesFeatureCollection, + TaskBoundaryFeature, + TaskBoundaryPolygon, + TaskListResponse, + TaskLockSummary, + TaskResponse, + ValidatePreviewResponse, + ValidateWarning, +) +from api.src.tasking.tasks.schemas import ( + LockReleaseReason, + TaskingChangeset, + TaskingFeedback, + TaskingLock, + TaskingTask, + TaskStatus, +) + +# Equirectangular approximation for area calculations on small +# EPSG:4326 polygons: 1 degree latitude ≈ 111.32 km. Sufficient for +# the grid-size warning threshold; precise areas need a metric +# reprojection. +_DEG2_TO_KM2 = 111.32 * 111.32 + +# Threshold for the `polygon_exceeds_grid_size` warning. Default is +# 5000 m per side (matching the conventional Tasking Manager grid). +# Overridable via the `TM_TASKING_GRID_SIZE_METERS` environment +# variable; read once at import time. +import os as _os # noqa: E402 + +try: + _GRID_SIZE_M = float(_os.getenv("TM_TASKING_GRID_SIZE_METERS", "5000")) +except ValueError: + _GRID_SIZE_M = 5000.0 +_GRID_MAX_KM2 = (_GRID_SIZE_M / 1000.0) ** 2 + + +# --------------------------------------------------------------------------- +# Geometry helpers +# --------------------------------------------------------------------------- + + +def _polygon_to_shapely(geom_dict: dict) -> ShapelyPolygon: + try: + geom = shapely_shape(geom_dict) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Invalid task geometry: {e}", + ) from None + if not isinstance(geom, ShapelyPolygon): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Each task feature must be a Polygon", + ) + if not geom.is_valid: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Task polygon is not valid (self-intersection or invalid ring)", + ) + return geom + + +def _shapely_polygon_to_geojson(geom: ShapelyPolygon) -> TaskBoundaryPolygon: + raw = geom.__geo_interface__ + return TaskBoundaryPolygon(type="Polygon", coordinates=raw["coordinates"]) + + +def _polygon_area_km2(geom: ShapelyPolygon) -> float: + return float(geom.area) * _DEG2_TO_KM2 + + +# AOI containment is checked with `aoi.covers(poly)`, which uses exact +# predicates. Grid generation produces cells clipped against the AOI; +# the clipped vertices land on (or fractionally outside) the AOI edge +# due to floating-point rounding, so `covers` returns False even though +# the cell is topologically inside the AOI. +# +# Treat a polygon as inside the AOI when the area of `poly.difference(aoi)` +# is below this fraction of the polygon's own area. 1e-9 corresponds to +# ~1 µm² at this latitude — well below any real-world AOI authoring error. +_AOI_COVER_REL_TOLERANCE = 1e-9 + + +def _aoi_covers(aoi_geom, poly: ShapelyPolygon) -> bool: + """Tolerant containment test for AOI vs. task polygon.""" + if aoi_geom.covers(poly): + return True + poly_area = poly.area + if poly_area == 0: + return True + overrun = poly.difference(aoi_geom).area + return overrun <= _AOI_COVER_REL_TOLERANCE * poly_area + + +def _generate_grid_over_aoi( + aoi: ShapelyMultiPolygon, + cell_size_m: float, +) -> list[ShapelyPolygon]: + """Build a regular grid of square cells over the AOI bounding box, + clip each cell to the AOI, and return the resulting polygons. + + Sizes are converted from meters using an equirectangular + approximation: + - 1° latitude ≈ 111 320 m everywhere. + - 1° longitude ≈ 111 320 · cos(centre latitude) m. + + Adequate for small project AOIs; a proper metric reprojection + (pyproj) is required for global-scale precision. + """ + minx, miny, maxx, maxy = aoi.bounds + center_lat = (miny + maxy) / 2.0 + lat_step = cell_size_m / 111_320.0 + lon_step = cell_size_m / (111_320.0 * max(math.cos(math.radians(center_lat)), 0.01)) + + cells: list[ShapelyPolygon] = [] + # Safety cap for accidental large-AOI + small-cell combinations. + cell_cap = 50_000 + + y = miny + while y < maxy: + x = minx + while x < maxx: + cell = ShapelyPolygon( + [ + (x, y), + (x + lon_step, y), + (x + lon_step, y + lat_step), + (x, y + lat_step), + (x, y), + ] + ) + if cell.intersects(aoi): + clipped = cell.intersection(aoi) + if not clipped.is_empty and clipped.area > 0: + # `intersection` can return a Polygon, MultiPolygon, + # or GeometryCollection; retain polygon pieces only. + geoms = ( + list(clipped.geoms) if hasattr(clipped, "geoms") else [clipped] + ) + for piece in geoms: + if isinstance(piece, ShapelyPolygon) and piece.area > 0: + cells.append(piece) + if len(cells) >= cell_cap: + return cells + x += lon_step + y += lat_step + + return cells + + +# --------------------------------------------------------------------------- +# Repository +# --------------------------------------------------------------------------- + + +class TaskingTaskRepository: + """Tasks, locks, changesets, and submit-flow operations. + + Methods assume the caller has already passed the workspace tenancy + gate (`WorkspaceRepository.getById`); that check is performed at + the route layer. + """ + + def __init__(self, session: AsyncSession): + self.session = session + + # ---- common helpers --------------------------------------------------- + + async def _get_project(self, workspace_id: int, project_id: int) -> TaskingProject: + rs = await self.session.execute( + select(TaskingProject).where( + (TaskingProject.id == project_id) + & (TaskingProject.workspace_id == workspace_id) + & (TaskingProject.deleted_at.is_(None)) + ) + ) + project = rs.scalar_one_or_none() + if project is None: + raise NotFoundException(f"Project {project_id} not found") + return project + + async def _get_task(self, project_id: int, task_number: int) -> TaskingTask: + rs = await self.session.execute( + select(TaskingTask).where( + (TaskingTask.project_id == project_id) + & (TaskingTask.task_number == task_number) + ) + ) + task = rs.scalar_one_or_none() + if task is None: + raise NotFoundException( + f"Task {task_number} not found in project {project_id}" + ) + return task + + async def _get_active_lock(self, task_id: int) -> Optional[TaskingLock]: + rs = await self.session.execute( + select(TaskingLock).where( + (TaskingLock.task_id == task_id) & (TaskingLock.released_at.is_(None)) + ) + ) + return rs.scalar_one_or_none() + + async def _get_active_lock_for_user_in_project( + self, project_id: int, user_auth_uid: str + ) -> Optional[TaskingLock]: + rs = await self.session.execute( + select(TaskingLock).where( + (TaskingLock.project_id == project_id) + & (TaskingLock.user_auth_uid == user_auth_uid) + & (TaskingLock.released_at.is_(None)) + ) + ) + return rs.scalar_one_or_none() + + async def _project_role( + self, project_id: int, user: UserInfo, workspace_id: int + ) -> Optional[str]: + """Effective project role: explicit row overrides workspace-level. + + Returns one of `lead`, `validator`, `contributor`, or `None` + (outsider). Workspace LEAD beats an explicit non-lead row so + a workspace lead is never accidentally demoted by a stale role + assignment. + """ + rs = await self.session.execute( + text( + "SELECT role FROM tasking_project_roles " + "WHERE project_id = :pid AND user_auth_uid = :uid" + ), + {"pid": project_id, "uid": str(user.user_uuid)}, + ) + explicit = rs.scalar_one_or_none() + + # Workspace LEAD always wins. + if user.isWorkspaceLead(workspace_id): + return "lead" + if explicit: + return str(explicit) + if user.isWorkspaceValidator(workspace_id): + return "validator" + if user.isWorkspaceContributor(workspace_id): + return "contributor" + return None + + async def _audit( + self, + *, + event_type: AuditEventType, + project_id: int, + task_id: Optional[int], + actor_uuid: UUID, + details: Optional[dict[str, Any]] = None, + ) -> None: + await self.session.execute( + text( + "INSERT INTO tasking_audit_events " + "(event_type, project_id, task_id, actor_user_auth_uid, details) " + "VALUES (:et, :pid, :tid, :uid, CAST(:dt AS jsonb))" + ), + { + "et": event_type, + "pid": project_id, + "tid": task_id, + "uid": str(actor_uuid), + "dt": json.dumps(details or {}), + }, + ) + + async def _lookup_user_display(self, user_auth_uid: Optional[str]) -> Optional[str]: + if not user_auth_uid: + return None + rs = await self.session.execute( + text("SELECT display_name FROM users WHERE auth_uid = :uid"), + {"uid": user_auth_uid}, + ) + return rs.scalar_one_or_none() + + async def _to_task_response(self, task: TaskingTask) -> TaskResponse: + geom_shape = to_shape(task.geometry) if task.geometry is not None else None + if geom_shape is None or not isinstance(geom_shape, ShapelyPolygon): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Task geometry missing or non-Polygon", + ) + + lock_row = await self._get_active_lock(task.id) # type: ignore[arg-type] + lock_summary: Optional[TaskLockSummary] = None + if lock_row is not None: + display = await self._lookup_user_display(lock_row.user_auth_uid) + lock_summary = TaskLockSummary( + user_id=UUID(lock_row.user_auth_uid), + user_name=display, + locked_at=lock_row.locked_at, + expires_at=lock_row.expires_at, + ) + + last_mapper: Optional[LastMapper] = None + if task.last_mapper_id: + display = await self._lookup_user_display(task.last_mapper_id) + last_mapper = LastMapper( + user_id=UUID(task.last_mapper_id), user_name=display + ) + + return TaskResponse( + id=task.id, # type: ignore[arg-type] + task_number=task.task_number, + status=task.status, + geometry=_shapely_polygon_to_geojson(geom_shape), + area_sqkm=float(task.area_sqkm), + lock=lock_summary, + last_mapper=last_mapper, + created_at=task.created_at, + updated_at=task.updated_at, + ) + + # ---- grid generation ------------------------------------------------- + + async def generate_grid( + self, + workspace_id: int, + project_id: int, + cell_size_m: int, + ) -> TaskBoundariesFeatureCollection: + """Preview-only grid generation: returns a FeatureCollection of + clipped grid cells over the project AOI without persisting. + + Caller previews the returned shapes and then commits the same + FeatureCollection via `POST /tasks/save` (with + ``source: "grid"``). LEAD-only; project must be in `draft` + with an AOI set. + """ + project = await self._get_project(workspace_id, project_id) + if project.status != ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Tasks can only be generated while the project is in draft", + ) + if project.aoi is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Project AOI is required before generating a grid", + ) + + aoi_geom = to_shape(project.aoi) + if isinstance(aoi_geom, ShapelyPolygon): + aoi_geom = ShapelyMultiPolygon([aoi_geom]) + + cells = _generate_grid_over_aoi(aoi_geom, float(cell_size_m)) + if not cells: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=( + "Grid produced no cells — AOI may be too small for " + "the chosen cellSizeMeters." + ), + ) + + features = [ + TaskBoundaryFeature( + type="Feature", + geometry=_shapely_polygon_to_geojson(cell), + properties={"cellIndex": idx}, + ) + for idx, cell in enumerate(cells) + ] + return TaskBoundariesFeatureCollection( + type="FeatureCollection", + features=features, + ) + + # ---- validate -------------------------------------------------------- + + async def validate( + self, + workspace_id: int, + project_id: int, + fc: TaskBoundariesFeatureCollection, + ) -> ValidatePreviewResponse: + project = await self._get_project(workspace_id, project_id) + if project.status != ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Tasks can only be validated while the project is in draft", + ) + if project.aoi is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Project AOI is required before validating tasks", + ) + + aoi_geom = to_shape(project.aoi) + if isinstance(aoi_geom, ShapelyPolygon): + aoi_geom = ShapelyMultiPolygon([aoi_geom]) + + warnings: list[ValidateWarning] = [] + for idx, feat in enumerate(fc.features): + poly = _polygon_to_shapely(feat.geometry.model_dump()) + if not _aoi_covers(aoi_geom, poly): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Task feature {idx} is not fully inside the project AOI", + ) + area_km2 = _polygon_area_km2(poly) + if area_km2 > _GRID_MAX_KM2: + warnings.append( + ValidateWarning( + task_index=idx, + issue="polygon_exceeds_grid_size", + area_sqkm=round(area_km2, 4), + ) + ) + + return ValidatePreviewResponse( + valid=True, + warnings=warnings, + source="import", + feature_collection=fc, + ) + + # ---- save ------------------------------------------------------------ + + async def save( + self, + workspace_id: int, + project_id: int, + current_user: UserInfo, + body: SaveTasksRequest, + idempotency_key: Optional[str], + ) -> tuple[SaveTasksResponse, bool]: + """Bulk-insert tasks. Returns (response, replayed).""" + project = await self._get_project(workspace_id, project_id) + if project.status != ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Tasks can only be saved while the project is in draft", + ) + if project.aoi is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Project AOI is required before saving tasks", + ) + + body_bytes = json.dumps(body.model_dump(mode="json"), sort_keys=True).encode() + body_hash = hashlib.sha256(body_bytes).hexdigest() + + # Idempotent replay path. + if idempotency_key: + rs = await self.session.execute( + text( + "SELECT body_hash, response_json " + "FROM tasking_task_save_idempotency " + "WHERE project_id = :pid AND key = :k" + ), + {"pid": project.id, "k": idempotency_key}, + ) + row = rs.first() + if row is not None: + if row.body_hash != body_hash: + raise AlreadyExistsException( + "Idempotency key reused with a different request" + ) + stored = row.response_json + if isinstance(stored, str): + stored = json.loads(stored) + # Stored payload was serialised with `replayed=False` + # at first-write time; set it to True on replay so the + # caller can distinguish a replay from a fresh save. + stored["replayed"] = True + return SaveTasksResponse(**stored), True + + # Refuse if tasks already exist (re-upload AOI to wipe). + existing = await self.session.execute( + text("SELECT 1 FROM tasking_tasks WHERE project_id = :pid LIMIT 1"), + {"pid": project.id}, + ) + if existing.scalar() is not None: + raise AlreadyExistsException("Tasks already saved") + + # Re-validate every feature against AOI (atomic guarantee). + aoi_geom = to_shape(project.aoi) + if isinstance(aoi_geom, ShapelyPolygon): + aoi_geom = ShapelyMultiPolygon([aoi_geom]) + + created: list[TaskingTask] = [] + for idx, feat in enumerate(body.feature_collection.features): + poly = _polygon_to_shapely(feat.geometry.model_dump()) + if not _aoi_covers(aoi_geom, poly): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Task feature {idx} is not fully inside the project AOI", + ) + task = TaskingTask( + project_id=project.id, # type: ignore[arg-type] + task_number=idx + 1, + area_sqkm=round(_polygon_area_km2(poly), 4), + status=TaskStatus.TO_MAP, + geometry=from_shape(poly, srid=4326), + ) + self.session.add(task) + created.append(task) + + await self.session.flush() # populate ids + + # Set boundary type + bump project updated_at. + await self.session.execute( + update(TaskingProject) + .where(TaskingProject.id == project.id) + .values( + task_boundary_type=body.source, + updated_at=datetime.now(), + ) + ) + + await self._audit( + event_type=AuditEventType.TASKS_CREATED, + project_id=project.id, # type: ignore[arg-type] + task_id=None, + actor_uuid=current_user.user_uuid, + details={"taskCount": len(created), "source": body.source}, + ) + + task_responses = [await self._to_task_response(t) for t in created] + response = SaveTasksResponse( + project_id=project.id, # type: ignore[arg-type] + task_boundary_type=body.source, + task_count=len(created), + tasks=task_responses, + idempotency_key=idempotency_key, + replayed=False, + ) + + # Persist idempotency record (committed in the same txn). + if idempotency_key: + payload = response.model_dump(mode="json") + await self.session.execute( + text( + "INSERT INTO tasking_task_save_idempotency " + "(project_id, key, body_hash, response_json) " + "VALUES (:pid, :k, :bh, CAST(:rj AS jsonb))" + ), + { + "pid": project.id, + "k": idempotency_key, + "bh": body_hash, + "rj": json.dumps(payload), + }, + ) + + await self.session.commit() + return response, False + + # ---- list / get ------------------------------------------------------ + + async def list_tasks( + self, + workspace_id: int, + project_id: int, + *, + status_filter: Optional[TaskStatus] = None, + locked_by_user_id: Optional[UUID] = None, + last_mapper_id: Optional[UUID] = None, + page: int = 1, + page_size: int = 200, + ) -> TaskListResponse: + await self._get_project(workspace_id, project_id) + + where = TaskingTask.project_id == project_id + if status_filter is not None: + where = where & (TaskingTask.status == status_filter) + if last_mapper_id is not None: + where = where & (TaskingTask.last_mapper_id == str(last_mapper_id)) + + total_q = await self.session.execute( + select(func.count()).select_from(TaskingTask).where(where) + ) + total = int(total_q.scalar() or 0) + + page = max(page, 1) + page_size = max(min(page_size, 1000), 1) + offset = (page - 1) * page_size + + rows = await self.session.execute( + select(TaskingTask) + .where(where) + .order_by(TaskingTask.task_number.asc()) + .limit(page_size) + .offset(offset) + ) + tasks = list(rows.scalars().all()) + + responses: list[TaskResponse] = [] + for t in tasks: + tr = await self._to_task_response(t) + if locked_by_user_id is not None and ( + tr.lock is None or tr.lock.user_id != locked_by_user_id + ): + continue + responses.append(tr) + + return TaskListResponse( + tasks=responses, + pagination=Pagination(page=page, page_size=page_size, total=total), + ) + + async def get_task( + self, workspace_id: int, project_id: int, task_number: int + ) -> TaskResponse: + await self._get_project(workspace_id, project_id) + task = await self._get_task(project_id, task_number) + return await self._to_task_response(task) + + async def get_task_geojson( + self, workspace_id: int, project_id: int, task_number: int + ) -> TaskBoundariesFeatureCollection: + await self._get_project(workspace_id, project_id) + task = await self._get_task(project_id, task_number) + geom_shape = to_shape(task.geometry) if task.geometry is not None else None + if geom_shape is None or not isinstance(geom_shape, ShapelyPolygon): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Task geometry missing or non-Polygon", + ) + task_geometry = _shapely_polygon_to_geojson(geom_shape) + task_feature = TaskBoundaryFeature( + type="Feature", + geometry=task_geometry, + properties={"taskNumber": task.task_number}, + ) + task_feature_collection = TaskBoundariesFeatureCollection( + type="FeatureCollection", + features=[task_feature], + ) + return task_feature_collection + + # ---- lock / unlock / extend / reset ---------------------------------- + + async def lock_task( + self, + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo, + ) -> TaskResponse: + project = await self._get_project(workspace_id, project_id) + if project.status != ProjectStatus.OPEN: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Project is not open", + ) + + task = await self._get_task(project_id, task_number) + + # Eligibility table. + role = await self._project_role(project_id, current_user, workspace_id) + if role is None: + raise ForbiddenException("User has no access to this project") + + if task.status in (TaskStatus.TO_MAP, TaskStatus.TO_REMAP): + if role not in ("contributor", "validator", "lead"): + raise ForbiddenException( + "Role does not permit locking this task for mapping" + ) + elif task.status == TaskStatus.TO_REVIEW: + if role not in ("validator", "lead"): + raise ForbiddenException( + "Role does not permit locking this task for validation" + ) + if task.last_mapper_id and task.last_mapper_id == str( + current_user.user_uuid + ): + raise ForbiddenException("Cannot validate a task you last mapped") + else: + raise ForbiddenException("Task is in a terminal state") + + # One active lock per task. + if await self._get_active_lock(task.id) is not None: # type: ignore[arg-type] + raise AlreadyExistsException("Task is already locked") + + # One active lock per (project, user). + other = await self._get_active_lock_for_user_in_project( + project_id, str(current_user.user_uuid) + ) + if other is not None: + other_task_rs = await self.session.execute( + select(TaskingTask).where(TaskingTask.id == other.task_id) + ) + other_task = other_task_rs.scalar_one() + summary = ExistingLockSummary( + task_number=other_task.task_number, + task_status=other_task.status, + locked_at=other.locked_at, + expires_at=other.expires_at, + ) + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "message": "User already holds a lock in this project", + "existing_lock": summary.model_dump(mode="json"), + }, + ) + + now = datetime.now() + expires_at = now + timedelta(hours=project.lock_timeout_hours) + lock = TaskingLock( + task_id=task.id, # type: ignore[arg-type] + project_id=project_id, + user_auth_uid=str(current_user.user_uuid), + task_status_at_lock=task.status, + locked_at=now, + expires_at=expires_at, + ) + + try: + self.session.add(lock) + await self.session.flush() + await self._audit( + event_type=AuditEventType.TASK_LOCKED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={"taskNumber": task.task_number}, + ) + await self.session.commit() + except IntegrityError: + await self.session.rollback() + raise AlreadyExistsException( + "Task is already locked or user already holds a lock in this project" + ) + + return await self._to_task_response(task) + + async def unlock_task( + self, + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo, + force: bool = False, + ) -> None: + await self._get_project(workspace_id, project_id) + task = await self._get_task(project_id, task_number) + lock = await self._get_active_lock(task.id) # type: ignore[arg-type] + if lock is None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Task has no active lock", + ) + + if force: + if not current_user.isWorkspaceLead(workspace_id): + raise ForbiddenException("Only LEAD may force-release a lock") + release_reason = LockReleaseReason.LEAD_RELEASE + else: + if lock.user_auth_uid != str(current_user.user_uuid): + raise ForbiddenException("Only the lock holder may release it") + release_reason = LockReleaseReason.MANUAL + + now = datetime.now() + await self.session.execute( + update(TaskingLock) + .where(TaskingLock.id == lock.id) + .values(released_at=now, release_reason=release_reason) + ) + await self._audit( + event_type=AuditEventType.TASK_UNLOCKED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "releaseReason": release_reason.value, + }, + ) + await self.session.commit() + + async def extend_lock( + self, + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo, + ) -> TaskResponse: + project = await self._get_project(workspace_id, project_id) + task = await self._get_task(project_id, task_number) + lock = await self._get_active_lock(task.id) # type: ignore[arg-type] + if lock is None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Task has no active lock to extend", + ) + if lock.user_auth_uid != str(current_user.user_uuid): + raise ForbiddenException("Only the lock holder may extend the lock") + + # `expires_at` comes back tz-aware from Postgres (TIMESTAMPTZ); + # the SQLModel column is typed as naive `datetime`. Strip tzinfo + # before re-binding to avoid asyncpg's offset-aware/naive + # mismatch error. + prev = lock.expires_at + if prev.tzinfo is not None: + prev = prev.replace(tzinfo=None) + new_expiry = prev + timedelta(hours=project.lock_timeout_hours) + await self.session.execute( + update(TaskingLock) + .where(TaskingLock.id == lock.id) + .values(expires_at=new_expiry) + ) + await self._audit( + event_type=AuditEventType.TASK_LOCK_EXTENDED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "expiresAt": new_expiry.isoformat(), + }, + ) + await self.session.commit() + return await self._to_task_response(task) + + async def reset_task( + self, + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo, + ) -> TaskResponse: + project = await self._get_project(workspace_id, project_id) + if project.status == ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Cannot reset a task while the project is in draft", + ) + + task = await self._get_task(project_id, task_number) + + now = datetime.now() + # Release any active lock. + lock = await self._get_active_lock(task.id) # type: ignore[arg-type] + if lock is not None: + await self.session.execute( + update(TaskingLock) + .where(TaskingLock.id == lock.id) + .values( + released_at=now, + release_reason=LockReleaseReason.RESET, + ) + ) + await self._audit( + event_type=AuditEventType.TASK_UNLOCKED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "releaseReason": LockReleaseReason.RESET.value, + }, + ) + + previous_status = task.status + if previous_status != TaskStatus.TO_MAP: + await self.session.execute( + update(TaskingTask) + .where(TaskingTask.id == task.id) + .values( + status=TaskStatus.TO_MAP, + last_mapper_id=None, + updated_at=now, + ) + ) + await self._audit( + event_type=AuditEventType.TASK_STATE_CHANGED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "from": previous_status.value, + "to": TaskStatus.TO_MAP.value, + }, + ) + else: + # Clear last_mapper_id even if state was already to_map. + await self.session.execute( + update(TaskingTask) + .where(TaskingTask.id == task.id) + .values(last_mapper_id=None, updated_at=now) + ) + + await self._audit( + event_type=AuditEventType.TASK_RESET, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={"taskNumber": task.task_number}, + ) + + await self.session.commit() + refreshed = await self._get_task(project_id, task_number) + return await self._to_task_response(refreshed) + + # ---- submit ---------------------------------------------------------- + + async def submit( + self, + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo, + body: SubmitRequest, + ) -> TaskResponse: + project = await self._get_project(workspace_id, project_id) + task = await self._get_task(project_id, task_number) + lock = await self._get_active_lock(task.id) # type: ignore[arg-type] + if lock is None or lock.user_auth_uid != str(current_user.user_uuid): + raise ForbiddenException("Caller does not hold the active lock") + + # Effective role for the *current* task status — drives the + # state transition table. + if task.status in (TaskStatus.TO_MAP, TaskStatus.TO_REMAP): + actor_role = "mapper" + elif task.status == TaskStatus.TO_REVIEW: + actor_role = "validator" + else: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Task is in a terminal state", + ) + + # Feedback is only meaningful in validator context. + # if body.feedback is not None and actor_role != "validator": + # raise HTTPException( + # status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + # detail="feedback is only accepted in validator context", + # ) + + now = datetime.now() + + # # Record the changeset row. + # cs = TaskingChangeset( + # task_id=task.id, # type: ignore[arg-type] + # project_id=project_id, + # lock_id=lock.id, # type: ignore[arg-type] + # user_auth_uid=str(current_user.user_uuid), + # osm_changeset_id=body.osm_changeset_id, + # submitted_at=now, + # ) + # self.session.add(cs) + # await self.session.flush() + + # await self._audit( + # event_type=AuditEventType.CHANGESET_SUBMITTED, + # project_id=project_id, + # task_id=task.id, + # actor_uuid=current_user.user_uuid, + # details={ + # "taskNumber": task.task_number, + # "osmChangesetId": body.osm_changeset_id, + # "done": body.done, + # }, + # ) + + if not body.done: + # Slide lock expiry from submitted_at + lock_timeout_hours. + new_expiry = now + timedelta(hours=project.lock_timeout_hours) + await self.session.execute( + update(TaskingLock) + .where(TaskingLock.id == lock.id) + .values(expires_at=new_expiry) + ) + await self._audit( + event_type=AuditEventType.TASK_LOCK_RENEWED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "expiresAt": new_expiry.isoformat(), + }, + ) + await self.session.commit() + refreshed = await self._get_task(project_id, task_number) + return await self._to_task_response(refreshed) + + # done = True → resolve next status per transition table. + previous_status = task.status + new_last_mapper = task.last_mapper_id + + if actor_role == "mapper": + new_last_mapper = str(current_user.user_uuid) + if previous_status == TaskStatus.TO_MAP: + new_status = ( + TaskStatus.TO_REVIEW + if project.review_required + else TaskStatus.COMPLETED + ) + else: # to_remap + new_status = TaskStatus.TO_REVIEW + else: # validator + if body.feedback is not None: + if body.feedback.reason_category is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="feedback.reasonCategory is required when sending feedback", + ) + # Insert the remap feedback row. + self.session.add( + TaskingFeedback( + task_id=task.id, # type: ignore[arg-type] + project_id=project_id, + author_user_auth_uid=str(current_user.user_uuid), + reason_category=body.feedback.reason_category, + notes=body.feedback.notes, + created_at=now, + ) + ) + await self._audit( + event_type=AuditEventType.FEEDBACK_SUBMITTED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "reasonCategory": body.feedback.reason_category.value, + }, + ) + new_status = TaskStatus.TO_REMAP + else: + new_status = TaskStatus.COMPLETED + + # Release the lock (auto_unlock). + await self.session.execute( + update(TaskingLock) + .where(TaskingLock.id == lock.id) + .values( + released_at=now, + release_reason=LockReleaseReason.AUTO_UNLOCK, + ) + ) + await self._audit( + event_type=AuditEventType.TASK_UNLOCKED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "releaseReason": LockReleaseReason.AUTO_UNLOCK.value, + }, + ) + + # Apply state transition. + await self.session.execute( + update(TaskingTask) + .where(TaskingTask.id == task.id) + .values( + status=new_status, + last_mapper_id=new_last_mapper, + updated_at=now, + ) + ) + if new_status != previous_status: + await self._audit( + event_type=AuditEventType.TASK_STATE_CHANGED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "from": previous_status.value, + "to": new_status.value, + }, + ) + + await self.session.commit() + refreshed = await self._get_task(project_id, task_number) + return await self._to_task_response(refreshed) + + async def submit_changeset( + self, + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo, + changesetId: int, + ) -> int: + project = await self._get_project(workspace_id, project_id) + task = await self._get_task(project_id, task_number) + lock = await self._get_active_lock(task.id) # type: ignore[arg-type] + if lock is None or lock.user_auth_uid != str(current_user.user_uuid): + raise ForbiddenException("Caller does not hold the active lock") + now = datetime.now() + + # Record the changeset row. + cs = TaskingChangeset( + task_id=task.id, # type: ignore[arg-type] + project_id=project_id, + lock_id=lock.id, # type: ignore[arg-type] + user_auth_uid=str(current_user.user_uuid), + osm_changeset_id=changesetId, + submitted_at=now, + ) + self.session.add(cs) + await self.session.flush() + # Get the id of the newly inserted changeset row + changeset_row_id = cs.id # type: ignore[assignment] + print(f"Inserted changeset row with ID: {changeset_row_id}") + + # Audit record for changeset submission + await self._audit( + event_type=AuditEventType.CHANGESET_SUBMITTED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "osmChangesetId": changesetId, + }, + ) + await self.session.commit() + return cs.id # Return the ID of the newly inserted changeset row + + +__all__ = ["TaskingTaskRepository"] diff --git a/api/src/tasking/tasks/routes.py b/api/src/tasking/tasks/routes.py new file mode 100644 index 0000000..08fe0b3 --- /dev/null +++ b/api/src/tasking/tasks/routes.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +from typing import Annotated, Optional +from uuid import UUID + +from fastapi import ( + APIRouter, + Body, + Depends, + Header, + HTTPException, + Query, + Response, + status, +) +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.database import get_osm_session, get_task_session +from api.core.security import UserInfo, validate_token +from api.src.tasking.tasks.dtos import ( + SaveTasksRequest, + SaveTasksResponse, + SubmitRequest, + SubmitTaskChangeset, + SubmitTaskChangesetResponse, + TaskBoundariesFeatureCollection, + TaskListResponse, + TaskResponse, + ValidatePreviewResponse, +) +from api.src.tasking.tasks.repository import TaskingTaskRepository +from api.src.tasking.tasks.schemas import TaskStatus +from api.src.workspaces.repository import WorkspaceRepository + +router = APIRouter( + prefix="/workspaces/{workspace_id}/tasking/projects/{project_id}", + tags=["tasking-tasks"], +) + + +# --------------------------------------------------------------------------- +# Dependencies +# --------------------------------------------------------------------------- + + +def get_task_repo( + session: AsyncSession = Depends(get_osm_session), +) -> TaskingTaskRepository: + return TaskingTaskRepository(session) + + +def get_workspace_repo( + session: AsyncSession = Depends(get_task_session), +) -> WorkspaceRepository: + return WorkspaceRepository(session) + + +async def assert_workspace_visible( + workspace_id: int, + current_user: UserInfo, + workspace_repo: WorkspaceRepository, +) -> None: + await workspace_repo.getById(current_user, workspace_id) + + +def assert_workspace_lead(workspace_id: int, current_user: UserInfo) -> None: + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User does not have permission to edit this workspace", + ) + + +# --------------------------------------------------------------------------- +# Tasks — validate / save / list / get +# --------------------------------------------------------------------------- + + +@router.post("/tasks/grid", response_model=TaskBoundariesFeatureCollection) +async def generate_grid( + workspace_id: int, + project_id: int, + cell_size_meters: int = Query(1000, ge=50, le=100_000), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + """Generate a regular grid of square cells over the project AOI. + + LEAD-only preview — does NOT persist. The client posts the same + FeatureCollection back through `POST /tasks/save` to commit. + """ + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await task_repo.generate_grid(workspace_id, project_id, cell_size_meters) + + +@router.post("/tasks/validate", response_model=ValidatePreviewResponse) +async def validate_tasks( + workspace_id: int, + project_id: int, + body: TaskBoundariesFeatureCollection = Body(...), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await task_repo.validate(workspace_id, project_id, body) + + +@router.post("/tasks/save", response_model=SaveTasksResponse) +async def save_tasks( + workspace_id: int, + project_id: int, + body: SaveTasksRequest, + response: Response, + idempotency_key: Annotated[ + Optional[str], Header(alias="Idempotency-Key", min_length=8, max_length=128) + ] = None, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + payload, replayed = await task_repo.save( + workspace_id, project_id, current_user, body, idempotency_key + ) + response.status_code = status.HTTP_200_OK if replayed else status.HTTP_201_CREATED + return payload + + +@router.get("/tasks", response_model=TaskListResponse) +async def list_tasks( + workspace_id: int, + project_id: int, + status_filter: Annotated[Optional[TaskStatus], Query(alias="status")] = None, + locked_by_user_id: Optional[UUID] = Query(default=None), + last_mapper_id: Optional[UUID] = Query(default=None), + page: int = Query(1, ge=1), + page_size: int = Query(200, ge=1, le=1000), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await task_repo.list_tasks( + workspace_id, + project_id, + status_filter=status_filter, + locked_by_user_id=locked_by_user_id, + last_mapper_id=last_mapper_id, + page=page, + page_size=page_size, + ) + + +@router.get("/tasks/{task_number}", response_model=TaskResponse) +async def get_task( + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await task_repo.get_task(workspace_id, project_id, task_number) + + +@router.get( + "/tasks/{task_number}/boundary.geojson", + response_model=TaskBoundariesFeatureCollection, +) +async def get_task_geojson( + workspace_id: int, + project_id: int, + task_number: int, + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + return await task_repo.get_task_geojson(workspace_id, project_id, task_number) + + +# --------------------------------------------------------------------------- +# Locks — acquire / release / extend / reset +# --------------------------------------------------------------------------- + + +@router.post("/tasks/{task_number}/lock", response_model=TaskResponse) +async def lock_task( + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await task_repo.lock_task( + workspace_id, project_id, task_number, current_user + ) + + +@router.delete( + "/tasks/{task_number}/lock", + status_code=status.HTTP_204_NO_CONTENT, +) +async def unlock_task( + workspace_id: int, + project_id: int, + task_number: int, + force: bool = Query(False), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + await task_repo.unlock_task( + workspace_id, + project_id, + task_number, + current_user, + force=force, + ) + + +@router.post("/tasks/{task_number}/extend", response_model=TaskResponse) +async def extend_lock( + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await task_repo.extend_lock( + workspace_id, project_id, task_number, current_user + ) + + +@router.post("/tasks/{task_number}/reset", response_model=TaskResponse) +async def reset_task( + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await task_repo.reset_task( + workspace_id, project_id, task_number, current_user + ) + + +# --------------------------------------------------------------------------- +# Submit — Done? flow +# --------------------------------------------------------------------------- + + +@router.post("/tasks/{task_number}/submit", response_model=TaskResponse) +async def submit_task( + workspace_id: int, + project_id: int, + task_number: int, + body: SubmitRequest, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await task_repo.submit( + workspace_id, project_id, task_number, current_user, body + ) + + +@router.post( + "/tasks/{task_number}/submit-changeset", response_model=SubmitTaskChangesetResponse +) +async def submit_changeset( + workspace_id: int, + project_id: int, + task_number: int, + changeset_model: SubmitTaskChangeset, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + cs_id = await task_repo.submit_changeset( + workspace_id, + project_id, + task_number, + current_user, + changeset_model.osm_changeset_id, + ) + return SubmitTaskChangesetResponse( + osm_changeset_id=changeset_model.osm_changeset_id, + task_number=task_number, + project_id=project_id, + workspace_id=workspace_id, + inserted_id=cs_id, + ) diff --git a/api/src/tasking/tasks/schemas.py b/api/src/tasking/tasks/schemas.py new file mode 100644 index 0000000..f3c114b --- /dev/null +++ b/api/src/tasking/tasks/schemas.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from enum import StrEnum +from typing import Any, Optional + +from geoalchemy2 import Geometry +from sqlalchemy import Column +from sqlalchemy import Enum as SAEnum +from sqlmodel import Field, SQLModel + +# --------------------------------------------------------------------------- +# Enums (mirrors of postgres enums in the migration) +# --------------------------------------------------------------------------- + + +class TaskStatus(StrEnum): + TO_MAP = "to_map" + TO_REVIEW = "to_review" + TO_REMAP = "to_remap" + COMPLETED = "completed" + + +class LockReleaseReason(StrEnum): + AUTO_UNLOCK = "auto_unlock" + MANUAL = "manual" + LEAD_RELEASE = "lead_release" + STALE_TIMEOUT = "stale_timeout" + RESET = "reset" + + +class FeedbackReason(StrEnum): + INCOMPLETE_MAPPING = "incomplete_mapping" + DATA_QUALITY_ISSUE = "data_quality_issue" + WRONG_AREA = "wrong_area" + OTHER = "other" + + +def _task_status_column(*, nullable: bool = False) -> Column: + return Column( + SAEnum( + TaskStatus, + name="tasking_task_status", + create_type=False, + values_callable=lambda enum: [m.value for m in enum], + ), + nullable=nullable, + ) + + +def _release_reason_column() -> Column: + return Column( + SAEnum( + LockReleaseReason, + name="tasking_lock_release_reason", + create_type=False, + values_callable=lambda enum: [m.value for m in enum], + ), + nullable=True, + ) + + +def _feedback_reason_column() -> Column: + return Column( + SAEnum( + FeedbackReason, + name="tasking_feedback_reason", + create_type=False, + values_callable=lambda enum: [m.value for m in enum], + ), + nullable=True, + ) + + +# --------------------------------------------------------------------------- +# Table models +# --------------------------------------------------------------------------- + + +class TaskingTask(SQLModel, table=True): + """Per-project task polygon — saved as part of a bulk batch.""" + + __tablename__ = "tasking_tasks" # type: ignore[assignment] + + id: Optional[int] = Field(default=None, primary_key=True) + project_id: int = Field(nullable=False, index=True) + task_number: int = Field(nullable=False) + area_sqkm: Decimal = Field(nullable=False) + + status: TaskStatus = Field( + default=TaskStatus.TO_MAP, + sa_column=_task_status_column(), + ) + + last_mapper_id: Optional[str] = Field(default=None, nullable=True) + + geometry: Optional[Any] = Field( + default=None, + sa_column=Column( + Geometry(geometry_type="POLYGON", srid=4326), + nullable=False, + ), + ) + + created_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False}, + ) + updated_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False, "onupdate": datetime.now}, + ) + + +class TaskingLock(SQLModel, table=True): + """Active / historical lock on a task. + + Active rows have `released_at IS NULL`. Two partial unique indexes + enforce: at most one active lock per task, and at most one active + lock per (project, user). + """ + + __tablename__ = "tasking_locks" # type: ignore[assignment] + + id: Optional[int] = Field(default=None, primary_key=True) + task_id: int = Field(nullable=False) + project_id: int = Field(nullable=False) + user_auth_uid: str = Field(nullable=False) + + task_status_at_lock: TaskStatus = Field( + sa_column=_task_status_column(), + ) + + locked_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False}, + ) + expires_at: datetime = Field(nullable=False) + released_at: Optional[datetime] = None + + release_reason: Optional[LockReleaseReason] = Field( + default=None, + sa_column=_release_reason_column(), + ) + + +class TaskingChangeset(SQLModel, table=True): + """One row per `/submit` call — links a lock session to an OSM changeset.""" + + __tablename__ = "tasking_changesets" # type: ignore[assignment] + + id: Optional[int] = Field(default=None, primary_key=True) + task_id: int = Field(nullable=False) + project_id: int = Field(nullable=False) + lock_id: int = Field(nullable=False) + user_auth_uid: str = Field(nullable=False) + osm_changeset_id: int = Field(nullable=False) + + submitted_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False}, + ) + + +class TaskingFeedback(SQLModel, table=True): + """Per-task feedback row (remap rejections + free-form notes).""" + + __tablename__ = "tasking_feedback" # type: ignore[assignment] + + id: Optional[int] = Field(default=None, primary_key=True) + task_id: int = Field(nullable=False) + project_id: int = Field(nullable=False) + author_user_auth_uid: str = Field(nullable=False) + + reason_category: Optional[FeedbackReason] = Field( + default=None, + sa_column=_feedback_reason_column(), + ) + notes: str = Field(nullable=False) + + created_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False}, + ) + + +__all__ = [ + "FeedbackReason", + "LockReleaseReason", + "TaskStatus", + "TaskingChangeset", + "TaskingFeedback", + "TaskingLock", + "TaskingTask", +] diff --git a/docs/db-schema/db-schema.png b/docs/db-schema/db-schema.png new file mode 100644 index 0000000..fbd1187 Binary files /dev/null and b/docs/db-schema/db-schema.png differ diff --git a/docs/requirements/project-roles-integration.md b/docs/requirements/project-roles-integration.md new file mode 100644 index 0000000..87b25a9 --- /dev/null +++ b/docs/requirements/project-roles-integration.md @@ -0,0 +1,19 @@ +# User Role Management — Workspaces + Tasking Manager + +## Current Behaviour (Workspaces) + +- Users authenticate via TDEI and inherit **implicit contributor** access across all workspaces in their project groups — no manual provisioning needed. +- The user who creates a workspace is automatically assigned the **`lead`** role for that workspace and recorded in the system. +- Today, all collaboration within a workspace operates under this single workspace-level role model. + +## New Requirement (Tasking Manager) + +Tasking Manager introduces **project-level roles** (`lead`, `validator`, `contributor`) so that work inside a workspace can be delegated and reviewed by specific people, not just by anyone with workspace access. + +To deliver this, two new capabilities are needed: + +### 1. User Search +A workspace lead creating or managing a project must be able to search for the right users from within their project group — by name or username — and pick them. This requires **integration with the TDEI user-search API** to surface candidate users in the UI. + +### 2. Role Assignment +Once selected, users are assigned a project-level role (`lead`, `validator`, or `contributor`) and recorded against the project. The lead can later **add**, **change**, or **remove** these assignments as the project evolves, with safeguards to ensure every project always has at least one `lead`. \ No newline at end of file diff --git a/docs/tasking-mvp/tasking-mvp.openapi.json b/docs/tasking-mvp/tasking-mvp.openapi.json new file mode 100644 index 0000000..6e066dc --- /dev/null +++ b/docs/tasking-mvp/tasking-mvp.openapi.json @@ -0,0 +1,3358 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Workspaces Tasking Manager API", + "version": "1.0.0", + "description": "Tasking Manager API for the Workspaces Tasking Manager MVP.\n" + }, + "servers": [ + { + "url": "/api/v1", + "description": "workspaces-backend FastAPI app \u2014 all routers are mounted under `/api/v1` (see `api/main.py`)." + } + ], + "tags": [ + { + "name": "projects", + "description": "Tasking project CRUD and lifecycle." + }, + { + "name": "aoi", + "description": "Project area-of-interest upload, retrieval, deletion." + }, + { + "name": "tasks", + "description": "Task validation, persistence and read access." + }, + { + "name": "locks", + "description": "Lock lifecycle on a task." + }, + { + "name": "submit", + "description": "Done? submit flow \u2014 changeset + state transition." + }, + { + "name": "roles", + "description": "Workspace and project role management." + }, + { + "name": "audit", + "description": "Audit trail." + }, + { + "name": "stats", + "description": "Project and user statistics." + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "paths": { + "/workspaces/{workspace_id}/tasking/projects": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + } + ], + "get": { + "tags": [ + "projects" + ], + "summary": "List tasking projects in a workspace.", + "description": "Paginated list of non-deleted projects. Any workspace contributor.", + "operationId": "listProjects", + "parameters": [ + { + "in": "query", + "name": "status", + "schema": { + "$ref": "#/components/schemas/ProjectStatus" + } + }, + { + "in": "query", + "name": "textSearch", + "schema": { + "type": "string", + "maxLength": 255 + }, + "description": "Case-insensitive substring match on `name`." + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "in": "query", + "name": "pageSize", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 20 + } + }, + { + "in": "query", + "name": "orderBy", + "schema": { + "type": "string", + "enum": [ + "createdAt", + "updatedAt", + "name" + ], + "default": "createdAt" + } + }, + { + "in": "query", + "name": "orderByType", + "schema": { + "type": "string", + "enum": [ + "ASC", + "DESC" + ], + "default": "DESC" + } + } + ], + "responses": { + "200": { + "description": "Paginated list.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectListResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/WorkspaceNotFound" + } + } + }, + "post": { + "tags": [ + "projects" + ], + "summary": "Create a tasking project (optionally with AOI and role assignments).", + "description": "LEAD only. Creates a project in `draft` status.\n\nOptional inline AOI may be provided (same validation as\n`POST .../aoi`).\n\nOptional `roleAssignments` may be provided to seed the\nproject-level role overrides table (`tasking_project_roles`) in\nthe same transaction. The creator is auto-added with\n`role = lead`; other users may be added with any of `lead`,\n`validator`, `contributor`.\n\nActivation later requires at least one user assigned with\n`contributor` or `validator` so the project has at least one\nworker before it goes live \u2014 see `POST .../activate`.\n", + "operationId": "createProject", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectCreateRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/WorkspaceNotFound" + }, + "409": { + "description": "A non-deleted project with this name already exists.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "get": { + "tags": [ + "projects" + ], + "summary": "Get a tasking project.", + "operationId": "getProject", + "responses": { + "200": { + "description": "Project detail.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + } + } + }, + "patch": { + "tags": [ + "projects" + ], + "summary": "Update editable settings.", + "description": "LEAD only. Per-status mutability:\n\n| Field | draft | open | done |\n|--------------------|-------|------|------|\n| name | yes | yes | no |\n| instructions | yes | yes | no |\n| lockTimeoutHours | yes | yes | no |\n| reviewRequired | yes | NO | no |\n\nAOI is updated through the dedicated AOI endpoints, not PATCH.\nImmutable-field writes return 422.\n", + "operationId": "updateProject", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectUpdateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Updated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + } + } + }, + "delete": { + "tags": [ + "projects" + ], + "summary": "Soft-delete a tasking project.", + "description": "LEAD only. Sets `deletedAt`, hard-deletes child tasks, retains\naudit rows (flagged with `project_deleted=true`). Returns 409\nif any task currently has an active lock \u2014 force-release first.\n", + "operationId": "deleteProject", + "responses": { + "204": { + "description": "Soft-deleted." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "409": { + "description": "Project has active task locks.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/activate": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "post": { + "tags": [ + "projects" + ], + "summary": "Activate a draft project (`draft` \u2192 `open`).", + "description": "LEAD only. Pre-conditions (all must hold; 422 with a clear\n`detail` otherwise):\n - Project has a `name`.\n - Project has an AOI set.\n - Project has at least one saved task.\n - At least one user is allocated to the project with role\n `contributor` or `validator` in `tasking_project_roles`\n (the creator's auto-`lead` row does not satisfy this \u2014 a\n worker must be assigned).\n", + "operationId": "activateProject", + "responses": { + "200": { + "description": "Activated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/close": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "post": { + "tags": [ + "projects" + ], + "summary": "Close an open project (`open` \u2192 `done`).", + "description": "LEAD only. Requires every task to be `completed` and no active\nlocks.\n", + "operationId": "closeProject", + "responses": { + "200": { + "description": "Closed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "409": { + "description": "One or more tasks are still locked or not completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/reset": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "post": { + "tags": [ + "projects" + ], + "summary": "Reset all tasks in a project back to `to_map`.", + "description": "LEAD only. Restarts work on the whole project so contributors\ncan re-map and re-validate.\n\nEffect (single transaction):\n - Releases every active lock with `release_reason = 'reset'`,\n emitting one `task_unlocked` audit event per released lock.\n - Sets every task's `status` to `to_map`, clears\n `last_mapper_id`, emits a `task_state_changed` audit event\n for every task whose status actually changed.\n - If the project was `done`, transitions it back to `open`.\n - Emits one `project_reset` audit event for the project.\n\nTasks themselves (geometry, history of changesets and remap\nfeedback) are NOT deleted \u2014 only their lifecycle state is\nrewound. Stats and audit history remain intact.\n", + "operationId": "resetProject", + "responses": { + "200": { + "description": "Reset complete.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "422": { + "description": "Project is in `draft` (nothing to reset).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/aoi": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "get": { + "tags": [ + "aoi" + ], + "summary": "Get the AOI of a project.", + "description": "Returns the AOI as a GeoJSON Feature (Polygon, EPSG:4326).\n`404` if no AOI is set.\n", + "operationId": "getProjectAoi", + "responses": { + "200": { + "description": "AOI.", + "content": { + "application/geo+json": { + "schema": { + "$ref": "#/components/schemas/AoiFeature" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/AoiFeature" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + } + } + }, + "post": { + "tags": [ + "aoi" + ], + "summary": "Upload or replace the AOI.", + "description": "LEAD only. Accepts a GeoJSON `Feature`, `FeatureCollection`\n(single feature), or bare geometry. Geometry may be either\n`Polygon` or `MultiPolygon` (EPSG:4326). Single-Polygon inputs\nare upcast to MultiPolygon on insert; the storage column is\n`GEOMETRY(MultiPolygon, 4326)`.\n\nProject must be in `draft`. Replacing an AOI hard-deletes any\nsaved tasks.\n", + "operationId": "uploadProjectAoi", + "requestBody": { + "required": true, + "content": { + "application/geo+json": { + "schema": { + "$ref": "#/components/schemas/AoiUploadRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/AoiUploadRequest" + } + } + } + }, + "responses": { + "200": { + "description": "AOI stored.", + "content": { + "application/geo+json": { + "schema": { + "$ref": "#/components/schemas/AoiFeature" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/AoiFeature" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + } + } + }, + "delete": { + "tags": [ + "aoi" + ], + "summary": "Delete the AOI.", + "description": "LEAD only. Project must be in `draft`. Clears the AOI, hard-\ndeletes any saved tasks (releasing their locks in the same\ntransaction), clears `taskBoundaryType`. Returns 404 if no AOI\nis set.\n", + "operationId": "deleteProjectAoi", + "responses": { + "204": { + "description": "AOI deleted." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "description": "Project not found, or project has no AOI.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Project is not in `draft`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/validate": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "post": { + "tags": [ + "tasks" + ], + "summary": "Validate a GeoJSON FeatureCollection of candidate task boundaries.", + "description": "LEAD only. Does NOT persist anything \u2014 the client must POST each\nvalidated Feature to `/tasks/save` to commit.\n\nProject preconditions: status `draft`, AOI uploaded.\n\nHard validation (422 on failure):\n - Top-level type must be `FeatureCollection`.\n - Every feature geometry must be `Polygon` (no MultiPolygon).\n - Coordinates valid lon/lat (EPSG:4326).\n - Every polygon lies within the project AOI (centroid-inside\n is not sufficient).\n - At least one feature.\n\nNon-blocking warning:\n - `polygon_exceeds_grid_size` \u2014 area exceeds\n `TM_TASKING_GRID_SIZE_METERS`\u00b2 (km\u00b2).\n", + "operationId": "validateTasks", + "requestBody": { + "required": true, + "content": { + "application/geo+json": { + "schema": { + "$ref": "#/components/schemas/TaskBoundariesFeatureCollection" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskBoundariesFeatureCollection" + } + } + } + }, + "responses": { + "200": { + "description": "Validation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidatePreviewResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/save": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/IdempotencyKeyHeader" + } + ], + "post": { + "tags": [ + "tasks" + ], + "summary": "Persist a validated FeatureCollection as the project's tasks (bulk).", + "description": "LEAD only. Commits the **entire** FeatureCollection as\n`tasking_tasks` rows in a single transaction.\n\nEffect (single transaction; all-or-nothing):\n 1. Re-validates every feature against the project AOI (same\n rules as `POST /tasks/validate`). Any hard failure aborts\n the transaction \u2014 no rows are written.\n 2. Allocates sequential `taskNumber`s starting at 1, in the\n order features appear in the request.\n 3. Bulk-inserts the `tasking_tasks` rows.\n 4. Sets `tasking_projects.task_boundary_type` to `source`.\n 5. Emits one `task_created` audit event per task.\n\nProject preconditions:\n - Project must be in `draft`.\n - Project must have an AOI set.\n - Project must currently have **no tasks** (a second save is\n rejected with 409 `\"Tasks already saved\"`). To replace\n tasks, re-upload the AOI in `draft` \u2014 that wipes existing\n tasks and re-enables save.\n\nIdempotency: `Idempotency-Key` is honoured at the batch level.\nSame key + same body within the configured TTL replays the\noriginal response (HTTP 200, `replayed: true`). Same key with\ndifferent body returns 409\n`\"Idempotency key reused with a different request\"`. See the\ntop-level idempotency contract.\n", + "operationId": "saveTasks", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveTasksRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Tasks created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveTasksResponse" + } + } + } + }, + "200": { + "description": "Idempotent replay (same key + same body); `replayed: true`.\n", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveTasksResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "409": { + "description": "One of:\n - \"Tasks already saved\" \u2014 project already has tasks; re-upload AOI to start over.\n - \"Idempotency key reused with a different request\".\n", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "get": { + "tags": [ + "tasks" + ], + "summary": "List tasks (geometry always included; serves list + map views).", + "description": "Paginated. Any workspace contributor.\n", + "operationId": "listTasks", + "parameters": [ + { + "in": "query", + "name": "status", + "schema": { + "$ref": "#/components/schemas/TaskStatus" + } + }, + { + "in": "query", + "name": "lockedByUserId", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "lastMapperId", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "in": "query", + "name": "pageSize", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 200 + } + } + ], + "responses": { + "200": { + "description": "Tasks.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskListResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/TaskNumberPath" + } + ], + "get": { + "tags": [ + "tasks" + ], + "summary": "Get task detail (geometry, status, lock, last mapper).", + "operationId": "getTask", + "responses": { + "200": { + "description": "Task.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "Workspace, project, or task not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/lock": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/TaskNumberPath" + } + ], + "post": { + "tags": [ + "locks" + ], + "summary": "Acquire a lock on a task.", + "description": "Eligibility:\n\n| Task status | Allowed roles |\n|-------------|---------------------------------------------------|\n| `to_map` | CONTRIBUTOR, LEAD |\n| `to_remap` | CONTRIBUTOR, LEAD |\n| `to_review` | VALIDATOR, LEAD \u2014 and NOT this task's last_mapper |\n| `completed` | (none) |\n\nOther rules:\n - No other active lock on this task.\n - Caller holds no other active lock in this project (one-lock-\n per-project rule). 409 response includes `existingLock`.\n - Project status is `open`.\n\nSide effects:\n - INSERT `tasking_locks` with\n `expires_at = NOW() + project.lock_timeout_hours`.\n - Emit `task_locked` audit event.\n", + "operationId": "lockTask", + "responses": { + "200": { + "description": "Lock acquired. Body is the updated task.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Role does not permit locking in this status, or self-validation guard hit.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "$ref": "#/components/responses/TaskOrProjectOrWorkspaceNotFound" + }, + "409": { + "description": "One of:\n - \"Task is already locked\".\n - \"User already holds a lock in this project\" \u2014 body includes `existingLock`.\n - \"Project is not open\".\n", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LockConflictError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "locks" + ], + "summary": "Release a lock on a task.", + "description": "Default: caller must be the active lock holder\n(`release_reason = manual`).\n\nWith `?force=true`: caller must be LEAD\n(`release_reason = lead_release`).\n\nTask `status` is unchanged in both modes. Force-release does\nnot relock \u2014 LEAD must POST `/lock` separately to take over.\n", + "operationId": "unlockTask", + "parameters": [ + { + "in": "query", + "name": "force", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "204": { + "description": "Released." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Not the lock holder (default mode) or not LEAD (force mode).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "$ref": "#/components/responses/TaskOrProjectOrWorkspaceNotFound" + }, + "409": { + "description": "Task has no active lock.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/extend": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/TaskNumberPath" + } + ], + "post": { + "tags": [ + "locks" + ], + "summary": "Extend the expiry of your own lock.", + "description": "Caller must hold the active lock. Adds the project's\n`lock_timeout_hours` to the current `expires_at` (slides from\nthe existing expiry, not from `NOW()`). Emits\n`task_lock_extended`.\n", + "operationId": "extendTaskLock", + "responses": { + "200": { + "description": "Lock extended.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Caller does not hold the active lock.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "$ref": "#/components/responses/TaskOrProjectOrWorkspaceNotFound" + }, + "409": { + "description": "Task has no active lock to extend.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/reset": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/TaskNumberPath" + } + ], + "post": { + "tags": [ + "locks" + ], + "summary": "Reset a single task back to `to_map`.", + "description": "LEAD only. Restarts work on a task \u2014 useful when a contributor\nor validator pushed a task into a wrong terminal state and\nsomeone needs to redo it.\n\nEffect (single transaction):\n - If an active lock exists, it is released with\n `release_reason = 'reset'` and a `task_unlocked` audit\n event is emitted.\n - Task `status` is set to `to_map`, `last_mapper_id` is\n cleared, and a `task_state_changed` audit event is emitted\n (only when the status actually changed).\n - A `task_reset` audit event is emitted.\n\nExisting changeset rows and remap-feedback rows are NOT\ndeleted \u2014 only the live task state is rewound.\n\nPre-conditions: project status is `open` or `done`.\n", + "operationId": "resetTask", + "responses": { + "200": { + "description": "Task reset.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/TaskOrProjectOrWorkspaceNotFound" + }, + "422": { + "description": "Project is in `draft` (no tasks to reset yet).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/submit": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/TaskNumberPath" + } + ], + "post": { + "tags": [ + "submit" + ], + "summary": "Submit an OSM changeset and optionally transition state.", + "description": "The \"Done?\" flow. Caller must hold the active lock.\n\nEffective actor role is inferred from current task status:\n - `to_map` / `to_remap` \u2192 mapper context\n - `to_review` \u2192 validator context\n (LEAD may act in either context.)\n\nState transition (when `done:true`):\n\n| Current | Effective role | Feedback? | New status |\n|-------------|--------------------|-----------|---------------------------------------------------------|\n| `to_map` | contributor / lead | n/a | `to_review` if `review_required` else `completed` |\n| `to_remap` | contributor / lead | n/a | `to_review` |\n| `to_review` | validator / lead | no | `completed` |\n| `to_review` | validator / lead | yes | `to_remap` (and INSERT `tasking_feedback` with `reasonCategory` set) |\n\nWith `done:false` the state is unchanged but the lock's\n`expires_at` slides forward to `submitted_at + lock_timeout_hours`\n(emits `task_lock_renewed`).\n\n`osmChangesetId` is supplied by the client. In practice it is\nauto-populated by the editor (Rapid in the iframe) on save \u2014\nthe UI does not ask the user to type it. The id may optionally\nbe verified by looking it up in the internal `osm-web` service\n(same network); a verification miss returns `422` with detail\n\"Invalid OSM changeset id\" and nothing is written.\n\n`last_mapper_id` is updated only when the effective role is\nmapper.\n\nFeedback rules:\n - `feedback` is meaningful only in validator context on\n `to_review`. Otherwise its presence returns 422.\n - For `to_review` \u2192 `to_remap`, `feedback.reasonCategory` is\n required (422 if missing).\n", + "operationId": "submitTask", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Submission accepted.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Caller does not hold the active lock.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "$ref": "#/components/responses/TaskOrProjectOrWorkspaceNotFound" + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + } + } + } + }, + "/workspaces/{workspace_id}/users": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + } + ], + "get": { + "tags": [ + "roles" + ], + "summary": "List privileged workspace members (lead + validator role rows).", + "description": "Returns the rows in `user_workspace_roles` for this workspace,\njoined with their `users` record. Visible to any workspace\ncontributor (`isWorkspaceContributor`).\n\nDoes NOT include implicit contributors \u2014 only users with an\nexplicit LEAD or VALIDATOR row. The UI uses this for the team\nmanagement table; broader user listings come from the TDEI\nproxy (see `/assignable-users`).\n", + "operationId": "listWorkspaceMembers", + "responses": { + "200": { + "description": "Privileged members.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkspaceUserRoleItem" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Caller is not a member of the workspace's project group.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/users/{user_id}/role": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/UserIdPath" + } + ], + "put": { + "tags": [ + "roles" + ], + "summary": "Assign (upsert) a privileged role for a user on a workspace.", + "description": "LEAD only. Idempotent upsert into `user_workspace_roles`.\n\nThe body's `role` must be `lead` or `validator` \u2014 assigning\n`contributor` is rejected with 422 (\"cannot assign implicit\nrole 'contributor' directly\"). Contributor is the default for\nany project-group member; to remove an explicit role and fall\nback to contributor, call `DELETE /users/{user_id}` below.\n\nThe named user must have signed in to Workspaces at least once\n(i.e. exist in `users.auth_uid`). If they have not, returns\n404 with detail \"User {uuid} has not signed in to Workspaces\nyet\".\n\nSide effect: the target user's `UserInfo` cache entry is\nevicted (`evict_user_from_cache`) so their next request\nreflects the new role immediately.\n", + "operationId": "assignWorkspaceMemberRole", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetRoleRequest" + } + } + } + }, + "responses": { + "204": { + "description": "Role assigned (or updated)." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Caller is not LEAD on this workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Workspace not found, or the named user has not signed in to Workspaces yet.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "\"cannot assign implicit role 'contributor' directly\".", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/users/{user_id}": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/UserIdPath" + } + ], + "delete": { + "tags": [ + "roles" + ], + "summary": "Remove a user's privileged role on a workspace.", + "description": "LEAD only. Deletes the row from `user_workspace_roles`; the\nuser's role falls back to implicit `contributor` (or to\nwhatever TDEI grants \u2014 `poc` still maps to LEAD).\n\nSide effect: the target user's `UserInfo` cache entry is\nevicted.\n", + "operationId": "removeWorkspaceMemberRole", + "responses": { + "204": { + "description": "Role row removed." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Caller is not LEAD on this workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "No role row exists for this user/workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/roles": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "get": { + "tags": [ + "roles" + ], + "summary": "List role assignments on a project.", + "description": "Returns every row in `tasking_project_roles` for the project,\njoined with `users.display_name` for human-readable labels.\nAny workspace contributor; the workspace tenancy gate still\napplies (404 for outsiders).\n", + "operationId": "listProjectRoles", + "responses": { + "200": { + "description": "All role assignments.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleListResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + } + } + }, + "post": { + "tags": [ + "roles" + ], + "summary": "Add a role assignment to a project.", + "description": "Workspace LEAD **or** project LEAD only. Inserts a row in\n`tasking_project_roles`. Duplicate `(project_id, user_id)` pairs\nreturn 409 \u2014 use PATCH to change a role. The `user_id` must\nalready exist in `users` (i.e. the user has signed in to\nWorkspaces at least once); otherwise 422 with a\n`missing_user_ids` list.\n", + "operationId": "addProjectRole", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleAddRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Role added.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleItem" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "409": { + "description": "User already has a role on this project \u2014 use PATCH.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "`user_id` does not exist in the `users` table.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "missing_user_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + } + } + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/roles/{user_id}": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/UserIdPath" + } + ], + "get": { + "tags": [ + "roles" + ], + "summary": "Get a single user's role on a project.", + "description": "Returns the single `tasking_project_roles` row for this user on\nthis project. 404 if no assignment exists. Any workspace\ncontributor; the tenancy gate still applies.\n", + "operationId": "getProjectRole", + "responses": { + "200": { + "description": "Role assignment.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleItem" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "User has no role on this project, or project not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "put": { + "tags": [ + "roles" + ], + "summary": "Upsert a user's role on a project.", + "description": "Workspace LEAD **or** project LEAD only. Idempotent insert-or-\nupdate on `tasking_project_roles`. Returns **201** when the row\nis created, **200** when an existing row is updated.\n\n**Last-LEAD guard**: a PUT that demotes the only remaining LEAD\nreturns 422 \u2014 assign another LEAD first.\n\nFor partial-update semantics, prefer PATCH.\n", + "operationId": "putProjectRole", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleUpdateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Existing assignment updated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleItem" + } + } + } + }, + "201": { + "description": "New assignment created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleItem" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "422": { + "description": "One of:\n - `user_id` does not exist in `users` (insert path).\n - Would remove the last LEAD (demote path).\n", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "patch": { + "tags": [ + "roles" + ], + "summary": "Change a user's role on a project.", + "description": "Workspace LEAD **or** project LEAD only. Updates the row in\n`tasking_project_roles`. Returns 404 if the user has no role\nassignment yet (use POST to add one).\n\n**Last-LEAD guard**: demoting the only remaining LEAD on the\nproject returns 422 \u2014 assign another LEAD first.\n", + "operationId": "updateProjectRole", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleUpdateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Role updated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleItem" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "description": "User has no role on this project, or project not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Would remove the last LEAD on the project.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "tags": [ + "roles" + ], + "summary": "Remove a role assignment from a project.", + "description": "Workspace LEAD **or** project LEAD only. Deletes the row from\n`tasking_project_roles`.\n\n**Last-LEAD guard**: removing the only remaining LEAD on the\nproject returns 422 \u2014 assign another LEAD first.\n", + "operationId": "removeProjectRole", + "responses": { + "204": { + "description": "Role removed." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "description": "User has no role on this project, or project not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Would remove the last LEAD on the project.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/me/workspaces/{workspace_id}/tasking/projects/roles": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + } + ], + "get": { + "tags": [ + "roles" + ], + "summary": "List the caller's role for every project in a workspace.", + "description": "Single round-trip for the project-list page: returns one entry\nper project with the caller's role on that project.\n", + "operationId": "listSelfProjectRoles", + "responses": { + "200": { + "description": "Project roles for the caller.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SelfProjectRolesResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/WorkspaceNotFound" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/audit": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "get": { + "tags": [ + "audit" + ], + "summary": "List project-level audit events.", + "description": "Paginated, newest first. Any workspace contributor. Soft-deleted\nprojects are visible only with `include_deleted=true`.\n", + "operationId": "listProjectAudit", + "parameters": [ + { + "in": "query", + "name": "event_type", + "schema": { + "$ref": "#/components/schemas/AuditEventType" + } + }, + { + "in": "query", + "name": "task_number", + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "in": "query", + "name": "actor_user_id", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "occurred_from", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "occurred_to", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "include_deleted", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "in": "query", + "name": "page_size", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + } + }, + { + "in": "query", + "name": "order_by_type", + "schema": { + "type": "string", + "enum": [ + "ASC", + "DESC" + ], + "default": "DESC" + } + } + ], + "responses": { + "200": { + "description": "Events.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuditEventListResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/audit": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/TaskNumberPath" + } + ], + "get": { + "tags": [ + "audit" + ], + "summary": "List task-level audit events.", + "description": "Newest first. Any workspace contributor.", + "operationId": "listTaskAudit", + "parameters": [ + { + "in": "query", + "name": "event_type", + "schema": { + "$ref": "#/components/schemas/AuditEventType" + } + }, + { + "in": "query", + "name": "actor_user_id", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "occurred_from", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "occurred_to", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "in": "query", + "name": "page_size", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 25 + } + }, + { + "in": "query", + "name": "order_by_type", + "schema": { + "type": "string", + "enum": [ + "ASC", + "DESC" + ], + "default": "DESC" + } + } + ], + "responses": { + "200": { + "description": "Events for the task.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuditEventListResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/TaskOrProjectOrWorkspaceNotFound" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/stats": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "get": { + "tags": [ + "stats" + ], + "summary": "Project statistics summary.", + "description": "Live-computed. Any workspace contributor.\n\nField semantics:\n - `tasksByStatus` always zero-fills all four states.\n - `percentMapped` \u2014 share of tasks past `to_map` at least\n once. Rounded to nearest integer.\n - `percentCompleted` \u2014 share whose current status is\n `completed`.\n - `avgTaskDurationMinutes` \u2014 mean across completed tasks of\n the sum of `(released_at - locked_at)` over that task's\n locks. `null` when no task is completed yet.\n - `uniqueMappers` \u2014 distinct submitters with at least one\n changeset while task was in `to_map` or `to_remap`.\n - `uniqueValidators` \u2014 distinct submitters with at least one\n changeset while task was in `to_review`.\n", + "operationId": "getProjectStats", + "responses": { + "200": { + "description": "Stats.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectStats" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/users/{user_id}/stats": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/UserIdPath" + } + ], + "get": { + "tags": [ + "stats" + ], + "summary": "Cross-project stats for a user within a workspace.", + "description": "`totals` sums across the workspace's non-deleted projects.\n`byProject` lists only projects with non-zero contribution.\n\nCounting rules:\n - `tasksMapped` \u2014 distinct tasks where the user submitted at\n least one `done:true` changeset while the task was in\n `to_map` or `to_remap`.\n - `tasksValidated` \u2014 distinct tasks where the user submitted\n at least one `done:true` changeset while in `to_review`.\n - `totalMappingMinutes` / `totalValidationMinutes` \u2014 sum of\n `(released_at - locked_at)` over the user's lock sessions\n in mapping/validation context.\n - `totalAreaSqkm` \u2014 sum of `tasking_tasks.area_sqkm` over\n tasks the user mapped (each task counted once).\n", + "operationId": "getUserStats", + "responses": { + "200": { + "description": "User stats.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserStats" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "Workspace not found, user has no activity, or outside tenancy.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + }, + "parameters": { + "WorkspaceIdPath": { + "name": "workspace_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + "ProjectIdPath": { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + "TaskNumberPath": { + "name": "task_number", + "in": "path", + "required": true, + "description": "Sequential, human-readable id scoped to the project. Starts at 1.", + "schema": { + "type": "integer", + "minimum": 1 + } + }, + "UserIdPath": { + "name": "user_id", + "in": "path", + "required": true, + "description": "TDEI auth user uuid.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + "IdempotencyKeyHeader": { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "description": "Client-generated unique value (UUID recommended). Scoped per project.", + "schema": { + "type": "string", + "minLength": 8, + "maxLength": 128 + } + } + }, + "schemas": { + "ProjectStatus": { + "type": "string", + "enum": [ + "draft", + "open", + "done" + ] + }, + "TaskStatus": { + "type": "string", + "enum": [ + "to_map", + "to_review", + "to_remap", + "completed" + ] + }, + "TaskBoundaryType": { + "type": "string", + "enum": [ + "grid", + "import" + ], + "nullable": true + }, + "GridSource": { + "type": "string", + "enum": [ + "grid", + "import" + ] + }, + "WorkspaceUserRoleType": { + "type": "string", + "enum": [ + "lead", + "validator", + "contributor" + ] + }, + "LockReleaseReason": { + "type": "string", + "enum": [ + "auto_unlock", + "manual", + "lead_release", + "stale_timeout", + "reset" + ], + "description": "`auto_unlock` \u2014 released by a successful `/submit` with `done:true`.\n`manual` \u2014 caller released their own lock via `DELETE /lock`.\n`lead_release` \u2014 LEAD force-released via `DELETE /lock?force=true`.\n`stale_timeout` \u2014 released by the background job (`expires_at < NOW()`).\n`reset` \u2014 released by `POST /reset` on the task or its project.\n" + }, + "FeedbackReason": { + "type": "string", + "enum": [ + "incomplete_mapping", + "data_quality_issue", + "wrong_area", + "other" + ], + "description": "Reason categories persisted to `tasking_feedback.reason_category`.\nRequired when feedback is used to drive a `to_review \u2192 to_remap`\ntransition (see `POST /submit`); optional for generic free-form\nnotes on a task.\n" + }, + "AuditEventType": { + "type": "string", + "enum": [ + "project_created", + "project_activated", + "project_closed", + "project_edited", + "project_deleted", + "aoi_uploaded", + "aoi_deleted", + "task_created", + "task_state_changed", + "task_locked", + "task_lock_extended", + "task_lock_renewed", + "task_unlocked", + "task_reset", + "project_reset", + "changeset_submitted", + "feedback_submitted" + ] + }, + "GeoJsonPolygon": { + "type": "object", + "required": [ + "type", + "coordinates" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "Polygon" + ] + }, + "coordinates": { + "type": "array", + "minItems": 1, + "items": { + "type": "array", + "minItems": 4, + "items": { + "type": "array", + "minItems": 2, + "maxItems": 3, + "items": { + "type": "number" + } + } + } + } + } + }, + "GeoJsonMultiPolygon": { + "type": "object", + "required": [ + "type", + "coordinates" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "MultiPolygon" + ] + }, + "coordinates": { + "type": "array", + "minItems": 1, + "items": { + "type": "array", + "minItems": 1, + "items": { + "type": "array", + "minItems": 4, + "items": { + "type": "array", + "minItems": 2, + "maxItems": 3, + "items": { + "type": "number" + } + } + } + } + } + } + }, + "AoiGeometry": { + "description": "AOI accepts either a single Polygon or a MultiPolygon (EPSG:4326). Servers store both as MultiPolygon (single-Polygon inputs are upcast on insert).", + "oneOf": [ + { + "$ref": "#/components/schemas/GeoJsonPolygon" + }, + { + "$ref": "#/components/schemas/GeoJsonMultiPolygon" + } + ] + }, + "AoiFeature": { + "type": "object", + "required": [ + "type", + "geometry", + "properties" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "Feature" + ] + }, + "geometry": { + "$ref": "#/components/schemas/AoiGeometry" + }, + "properties": { + "type": "object", + "additionalProperties": true + } + } + }, + "AoiFeatureCollection": { + "type": "object", + "required": [ + "type", + "features" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "FeatureCollection" + ] + }, + "features": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "$ref": "#/components/schemas/AoiFeature" + } + } + } + }, + "AoiUploadRequest": { + "oneOf": [ + { + "$ref": "#/components/schemas/AoiFeature" + }, + { + "$ref": "#/components/schemas/AoiFeatureCollection" + }, + { + "$ref": "#/components/schemas/AoiGeometry" + } + ] + }, + "TaskBoundaryFeature": { + "type": "object", + "required": [ + "type", + "geometry" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "Feature" + ] + }, + "geometry": { + "$ref": "#/components/schemas/GeoJsonPolygon" + }, + "properties": { + "type": "object", + "additionalProperties": true + } + } + }, + "TaskBoundariesFeatureCollection": { + "type": "object", + "required": [ + "type", + "features" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "FeatureCollection" + ] + }, + "features": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/components/schemas/TaskBoundaryFeature" + } + } + } + }, + "Project": { + "type": "object", + "required": [ + "id", + "workspaceId", + "name", + "status", + "reviewRequired", + "lockTimeoutHours", + "createdBy", + "createdAt", + "updatedAt", + "hasAoi", + "taskCount" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "workspaceId": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string", + "maxLength": 255 + }, + "instructions": { + "type": "string", + "nullable": true + }, + "status": { + "$ref": "#/components/schemas/ProjectStatus" + }, + "reviewRequired": { + "type": "boolean", + "default": true + }, + "lockTimeoutHours": { + "type": "integer", + "minimum": 1, + "maximum": 720, + "default": 8 + }, + "taskBoundaryType": { + "$ref": "#/components/schemas/TaskBoundaryType" + }, + "hasAoi": { + "type": "boolean" + }, + "taskCount": { + "type": "integer", + "minimum": 0 + }, + "createdBy": { + "type": "string", + "format": "uuid" + }, + "createdByName": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ProjectListItem": { + "type": "object", + "required": [ + "id", + "name", + "status", + "taskCount", + "percentCompleted", + "createdAt" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/ProjectStatus" + }, + "taskCount": { + "type": "integer", + "minimum": 0 + }, + "percentCompleted": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "createdBy": { + "type": "string", + "format": "uuid" + }, + "createdByName": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ProjectListResponse": { + "type": "object", + "required": [ + "results", + "pagination" + ], + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectListItem" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "ProjectCreateRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Unique within the workspace (case-insensitive) among non-deleted projects." + }, + "instructions": { + "type": "string", + "maxLength": 10000, + "nullable": true + }, + "reviewRequired": { + "type": "boolean", + "default": true, + "description": "If false, tasks transition straight to `completed` on mapper submit. Immutable after activation." + }, + "lockTimeoutHours": { + "type": "integer", + "minimum": 1, + "maximum": 720, + "default": 8 + }, + "aoi": { + "allOf": [ + { + "$ref": "#/components/schemas/AoiUploadRequest" + } + ], + "nullable": true, + "description": "Optional inline AOI (same validation as `POST .../aoi`)." + }, + "roleAssignments": { + "type": "array", + "description": "Optional list of project-level role assignments to seed at\ncreation. Each entry is upserted into `tasking_project_roles`\nin the same transaction as the project insert.\n\nThe creator is auto-added as `lead` on `tasking_project_roles`\nand does not need to appear here.\n\nEach `userId` must be a member of the workspace's TDEI\nproject group; entries failing this check are rejected with\n422 and no rows are written.\n", + "items": { + "$ref": "#/components/schemas/ProjectRoleAssignment" + } + } + } + }, + "ProjectRoleAssignment": { + "type": "object", + "required": [ + "userId", + "role" + ], + "properties": { + "userId": { + "type": "string", + "format": "uuid" + }, + "role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + } + } + }, + "ProjectUpdateRequest": { + "type": "object", + "description": "All fields optional. Server enforces per-status mutability.", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "instructions": { + "type": "string", + "maxLength": 10000, + "nullable": true + }, + "lockTimeoutHours": { + "type": "integer", + "minimum": 1, + "maximum": 720 + }, + "reviewRequired": { + "type": "boolean" + } + } + }, + "TaskLockSummary": { + "type": "object", + "required": [ + "userId", + "lockedAt", + "expiresAt" + ], + "properties": { + "userId": { + "type": "string", + "format": "uuid" + }, + "userName": { + "type": "string", + "nullable": true + }, + "lockedAt": { + "type": "string", + "format": "date-time" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + } + }, + "LastMapper": { + "type": "object", + "required": [ + "userId" + ], + "properties": { + "userId": { + "type": "string", + "format": "uuid" + }, + "userName": { + "type": "string", + "nullable": true + } + } + }, + "Task": { + "type": "object", + "required": [ + "id", + "taskNumber", + "status", + "geometry", + "areaSqkm", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "taskNumber": { + "type": "integer", + "minimum": 1 + }, + "status": { + "$ref": "#/components/schemas/TaskStatus" + }, + "geometry": { + "$ref": "#/components/schemas/GeoJsonPolygon" + }, + "areaSqkm": { + "type": "number" + }, + "lock": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/TaskLockSummary" + } + ] + }, + "lastMapper": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/LastMapper" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "TaskListResponse": { + "type": "object", + "required": [ + "tasks", + "pagination" + ], + "properties": { + "tasks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Task" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "ValidateWarning": { + "type": "object", + "required": [ + "taskIndex", + "issue" + ], + "properties": { + "taskIndex": { + "type": "integer", + "description": "0-based index into the submitted `features` array." + }, + "issue": { + "type": "string", + "enum": [ + "polygon_exceeds_grid_size" + ] + }, + "areaSqkm": { + "type": "number" + } + } + }, + "ValidatePreviewResponse": { + "type": "object", + "required": [ + "valid", + "warnings", + "source", + "featureCollection" + ], + "properties": { + "valid": { + "type": "boolean", + "description": "True when no hard failures. Warnings do not affect this." + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ValidateWarning" + } + }, + "source": { + "$ref": "#/components/schemas/GridSource", + "description": "Always `import` for this response. Pass through unchanged to `/save`." + }, + "featureCollection": { + "$ref": "#/components/schemas/TaskBoundariesFeatureCollection" + } + } + }, + "SaveTasksRequest": { + "type": "object", + "required": [ + "source", + "featureCollection" + ], + "properties": { + "source": { + "$ref": "#/components/schemas/GridSource", + "description": "Sets `tasking_projects.task_boundary_type` for the project." + }, + "featureCollection": { + "$ref": "#/components/schemas/TaskBoundariesFeatureCollection" + } + } + }, + "SaveTasksResponse": { + "type": "object", + "required": [ + "projectId", + "taskBoundaryType", + "taskCount", + "tasks" + ], + "properties": { + "projectId": { + "type": "integer", + "format": "int64" + }, + "taskBoundaryType": { + "$ref": "#/components/schemas/GridSource" + }, + "taskCount": { + "type": "integer", + "minimum": 1, + "description": "Number of tasks created (== `featureCollection.features.length`)." + }, + "tasks": { + "type": "array", + "description": "All created tasks in the same order as the request's `features[]`. `taskNumber` is sequential starting at 1.", + "items": { + "$ref": "#/components/schemas/Task" + } + }, + "idempotencyKey": { + "type": "string", + "nullable": true + }, + "replayed": { + "type": "boolean", + "default": false, + "description": "True on idempotent replay (HTTP 200 case)." + } + } + }, + "Feedback": { + "type": "object", + "description": "Feedback payload \u2014 persisted to `tasking_feedback`. Used by\n`POST /submit` to attach a validator's remap-rejection note,\nand reusable by any future endpoint that records free-form\ntask feedback.\n", + "required": [ + "notes" + ], + "properties": { + "reasonCategory": { + "allOf": [ + { + "$ref": "#/components/schemas/FeedbackReason" + } + ], + "nullable": true, + "description": "Required when the feedback drives `to_review \u2192 to_remap`; optional otherwise." + }, + "notes": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + } + } + }, + "SubmitRequest": { + "type": "object", + "required": [ + "osmChangesetId", + "done" + ], + "properties": { + "osmChangesetId": { + "type": "integer", + "format": "int64", + "minimum": 1 + }, + "done": { + "type": "boolean", + "description": "`true` \u2014 auto-unlocks and transitions state per the table.\n`false` \u2014 records the changeset, state unchanged, lock\n`expires_at` slides forward to `submitted_at +\nlock_timeout_hours`.\n" + }, + "feedback": { + "allOf": [ + { + "$ref": "#/components/schemas/Feedback" + } + ], + "description": "Meaningful only in validator context on `to_review`. When\nprovided here, `feedback.reasonCategory` is required (drives\nthe `to_remap` transition) and the row is persisted to\n`tasking_feedback`.\n" + } + } + }, + "ExistingLockSummary": { + "type": "object", + "required": [ + "taskNumber", + "taskStatus", + "lockedAt", + "expiresAt" + ], + "properties": { + "taskNumber": { + "type": "integer", + "minimum": 1 + }, + "taskStatus": { + "$ref": "#/components/schemas/TaskStatus" + }, + "lockedAt": { + "type": "string", + "format": "date-time" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + } + }, + "LockConflictError": { + "allOf": [ + { + "$ref": "#/components/schemas/Error" + }, + { + "type": "object", + "properties": { + "existingLock": { + "$ref": "#/components/schemas/ExistingLockSummary" + } + } + } + ] + }, + "UserSummary": { + "type": "object", + "required": [ + "userId" + ], + "properties": { + "userId": { + "type": "string", + "format": "uuid" + }, + "displayName": { + "type": "string", + "nullable": true + }, + "email": { + "type": "string", + "nullable": true + } + } + }, + "AssignableRoleType": { + "type": "string", + "enum": [ + "lead", + "validator" + ], + "description": "Roles assignable via `PUT /workspaces/{wid}/users/{uid}/role`.\nCONTRIBUTOR is implicit (project-group membership grants it\nautomatically) and cannot be set by this endpoint.\n" + }, + "SetRoleRequest": { + "type": "object", + "required": [ + "role" + ], + "properties": { + "role": { + "$ref": "#/components/schemas/AssignableRoleType" + } + } + }, + "WorkspaceUserRoleItem": { + "type": "object", + "description": "Privileged-member DTO returned by `GET /workspaces/{wid}/users`.\nMirrors the shipped `WorkspaceUserRoleItem` from\n`api/src/users/schemas.py` on the `roles` branch.\n", + "required": [ + "id", + "auth_uid", + "email", + "display_name", + "role" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64", + "description": "Internal OSM-DB `users.id`." + }, + "auth_uid": { + "type": "string", + "description": "TDEI auth user uuid as string (`users.auth_uid`)." + }, + "email": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + } + } + }, + "ProjectRoleItem": { + "type": "object", + "description": "One row of `tasking_project_roles`, joined with `users` for the\ndisplay name. Returned by every `/projects/{pid}/roles[/{uid}]`\nendpoint.\n", + "required": [ + "user_id", + "role", + "updated_at" + ], + "properties": { + "user_id": { + "type": "string", + "format": "uuid", + "description": "Maps to `users.auth_uid`." + }, + "user_name": { + "type": "string", + "nullable": true, + "description": "Mirror of `users.display_name`, joined at read time." + }, + "role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "ProjectRoleListResponse": { + "type": "object", + "required": [ + "results" + ], + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectRoleItem" + } + } + } + }, + "ProjectRoleAddRequest": { + "type": "object", + "description": "Body for `POST /projects/{pid}/roles`.", + "required": [ + "user_id", + "role" + ], + "additionalProperties": false, + "properties": { + "user_id": { + "type": "string", + "format": "uuid" + }, + "role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + } + } + }, + "ProjectRoleUpdateRequest": { + "type": "object", + "description": "Body for `PATCH /projects/{pid}/roles/{user_id}`.", + "required": [ + "role" + ], + "additionalProperties": false, + "properties": { + "role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + } + } + }, + "SelfProjectRoleItem": { + "type": "object", + "required": [ + "project_id", + "project_name", + "project_status", + "role" + ], + "properties": { + "project_id": { + "type": "integer", + "format": "int64" + }, + "project_name": { + "type": "string" + }, + "project_status": { + "$ref": "#/components/schemas/ProjectStatus" + }, + "role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + } + } + }, + "SelfProjectRolesResponse": { + "type": "object", + "required": [ + "workspace_role", + "projects" + ], + "properties": { + "workspace_role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + }, + "projects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelfProjectRoleItem" + } + } + } + }, + "ActorRef": { + "type": "object", + "required": [ + "user_id" + ], + "properties": { + "user_id": { + "type": "string", + "format": "uuid" + }, + "display_name": { + "type": "string", + "nullable": true + } + } + }, + "AuditEvent": { + "type": "object", + "required": [ + "id", + "event_type", + "project_id", + "actor", + "occurred_at", + "details" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "event_type": { + "$ref": "#/components/schemas/AuditEventType" + }, + "project_id": { + "type": "integer", + "format": "int64" + }, + "task_id": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "task_number": { + "type": "integer", + "nullable": true, + "description": "Convenience copy from `details` (so list renderers don't peek inside JSONB)." + }, + "actor": { + "$ref": "#/components/schemas/ActorRef" + }, + "occurred_at": { + "type": "string", + "format": "date-time" + }, + "details": { + "type": "object", + "additionalProperties": true + }, + "project_deleted": { + "type": "boolean", + "default": false + } + } + }, + "AuditEventListResponse": { + "type": "object", + "required": [ + "results", + "pagination" + ], + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuditEvent" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "TaskStatusCounts": { + "type": "object", + "required": [ + "to_map", + "to_review", + "to_remap", + "completed" + ], + "properties": { + "to_map": { + "type": "integer", + "minimum": 0 + }, + "to_review": { + "type": "integer", + "minimum": 0 + }, + "to_remap": { + "type": "integer", + "minimum": 0 + }, + "completed": { + "type": "integer", + "minimum": 0 + } + } + }, + "ProjectStats": { + "type": "object", + "required": [ + "projectId", + "totalTasks", + "tasksByStatus", + "percentMapped", + "percentCompleted", + "uniqueMappers", + "uniqueValidators" + ], + "properties": { + "projectId": { + "type": "integer", + "format": "int64" + }, + "totalTasks": { + "type": "integer", + "minimum": 0 + }, + "tasksByStatus": { + "$ref": "#/components/schemas/TaskStatusCounts" + }, + "percentMapped": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "percentCompleted": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "avgTaskDurationMinutes": { + "type": "number", + "nullable": true + }, + "uniqueMappers": { + "type": "integer", + "minimum": 0 + }, + "uniqueValidators": { + "type": "integer", + "minimum": 0 + } + } + }, + "UserStatsTotals": { + "type": "object", + "required": [ + "tasksMapped", + "tasksValidated", + "totalMappingMinutes", + "totalValidationMinutes", + "totalChangesets", + "totalAreaSqkm" + ], + "properties": { + "tasksMapped": { + "type": "integer", + "minimum": 0 + }, + "tasksValidated": { + "type": "integer", + "minimum": 0 + }, + "totalMappingMinutes": { + "type": "integer", + "minimum": 0 + }, + "totalValidationMinutes": { + "type": "integer", + "minimum": 0 + }, + "totalChangesets": { + "type": "integer", + "minimum": 0 + }, + "totalAreaSqkm": { + "type": "number", + "minimum": 0 + } + } + }, + "UserStatsByProject": { + "type": "object", + "required": [ + "projectId", + "projectName", + "tasksMapped", + "tasksValidated" + ], + "properties": { + "projectId": { + "type": "integer", + "format": "int64" + }, + "projectName": { + "type": "string" + }, + "tasksMapped": { + "type": "integer", + "minimum": 0 + }, + "tasksValidated": { + "type": "integer", + "minimum": 0 + }, + "totalChangesets": { + "type": "integer", + "minimum": 0 + }, + "totalAreaSqkm": { + "type": "number", + "minimum": 0 + } + } + }, + "UserStats": { + "type": "object", + "required": [ + "workspaceId", + "userId", + "totals", + "byProject" + ], + "properties": { + "workspaceId": { + "type": "integer", + "format": "int64" + }, + "userId": { + "type": "string", + "format": "uuid" + }, + "totals": { + "$ref": "#/components/schemas/UserStatsTotals" + }, + "byProject": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserStatsByProject" + } + } + } + }, + "Pagination": { + "type": "object", + "required": [ + "page", + "pageSize", + "total" + ], + "properties": { + "page": { + "type": "integer", + "minimum": 1 + }, + "pageSize": { + "type": "integer", + "minimum": 1 + }, + "total": { + "type": "integer", + "minimum": 0 + } + } + }, + "Error": { + "type": "object", + "required": [ + "detail" + ], + "properties": { + "detail": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + ], + "description": "FastAPI default error shape \u2014 string for raised `HTTPException`, structured list for pydantic validation errors." + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Malformed JSON or schema-level failure.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "Unauthorized": { + "description": "Missing or invalid bearer token.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "ForbiddenLeadRequired": { + "description": "Caller is a workspace contributor but does not hold LEAD.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "WorkspaceNotFound": { + "description": "Workspace does not exist, or is outside the caller's tenancy.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "ProjectOrWorkspaceNotFound": { + "description": "Workspace or project does not exist, or is outside the caller's tenancy.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "TaskOrProjectOrWorkspaceNotFound": { + "description": "Workspace, project, or task does not exist, or is outside the caller's tenancy.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "UnprocessableEntity": { + "description": "Well-formed request that violates a business rule.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } +} \ No newline at end of file diff --git a/docs/tasking-mvp/tasking-mvp.openapi.yaml b/docs/tasking-mvp/tasking-mvp.openapi.yaml new file mode 100644 index 0000000..8076441 --- /dev/null +++ b/docs/tasking-mvp/tasking-mvp.openapi.yaml @@ -0,0 +1,2029 @@ +openapi: 3.0.3 +info: + title: Workspaces Tasking Manager API + version: 1.0.0 + description: | + Tasking Manager API for the Workspaces Tasking Manager MVP. + +servers: + - url: /api/v1 + description: workspaces-backend FastAPI app — all routers are mounted under `/api/v1` (see `api/main.py`). + +tags: + - name: projects + description: Tasking project CRUD and lifecycle. + - name: aoi + description: Project area-of-interest upload, retrieval, deletion. + - name: tasks + description: Task validation, persistence and read access. + - name: locks + description: Lock lifecycle on a task. + - name: submit + description: Done? submit flow — changeset + state transition. + - name: roles + description: Workspace and project role management. + - name: audit + description: Audit trail. + - name: stats + description: Project and user statistics. + +security: + - bearerAuth: [] + +paths: + # ========================================================================= + # Projects + # ========================================================================= + + /workspaces/{workspace_id}/tasking/projects: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + get: + tags: [projects] + summary: List tasking projects in a workspace. + description: Paginated list of non-deleted projects. Any workspace contributor. + operationId: listProjects + parameters: + - in: query + name: status + schema: { $ref: "#/components/schemas/ProjectStatus" } + - in: query + name: textSearch + schema: { type: string, maxLength: 255 } + description: Case-insensitive substring match on `name`. + - in: query + name: page + schema: { type: integer, minimum: 1, default: 1 } + - in: query + name: pageSize + schema: { type: integer, minimum: 1, maximum: 200, default: 20 } + - in: query + name: orderBy + schema: + type: string + enum: [createdAt, updatedAt, name] + default: createdAt + - in: query + name: orderByType + schema: { type: string, enum: [ASC, DESC], default: DESC } + responses: + "200": + description: Paginated list. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/WorkspaceNotFound" } + post: + tags: [projects] + summary: Create a tasking project (optionally with AOI and role assignments). + description: | + LEAD only. Creates a project in `draft` status. + + Optional inline AOI may be provided (same validation as + `POST .../aoi`). + + Optional `roleAssignments` may be provided to seed the + project-level role overrides table (`tasking_project_roles`) in + the same transaction. The creator is auto-added with + `role = lead`; other users may be added with any of `lead`, + `validator`, `contributor`. + + Activation later requires at least one user assigned with + `contributor` or `validator` so the project has at least one + worker before it goes live — see `POST .../activate`. + operationId: createProject + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectCreateRequest" } + responses: + "201": + description: Created. + content: + application/json: + schema: { $ref: "#/components/schemas/Project" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/WorkspaceNotFound" } + "409": + description: A non-deleted project with this name already exists. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": { $ref: "#/components/responses/UnprocessableEntity" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + get: + tags: [projects] + summary: Get a tasking project. + operationId: getProject + responses: + "200": + description: Project detail. + content: + application/json: + schema: { $ref: "#/components/schemas/Project" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + patch: + tags: [projects] + summary: Update editable settings. + description: | + LEAD only. Per-status mutability: + + | Field | draft | open | done | + |--------------------|-------|------|------| + | name | yes | yes | no | + | instructions | yes | yes | no | + | lockTimeoutHours | yes | yes | no | + | reviewRequired | yes | NO | no | + + AOI is updated through the dedicated AOI endpoints, not PATCH. + Immutable-field writes return 422. + operationId: updateProject + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectUpdateRequest" } + responses: + "200": + description: Updated. + content: + application/json: + schema: { $ref: "#/components/schemas/Project" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "422": { $ref: "#/components/responses/UnprocessableEntity" } + delete: + tags: [projects] + summary: Soft-delete a tasking project. + description: | + LEAD only. Sets `deletedAt`, hard-deletes child tasks, retains + audit rows (flagged with `project_deleted=true`). Returns 409 + if any task currently has an active lock — force-release first. + operationId: deleteProject + responses: + "204": + description: Soft-deleted. + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "409": + description: Project has active task locks. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/activate: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + post: + tags: [projects] + summary: Activate a draft project (`draft` → `open`). + description: | + LEAD only. Pre-conditions (all must hold; 422 with a clear + `detail` otherwise): + - Project has a `name`. + - Project has an AOI set. + - Project has at least one saved task. + - At least one user is allocated to the project with role + `contributor` or `validator` in `tasking_project_roles` + (the creator's auto-`lead` row does not satisfy this — a + worker must be assigned). + operationId: activateProject + responses: + "200": + description: Activated. + content: + application/json: + schema: { $ref: "#/components/schemas/Project" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "422": { $ref: "#/components/responses/UnprocessableEntity" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/close: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + post: + tags: [projects] + summary: Close an open project (`open` → `done`). + description: | + LEAD only. Requires every task to be `completed` and no active + locks. + operationId: closeProject + responses: + "200": + description: Closed. + content: + application/json: + schema: { $ref: "#/components/schemas/Project" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "409": + description: One or more tasks are still locked or not completed. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/reset: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + post: + tags: [projects] + summary: Reset all tasks in a project back to `to_map`. + description: | + LEAD only. Restarts work on the whole project so contributors + can re-map and re-validate. + + Effect (single transaction): + - Releases every active lock with `release_reason = 'reset'`, + emitting one `task_unlocked` audit event per released lock. + - Sets every task's `status` to `to_map`, clears + `last_mapper_id`, emits a `task_state_changed` audit event + for every task whose status actually changed. + - If the project was `done`, transitions it back to `open`. + - Emits one `project_reset` audit event for the project. + + Tasks themselves (geometry, history of changesets and remap + feedback) are NOT deleted — only their lifecycle state is + rewound. Stats and audit history remain intact. + operationId: resetProject + responses: + "200": + description: Reset complete. + content: + application/json: + schema: { $ref: "#/components/schemas/Project" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "422": + description: Project is in `draft` (nothing to reset). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + # ========================================================================= + # AOI + # ========================================================================= + + /workspaces/{workspace_id}/tasking/projects/{project_id}/aoi: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + get: + tags: [aoi] + summary: Get the AOI of a project. + description: | + Returns the AOI as a GeoJSON Feature (Polygon, EPSG:4326). + `404` if no AOI is set. + operationId: getProjectAoi + responses: + "200": + description: AOI. + content: + application/geo+json: + schema: { $ref: "#/components/schemas/AoiFeature" } + application/json: + schema: { $ref: "#/components/schemas/AoiFeature" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + post: + tags: [aoi] + summary: Upload or replace the AOI. + description: | + LEAD only. Accepts a GeoJSON `Feature`, `FeatureCollection` + (single feature), or bare geometry. Geometry may be either + `Polygon` or `MultiPolygon` (EPSG:4326). Single-Polygon inputs + are upcast to MultiPolygon on insert; the storage column is + `GEOMETRY(MultiPolygon, 4326)`. + + Project must be in `draft`. Replacing an AOI hard-deletes any + saved tasks. + operationId: uploadProjectAoi + requestBody: + required: true + content: + application/geo+json: + schema: { $ref: "#/components/schemas/AoiUploadRequest" } + application/json: + schema: { $ref: "#/components/schemas/AoiUploadRequest" } + responses: + "200": + description: AOI stored. + content: + application/geo+json: + schema: { $ref: "#/components/schemas/AoiFeature" } + application/json: + schema: { $ref: "#/components/schemas/AoiFeature" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "422": { $ref: "#/components/responses/UnprocessableEntity" } + delete: + tags: [aoi] + summary: Delete the AOI. + description: | + LEAD only. Project must be in `draft`. Clears the AOI, hard- + deletes any saved tasks (releasing their locks in the same + transaction), clears `taskBoundaryType`. Returns 404 if no AOI + is set. + operationId: deleteProjectAoi + responses: + "204": + description: AOI deleted. + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": + description: Project not found, or project has no AOI. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": + description: Project is not in `draft`. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + # ========================================================================= + # Tasks (validate + save + read) + # ========================================================================= + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/validate: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + post: + tags: [tasks] + summary: Validate a GeoJSON FeatureCollection of candidate task boundaries. + description: | + LEAD only. Does NOT persist anything — the client must POST each + validated Feature to `/tasks/save` to commit. + + Project preconditions: status `draft`, AOI uploaded. + + Hard validation (422 on failure): + - Top-level type must be `FeatureCollection`. + - Every feature geometry must be `Polygon` (no MultiPolygon). + - Coordinates valid lon/lat (EPSG:4326). + - Every polygon lies within the project AOI (centroid-inside + is not sufficient). + - At least one feature. + + Non-blocking warning: + - `polygon_exceeds_grid_size` — area exceeds + `TM_TASKING_GRID_SIZE_METERS`² (km²). + operationId: validateTasks + requestBody: + required: true + content: + application/geo+json: + schema: + { $ref: "#/components/schemas/TaskBoundariesFeatureCollection" } + application/json: + schema: + { $ref: "#/components/schemas/TaskBoundariesFeatureCollection" } + responses: + "200": + description: Validation result. + content: + application/json: + schema: { $ref: "#/components/schemas/ValidatePreviewResponse" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "422": { $ref: "#/components/responses/UnprocessableEntity" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/save: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/IdempotencyKeyHeader" + post: + tags: [tasks] + summary: Persist a validated FeatureCollection as the project's tasks (bulk). + description: | + LEAD only. Commits the **entire** FeatureCollection as + `tasking_tasks` rows in a single transaction. + + Effect (single transaction; all-or-nothing): + 1. Re-validates every feature against the project AOI (same + rules as `POST /tasks/validate`). Any hard failure aborts + the transaction — no rows are written. + 2. Allocates sequential `taskNumber`s starting at 1, in the + order features appear in the request. + 3. Bulk-inserts the `tasking_tasks` rows. + 4. Sets `tasking_projects.task_boundary_type` to `source`. + 5. Emits one `task_created` audit event per task. + + Project preconditions: + - Project must be in `draft`. + - Project must have an AOI set. + - Project must currently have **no tasks** (a second save is + rejected with 409 `"Tasks already saved"`). To replace + tasks, re-upload the AOI in `draft` — that wipes existing + tasks and re-enables save. + + Idempotency: `Idempotency-Key` is honoured at the batch level. + Same key + same body within the configured TTL replays the + original response (HTTP 200, `replayed: true`). Same key with + different body returns 409 + `"Idempotency key reused with a different request"`. See the + top-level idempotency contract. + operationId: saveTasks + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SaveTasksRequest" } + responses: + "201": + description: Tasks created. + content: + application/json: + schema: { $ref: "#/components/schemas/SaveTasksResponse" } + "200": + description: | + Idempotent replay (same key + same body); `replayed: true`. + content: + application/json: + schema: { $ref: "#/components/schemas/SaveTasksResponse" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "409": + description: | + One of: + - "Tasks already saved" — project already has tasks; re-upload AOI to start over. + - "Idempotency key reused with a different request". + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": { $ref: "#/components/responses/UnprocessableEntity" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + get: + tags: [tasks] + summary: List tasks (geometry always included; serves list + map views). + description: | + Paginated. Any workspace contributor. + operationId: listTasks + parameters: + - in: query + name: status + schema: { $ref: "#/components/schemas/TaskStatus" } + - in: query + name: lockedByUserId + schema: { type: string, format: uuid } + - in: query + name: lastMapperId + schema: { type: string, format: uuid } + - in: query + name: page + schema: { type: integer, minimum: 1, default: 1 } + - in: query + name: pageSize + schema: { type: integer, minimum: 1, maximum: 1000, default: 200 } + responses: + "200": + description: Tasks. + content: + application/json: + schema: { $ref: "#/components/schemas/TaskListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/TaskNumberPath" + get: + tags: [tasks] + summary: Get task detail (geometry, status, lock, last mapper). + operationId: getTask + responses: + "200": + description: Task. + content: + application/json: + schema: { $ref: "#/components/schemas/Task" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": + description: Workspace, project, or task not found. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + # ========================================================================= + # Locking + Submit + # ========================================================================= + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/lock: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/TaskNumberPath" + post: + tags: [locks] + summary: Acquire a lock on a task. + description: | + Eligibility: + + | Task status | Allowed roles | + |-------------|---------------------------------------------------| + | `to_map` | CONTRIBUTOR, LEAD | + | `to_remap` | CONTRIBUTOR, LEAD | + | `to_review` | VALIDATOR, LEAD — and NOT this task's last_mapper | + | `completed` | (none) | + + Other rules: + - No other active lock on this task. + - Caller holds no other active lock in this project (one-lock- + per-project rule). 409 response includes `existingLock`. + - Project status is `open`. + + Side effects: + - INSERT `tasking_locks` with + `expires_at = NOW() + project.lock_timeout_hours`. + - Emit `task_locked` audit event. + operationId: lockTask + responses: + "200": + description: Lock acquired. Body is the updated task. + content: + application/json: + schema: { $ref: "#/components/schemas/Task" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": + description: Role does not permit locking in this status, or self-validation guard hit. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + { $ref: "#/components/responses/TaskOrProjectOrWorkspaceNotFound" } + "409": + description: | + One of: + - "Task is already locked". + - "User already holds a lock in this project" — body includes `existingLock`. + - "Project is not open". + content: + application/json: + schema: { $ref: "#/components/schemas/LockConflictError" } + delete: + tags: [locks] + summary: Release a lock on a task. + description: | + Default: caller must be the active lock holder + (`release_reason = manual`). + + With `?force=true`: caller must be LEAD + (`release_reason = lead_release`). + + Task `status` is unchanged in both modes. Force-release does + not relock — LEAD must POST `/lock` separately to take over. + operationId: unlockTask + parameters: + - in: query + name: force + schema: { type: boolean, default: false } + responses: + "204": + description: Released. + "401": { $ref: "#/components/responses/Unauthorized" } + "403": + description: Not the lock holder (default mode) or not LEAD (force mode). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + { $ref: "#/components/responses/TaskOrProjectOrWorkspaceNotFound" } + "409": + description: Task has no active lock. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/extend: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/TaskNumberPath" + post: + tags: [locks] + summary: Extend the expiry of your own lock. + description: | + Caller must hold the active lock. Adds the project's + `lock_timeout_hours` to the current `expires_at` (slides from + the existing expiry, not from `NOW()`). Emits + `task_lock_extended`. + operationId: extendTaskLock + responses: + "200": + description: Lock extended. + content: + application/json: + schema: { $ref: "#/components/schemas/Task" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": + description: Caller does not hold the active lock. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + { $ref: "#/components/responses/TaskOrProjectOrWorkspaceNotFound" } + "409": + description: Task has no active lock to extend. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/reset: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/TaskNumberPath" + post: + tags: [locks] + summary: Reset a single task back to `to_map`. + description: | + LEAD only. Restarts work on a task — useful when a contributor + or validator pushed a task into a wrong terminal state and + someone needs to redo it. + + Effect (single transaction): + - If an active lock exists, it is released with + `release_reason = 'reset'` and a `task_unlocked` audit + event is emitted. + - Task `status` is set to `to_map`, `last_mapper_id` is + cleared, and a `task_state_changed` audit event is emitted + (only when the status actually changed). + - A `task_reset` audit event is emitted. + + Existing changeset rows and remap-feedback rows are NOT + deleted — only the live task state is rewound. + + Pre-conditions: project status is `open` or `done`. + operationId: resetTask + responses: + "200": + description: Task reset. + content: + application/json: + schema: { $ref: "#/components/schemas/Task" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": + { $ref: "#/components/responses/TaskOrProjectOrWorkspaceNotFound" } + "422": + description: Project is in `draft` (no tasks to reset yet). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/submit: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/TaskNumberPath" + post: + tags: [submit] + summary: Submit an OSM changeset and optionally transition state. + description: | + The "Done?" flow. Caller must hold the active lock. + + Effective actor role is inferred from current task status: + - `to_map` / `to_remap` → mapper context + - `to_review` → validator context + (LEAD may act in either context.) + + State transition (when `done:true`): + + | Current | Effective role | Feedback? | New status | + |-------------|--------------------|-----------|---------------------------------------------------------| + | `to_map` | contributor / lead | n/a | `to_review` if `review_required` else `completed` | + | `to_remap` | contributor / lead | n/a | `to_review` | + | `to_review` | validator / lead | no | `completed` | + | `to_review` | validator / lead | yes | `to_remap` (and INSERT `tasking_feedback` with `reasonCategory` set) | + + With `done:false` the state is unchanged but the lock's + `expires_at` slides forward to `submitted_at + lock_timeout_hours` + (emits `task_lock_renewed`). + + `osmChangesetId` is supplied by the client. In practice it is + auto-populated by the editor (Rapid in the iframe) on save — + the UI does not ask the user to type it. The id may optionally + be verified by looking it up in the internal `osm-web` service + (same network); a verification miss returns `422` with detail + "Invalid OSM changeset id" and nothing is written. + + `last_mapper_id` is updated only when the effective role is + mapper. + + Feedback rules: + - `feedback` is meaningful only in validator context on + `to_review`. Otherwise its presence returns 422. + - For `to_review` → `to_remap`, `feedback.reasonCategory` is + required (422 if missing). + operationId: submitTask + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SubmitRequest" } + responses: + "200": + description: Submission accepted. + content: + application/json: + schema: { $ref: "#/components/schemas/Task" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": + description: Caller does not hold the active lock. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + { $ref: "#/components/responses/TaskOrProjectOrWorkspaceNotFound" } + "422": { $ref: "#/components/responses/UnprocessableEntity" } + + # ========================================================================= + # Roles + # ========================================================================= + + # NOTE: the workspace-level role surface below mirrors the shipped + # endpoints on the `roles` branch (api/src/users/routes.py). It uses + # `/workspaces/{workspace_id}/users/...`, not `/.../roles/...`. Only + # privileged role rows (LEAD, VALIDATOR) are stored; CONTRIBUTOR is + # implicit for any project-group member and cannot be assigned via PUT. + + /workspaces/{workspace_id}/users: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + get: + tags: [roles] + summary: List privileged workspace members (lead + validator role rows). + description: | + Returns the rows in `user_workspace_roles` for this workspace, + joined with their `users` record. Visible to any workspace + contributor (`isWorkspaceContributor`). + + Does NOT include implicit contributors — only users with an + explicit LEAD or VALIDATOR row. The UI uses this for the team + management table; broader user listings come from the TDEI + proxy (see `/assignable-users`). + operationId: listWorkspaceMembers + responses: + "200": + description: Privileged members. + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/WorkspaceUserRoleItem" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": + description: Caller is not a member of the workspace's project group. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/users/{user_id}/role: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/UserIdPath" + put: + tags: [roles] + summary: Assign (upsert) a privileged role for a user on a workspace. + description: | + LEAD only. Idempotent upsert into `user_workspace_roles`. + + The body's `role` must be `lead` or `validator` — assigning + `contributor` is rejected with 422 ("cannot assign implicit + role 'contributor' directly"). Contributor is the default for + any project-group member; to remove an explicit role and fall + back to contributor, call `DELETE /users/{user_id}` below. + + The named user must have signed in to Workspaces at least once + (i.e. exist in `users.auth_uid`). If they have not, returns + 404 with detail "User {uuid} has not signed in to Workspaces + yet". + + Side effect: the target user's `UserInfo` cache entry is + evicted (`evict_user_from_cache`) so their next request + reflects the new role immediately. + operationId: assignWorkspaceMemberRole + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SetRoleRequest" } + responses: + "204": + description: Role assigned (or updated). + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": + description: Caller is not LEAD on this workspace. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + description: Workspace not found, or the named user has not signed in to Workspaces yet. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": + description: '"cannot assign implicit role ''contributor'' directly".' + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/users/{user_id}: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/UserIdPath" + delete: + tags: [roles] + summary: Remove a user's privileged role on a workspace. + description: | + LEAD only. Deletes the row from `user_workspace_roles`; the + user's role falls back to implicit `contributor` (or to + whatever TDEI grants — `poc` still maps to LEAD). + + Side effect: the target user's `UserInfo` cache entry is + evicted. + operationId: removeWorkspaceMemberRole + responses: + "204": + description: Role row removed. + "401": { $ref: "#/components/responses/Unauthorized" } + "403": + description: Caller is not LEAD on this workspace. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + description: No role row exists for this user/workspace. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/roles: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + get: + tags: [roles] + summary: List role assignments on a project. + description: | + Returns every row in `tasking_project_roles` for the project, + joined with `users.display_name` for human-readable labels. + Any workspace contributor; the workspace tenancy gate still + applies (404 for outsiders). + operationId: listProjectRoles + responses: + "200": + description: All role assignments. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + post: + tags: [roles] + summary: Add a role assignment to a project. + description: | + Workspace LEAD **or** project LEAD only. Inserts a row in + `tasking_project_roles`. Duplicate `(project_id, user_id)` pairs + return 409 — use PATCH to change a role. The `user_id` must + already exist in `users` (i.e. the user has signed in to + Workspaces at least once); otherwise 422 with a + `missing_user_ids` list. + operationId: addProjectRole + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleAddRequest" } + responses: + "201": + description: Role added. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleItem" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "409": + description: User already has a role on this project — use PATCH. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": + description: "`user_id` does not exist in the `users` table." + content: + application/json: + schema: + type: object + properties: + detail: + type: object + properties: + message: { type: string } + missing_user_ids: + type: array + items: { type: string, format: uuid } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/roles/{user_id}: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/UserIdPath" + get: + tags: [roles] + summary: Get a single user's role on a project. + description: | + Returns the single `tasking_project_roles` row for this user on + this project. 404 if no assignment exists. Any workspace + contributor; the tenancy gate still applies. + operationId: getProjectRole + responses: + "200": + description: Role assignment. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleItem" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": + description: User has no role on this project, or project not found. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + put: + tags: [roles] + summary: Upsert a user's role on a project. + description: | + Workspace LEAD **or** project LEAD only. Idempotent insert-or- + update on `tasking_project_roles`. Returns **201** when the row + is created, **200** when an existing row is updated. + + **Last-LEAD guard**: a PUT that demotes the only remaining LEAD + returns 422 — assign another LEAD first. + + For partial-update semantics, prefer PATCH. + operationId: putProjectRole + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleUpdateRequest" } + responses: + "200": + description: Existing assignment updated. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleItem" } + "201": + description: New assignment created. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleItem" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "422": + description: | + One of: + - `user_id` does not exist in `users` (insert path). + - Would remove the last LEAD (demote path). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + patch: + tags: [roles] + summary: Change a user's role on a project. + description: | + Workspace LEAD **or** project LEAD only. Updates the row in + `tasking_project_roles`. Returns 404 if the user has no role + assignment yet (use POST to add one). + + **Last-LEAD guard**: demoting the only remaining LEAD on the + project returns 422 — assign another LEAD first. + operationId: updateProjectRole + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleUpdateRequest" } + responses: + "200": + description: Role updated. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleItem" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": + description: User has no role on this project, or project not found. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": + description: Would remove the last LEAD on the project. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + delete: + tags: [roles] + summary: Remove a role assignment from a project. + description: | + Workspace LEAD **or** project LEAD only. Deletes the row from + `tasking_project_roles`. + + **Last-LEAD guard**: removing the only remaining LEAD on the + project returns 422 — assign another LEAD first. + operationId: removeProjectRole + responses: + "204": + description: Role removed. + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": + description: User has no role on this project, or project not found. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": + description: Would remove the last LEAD on the project. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /me/workspaces/{workspace_id}/tasking/projects/roles: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + get: + tags: [roles] + summary: List the caller's role for every project in a workspace. + description: | + Single round-trip for the project-list page: returns one entry + per project with the caller's role on that project. + operationId: listSelfProjectRoles + responses: + "200": + description: Project roles for the caller. + content: + application/json: + schema: { $ref: "#/components/schemas/SelfProjectRolesResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/WorkspaceNotFound" } + + # ========================================================================= + # Audit + # ========================================================================= + + /workspaces/{workspace_id}/tasking/projects/{project_id}/audit: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + get: + tags: [audit] + summary: List project-level audit events. + description: | + Paginated, newest first. Any workspace contributor. Soft-deleted + projects are visible only with `include_deleted=true`. + operationId: listProjectAudit + parameters: + - in: query + name: event_type + schema: { $ref: "#/components/schemas/AuditEventType" } + - in: query + name: task_number + schema: { type: integer, minimum: 1 } + - in: query + name: actor_user_id + schema: { type: string, format: uuid } + - in: query + name: occurred_from + schema: { type: string, format: date-time } + - in: query + name: occurred_to + schema: { type: string, format: date-time } + - in: query + name: include_deleted + schema: { type: boolean, default: false } + - in: query + name: page + schema: { type: integer, minimum: 1, default: 1 } + - in: query + name: page_size + schema: { type: integer, minimum: 1, maximum: 200, default: 50 } + - in: query + name: order_by_type + schema: { type: string, enum: [ASC, DESC], default: DESC } + responses: + "200": + description: Events. + content: + application/json: + schema: { $ref: "#/components/schemas/AuditEventListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/audit: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/TaskNumberPath" + get: + tags: [audit] + summary: List task-level audit events. + description: Newest first. Any workspace contributor. + operationId: listTaskAudit + parameters: + - in: query + name: event_type + schema: { $ref: "#/components/schemas/AuditEventType" } + - in: query + name: actor_user_id + schema: { type: string, format: uuid } + - in: query + name: occurred_from + schema: { type: string, format: date-time } + - in: query + name: occurred_to + schema: { type: string, format: date-time } + - in: query + name: page + schema: { type: integer, minimum: 1, default: 1 } + - in: query + name: page_size + schema: { type: integer, minimum: 1, maximum: 200, default: 25 } + - in: query + name: order_by_type + schema: { type: string, enum: [ASC, DESC], default: DESC } + responses: + "200": + description: Events for the task. + content: + application/json: + schema: { $ref: "#/components/schemas/AuditEventListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": + { $ref: "#/components/responses/TaskOrProjectOrWorkspaceNotFound" } + + # ========================================================================= + # Stats + # ========================================================================= + + /workspaces/{workspace_id}/tasking/projects/{project_id}/stats: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + get: + tags: [stats] + summary: Project statistics summary. + description: | + Live-computed. Any workspace contributor. + + Field semantics: + - `tasksByStatus` always zero-fills all four states. + - `percentMapped` — share of tasks past `to_map` at least + once. Rounded to nearest integer. + - `percentCompleted` — share whose current status is + `completed`. + - `avgTaskDurationMinutes` — mean across completed tasks of + the sum of `(released_at - locked_at)` over that task's + locks. `null` when no task is completed yet. + - `uniqueMappers` — distinct submitters with at least one + changeset while task was in `to_map` or `to_remap`. + - `uniqueValidators` — distinct submitters with at least one + changeset while task was in `to_review`. + operationId: getProjectStats + responses: + "200": + description: Stats. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectStats" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + + /workspaces/{workspace_id}/tasking/users/{user_id}/stats: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/UserIdPath" + get: + tags: [stats] + summary: Cross-project stats for a user within a workspace. + description: | + `totals` sums across the workspace's non-deleted projects. + `byProject` lists only projects with non-zero contribution. + + Counting rules: + - `tasksMapped` — distinct tasks where the user submitted at + least one `done:true` changeset while the task was in + `to_map` or `to_remap`. + - `tasksValidated` — distinct tasks where the user submitted + at least one `done:true` changeset while in `to_review`. + - `totalMappingMinutes` / `totalValidationMinutes` — sum of + `(released_at - locked_at)` over the user's lock sessions + in mapping/validation context. + - `totalAreaSqkm` — sum of `tasking_tasks.area_sqkm` over + tasks the user mapped (each task counted once). + operationId: getUserStats + responses: + "200": + description: User stats. + content: + application/json: + schema: { $ref: "#/components/schemas/UserStats" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": + description: Workspace not found, user has no activity, or outside tenancy. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + parameters: + WorkspaceIdPath: + name: workspace_id + in: path + required: true + schema: { type: integer, format: int64 } + ProjectIdPath: + name: project_id + in: path + required: true + schema: { type: integer, format: int64 } + TaskNumberPath: + name: task_number + in: path + required: true + description: Sequential, human-readable id scoped to the project. Starts at 1. + schema: { type: integer, minimum: 1 } + UserIdPath: + name: user_id + in: path + required: true + description: TDEI auth user uuid. + schema: { type: string, format: uuid } + IdempotencyKeyHeader: + name: Idempotency-Key + in: header + required: false + description: Client-generated unique value (UUID recommended). Scoped per project. + schema: + type: string + minLength: 8 + maxLength: 128 + + schemas: + # ---------- Enums ---------- + + ProjectStatus: + type: string + enum: [draft, open, done] + + TaskStatus: + type: string + enum: [to_map, to_review, to_remap, completed] + + TaskBoundaryType: + type: string + enum: [grid, import] + nullable: true + + GridSource: + type: string + enum: [grid, import] + + WorkspaceUserRoleType: + type: string + enum: [lead, validator, contributor] + + LockReleaseReason: + type: string + enum: [auto_unlock, manual, lead_release, stale_timeout, reset] + description: | + `auto_unlock` — released by a successful `/submit` with `done:true`. + `manual` — caller released their own lock via `DELETE /lock`. + `lead_release` — LEAD force-released via `DELETE /lock?force=true`. + `stale_timeout` — released by the background job (`expires_at < NOW()`). + `reset` — released by `POST /reset` on the task or its project. + + FeedbackReason: + type: string + enum: [incomplete_mapping, data_quality_issue, wrong_area, other] + description: | + Reason categories persisted to `tasking_feedback.reason_category`. + Required when feedback is used to drive a `to_review → to_remap` + transition (see `POST /submit`); optional for generic free-form + notes on a task. + + AuditEventType: + type: string + enum: + - project_created + - project_activated + - project_closed + - project_edited + - project_deleted + - aoi_uploaded + - aoi_deleted + - task_created + - task_state_changed + - task_locked + - task_lock_extended + - task_lock_renewed + - task_unlocked + - task_reset + - project_reset + - changeset_submitted + - feedback_submitted + + # ---------- GeoJSON ---------- + + GeoJsonPolygon: + type: object + required: [type, coordinates] + properties: + type: { type: string, enum: [Polygon] } + coordinates: + type: array + minItems: 1 + items: + type: array + minItems: 4 + items: + type: array + minItems: 2 + maxItems: 3 + items: { type: number } + + GeoJsonMultiPolygon: + type: object + required: [type, coordinates] + properties: + type: { type: string, enum: [MultiPolygon] } + coordinates: + type: array + minItems: 1 + items: + type: array + minItems: 1 + items: + type: array + minItems: 4 + items: + type: array + minItems: 2 + maxItems: 3 + items: { type: number } + + AoiGeometry: + description: AOI accepts either a single Polygon or a MultiPolygon (EPSG:4326). Servers store both as MultiPolygon (single-Polygon inputs are upcast on insert). + oneOf: + - $ref: "#/components/schemas/GeoJsonPolygon" + - $ref: "#/components/schemas/GeoJsonMultiPolygon" + + AoiFeature: + type: object + required: [type, geometry, properties] + properties: + type: { type: string, enum: [Feature] } + geometry: { $ref: "#/components/schemas/AoiGeometry" } + properties: + type: object + additionalProperties: true + + AoiFeatureCollection: + type: object + required: [type, features] + properties: + type: { type: string, enum: [FeatureCollection] } + features: + type: array + minItems: 1 + maxItems: 1 + items: { $ref: "#/components/schemas/AoiFeature" } + + AoiUploadRequest: + oneOf: + - $ref: "#/components/schemas/AoiFeature" + - $ref: "#/components/schemas/AoiFeatureCollection" + - $ref: "#/components/schemas/AoiGeometry" + + TaskBoundaryFeature: + type: object + required: [type, geometry] + properties: + type: { type: string, enum: [Feature] } + geometry: { $ref: "#/components/schemas/GeoJsonPolygon" } + properties: + type: object + additionalProperties: true + + TaskBoundariesFeatureCollection: + type: object + required: [type, features] + properties: + type: { type: string, enum: [FeatureCollection] } + features: + type: array + minItems: 1 + items: { $ref: "#/components/schemas/TaskBoundaryFeature" } + + # ---------- Projects ---------- + + Project: + type: object + required: + - id + - workspaceId + - name + - status + - reviewRequired + - lockTimeoutHours + - createdBy + - createdAt + - updatedAt + - hasAoi + - taskCount + properties: + id: { type: integer, format: int64 } + workspaceId: { type: integer, format: int64 } + name: { type: string, maxLength: 255 } + instructions: { type: string, nullable: true } + status: { $ref: "#/components/schemas/ProjectStatus" } + reviewRequired: { type: boolean, default: true } + lockTimeoutHours: + { type: integer, minimum: 1, maximum: 720, default: 8 } + taskBoundaryType: { $ref: "#/components/schemas/TaskBoundaryType" } + hasAoi: { type: boolean } + taskCount: { type: integer, minimum: 0 } + createdBy: { type: string, format: uuid } + createdByName: { type: string, nullable: true } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + + ProjectListItem: + type: object + required: [id, name, status, taskCount, percentCompleted, createdAt] + properties: + id: { type: integer, format: int64 } + name: { type: string } + status: { $ref: "#/components/schemas/ProjectStatus" } + taskCount: { type: integer, minimum: 0 } + percentCompleted: { type: integer, minimum: 0, maximum: 100 } + createdBy: { type: string, format: uuid } + createdByName: { type: string, nullable: true } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + + ProjectListResponse: + type: object + required: [results, pagination] + properties: + results: + type: array + items: { $ref: "#/components/schemas/ProjectListItem" } + pagination: { $ref: "#/components/schemas/Pagination" } + + ProjectCreateRequest: + type: object + required: [name] + properties: + name: + type: string + minLength: 1 + maxLength: 255 + description: Unique within the workspace (case-insensitive) among non-deleted projects. + instructions: + type: string + maxLength: 10000 + nullable: true + reviewRequired: + type: boolean + default: true + description: If false, tasks transition straight to `completed` on mapper submit. Immutable after activation. + lockTimeoutHours: + type: integer + minimum: 1 + maximum: 720 + default: 8 + aoi: + allOf: + - $ref: "#/components/schemas/AoiUploadRequest" + nullable: true + description: Optional inline AOI (same validation as `POST .../aoi`). + roleAssignments: + type: array + description: | + Optional list of project-level role assignments to seed at + creation. Each entry is upserted into `tasking_project_roles` + in the same transaction as the project insert. + + The creator is auto-added as `lead` on `tasking_project_roles` + and does not need to appear here. + + Each `userId` must be a member of the workspace's TDEI + project group; entries failing this check are rejected with + 422 and no rows are written. + items: { $ref: "#/components/schemas/ProjectRoleAssignment" } + + ProjectRoleAssignment: + type: object + required: [userId, role] + properties: + userId: { type: string, format: uuid } + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + + ProjectUpdateRequest: + type: object + description: All fields optional. Server enforces per-status mutability. + properties: + name: { type: string, minLength: 1, maxLength: 255 } + instructions: { type: string, maxLength: 10000, nullable: true } + lockTimeoutHours: { type: integer, minimum: 1, maximum: 720 } + reviewRequired: { type: boolean } + + # ---------- Tasks ---------- + + TaskLockSummary: + type: object + required: [userId, lockedAt, expiresAt] + properties: + userId: { type: string, format: uuid } + userName: { type: string, nullable: true } + lockedAt: { type: string, format: date-time } + expiresAt: { type: string, format: date-time } + + LastMapper: + type: object + required: [userId] + properties: + userId: { type: string, format: uuid } + userName: { type: string, nullable: true } + + Task: + type: object + required: + [id, taskNumber, status, geometry, areaSqkm, createdAt, updatedAt] + properties: + id: { type: integer, format: int64 } + taskNumber: { type: integer, minimum: 1 } + status: { $ref: "#/components/schemas/TaskStatus" } + geometry: { $ref: "#/components/schemas/GeoJsonPolygon" } + areaSqkm: { type: number } + lock: + nullable: true + allOf: + - $ref: "#/components/schemas/TaskLockSummary" + lastMapper: + nullable: true + allOf: + - $ref: "#/components/schemas/LastMapper" + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + + TaskListResponse: + type: object + required: [tasks, pagination] + properties: + tasks: + type: array + items: { $ref: "#/components/schemas/Task" } + pagination: { $ref: "#/components/schemas/Pagination" } + + ValidateWarning: + type: object + required: [taskIndex, issue] + properties: + taskIndex: + type: integer + description: 0-based index into the submitted `features` array. + issue: + type: string + enum: [polygon_exceeds_grid_size] + areaSqkm: + type: number + + ValidatePreviewResponse: + type: object + required: [valid, warnings, source, featureCollection] + properties: + valid: + type: boolean + description: True when no hard failures. Warnings do not affect this. + warnings: + type: array + items: { $ref: "#/components/schemas/ValidateWarning" } + source: + $ref: "#/components/schemas/GridSource" + description: Always `import` for this response. Pass through unchanged to `/save`. + featureCollection: + $ref: "#/components/schemas/TaskBoundariesFeatureCollection" + + SaveTasksRequest: + type: object + required: [source, featureCollection] + properties: + source: + $ref: "#/components/schemas/GridSource" + description: Sets `tasking_projects.task_boundary_type` for the project. + featureCollection: + $ref: "#/components/schemas/TaskBoundariesFeatureCollection" + + SaveTasksResponse: + type: object + required: [projectId, taskBoundaryType, taskCount, tasks] + properties: + projectId: { type: integer, format: int64 } + taskBoundaryType: { $ref: "#/components/schemas/GridSource" } + taskCount: + type: integer + minimum: 1 + description: Number of tasks created (== `featureCollection.features.length`). + tasks: + type: array + description: All created tasks in the same order as the request's `features[]`. `taskNumber` is sequential starting at 1. + items: { $ref: "#/components/schemas/Task" } + idempotencyKey: + type: string + nullable: true + replayed: + type: boolean + default: false + description: True on idempotent replay (HTTP 200 case). + + # ---------- Submit / Lock ---------- + + Feedback: + type: object + description: | + Feedback payload — persisted to `tasking_feedback`. Used by + `POST /submit` to attach a validator's remap-rejection note, + and reusable by any future endpoint that records free-form + task feedback. + required: [notes] + properties: + reasonCategory: + allOf: + - $ref: "#/components/schemas/FeedbackReason" + nullable: true + description: Required when the feedback drives `to_review → to_remap`; optional otherwise. + notes: + type: string + minLength: 1 + maxLength: 4000 + + SubmitRequest: + type: object + required: [osmChangesetId, done] + properties: + osmChangesetId: + type: integer + format: int64 + minimum: 1 + done: + type: boolean + description: | + `true` — auto-unlocks and transitions state per the table. + `false` — records the changeset, state unchanged, lock + `expires_at` slides forward to `submitted_at + + lock_timeout_hours`. + feedback: + allOf: + - $ref: "#/components/schemas/Feedback" + description: | + Meaningful only in validator context on `to_review`. When + provided here, `feedback.reasonCategory` is required (drives + the `to_remap` transition) and the row is persisted to + `tasking_feedback`. + + ExistingLockSummary: + type: object + required: [taskNumber, taskStatus, lockedAt, expiresAt] + properties: + taskNumber: { type: integer, minimum: 1 } + taskStatus: { $ref: "#/components/schemas/TaskStatus" } + lockedAt: { type: string, format: date-time } + expiresAt: { type: string, format: date-time } + + LockConflictError: + allOf: + - $ref: "#/components/schemas/Error" + - type: object + properties: + existingLock: + $ref: "#/components/schemas/ExistingLockSummary" + + # ---------- Roles ---------- + + UserSummary: + type: object + required: [userId] + properties: + userId: { type: string, format: uuid } + displayName: { type: string, nullable: true } + email: { type: string, nullable: true } + + AssignableRoleType: + type: string + enum: [lead, validator] + description: | + Roles assignable via `PUT /workspaces/{wid}/users/{uid}/role`. + CONTRIBUTOR is implicit (project-group membership grants it + automatically) and cannot be set by this endpoint. + + SetRoleRequest: + type: object + required: [role] + properties: + role: { $ref: "#/components/schemas/AssignableRoleType" } + + WorkspaceUserRoleItem: + type: object + description: | + Privileged-member DTO returned by `GET /workspaces/{wid}/users`. + Mirrors the shipped `WorkspaceUserRoleItem` from + `api/src/users/schemas.py` on the `roles` branch. + required: [id, auth_uid, email, display_name, role] + properties: + id: + type: integer + format: int64 + description: Internal OSM-DB `users.id`. + auth_uid: + type: string + description: TDEI auth user uuid as string (`users.auth_uid`). + email: { type: string } + display_name: { type: string } + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + + ProjectRoleItem: + type: object + description: | + One row of `tasking_project_roles`, joined with `users` for the + display name. Returned by every `/projects/{pid}/roles[/{uid}]` + endpoint. + required: [user_id, role, updated_at] + properties: + user_id: + type: string + format: uuid + description: Maps to `users.auth_uid`. + user_name: + type: string + nullable: true + description: Mirror of `users.display_name`, joined at read time. + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + updated_at: { type: string, format: date-time } + + ProjectRoleListResponse: + type: object + required: [results] + properties: + results: + type: array + items: { $ref: "#/components/schemas/ProjectRoleItem" } + + ProjectRoleAddRequest: + type: object + description: Body for `POST /projects/{pid}/roles`. + required: [user_id, role] + additionalProperties: false + properties: + user_id: + type: string + format: uuid + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + + ProjectRoleUpdateRequest: + type: object + description: Body for `PATCH /projects/{pid}/roles/{user_id}`. + required: [role] + additionalProperties: false + properties: + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + + SelfProjectRoleItem: + type: object + required: [project_id, project_name, project_status, role] + properties: + project_id: { type: integer, format: int64 } + project_name: { type: string } + project_status: { $ref: "#/components/schemas/ProjectStatus" } + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + + SelfProjectRolesResponse: + type: object + required: [workspace_role, projects] + properties: + workspace_role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + projects: + type: array + items: { $ref: "#/components/schemas/SelfProjectRoleItem" } + + # ---------- Audit ---------- + + ActorRef: + type: object + required: [user_id] + properties: + user_id: { type: string, format: uuid } + display_name: { type: string, nullable: true } + + AuditEvent: + type: object + required: [id, event_type, project_id, actor, occurred_at, details] + properties: + id: { type: integer, format: int64 } + event_type: { $ref: "#/components/schemas/AuditEventType" } + project_id: { type: integer, format: int64 } + task_id: + type: integer + format: int64 + nullable: true + task_number: + type: integer + nullable: true + description: Convenience copy from `details` (so list renderers don't peek inside JSONB). + actor: { $ref: "#/components/schemas/ActorRef" } + occurred_at: { type: string, format: date-time } + details: + type: object + additionalProperties: true + project_deleted: + type: boolean + default: false + + AuditEventListResponse: + type: object + required: [results, pagination] + properties: + results: + type: array + items: { $ref: "#/components/schemas/AuditEvent" } + pagination: { $ref: "#/components/schemas/Pagination" } + + # ---------- Stats ---------- + + TaskStatusCounts: + type: object + required: [to_map, to_review, to_remap, completed] + properties: + to_map: { type: integer, minimum: 0 } + to_review: { type: integer, minimum: 0 } + to_remap: { type: integer, minimum: 0 } + completed: { type: integer, minimum: 0 } + + ProjectStats: + type: object + required: + - projectId + - totalTasks + - tasksByStatus + - percentMapped + - percentCompleted + - uniqueMappers + - uniqueValidators + properties: + projectId: { type: integer, format: int64 } + totalTasks: { type: integer, minimum: 0 } + tasksByStatus: { $ref: "#/components/schemas/TaskStatusCounts" } + percentMapped: { type: integer, minimum: 0, maximum: 100 } + percentCompleted: { type: integer, minimum: 0, maximum: 100 } + avgTaskDurationMinutes: + type: number + nullable: true + uniqueMappers: { type: integer, minimum: 0 } + uniqueValidators: { type: integer, minimum: 0 } + + UserStatsTotals: + type: object + required: + - tasksMapped + - tasksValidated + - totalMappingMinutes + - totalValidationMinutes + - totalChangesets + - totalAreaSqkm + properties: + tasksMapped: { type: integer, minimum: 0 } + tasksValidated: { type: integer, minimum: 0 } + totalMappingMinutes: { type: integer, minimum: 0 } + totalValidationMinutes: { type: integer, minimum: 0 } + totalChangesets: { type: integer, minimum: 0 } + totalAreaSqkm: { type: number, minimum: 0 } + + UserStatsByProject: + type: object + required: [projectId, projectName, tasksMapped, tasksValidated] + properties: + projectId: { type: integer, format: int64 } + projectName: { type: string } + tasksMapped: { type: integer, minimum: 0 } + tasksValidated: { type: integer, minimum: 0 } + totalChangesets: { type: integer, minimum: 0 } + totalAreaSqkm: { type: number, minimum: 0 } + + UserStats: + type: object + required: [workspaceId, userId, totals, byProject] + properties: + workspaceId: { type: integer, format: int64 } + userId: { type: string, format: uuid } + totals: { $ref: "#/components/schemas/UserStatsTotals" } + byProject: + type: array + items: { $ref: "#/components/schemas/UserStatsByProject" } + + # ---------- Shared ---------- + + Pagination: + type: object + required: [page, pageSize, total] + properties: + page: { type: integer, minimum: 1 } + pageSize: { type: integer, minimum: 1 } + total: { type: integer, minimum: 0 } + + Error: + type: object + required: [detail] + properties: + detail: + oneOf: + - type: string + - type: array + items: { type: object, additionalProperties: true } + description: FastAPI default error shape — string for raised `HTTPException`, structured list for pydantic validation errors. + + responses: + BadRequest: + description: Malformed JSON or schema-level failure. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + Unauthorized: + description: Missing or invalid bearer token. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + ForbiddenLeadRequired: + description: Caller is a workspace contributor but does not hold LEAD. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + WorkspaceNotFound: + description: Workspace does not exist, or is outside the caller's tenancy. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + ProjectOrWorkspaceNotFound: + description: Workspace or project does not exist, or is outside the caller's tenancy. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + TaskOrProjectOrWorkspaceNotFound: + description: Workspace, project, or task does not exist, or is outside the caller's tenancy. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + UnprocessableEntity: + description: Well-formed request that violates a business rule. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } diff --git a/pyproject.toml b/pyproject.toml index b4e4b1f..4f507b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,8 @@ dependencies = [ "requests-cache>=1.2.1", "pyjwt", "sqlmodel>=0.0.8", - "cachetools" + "cachetools", + "shapely>=2.1.2", ] [tool.pytest.ini_options] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..309c430 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator, Iterator +from typing import Callable +from uuid import UUID, uuid4 + +import pytest + +# Constants — referenced by both unit and integration suites. +SEED_WORKSPACE_ID = 1899 +SEED_PROJECT_GROUP_ID = UUID("00000000-0000-0000-0000-000000001899") + + +@pytest.fixture +def seeded_workspace_id() -> int: + """Workspace id used by route URLs. + + Unit tests pretend this exists via a FakeWorkspaceRepository. + Integration overrides this fixture (and seeds a real workspaces + row with the same id) in ``tests/integration/conftest.py``. + """ + return SEED_WORKSPACE_ID + + +# --------------------------------------------------------------------------- +# HTTP client — ASGI transport (no socket); shared by unit and +# integration suites. The `request` / `response` event hooks log every +# call through stdlib `logging` so pytest-html captures the full HTTP +# trace per test in the report. +# --------------------------------------------------------------------------- + + +import logging # noqa: E402 (kept near the fixture for locality) + +_http_log = logging.getLogger("tests.http") + + +# --------------------------------------------------------------------------- +# pytest-html: surface each test's docstring as a "Description" column +# so the report is self-explanatory. +# --------------------------------------------------------------------------- + + +def _test_description(item) -> str: + """Pull the first non-empty docstring line off a pytest item.""" + fn = getattr(item, "function", None) or getattr(item, "obj", None) + doc = (getattr(fn, "__doc__", None) or "").strip() + if not doc: + return "" + return doc.splitlines()[0].strip() + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item, call): + """Annotate the test report with the docstring for pytest-html to render.""" + outcome = yield + report = outcome.get_result() + report.description = _test_description(item) + + +# The two hooks below are owned by the `pytest-html` plugin. When the +# plugin isn't installed (e.g. `uv sync` doesn't include it as a base +# dependency), pluggy refuses to register them and aborts collection. +# Declare them conditionally so the suite runs either way. +try: + import pytest_html # noqa: F401 + + def pytest_html_results_table_header(cells): + """Inject a 'Description' column header next to the test name.""" + cells.insert(2, "