Features
Notifications

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:

  1. In-process, synchronous. Most domains — posts, actions, admin, comments, the Cognito post-confirmation trigger — import common.slack directly 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.
  2. 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 matching common.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 manual boto3 call, 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 typeWhat it meansWho it tries to notify
new_user_registeredA new user signed up and needs approvalEvery admin, individually DM'd
user_approvedAn admin approved a pending userThat user
idea_created_quick_captureAn idea came in via the public quick-capture form(see below — currently unreachable)
post_submitted_for_reviewA post moved to "in review"(see below — currently unreachable)
revision_requestedA reviewer asked for changesThe post's author
post_approvedA post was approvedThe post's author
post_scheduledA post got an auto-assigned publish slotThe post's author
slot_reminder2-day heads-up before a scheduled slotThe post's author
missed_scheduleA post blew past its scheduled slotThe post's author
post_publishedA post went live(see below — currently unreachable)
action_createdA new action request was assignedThe assignee
action_completedAn assignee marked an action doneThe action's creator
action_respondedAn assignee accepted/declined an actionThe action's creator
collaborator_invitedSomeone was invited to a workspaceThe invitee
workspace_member_joinedA 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 *_email field (author_email, assignee_email, creator_email, invitee_email, user_email). That email is looked up against Slack's users.lookupByEmail API; 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_registered is the one event that notifies more than one person: it scans DynamoDB for every user with role=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_type that isn't one of the 14 above logs a warning and returns — the Lambda still reports {"status": "sent", ...}. A typo in an event_type string 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

WhatWhere
Slack bot tokenSecrets 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.