Notifications
Notifications in lk-wiz are Slack DMs — nudges like "your post was approved" or "a new user signed up and needs approval" delivered straight to the right person's Slack inbox, with an action button linking back into the app. There is no in-app notification center and no email channel; if it doesn't reach you on Slack, it doesn't reach you.
This page covers the standalone notifications dispatch Lambda
(notifications_service.dispatch_notification) — a direct-invoke event
router, not an HTTP endpoint. For the authoritative, code-verified
behavior (including an important caveat about which event types are
currently reachable), see specs/features/notifications.md in the repo.
Two ways a Slack message gets sent
lk-wiz has two independent mechanisms that both end up calling the same
underlying common.slack Block Kit builders:
- In-process, synchronous. Most domains — posts, actions, admin,
comments, the Cognito post-confirmation trigger — import
common.slackdirectly and call it in the same Lambda invocation that made the change. This is how nearly all Slack notifications you actually see in production are sent today. - The notifications dispatch Lambda (this page). A separate Lambda
function,
notifications_handler, that accepts a generic{"event_type": "...", "payload": {...}}event and routes it through a 14-entry dispatch table to the matchingcommon.slack.*call. It is fully built, tested, and deployed — but no other Lambda in the codebase currently invokes it, and it has no EventBridge rule pointed at it either. It exists as a ready-to-use dispatch surface for future callers; today it would only run if something invoked it directly (the AWS CLI, a manualboto3call, or a test).
If you're debugging "why didn't a Slack notification fire," check which
path applies first. For post/action/admin/comment events, the answer is
almost always in the domain's own service file
(posts_service.py, actions_service.py, etc.), not in
notifications_service.py.
Why direct-invoke instead of HTTP
notifications_handler is registered in Terraform's Lambda handler map
(local.lambda_handlers) but deliberately has no route in
local.http_routes — there's nothing for a browser or the MCP server to
call. The intended calling convention is a raw Lambda invoke (sync or
async) with this event shape:
{
"event_type": "post_approved",
"payload": {
"post_title": "Building a Data Pipeline with Lambda",
"post_url": "https://app.lkwiz.io/posts/abc123",
"author_email": "marco@example.com"
}
}The handler does the bare minimum of parsing — pull event_type (raise
ValueError if missing or empty), default payload to {} — and hands
everything else to notifications_service.dispatch_notification. There's
no request/response formatting layer because there's no HTTP boundary: a
successful call returns {"status": "sent", "event_type": "post_approved"},
and a failure is a raw exception.
Supported event types
| Event type | What it means | Who it tries to notify |
|---|---|---|
new_user_registered | A new user signed up and needs approval | Every admin, individually DM'd |
user_approved | An admin approved a pending user | That user |
idea_created_quick_capture | An idea came in via the public quick-capture form | (see below — currently unreachable) |
post_submitted_for_review | A post moved to "in review" | (see below — currently unreachable) |
revision_requested | A reviewer asked for changes | The post's author |
post_approved | A post was approved | The post's author |
post_scheduled | A post got an auto-assigned publish slot | The post's author |
slot_reminder | 2-day heads-up before a scheduled slot | The post's author |
missed_schedule | A post blew past its scheduled slot | The post's author |
post_published | A post went live | (see below — currently unreachable) |
action_created | A new action request was assigned | The assignee |
action_completed | An assignee marked an action done | The action's creator |
action_responded | An assignee accepted/declined an action | The action's creator |
collaborator_invited | Someone was invited to a workspace | The invitee |
workspace_member_joined | A new member joined a workspace | (see below — currently unreachable) |
All payload fields are snake_case (post_title, author_email, etc.) —
see specs/features/notifications.md#event-types for the exact required
vs. optional fields per event.
Four event types are always a no-op through this dispatch table:
idea_created_quick_capture, post_submitted_for_review,
post_published, and workspace_member_joined. The dispatch code passes
channel=None for these unconditionally — there's no workspace-level
"Slack channel" field anywhere in the data model for it to resolve
instead. common.slack.send_notification treats a missing channel as
"nothing to send to" and silently skips (with a warning log), so calling
dispatch_notification with one of these four event types will always
log-and-skip, never actually post to Slack.
How a recipient gets resolved
- By email, one at a time. Most event types carry a
*_emailfield (author_email,assignee_email,creator_email,invitee_email,user_email). That email is looked up against Slack'susers.lookupByEmailAPI; if it resolves, the Slack user ID becomes the DM channel. If the email is missing, empty, or doesn't match a Slack account, the notification is skipped (with a warning log) — not an error. - Fan-out for admins.
new_user_registeredis the one event that notifies more than one person: it scans DynamoDB for every user withrole=admin, resolves each one's Slack ID independently, and sends one DM per admin. If there are zero admins in the table, nothing is sent and a warning is logged — the dispatch call still reports success. - Unknown event types don't error. Passing an
event_typethat isn't one of the 14 above logs a warning and returns — the Lambda still reports{"status": "sent", ...}. A typo in anevent_typestring looks identical, from the outside, to a message that was actually delivered.
Failure behavior
A Slack API failure (bad token, rate limit, network error) or a DynamoDB
scan failure (while resolving admins) propagates as a raised exception out
of dispatch_notification — there's no silent swallowing. Before
re-raising, the failure is recorded as a SlackNotificationFailed
CloudWatch metric (dimensions: stage, event_type); a successful
dispatch — including one of the four always-skipped event types above, or
an unrecognized event type — records SlackNotificationSent instead. That
means SlackNotificationSent is a "the code ran without throwing" signal,
not a "a human got pinged" signal — check Lambda logs for the actual
delivery outcome.
Where the reminder/missed-schedule events actually stand
slot_reminder and missed_schedule are real branches in the dispatch
table and have working common.slack message builders, but the daily
scheduler job (scheduler_service.py) that's supposed to trigger them
currently only logs and records a metric — it never calls
common.slack.* or invokes this Lambda. So today, nobody receives a
"2-day reminder" or "you missed your slot" Slack message from the
scheduled job; those two event types are only reachable by invoking
notifications_handler directly with a hand-built event.
Configuration
| What | Where |
|---|---|
| Slack bot token | Secrets Manager, {stage}/lkwiz-slack, key bot_token |
| DynamoDB table (for the admin scan) | TABLE_NAME env var, shared with every other Lambda |
| Deployment stage (metric dimension) | STAGE env var, default dev |
There is no email/SES channel in this domain. The ses:SendEmail
permission you'll find on the shared Lambda role backs Cognito's own
account-verification emails, not anything in notifications_service.py.
See also
specs/features/notifications.md— the authoritative, code-verified spec (event-by-event payload contracts, exact recipient-resolution rules, and the reachability caveats above).- Actions — actions' own in-process Slack sends (created / completed / responded).
- Post Comments & Threads — comment notifications
(
post_comment_added,post_comment_reply), which are not part of this dispatch table.