Skip to content

msflib-workspace-notifications

Purpose

msflib-workspace-notifications adds workspace-scoped notifications on top of notifications. Workspace admins send a notification to some or all users of a workspace; recipients get an in-app inbox, and the same message can go out by email, SMS and Discord. It also stores the Discord channels a workspace has registered.

The base Notification table stays free of any workspace dependency. This package links a notification to a workspace through its own WorkspaceNotification table and marks it with scope = "workspace", which keeps it invisible to the account-level notification routes.

Install

[tool.poetry.dependencies]
msflib = { git = "https://github.com/msflib/fastapi.git", subdirectory = "core", rev = "core-v0.2.1" }
msflib-account = { git = "https://github.com/msflib/fastapi.git", subdirectory = "modules/account", rev = "account-v0.2.2" }
msflib-workspaces = { git = "https://github.com/msflib/fastapi.git", subdirectory = "modules/workspaces", rev = "workspaces-v0.2.1" }
msflib-notifications = { git = "https://github.com/msflib/fastapi.git", subdirectory = "modules/notifications", rev = "notifications-v0.2.1" }
msflib-workspace-notifications = { git = "https://github.com/msflib/fastapi.git", subdirectory = "modules/workspace_notifications", rev = "workspace_notifications-v0.2.1" }

The package declares discord, sms, email and all extras (discord-py, twilio, emails). msflib core already depends on discord-py, twilio and emails unconditionally, so on this branch the extras add nothing when core is installed. The Discord import in service/discord.py is still guarded: if discord cannot be imported, the notifier logs an error and Discord messages are not sent.

