diff --git a/docs/wiki/Forum-Channels-and-Hubs.md b/docs/wiki/Forum-Channels-and-Hubs.md
index bebc9b0..3b13ae8 100644
--- a/docs/wiki/Forum-Channels-and-Hubs.md
+++ b/docs/wiki/Forum-Channels-and-Hubs.md
@@ -24,10 +24,12 @@ In Discord Forum Channels, users cannot type slash commands at the forum root le
To make forum channels completely self-sufficient:
- When projects are linked to a forum channel, the bot automatically creates and **pins** a permanent thread post: **`đ đ [Channel Name / Project] âĸ Control Hub`**.
- The post contains persistent interactive buttons:
- - **`â New Task`**: Opens the interactive Task Creation Builder (in multi-project channels, first prompts with a project selection menu).
- - **`đ¤ My Tasks`**: Launches a private personal dashboard showing assigned tasks, deadlines, and status.
- - **`đ Projects Hub`**: Launches a private project directory workspace.
- - **`đ˛ Visual Graph`**: Renders the interactive visual dependency DAG diagram (with project selector if multiple projects are bound).
+ - **`â New Task`** (Row 0): Opens the interactive Task Creation Builder (in multi-project channels, first prompts with a project selection menu).
+ - **`đ¤ My Tasks`** (Row 0): Launches a private personal dashboard showing assigned tasks, deadlines, and status.
+ - **`đ Projects Hub`** (Row 0): Launches a private project management workspace with container administration tools for authorized Team Leads and Server Managers.
+ - **`đ˛ Visual Graph`** (Row 0): Renders the interactive visual dependency DAG diagram (with project selector if multiple projects are bound).
+ - **`â° Overdue`** (Row 0): Directly opens the task board filtered to incomplete tasks past their due date.
+ - **`đ Create Project`** (Row 1): Allows authorized Team Leads and Server Managers to initialize and bind a new project forum channel directly from the control hub.
### Single-Project vs. Multi-Project Forums
@@ -53,8 +55,9 @@ To make forum channels completely self-sufficient:
â đ đ Mobile App âĸ Control Hub (Public / Pinned) â
â Interactive management dashboard for Mobile App. â
â â
-â [ â New Task ] [ đ¤ My Tasks ] â
-â [ đ Projects Hub ] [ đ˛ Visual Graph ] â
+â [ â New Task ] [ đ¤ My Tasks ] [ đ Projects Hub ] â
+â [ đ˛ Visual Graph ] [ â° Overdue ] â
+â [ đ Create Project ] â
ââââââââââââââââââââââââââââŦââââââââââââââââââââââââââââââââ
â (Alice clicks [ â New Task ])
âŧ
diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md
index 4132571..08cba3b 100644
--- a/docs/wiki/Home.md
+++ b/docs/wiki/Home.md
@@ -20,12 +20,13 @@ Welcome to the **DGG-PM** GitHub Wiki!
3. **[Squads & Authorization Matrix](Teams-and-Authorization.md)**
- Discord-native role membership.
+ - Configurable Team Lead roles for delegated project management.
- Squad Leads & 3-tier self-healing protection.
- Role-restricted task assignments and mutation guards.
4. **[Forum Channels & Interactive Hubs](Forum-Channels-and-Hubs.md)**
- Automatic PM tag provisioning (Status, Priority, Unassigned).
- - Automated pinned control center post creation.
+ - Automated pinned control center post creation with direct Project & Task creation.
- Dynamic Task Action Cards & thread workspaces.
5. **[Database Migrations Manual (Alembic)](../migrations.md)**
@@ -38,5 +39,7 @@ Welcome to the **DGG-PM** GitHub Wiki!
- **Single Namespace (`/pm`)**: No top-level slash command clutter or collisions with other server bots.
- **100% Discord-Native**: Discord Server Roles are the real-time source of truth for squad rosters.
+- **Flexible Least-Privilege Delegation**: Authorize designated Discord roles as Team Leads for project and squad management without granting server-wide administrator permissions.
- **Zero-Command Workflows**: Pinned Forum Hubs, Modals, Dropdowns, and Action Cards allow daily operations without typing CLI commands.
- **Self-Healing State**: Stripping a Discord role instantly revokes lead privileges and cleans up database records automatically.
+
diff --git a/docs/wiki/Slash-Commands-Reference.md b/docs/wiki/Slash-Commands-Reference.md
index a1f77e7..f932c56 100644
--- a/docs/wiki/Slash-Commands-Reference.md
+++ b/docs/wiki/Slash-Commands-Reference.md
@@ -37,16 +37,16 @@ All DGG-PM bot commands are grouped under the single `/pm` top-level namespace t
| Subcommand | Parameters | Description | Permission Required |
| :--- | :--- | :--- | :--- |
-| **`create`** | `name` (required)
`prefix` (required)
`role` (required: `@Role`)
`channel` (optional)
`description` (optional)
`category` (optional) | Creates a project container, maps the squad Discord role, and automatically provisions standard tags + pinned Control Hub if a forum channel is linked. | `Manage Server` |
+| **`create`** | `name` (required)
`prefix` (required)
`role` (required: `@Role`)
`channel` (optional)
`description` (optional)
`category` (optional) | Creates a project container, maps the squad Discord role, and automatically provisions standard tags + pinned Control Hub if a forum channel is linked. | `Manage Server` OR Authorized Team Lead Role |
| **`tree`** | `project_name` (required)
`orientation` (optional: `lr`, `tb`) | Renders the interactive visual dependency graph for the project. | `@everyone` |
-| **`role`** | `project_name` (required)
`role` (required: `@Role`)
`action` (required: `add`, `remove`) | Maps or unmaps additional Discord squad roles to a project container (for cross-functional squads). | `Manage Server` |
-| **`squad`** | `project_name` (required)
`squad_name` (required)
`action` (required: `add`, `remove`) | Maps or unmaps a functional squad to a project container (alias: `team`). | `Manage Server` |
-| **`lead`** | `project_name` (required)
`user` (required: `@Member`)
`action` (required: `add`, `remove`) | Designates or removes a Squad Lead for the project's squads. | `Manage Server` OR Active Squad Lead |
+| **`role`** | `project_name` (required)
`role` (required: `@Role`)
`action` (required: `add`, `remove`) | Maps or unmaps additional Discord squad roles to a project container (for cross-functional squads). | `Manage Server` OR Authorized Team Lead Role |
+| **`squad`** | `project_name` (required)
`squad_name` (required)
`action` (required: `add`, `remove`) | Maps or unmaps a functional squad to a project container (alias: `team`). | `Manage Server` OR Authorized Team Lead Role |
+| **`lead`** | `project_name` (required)
`user` (required: `@Member`)
`action` (required: `add`, `remove`) | Designates or removes a Squad Lead for the project's squads. | `Manage Server` OR Authorized Team Lead Role OR Active Squad Lead |
| **`list`** | None | Lists all active project containers and their bound Discord channels. | `@everyone` |
-| **`archive`** | `project_name` (required) | Archives a project container and cascades thread archiving. | `Manage Server` |
-| **`unarchive`** | `project_name` (required) | Restores an archived project container and reopens task threads. | `Manage Server` |
-| **`setup_forum`** | `forum` (required) | Automatically configures standard PM tags on a Discord Forum Channel. | `Manage Server` |
-| **`rebuild`** | `project_name` (required)
`forum` (optional: `#Channel`) | Reconstructs and reconciles a project's Discord presence (forum channel, tags, control hub, and task thread workspaces) from database state. | `Manage Server` |
+| **`archive`** | `project_name` (required) | Archives a project container and cascades thread archiving. | `Manage Server` OR Authorized Team Lead Role |
+| **`unarchive`** | `project_name` (required) | Restores an archived project container and reopens task threads. | `Manage Server` OR Authorized Team Lead Role |
+| **`setup_forum`** | `forum` (required) | Automatically configures standard PM tags on a Discord Forum Channel. | `Manage Server` OR Authorized Team Lead Role |
+| **`rebuild`** | `project_name` (required)
`forum` (optional: `#Channel`) | Reconstructs and reconciles a project's Discord presence (forum channel, tags, control hub, and task thread workspaces) from database state. | `Manage Server` OR Authorized Team Lead Role |
---
@@ -56,8 +56,8 @@ All DGG-PM bot commands are grouped under the single `/pm` top-level namespace t
| Subcommand | Parameters | Description | Permission Required |
| :--- | :--- | :--- | :--- |
-| **`create`** | `role` (required)
`squad_name` (optional) | Creates a functional squad mapped to an existing Discord Server Role. | `Manage Server` |
-| **`lead`** | `action` (required: `add`, `remove`)
`squad_name` (required)
`user` (required) | Designates or removes a Squad Lead by squad name. | `Manage Server` OR Active Squad Lead |
+| **`create`** | `role` (required)
`squad_name` (optional) | Creates a functional squad mapped to an existing Discord Server Role. | `Manage Server` OR Authorized Team Lead Role |
+| **`lead`** | `action` (required: `add`, `remove`)
`squad_name` (required)
`user` (required) | Designates or removes a Squad Lead by squad name. | `Manage Server` OR Authorized Team Lead Role OR Active Squad Lead |
| **`list`** | None | Displays all squads, designated leads, and live Discord role member counts. | `@everyone` |
---
@@ -66,5 +66,7 @@ All DGG-PM bot commands are grouped under the single `/pm` top-level namespace t
| Subcommand | Parameters | Description | Permission Required |
| :--- | :--- | :--- | :--- |
+| **`lead-role`** | `action` (required: `add`, `remove`, `list`)
`role` (optional: `@Role`) | Registers, removes, or lists Discord server roles authorized as Team Leads for project and squad management. | `Manage Server` |
| **`sync`** | `scope` (optional: `guild`, `global`) | Synchronizes application slash commands with Discord on demand without restarting the bot. | `Manage Server` |
+
diff --git a/docs/wiki/Teams-and-Authorization.md b/docs/wiki/Teams-and-Authorization.md
index 957483e..a705aaf 100644
--- a/docs/wiki/Teams-and-Authorization.md
+++ b/docs/wiki/Teams-and-Authorization.md
@@ -9,7 +9,13 @@ DGG-PM implements a Discord-native permission model. Discord server roles act as
```
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â Server Managers (Manage Server / Administrator) â
-â - Full bypass across all projects, tasks, and squads â
+â - Full server bypass; configure Team Lead roles â
+ââââââââââââââââââââââââââââŦââââââââââââââââââââââââââââââ
+ â
+ââââââââââââââââââââââââââââŧââââââââââââââââââââââââââââââ
+â Authorized Team Lead Roles & Active Squad Leads â
+â - Create and manage projects, channels, & squads â
+â - Granted via server role without Discord admin perms â
ââââââââââââââââââââââââââââŦââââââââââââââââââââââââââââââ
â
ââââââââââââââââââââââââââââŧââââââââââââââââââââââââââââââ
@@ -32,14 +38,46 @@ DGG-PM implements a Discord-native permission model. Discord server roles act as
## đĄī¸ Mutation Authorization Matrix
-| Action | Server Manager | Squad Lead | Squad Member (Mapped Role) | Task Assignee | Task Creator | Other Server Member |
-| :--- | :---: | :---: | :---: | :---: | :---: | :---: |
-| **Create Project / Squad** | â
| â | â | â | â | â |
-| **Designate / Remove Lead** | â
| â
(Own Squad) | â | â | â | â |
-| **Create Project Task** | â
| â
| â
| â | â | â |
-| **Mutate / Edit Task** | â
| â
| â
| â
| â
| â |
-| **Assign Task to Member** | â
| â
| â
| â
| â
| â (Target must hold squad role) |
-| **Self-Service Watchers (CC)** | â
| â
| â
| â
| â
| â
(Add/Remove Self) |
+| Action | Server Manager | Authorized Team Lead Role | Squad Lead | Squad Member (Mapped Role) | Task Assignee | Task Creator | Other Server Member |
+| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
+| **Configure Lead Roles** | â
| â | â | â | â | â | â |
+| **Create Project / Squad** | â
| â
| â
| â | â | â | â |
+| **Manage Project (Role, Archive)** | â
| â
| â | â | â | â | â |
+| **Designate / Remove Squad Lead** | â
| â
| â
(Own Squad) | â | â | â | â |
+| **Create Project Task** | â
| â
| â
| â
| â | â | â |
+| **Mutate / Edit Task** | â
| â
| â
| â
| â
| â
| â |
+| **Assign Task to Member** | â
| â
| â
| â
| â
| â
| â (Target must hold squad role) |
+| **Self-Service Watchers (CC)** | â
| â
| â
| â
| â
| â
| â
(Add/Remove Self) |
+
+---
+
+## đī¸ Configurable Team Lead Roles
+
+To adhere to the principle of least privilege, DGG-PM allows server managers to authorize specific Discord server roles as **Team Lead roles**. Members holding these roles can create and manage project containers, map squads, bind channels, and designate leads **without requiring server-wide `Manage Server` or `Administrator` permissions**.
+
+### 1. Interactive Admin Dashboard (`/pm menu`)
+1. Open the project dashboard using `/pm menu`.
+2. Members with `Manage Server` or `Administrator` permissions will see a **`Lead Roles`** button in the top action row.
+3. Clicking **`Lead Roles`** opens the interactive role management portal:
+ - Displays all currently authorized Team Lead roles in an embed list.
+ - Includes a native Discord **Role Select** dropdown to select any server role.
+ - Provides 1-click **`Assign Role`** and **`Remove Role`** actions.
+ - Includes a **`Back to Dashboard`** button to return cleanly.
+
+### 2. Slash Command Management (`/pm admin lead-role`)
+Server Managers can also configure authorized roles via slash commands:
+- **Add a Lead Role**:
+ ```text
+ /pm admin lead-role action:add role:@Engineering Lead
+ ```
+- **Remove a Lead Role**:
+ ```text
+ /pm admin lead-role action:remove role:@Engineering Lead
+ ```
+- **List All Lead Roles**:
+ ```text
+ /pm admin lead-role action:list
+ ```
---
@@ -56,3 +94,4 @@ If an administrator strips a Discord role from a user in server settings (or if
3. **Tier 3: Display-Time Reconciliation**:
- `/pm squad list` (alias: `/pm team list`) and the interactive Squad Roster detail menu cross-reference database records against live Discord `role.members` and automatically clean up any lingering records on-the-fly.
+
diff --git a/docs/wiki/Workflow-Guide.md b/docs/wiki/Workflow-Guide.md
index a7dd4b5..8ec4c6a 100644
--- a/docs/wiki/Workflow-Guide.md
+++ b/docs/wiki/Workflow-Guide.md
@@ -58,9 +58,28 @@ Before creating projects and running commands in your server, configure your bot
## đ ī¸ Step-by-Step Setup (Admins & Leads)
-### 1. Create a Project & Map Discord Role (1 Step)
-In DGG-PM, projects are directly bound to the Discord Server Role representing the functional squad working on that project.
+### 1. (Optional) Authorize Team Lead Roles (Server Managers)
+Server managers can delegate project creation and management authority to trusted team lead roles without granting full Discord administrator permissions:
+- **Via Interactive Menu**: Run `/pm menu`, click the **`Lead Roles`** button, select the role (e.g. `@Engineering Lead`) from the dropdown, and click **`Assign Role`**.
+- **Via Slash Command**:
+ ```text
+ /pm admin lead-role action:add role:@Engineering Lead
+ ```
+
+Members with this role can now create projects, bind channels, and map squads across the server.
+
+---
+
+### 2. Create a Project & Map Discord Role
+Projects can be created by Server Managers or any member holding an Authorized Team Lead role.
+
+#### Option A: Zero-Command Interactive Creation
+1. From any pinned **Control Hub**, click **`đ Create Project`** (or open `/pm menu` and click **`Create Project`**).
+2. Select an existing Forum Channel (or choose to create a new one).
+3. Fill out the popup modal with Project Name, Prefix, and primary squad role.
+
+#### Option B: Slash Command
```text
/pm project create name:Mobile App prefix:MOB role:@Mobile Developers channel:#mobile-dev-forum
```
@@ -73,18 +92,18 @@ In DGG-PM, projects are directly bound to the Discord Server Role representing t
---
-### 2. Optional: Designate Team Leads
-Assign one or more members holding the project's role as Team Leads:
+### 3. Designate Squad Leads
+Assign one or more members holding the project's role as Squad Leads:
```text
/pm project lead project_name:Mobile App user:@Alice action:add
```
> [!NOTE]
-> Team leads can manage task assignments and mutations for their project. If an admin strips the Discord role from a user in server settings, their lead privileges are revoked immediately and automatically cleaned up.
+> Squad leads can manage task assignments, squad rosters, and mutations for their project. If an admin strips the Discord role from a user in server settings, their lead privileges are revoked immediately and automatically cleaned up via our 3-tier self-healing system.
---
-### 3. Optional: Add Additional Roles (Cross-Functional Projects)
+### 4. Optional: Add Additional Roles (Cross-Functional Projects)
If a project needs multiple squads (e.g. adding `@QA` or `@Design`):
```text
diff --git a/src/adapters/db/migrations/versions/0002_add_guild_lead_roles.py b/src/adapters/db/migrations/versions/0002_add_guild_lead_roles.py
new file mode 100644
index 0000000..d7e5c59
--- /dev/null
+++ b/src/adapters/db/migrations/versions/0002_add_guild_lead_roles.py
@@ -0,0 +1,34 @@
+"""add_guild_lead_roles
+
+Revision ID: 0002
+Revises: 0001
+Create Date: 2026-09-17 18:12:06.514224
+
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "0002"
+down_revision: str | Sequence[str] | None = "0001"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ op.create_table(
+ "guild_lead_roles",
+ sa.Column("guild_id", sa.BigInteger(), nullable=False),
+ sa.Column("discord_role_id", sa.BigInteger(), nullable=False),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ sa.PrimaryKeyConstraint("guild_id", "discord_role_id"),
+ )
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ op.drop_table("guild_lead_roles")
diff --git a/src/adapters/db/postgres_repo.py b/src/adapters/db/postgres_repo.py
index 925a289..a89a88c 100644
--- a/src/adapters/db/postgres_repo.py
+++ b/src/adapters/db/postgres_repo.py
@@ -11,6 +11,7 @@
from sqlalchemy.orm import selectinload
from src.adapters.db.tables import (
+ GuildLeadRoleTable,
OutboxEventTable,
ProjectSquadTable,
ProjectTable,
@@ -42,6 +43,7 @@
UserPreference,
)
from src.ports.repositories import (
+ IGuildLeadRoleRepository,
IOutboxRepo,
IProjectRepo,
ISquadRepo,
@@ -1281,3 +1283,40 @@ async def get_preferences_bulk(
except ValueError:
result[r.user_discord_id] = NotificationPreference.DM
return result
+
+
+class PostgresGuildLeadRoleRepository(BasePostgresRepo, IGuildLeadRoleRepository):
+ async def add_lead_role(self, guild_id: int, discord_role_id: int) -> None:
+ async with self._get_session() as session:
+ stmt = select(GuildLeadRoleTable).where(
+ GuildLeadRoleTable.guild_id == guild_id,
+ GuildLeadRoleTable.discord_role_id == discord_role_id,
+ )
+ res = await session.execute(stmt)
+ if res.scalar_one_or_none() is not None:
+ return
+ row = GuildLeadRoleTable(guild_id=guild_id, discord_role_id=discord_role_id)
+ session.add(row)
+ if self._should_commit(None):
+ await session.commit()
+ else:
+ await session.flush()
+
+ async def remove_lead_role(self, guild_id: int, discord_role_id: int) -> bool:
+ async with self._get_session() as session:
+ stmt = delete(GuildLeadRoleTable).where(
+ GuildLeadRoleTable.guild_id == guild_id,
+ GuildLeadRoleTable.discord_role_id == discord_role_id,
+ )
+ res = await session.execute(stmt)
+ if self._should_commit(None):
+ await session.commit()
+ else:
+ await session.flush()
+ return bool(res.rowcount and res.rowcount > 0)
+
+ async def list_lead_role_ids(self, guild_id: int) -> set[int]:
+ async with self._get_session() as session:
+ stmt = select(GuildLeadRoleTable.discord_role_id).where(GuildLeadRoleTable.guild_id == guild_id)
+ res = await session.execute(stmt)
+ return set(res.scalars().all())
diff --git a/src/adapters/db/tables.py b/src/adapters/db/tables.py
index bb070ed..f024add 100644
--- a/src/adapters/db/tables.py
+++ b/src/adapters/db/tables.py
@@ -192,3 +192,11 @@ class UserPreferenceTable(Base):
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
+
+
+class GuildLeadRoleTable(Base):
+ __tablename__ = "guild_lead_roles"
+
+ guild_id = Column(BigInteger, primary_key=True)
+ discord_role_id = Column(BigInteger, primary_key=True)
+ created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
diff --git a/src/adapters/db/unit_of_work.py b/src/adapters/db/unit_of_work.py
index 5dd2d7a..4aa74cf 100644
--- a/src/adapters/db/unit_of_work.py
+++ b/src/adapters/db/unit_of_work.py
@@ -6,6 +6,7 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.adapters.db.postgres_repo import (
+ PostgresGuildLeadRoleRepository,
PostgresOutboxRepo,
PostgresProjectRepo,
PostgresSquadRepo,
@@ -13,6 +14,7 @@
PostgresUserPreferenceRepo,
)
from src.ports.repositories import (
+ IGuildLeadRoleRepository,
IOutboxRepo,
IProjectRepo,
ISquadRepo,
@@ -35,6 +37,11 @@ def __init__(self, session_factory: async_sessionmaker[AsyncSession]):
self.squads: ISquadRepo = None # type: ignore[assignment]
self.outbox: IOutboxRepo = None # type: ignore[assignment]
self.user_prefs: IUserPreferenceRepo = None # type: ignore[assignment]
+ self.guild_lead_roles: IGuildLeadRoleRepository = None # type: ignore[assignment]
+
+ @property
+ def session_factory(self) -> async_sessionmaker[AsyncSession]:
+ return self._session_factory
async def __aenter__(self) -> SqlAlchemyUnitOfWork:
self._session = self._session_factory()
@@ -43,6 +50,7 @@ async def __aenter__(self) -> SqlAlchemyUnitOfWork:
self.squads = PostgresSquadRepo(self._session)
self.outbox = PostgresOutboxRepo(self._session)
self.user_prefs = PostgresUserPreferenceRepo(self._session)
+ self.guild_lead_roles = PostgresGuildLeadRoleRepository(self._session)
return self
async def __aexit__(
diff --git a/src/adapters/discord_bot/bot.py b/src/adapters/discord_bot/bot.py
index f41e87d..112265b 100644
--- a/src/adapters/discord_bot/bot.py
+++ b/src/adapters/discord_bot/bot.py
@@ -7,6 +7,7 @@
from discord.ext import commands
from src.adapters.discord_bot.cogs.pm_cog import PmCog
+from src.adapters.discord_bot.error_handler import send_interaction_error
from src.adapters.discord_bot.project_workspace import DiscordProjectWorkspaceAdapter
from src.adapters.discord_bot.task_workspace import DiscordTaskWorkspaceAdapter
from src.adapters.discord_bot.views.hub_menu import PmHubView
@@ -16,6 +17,7 @@
)
from src.config import settings
from src.domain.models import Task
+from src.ports.repositories import IGuildLeadRoleRepository
from src.services.auth_service import AuthService
from src.services.outbox_service import OutboxService
from src.services.project_service import ProjectService
@@ -36,6 +38,8 @@ def __init__(
outbox_service: OutboxService | None = None,
workspace: ITaskDiscordWorkspace | None = None,
project_workspace: IProjectDiscordWorkspace | None = None,
+ guild_lead_role_repo: IGuildLeadRoleRepository | None = None,
+ auth_service: AuthService | None = None,
):
intents = discord.Intents.default()
intents.guilds = True
@@ -54,10 +58,16 @@ def __init__(
self.outbox_service = outbox_service or (
getattr(task_service, "outbox_service", None) if task_service else None
)
+ self.guild_lead_role_repo = guild_lead_role_repo
self._background_tasks: set[asyncio.Task] = set()
- self.auth_service = (
- AuthService(project_service, self.squad_service) if project_service and self.squad_service else None
- )
+ if auth_service is not None:
+ self.auth_service = auth_service
+ elif project_service and self.squad_service:
+ self.auth_service = AuthService(
+ project_service, self.squad_service, guild_lead_role_repo=guild_lead_role_repo
+ )
+ else:
+ self.auth_service = None
if workspace is not None:
self.workspace = workspace
elif task_service and project_service:
@@ -84,6 +94,21 @@ def __init__(
else:
self.project_workspace = None
+ self.tree.on_error = self.on_tree_error
+
+ async def on_tree_error(
+ self, interaction: discord.Interaction, error: discord.app_commands.AppCommandError
+ ) -> None:
+ """Global fallback error handler for slash commands outside cogs or tree-level errors."""
+ cmd_name = interaction.command.qualified_name if interaction.command else "command"
+ await send_interaction_error(
+ interaction,
+ error,
+ f"executing '/{cmd_name}'",
+ logger,
+ ephemeral=True,
+ )
+
async def setup_hook(self) -> None:
"""Invoked when bot is starting up before login."""
# Load unified /pm command group cog
diff --git a/src/adapters/discord_bot/cogs/pm_cog.py b/src/adapters/discord_bot/cogs/pm_cog.py
index e6d15cf..25f764b 100644
--- a/src/adapters/discord_bot/cogs/pm_cog.py
+++ b/src/adapters/discord_bot/cogs/pm_cog.py
@@ -80,7 +80,7 @@ def __init__(
self.squad_service = squad_service
self.task_service = task_service
self.auth_service = auth_service or (
- AuthService(project_service, self.squad_service) if project_service and self.squad_service else None
+ AuthService(project_service, self.squad_service) if project_service else None
)
self.user_service = user_service
self.outbox_service = outbox_service or getattr(bot, "outbox_service", None)
@@ -187,12 +187,26 @@ async def menu(self, interaction: discord.Interaction) -> None:
else NotificationPreference.DM
)
+ can_manage = (
+ await self.auth_service.can_manage_projects(
+ interaction.user,
+ guild_id=interaction.guild.id,
+ guild=interaction.guild,
+ )
+ if self.auth_service
+ else False
+ )
+ is_server_admin = self.auth_service.is_server_manager(interaction.user) if self.auth_service else False
+
view = PmDashboardView(
project_service=self.project_service,
squad_service=self.squad_service,
task_service=self.task_service,
user_service=self.user_service,
+ auth_service=self.auth_service,
initial_interaction=interaction,
+ is_server_manager=can_manage,
+ is_server_admin=is_server_admin,
)
embed = build_pm_dashboard_embed(
guild=interaction.guild,
@@ -200,7 +214,8 @@ async def menu(self, interaction: discord.Interaction) -> None:
active_projects=projects,
active_tasks_count=count,
current_pref=current_pref,
- is_server_manager=view.is_server_manager,
+ is_server_manager=can_manage,
+ is_server_admin=is_server_admin,
)
await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
from src.adapters.discord_bot.menu_manager import menu_manager
@@ -329,7 +344,6 @@ async def notification(self, interaction: discord.Interaction, notify_preference
description="Post and pin an interactive Project Management Control Hub in a forum or text channel.",
)
@app_commands.describe(channel="Target channel to post and pin the PM Hub")
- @app_commands.checks.has_permissions(manage_guild=True)
async def post_hub(
self,
interaction: discord.Interaction,
@@ -348,6 +362,8 @@ async def post_hub(
await interaction.response.defer(ephemeral=True)
try:
+ if self.auth_service:
+ await self.auth_service.require_project_management(interaction.user, interaction.guild.id)
_ok, msg = await ensure_pinned_hub_post(
channel=target_channel,
project_service=self.project_service,
@@ -486,6 +502,83 @@ async def admin_retry_outbox(
ephemeral=True,
)
+ @admin_group.command(
+ name="lead-role",
+ description="Authorize or revoke an existing Discord Team Lead role to create and manage projects",
+ )
+ @app_commands.describe(
+ action="Action to perform (add, remove, or list authorized Team Lead roles)",
+ role="Discord role to authorize or revoke (required for add/remove)",
+ )
+ @app_commands.choices(
+ action=[
+ app_commands.Choice(name="Add Team Lead Role", value="add"),
+ app_commands.Choice(name="Remove Team Lead Role", value="remove"),
+ app_commands.Choice(name="List Team Lead Roles", value="list"),
+ ]
+ )
+ @app_commands.checks.has_permissions(manage_guild=True)
+ async def admin_lead_role(
+ self,
+ interaction: discord.Interaction,
+ action: str,
+ role: discord.Role | None = None,
+ ) -> None:
+ """Authorize or revoke an existing Discord role to create/manage projects and squads."""
+ if not interaction.guild:
+ return
+ await interaction.response.defer(ephemeral=True)
+ try:
+ guild_id = interaction.guild.id
+ if action == "add":
+ if not role:
+ await interaction.followup.send("â Please specify a Discord role to add.", ephemeral=True)
+ return
+ await self.auth_service.add_guild_lead_role(guild_id, role.id)
+ await interaction.followup.send(
+ f"â **Added Team Lead Role**: <@&{role.id}>\n"
+ f"Members holding this role are now authorized to create and manage projects and squads.",
+ ephemeral=True,
+ )
+ elif action == "remove":
+ if not role:
+ await interaction.followup.send("â Please specify a Discord role to remove.", ephemeral=True)
+ return
+ removed = await self.auth_service.remove_guild_lead_role(guild_id, role.id)
+ if removed:
+ await interaction.followup.send(
+ f"â
**Removed Team Lead Role**: <@&{role.id}> is no longer authorized to manage projects.",
+ ephemeral=True,
+ )
+ else:
+ await interaction.followup.send(
+ f"âšī¸ <@&{role.id}> was not registered as an authorized Team Lead role.",
+ ephemeral=True,
+ )
+ elif action == "list":
+ role_ids = await self.auth_service.list_guild_lead_roles(guild_id)
+ if not role_ids:
+ await interaction.followup.send(
+ "âšī¸ **No Team Lead Roles Configured**\n"
+ "Only Discord Server Managers (users with `Manage Server` or `Administrator`) "
+ "can create and manage projects.",
+ ephemeral=True,
+ )
+ else:
+ mentions = "\n".join(f"âĸ <@&{rid}> (`{rid}`)" for rid in sorted(role_ids))
+ embed = discord.Embed(
+ title="đĨ Authorized Team Lead Roles",
+ description=(
+ "Members holding any of the following Discord roles can create and manage "
+ "projects and squads without requiring server-wide `Manage Server` permissions:\n\n"
+ + mentions
+ ),
+ color=discord.Color.blue(),
+ )
+ await interaction.followup.send(embed=embed, ephemeral=True)
+ except Exception as e:
+ await send_interaction_error(interaction, e, "managing team lead roles", logger, ephemeral=True)
+
# ==========================================
# Task Subgroup: /pm task
# ==========================================
@@ -1242,7 +1335,6 @@ async def task_undepend(
description="Optional markdown project overview",
category="Optional organizational category",
)
- @app_commands.checks.has_permissions(manage_guild=True)
async def project_create(
self,
interaction: discord.Interaction,
@@ -1264,6 +1356,8 @@ async def project_create(
return
await interaction.response.defer(ephemeral=True)
try:
+ if self.auth_service:
+ await self.auth_service.require_project_management(interaction.user, interaction.guild.id)
roles = [r for r in [role, role_2, role_3] if r is not None]
ref = await self.project_workspace.provision_project(
ProjectProvisionSpec(
@@ -1387,12 +1481,13 @@ async def project_tree(
@project_group.command(name="archive", description="Archive a project container.")
@app_commands.describe(project_name="Name of the project to archive")
@app_commands.autocomplete(project_name=project_autocomplete)
- @app_commands.checks.has_permissions(manage_guild=True)
async def project_archive(self, interaction: discord.Interaction, project_name: str) -> None:
if not interaction.guild:
return
await interaction.response.defer(ephemeral=True)
try:
+ if self.auth_service:
+ await self.auth_service.require_project_management(interaction.user, interaction.guild.id)
project = await self.project_service.get_by_name(interaction.guild.id, project_name)
if not project:
await interaction.followup.send(f"â Project '{project_name}' not found.", ephemeral=True)
@@ -1435,12 +1530,13 @@ async def project_archive(self, interaction: discord.Interaction, project_name:
@project_group.command(name="unarchive", description="Restore an archived project container.")
@app_commands.describe(project_name="Name of the project to restore")
@app_commands.autocomplete(project_name=project_autocomplete)
- @app_commands.checks.has_permissions(manage_guild=True)
async def project_unarchive(self, interaction: discord.Interaction, project_name: str) -> None:
if not interaction.guild:
return
await interaction.response.defer(ephemeral=True)
try:
+ if self.auth_service:
+ await self.auth_service.require_project_management(interaction.user, interaction.guild.id)
project = await self.project_service.get_by_name(interaction.guild.id, project_name)
if not project:
await interaction.followup.send(f"â Project '{project_name}' not found.", ephemeral=True)
@@ -1493,7 +1589,6 @@ async def project_unarchive(self, interaction: discord.Interaction, project_name
]
)
@app_commands.autocomplete(project_name=project_autocomplete)
- @app_commands.checks.has_permissions(manage_guild=True)
async def project_role(
self,
interaction: discord.Interaction,
@@ -1505,6 +1600,8 @@ async def project_role(
return
await interaction.response.defer(ephemeral=True)
try:
+ if self.auth_service:
+ await self.auth_service.require_project_management(interaction.user, interaction.guild.id)
project = await self.project_service.get_by_name(interaction.guild.id, project_name)
if not project:
await interaction.followup.send(f"â Project '{project_name}' not found.", ephemeral=True)
@@ -1619,7 +1716,6 @@ async def project_lead(
]
)
@app_commands.autocomplete(project_name=project_autocomplete, squad_name=squad_autocomplete)
- @app_commands.checks.has_permissions(manage_guild=True)
async def project_squad(
self,
interaction: discord.Interaction,
@@ -1631,6 +1727,8 @@ async def project_squad(
return
await interaction.response.defer(ephemeral=True)
try:
+ if self.auth_service:
+ await self.auth_service.require_project_management(interaction.user, interaction.guild.id)
project = await self.project_service.get_by_name(interaction.guild.id, project_name)
if not project:
await interaction.followup.send(f"â Project '{project_name}' not found.", ephemeral=True)
@@ -1662,7 +1760,6 @@ async def project_squad(
description="Automatically configure standard project management tags on a Forum Channel.",
)
@app_commands.describe(forum="The Discord Forum Channel to configure with PM tags")
- @app_commands.checks.has_permissions(manage_guild=True)
async def project_setup_forum(
self,
interaction: discord.Interaction,
@@ -1674,6 +1771,8 @@ async def project_setup_forum(
await interaction.response.defer(ephemeral=True)
try:
+ if self.auth_service:
+ await self.auth_service.require_project_management(interaction.user, interaction.guild.id)
tags_added, total_tags, err = await setup_forum_tags(forum)
if err:
await interaction.followup.send(f"â Failed to configure tags in <#{forum.id}>: {err}", ephemeral=True)
@@ -1710,7 +1809,6 @@ async def project_setup_forum(
forum="Optional target Forum Channel to rebind to (auto-creates if omitted and missing)",
)
@app_commands.autocomplete(project_name=project_autocomplete)
- @app_commands.checks.has_permissions(manage_guild=True)
async def project_rebuild(
self,
interaction: discord.Interaction,
@@ -1721,55 +1819,60 @@ async def project_rebuild(
await interaction.response.send_message("â Must be used inside a Discord server.", ephemeral=True)
return
- project = await self.project_service.get_by_name(interaction.guild.id, project_name)
- if not project:
- # Check if archived
- projects = await self.project_service.list_projects(interaction.guild.id, include_archived=True)
- project = next((p for p in projects if p.name.lower() == project_name.lower()), None)
+ try:
+ if self.auth_service:
+ await self.auth_service.require_project_management(interaction.user, interaction.guild.id)
+ project = await self.project_service.get_by_name(interaction.guild.id, project_name)
if not project:
- await interaction.response.send_message(f"â Project '{project_name}' not found.", ephemeral=True)
- return
+ # Check if archived
+ projects = await self.project_service.list_projects(interaction.guild.id, include_archived=True)
+ project = next((p for p in projects if p.name.lower() == project_name.lower()), None)
+ if not project:
+ await interaction.response.send_message(f"â Project '{project_name}' not found.", ephemeral=True)
+ return
- # Fetch task count for preview
- task_count = 0
- if self.task_service:
- _, task_count = await self.task_service.list_tasks(
- guild_id=interaction.guild.id, project_id=project.id, include_archived=True
- )
+ # Fetch task count for preview
+ task_count = 0
+ if self.task_service:
+ _, task_count = await self.task_service.list_tasks(
+ guild_id=interaction.guild.id, project_id=project.id, include_archived=True
+ )
- channel_status = (
- f"Rebind to <#{forum.id}>"
- if forum
- else (
- f"Existing: <#{project.discord_channel_id}>"
- if project.discord_channel_id and interaction.guild.get_channel(project.discord_channel_id)
- else "Auto-create new ForumChannel"
+ channel_status = (
+ f"Rebind to <#{forum.id}>"
+ if forum
+ else (
+ f"Existing: <#{project.discord_channel_id}>"
+ if project.discord_channel_id and interaction.guild.get_channel(project.discord_channel_id)
+ else "Auto-create new ForumChannel"
+ )
)
- )
- embed = discord.Embed(
- title=f"â ī¸ Confirm Workspace Rebuild: [{project.prefix}] {project.name}",
- description=(
- f"Are you sure you want to rebuild the Discord workspace for **{project.name}**?\n\n"
- f"**Planned Actions:**\n"
- f"âĸ **Channel Target:** {channel_status}\n"
- f"âĸ **Forum Tags:** Configure standard status, priority, and project tags\n"
- f"âĸ **Control Hub:** Mount and pin interactive Control Hub post\n"
- f"âĸ **Task Threads:** Reconcile **{task_count}** tasks "
- "(recreate missing threads and enforce Archive Invariant)\n"
- f"âĸ **Project Status:** {'Unarchive and restore' if project.is_archived else 'Active'}\n\n"
- "â ī¸ *This will pace Discord API calls to respect rate limits.*"
- ),
- color=discord.Color.orange(),
- )
- view = RebuildConfirmView(
- project_workspace=self.project_workspace,
- project=project,
- target_channel=forum,
- interaction=interaction,
- task_count=task_count,
- )
- await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
+ embed = discord.Embed(
+ title=f"â ī¸ Confirm Workspace Rebuild: [{project.prefix}] {project.name}",
+ description=(
+ f"Are you sure you want to rebuild the Discord workspace for **{project.name}**?\n\n"
+ f"**Planned Actions:**\n"
+ f"âĸ **Channel Target:** {channel_status}\n"
+ f"âĸ **Forum Tags:** Configure standard status, priority, and project tags\n"
+ f"âĸ **Control Hub:** Mount and pin interactive Control Hub post\n"
+ f"âĸ **Task Threads:** Reconcile **{task_count}** tasks "
+ "(recreate missing threads and enforce Archive Invariant)\n"
+ f"âĸ **Project Status:** {'Unarchive and restore' if project.is_archived else 'Active'}\n\n"
+ "â ī¸ *This will pace Discord API calls to respect rate limits.*"
+ ),
+ color=discord.Color.orange(),
+ )
+ view = RebuildConfirmView(
+ project_workspace=self.project_workspace,
+ project=project,
+ target_channel=forum,
+ interaction=interaction,
+ task_count=task_count,
+ )
+ await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
+ except Exception as e:
+ await send_interaction_error(interaction, e, "initiating project rebuild", logger, ephemeral=True)
# ==========================================
# Squad Subgroup: /pm squad
@@ -1779,7 +1882,6 @@ async def project_rebuild(
role="Discord server role representing the squad",
squad_name="Optional custom squad name",
)
- @app_commands.checks.has_permissions(manage_guild=True)
async def squad_create(
self,
interaction: discord.Interaction,
@@ -1790,6 +1892,8 @@ async def squad_create(
return
await interaction.response.defer(ephemeral=True)
try:
+ if self.auth_service:
+ await self.auth_service.require_project_management(interaction.user, interaction.guild.id)
name = squad_name.strip() if squad_name else role.name
squad = await self.squad_service.create_squad(
guild_id=interaction.guild.id,
@@ -1980,3 +2084,16 @@ async def pm_tree(
await send_interaction_error(
interaction, e, f"rendering tech tree for '{project_name}'", logger, ephemeral=True
)
+
+ async def cog_app_command_error(
+ self, interaction: discord.Interaction, error: app_commands.AppCommandError
+ ) -> None:
+ """Handle errors raised during app command check evaluation or execution within PmCog."""
+ cmd_name = interaction.command.qualified_name if interaction.command else "command"
+ await send_interaction_error(
+ interaction,
+ error,
+ f"executing '/{cmd_name}'",
+ logger,
+ ephemeral=True,
+ )
diff --git a/src/adapters/discord_bot/error_handler.py b/src/adapters/discord_bot/error_handler.py
index 8b84368..ecc5830 100644
--- a/src/adapters/discord_bot/error_handler.py
+++ b/src/adapters/discord_bot/error_handler.py
@@ -21,6 +21,9 @@ def translate_error(error: Exception, action_description: str) -> tuple[str, boo
Returns:
tuple[str, bool]: (message_text, is_unexpected_error)
"""
+ if isinstance(error, discord.app_commands.CommandInvokeError) and error.original:
+ error = error.original
+
if isinstance(error, StaleVersionError):
return (
"â ī¸ This task was already modified by another user. Please refresh the card and try again.",
@@ -28,6 +31,16 @@ def translate_error(error: Exception, action_description: str) -> tuple[str, boo
)
if isinstance(error, (DggPmError, ValueError)):
return (f"â {error}", False)
+ if isinstance(error, discord.app_commands.MissingPermissions):
+ perms = ", ".join(p.replace("_", " ").title().replace("Guild", "Server") for p in error.missing_permissions)
+ return (f"â You are missing required Discord permission(s) to run this command: **{perms}**.", False)
+ if isinstance(error, discord.app_commands.BotMissingPermissions):
+ perms = ", ".join(p.replace("_", " ").title().replace("Guild", "Server") for p in error.missing_permissions)
+ return (f"â The bot is missing required Discord permission(s) to run this command: **{perms}**.", False)
+ if isinstance(error, discord.app_commands.CommandOnCooldown):
+ return (f"âŗ This command is on cooldown. Please try again in {error.retry_after:.1f}s.", False)
+ if isinstance(error, discord.app_commands.CheckFailure):
+ return ("â You do not have permission or meet the requirements to run this command.", False)
if isinstance(error, discord.Forbidden):
return (
"â The bot lacks required Discord permissions in this channel or server "
@@ -68,6 +81,8 @@ async def send_interaction_error(
log.exception("Unexpected error while %s: %s", action_description, error)
elif isinstance(error, (discord.Forbidden, discord.HTTPException)):
log.warning("Discord API error while %s: %s", action_description, error)
+ elif isinstance(error, discord.app_commands.CheckFailure):
+ log.warning("App command check failure while %s: %s", action_description, error)
else:
log.debug("Known business error while %s: %s", action_description, error)
diff --git a/src/adapters/discord_bot/views/admin_menu.py b/src/adapters/discord_bot/views/admin_menu.py
index ee43c84..0510d30 100644
--- a/src/adapters/discord_bot/views/admin_menu.py
+++ b/src/adapters/discord_bot/views/admin_menu.py
@@ -38,6 +38,7 @@ def build_pm_dashboard_embed(
active_tasks_count: int = 0,
current_pref: NotificationPreference = NotificationPreference.DM,
is_server_manager: bool = False,
+ is_server_admin: bool | None = None,
) -> discord.Embed:
"""Builds the main Project Management & Administration Workspace embed for /pm menu."""
guild_name = guild.name if guild else "Server"
@@ -51,7 +52,13 @@ def build_pm_dashboard_embed(
}
pref_str = pref_labels.get(current_pref, current_pref.value)
- role_badge = "Server Administrator / Manager" if is_server_manager else "Project Contributor"
+ effective_admin = is_server_admin if is_server_admin is not None else is_server_manager
+ if effective_admin:
+ role_badge = "Server Administrator / Manager"
+ elif is_server_manager:
+ role_badge = "Team Lead / Project Manager"
+ else:
+ role_badge = "Project Contributor"
embed = discord.Embed(
title="Project Management Control Center",
@@ -80,14 +87,21 @@ def build_pm_dashboard_embed(
)
if is_server_manager:
+ actions = [
+ "âĸ **`Create Project`**: Launch the multi-step project creation wizard.",
+ "âĸ **`Projects`**: Manage channels, assign squad roles, set leads, archive.",
+ ]
+ if effective_admin:
+ actions.append("âĸ **`Lead Roles`**: Assign or view authorized Team Lead Discord roles.")
+ actions.extend(
+ [
+ "âĸ **`Server Overview`**: Server-wide project status & completion metrics.",
+ "âĸ **`Settings`**: Configure personal notification preferences.",
+ ]
+ )
embed.add_field(
name="Management Actions",
- value=(
- "âĸ **`New Project`**: Launch the multi-step project creation wizard.\n"
- "âĸ **`Projects`**: Manage channels, assign squad roles, set leads, archive.\n"
- "âĸ **`Server Overview`**: Server-wide project status & completion metrics.\n"
- "âĸ **`Settings`**: Configure personal notification preferences."
- ),
+ value="\n".join(actions),
inline=False,
)
else:
@@ -160,6 +174,205 @@ async def _on_back_clicked(self, interaction: discord.Interaction) -> None:
await interaction.response.edit_message(content=None, embed=embed, view=view)
+class LeadRolesAdminView(BaseView):
+ """View allowing Server Managers to assign, view, and remove Team Lead roles for the guild."""
+
+ def __init__(
+ self,
+ project_service: ProjectService,
+ squad_service: SquadService | None = None,
+ task_service: TaskService | None = None,
+ user_service: UserService | None = None,
+ auth_service: AuthService | None = None,
+ initial_interaction: discord.Interaction | None = None,
+ ):
+ super().__init__(timeout=180)
+ self.project_service = project_service
+ self.squad_service = squad_service
+ self.task_service = task_service
+ self.user_service = user_service
+ self.auth_service = auth_service
+ self._initial_interaction = initial_interaction
+ self.selected_role_id: int | None = None
+ self._rebuild_items()
+
+ def _rebuild_items(self) -> None:
+ self.clear_items()
+
+ # Row 0: Select Discord Role
+ self.role_select = discord.ui.RoleSelect(
+ placeholder="Select a Discord role to assign or remove...",
+ min_values=1,
+ max_values=1,
+ row=0,
+ )
+ self.role_select.callback = self._on_role_selected
+ self.add_item(self.role_select)
+
+ # Row 1: Action buttons
+ self.assign_btn = discord.ui.Button(
+ label="Assign Role",
+ style=discord.ButtonStyle.success,
+ row=1,
+ )
+ self.assign_btn.callback = self._on_assign_clicked
+ self.add_item(self.assign_btn)
+
+ self.remove_btn = discord.ui.Button(
+ label="Remove Role",
+ style=discord.ButtonStyle.danger,
+ row=1,
+ )
+ self.remove_btn.callback = self._on_remove_clicked
+ self.add_item(self.remove_btn)
+
+ self.back_btn = discord.ui.Button(
+ label="Back to Dashboard",
+ style=discord.ButtonStyle.secondary,
+ row=1,
+ )
+ self.back_btn.callback = self._on_back_clicked
+ self.add_item(self.back_btn)
+
+ async def _on_role_selected(self, interaction: discord.Interaction) -> None:
+ selected_values = getattr(self.role_select, "values", []) or getattr(self.role_select, "_values", [])
+ if selected_values:
+ val = selected_values[0]
+ self.selected_role_id = int(val.id) if hasattr(val, "id") else int(val)
+ embed = await self.build_embed(interaction.guild)
+ await interaction.response.edit_message(embed=embed, view=self)
+
+ async def build_embed(self, guild: discord.Guild | None) -> discord.Embed:
+ guild_id = guild.id if guild else None
+ role_ids: set[int] = set()
+ if self.auth_service and guild_id:
+ role_ids = await self.auth_service.list_guild_lead_roles(guild_id)
+
+ embed = discord.Embed(
+ title="đĨ Authorized Team Lead Roles",
+ description=(
+ "Members holding any of the configured roles can create and manage "
+ "projects and squads without requiring server-wide `Manage Server` permissions.\n\n"
+ ),
+ color=discord.Color.blue(),
+ )
+
+ if role_ids:
+ role_lines = []
+ for rid in sorted(role_ids):
+ role = guild.get_role(rid) if guild else None
+ if role:
+ role_lines.append(f"âĸ **@{role.name}** (`{rid}`)")
+ else:
+ role_lines.append(f"âĸ <@&{rid}> (`{rid}`)")
+ embed.add_field(
+ name=f"Configured Roles ({len(role_ids)})",
+ value="\n".join(role_lines),
+ inline=False,
+ )
+ else:
+ embed.add_field(
+ name="Configured Roles (0)",
+ value="*No team lead roles configured yet. Use the picker below to assign one.*",
+ inline=False,
+ )
+
+ if self.selected_role_id:
+ role = guild.get_role(self.selected_role_id) if guild else None
+ role_name = f"@{role.name}" if role else f"<@&{self.selected_role_id}>"
+ status = "Already Configured" if self.selected_role_id in role_ids else "Not Configured"
+ embed.add_field(
+ name="Selected Role",
+ value=f"**{role_name}** (`{self.selected_role_id}`) â *{status}*",
+ inline=False,
+ )
+
+ embed.set_footer(text="dgg-pm âĸ Team Lead Role Administration")
+ return embed
+
+ async def _on_assign_clicked(self, interaction: discord.Interaction) -> None:
+ if not interaction.guild:
+ await interaction.response.send_message("â Must be used inside a Discord server.", ephemeral=True)
+ return
+
+ if not AuthService.is_server_manager(interaction.user):
+ await interaction.response.send_message(
+ "â Only Discord Server Managers can configure team lead roles.", ephemeral=True
+ )
+ return
+
+ selected_values = getattr(self.role_select, "values", []) or getattr(self.role_select, "_values", [])
+ if selected_values and not self.selected_role_id:
+ val = selected_values[0]
+ self.selected_role_id = int(val.id) if hasattr(val, "id") else int(val)
+
+ if not self.selected_role_id:
+ await interaction.response.send_message("â ī¸ Please select a role from the dropdown first.", ephemeral=True)
+ return
+
+ if self.auth_service:
+ await self.auth_service.add_guild_lead_role(interaction.guild.id, self.selected_role_id)
+
+ embed = await self.build_embed(interaction.guild)
+ await interaction.response.edit_message(embed=embed, view=self)
+
+ async def _on_remove_clicked(self, interaction: discord.Interaction) -> None:
+ if not interaction.guild:
+ await interaction.response.send_message("â Must be used inside a Discord server.", ephemeral=True)
+ return
+
+ if not AuthService.is_server_manager(interaction.user):
+ await interaction.response.send_message(
+ "â Only Discord Server Managers can configure team lead roles.", ephemeral=True
+ )
+ return
+
+ selected_values = getattr(self.role_select, "values", []) or getattr(self.role_select, "_values", [])
+ if selected_values and not self.selected_role_id:
+ val = selected_values[0]
+ self.selected_role_id = int(val.id) if hasattr(val, "id") else int(val)
+
+ if not self.selected_role_id:
+ await interaction.response.send_message("â ī¸ Please select a role from the dropdown first.", ephemeral=True)
+ return
+
+ if self.auth_service:
+ await self.auth_service.remove_guild_lead_role(interaction.guild.id, self.selected_role_id)
+
+ embed = await self.build_embed(interaction.guild)
+ await interaction.response.edit_message(embed=embed, view=self)
+
+ async def _on_back_clicked(self, interaction: discord.Interaction) -> None:
+ if not interaction.guild:
+ return
+ projects = await self.project_service.list_projects(interaction.guild.id, include_archived=False)
+ _, count = await self.task_service.list_tasks(interaction.guild.id, limit=1) if self.task_service else ([], 0)
+ current_pref = (
+ await self.user_service.get_preference(interaction.guild.id, interaction.user.id)
+ if self.user_service
+ else NotificationPreference.DM
+ )
+
+ view = PmDashboardView(
+ project_service=self.project_service,
+ squad_service=self.squad_service,
+ task_service=self.task_service,
+ user_service=self.user_service,
+ auth_service=self.auth_service,
+ initial_interaction=interaction,
+ user=interaction.user,
+ )
+ embed = build_pm_dashboard_embed(
+ guild=interaction.guild,
+ user=interaction.user,
+ active_projects=projects,
+ active_tasks_count=count,
+ current_pref=current_pref,
+ is_server_manager=view.is_server_manager,
+ )
+ await interaction.response.edit_message(content=None, embed=embed, view=view)
+
+
class PmDashboardView(BaseView):
"""Interactive administration and project management dashboard view for /pm menu."""
@@ -172,12 +385,15 @@ def __init__(
initial_interaction: discord.Interaction | None = None,
user: discord.Member | discord.User | None = None,
is_server_manager: bool | None = None,
+ is_server_admin: bool | None = None,
+ auth_service: AuthService | None = None,
):
super().__init__(timeout=180)
self.project_service = project_service
self.squad_service = squad_service
self.task_service = task_service
self.user_service = user_service
+ self.auth_service = auth_service
self._initial_interaction = initial_interaction
effective_user = user or (initial_interaction.user if initial_interaction else None)
@@ -188,6 +404,13 @@ def __init__(
else:
self.is_server_manager = True
+ if is_server_admin is not None:
+ self.is_server_admin = is_server_admin
+ elif effective_user is not None:
+ self.is_server_admin = AuthService.is_server_manager(effective_user)
+ else:
+ self.is_server_admin = self.is_server_manager
+
self._rebuild_items()
def _rebuild_items(self) -> None:
@@ -196,12 +419,25 @@ def _rebuild_items(self) -> None:
# Row 0: Core Management Buttons
if self.is_server_manager:
self.new_proj_btn = discord.ui.Button(
- label="New Project",
+ label="Create Project",
style=discord.ButtonStyle.success,
row=0,
)
self.new_proj_btn.callback = self._on_new_project_clicked
self.add_item(self.new_proj_btn)
+ else:
+ self.new_proj_btn = None
+
+ if self.is_server_admin:
+ self.lead_roles_btn = discord.ui.Button(
+ label="Lead Roles",
+ style=discord.ButtonStyle.secondary,
+ row=0,
+ )
+ self.lead_roles_btn.callback = self._on_lead_roles_clicked
+ self.add_item(self.lead_roles_btn)
+ else:
+ self.lead_roles_btn = None
self.projects_btn = discord.ui.Button(
label="Projects",
@@ -274,6 +510,22 @@ async def _on_new_project_clicked(self, interaction: discord.Interaction) -> Non
)
await interaction.response.edit_message(content=None, embed=embed, view=view)
+ async def _on_lead_roles_clicked(self, interaction: discord.Interaction) -> None:
+ if not interaction.guild:
+ await interaction.response.send_message("â Must be run in a Discord server.", ephemeral=True)
+ return
+
+ view = LeadRolesAdminView(
+ project_service=self.project_service,
+ squad_service=self.squad_service,
+ task_service=self.task_service,
+ user_service=self.user_service,
+ auth_service=self.auth_service,
+ initial_interaction=interaction,
+ )
+ embed = await view.build_embed(interaction.guild)
+ await interaction.response.edit_message(content=None, embed=embed, view=view)
+
async def _on_projects_clicked(self, interaction: discord.Interaction) -> None:
view = ProjectMenuView(
self.project_service,
@@ -282,6 +534,8 @@ async def _on_projects_clicked(self, interaction: discord.Interaction) -> None:
initial_interaction=interaction,
user_service=self.user_service,
return_to="dashboard",
+ is_server_manager=self.is_server_manager,
+ auth_service=self.auth_service,
)
embed = build_project_menu_embed(view.is_server_manager)
await interaction.response.edit_message(content=None, embed=embed, view=view)
diff --git a/src/adapters/discord_bot/views/forum_helpers.py b/src/adapters/discord_bot/views/forum_helpers.py
index 01dc400..80050f4 100644
--- a/src/adapters/discord_bot/views/forum_helpers.py
+++ b/src/adapters/discord_bot/views/forum_helpers.py
@@ -10,6 +10,7 @@
if TYPE_CHECKING:
from src.domain.models import Task
+ from src.services.auth_service import AuthService
from src.services.project_service import ProjectService
from src.services.squad_service import SquadService
from src.services.task_service import TaskService
@@ -352,6 +353,7 @@ async def ensure_pinned_hub_post(
task_service: TaskService | None = None,
user_service: UserService | None = None,
project_name: str | None = None,
+ auth_service: AuthService | None = None,
) -> tuple[bool, str]:
"""Ensures a pinned Project Management Hub post exists in the given Forum or Text channel."""
from src.adapters.discord_bot.views.hub_menu import PmHubView, build_hub_welcome_embed
@@ -411,12 +413,25 @@ async def ensure_pinned_hub_post(
except Exception as e:
logger.debug("Could not auto-create user_service for hub: %s", e)
+ uow = getattr(task_service, "uow", None)
+ sf = getattr(uow, "session_factory", None) or getattr(uow, "_session_factory", None)
+ if not auth_service and squad_service and sf:
+ try:
+ from src.adapters.db.postgres_repo import PostgresGuildLeadRoleRepository
+ from src.services.auth_service import AuthService
+
+ lead_repo = PostgresGuildLeadRoleRepository(sf)
+ auth_service = AuthService(project_service, squad_service, guild_lead_role_repo=lead_repo)
+ except Exception as e:
+ logger.debug("Could not auto-create auth_service for hub: %s", e)
+
if squad_service:
view = PmHubView(
project_service=project_service,
squad_service=squad_service,
task_service=task_service,
user_service=user_service,
+ auth_service=auth_service,
)
if isinstance(channel, discord.ForumChannel):
diff --git a/src/adapters/discord_bot/views/hub_menu.py b/src/adapters/discord_bot/views/hub_menu.py
index 0031c3a..89f3b6c 100644
--- a/src/adapters/discord_bot/views/hub_menu.py
+++ b/src/adapters/discord_bot/views/hub_menu.py
@@ -307,7 +307,20 @@ def __init__(
self.squad_service = squad_service
self.task_service = task_service
self.user_service = user_service
- self.auth_service = auth_service or AuthService(project_service, squad_service)
+ if auth_service is not None:
+ self.auth_service = auth_service
+ else:
+ lead_repo = None
+ uow = getattr(task_service, "uow", None)
+ sf = getattr(uow, "session_factory", None) or getattr(uow, "_session_factory", None)
+ if sf:
+ try:
+ from src.adapters.db.postgres_repo import PostgresGuildLeadRoleRepository
+
+ lead_repo = PostgresGuildLeadRoleRepository(sf)
+ except Exception as e:
+ logger.debug("Could not auto-create lead_role_repo for hub: %s", e)
+ self.auth_service = AuthService(project_service, squad_service, guild_lead_role_repo=lead_repo)
async def _refresh_hub_message(
self,
@@ -587,15 +600,79 @@ async def projects_tab(self, interaction: discord.Interaction, button: discord.u
await self._refresh_hub_message(interaction)
await menu_manager.register_menu(interaction)
+ can_manage = (
+ await self.auth_service.can_manage_projects(
+ interaction.user,
+ guild_id=interaction.guild.id,
+ guild=interaction.guild,
+ )
+ if (self.auth_service and interaction.guild)
+ else False
+ )
view = ProjectMenuView(
self.project_service,
self.squad_service,
self.task_service,
initial_interaction=interaction,
+ is_server_manager=can_manage,
+ auth_service=self.auth_service,
)
embed = build_project_menu_embed(view.is_server_manager)
await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
+ @discord.ui.button(
+ label="Create Project",
+ style=discord.ButtonStyle.secondary,
+ row=1,
+ custom_id="pm_hub:create_project",
+ )
+ async def create_project_btn(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
+ if not interaction.guild:
+ await interaction.response.send_message("â Must be run in a Discord server.", ephemeral=True)
+ return
+
+ can_manage = (
+ await self.auth_service.can_manage_projects(
+ interaction.user,
+ guild_id=interaction.guild.id,
+ guild=interaction.guild,
+ )
+ if self.auth_service
+ else False
+ )
+ if not can_manage:
+ await interaction.response.send_message(
+ "â You do not have permission to create projects. "
+ "Contact a Server Manager or hold an authorized Team Lead role.",
+ ephemeral=True,
+ )
+ return
+
+ from src.adapters.discord_bot.menu_manager import menu_manager
+
+ await menu_manager.register_menu(interaction)
+
+ from src.adapters.discord_bot.views.project_menu import ProjectChannelSelectView
+
+ view = ProjectChannelSelectView(
+ self.project_service,
+ self.squad_service,
+ self.task_service,
+ initial_interaction=interaction,
+ user_service=self.user_service,
+ return_to="hub",
+ )
+ embed = discord.Embed(
+ title="Create Project: Select Forum Channel",
+ description=(
+ "Choose a **Forum Channel** to bind as the project's task board.\n\n"
+ "Tasks become organized forum post cards with native Discord tag filtering "
+ "and an interactive pinned Control Hub."
+ ),
+ color=discord.Color.blue(),
+ )
+ await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
+
@discord.ui.button(
label="Visual Graph",
style=discord.ButtonStyle.primary,
diff --git a/src/adapters/discord_bot/views/project_menu.py b/src/adapters/discord_bot/views/project_menu.py
index 032151c..9a8ca7d 100644
--- a/src/adapters/discord_bot/views/project_menu.py
+++ b/src/adapters/discord_bot/views/project_menu.py
@@ -24,6 +24,7 @@
from src.services.task_service import TaskService
if TYPE_CHECKING:
+ from src.services.auth_service import AuthService
from src.services.user_service import UserService
logger = logging.getLogger("dgg_pm.views.project_menu")
@@ -2509,6 +2510,39 @@ async def _on_back_clicked(self, interaction: discord.Interaction) -> None:
class ProjectMenuView(BaseView):
"""Control Center View for Project Operations."""
+ @classmethod
+ async def create(
+ cls,
+ project_service: ProjectService,
+ squad_service: SquadService | None = None,
+ task_service: TaskService | None = None,
+ initial_interaction: discord.Interaction | None = None,
+ user: discord.Member | discord.User | None = None,
+ is_server_manager: bool | None = None,
+ user_service: UserService | None = None,
+ return_to: str = "dashboard",
+ auth_service: AuthService | None = None,
+ ) -> ProjectMenuView:
+ effective_user = user or (initial_interaction.user if initial_interaction else None)
+ guild_id = None
+ if initial_interaction and initial_interaction.guild:
+ guild_id = initial_interaction.guild.id
+ elif hasattr(effective_user, "guild") and getattr(effective_user, "guild", None):
+ guild_id = getattr(effective_user.guild, "id", None)
+ if is_server_manager is None and auth_service and effective_user and guild_id:
+ is_server_manager = await auth_service.can_manage_projects(effective_user, guild_id)
+ return cls(
+ project_service=project_service,
+ squad_service=squad_service,
+ task_service=task_service,
+ initial_interaction=initial_interaction,
+ user=user,
+ is_server_manager=is_server_manager,
+ user_service=user_service,
+ return_to=return_to,
+ auth_service=auth_service,
+ )
+
def __init__(
self,
project_service: ProjectService,
@@ -2519,6 +2553,7 @@ def __init__(
is_server_manager: bool | None = None,
user_service: UserService | None = None,
return_to: str = "dashboard",
+ auth_service: AuthService | None = None,
):
super().__init__(timeout=180)
self.project_service = project_service
@@ -2526,6 +2561,7 @@ def __init__(
self.task_service = task_service
self.user_service = user_service
self.return_to = return_to
+ self.auth_service = auth_service
self._initial_interaction = initial_interaction
effective_user = user or (initial_interaction.user if initial_interaction else None)
@@ -2545,7 +2581,7 @@ def _rebuild_items(self) -> None:
if self.is_server_manager:
self.new_project_btn = discord.ui.Button(
- label="New Project",
+ label="Create Project",
style=discord.ButtonStyle.primary,
row=0,
)
@@ -2735,7 +2771,13 @@ async def _on_hub_clicked(self, interaction: discord.Interaction) -> None:
from src.adapters.discord_bot.views.hub_menu import PmHubView, build_hub_welcome_embed
if self.task_service:
- view = PmHubView(self.project_service, self.squad_service, self.task_service, self.user_service)
+ view = PmHubView(
+ self.project_service,
+ self.squad_service,
+ self.task_service,
+ self.user_service,
+ auth_service=self.auth_service,
+ )
embed = build_hub_welcome_embed()
await interaction.response.edit_message(content=None, embed=embed, view=view)
@@ -2933,7 +2975,7 @@ def build_project_menu_embed(is_server_manager: bool = True) -> discord.Embed:
embed.description = (
"> **Project Administration & Squad Routing**\n"
"> Manage project containers, channel bindings, squad roles, and project leads.\n\n"
- "âĸ **`New Project`**: Create a project container bound to a Forum channel\n"
+ "âĸ **`Create Project`**: Create a project container bound to a Forum channel\n"
"âĸ **`Active Projects`**: View all running projects, squad roles, and designated leads\n"
"âĸ **`Set Squad Role`**: Map a Discord role as the project's contributor squad\n"
"âĸ **`Set Project Lead`**: Designate the project owner / lead with elevated permissions\n"
diff --git a/src/domain/models.py b/src/domain/models.py
index f1a5ac6..ca86672 100644
--- a/src/domain/models.py
+++ b/src/domain/models.py
@@ -152,3 +152,9 @@ class UserPreference(DomainModel):
notify_preference: NotificationPreference = NotificationPreference.DM
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
+
+
+class GuildLeadRole(DomainModel):
+ guild_id: int
+ discord_role_id: int
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
diff --git a/src/main.py b/src/main.py
index 46f3234..134902a 100644
--- a/src/main.py
+++ b/src/main.py
@@ -14,6 +14,7 @@
from src.adapters.api.app import api_app # noqa: E402
from src.adapters.db.postgres_repo import ( # noqa: E402
+ PostgresGuildLeadRoleRepository,
PostgresOutboxRepo,
PostgresProjectRepo,
PostgresSquadRepo,
@@ -54,6 +55,7 @@ async def run_app() -> None:
squad_repo = PostgresSquadRepo(async_session_factory)
outbox_repo = PostgresOutboxRepo(async_session_factory)
user_pref_repo = PostgresUserPreferenceRepo(async_session_factory)
+ guild_lead_role_repo = PostgresGuildLeadRoleRepository(async_session_factory)
uow = SqlAlchemyUnitOfWork(async_session_factory)
project_service = ProjectService(project_repo)
@@ -69,6 +71,7 @@ async def run_app() -> None:
squad_service=squad_service,
user_service=user_service,
outbox_service=outbox_service,
+ guild_lead_role_repo=guild_lead_role_repo,
)
notifier = DiscordNotifier(
bot,
diff --git a/src/ports/repositories.py b/src/ports/repositories.py
index b1ff594..95a4faf 100644
--- a/src/ports/repositories.py
+++ b/src/ports/repositories.py
@@ -299,3 +299,17 @@ async def get_preferences_bulk(
user_ids: list[int],
) -> dict[int, NotificationPreference]:
"""Fetches preferences for multiple users in a guild."""
+
+
+class IGuildLeadRoleRepository(ABC):
+ @abstractmethod
+ async def add_lead_role(self, guild_id: int, discord_role_id: int) -> None:
+ """Registers a Discord role ID as an authorized Team Lead role for the guild."""
+
+ @abstractmethod
+ async def remove_lead_role(self, guild_id: int, discord_role_id: int) -> bool:
+ """Removes an authorized Team Lead role from the guild. Returns True if removed, False if not found."""
+
+ @abstractmethod
+ async def list_lead_role_ids(self, guild_id: int) -> set[int]:
+ """Lists all authorized Team Lead Discord role IDs for the guild."""
diff --git a/src/ports/unit_of_work.py b/src/ports/unit_of_work.py
index e062565..ce7ea74 100644
--- a/src/ports/unit_of_work.py
+++ b/src/ports/unit_of_work.py
@@ -5,6 +5,7 @@
if TYPE_CHECKING:
from src.ports.repositories import (
+ IGuildLeadRoleRepository,
IOutboxRepo,
IProjectRepo,
ISquadRepo,
@@ -21,6 +22,7 @@ class IUnitOfWork(ABC):
squads: ISquadRepo
outbox: IOutboxRepo
user_prefs: IUserPreferenceRepo
+ guild_lead_roles: IGuildLeadRoleRepository
@abstractmethod
async def __aenter__(self) -> IUnitOfWork:
diff --git a/src/services/auth_service.py b/src/services/auth_service.py
index 60ac7e2..37a3da5 100644
--- a/src/services/auth_service.py
+++ b/src/services/auth_service.py
@@ -13,6 +13,7 @@
from src.domain.models import Project, Task
if TYPE_CHECKING:
+ from src.ports.repositories import IGuildLeadRoleRepository
from src.services.project_service import ProjectService
from src.services.squad_service import SquadService
@@ -26,9 +27,93 @@ def __init__(
self,
project_service: ProjectService,
squad_service: SquadService | None = None,
+ guild_lead_role_repo: IGuildLeadRoleRepository | None = None,
):
self.project_service = project_service
self.squad_service = squad_service
+ self.guild_lead_role_repo = guild_lead_role_repo
+
+ async def add_guild_lead_role(self, guild_id: int, discord_role_id: int) -> None:
+ """Registers a Discord role as an authorized Team Lead role for the guild."""
+ if self.guild_lead_role_repo:
+ await self.guild_lead_role_repo.add_lead_role(guild_id, discord_role_id)
+
+ async def remove_guild_lead_role(self, guild_id: int, discord_role_id: int) -> bool:
+ """Removes a Discord role from authorized Team Lead roles for the guild."""
+ if self.guild_lead_role_repo:
+ return await self.guild_lead_role_repo.remove_lead_role(guild_id, discord_role_id)
+ return False
+
+ async def list_guild_lead_roles(self, guild_id: int) -> set[int]:
+ """Returns all authorized Team Lead role IDs for the guild."""
+ if self.guild_lead_role_repo:
+ return await self.guild_lead_role_repo.list_lead_role_ids(guild_id)
+ return set()
+
+ async def can_manage_projects(
+ self,
+ user: discord.Member | discord.User,
+ guild_id: int | None = None,
+ guild: discord.Guild | None = None,
+ ) -> bool:
+ """Checks if user has authority to create and manage projects and squads:
+ 1. Discord Server Manager (manage_guild or administrator).
+ 2. Holds any authorized Team Lead role configured for the guild.
+ 3. Active Squad Lead in the guild.
+ """
+ if self.is_server_manager(user):
+ return True
+
+ target_guild_id = guild_id or (guild.id if guild else None) or getattr(getattr(user, "guild", None), "id", None)
+ if not target_guild_id:
+ return False
+
+ # If user is a discord.User or missing roles, attempt to resolve member from guild
+ member = user
+ if not hasattr(member, "roles") or not getattr(member, "roles", None):
+ target_guild = guild or getattr(user, "guild", None)
+ if target_guild:
+ if hasattr(target_guild, "get_member"):
+ m = target_guild.get_member(user.id)
+ if m:
+ member = m
+ if (not hasattr(member, "roles") or not getattr(member, "roles", None)) and hasattr(
+ target_guild, "fetch_member"
+ ):
+ try:
+ member = await target_guild.fetch_member(user.id)
+ except Exception:
+ pass
+
+ # 2. Check configured Team Lead roles
+ if self.guild_lead_role_repo:
+ lead_role_ids = await self.guild_lead_role_repo.list_lead_role_ids(target_guild_id)
+ if lead_role_ids:
+ user_role_ids = {r.id for r in getattr(member, "roles", []) if hasattr(r, "id")}
+ if any(rid in user_role_ids for rid in lead_role_ids):
+ return True
+
+ # 3. Check if active Squad Lead in this guild
+ if self.squad_service and getattr(member, "id", None):
+ squads = await self.squad_service.list_squads(target_guild_id)
+ for s in squads:
+ if await self.squad_service.is_squad_lead(s.id, member.id):
+ return True
+
+ return False
+
+ async def require_project_management(
+ self,
+ user: discord.Member | discord.User,
+ guild_id: int | None = None,
+ guild: discord.Guild | None = None,
+ ) -> None:
+ """Raises PermissionDeniedError if the user cannot create or manage projects and squads."""
+ if not await self.can_manage_projects(user, guild_id=guild_id, guild=guild):
+ raise PermissionDeniedError(
+ "You do not have permission to manage projects or squads. "
+ "You must be a Discord Server Manager or hold an authorized Team Lead role."
+ )
@staticmethod
def is_server_manager(user: discord.Member | discord.User) -> bool:
diff --git a/tests/conftest.py b/tests/conftest.py
index aed74bb..e3d17b1 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -67,17 +67,21 @@ async def db_session(async_engine: AsyncEngine) -> AsyncGenerator[AsyncSession]:
@pytest_asyncio.fixture(scope="function")
async def repos(db_session: AsyncSession):
+ from src.adapters.db.postgres_repo import PostgresGuildLeadRoleRepository
+
task_repo = PostgresTaskRepo(db_session)
project_repo = PostgresProjectRepo(db_session)
squad_repo = PostgresSquadRepo(db_session)
outbox_repo = PostgresOutboxRepo(db_session)
user_repo = PostgresUserPreferenceRepo(db_session)
+ guild_lead_role_repo = PostgresGuildLeadRoleRepository(db_session)
return {
"task": task_repo,
"project": project_repo,
"squad": squad_repo,
"outbox": outbox_repo,
"user": user_repo,
+ "guild_lead_role": guild_lead_role_repo,
}
diff --git a/tests/test_authorization.py b/tests/test_authorization.py
index 66a9537..3926a48 100644
--- a/tests/test_authorization.py
+++ b/tests/test_authorization.py
@@ -686,7 +686,7 @@ async def test_permission_aware_project_menu_ui(services):
assert manager_view.hub_btn is not None
manager_embed = build_project_menu_embed(is_server_manager=True)
- assert "New Project" in manager_embed.description
+ assert "Create Project" in manager_embed.description
assert "Set Squad Role" in manager_embed.description
assert "Archive Project" in manager_embed.description
assert "Rebuild Workspace" in manager_embed.description
@@ -707,6 +707,7 @@ async def test_permission_aware_project_menu_ui(services):
regular_embed = build_project_menu_embed(is_server_manager=False)
assert "Active Projects" in regular_embed.description
+ assert "Create Project" not in regular_embed.description
assert "New Project" not in regular_embed.description
assert "Set Squad Role" not in regular_embed.description
assert "Archive Project" not in regular_embed.description
@@ -1367,3 +1368,253 @@ async def test_auth_service_dependency_bypass_permissions(services):
assert await auth_srv.can_bypass_dependencies(unauthorized_member, task) is False
with pytest.raises(PermissionDeniedError, match="do not have permission"):
await auth_srv.require_dependency_bypass(unauthorized_member, task)
+
+
+@pytest.mark.asyncio
+async def test_auth_service_can_manage_projects_lifecycle(services, repos):
+ """Verify AuthService.can_manage_projects allows Server Managers, Team Lead roles, and Squad Leads."""
+ proj_srv = services["project"]
+ squad_srv = services["squad"]
+ lead_role_repo = repos["guild_lead_role"]
+ auth_srv = AuthService(proj_srv, squad_srv, guild_lead_role_repo=lead_role_repo)
+
+ guild_id = 444333222
+
+ admin_member = _make_mock_member(1000, manage_guild=True)
+ team_lead_member = _make_mock_member(2000, role_ids=[777888])
+ squad_lead_member = _make_mock_member(3000, role_ids=[555666])
+ regular_member = _make_mock_member(4000, role_ids=[999111])
+
+ # 1. Server Manager can manage projects
+ assert await auth_srv.can_manage_projects(admin_member, guild_id) is True
+ await auth_srv.require_project_management(admin_member, guild_id)
+
+ # 2. Team lead without configured lead role cannot yet manage projects
+ assert await auth_srv.can_manage_projects(team_lead_member, guild_id) is False
+ with pytest.raises(PermissionDeniedError, match="do not have permission"):
+ await auth_srv.require_project_management(team_lead_member, guild_id)
+
+ # 3. Add 777888 as authorized Team Lead role for guild
+ await auth_srv.add_guild_lead_role(guild_id, 777888)
+ assert await auth_srv.list_guild_lead_roles(guild_id) == {777888}
+
+ # Now team_lead_member is authorized!
+ assert await auth_srv.can_manage_projects(team_lead_member, guild_id) is True
+ await auth_srv.require_project_management(team_lead_member, guild_id)
+
+ # 4. Regular member still denied
+ assert await auth_srv.can_manage_projects(regular_member, guild_id) is False
+ with pytest.raises(PermissionDeniedError):
+ await auth_srv.require_project_management(regular_member, guild_id)
+
+ # 5. Active Squad Lead in guild is also authorized
+ squad = await squad_srv.create_squad(guild_id=guild_id, name="Core Squad", discord_role_id=555666)
+ await squad_srv.add_squad_lead(squad.id, squad_lead_member.id)
+ assert await auth_srv.can_manage_projects(squad_lead_member, guild_id) is True
+ await auth_srv.require_project_management(squad_lead_member, guild_id)
+
+ # 6. Remove the lead role
+ assert await auth_srv.remove_guild_lead_role(guild_id, 777888) is True
+ assert await auth_srv.list_guild_lead_roles(guild_id) == set()
+ assert await auth_srv.can_manage_projects(team_lead_member, guild_id) is False
+
+
+@pytest.mark.asyncio
+async def test_pm_hub_projects_tab_authorizes_lead_role_to_create_projects(services, repos):
+ """Verify that clicking Projects Hub on PmHubView shows 'New Project' for Team Lead roles."""
+ from src.adapters.discord_bot.views.hub_menu import PmHubView
+
+ proj_srv = services["project"]
+ squad_srv = services["squad"]
+ task_srv = services["task"]
+ lead_role_repo = repos["guild_lead_role"]
+ auth_srv = AuthService(proj_srv, squad_srv, guild_lead_role_repo=lead_role_repo)
+
+ guild_id = 999111
+ lead_role_id = 777888
+ await lead_role_repo.add_lead_role(guild_id, lead_role_id)
+
+ lead_member = _make_mock_member(4001, role_ids=[lead_role_id], manage_guild=False)
+ regular_member = _make_mock_member(4002, role_ids=[], manage_guild=False)
+
+ hub_view = PmHubView(proj_srv, squad_srv, task_srv, auth_service=auth_srv)
+
+ # 1. Lead role member clicks Projects Hub
+ interaction = MagicMock(spec=discord.Interaction)
+ interaction.guild = MagicMock(id=guild_id)
+ interaction.user = lead_member
+ interaction.response = MagicMock()
+ interaction.response.send_message = AsyncMock()
+
+ await hub_view.projects_tab.callback(interaction)
+
+ interaction.response.send_message.assert_awaited_once()
+ kwargs = interaction.response.send_message.call_args.kwargs
+ view = kwargs["view"]
+ embed = kwargs["embed"]
+
+ assert view.is_server_manager is True
+ assert view.new_project_btn is not None
+ assert "Create Project" in embed.description
+
+ # 2. Regular member clicks Projects Hub
+ interaction.response.send_message.reset_mock()
+ interaction.user = regular_member
+
+ await hub_view.projects_tab.callback(interaction)
+
+ interaction.response.send_message.assert_awaited_once()
+ kwargs = interaction.response.send_message.call_args.kwargs
+ reg_view = kwargs["view"]
+ reg_embed = kwargs["embed"]
+
+ assert reg_view.is_server_manager is False
+ assert reg_view.new_project_btn is None
+ assert "Create Project" not in reg_embed.description
+
+
+@pytest.mark.asyncio
+async def test_can_manage_projects_resolves_member_when_user_is_discord_user(services, repos):
+ """Verify that can_manage_projects resolves a discord.User via guild when roles attribute is absent."""
+ proj_srv = services["project"]
+ squad_srv = services["squad"]
+ lead_role_repo = repos["guild_lead_role"]
+ auth_srv = AuthService(proj_srv, squad_srv, guild_lead_role_repo=lead_role_repo)
+
+ guild_id = 888111
+ lead_role_id = 999222
+ await lead_role_repo.add_lead_role(guild_id, lead_role_id)
+
+ # Mock discord.User without 'roles'
+ user = MagicMock(spec=discord.User)
+ user.id = 5001
+ user.guild_permissions = None
+ del user.roles
+
+ # Mock member that guild has
+ mock_member = _make_mock_member(5001, role_ids=[lead_role_id], manage_guild=False)
+ guild = MagicMock(spec=discord.Guild)
+ guild.id = guild_id
+ guild.get_member.return_value = mock_member
+
+ can_manage = await auth_srv.can_manage_projects(user, guild_id=guild_id, guild=guild)
+ assert can_manage is True
+ guild.get_member.assert_called_once_with(5001)
+
+
+@pytest.mark.asyncio
+async def test_pm_hub_view_auto_wires_lead_role_repo_and_has_create_project_button(services, repos):
+ """Verify PmHubView auto-wires guild_lead_role_repo if auth_service is omitted, and has Create Project button."""
+ from src.adapters.discord_bot.views.hub_menu import PmHubView
+ from src.adapters.discord_bot.views.project_menu import ProjectChannelSelectView
+
+ proj_srv = services["project"]
+ squad_srv = services["squad"]
+ task_srv = services["task"]
+ lead_role_repo = repos["guild_lead_role"]
+
+ guild_id = 777111
+ lead_role_id = 888222
+ await lead_role_repo.add_lead_role(guild_id, lead_role_id)
+
+ lead_member = _make_mock_member(6001, role_ids=[lead_role_id], manage_guild=False)
+ reg_member = _make_mock_member(6002, role_ids=[], manage_guild=False)
+
+ # Instantiate PmHubView WITHOUT auth_service
+ hub_view = PmHubView(proj_srv, squad_srv, task_srv)
+ assert hub_view.auth_service.guild_lead_role_repo is not None
+
+ # Verify Create Project button exists on row 1
+ assert hasattr(hub_view, "create_project_btn")
+ assert hub_view.create_project_btn.label == "Create Project"
+
+ # 1. Lead member clicks Create Project button directly on PmHubView
+ inter = MagicMock(spec=discord.Interaction)
+ inter.guild = MagicMock(id=guild_id)
+ inter.guild.get_member.return_value = lead_member
+ inter.user = lead_member
+ inter.response = MagicMock()
+ inter.response.send_message = AsyncMock()
+
+ await hub_view.create_project_btn.callback(inter)
+ inter.response.send_message.assert_awaited_once()
+ kwargs = inter.response.send_message.call_args.kwargs
+ assert isinstance(kwargs["view"], ProjectChannelSelectView)
+
+ # 2. Regular member clicks Create Project button -> permission error
+ inter.response.send_message.reset_mock()
+ inter.user = reg_member
+ inter.guild.get_member.return_value = reg_member
+
+ await hub_view.create_project_btn.callback(inter)
+ inter.response.send_message.assert_awaited_once()
+ args, kwargs = inter.response.send_message.call_args
+ error_msg = kwargs.get("content", args[0] if args else "")
+ assert "You do not have permission" in error_msg
+
+
+@pytest.mark.asyncio
+async def test_pm_cog_menu_authorizes_lead_role_for_project_creation(services, repos):
+ """Verify /pm menu checks can_manage_projects so Team Leads see Create Project button."""
+ from src.adapters.discord_bot.cogs.pm_cog import PmCog
+ from src.adapters.discord_bot.views.admin_menu import PmDashboardView
+ from src.adapters.discord_bot.views.project_menu import ProjectMenuView
+
+ proj_srv = services["project"]
+ squad_srv = services["squad"]
+ task_srv = services["task"]
+ user_srv = services["user"]
+ lead_role_repo = repos["guild_lead_role"]
+ auth_srv = AuthService(proj_srv, squad_srv, guild_lead_role_repo=lead_role_repo)
+
+ guild_id = 999333
+ lead_role_id = 555666
+ await lead_role_repo.add_lead_role(guild_id, lead_role_id)
+
+ lead_member = _make_mock_member(7001, role_ids=[lead_role_id], manage_guild=False)
+
+ bot = MagicMock()
+ cog = PmCog(
+ bot=bot,
+ project_service=proj_srv,
+ squad_service=squad_srv,
+ task_service=task_srv,
+ user_service=user_srv,
+ auth_service=auth_srv,
+ )
+
+ inter = MagicMock(spec=discord.Interaction)
+ inter.guild = MagicMock(id=guild_id)
+ inter.guild.name = "Test Guild"
+ inter.user = lead_member
+ inter.response = MagicMock()
+ inter.response.send_message = AsyncMock()
+
+ await cog.menu.callback(cog, inter)
+
+ inter.response.send_message.assert_awaited_once()
+ dash_view = inter.response.send_message.call_args.kwargs["view"]
+ dash_embed = inter.response.send_message.call_args.kwargs["embed"]
+ assert "Create Project" in dash_embed.fields[3].value
+
+ assert isinstance(dash_view, PmDashboardView)
+ assert dash_view.is_server_manager is True
+ assert dash_view.new_proj_btn is not None
+ assert dash_view.new_proj_btn.label == "Create Project"
+ # Lead role should NOT see Lead Roles admin button (only Server Managers can configure roles)
+ assert getattr(dash_view, "lead_roles_btn", None) is None
+
+ # Clicking Projects button from dashboard opens ProjectMenuView with Create Project button
+ proj_inter = MagicMock(spec=discord.Interaction)
+ proj_inter.guild = inter.guild
+ proj_inter.user = lead_member
+ proj_inter.response = MagicMock()
+ proj_inter.response.edit_message = AsyncMock()
+
+ await dash_view._on_projects_clicked(proj_inter)
+ proj_inter.response.edit_message.assert_awaited_once()
+ proj_view = proj_inter.response.edit_message.call_args.kwargs["view"]
+ assert isinstance(proj_view, ProjectMenuView)
+ assert proj_view.is_server_manager is True
+ assert proj_view.new_project_btn is not None
+ assert proj_view.new_project_btn.label == "Create Project"
diff --git a/tests/test_cogs.py b/tests/test_cogs.py
index 2c212b6..a70093c 100644
--- a/tests/test_cogs.py
+++ b/tests/test_cogs.py
@@ -1959,3 +1959,181 @@ async def test_task_status_blocked_guard_with_force_unauthorized(services):
task_in_db = await task_srv.get_by_id(blocked_task.id)
assert task_in_db.status == TaskStatus.NOT_STARTED
+
+
+@pytest.mark.asyncio
+async def test_admin_lead_role_command_metadata():
+ """Verify that PmCog defines admin_group with lead-role command and manage_guild check."""
+ from src.adapters.discord_bot.cogs.pm_cog import PmCog
+
+ assert hasattr(PmCog, "admin_group")
+ cmd = next((c for c in PmCog.admin_group.commands if c.name == "lead-role"), None)
+ assert cmd is not None
+ params = [p.name for p in cmd.parameters]
+ assert "action" in params
+ assert "role" in params
+
+
+@pytest.mark.asyncio
+async def test_admin_lead_role_execution(services, repos):
+ """Verify /pm admin lead-role can add, list, and remove authorized Team Lead roles."""
+ from src.adapters.discord_bot.cogs.pm_cog import PmCog
+ from src.services.auth_service import AuthService
+
+ proj_srv = services["project"]
+ squad_srv = services["squad"]
+ lead_repo = repos["guild_lead_role"]
+ auth_srv = AuthService(proj_srv, squad_srv, guild_lead_role_repo=lead_repo)
+
+ mock_bot = MagicMock()
+ cog = PmCog(
+ bot=mock_bot,
+ project_service=proj_srv,
+ squad_service=squad_srv,
+ task_service=services["task"],
+ auth_service=auth_srv,
+ )
+
+ guild_id = 9988776655
+ interaction = MagicMock(spec=discord.Interaction)
+ interaction.guild = MagicMock(id=guild_id)
+ interaction.response = MagicMock()
+ interaction.response.defer = AsyncMock()
+ interaction.followup = MagicMock()
+ interaction.followup.send = AsyncMock()
+
+ mock_role = MagicMock(spec=discord.Role, id=11223344, name="Lead Engineer")
+
+ # 1. Add lead role
+ await cog.admin_lead_role.callback(cog, interaction, action="add", role=mock_role)
+ interaction.followup.send.assert_awaited()
+ assert "Added" in interaction.followup.send.call_args[0][0]
+ assert await auth_srv.list_guild_lead_roles(guild_id) == {11223344}
+
+ # 2. List lead roles
+ interaction.followup.send.reset_mock()
+ await cog.admin_lead_role.callback(cog, interaction, action="list")
+ interaction.followup.send.assert_awaited()
+ embed = interaction.followup.send.call_args[1].get("embed")
+ assert embed is not None
+ assert "11223344" in embed.description
+
+ # 3. Remove lead role
+ interaction.followup.send.reset_mock()
+ await cog.admin_lead_role.callback(cog, interaction, action="remove", role=mock_role)
+ interaction.followup.send.assert_awaited()
+ assert "Removed" in interaction.followup.send.call_args[0][0]
+ assert await auth_srv.list_guild_lead_roles(guild_id) == set()
+
+ # 4. Add without specifying role fails gracefully
+ interaction.followup.send.reset_mock()
+ await cog.admin_lead_role.callback(cog, interaction, action="add", role=None)
+ interaction.followup.send.assert_awaited()
+ assert "Please specify a Discord role" in interaction.followup.send.call_args[0][0]
+
+
+@pytest.mark.asyncio
+async def test_project_and_squad_create_by_team_lead_role(services, repos):
+ """Verify that users holding authorized Team Lead roles can create projects and squads without manage_guild."""
+ from src.adapters.discord_bot.cogs.pm_cog import PmCog
+ from src.services.auth_service import AuthService
+
+ proj_srv = services["project"]
+ squad_srv = services["squad"]
+ lead_repo = repos["guild_lead_role"]
+ auth_srv = AuthService(proj_srv, squad_srv, guild_lead_role_repo=lead_repo)
+
+ mock_bot = MagicMock()
+ cog = PmCog(
+ bot=mock_bot,
+ project_service=proj_srv,
+ squad_service=squad_srv,
+ task_service=services["task"],
+ auth_service=auth_srv,
+ )
+
+ guild_id = 7766554433
+ team_lead_role_id = 88889999
+ other_role_id = 11112222
+
+ # Register team lead role in guild
+ await auth_srv.add_guild_lead_role(guild_id, team_lead_role_id)
+
+ # 1. Team lead member (has team_lead_role_id, manage_guild=False)
+ team_lead_role = MagicMock(spec=discord.Role, id=team_lead_role_id, name="Lead")
+ squad_role = MagicMock(spec=discord.Role, id=44445555, name="Devs")
+ team_lead_user = MagicMock(
+ spec=discord.Member,
+ id=2001,
+ roles=[team_lead_role],
+ guild_permissions=discord.Permissions(manage_guild=False, administrator=False),
+ )
+
+ interaction = MagicMock(spec=discord.Interaction)
+ interaction.guild = MagicMock(id=guild_id)
+ interaction.user = team_lead_user
+ interaction.response = MagicMock()
+ interaction.response.defer = AsyncMock()
+ interaction.followup = MagicMock()
+ interaction.followup.send = AsyncMock()
+
+ # Team Lead creates project
+ await cog.project_create.callback(
+ cog,
+ interaction,
+ name="Team Lead Project",
+ prefix="TLP",
+ role=squad_role,
+ )
+ interaction.followup.send.assert_awaited()
+ embed = interaction.followup.send.call_args[1].get("embed")
+ assert embed is not None
+ assert "Team Lead Project" in embed.title
+
+ # Team Lead creates squad
+ interaction.followup.send.reset_mock()
+ await cog.squad_create.callback(
+ cog,
+ interaction,
+ role=squad_role,
+ squad_name="Dev Squad",
+ )
+ interaction.followup.send.assert_awaited()
+ embed = interaction.followup.send.call_args[1].get("embed")
+ assert embed is not None
+ assert "Dev Squad" in embed.title
+
+ # 2. Regular user (does NOT have team_lead_role_id, manage_guild=False)
+ other_role = MagicMock(spec=discord.Role, id=other_role_id, name="Member")
+ regular_user = MagicMock(
+ spec=discord.Member,
+ id=3001,
+ roles=[other_role],
+ guild_permissions=discord.Permissions(manage_guild=False, administrator=False),
+ )
+ interaction.user = regular_user
+ interaction.followup.send.reset_mock()
+
+ # Regular user fails creating project
+ await cog.project_create.callback(
+ cog,
+ interaction,
+ name="Unauthorized Project",
+ prefix="UAP",
+ role=squad_role,
+ )
+ interaction.followup.send.assert_awaited()
+ sent_msg = interaction.followup.send.call_args[0][0]
+ assert "You do not have permission" in sent_msg
+
+ # Regular user fails creating squad
+ interaction.followup.send.reset_mock()
+ await cog.squad_create.callback(
+ cog,
+ interaction,
+ role=squad_role,
+ squad_name="Unauthorized Squad",
+ )
+ interaction.followup.send.assert_awaited()
+ sent_msg = interaction.followup.send.call_args[0][0]
+ assert "You do not have permission" in sent_msg
diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py
index b19b267..cf18650 100644
--- a/tests/test_error_handling.py
+++ b/tests/test_error_handling.py
@@ -241,3 +241,100 @@ async def test_cogs_handle_service_and_unexpected_errors(services, caplog):
interaction.followup.send.assert_awaited()
last_call_arg = interaction.followup.send.call_args[0][0]
assert "not found" in last_call_arg
+
+
+def test_translate_error_app_command_errors():
+ """Verify translate_error correctly formats discord.app_commands errors without unexpected flags."""
+ # 1. MissingPermissions
+ missing_perm_err = discord.app_commands.MissingPermissions(["manage_guild"])
+ msg, unexpected = translate_error(missing_perm_err, "creating project")
+ assert not unexpected
+ assert "Manage Server" in msg
+ assert "missing required Discord permission" in msg
+
+ # 2. BotMissingPermissions
+ bot_missing_err = discord.app_commands.BotMissingPermissions(["manage_channels"])
+ msg, unexpected = translate_error(bot_missing_err, "creating channel")
+ assert not unexpected
+ assert "Manage Channels" in msg
+ assert "bot is missing required Discord permission" in msg
+
+ # 3. CommandOnCooldown
+ cooldown_err = discord.app_commands.CommandOnCooldown(5.0, 10.0)
+ msg, unexpected = translate_error(cooldown_err, "syncing commands")
+ assert not unexpected
+ assert "cooldown" in msg
+
+ # 4. Generic CheckFailure
+ check_err = discord.app_commands.CheckFailure()
+ msg, unexpected = translate_error(check_err, "executing command")
+ assert not unexpected
+ assert "do not have permission" in msg
+
+ # 5. CommandInvokeError unwraps original error
+ mock_cmd = MagicMock()
+ mock_cmd.name = "test_cmd"
+ invoke_err = discord.app_commands.CommandInvokeError(mock_cmd, ValueError("Validation problem"))
+ msg, unexpected = translate_error(invoke_err, "executing test_cmd")
+ assert not unexpected
+ assert "Validation problem" in msg
+
+
+@pytest.mark.asyncio
+async def test_cog_app_command_error_handles_missing_permissions(services, caplog):
+ """Verify PmCog.cog_app_command_error catches MissingPermissions and responds ephemerally."""
+ bot = MagicMock(spec=discord.Client)
+ pm_cog = PmCog(
+ bot,
+ project_service=services["project"],
+ squad_service=services["squad"],
+ task_service=services["task"],
+ )
+
+ interaction = MagicMock(spec=discord.Interaction)
+ interaction.command = MagicMock()
+ interaction.command.qualified_name = "pm project create"
+ interaction.response = MagicMock()
+ interaction.response.is_done.return_value = False
+ interaction.response.send_message = AsyncMock()
+
+ missing_err = discord.app_commands.MissingPermissions(["manage_guild"])
+
+ with caplog.at_level(logging.WARNING):
+ await pm_cog.cog_app_command_error(interaction, missing_err)
+
+ interaction.response.send_message.assert_awaited_once()
+ sent_msg = interaction.response.send_message.call_args[0][0]
+ assert "Manage Server" in sent_msg
+ assert interaction.response.send_message.call_args[1].get("ephemeral") is True
+ assert "App command check failure while executing '/pm project create'" in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_bot_on_tree_error_handles_missing_permissions(services, caplog):
+ """Verify DggPmBot.on_tree_error catches unhandled command tree errors and responds ephemerally."""
+ from src.adapters.discord_bot.bot import DggPmBot
+
+ bot = DggPmBot(
+ task_service=services["task"],
+ project_service=services["project"],
+ squad_service=services["squad"],
+ )
+
+ interaction = MagicMock(spec=discord.Interaction)
+ interaction.command = MagicMock()
+ interaction.command.qualified_name = "pm squad create"
+ interaction.response = MagicMock()
+ interaction.response.is_done.return_value = False
+ interaction.response.send_message = AsyncMock()
+
+ missing_err = discord.app_commands.MissingPermissions(["manage_guild"])
+
+ with caplog.at_level(logging.WARNING):
+ await bot.on_tree_error(interaction, missing_err)
+
+ interaction.response.send_message.assert_awaited_once()
+ sent_msg = interaction.response.send_message.call_args[0][0]
+ assert "Manage Server" in sent_msg
+ assert interaction.response.send_message.call_args[1].get("ephemeral") is True
+ assert "App command check failure while executing '/pm squad create'" in caplog.text
diff --git a/tests/test_menus.py b/tests/test_menus.py
index 38fd36d..bdce608 100644
--- a/tests/test_menus.py
+++ b/tests/test_menus.py
@@ -547,7 +547,7 @@ async def test_pm_hub_navigation(services):
hub_view = PmHubView(proj_srv, squad_srv, task_srv, user_service=user_srv)
welcome_embed = build_hub_welcome_embed()
assert "Control Hub" in welcome_embed.title
- assert len(hub_view.children) == 5 # New Task, Task Board, Projects, Tech Tree, Overdue
+ assert len(hub_view.children) == 6 # New Task, Task Board, Projects, Tech Tree, Overdue, Create Project
mock_interaction = MagicMock(spec=discord.Interaction)
mock_interaction.guild = MagicMock()
@@ -1500,6 +1500,118 @@ async def test_pm_dashboard_view_and_embed(services):
assert isinstance(guides_view, PmDashboardOverviewView)
+@pytest.mark.asyncio
+async def test_lead_roles_admin_menu_lifecycle(services, repos):
+ """Verify that PmDashboardView has a Lead Roles button for Server Managers,
+ and LeadRolesAdminView allows viewing, assigning, and removing team lead roles.
+ """
+ from src.adapters.discord_bot.views.admin_menu import (
+ LeadRolesAdminView,
+ PmDashboardView,
+ )
+ from src.services.auth_service import AuthService
+
+ proj_srv = services["project"]
+ squad_srv = services["squad"]
+ task_srv = services["task"]
+ user_srv = services["user"]
+ lead_role_repo = repos["guild_lead_role"]
+ auth_srv = AuthService(proj_srv, squad_srv, guild_lead_role_repo=lead_role_repo)
+
+ guild_id = 123456789
+ mock_guild = MagicMock(spec=discord.Guild)
+ mock_guild.id = guild_id
+ mock_guild.name = "Test Guild"
+
+ admin_user = MagicMock(spec=discord.Member)
+ admin_user.id = 9991
+ admin_user.guild_permissions = discord.Permissions(manage_guild=True)
+
+ reg_user = MagicMock(spec=discord.Member)
+ reg_user.id = 9992
+ reg_user.guild_permissions = discord.Permissions(manage_guild=False)
+
+ # 1. Admin dashboard view shows Lead Roles button for Server Manager
+ admin_dash = PmDashboardView(
+ project_service=proj_srv,
+ squad_service=squad_srv,
+ task_service=task_srv,
+ user_service=user_srv,
+ auth_service=auth_srv,
+ user=admin_user,
+ )
+ assert admin_dash.is_server_manager is True
+ assert hasattr(admin_dash, "lead_roles_btn")
+ assert admin_dash.lead_roles_btn is not None
+ assert admin_dash.lead_roles_btn.label == "Lead Roles"
+
+ # Regular user dashboard view hides Lead Roles button
+ reg_dash = PmDashboardView(
+ project_service=proj_srv,
+ squad_service=squad_srv,
+ task_service=task_srv,
+ user_service=user_srv,
+ auth_service=auth_srv,
+ user=reg_user,
+ )
+ assert reg_dash.is_server_manager is False
+ assert getattr(reg_dash, "lead_roles_btn", None) is None
+
+ # 2. Clicking Lead Roles button opens LeadRolesAdminView
+ inter = MagicMock(spec=discord.Interaction)
+ inter.guild = mock_guild
+ inter.user = admin_user
+ inter.response = MagicMock()
+ inter.response.edit_message = AsyncMock()
+
+ await admin_dash._on_lead_roles_clicked(inter)
+ inter.response.edit_message.assert_awaited_once()
+ lead_view = inter.response.edit_message.call_args.kwargs["view"]
+ assert isinstance(lead_view, LeadRolesAdminView)
+ lead_embed = inter.response.edit_message.call_args.kwargs["embed"]
+ assert "Authorized Team Lead Roles" in lead_embed.title
+
+ # 3. Assign role via LeadRolesAdminView
+ mock_role = MagicMock(spec=discord.Role)
+ mock_role.id = 555444333
+ mock_role.name = "Engineering Lead"
+ mock_guild.get_role.return_value = mock_role
+
+ lead_view.role_select._values = [str(mock_role.id)]
+ assign_inter = MagicMock(spec=discord.Interaction)
+ assign_inter.guild = mock_guild
+ assign_inter.user = admin_user
+ assign_inter.response = MagicMock()
+ assign_inter.response.edit_message = AsyncMock()
+
+ await lead_view._on_assign_clicked(assign_inter)
+ assigned_roles = await auth_srv.list_guild_lead_roles(guild_id)
+ assert mock_role.id in assigned_roles
+
+ # 4. Remove role via LeadRolesAdminView
+ remove_inter = MagicMock(spec=discord.Interaction)
+ remove_inter.guild = mock_guild
+ remove_inter.user = admin_user
+ remove_inter.response = MagicMock()
+ remove_inter.response.edit_message = AsyncMock()
+
+ await lead_view._on_remove_clicked(remove_inter)
+ assigned_roles_after = await auth_srv.list_guild_lead_roles(guild_id)
+ assert mock_role.id not in assigned_roles_after
+
+ # 5. Back button returns to PmDashboardView
+ back_inter = MagicMock(spec=discord.Interaction)
+ back_inter.guild = mock_guild
+ back_inter.user = admin_user
+ back_inter.response = MagicMock()
+ back_inter.response.edit_message = AsyncMock()
+
+ await lead_view._on_back_clicked(back_inter)
+ back_inter.response.edit_message.assert_awaited_once()
+ back_view = back_inter.response.edit_message.call_args.kwargs["view"]
+ assert isinstance(back_view, PmDashboardView)
+
+
def test_build_hub_welcome_embed_squad_roles():
from src.domain.models import Project, Squad
diff --git a/tests/test_repos.py b/tests/test_repos.py
index 83c77ca..075fec4 100644
--- a/tests/test_repos.py
+++ b/tests/test_repos.py
@@ -156,3 +156,36 @@ async def test_repo_list_tasks_overdue_pagination(repos, services):
assert total2 == 5
assert len(p2_tasks) == 2
assert p1_tasks[0].id != p2_tasks[0].id
+
+
+@pytest.mark.asyncio
+async def test_guild_lead_role_repo_lifecycle(db_session):
+ """Verify IGuildLeadRoleRepository can add, list, and remove authorized lead role IDs."""
+ from src.adapters.db.postgres_repo import PostgresGuildLeadRoleRepository
+
+ repo = PostgresGuildLeadRoleRepository(db_session)
+ guild_id = 9876543210
+
+ # Initially empty
+ assert await repo.list_lead_role_ids(guild_id) == set()
+
+ # Add roles
+ await repo.add_lead_role(guild_id, 111222)
+ await repo.add_lead_role(guild_id, 333444)
+
+ # Adding duplicate is idempotent
+ await repo.add_lead_role(guild_id, 111222)
+
+ lead_roles = await repo.list_lead_role_ids(guild_id)
+ assert lead_roles == {111222, 333444}
+
+ # Different guild is isolated
+ assert await repo.list_lead_role_ids(555555) == set()
+
+ # Remove existing role
+ assert await repo.remove_lead_role(guild_id, 111222) is True
+ assert await repo.list_lead_role_ids(guild_id) == {333444}
+
+ # Remove non-existent role
+ assert await repo.remove_lead_role(guild_id, 999999) is False
+ assert await repo.list_lead_role_ids(guild_id) == {333444}