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
23 changes: 23 additions & 0 deletions .changeset/auto-provision-digest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
---

feat(notifications): daily digest of new auto-provisioned members for org admins

The consent receipt for the `auto_provision_verified_domain` default. With auto-add on (which it is by default), org membership grows quietly when verified-domain emails sign in — owners had no signal that their seat list was changing. This adds a daily Slack digest that lists the new auto-joined members per org and links to the team page where the owner can review or flip the toggle off.

## Mechanics

- New per-org watermark `organizations.last_auto_provision_digest_sent_at`. Migration 437.
- New `findOrgsWithNewAutoProvisionedMembers()` and `listNewAutoProvisionedMembers(orgId, since)` queries find members where `provisioning_source = 'verified_domain'` and `created_at > watermark`. Skips personal workspaces and orgs with `auto_provision_verified_domain = false`.
- `server/src/scheduled/auto-provision-digest.ts` runs the check every 24 hours (5-minute startup delay so it doesn't fire during boot's noisy window). Uses the same Slack DM dispatch helper (`sendToOrgAdmins`) that the seat-request reminder job uses, so multi-admin orgs get a group DM and single-admin orgs get a direct DM.
- Watermark is only updated after a successful Slack delivery. If no admins are mapped to Slack, or delivery fails, the watermark stays put and the next run retries — eventually surfaces the news once an admin's Slack mapping shows up.

## Tests

- `server/tests/integration/membership-webhook.test.ts` — 31 → 37 tests. Six new cases cover: candidates filtered to verified-domain since watermark, opt-out flag honored, NULL watermark = beginning of time, members returned chronologically with non-verified-domain sources excluded, and `markAutoProvisionDigestSent` updating the timestamp.

## Future

- Email fallback for orgs without Slack mappings (defer — Slack is the primary admin surface today).
- Per-org cadence preference (defer — daily is the right default for a low-volume notification).
- "What's in this digest" preview accessible from the team page (defer — wire into the next admin UI cycle).
20 changes: 20 additions & 0 deletions .changeset/provisioning-source-attribution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
---

feat(membership): track provisioning source on each org membership

Each row in `organization_memberships` now carries a `provisioning_source` tag identifying how it came to exist:

- `verified_domain` — `autoLinkByVerifiedDomain` matched the user's email domain to a verified org with auto-provision on
- `invited` — accepted via `POST /:orgId/invitations` or `/members/by-email` Path 1
- `admin_added` — created via `/members/by-email` Path 2 (admin/owner direct add)
- `webhook` — surfaced by an `organization_membership.created` event with no staged source (e.g. someone added the membership directly in the WorkOS dashboard)
- `null` — pre-existing rows that haven't been touched since this migration

The originating endpoint stages source + seat_type into `invitation_seat_types` (a new `source` column there). The `organization_membership.created` webhook handler reads both back via `consumeInvitationSeatType` and writes `provisioning_source` on the local cache row. The upsert preserves an existing source on conflict, so a subsequent webhook upsert can't wipe a more-specific origin.

Sets up the new-member digest in the auto-provision notification feature so org owners can see which auto-joined members showed up via verified-domain vs. were explicitly invited.

## Migration

`436_organization_membership_provisioning_source.sql` adds the columns and an index keyed on `(workos_organization_id, provisioning_source, created_at DESC)` for the digest query.
180 changes: 173 additions & 7 deletions server/src/db/membership-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ const logger = createLogger('membership-db');

// ── Types ────────────────────────────────────────────────────────────

/** Tracks how each organization_memberships row came to exist. */
export type ProvisioningSource =
| 'verified_domain' // autoLinkByVerifiedDomain
| 'invited' // POST /:orgId/invitations or /members/by-email Path 1
| 'admin_added' // /members/by-email Path 2 direct add
| 'webhook' // organization_membership.created with no staged source
| 'unknown';

