Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions docs/wiki/Slash-Commands-Reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,24 +37,24 @@ All DGG-PM bot commands are grouped under the single `/pm` top-level namespace t

| Subcommand | Parameters | Description | Permission Required |
| :--- | :--- | :--- | :--- |
| **`create`** | `name` (required)<br>`prefix` (required)<br>`role` (required: `@Role`)<br>`channel` (optional)<br>`description` (optional)<br>`category` (optional) | Creates a project container, maps the squad Discord role, and automatically provisions standard tags + pinned Control Hub if a forum channel is linked. | `Manage Server` OR Authorized Team Lead Role |
| **`create`** | `name` (required)<br>`prefix` (required)<br>`role` (required: `@Role`)<br>`channel` (optional)<br>`description` (optional)<br>`category` (optional) | Creates a project container, maps the squad Discord role, and automatically provisions standard tags + pinned Control Hub if a forum channel is linked. | `Manage Server` OR Authorized Team Lead (Role/Member) |
| **`tree`** | `project_name` (required)<br>`orientation` (optional: `lr`, `tb`) | Renders the interactive visual dependency graph for the project. | `@everyone` |
| **`role`** | `project_name` (required)<br>`role` (required: `@Role`)<br>`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)<br>`squad_name` (required)<br>`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)<br>`user` (required: `@Member`)<br>`action` (required: `add`, `remove`) | Designates or removes a Squad Lead for the project's squads. | `Manage Server` OR Authorized Team Lead Role OR Active Squad Lead |
| **`role`** | `project_name` (required)<br>`role` (required: `@Role`)<br>`action` (required: `add`, `remove`) | Maps or unmaps additional Discord squad roles to a project container (for cross-functional squads). | `Manage Server` OR Authorized Team Lead (Role/Member) |
| **`squad`** | `project_name` (required)<br>`squad_name` (required)<br>`action` (required: `add`, `remove`) | Maps or unmaps a functional squad to a project container (alias: `team`). | `Manage Server` OR Authorized Team Lead (Role/Member) |
| **`lead`** | `project_name` (required)<br>`user` (required: `@Member`)<br>`action` (required: `add`, `remove`) | Designates or removes a Squad Lead for the project's squads. | `Manage Server` OR Authorized Team Lead (Role/Member) OR Active Squad Lead |
| **`list`** | None | Lists all active project containers and their bound Discord channels. | `@everyone` |
| **`archive`** | `project_name` (required) | Archives a project container and cascades thread archiving. | `Manage Server` OR Authorized Team Lead Role |
| **`unarchive`** | `project_name` (required) | Restores an archived project container and reopens task threads. | `Manage Server` OR Authorized Team Lead Role |
| **`setup_forum`** | `forum` (required) | Automatically configures standard PM tags on a Discord Forum Channel. | `Manage Server` OR Authorized Team Lead Role |
| **`rebuild`** | `project_name` (required)<br>`forum` (optional: `#Channel`) | Reconstructs and reconciles a project's Discord presence (forum channel, tags, control hub, and task thread workspaces) from database state. | `Manage Server` OR Authorized Team Lead Role |
| **`archive`** | `project_name` (required) | Archives a project container and cascades thread archiving. | `Manage Server` OR Authorized Team Lead (Role/Member) |
| **`unarchive`** | `project_name` (required) | Restores an archived project container and reopens task threads. | `Manage Server` OR Authorized Team Lead (Role/Member) |
| **`setup_forum`** | `forum` (required) | Automatically configures standard PM tags on a Discord Forum Channel. | `Manage Server` OR Authorized Team Lead (Role/Member) |
| **`rebuild`** | `project_name` (required)<br>`forum` (optional: `#Channel`) | Reconstructs and reconciles a project's Discord presence (forum channel, tags, control hub, and task thread workspaces) from database state. | `Manage Server` OR Authorized Team Lead (Role/Member) |

---

## 🛡️ Admin Commands (`/pm admin <command>`)

| Subcommand | Parameters | Description | Permission Required |
| :--- | :--- | :--- | :--- |
| **`lead-role`** | `action` (required: `add`, `remove`, `list`)<br>`role` (optional: `@Role`) | Registers, removes, or lists Discord server roles authorized as Team Leads for project and squad management. | `Manage Server` |
| **`lead`** | `action` (required: `add`, `remove`, `list`)<br>`role` (optional: `@Role`)<br>`user` (optional: `@Member`) | Registers, removes, or lists Discord server roles or individual members authorized as Team Leads for project and squad management. (Alias: `lead-role`). | `Manage Server` |
| **`sync`** | `scope` (optional: `guild`, `global`) | Synchronizes application slash commands with Discord on demand without restarting the bot. | `Manage Server` |


