From 9ba8876bc72b4bf406e2a1a42de88d1a7244ff61 Mon Sep 17 00:00:00 2001 From: espdesign Date: Thu, 17 Sep 2026 07:41:39 -0400 Subject: [PATCH 1/5] fix(discord-bot): handle app command check and permission errors gracefully - Add translation for discord.app_commands errors (MissingPermissions, BotMissingPermissions, CommandOnCooldown, CheckFailure) - Add cog_app_command_error handler in PmCog to return ephemeral error responses to users - Register fallback on_tree_error handler on DggPmBot.tree - Add tests covering app command error translation, PmCog error handling, and bot tree fallback --- src/adapters/discord_bot/bot.py | 16 ++++ src/adapters/discord_bot/cogs/pm_cog.py | 13 +++ src/adapters/discord_bot/error_handler.py | 15 ++++ tests/test_error_handling.py | 97 +++++++++++++++++++++++ 4 files changed, 141 insertions(+) diff --git a/src/adapters/discord_bot/bot.py b/src/adapters/discord_bot/bot.py index f41e87d..b9b2b09 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 @@ -84,6 +85,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..99ffbae 100644 --- a/src/adapters/discord_bot/cogs/pm_cog.py +++ b/src/adapters/discord_bot/cogs/pm_cog.py @@ -1980,3 +1980,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/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 From 51ec834570e3e77e15b2333c821d35f25ac6908e Mon Sep 17 00:00:00 2001 From: espdesign Date: Thu, 17 Sep 2026 18:13:37 -0400 Subject: [PATCH 2/5] feat(auth): allow designated team lead roles to create and manage projects and squads --- .../versions/0002_add_guild_lead_roles.py | 34 +++ src/adapters/db/postgres_repo.py | 39 ++++ src/adapters/db/tables.py | 8 + src/adapters/db/unit_of_work.py | 4 + src/adapters/discord_bot/bot.py | 15 +- src/adapters/discord_bot/cogs/pm_cog.py | 197 +++++++++++++----- src/domain/models.py | 6 + src/main.py | 3 + src/ports/repositories.py | 14 ++ src/ports/unit_of_work.py | 2 + src/services/auth_service.py | 59 ++++++ tests/conftest.py | 4 + tests/test_authorization.py | 49 +++++ tests/test_cogs.py | 178 ++++++++++++++++ tests/test_repos.py | 33 +++ 15 files changed, 588 insertions(+), 57 deletions(-) create mode 100644 src/adapters/db/migrations/versions/0002_add_guild_lead_roles.py 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..65943b2 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,7 @@ 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] async def __aenter__(self) -> SqlAlchemyUnitOfWork: self._session = self._session_factory() @@ -43,6 +46,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 b9b2b09..112265b 100644 --- a/src/adapters/discord_bot/bot.py +++ b/src/adapters/discord_bot/bot.py @@ -17,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 @@ -37,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 @@ -55,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: diff --git a/src/adapters/discord_bot/cogs/pm_cog.py b/src/adapters/discord_bot/cogs/pm_cog.py index 99ffbae..9504b17 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) @@ -329,7 +329,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 +347,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 +487,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 +1320,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 +1341,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 +1466,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 +1515,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 +1574,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 +1585,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 +1701,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 +1712,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 +1745,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 +1756,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 +1794,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 +1804,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 +1867,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 +1877,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, 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..27d5792 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,67 @@ 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) -> 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 + + if not guild_id or not hasattr(user, "roles"): + return False + + # 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(guild_id) + if lead_role_ids: + user_role_ids = {r.id for r in getattr(user, "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(user, "id", None): + squads = await self.squad_service.list_squads(guild_id) + for s in squads: + if await self.squad_service.is_squad_lead(s.id, user.id): + return True + + return False + + async def require_project_management( + self, user: discord.Member | discord.User, guild_id: int | 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): + 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..4ef8c08 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -1367,3 +1367,52 @@ 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 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_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} From 33ee1daf03bc6ce4b69421ab2cd7f5c0b26dc851 Mon Sep 17 00:00:00 2001 From: espdesign Date: Thu, 17 Sep 2026 18:35:44 -0400 Subject: [PATCH 3/5] feat(ui): add lead roles admin menu and show project creation for team lead roles --- src/adapters/discord_bot/cogs/pm_cog.py | 1 + src/adapters/discord_bot/views/admin_menu.py | 231 ++++++++++++++++++ src/adapters/discord_bot/views/hub_menu.py | 7 + .../discord_bot/views/project_menu.py | 36 +++ tests/test_authorization.py | 54 ++++ tests/test_menus.py | 112 +++++++++ 6 files changed, 441 insertions(+) diff --git a/src/adapters/discord_bot/cogs/pm_cog.py b/src/adapters/discord_bot/cogs/pm_cog.py index 9504b17..162f50f 100644 --- a/src/adapters/discord_bot/cogs/pm_cog.py +++ b/src/adapters/discord_bot/cogs/pm_cog.py @@ -192,6 +192,7 @@ async def menu(self, interaction: discord.Interaction) -> None: squad_service=self.squad_service, task_service=self.task_service, user_service=self.user_service, + auth_service=self.auth_service, initial_interaction=interaction, ) embed = build_pm_dashboard_embed( diff --git a/src/adapters/discord_bot/views/admin_menu.py b/src/adapters/discord_bot/views/admin_menu.py index ee43c84..324a0d4 100644 --- a/src/adapters/discord_bot/views/admin_menu.py +++ b/src/adapters/discord_bot/views/admin_menu.py @@ -85,6 +85,7 @@ def build_pm_dashboard_embed( value=( "• **`New Project`**: Launch the multi-step project creation wizard.\n" "• **`Projects`**: Manage channels, assign squad roles, set leads, archive.\n" + "• **`Lead Roles`**: Assign or view authorized Team Lead Discord roles.\n" "• **`Server Overview`**: Server-wide project status & completion metrics.\n" "• **`Settings`**: Configure personal notification preferences." ), @@ -160,6 +161,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 +372,14 @@ def __init__( initial_interaction: discord.Interaction | None = None, user: discord.Member | discord.User | None = None, is_server_manager: 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) @@ -203,6 +405,17 @@ def _rebuild_items(self) -> None: self.new_proj_btn.callback = self._on_new_project_clicked self.add_item(self.new_proj_btn) + 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.new_proj_btn = None + self.lead_roles_btn = None + self.projects_btn = discord.ui.Button( label="Projects", style=discord.ButtonStyle.secondary, @@ -274,6 +487,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 +511,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/hub_menu.py b/src/adapters/discord_bot/views/hub_menu.py index 0031c3a..74b6c22 100644 --- a/src/adapters/discord_bot/views/hub_menu.py +++ b/src/adapters/discord_bot/views/hub_menu.py @@ -587,11 +587,18 @@ 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, interaction.guild.id) + 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) diff --git a/src/adapters/discord_bot/views/project_menu.py b/src/adapters/discord_bot/views/project_menu.py index 032151c..2c83308 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) diff --git a/tests/test_authorization.py b/tests/test_authorization.py index 4ef8c08..e9aba76 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -1416,3 +1416,57 @@ async def test_auth_service_can_manage_projects_lifecycle(services, repos): 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 "New 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 "New Project" not in reg_embed.description diff --git a/tests/test_menus.py b/tests/test_menus.py index 38fd36d..6399ed2 100644 --- a/tests/test_menus.py +++ b/tests/test_menus.py @@ -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 From 60aa7772fb42035c35b2fa03250cc487cb153f15 Mon Sep 17 00:00:00 2001 From: espdesign Date: Thu, 17 Sep 2026 18:53:23 -0400 Subject: [PATCH 4/5] fix(ui): show create project button for team lead roles in hub and dashboard --- src/adapters/db/unit_of_work.py | 4 + src/adapters/discord_bot/cogs/pm_cog.py | 16 +- src/adapters/discord_bot/views/admin_menu.py | 43 +++-- .../discord_bot/views/forum_helpers.py | 15 ++ src/adapters/discord_bot/views/hub_menu.py | 74 ++++++++- .../discord_bot/views/project_menu.py | 12 +- src/services/auth_service.py | 44 ++++- tests/test_authorization.py | 154 +++++++++++++++++- tests/test_menus.py | 2 +- 9 files changed, 335 insertions(+), 29 deletions(-) diff --git a/src/adapters/db/unit_of_work.py b/src/adapters/db/unit_of_work.py index 65943b2..4aa74cf 100644 --- a/src/adapters/db/unit_of_work.py +++ b/src/adapters/db/unit_of_work.py @@ -39,6 +39,10 @@ def __init__(self, session_factory: async_sessionmaker[AsyncSession]): 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() self.tasks = PostgresTaskRepo(self._session) diff --git a/src/adapters/discord_bot/cogs/pm_cog.py b/src/adapters/discord_bot/cogs/pm_cog.py index 162f50f..25f764b 100644 --- a/src/adapters/discord_bot/cogs/pm_cog.py +++ b/src/adapters/discord_bot/cogs/pm_cog.py @@ -187,6 +187,17 @@ 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, @@ -194,6 +205,8 @@ async def menu(self, interaction: discord.Interaction) -> None: 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, @@ -201,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 diff --git a/src/adapters/discord_bot/views/admin_menu.py b/src/adapters/discord_bot/views/admin_menu.py index 324a0d4..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,15 +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" - "• **`Lead Roles`**: Assign or view authorized Team Lead Discord roles.\n" - "• **`Server Overview`**: Server-wide project status & completion metrics.\n" - "• **`Settings`**: Configure personal notification preferences." - ), + value="\n".join(actions), inline=False, ) else: @@ -372,6 +385,7 @@ 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) @@ -390,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: @@ -398,13 +419,16 @@ 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, @@ -413,7 +437,6 @@ def _rebuild_items(self) -> None: self.lead_roles_btn.callback = self._on_lead_roles_clicked self.add_item(self.lead_roles_btn) else: - self.new_proj_btn = None self.lead_roles_btn = None self.projects_btn = discord.ui.Button( 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 74b6c22..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, @@ -588,7 +601,11 @@ 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, interaction.guild.id) + 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 ) @@ -603,6 +620,59 @@ async def projects_tab(self, interaction: discord.Interaction, button: discord.u 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 2c83308..9a8ca7d 100644 --- a/src/adapters/discord_bot/views/project_menu.py +++ b/src/adapters/discord_bot/views/project_menu.py @@ -2581,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, ) @@ -2771,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) @@ -2969,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/services/auth_service.py b/src/services/auth_service.py index 27d5792..37a3da5 100644 --- a/src/services/auth_service.py +++ b/src/services/auth_service.py @@ -50,7 +50,12 @@ 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 can_manage_projects(self, user: discord.Member | discord.User, guild_id: int | None = None) -> bool: + 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. @@ -59,31 +64,52 @@ async def can_manage_projects(self, user: discord.Member | discord.User, guild_i if self.is_server_manager(user): return True - if not guild_id or not hasattr(user, "roles"): + 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(guild_id) + 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(user, "roles", []) if hasattr(r, "id")} + 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(user, "id", None): - squads = await self.squad_service.list_squads(guild_id) + 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, user.id): + 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 + 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): + 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." diff --git a/tests/test_authorization.py b/tests/test_authorization.py index e9aba76..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 @@ -1454,7 +1455,7 @@ async def test_pm_hub_projects_tab_authorizes_lead_role_to_create_projects(servi assert view.is_server_manager is True assert view.new_project_btn is not None - assert "New Project" in embed.description + assert "Create Project" in embed.description # 2. Regular member clicks Projects Hub interaction.response.send_message.reset_mock() @@ -1469,4 +1470,151 @@ async def test_pm_hub_projects_tab_authorizes_lead_role_to_create_projects(servi assert reg_view.is_server_manager is False assert reg_view.new_project_btn is None - assert "New Project" not in reg_embed.description + 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_menus.py b/tests/test_menus.py index 6399ed2..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() From 55de5393663dfa58d9b5fe2334ecd7a725803d9b Mon Sep 17 00:00:00 2001 From: espdesign Date: Thu, 17 Sep 2026 18:59:04 -0400 Subject: [PATCH 5/5] docs(wiki): update wiki documentation with team lead roles and hub controls --- docs/wiki/Forum-Channels-and-Hubs.md | 15 ++++--- docs/wiki/Home.md | 5 ++- docs/wiki/Slash-Commands-Reference.md | 22 ++++++----- docs/wiki/Teams-and-Authorization.md | 57 ++++++++++++++++++++++----- docs/wiki/Workflow-Guide.md | 31 ++++++++++++--- 5 files changed, 98 insertions(+), 32 deletions(-) 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