export interface MembershipUpsertParams {
user_id: string;
organization_id: string;
Expand All @@ -23,6 +31,13 @@ export interface MembershipUpsertParams {
role: string; // raw role slug from WorkOS (e.g. 'member', 'admin', 'owner')
seat_type: string; // resolved seat type ('contributor' | 'community_only')
has_explicit_seat_type: boolean;
/**
* Provisioning source to record on the local cache row. Only written when
* the row is being inserted (or when the existing row has NULL/'unknown'
* source) — once a membership is tagged, subsequent webhook upserts don't
* overwrite the original attribution.
*/
provisioning_source?: ProvisioningSource;
}

export interface MembershipUpsertResult {
Expand Down Expand Up @@ -57,6 +72,7 @@ export async function upsertOrganizationMembership(
last_name,
role,
seat_type,
provisioning_source,
synced_at
) VALUES (
$1, $2, $3, $4, $5, $6,
Expand All @@ -70,7 +86,7 @@ export async function upsertOrganizationMembership(
WHEN $7 = '__auto__' THEN 'member'
ELSE $7
END,
$8, NOW()
$8, $10, NOW()
)
ON CONFLICT (workos_user_id, workos_organization_id)
DO UPDATE SET
Expand All @@ -83,6 +99,9 @@ export async function upsertOrganizationMembership(
WHEN $9::boolean THEN EXCLUDED.seat_type
ELSE organization_memberships.seat_type
END,
-- Don't overwrite an existing attribution; later webhooks would be the
-- 'webhook' source and would otherwise wipe a more specific origin.
provisioning_source = COALESCE(organization_memberships.provisioning_source, EXCLUDED.provisioning_source),
synced_at = NOW(),
updated_at = NOW()
RETURNING role`,
Expand All @@ -96,6 +115,7 @@ export async function upsertOrganizationMembership(
effectiveRole,
params.seat_type,
params.has_explicit_seat_type,
params.provisioning_source ?? null,
],
);

Expand Down Expand Up @@ -136,23 +156,28 @@ export async function deleteOrganizationMembership(
// ── Invitation seat type ─────────────────────────────────────────────

/**
* Consume any pending seat_type assignment from an invitation.
* Returns the seat type if one was found, or null.
* Consume any pending seat_type and provisioning_source staged by the endpoint
* that triggered the membership creation. Returns both fields when a row is
* found; null when no staging row exists.
*/
export async function consumeInvitationSeatType(
organizationId: string,
email: string,
): Promise<string | null> {
): Promise<{ seat_type: string; source: ProvisioningSource | null } | null> {
const pool = getPool();

const result = await pool.query<{ seat_type: string }>(
const result = await pool.query<{ seat_type: string; source: string | null }>(
`DELETE FROM invitation_seat_types
WHERE workos_organization_id = $1 AND lower(email) = lower($2)
RETURNING seat_type`,
RETURNING seat_type, source`,
[organizationId, email],
);

return result.rows[0]?.seat_type ?? null;
if (!result.rows[0]) return null;
return {
seat_type: result.rows[0].seat_type,
source: (result.rows[0].source as ProvisioningSource | null) ?? null,
};
}

// ── Successor promotion query ────────────────────────────────────────
Expand Down Expand Up @@ -260,6 +285,23 @@ export async function autoLinkByVerifiedDomain(

if (userAlreadyMember) return null;

// Stage the provisioning source so the organization_membership.created
// webhook handler can record 'verified_domain' on the local cache row.
// Clear any stale (org, email) staging row first; consumeInvitationSeatType
// matches by (org, email) and we don't want a leftover row from a prior
// failed attempt to win.
const stagingKey = `verified_domain_${orgId}_${userId}`;
await pool.query(
'DELETE FROM invitation_seat_types WHERE workos_organization_id = $1 AND lower(email) = lower($2)',
[orgId, email],
);
await pool.query(
`INSERT INTO invitation_seat_types (workos_invitation_id, workos_organization_id, email, seat_type, source)
VALUES ($1, $2, $3, 'community_only', 'verified_domain')
ON CONFLICT (workos_invitation_id) DO UPDATE SET seat_type = EXCLUDED.seat_type, source = EXCLUDED.source`,
[stagingKey, orgId, email],
);

// Always create as member. Auto-promotion to owner for ownerless orgs is
// handled atomically by upsertOrganizationMembership when the
// organization_membership.created webhook fires — that path uses a NOT EXISTS
Expand All @@ -280,7 +322,131 @@ export async function autoLinkByVerifiedDomain(
logger.info({ userId, orgId }, 'Auto-link skipped: membership already exists in WorkOS');
return { organizationId: orgId, organizationName: orgName, role: 'member' };
}
// Roll back the staging row so a stale 'verified_domain' source can't be
// consumed by an unrelated future invite for the same (org, email) pair.
try {
await pool.query(
'DELETE FROM invitation_seat_types WHERE workos_invitation_id = $1',
[stagingKey],
);
} catch (rollbackErr) {
logger.error(
{ err: rollbackErr, userId, orgId, email, stagingKey },
'CRITICAL: failed to rollback verified_domain staging row after createOrganizationMembership failure — manually delete row to avoid source leak',
);
}
logger.warn({ err, userId, orgId }, 'Failed to auto-link user to organization');
return null;
}
}

// ── Auto-provision digest queries ───────────────────────────────────

/**
* Row in the auto-provision digest payload — one per newly-auto-joined member
* since the org's last digest watermark.
*/
export interface NewAutoProvisionedMember {
workos_user_id: string;
email: string;
first_name: string | null;
last_name: string | null;
role: string;
seat_type: string;
joined_at: Date;
}

/**
* Find orgs that have at least one auto-provisioned member since their last
* digest watermark. Returns one row per org (with the org's owner emails and
* the count of new members) so the caller can iterate.
*
* Skip personal workspaces and orgs with auto_provision_verified_domain=false
* (the latter shouldn't have any verified-domain members anyway, but the join
* makes the intent explicit).
*/
export async function findOrgsWithNewAutoProvisionedMembers(): Promise<
Array<{
workos_organization_id: string;
org_name: string;
last_sent_at: Date | null;
new_member_count: number;
}>
> {
const pool = getPool();
const result = await pool.query<{
workos_organization_id: string;
org_name: string;
last_sent_at: Date | null;
new_member_count: string; // pg COUNT comes back as string
}>(`
SELECT
o.workos_organization_id,
o.name AS org_name,
o.last_auto_provision_digest_sent_at AS last_sent_at,
COUNT(om.workos_user_id) AS new_member_count
FROM organizations o
JOIN organization_memberships om
ON om.workos_organization_id = o.workos_organization_id
WHERE om.provisioning_source = 'verified_domain'
AND om.created_at > COALESCE(o.last_auto_provision_digest_sent_at, 'epoch'::timestamptz)
AND COALESCE(o.is_personal, false) = false
AND COALESCE(o.auto_provision_verified_domain, true) = true
GROUP BY o.workos_organization_id, o.name, o.last_auto_provision_digest_sent_at
HAVING COUNT(om.workos_user_id) > 0
`);

return result.rows.map(r => ({
workos_organization_id: r.workos_organization_id,
org_name: r.org_name,
last_sent_at: r.last_sent_at,
new_member_count: parseInt(r.new_member_count, 10),
}));
}

/**
* List the auto-provisioned members added to a given org since the watermark.
* Used to build the digest body once findOrgsWithNewAutoProvisionedMembers has
* filtered to orgs with non-zero counts.
*/
export async function listNewAutoProvisionedMembers(
organizationId: string,
since: Date | null,
): Promise<NewAutoProvisionedMember[]> {
const pool = getPool();
const sinceTs = since ?? new Date(0);
const result = await pool.query<NewAutoProvisionedMember>(`
SELECT
workos_user_id,
email,
first_name,
last_name,
role,
seat_type,
created_at AS joined_at
FROM organization_memberships
WHERE workos_organization_id = $1
AND provisioning_source = 'verified_domain'
AND created_at > $2
ORDER BY created_at ASC
`, [organizationId, sinceTs]);

return result.rows;
}

/**
* Mark the digest as sent for an organization. Called after successful delivery
* so the next run skips the same members.
*/
export async function markAutoProvisionDigestSent(
organizationId: string,
sentAt: Date = new Date(),
): Promise<void> {
const pool = getPool();
await pool.query(
`UPDATE organizations
SET last_auto_provision_digest_sent_at = $2, updated_at = NOW()
WHERE workos_organization_id = $1`,
[organizationId, sentAt],
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
-- Track how each organization membership came to exist.
--
-- Existing rows are NULL ('unknown'). New rows are tagged at creation by the
-- code path that triggered them: autoLinkByVerifiedDomain → 'verified_domain',
-- /members/by-email Path 1 → 'invited', Path 2 → 'admin_added',
-- POST /:orgId/invitations → 'invited', everything else (sync from WorkOS,
-- webhook for an externally-created membership) → 'webhook'.
--
-- Used by the new-member digest in the auto-provision notification feature so
-- org owners can see which auto-joined members showed up via verified-domain
-- vs. were explicitly invited.

ALTER TABLE organization_memberships
ADD COLUMN IF NOT EXISTS provisioning_source VARCHAR(32);

COMMENT ON COLUMN organization_memberships.provisioning_source IS
'How this membership was created: verified_domain, invited, admin_added, webhook, unknown';

CREATE INDEX IF NOT EXISTS idx_organization_memberships_provisioning_source
ON organization_memberships(workos_organization_id, provisioning_source, created_at DESC)
WHERE provisioning_source IS NOT NULL;

-- Mirror on the invitation_seat_types staging table so the webhook handler can
-- read the source set by the originating endpoint and apply it to the local
-- cache row when the membership.created event arrives.
ALTER TABLE invitation_seat_types
ADD COLUMN IF NOT EXISTS source VARCHAR(32);

COMMENT ON COLUMN invitation_seat_types.source IS
'Provisioning source to apply to the membership when this staging row is consumed';
13 changes: 13 additions & 0 deletions server/src/db/migrations/437_auto_provision_digest_sent_at.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
-- Track the last time the auto-provision new-member digest was sent for an
-- organization. The scheduled job uses this to find members auto-joined since
-- the previous digest (no per-member tracking, just a watermark per org).
--
-- NULL means we've never sent — the first run after this migration deploys
-- will look back at the full window of `verified_domain` provisioning_source
-- members and include all of them in that org's first digest.

ALTER TABLE organizations
ADD COLUMN IF NOT EXISTS last_auto_provision_digest_sent_at TIMESTAMPTZ;

COMMENT ON COLUMN organizations.last_auto_provision_digest_sent_at IS
'Watermark for the auto-provision new-member digest. Updated when the digest is successfully delivered. NULL = never sent.';
10 changes: 10 additions & 0 deletions server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8647,6 +8647,12 @@ ${p.category ? `<category>${p.category}</category>\n` : ''}<url>${publishedUrl}<
import('./scheduled/seat-request-reminders.js').then(({ startSeatRequestReminders }) => {
startSeatRequestReminders(workos!);
}).catch(err => logger.warn({ err }, 'Failed to start seat request reminders'));

// Daily auto-provision new-member digest for org admins/owners.
// Consent receipt for the auto_provision_verified_domain default.
import('./scheduled/auto-provision-digest.js').then(({ startAutoProvisionDigest }) => {
startAutoProvisionDigest(workos!);
}).catch(err => logger.warn({ err }, 'Failed to start auto-provision digest'));
}

// Start Luma calendar sync (catches events missed by webhooks)
Expand Down Expand Up @@ -8701,6 +8707,10 @@ ${p.category ? `<category>${p.category}</category>\n` : ''}<url>${publishedUrl}<
stopSeatRequestReminders();
}).catch(() => {});

import('./scheduled/auto-provision-digest.js').then(({ stopAutoProvisionDigest }) => {
stopAutoProvisionDigest();
}).catch(() => {});

import('./luma/sync.js').then(({ stopLumaSync }) => {
stopLumaSync();
}).catch(() => {});
Expand Down
Loading
Loading