49 changes: 30 additions & 19 deletions docs/wiki/Teams-and-Authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ DGG-PM implements a Discord-native permission model. Discord server roles act as
```
┌────────────────────────────────────────────────────────┐
│ Server Managers (Manage Server / Administrator) │
│ - Full server bypass; configure Team Lead roles │
│ - Full server bypass; configure Team Leads │
└──────────────────────────┬─────────────────────────────┘
│
┌──────────────────────────▼─────────────────────────────┐
│ Authorized Team Lead Roles & Active Squad Leads │
│ Authorized Team Leads (Roles or Members) & Squad Leads│
│ - Create and manage projects, channels, & squads │
│ - Granted via server role without Discord admin perms │
│ - Granted via server role or member without admin perms│
└──────────────────────────┬─────────────────────────────┘
│
┌──────────────────────────▼─────────────────────────────┐
Expand All @@ -38,9 +38,9 @@ DGG-PM implements a Discord-native permission model. Discord server roles act as

## 🛡️ Mutation Authorization Matrix

| Action | Server Manager | Authorized Team Lead Role | Squad Lead | Squad Member (Mapped Role) | Task Assignee | Task Creator | Other Server Member |
| Action | Server Manager | Authorized Team Lead (Role/Member) | Squad Lead | Squad Member (Mapped Role) | Task Assignee | Task Creator | Other Server Member |
| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
| **Configure Lead Roles** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Configure Team Leads** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Create Project / Squad** | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| **Manage Project (Role, Archive)** | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Designate / Remove Squad Lead** | ✅ | ✅ | ✅ (Own Squad) | ❌ | ❌ | ❌ | ❌ |
Expand All @@ -51,33 +51,44 @@ DGG-PM implements a Discord-native permission model. Discord server roles act as

---

## 🎖️ Configurable Team Lead Roles
## 🎖️ Configurable Team Leads (Roles & Individual Members)

To adhere to the principle of least privilege, DGG-PM allows server managers to authorize specific Discord server roles as **Team Lead roles**. Members holding these roles can create and manage project containers, map squads, bind channels, and designate leads **without requiring server-wide `Manage Server` or `Administrator` permissions**.
To adhere to the principle of least privilege, DGG-PM allows server managers to authorize specific Discord server roles **or individual server members** as **Team Leads**. Authorized Team Leads can create and manage project containers, map squads, bind channels, and designate leads **without requiring server-wide `Manage Server` or `Administrator` permissions**.

### 1. Interactive Admin Dashboard (`/pm menu`)
1. Open the project dashboard using `/pm menu`.
2. Members with `Manage Server` or `Administrator` permissions will see a **`Lead Roles`** button in the top action row.
3. Clicking **`Lead Roles`** opens the interactive role management portal:
- Displays all currently authorized Team Lead roles in an embed list.
2. Members with `Manage Server` or `Administrator` permissions will see a **`Team Leads`** button in the top action row.
3. Clicking **`Team Leads`** opens the unified management portal:
- Displays all currently authorized Team Lead roles and individual members in an embed list.
- Includes a native Discord **Role Select** dropdown to select any server role.
- Provides 1-click **`Assign Role`** and **`Remove Role`** actions.
- Includes a native Discord **User Select** dropdown to select any server member (bots excluded).
- Provides 1-click **`Assign Role`**, **`Remove Role`**, **`Assign User`**, and **`Remove User`** actions.
- Includes a **`Back to Dashboard`** button to return cleanly.

### 2. Slash Command Management (`/pm admin lead-role`)
Server Managers can also configure authorized roles via slash commands:
- **Add a Lead Role**:
### 2. Slash Command Management (`/pm admin lead`)
Server Managers can configure authorized roles and individual members via slash commands:
- **Add / Remove a Team Lead Role**:
```text
/pm admin lead-role action:add role:@Engineering Lead
/pm admin lead action:add role:@Engineering Lead
/pm admin lead action:remove role:@Engineering Lead
```
- **Remove a Lead Role**:
- **Add / Remove an Individual Team Lead Member**:
```text
/pm admin lead-role action:remove role:@Engineering Lead
/pm admin lead action:add user:@Alice
/pm admin lead action:remove user:@Alice
```
- **List All Lead Roles**:
- **List All Authorized Team Leads (Roles and Members)**:
```text
/pm admin lead-role action:list
/pm admin lead action:list
```
- *(Backwards compatibility)*: `/pm admin lead-role` is retained as an alias for `/pm admin lead`.

---

## 🔄 Self-Healing & Departure Lifecycle

1. **Individual Team Lead Departure**:
- When a member leaves the Discord server (`on_member_remove`), DGG-PM automatically prunes their entry from `guild_lead_users`, preventing stale authorizations if the user ever rejoins.

---

Expand Down
34 changes: 34 additions & 0 deletions src/adapters/db/migrations/versions/0003_add_guild_lead_users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""add_guild_lead_users

Revision ID: 0003
Revises: 0002
Create Date: 2026-09-24 02:17:00.000000

"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "0003"
down_revision: str | Sequence[str] | None = "0002"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Upgrade schema."""
op.create_table(
"guild_lead_users",
sa.Column("guild_id", sa.BigInteger(), nullable=False),
sa.Column("user_discord_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("guild_id", "user_discord_id"),
)


def downgrade() -> None:
"""Downgrade schema."""
op.drop_table("guild_lead_users")
39 changes: 39 additions & 0 deletions src/adapters/db/postgres_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from src.adapters.db.tables import (
GuildLeadRoleTable,
GuildLeadUserTable,
OutboxEventTable,
ProjectSquadTable,
ProjectTable,
Expand Down Expand Up @@ -44,6 +45,7 @@
)
from src.ports.repositories import (
IGuildLeadRoleRepository,
IGuildLeadUserRepository,
IOutboxRepo,
IProjectRepo,
ISquadRepo,
Expand Down Expand Up @@ -1320,3 +1322,40 @@ async def list_lead_role_ids(self, guild_id: int) -> set[int]:
stmt = select(GuildLeadRoleTable.discord_role_id).where(GuildLeadRoleTable.guild_id == guild_id)
res = await session.execute(stmt)
return set(res.scalars().all())


class PostgresGuildLeadUserRepository(BasePostgresRepo, IGuildLeadUserRepository):
async def add_lead_user(self, guild_id: int, user_discord_id: int) -> None:
async with self._get_session() as session:
stmt = select(GuildLeadUserTable).where(
GuildLeadUserTable.guild_id == guild_id,
GuildLeadUserTable.user_discord_id == user_discord_id,
)
res = await session.execute(stmt)
if res.scalar_one_or_none() is not None:
return
row = GuildLeadUserTable(guild_id=guild_id, user_discord_id=user_discord_id)
session.add(row)
if self._should_commit(None):
await session.commit()
else:
await session.flush()

async def remove_lead_user(self, guild_id: int, user_discord_id: int) -> bool:
async with self._get_session() as session:
stmt = delete(GuildLeadUserTable).where(
GuildLeadUserTable.guild_id == guild_id,
GuildLeadUserTable.user_discord_id == user_discord_id,
)
res = await session.execute(stmt)
if self._should_commit(None):
await session.commit()
else:
await session.flush()
return bool(res.rowcount and res.rowcount > 0)

async def list_lead_user_ids(self, guild_id: int) -> set[int]:
async with self._get_session() as session:
stmt = select(GuildLeadUserTable.user_discord_id).where(GuildLeadUserTable.guild_id == guild_id)
res = await session.execute(stmt)
return set(res.scalars().all())
8 changes: 8 additions & 0 deletions src/adapters/db/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,11 @@ class GuildLeadRoleTable(Base):
guild_id = Column(BigInteger, primary_key=True)
discord_role_id = Column(BigInteger, primary_key=True)
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))


class GuildLeadUserTable(Base):
__tablename__ = "guild_lead_users"

guild_id = Column(BigInteger, primary_key=True)
user_discord_id = Column(BigInteger, primary_key=True)
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
4 changes: 4 additions & 0 deletions src/adapters/db/unit_of_work.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from src.adapters.db.postgres_repo import (
PostgresGuildLeadRoleRepository,
PostgresGuildLeadUserRepository,
PostgresOutboxRepo,
PostgresProjectRepo,
PostgresSquadRepo,
Expand All @@ -15,6 +16,7 @@
)
from src.ports.repositories import (
IGuildLeadRoleRepository,
IGuildLeadUserRepository,
IOutboxRepo,
IProjectRepo,
ISquadRepo,
Expand All @@ -38,6 +40,7 @@ def __init__(self, session_factory: async_sessionmaker[AsyncSession]):
self.outbox: IOutboxRepo = None # type: ignore[assignment]
self.user_prefs: IUserPreferenceRepo = None # type: ignore[assignment]
self.guild_lead_roles: IGuildLeadRoleRepository = None # type: ignore[assignment]
self.guild_lead_users: IGuildLeadUserRepository = None # type: ignore[assignment]

@property
def session_factory(self) -> async_sessionmaker[AsyncSession]:
Expand All @@ -51,6 +54,7 @@ async def __aenter__(self) -> SqlAlchemyUnitOfWork:
self.outbox = PostgresOutboxRepo(self._session)
self.user_prefs = PostgresUserPreferenceRepo(self._session)
self.guild_lead_roles = PostgresGuildLeadRoleRepository(self._session)
self.guild_lead_users = PostgresGuildLeadUserRepository(self._session)
return self

async def __aexit__(
Expand Down
44 changes: 30 additions & 14 deletions src/adapters/discord_bot/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
)
from src.config import settings
from src.domain.models import Task
from src.ports.repositories import IGuildLeadRoleRepository
from src.ports.repositories import IGuildLeadRoleRepository, IGuildLeadUserRepository
from src.services.auth_service import AuthService
from src.services.outbox_service import OutboxService
from src.services.project_service import ProjectService
Expand All @@ -39,6 +39,7 @@ def __init__(
workspace: ITaskDiscordWorkspace | None = None,
project_workspace: IProjectDiscordWorkspace | None = None,
guild_lead_role_repo: IGuildLeadRoleRepository | None = None,
guild_lead_user_repo: IGuildLeadUserRepository | None = None,
auth_service: AuthService | None = None,
):
intents = discord.Intents.default()
Expand All @@ -59,12 +60,16 @@ def __init__(
getattr(task_service, "outbox_service", None) if task_service else None
)
self.guild_lead_role_repo = guild_lead_role_repo
self.guild_lead_user_repo = guild_lead_user_repo
self._background_tasks: set[asyncio.Task] = set()
if auth_service is not None:
self.auth_service = auth_service
elif project_service and self.squad_service:
self.auth_service = AuthService(
project_service, self.squad_service, guild_lead_role_repo=guild_lead_role_repo
project_service,
self.squad_service,
guild_lead_role_repo=guild_lead_role_repo,
guild_lead_user_repo=guild_lead_user_repo,
)
else:
self.auth_service = None
Expand Down Expand Up @@ -284,22 +289,33 @@ async def on_member_update(self, before: discord.Member, after: discord.Member)
logger.warning("Error auto-pruning squad lead on member update for user %s: %s", after.id, e)

async def on_member_remove(self, member: discord.Member) -> None:
"""Auto-prune squad lead records across all squads in the guild if a member leaves the server."""
if not self.squad_service:
return
"""Auto-prune squad lead and individual team lead records across the guild if a member leaves the server."""
if self.squad_service:
try:
squads = await self.squad_service.list_squads(member.guild.id)
for squad in squads:
if await self.squad_service.is_squad_lead(squad.id, member.id):
await self.squad_service.remove_squad_lead(squad.id, member.id)
logger.info(
"Auto-pruned squad lead record for user %s from squad '%s' because member left the server",
member.id,
squad.name,
)
except Exception as e:
logger.warning("Error auto-pruning squad leads on member remove for user %s: %s", member.id, e)

try:
squads = await self.squad_service.list_squads(member.guild.id)
for squad in squads:
if await self.squad_service.is_squad_lead(squad.id, member.id):
await self.squad_service.remove_squad_lead(squad.id, member.id)
if self.auth_service:
try:
removed = await self.auth_service.remove_guild_lead_user(member.guild.id, member.id)
if removed:
logger.info(
"Auto-pruned squad lead record for user %s from squad '%s' because member left the server",
"Auto-pruned individual team lead record for user %s from guild %s "
"because member left the server",
member.id,
squad.name,
member.guild.id,
)
except Exception as e:
logger.warning("Error auto-pruning squad leads on member remove for user %s: %s", member.id, e)
except Exception as e:
logger.warning("Error auto-pruning individual team lead on member remove for user %s: %s", member.id, e)

async def on_interaction(self, interaction: discord.Interaction) -> None:
"""Global interaction dispatcher handling dynamic persistent task buttons across restarts."""
Expand Down
Loading
Loading