Packaging (#279)

The sms and all extras pin twilio ^8.0.0, which conflicts with the ^9.4.6 pin in core and msflib-notifications. Avoid installing those extras and rely on core's twilio (see Troubleshooting).

Wiring into a host app

Three router factories live in msflib.workspace_notifications.router. They need the workspace-aware identity dependencies from workspaces (get_current_user, get_current_workspace, and a role check for workspace admins).

from fastapi import FastAPI
from msflib.account.models.account import Account
from msflib.api.deps import get_keystore_factory, get_session_factory
from msflib.auth.deps import get_account_dependencies, get_user_dependencies
from msflib.workspaces.actions import UserAction
from msflib.workspaces.models import UserCreate, UserUpdate
from msflib.workspaces.models.user import User
from msflib.workspaces.models.workspace import Workspace
from msflib.workspace_notifications.router import (
    admin_discord_channel_router,
    admin_user_notification_router,
    user_notification_router,
)

app = FastAPI()
get_session = get_session_factory(engine)   # engine and settings come from your host
get_keystore = get_keystore_factory()

account_deps = get_account_dependencies(
    AccountModel=Account,
    oauth_token_url="/token",
    secret_key=settings.scope("CORE").SECRET_KEY,
    session_dep=get_session,
    keystore_dep=get_keystore,
)
ws_deps = get_user_dependencies(
    UserModel=User,
    WorkspaceModel=Workspace,
    session_dep=get_session,
    account_dependencies=account_deps,
)
admin_role_check = ws_deps.WorkspaceRoleCheck(roles=["admin", "owner"])

app.include_router(
    user_notification_router(
        get_session=get_session,
        get_current_user=ws_deps.get_current_user,
        get_current_workspace=ws_deps.get_current_workspace,
        prefix="/{workspace_slug}/user",
    )
)
app.include_router(
    admin_user_notification_router(
        get_session=get_session,
        get_current_user=ws_deps.get_current_user,
        get_current_workspace=ws_deps.get_current_workspace,
        admin_role_check=admin_role_check,
        settings=settings,
        user_action=UserAction[User, UserCreate, UserUpdate](),
        prefix="/{workspace_slug}/admin/notification",
    )
)
app.include_router(
    admin_discord_channel_router(
        get_session=get_session,
        get_current_user=ws_deps.get_current_user,
        get_current_workspace=ws_deps.get_current_workspace,
        admin_role_check=admin_role_check,
        user_action=UserAction[User, UserCreate, UserUpdate](),
        prefix="/{workspace_slug}/admin/discord",
    )
)

This mirrors the module's own test fixture, including user_action: without it the admin POST returns 500 (TypeError: UserAction must be instantiated with concrete generic types), because the default is a bare UserAction(). The {workspace_slug} path parameter is what get_current_workspace resolves; use whatever your workspace dependency expects.

Router Method and path Effect
user GET / The workspace user's notifications (offset, limit)
user GET /{notification_id} One, looked up by notification_id, not by the link row id
user PATCH /{notification_id}/mark, PATCH /mark-all Set or toggle read state (?status=)
user DELETE /{notification_id} Delete the user's copy
admin POST / Send; body is {"data": <NotificationCreate>} (the route uses validation_types={"data": ...}, so the payload is wrapped and a flat body gives 422), query user_receiver_ids (omit for all users in the workspace). 201
admin GET /, GET /{notification_id} Admin notifications this admin sent in this workspace
admin DELETE /{notification_id} Delete a sent notification and its link rows
discord POST /channel, GET /channel Register and list the workspace's Discord channels
discord PUT /channel/{id}, DELETE /channel/{id} Update and delete one

The admin POST returns 400 for an empty channels list, and 400 ("In-app channel is required.") when the dispatch returns nothing because inapp was not among the channels. The caller needs a workspace User row of type admin or owner (created by the workspaces account hook or UserAction.create); without a membership the admin routes return 403 "User not found in specified workspace". admin_user_notification_router also accepts user_action, notification_action and notification_create_type for customization. Default prefixes if you omit prefix: /notifications, /admin/notification, /admin/discord.

Configuration

WsNotificationSettings has the WS_NOTIFICATIONS namespace.

Key Default Notes
DISCORD_BOT_TOKEN None Bot token. Without it the Discord handler is skipped with a warning

Flat alias: BOT_TOKEN maps to WS_NOTIFICATIONS.DISCORD_BOT_TOKEN (confirmed). The environment variable depends on how you compose settings:

  • Subclassed (class AppSettings(WsNotificationSettings, ...)): use the flat names, DISCORD_BOT_TOKEN or BOT_TOKEN. WS_NOTIFICATIONS__DISCORD_BOT_TOKEN is not read in this style.
  • Nested field (WS_NOTIFICATIONS: WsNotificationSettings = WsNotificationSettings()): WS_NOTIFICATIONS__DISCORD_BOT_TOKEN or BOT_TOKEN. The bare DISCORD_BOT_TOKEN is not read.

Behaviour may change (#280)

The NAMESPACE__KEY environment variable style only works for settings composed as a field, not for subclassed hosts. This may change.

Email and SMS use the same settings as notifications, including the TWILIO_* attributes you must declare yourself.

Key concepts

  • WorkspaceNotification links one Notification (unique) to a workspace_id. WorkspaceNotificationAction.link sets notification.scope = "workspace" (WORKSPACE_SCOPE) and creates the link. get_notifications and get_notification query notifications through that link.
  • UserNotification is the per-recipient inbox row. Its receiver_id points at a workspace user.id (a membership), not an account id. UserNotificationAction has save_for_recipients, mark_one and mark_all.
  • DiscordChannel: a named Discord channel id belonging to a workspace. DiscordChannelAction.store_channel records the admin's account as admin_id. If you pass admin_account, it looks up that account's workspace membership with an allowed type (default admin or owner) and raises ValueError if none is found. If you pass admin_user, that user is used as given with no type check, so the role check is up to the caller (the router relies on admin_role_check).
  • dispatch_user_notifications (msflib.workspace_notifications.service.notification) mirrors the account dispatcher. It creates the notification with scope="workspace", links it to the workspace and recipients, then runs the other channels. Email and SMS handlers receive account objects, so it resolves each workspace user's account_id to an account before calling them. inapp handling and the sender rules are the same as in the account version.
  • NotificationHandlerFactory in this package extends the base factory with a discord handler and takes a workspace argument. Pass handler_factory= to override.
  • DiscordNotifier wraps a discord.py bot. create_discord_notifier(settings) returns None when no token is configured; otherwise it builds the bot and starts it on a daemon thread. Messages go to every channel id registered for the workspace.

Known issue (#274)

The Discord notifier starts a new bot thread on every dispatch that includes the discord channel.

Examples

Send an in-app notification to a workspace user. This ran against in-memory SQLite with the default tenant seeded:

from msflib.notifications.models import (
    NotificationChannel,
    NotificationCreate,
    NotificationType,
)
from msflib.workspace_notifications.actions import (
    UserNotificationAction,
    WorkspaceNotificationAction,
)
from msflib.workspace_notifications.service.notification import dispatch_user_notifications

notification = dispatch_user_notifications(
    session=session,
    settings=settings,
    data=NotificationCreate(
        title="Standup", message="10am", channels=[NotificationChannel.inapp]
    ),
    n_type=NotificationType.admin,
    workspace=workspace,
    receivers=[member_user],   # workspace User rows
    sender=admin_user,         # workspace User row
)
notification.scope             # "workspace"

WorkspaceNotificationAction().get_notifications(session, workspace.id)
UserNotificationAction().get_multi_by_all(session, receiver_id=member_user.id)

Register a Discord channel for a workspace from code:

from msflib.workspace_notifications.actions import DiscordChannelAction
from msflib.workspace_notifications.models import DiscordChannelCreate
from msflib.workspaces.actions import UserAction
from msflib.workspaces.models import UserCreate, UserUpdate
from msflib.workspaces.models.user import User

dca = DiscordChannelAction(UserAction[User, UserCreate, UserUpdate]())
dca.store_channel(
    session,
    data=DiscordChannelCreate(name="ops-alerts", channel_id=123456789012345678),
    workspace=workspace,
    admin_user=admin_user,
)

Then send with channels=[NotificationChannel.inapp, NotificationChannel.discord] and set DISCORD_BOT_TOKEN (or BOT_TOKEN, which is read in both layouts). The Discord example was not run, because it needs a live bot connection.

Troubleshooting

  • Discord messages are not delivered and the log says "Discord BOT_TOKEN not configured". Set the token using the environment variable name that matches your settings composition (see Configuration). Dispatch continues without Discord.
  • "Workspace is required for Discord notifications". The Discord handler is only built when a workspace is passed. dispatch_user_notifications always passes it; this appears only with a custom caller of the factory.
  • "Discord bot is not initialized. Message cannot be sent." discord.py could not be imported, or the notifier was built without a token.
  • Discord channel not found. The bot only sends to channels it can see (bot.get_channel); missing ones are logged at info level and skipped.
  • ValueError: Admin user not found for account in workspace. store_channel was called with an admin_account that has no admin or owner membership in that workspace, or with neither admin_account nor admin_user. DiscordChannelAction needs a concrete UserAction[User, UserCreate, UserUpdate](); a bare UserAction() raises TypeError when admin_account is used.
  • SMS and email silently not sent. See the Troubleshooting section of notifications; the same handlers run here.
  • Workspace notifications show up nowhere in the account inbox. That is intended: they have scope="workspace" and use UserNotification, not AccountNotification.
  • Dependency resolution conflict on twilio. msflib-workspace-notifications pins twilio ^8.0.0 for its sms extra while core and msflib-notifications pin ^9.4.6. Avoid installing the sms or all extra of this package; rely on core's twilio instead.

API reference

See the generated API reference for msflib.workspace_notifications. The module README is a short stub.

See also