diff --git a/docs/wiki/Slash-Commands-Reference.md b/docs/wiki/Slash-Commands-Reference.md
index 483ae85..0f273c5 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` OR Authorized Team Lead Role |
+| **`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/Member) |
| **`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` 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 |
+| **`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/Member) |
+| **`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/Member) |
+| **`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/Member) 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` 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 |
+| **`archive`** | `project_name` (required) | Archives a project container and cascades thread archiving. | `Manage Server` OR Authorized Team Lead (Role/Member) |
+| **`unarchive`** | `project_name` (required) | Restores an archived project container and reopens task threads. | `Manage Server` OR Authorized Team Lead (Role/Member) |
+| **`setup_forum`** | `forum` (required) | Automatically configures standard PM tags on a Discord Forum Channel. | `Manage Server` OR Authorized Team Lead (Role/Member) |
+| **`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/Member) |
---
@@ -54,7 +54,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` |
+| **`lead`** | `action` (required: `add`, `remove`, `list`)
`role` (optional: `@Role`)
`user` (optional: `@Member`) | Registers, removes, or lists Discord server roles or individual members authorized as Team Leads for project and squad management. (Alias: `lead-role`). | `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 29b48d2..680a92d 100644
--- a/docs/wiki/Teams-and-Authorization.md
+++ b/docs/wiki/Teams-and-Authorization.md
@@ -9,13 +9,13 @@ DGG-PM implements a Discord-native permission model. Discord server roles act as
```
┌────────────────────────────────────────────────────────┐
│ Server Managers (Manage Server / Administrator) │
-│ - Full server bypass; configure Team Lead roles │
+│ - Full server bypass; configure Team Leads │
└──────────────────────────┬─────────────────────────────┘
│
┌──────────────────────────▼─────────────────────────────┐
-│ Authorized Team Lead Roles & Active Squad Leads │
+│ Authorized Team Leads (Roles or Members) & Squad Leads│
│ - Create and manage projects, channels, & squads │
-│ - Granted via server role without Discord admin perms │
+│ - Granted via server role or member without admin perms│
└──────────────────────────┬─────────────────────────────┘
│
┌──────────────────────────▼─────────────────────────────┐
@@ -38,9 +38,9 @@ DGG-PM implements a Discord-native permission model. Discord server roles act as
## 🛡️ Mutation Authorization Matrix
-| Action | Server Manager | Authorized Team Lead Role | Squad Lead | Squad Member (Mapped Role) | Task Assignee | Task Creator | Other Server Member |
+| Action | Server Manager | Authorized Team Lead (Role/Member) | Squad Lead | Squad Member (Mapped Role) | Task Assignee | Task Creator | Other Server Member |
| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
-| **Configure Lead Roles** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
+| **Configure Team Leads** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Create Project / Squad** | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| **Manage Project (Role, Archive)** | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Designate / Remove Squad Lead** | ✅ | ✅ | ✅ (Own Squad) | ❌ | ❌ | ❌ | ❌ |
@@ -51,33 +51,44 @@ DGG-PM implements a Discord-native permission model. Discord server roles act as
---
-## 🎖️ Configurable Team Lead Roles
+## 🎖️ Configurable Team Leads (Roles & Individual Members)
-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**.
+To adhere to the principle of least privilege, DGG-PM allows server managers to authorize specific Discord server roles **or individual server members** as **Team Leads**. Authorized Team Leads 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.
+2. Members with `Manage Server` or `Administrator` permissions will see a **`Team Leads`** button in the top action row.
+3. Clicking **`Team Leads`** opens the unified management portal:
+ - Displays all currently authorized Team Lead roles and individual members 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 native Discord **User Select** dropdown to select any server member (bots excluded).
+ - Provides 1-click **`Assign Role`**, **`Remove Role`**, **`Assign User`**, and **`Remove User`** 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**:
+### 2. Slash Command Management (`/pm admin lead`)
+Server Managers can configure authorized roles and individual members via slash commands:
+- **Add / Remove a Team Lead Role**:
```text
- /pm admin lead-role action:add role:@Engineering Lead
+ /pm admin lead action:add role:@Engineering Lead
+ /pm admin lead action:remove role:@Engineering Lead
```
-- **Remove a Lead Role**:
+- **Add / Remove an Individual Team Lead Member**:
```text
- /pm admin lead-role action:remove role:@Engineering Lead
+ /pm admin lead action:add user:@Alice
+ /pm admin lead action:remove user:@Alice
```
-- **List All Lead Roles**:
+- **List All Authorized Team Leads (Roles and Members)**:
```text
- /pm admin lead-role action:list
+ /pm admin lead action:list
```
+- *(Backwards compatibility)*: `/pm admin lead-role` is retained as an alias for `/pm admin lead`.
+
+---
+
+## 🔄 Self-Healing & Departure Lifecycle
+
+1. **Individual Team Lead Departure**:
+ - When a member leaves the Discord server (`on_member_remove`), DGG-PM automatically prunes their entry from `guild_lead_users`, preventing stale authorizations if the user ever rejoins.
---
diff --git a/src/adapters/db/migrations/versions/0003_add_guild_lead_users.py b/src/adapters/db/migrations/versions/0003_add_guild_lead_users.py
new file mode 100644
index 0000000..8ee80f9
--- /dev/null
+++ b/src/adapters/db/migrations/versions/0003_add_guild_lead_users.py
@@ -0,0 +1,34 @@
+"""add_guild_lead_users
+
+Revision ID: 0003
+Revises: 0002
+Create Date: 2026-09-24 02:17:00.000000
+
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "0003"
+down_revision: str | Sequence[str] | None = "0002"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ op.create_table(
+ "guild_lead_users",
+ sa.Column("guild_id", sa.BigInteger(), nullable=False),
+ sa.Column("user_discord_id", sa.BigInteger(), nullable=False),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ sa.PrimaryKeyConstraint("guild_id", "user_discord_id"),
+ )
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ op.drop_table("guild_lead_users")
diff --git a/src/adapters/db/postgres_repo.py b/src/adapters/db/postgres_repo.py
index a89a88c..41f79ac 100644
--- a/src/adapters/db/postgres_repo.py
+++ b/src/adapters/db/postgres_repo.py
@@ -12,6 +12,7 @@
from src.adapters.db.tables import (
GuildLeadRoleTable,
+ GuildLeadUserTable,
OutboxEventTable,
ProjectSquadTable,
ProjectTable,
@@ -44,6 +45,7 @@
)
from src.ports.repositories import (
IGuildLeadRoleRepository,
+ IGuildLeadUserRepository,
IOutboxRepo,
IProjectRepo,
ISquadRepo,
@@ -1320,3 +1322,40 @@ async def list_lead_role_ids(self, guild_id: int) -> set[int]:
stmt = select(GuildLeadRoleTable.discord_role_id).where(GuildLeadRoleTable.guild_id == guild_id)
res = await session.execute(stmt)
return set(res.scalars().all())
+
+
+class PostgresGuildLeadUserRepository(BasePostgresRepo, IGuildLeadUserRepository):
+ async def add_lead_user(self, guild_id: int, user_discord_id: int) -> None:
+ async with self._get_session() as session:
+ stmt = select(GuildLeadUserTable).where(
+ GuildLeadUserTable.guild_id == guild_id,
+ GuildLeadUserTable.user_discord_id == user_discord_id,
+ )
+ res = await session.execute(stmt)
+ if res.scalar_one_or_none() is not None:
+ return
+ row = GuildLeadUserTable(guild_id=guild_id, user_discord_id=user_discord_id)
+ session.add(row)
+ if self._should_commit(None):
+ await session.commit()
+ else:
+ await session.flush()
+
+ async def remove_lead_user(self, guild_id: int, user_discord_id: int) -> bool:
+ async with self._get_session() as session:
+ stmt = delete(GuildLeadUserTable).where(
+ GuildLeadUserTable.guild_id == guild_id,
+ GuildLeadUserTable.user_discord_id == user_discord_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_user_ids(self, guild_id: int) -> set[int]:
+ async with self._get_session() as session:
+ stmt = select(GuildLeadUserTable.user_discord_id).where(GuildLeadUserTable.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 f024add..f8d57a6 100644
--- a/src/adapters/db/tables.py
+++ b/src/adapters/db/tables.py
@@ -200,3 +200,11 @@ class GuildLeadRoleTable(Base):
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))
+
+
+class GuildLeadUserTable(Base):
+ __tablename__ = "guild_lead_users"
+
+ guild_id = Column(BigInteger, primary_key=True)
+ user_discord_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 4aa74cf..980ef98 100644
--- a/src/adapters/db/unit_of_work.py
+++ b/src/adapters/db/unit_of_work.py
@@ -7,6 +7,7 @@
from src.adapters.db.postgres_repo import (
PostgresGuildLeadRoleRepository,
+ PostgresGuildLeadUserRepository,
PostgresOutboxRepo,
PostgresProjectRepo,
PostgresSquadRepo,
@@ -15,6 +16,7 @@
)
from src.ports.repositories import (
IGuildLeadRoleRepository,
+ IGuildLeadUserRepository,
IOutboxRepo,
IProjectRepo,
ISquadRepo,
@@ -38,6 +40,7 @@ def __init__(self, session_factory: async_sessionmaker[AsyncSession]):
self.outbox: IOutboxRepo = None # type: ignore[assignment]
self.user_prefs: IUserPreferenceRepo = None # type: ignore[assignment]
self.guild_lead_roles: IGuildLeadRoleRepository = None # type: ignore[assignment]
+ self.guild_lead_users: IGuildLeadUserRepository = None # type: ignore[assignment]
@property
def session_factory(self) -> async_sessionmaker[AsyncSession]:
@@ -51,6 +54,7 @@ async def __aenter__(self) -> SqlAlchemyUnitOfWork:
self.outbox = PostgresOutboxRepo(self._session)
self.user_prefs = PostgresUserPreferenceRepo(self._session)
self.guild_lead_roles = PostgresGuildLeadRoleRepository(self._session)
+ self.guild_lead_users = PostgresGuildLeadUserRepository(self._session)
return self
async def __aexit__(
diff --git a/src/adapters/discord_bot/bot.py b/src/adapters/discord_bot/bot.py
index 112265b..822713d 100644
--- a/src/adapters/discord_bot/bot.py
+++ b/src/adapters/discord_bot/bot.py
@@ -17,7 +17,7 @@
)
from src.config import settings
from src.domain.models import Task
-from src.ports.repositories import IGuildLeadRoleRepository
+from src.ports.repositories import IGuildLeadRoleRepository, IGuildLeadUserRepository
from src.services.auth_service import AuthService
from src.services.outbox_service import OutboxService
from src.services.project_service import ProjectService
@@ -39,6 +39,7 @@ def __init__(
workspace: ITaskDiscordWorkspace | None = None,
project_workspace: IProjectDiscordWorkspace | None = None,
guild_lead_role_repo: IGuildLeadRoleRepository | None = None,
+ guild_lead_user_repo: IGuildLeadUserRepository | None = None,
auth_service: AuthService | None = None,
):
intents = discord.Intents.default()
@@ -59,12 +60,16 @@ def __init__(
getattr(task_service, "outbox_service", None) if task_service else None
)
self.guild_lead_role_repo = guild_lead_role_repo
+ self.guild_lead_user_repo = guild_lead_user_repo
self._background_tasks: set[asyncio.Task] = set()
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
+ project_service,
+ self.squad_service,
+ guild_lead_role_repo=guild_lead_role_repo,
+ guild_lead_user_repo=guild_lead_user_repo,
)
else:
self.auth_service = None
@@ -284,22 +289,33 @@ async def on_member_update(self, before: discord.Member, after: discord.Member)
logger.warning("Error auto-pruning squad lead on member update for user %s: %s", after.id, e)
async def on_member_remove(self, member: discord.Member) -> None:
- """Auto-prune squad lead records across all squads in the guild if a member leaves the server."""
- if not self.squad_service:
- return
+ """Auto-prune squad lead and individual team lead records across the guild if a member leaves the server."""
+ if self.squad_service:
+ try:
+ squads = await self.squad_service.list_squads(member.guild.id)
+ for squad in squads:
+ if await self.squad_service.is_squad_lead(squad.id, member.id):
+ await self.squad_service.remove_squad_lead(squad.id, member.id)
+ logger.info(
+ "Auto-pruned squad lead record for user %s from squad '%s' because member left the server",
+ member.id,
+ squad.name,
+ )
+ except Exception as e:
+ logger.warning("Error auto-pruning squad leads on member remove for user %s: %s", member.id, e)
- try:
- squads = await self.squad_service.list_squads(member.guild.id)
- for squad in squads:
- if await self.squad_service.is_squad_lead(squad.id, member.id):
- await self.squad_service.remove_squad_lead(squad.id, member.id)
+ if self.auth_service:
+ try:
+ removed = await self.auth_service.remove_guild_lead_user(member.guild.id, member.id)
+ if removed:
logger.info(
- "Auto-pruned squad lead record for user %s from squad '%s' because member left the server",
+ "Auto-pruned individual team lead record for user %s from guild %s "
+ "because member left the server",
member.id,
- squad.name,
+ member.guild.id,
)
- except Exception as e:
- logger.warning("Error auto-pruning squad leads on member remove for user %s: %s", member.id, e)
+ except Exception as e:
+ logger.warning("Error auto-pruning individual team lead on member remove for user %s: %s", member.id, e)
async def on_interaction(self, interaction: discord.Interaction) -> None:
"""Global interaction dispatcher handling dynamic persistent task buttons across restarts."""
diff --git a/src/adapters/discord_bot/cogs/pm_cog.py b/src/adapters/discord_bot/cogs/pm_cog.py
index 71bfc0f..fd1e0bf 100644
--- a/src/adapters/discord_bot/cogs/pm_cog.py
+++ b/src/adapters/discord_bot/cogs/pm_cog.py
@@ -501,6 +501,130 @@ async def admin_retry_outbox(
ephemeral=True,
)
+ @admin_group.command(
+ name="lead",
+ description="Authorize, revoke, or list Team Lead roles and individual members",
+ )
+ @app_commands.describe(
+ action="Action to perform (add, remove, or list authorized Team Leads)",
+ role="Discord role to authorize or revoke",
+ user="Discord member to authorize or revoke",
+ )
+ @app_commands.choices(
+ action=[
+ app_commands.Choice(name="Add Team Lead(s)", value="add"),
+ app_commands.Choice(name="Remove Team Lead(s)", value="remove"),
+ app_commands.Choice(name="List Team Leads", value="list"),
+ ]
+ )
+ @app_commands.checks.has_permissions(manage_guild=True)
+ async def admin_lead(
+ self,
+ interaction: discord.Interaction,
+ action: str,
+ role: discord.Role | None = None,
+ user: discord.Member | None = None,
+ ) -> None:
+ """Authorize or revoke Discord roles and individual members 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 in ("add", "remove"):
+ if not role and not user:
+ await interaction.followup.send(
+ "❌ Please specify a role, a member, or both to add/remove.",
+ ephemeral=True,
+ )
+ return
+
+ if user and getattr(user, "bot", False):
+ await interaction.followup.send(
+ "❌ Bots cannot be authorized as Team Leads.",
+ ephemeral=True,
+ )
+ return
+
+ messages: list[str] = []
+ if action == "add":
+ if role:
+ await self.auth_service.add_guild_lead_role(guild_id, role.id)
+ messages.append(f"⭐ **Added Team Lead Role**: <@&{role.id}>")
+ if user:
+ await self.auth_service.add_guild_lead_user(guild_id, user.id)
+ messages.append(f"⭐ **Added Team Lead Member**: <@{user.id}>")
+ messages.append("They are now authorized to create and manage projects and squads.")
+ await interaction.followup.send("\n".join(messages), ephemeral=True)
+ else: # remove
+ if role:
+ removed_role = await self.auth_service.remove_guild_lead_role(guild_id, role.id)
+ if removed_role:
+ messages.append(f"✅ **Removed Team Lead Role**: <@&{role.id}>")
+ else:
+ messages.append(f"ℹ️ <@&{role.id}> was not registered as an authorized Team Lead role.")
+ if user:
+ removed_user = await self.auth_service.remove_guild_lead_user(guild_id, user.id)
+ if removed_user:
+ messages.append(f"✅ **Removed Team Lead Member**: <@{user.id}>")
+ else:
+ messages.append(f"ℹ️ <@{user.id}> was not registered as an authorized Team Lead member.")
+ await interaction.followup.send("\n".join(messages), ephemeral=True)
+
+ elif action == "list":
+ role_ids = await self.auth_service.list_guild_lead_roles(guild_id)
+ user_ids = await self.auth_service.list_guild_lead_users(guild_id)
+
+ if not role_ids and not user_ids:
+ await interaction.followup.send(
+ "ℹ️ **No Team Leads Configured**\n"
+ "Only Discord Server Managers (users with `Manage Server` or `Administrator`) "
+ "can create and manage projects.",
+ ephemeral=True,
+ )
+ return
+
+ embed = discord.Embed(
+ title="👥 Authorized Team Leads",
+ description=(
+ "The following Discord roles and individual members are authorized to create "
+ "and manage projects and squads without requiring server-wide `Manage Server` permissions:\n"
+ ),
+ color=discord.Color.blue(),
+ )
+
+ if role_ids:
+ role_lines = [f"• <@&{rid}> (`{rid}`)" for rid in sorted(role_ids)]
+ 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 roles configured*",
+ inline=False,
+ )
+
+ if user_ids:
+ user_lines = [f"• <@{uid}> (`{uid}`)" for uid in sorted(user_ids)]
+ embed.add_field(
+ name=f"Configured Members ({len(user_ids)})",
+ value="\n".join(user_lines),
+ inline=False,
+ )
+ else:
+ embed.add_field(
+ name="Configured Members (0)",
+ value="*No individual members configured*",
+ inline=False,
+ )
+
+ await interaction.followup.send(embed=embed, ephemeral=True)
+ except Exception as e:
+ await send_interaction_error(interaction, e, "managing team leads", logger, ephemeral=True)
+
@admin_group.command(
name="lead-role",
description="Authorize or revoke an existing Discord Team Lead role to create and manage projects",
diff --git a/src/adapters/discord_bot/views/admin_menu.py b/src/adapters/discord_bot/views/admin_menu.py
index 5875e64..d634bb3 100644
--- a/src/adapters/discord_bot/views/admin_menu.py
+++ b/src/adapters/discord_bot/views/admin_menu.py
@@ -174,8 +174,8 @@ 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."""
+class TeamLeadsAdminView(BaseView):
+ """View allowing Server Managers to assign, view, and remove Team Leads (roles and members) for the guild."""
def __init__(
self,
@@ -194,6 +194,7 @@ def __init__(
self.auth_service = auth_service
self._initial_interaction = initial_interaction
self.selected_role_id: int | None = None
+ self.selected_user_id: int | None = None
self._rebuild_items()
def _rebuild_items(self) -> None:
@@ -209,7 +210,7 @@ def _rebuild_items(self) -> None:
self.role_select.callback = self._on_role_selected
self.add_item(self.role_select)
- # Row 1: Action buttons
+ # Row 1: Role Action buttons
self.assign_btn = discord.ui.Button(
label="Assign Role",
style=discord.ButtonStyle.success,
@@ -217,6 +218,7 @@ def _rebuild_items(self) -> None:
)
self.assign_btn.callback = self._on_assign_clicked
self.add_item(self.assign_btn)
+ self.assign_role_btn = self.assign_btn
self.remove_btn = discord.ui.Button(
label="Remove Role",
@@ -225,11 +227,40 @@ def _rebuild_items(self) -> None:
)
self.remove_btn.callback = self._on_remove_clicked
self.add_item(self.remove_btn)
+ self.remove_role_btn = self.remove_btn
+ # Row 2: Select Discord User
+ self.user_select = discord.ui.UserSelect(
+ placeholder="Select a Discord member to assign or remove...",
+ min_values=1,
+ max_values=1,
+ row=2,
+ )
+ self.user_select.callback = self._on_user_selected
+ self.add_item(self.user_select)
+
+ # Row 3: User Action buttons
+ self.assign_user_btn = discord.ui.Button(
+ label="Assign Member",
+ style=discord.ButtonStyle.success,
+ row=3,
+ )
+ self.assign_user_btn.callback = self._on_assign_user_clicked
+ self.add_item(self.assign_user_btn)
+
+ self.remove_user_btn = discord.ui.Button(
+ label="Remove Member",
+ style=discord.ButtonStyle.danger,
+ row=3,
+ )
+ self.remove_user_btn.callback = self._on_remove_user_clicked
+ self.add_item(self.remove_user_btn)
+
+ # Row 4: Navigation
self.back_btn = discord.ui.Button(
label="Back to Dashboard",
style=discord.ButtonStyle.secondary,
- row=1,
+ row=4,
)
self.back_btn.callback = self._on_back_clicked
self.add_item(self.back_btn)
@@ -242,16 +273,26 @@ async def _on_role_selected(self, interaction: discord.Interaction) -> None:
embed = await self.build_embed(interaction.guild)
await interaction.response.edit_message(embed=embed, view=self)
+ async def _on_user_selected(self, interaction: discord.Interaction) -> None:
+ selected_values = getattr(self.user_select, "values", []) or getattr(self.user_select, "_values", [])
+ if selected_values:
+ val = selected_values[0]
+ self.selected_user_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()
+ user_ids: set[int] = set()
if self.auth_service and guild_id:
role_ids = await self.auth_service.list_guild_lead_roles(guild_id)
+ user_ids = await self.auth_service.list_guild_lead_users(guild_id)
embed = discord.Embed(
- title="👥 Authorized Team Lead Roles",
+ title="👥 Authorized Team Leads",
description=(
- "Members holding any of the configured roles can create and manage "
+ "Members holding any of the configured roles or designated individually can create and manage "
"projects and squads without requiring server-wide `Manage Server` permissions.\n\n"
),
color=discord.Color.blue(),
@@ -273,7 +314,27 @@ async def build_embed(self, guild: discord.Guild | None) -> discord.Embed:
else:
embed.add_field(
name="Configured Roles (0)",
- value="*No team lead roles configured yet. Use the picker below to assign one.*",
+ value="*No team lead roles configured yet. Use the role picker to assign one.*",
+ inline=False,
+ )
+
+ if user_ids:
+ user_lines = []
+ for uid in sorted(user_ids):
+ member = guild.get_member(uid) if guild else None
+ if member:
+ user_lines.append(f"• **@{member.display_name}** (`{uid}`)")
+ else:
+ user_lines.append(f"• <@{uid}> (`{uid}`)")
+ embed.add_field(
+ name=f"Configured Members ({len(user_ids)})",
+ value="\n".join(user_lines),
+ inline=False,
+ )
+ else:
+ embed.add_field(
+ name="Configured Members (0)",
+ value="*No individual team lead members configured yet. Use the member picker to assign one.*",
inline=False,
)
@@ -287,7 +348,17 @@ async def build_embed(self, guild: discord.Guild | None) -> discord.Embed:
inline=False,
)
- embed.set_footer(text="dgg-pm • Team Lead Role Administration")
+ if self.selected_user_id:
+ member = guild.get_member(self.selected_user_id) if guild else None
+ user_name = f"@{member.display_name}" if member else f"<@{self.selected_user_id}>"
+ status = "Already Configured" if self.selected_user_id in user_ids else "Not Configured"
+ embed.add_field(
+ name="Selected Member",
+ value=f"**{user_name}** (`{self.selected_user_id}`) — *{status}*",
+ inline=False,
+ )
+
+ embed.set_footer(text="dgg-pm • Team Lead Administration")
return embed
async def _on_assign_clicked(self, interaction: discord.Interaction) -> None:
@@ -297,7 +368,7 @@ async def _on_assign_clicked(self, interaction: discord.Interaction) -> None:
if not AuthService.is_server_manager(interaction.user):
await interaction.response.send_message(
- "❌ Only Discord Server Managers can configure team lead roles.", ephemeral=True
+ "❌ Only Discord Server Managers can configure team leads.", ephemeral=True
)
return
@@ -323,7 +394,7 @@ async def _on_remove_clicked(self, interaction: discord.Interaction) -> None:
if not AuthService.is_server_manager(interaction.user):
await interaction.response.send_message(
- "❌ Only Discord Server Managers can configure team lead roles.", ephemeral=True
+ "❌ Only Discord Server Managers can configure team leads.", ephemeral=True
)
return
@@ -342,6 +413,58 @@ async def _on_remove_clicked(self, interaction: discord.Interaction) -> None:
embed = await self.build_embed(interaction.guild)
await interaction.response.edit_message(embed=embed, view=self)
+ async def _on_assign_user_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 leads.", ephemeral=True
+ )
+ return
+
+ selected_values = getattr(self.user_select, "values", []) or getattr(self.user_select, "_values", [])
+ if selected_values and not self.selected_user_id:
+ val = selected_values[0]
+ self.selected_user_id = int(val.id) if hasattr(val, "id") else int(val)
+
+ if not self.selected_user_id:
+ await interaction.response.send_message("⚠️ Please select a member from the dropdown first.", ephemeral=True)
+ return
+
+ if self.auth_service:
+ await self.auth_service.add_guild_lead_user(interaction.guild.id, self.selected_user_id)
+
+ embed = await self.build_embed(interaction.guild)
+ await interaction.response.edit_message(embed=embed, view=self)
+
+ async def _on_remove_user_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 leads.", ephemeral=True
+ )
+ return
+
+ selected_values = getattr(self.user_select, "values", []) or getattr(self.user_select, "_values", [])
+ if selected_values and not self.selected_user_id:
+ val = selected_values[0]
+ self.selected_user_id = int(val.id) if hasattr(val, "id") else int(val)
+
+ if not self.selected_user_id:
+ await interaction.response.send_message("⚠️ Please select a member from the dropdown first.", ephemeral=True)
+ return
+
+ if self.auth_service:
+ await self.auth_service.remove_guild_lead_user(interaction.guild.id, self.selected_user_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
@@ -373,6 +496,9 @@ async def _on_back_clicked(self, interaction: discord.Interaction) -> None:
await interaction.response.edit_message(content=None, embed=embed, view=view)
+LeadRolesAdminView = TeamLeadsAdminView
+
+
class PmDashboardView(BaseView):
"""Interactive administration and project management dashboard view for /pm menu."""
@@ -430,7 +556,7 @@ def _rebuild_items(self) -> None:
if self.is_server_admin:
self.lead_roles_btn = discord.ui.Button(
- label="Lead Roles",
+ label="Team Leads",
style=discord.ButtonStyle.secondary,
row=0,
)
@@ -515,7 +641,7 @@ async def _on_lead_roles_clicked(self, interaction: discord.Interaction) -> None
await interaction.response.send_message("❌ Must be run in a Discord server.", ephemeral=True)
return
- view = LeadRolesAdminView(
+ view = TeamLeadsAdminView(
project_service=self.project_service,
squad_service=self.squad_service,
task_service=self.task_service,
diff --git a/src/adapters/discord_bot/views/forum_helpers.py b/src/adapters/discord_bot/views/forum_helpers.py
index 80050f4..cd83cfc 100644
--- a/src/adapters/discord_bot/views/forum_helpers.py
+++ b/src/adapters/discord_bot/views/forum_helpers.py
@@ -417,11 +417,20 @@ async def ensure_pinned_hub_post(
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.adapters.db.postgres_repo import (
+ PostgresGuildLeadRoleRepository,
+ PostgresGuildLeadUserRepository,
+ )
from src.services.auth_service import AuthService
- lead_repo = PostgresGuildLeadRoleRepository(sf)
- auth_service = AuthService(project_service, squad_service, guild_lead_role_repo=lead_repo)
+ lead_role_repo = PostgresGuildLeadRoleRepository(sf)
+ lead_user_repo = PostgresGuildLeadUserRepository(sf)
+ auth_service = AuthService(
+ project_service,
+ squad_service,
+ guild_lead_role_repo=lead_role_repo,
+ guild_lead_user_repo=lead_user_repo,
+ )
except Exception as e:
logger.debug("Could not auto-create auth_service for hub: %s", e)
diff --git a/src/adapters/discord_bot/views/hub_menu.py b/src/adapters/discord_bot/views/hub_menu.py
index 89f3b6c..7fdfea6 100644
--- a/src/adapters/discord_bot/views/hub_menu.py
+++ b/src/adapters/discord_bot/views/hub_menu.py
@@ -310,17 +310,27 @@ def __init__(
if auth_service is not None:
self.auth_service = auth_service
else:
- lead_repo = None
+ lead_role_repo = None
+ lead_user_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
+ from src.adapters.db.postgres_repo import (
+ PostgresGuildLeadRoleRepository,
+ PostgresGuildLeadUserRepository,
+ )
- lead_repo = PostgresGuildLeadRoleRepository(sf)
+ lead_role_repo = PostgresGuildLeadRoleRepository(sf)
+ lead_user_repo = PostgresGuildLeadUserRepository(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)
+ logger.debug("Could not auto-create lead repos for hub: %s", e)
+ self.auth_service = AuthService(
+ project_service,
+ squad_service,
+ guild_lead_role_repo=lead_role_repo,
+ guild_lead_user_repo=lead_user_repo,
+ )
async def _refresh_hub_message(
self,
diff --git a/src/main.py b/src/main.py
index 134902a..6671349 100644
--- a/src/main.py
+++ b/src/main.py
@@ -15,6 +15,7 @@
from src.adapters.api.app import api_app # noqa: E402
from src.adapters.db.postgres_repo import ( # noqa: E402
PostgresGuildLeadRoleRepository,
+ PostgresGuildLeadUserRepository,
PostgresOutboxRepo,
PostgresProjectRepo,
PostgresSquadRepo,
@@ -56,6 +57,7 @@ async def run_app() -> None:
outbox_repo = PostgresOutboxRepo(async_session_factory)
user_pref_repo = PostgresUserPreferenceRepo(async_session_factory)
guild_lead_role_repo = PostgresGuildLeadRoleRepository(async_session_factory)
+ guild_lead_user_repo = PostgresGuildLeadUserRepository(async_session_factory)
uow = SqlAlchemyUnitOfWork(async_session_factory)
project_service = ProjectService(project_repo)
@@ -72,6 +74,7 @@ async def run_app() -> None:
user_service=user_service,
outbox_service=outbox_service,
guild_lead_role_repo=guild_lead_role_repo,
+ guild_lead_user_repo=guild_lead_user_repo,
)
notifier = DiscordNotifier(
bot,
diff --git a/src/ports/repositories.py b/src/ports/repositories.py
index 95a4faf..e5d9471 100644
--- a/src/ports/repositories.py
+++ b/src/ports/repositories.py
@@ -313,3 +313,17 @@ async def remove_lead_role(self, guild_id: int, discord_role_id: int) -> bool:
@abstractmethod
async def list_lead_role_ids(self, guild_id: int) -> set[int]:
"""Lists all authorized Team Lead Discord role IDs for the guild."""
+
+
+class IGuildLeadUserRepository(ABC):
+ @abstractmethod
+ async def add_lead_user(self, guild_id: int, user_discord_id: int) -> None:
+ """Registers a Discord user ID as an authorized Team Lead for the guild."""
+
+ @abstractmethod
+ async def remove_lead_user(self, guild_id: int, user_discord_id: int) -> bool:
+ """Removes an authorized Team Lead user from the guild. Returns True if removed, False if not found."""
+
+ @abstractmethod
+ async def list_lead_user_ids(self, guild_id: int) -> set[int]:
+ """Lists all authorized Team Lead Discord user IDs for the guild."""
diff --git a/src/ports/unit_of_work.py b/src/ports/unit_of_work.py
index ce7ea74..920e229 100644
--- a/src/ports/unit_of_work.py
+++ b/src/ports/unit_of_work.py
@@ -6,6 +6,7 @@
if TYPE_CHECKING:
from src.ports.repositories import (
IGuildLeadRoleRepository,
+ IGuildLeadUserRepository,
IOutboxRepo,
IProjectRepo,
ISquadRepo,
@@ -23,6 +24,7 @@ class IUnitOfWork(ABC):
outbox: IOutboxRepo
user_prefs: IUserPreferenceRepo
guild_lead_roles: IGuildLeadRoleRepository
+ guild_lead_users: IGuildLeadUserRepository
@abstractmethod
async def __aenter__(self) -> IUnitOfWork:
diff --git a/src/services/auth_service.py b/src/services/auth_service.py
index 37a3da5..6e77fad 100644
--- a/src/services/auth_service.py
+++ b/src/services/auth_service.py
@@ -13,7 +13,7 @@
from src.domain.models import Project, Task
if TYPE_CHECKING:
- from src.ports.repositories import IGuildLeadRoleRepository
+ from src.ports.repositories import IGuildLeadRoleRepository, IGuildLeadUserRepository
from src.services.project_service import ProjectService
from src.services.squad_service import SquadService
@@ -28,10 +28,12 @@ def __init__(
project_service: ProjectService,
squad_service: SquadService | None = None,
guild_lead_role_repo: IGuildLeadRoleRepository | None = None,
+ guild_lead_user_repo: IGuildLeadUserRepository | None = None,
):
self.project_service = project_service
self.squad_service = squad_service
self.guild_lead_role_repo = guild_lead_role_repo
+ self.guild_lead_user_repo = guild_lead_user_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."""
@@ -50,6 +52,23 @@ async def list_guild_lead_roles(self, guild_id: int) -> set[int]:
return await self.guild_lead_role_repo.list_lead_role_ids(guild_id)
return set()
+ async def add_guild_lead_user(self, guild_id: int, user_discord_id: int) -> None:
+ """Registers a Discord user as an authorized Team Lead for the guild."""
+ if self.guild_lead_user_repo:
+ await self.guild_lead_user_repo.add_lead_user(guild_id, user_discord_id)
+
+ async def remove_guild_lead_user(self, guild_id: int, user_discord_id: int) -> bool:
+ """Removes a Discord user from authorized Team Leads for the guild."""
+ if self.guild_lead_user_repo:
+ return await self.guild_lead_user_repo.remove_lead_user(guild_id, user_discord_id)
+ return False
+
+ async def list_guild_lead_users(self, guild_id: int) -> set[int]:
+ """Returns all authorized Team Lead user IDs for the guild."""
+ if self.guild_lead_user_repo:
+ return await self.guild_lead_user_repo.list_lead_user_ids(guild_id)
+ return set()
+
async def can_manage_projects(
self,
user: discord.Member | discord.User,
@@ -58,8 +77,9 @@ async def can_manage_projects(
) -> 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.
+ 2. Authorized Individual Team Lead members (guild_lead_users).
+ 3. Configured Team Lead roles (guild_lead_roles).
+ 4. Active Squad Leads in the guild.
"""
if self.is_server_manager(user):
return True
@@ -68,6 +88,13 @@ async def can_manage_projects(
if not target_guild_id:
return False
+ # 2. Check configured individual Team Lead members
+ user_id = getattr(user, "id", None)
+ if self.guild_lead_user_repo and user_id:
+ lead_user_ids = await self.guild_lead_user_repo.list_lead_user_ids(target_guild_id)
+ if user_id in lead_user_ids:
+ return True
+
# 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):
@@ -85,7 +112,7 @@ async def can_manage_projects(
except Exception:
pass
- # 2. Check configured Team Lead roles
+ # 3. 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:
@@ -93,7 +120,7 @@ async def can_manage_projects(
if any(rid in user_role_ids for rid in lead_role_ids):
return True
- # 3. Check if active Squad Lead in this guild
+ # 4. 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:
@@ -112,7 +139,7 @@ async def require_project_management(
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."
+ "You must be a Discord Server Manager, an authorized Team Lead, or hold an authorized Team Lead role."
)
@staticmethod
diff --git a/tests/conftest.py b/tests/conftest.py
index e3d17b1..aeb11ee 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -67,7 +67,10 @@ 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
+ from src.adapters.db.postgres_repo import (
+ PostgresGuildLeadRoleRepository,
+ PostgresGuildLeadUserRepository,
+ )
task_repo = PostgresTaskRepo(db_session)
project_repo = PostgresProjectRepo(db_session)
@@ -75,6 +78,7 @@ async def repos(db_session: AsyncSession):
outbox_repo = PostgresOutboxRepo(db_session)
user_repo = PostgresUserPreferenceRepo(db_session)
guild_lead_role_repo = PostgresGuildLeadRoleRepository(db_session)
+ guild_lead_user_repo = PostgresGuildLeadUserRepository(db_session)
return {
"task": task_repo,
"project": project_repo,
@@ -82,6 +86,7 @@ async def repos(db_session: AsyncSession):
"outbox": outbox_repo,
"user": user_repo,
"guild_lead_role": guild_lead_role_repo,
+ "guild_lead_user": guild_lead_user_repo,
}
diff --git a/tests/test_authorization.py b/tests/test_authorization.py
index 6b0959b..5568c05 100644
--- a/tests/test_authorization.py
+++ b/tests/test_authorization.py
@@ -599,6 +599,41 @@ async def test_on_member_remove_auto_prunes_squad_lead(services):
assert await squad_srv.is_squad_lead(squad2.id, user_id) is False
+@pytest.mark.asyncio
+async def test_on_member_remove_auto_prunes_individual_lead(services, repos):
+ """When an authorized individual lead leaves the server, their lead record is pruned."""
+ proj_srv = services["project"]
+ squad_srv = services["squad"]
+ task_srv = services["task"]
+ lead_user_repo = repos["guild_lead_user"]
+ guild_id = 9990014
+ user_id = 9004
+
+ auth_srv = AuthService(
+ proj_srv,
+ squad_srv,
+ guild_lead_user_repo=lead_user_repo,
+ )
+ bot = DggPmBot(
+ task_service=task_srv,
+ project_service=proj_srv,
+ squad_service=squad_srv,
+ auth_service=auth_srv,
+ )
+
+ await auth_srv.add_guild_lead_user(guild_id, user_id)
+ assert await auth_srv.list_guild_lead_users(guild_id) == {user_id}
+
+ mock_guild = MagicMock()
+ mock_guild.id = guild_id
+ leaving_member = _make_mock_member(user_id)
+ leaving_member.guild = mock_guild
+
+ await bot.on_member_remove(leaving_member)
+
+ assert await auth_srv.list_guild_lead_users(guild_id) == set()
+
+
@pytest.mark.asyncio
async def test_project_lead_authorization(services):
"""Verify Project Leads have elevated permissions to create, assign, and mutate tasks in their projects."""
@@ -1422,6 +1457,46 @@ async def test_auth_service_can_manage_projects_lifecycle(services, repos):
assert await auth_srv.can_manage_projects(team_lead_member, guild_id) is False
+@pytest.mark.asyncio
+async def test_auth_service_individual_lead_user_lifecycle(services, repos):
+ """Verify AuthService.can_manage_projects allows designated individual Team Lead members."""
+ proj_srv = services["project"]
+ squad_srv = services["squad"]
+ lead_role_repo = repos["guild_lead_role"]
+ lead_user_repo = repos["guild_lead_user"]
+ auth_srv = AuthService(
+ proj_srv,
+ squad_srv,
+ guild_lead_role_repo=lead_role_repo,
+ guild_lead_user_repo=lead_user_repo,
+ )
+
+ guild_id = 888777666
+ individual_lead_member = _make_mock_member(5501, role_ids=[123], manage_guild=False)
+ regular_member = _make_mock_member(5502, role_ids=[123], manage_guild=False)
+
+ # 1. Initially, individual member is not authorized
+ assert await auth_srv.can_manage_projects(individual_lead_member, guild_id) is False
+ with pytest.raises(PermissionDeniedError, match="do not have permission"):
+ await auth_srv.require_project_management(individual_lead_member, guild_id)
+
+ # 2. Add individual lead user
+ await auth_srv.add_guild_lead_user(guild_id, individual_lead_member.id)
+ assert await auth_srv.list_guild_lead_users(guild_id) == {individual_lead_member.id}
+
+ # Now individual lead is authorized!
+ assert await auth_srv.can_manage_projects(individual_lead_member, guild_id) is True
+ await auth_srv.require_project_management(individual_lead_member, guild_id)
+
+ # Regular member is still denied
+ assert await auth_srv.can_manage_projects(regular_member, guild_id) is False
+
+ # 3. Remove individual lead user
+ assert await auth_srv.remove_guild_lead_user(guild_id, individual_lead_member.id) is True
+ assert await auth_srv.list_guild_lead_users(guild_id) == set()
+ assert await auth_srv.can_manage_projects(individual_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."""
@@ -1506,8 +1581,8 @@ async def test_can_manage_projects_resolves_member_when_user_is_discord_user(ser
@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."""
+async def test_pm_hub_view_auto_wires_lead_role_repo_and_has_create_project_button(services, repos, db_session):
+ """Verify PmHubView auto-wires lead repos 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
@@ -1515,27 +1590,33 @@ async def test_pm_hub_view_auto_wires_lead_role_repo_and_has_create_project_butt
squad_srv = services["squad"]
task_srv = services["task"]
lead_role_repo = repos["guild_lead_role"]
+ lead_user_repo = repos["guild_lead_user"]
guild_id = 777111
lead_role_id = 888222
+ lead_user_id = 6003
await lead_role_repo.add_lead_role(guild_id, lead_role_id)
+ await lead_user_repo.add_lead_user(guild_id, lead_user_id)
+ await db_session.commit()
- lead_member = _make_mock_member(6001, role_ids=[lead_role_id], manage_guild=False)
+ lead_role_member = _make_mock_member(6001, role_ids=[lead_role_id], manage_guild=False)
reg_member = _make_mock_member(6002, role_ids=[], manage_guild=False)
+ lead_user_member = _make_mock_member(lead_user_id, 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
+ assert hub_view.auth_service.guild_lead_user_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
+ # 1. Lead role 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.guild.get_member.return_value = lead_role_member
+ inter.user = lead_role_member
inter.response = MagicMock()
inter.response.send_message = AsyncMock()
@@ -1544,7 +1625,17 @@ async def test_pm_hub_view_auto_wires_lead_role_repo_and_has_create_project_butt
kwargs = inter.response.send_message.call_args.kwargs
assert isinstance(kwargs["view"], ProjectChannelSelectView)
- # 2. Regular member clicks Create Project button -> permission error
+ # 2. Individual lead user clicks Create Project button -> allowed
+ inter.response.send_message.reset_mock()
+ inter.user = lead_user_member
+ inter.guild.get_member.return_value = lead_user_member
+
+ 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)
+
+ # 3. 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
diff --git a/tests/test_cogs.py b/tests/test_cogs.py
index 5e057db..361fd2b 100644
--- a/tests/test_cogs.py
+++ b/tests/test_cogs.py
@@ -2032,6 +2032,108 @@ async def test_admin_lead_role_execution(services, repos):
assert "Please specify a Discord role" in interaction.followup.send.call_args[0][0]
+@pytest.mark.asyncio
+async def test_admin_lead_command_metadata():
+ """Verify that PmCog defines admin_group with unified 'lead' command and 'lead-role' alias."""
+ 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"), None)
+ assert cmd is not None
+ params = [p.name for p in cmd.parameters]
+ assert "action" in params
+ assert "role" in params
+ assert "user" in params
+
+ # Legacy alias still exists
+ legacy_cmd = next((c for c in PmCog.admin_group.commands if c.name == "lead-role"), None)
+ assert legacy_cmd is not None
+
+
+@pytest.mark.asyncio
+async def test_admin_lead_execution(services, repos):
+ """Verify /pm admin lead can add, list, and remove authorized Team Lead roles and users."""
+ 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_role_repo = repos["guild_lead_role"]
+ lead_user_repo = repos["guild_lead_user"]
+ auth_srv = AuthService(
+ proj_srv,
+ squad_srv,
+ guild_lead_role_repo=lead_role_repo,
+ guild_lead_user_repo=lead_user_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 = 8877665544
+ interaction = MagicMock(spec=discord.Interaction)
+ interaction.guild = MagicMock(id=guild_id)
+ interaction.guild.get_role.return_value = None
+ interaction.guild.get_member.return_value = None
+ interaction.response = MagicMock()
+ interaction.response.defer = AsyncMock()
+ interaction.followup = MagicMock()
+ interaction.followup.send = AsyncMock()
+
+ mock_role = MagicMock(spec=discord.Role, id=223344, name="Lead Role")
+ mock_user = MagicMock(spec=discord.Member, id=998877, name="LeadUser", bot=False)
+ mock_bot_user = MagicMock(spec=discord.Member, id=998899, name="BotUser", bot=True)
+
+ # 1. Add validation error: neither role nor user provided
+ await cog.admin_lead.callback(cog, interaction, action="add", role=None, user=None)
+ interaction.followup.send.assert_awaited()
+ assert "specify a role, a member, or both" in interaction.followup.send.call_args[0][0]
+
+ # 2. Reject bot user
+ interaction.followup.send.reset_mock()
+ await cog.admin_lead.callback(cog, interaction, action="add", role=None, user=mock_bot_user)
+ assert "Bots cannot be authorized" in interaction.followup.send.call_args[0][0]
+
+ # 3. Add individual lead user
+ interaction.followup.send.reset_mock()
+ await cog.admin_lead.callback(cog, interaction, action="add", role=None, user=mock_user)
+ assert "Added Team Lead" in interaction.followup.send.call_args[0][0]
+ assert await auth_srv.list_guild_lead_users(guild_id) == {998877}
+
+ # 4. Add role as well
+ interaction.followup.send.reset_mock()
+ await cog.admin_lead.callback(cog, interaction, action="add", role=mock_role, user=None)
+ assert "Added Team Lead" in interaction.followup.send.call_args[0][0]
+ assert await auth_srv.list_guild_lead_roles(guild_id) == {223344}
+
+ # 5. List leads (both role and user should appear)
+ interaction.followup.send.reset_mock()
+ await cog.admin_lead.callback(cog, interaction, action="list")
+ embed = interaction.followup.send.call_args[1].get("embed")
+ assert embed is not None
+ field_names = [f.name for f in embed.fields]
+ assert any("Roles" in name for name in field_names)
+ assert any("Members" in name for name in field_names)
+
+ # 6. Remove user
+ interaction.followup.send.reset_mock()
+ await cog.admin_lead.callback(cog, interaction, action="remove", role=None, user=mock_user)
+ assert "Removed Team Lead" in interaction.followup.send.call_args[0][0]
+ assert await auth_srv.list_guild_lead_users(guild_id) == set()
+
+ # 7. Remove role
+ interaction.followup.send.reset_mock()
+ await cog.admin_lead.callback(cog, interaction, action="remove", role=mock_role, user=None)
+ assert "Removed Team Lead" in interaction.followup.send.call_args[0][0]
+ assert await auth_srv.list_guild_lead_roles(guild_id) == set()
+
+
@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."""
diff --git a/tests/test_menus.py b/tests/test_menus.py
index bdce608..e713532 100644
--- a/tests/test_menus.py
+++ b/tests/test_menus.py
@@ -1543,7 +1543,7 @@ async def test_lead_roles_admin_menu_lifecycle(services, repos):
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"
+ assert admin_dash.lead_roles_btn.label in ("Team Leads", "Lead Roles")
# Regular user dashboard view hides Lead Roles button
reg_dash = PmDashboardView(
@@ -1569,7 +1569,7 @@ async def test_lead_roles_admin_menu_lifecycle(services, repos):
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
+ assert "Authorized Team Lead" in lead_embed.title
# 3. Assign role via LeadRolesAdminView
mock_role = MagicMock(spec=discord.Role)
@@ -1612,6 +1612,96 @@ async def test_lead_roles_admin_menu_lifecycle(services, repos):
assert isinstance(back_view, PmDashboardView)
+@pytest.mark.asyncio
+async def test_team_leads_admin_view_lifecycle(services, repos):
+ """Verify TeamLeadsAdminView allows managing both roles and individual users via RoleSelect and UserSelect."""
+ from src.adapters.discord_bot.views.admin_menu import (
+ PmDashboardView,
+ TeamLeadsAdminView,
+ )
+ 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"]
+ lead_user_repo = repos["guild_lead_user"]
+ auth_srv = AuthService(
+ proj_srv,
+ squad_srv,
+ guild_lead_role_repo=lead_role_repo,
+ guild_lead_user_repo=lead_user_repo,
+ )
+
+ guild_id = 987654321
+ 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)
+
+ # 1. PmDashboardView shows Team Leads button
+ 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 dash.lead_roles_btn is not None
+ assert dash.lead_roles_btn.label == "Team Leads"
+
+ # 2. TeamLeadsAdminView has both RoleSelect and UserSelect
+ team_view = TeamLeadsAdminView(
+ project_service=proj_srv,
+ squad_service=squad_srv,
+ task_service=task_srv,
+ user_service=user_srv,
+ auth_service=auth_srv,
+ )
+ assert hasattr(team_view, "role_select")
+ assert hasattr(team_view, "user_select")
+ assert hasattr(team_view, "assign_user_btn")
+ assert hasattr(team_view, "remove_user_btn")
+
+ # 3. Assign an individual user
+ mock_target_user = MagicMock(spec=discord.Member)
+ mock_target_user.id = 770011
+ mock_target_user.display_name = "Alice"
+ mock_guild.get_member.return_value = mock_target_user
+
+ team_view.user_select._values = [str(mock_target_user.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 team_view._on_assign_user_clicked(assign_inter)
+ assert await auth_srv.list_guild_lead_users(guild_id) == {mock_target_user.id}
+
+ # 4. Verify embed renders both roles and users
+ embed = await team_view.build_embed(mock_guild)
+ assert "Authorized Team Leads" in embed.title
+ field_names = [f.name for f in embed.fields]
+ assert any("Roles" in name for name in field_names)
+ assert any("Members" in name for name in field_names)
+
+ # 5. Remove individual user
+ 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 team_view._on_remove_user_clicked(remove_inter)
+ assert await auth_srv.list_guild_lead_users(guild_id) == set()
+
+
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 075fec4..6407e4a 100644
--- a/tests/test_repos.py
+++ b/tests/test_repos.py
@@ -189,3 +189,36 @@ async def test_guild_lead_role_repo_lifecycle(db_session):
# 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}
+
+
+@pytest.mark.asyncio
+async def test_guild_lead_user_repo_lifecycle(db_session):
+ """Verify IGuildLeadUserRepository can add, list, and remove authorized lead user IDs."""
+ from src.adapters.db.postgres_repo import PostgresGuildLeadUserRepository
+
+ repo = PostgresGuildLeadUserRepository(db_session)
+ guild_id = 9876543210
+
+ # Initially empty
+ assert await repo.list_lead_user_ids(guild_id) == set()
+
+ # Add users
+ await repo.add_lead_user(guild_id, 100001)
+ await repo.add_lead_user(guild_id, 100002)
+
+ # Adding duplicate is idempotent
+ await repo.add_lead_user(guild_id, 100001)
+
+ lead_users = await repo.list_lead_user_ids(guild_id)
+ assert lead_users == {100001, 100002}
+
+ # Different guild is isolated
+ assert await repo.list_lead_user_ids(555555) == set()
+
+ # Remove existing user
+ assert await repo.remove_lead_user(guild_id, 100001) is True
+ assert await repo.list_lead_user_ids(guild_id) == {100002}
+
+ # Remove non-existent user
+ assert await repo.remove_lead_user(guild_id, 999999) is False
+ assert await repo.list_lead_user_ids(guild_id) == {100002}