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
16 changes: 8 additions & 8 deletions server/public/committees.html
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ <h1 id="headerTitle">Committees</h1>
<div class="type-tabs">
<a href="/committees" class="type-tab" id="tab-all">All Committees</a>
<a href="/committees?type=working_group" class="type-tab" id="tab-working_group">Working Groups</a>
<a href="/committees?type=council" class="type-tab" id="tab-council">Industry Councils</a>
<a href="/committees?type=council" class="type-tab" id="tab-council">Mastermind Councils</a>
<a href="/committees?type=chapter" class="type-tab" id="tab-chapter">Regional Chapters</a>
<a href="/committees?type=industry_gathering" class="type-tab" id="tab-industry_gathering">Industry Gatherings</a>
</div>
Expand Down Expand Up @@ -493,15 +493,15 @@ <h2>Want to Get Involved?</h2>
apiType: 'working_group'
},
council: {
title: 'Industry Councils',
pageTitle: 'Industry Councils | AgenticAdvertising.org',
subtitle: 'Driving agentic advertising adoption across industry verticals',
sectionTitle: 'All Industry Councils',
sectionDesc: 'Industry councils convene stakeholders from specific advertising verticals to accelerate adoption of agentic advertising standards.',
title: 'Mastermind Councils',
pageTitle: 'Mastermind Councils | AgenticAdvertising.org',
subtitle: 'Exploring how agentic AI is impacting two outcomes that marketers care about: growth and brand value.',
sectionTitle: 'All Mastermind Councils',
sectionDesc: 'Mastermind Councils are interactive peer forums where members openly exchange ideas, share success stories, and collectively develop scalable playbooks and emerging best practices to shape the future of agentic marketing.',
emptyTitle: 'No Councils Yet',
emptyMessage: 'Industry councils are being established. Check back soon!',
emptyMessage: 'Mastermind Councils are being established. Check back soon!',
myGroupsTitle: 'Your Councils',
joinCta: 'Become an AAO member to join industry councils and help shape your vertical\'s approach to agentic advertising.',
joinCta: 'Become an AgenticAdvertising.org member to join Mastermind Councils and help shape the future of agentic marketing.',
viewButton: 'View Council',
apiType: 'council'
},
Expand Down
29 changes: 29 additions & 0 deletions server/public/js/markdown-to-plain-text.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
(function installMarkdownToPlainText(global) {
const SAFE_TAGS = [
'p', 'div', 'span', 'strong', 'em', 'b', 'i', 'del', 's', 'code', 'pre',
'blockquote', 'ul', 'ol', 'li', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'img',
];
const BLOCK_TAGS = 'p, div, li, blockquote, h1, h2, h3, h4, h5, h6, td, th, tr';

global.markdownToPlainText = function markdownToPlainText(markdown) {
const container = document.createElement('div');
const rendered = global.marked.parse(String(markdown));
container.innerHTML = global.DOMPurify.sanitize(rendered, {
ALLOWED_TAGS: SAFE_TAGS,
ALLOWED_ATTR: ['alt'],
});

container.querySelectorAll('img').forEach(image => {
image.replaceWith(document.createTextNode(image.getAttribute('alt') || ''));
});
container.querySelectorAll('br').forEach(lineBreak => {
lineBreak.replaceWith(document.createTextNode(' '));
});
container.querySelectorAll(BLOCK_TAGS).forEach(block => {
block.append(document.createTextNode(' '));
});

return (container.textContent || '').replace(/\s+/g, ' ').trim();
};
})(window);
39 changes: 22 additions & 17 deletions server/public/working-groups/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
<link rel="stylesheet" href="/design-system.css">
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.6/dist/purify.min.js"></script>
<script src="/js/markdown-to-plain-text.js"></script>
<script src="/perspective-url.js"></script>
<script src="/external-url.js"></script>
<script src="/slack-cta.js"></script>
Expand Down Expand Up @@ -1174,7 +1175,7 @@ <h1 class="group-title">
<line x1="20" y1="8" x2="20" y2="14"/>
<line x1="23" y1="11" x2="17" y2="11"/>
</svg>
Join Working Group
<span id="joinBtnLabel">Join Working Group</span>
</button>
<button id="leaveBtn" class="btn btn-danger" style="display: none;" onclick="leaveGroup()">
Leave Group
Expand Down Expand Up @@ -1581,8 +1582,12 @@ <h1 id="articleViewTitle" class="article-view-title"></h1>
document.getElementById('privateBadge').style.display = 'inline';
}

// Region badge and back link for chapters
if (currentGroup.committee_type === 'chapter') {
// Type-specific back links and action copy
if (currentGroup.committee_type === 'council') {
document.getElementById('backLink').href = '/committees?type=council';
document.getElementById('backLinkText').textContent = 'Back to Councils';
document.getElementById('joinBtnLabel').textContent = 'Join Council';
} else if (currentGroup.committee_type === 'chapter') {
document.getElementById('backLink').href = '/chapters';
document.getElementById('backLinkText').textContent = 'Back to Chapters';
if (currentGroup.region) {
Expand Down Expand Up @@ -2033,7 +2038,7 @@ <h3 class="events-subsection-title">Recent Events</h3>
${lastModified ? `<span>Updated ${lastModified}</span>` : ''}
${doc.index_status !== 'success' ? `<span class="document-status ${statusClass}">${formatIndexStatus(doc.index_status)}</span>` : ''}
</div>
${doc.document_summary ? `<div class="document-summary">${escapeHtml(doc.document_summary)}</div>` : ''}
${doc.document_summary ? `<div class="document-summary">${escapeHtml(markdownToPlainText(doc.document_summary))}</div>` : ''}
${doc.index_status === 'access_denied' && (isMember || isAdmin) ? `
<div class="document-fix-banner">
<p>Addie can't read this document. To fix it:</p>
Expand Down Expand Up @@ -2179,6 +2184,13 @@ <h3 class="events-subsection-title">Recent Events</h3>
`;
}

function resetJoinButton() {
const joinBtn = document.getElementById('joinBtn');
joinBtn.disabled = false;
document.getElementById('joinBtnLabel').textContent =
currentGroup.committee_type === 'council' ? 'Join Council' : 'Join Working Group';
}

async function joinGroup() {
if (!currentUser) {
window.location.href = `/auth/login?return_to=${encodeURIComponent(window.location.pathname)}`;
Expand All @@ -2187,7 +2199,7 @@ <h3 class="events-subsection-title">Recent Events</h3>

const joinBtn = document.getElementById('joinBtn');
joinBtn.disabled = true;
joinBtn.textContent = 'Joining...';
document.getElementById('joinBtnLabel').textContent = 'Joining...';

try {
const response = await fetch(`/api/working-groups/${currentSlug}/join`, {
Expand All @@ -2197,6 +2209,7 @@ <h3 class="events-subsection-title">Recent Events</h3>

if (response.ok) {
isMember = true;
resetJoinButton();
joinBtn.style.display = 'none';
document.getElementById('membershipStatus').style.display = 'block';
document.getElementById('leaveBtn').style.display = 'inline-flex';
Expand All @@ -2206,22 +2219,13 @@ <h3 class="events-subsection-title">Recent Events</h3>
await loadWorkingGroup();
} else {
const data = await response.json();
alert(data.error || 'Failed to join group');
joinBtn.disabled = false;
joinBtn.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="8.5" cy="7" r="4"/>
<line x1="20" y1="8" x2="20" y2="14"/>
<line x1="23" y1="11" x2="17" y2="11"/>
</svg>
Join Working Group
`;
alert(data.message || data.error || 'Failed to join group');
resetJoinButton();
}
} catch (error) {
console.error('Error joining group:', error);
alert('Failed to join group');
joinBtn.disabled = false;
resetJoinButton();
}
}

Expand All @@ -2243,6 +2247,7 @@ <h3 class="events-subsection-title">Recent Events</h3>
document.getElementById('newPostBtn').style.display = 'none';

if (!currentGroup.is_private) {
resetJoinButton();
document.getElementById('joinBtn').style.display = 'inline-flex';
}

Expand Down
8 changes: 6 additions & 2 deletions server/src/addie/mcp/member-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ import {
withdrawCommitteeInterest as withdrawCommitteeInterestService,
listMyWorkingGroups as listMyWorkingGroupsService,
listMyCommitteeInterests as listMyCommitteeInterestsService,
MASTERMIND_COUNCIL_MEMBERSHIP_NOTICE,
WorkingGroupMembershipError,
} from '../../services/working-group-membership-service.js';
import { listMyContent as listMyContentService } from '../../services/my-content-service.js';
Expand Down Expand Up @@ -2593,7 +2594,7 @@ export function createMemberToolHandlers(
user: { id: wu.workos_user_id, email: wu.email, firstName: wu.first_name, lastName: wu.last_name },
slug,
});
return `Successfully joined the "${result.groupName}" working group! You can now participate in discussions and see group posts.`;
return `Successfully joined "${result.groupName}"! You can now participate in discussions and see group posts.`;
} catch (error) {
if (error instanceof WorkingGroupMembershipError) {
if (error.is('group_not_found')) {
Expand All @@ -2602,11 +2603,14 @@ export function createMemberToolHandlers(
if (error.is('group_private')) {
return `"${error.meta.groupName}" is a private working group that requires an invitation. Use request_working_group_invitation to request access.`;
}
if (error.is('council_membership_required')) {
return MASTERMIND_COUNCIL_MEMBERSHIP_NOTICE;
}
if (error.is('community_only_seat_blocked')) {
return `Joining "${error.meta.groupName}" requires a contributor seat. Ask your org admin to upgrade your access.`;
}
if (error.is('already_member')) {
return `You're already a member of the "${error.meta.groupName}" working group!`;
return `You're already a member of "${error.meta.groupName}"!`;
}
}
throw new ToolError(`Failed to join working group: ${error instanceof Error ? error.message : String(error)}`);
Expand Down
7 changes: 7 additions & 0 deletions server/src/routes/committees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
withdrawCommitteeInterest as withdrawCommitteeInterestService,
listMyWorkingGroups as listMyWorkingGroupsService,
listMyCommitteeInterests as listMyCommitteeInterestsService,
MASTERMIND_COUNCIL_MEMBERSHIP_NOTICE,
WorkingGroupMembershipError,
} from "../services/working-group-membership-service.js";
import {
Expand Down Expand Up @@ -1201,6 +1202,12 @@ export function createCommitteeRouters(): {
message: 'This working group is private and requires an invitation to join',
});
}
if (error.is('council_membership_required')) {
return res.status(403).json({
error: 'Paid membership required',
message: MASTERMIND_COUNCIL_MEMBERSHIP_NOTICE,
});
}
if (error.is('community_only_seat_blocked')) {
const { userOrgId, workingGroupId, groupName, resourceType } = error.meta;
return res.status(403).json({
Expand Down
63 changes: 63 additions & 0 deletions server/src/services/active-membership-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { getPool } from '../db/client.js';
import {
invalidateMembershipCache,
resolveEffectiveMembership,
type EffectiveMembership,
} from '../db/org-filters.js';
import { createLogger } from '../logger.js';

const logger = createLogger('active-membership-service');

export interface ActiveMembershipCheckDeps {
listOrganizationIdsForUser: (userId: string) => Promise<string[]>;
invalidateMembershipCache: (orgId: string) => void;
resolveEffectiveMembership: (orgId: string) => Promise<EffectiveMembership>;
}

async function listOrganizationIdsForUser(userId: string): Promise<string[]> {
const result = await getPool().query<{ workos_organization_id: string }>(
`SELECT DISTINCT workos_organization_id
FROM organization_memberships
WHERE workos_user_id = $1`,
[userId],
);
return result.rows.map((row) => row.workos_organization_id);
}

const DEFAULT_DEPS: ActiveMembershipCheckDeps = {
listOrganizationIdsForUser,
invalidateMembershipCache,
resolveEffectiveMembership,
};

/**
* Check every organization the user belongs to for an active effective
* AgenticAdvertising.org membership. Each decision bypasses the membership
* cache so authorization never rests on a recently canceled subscription.
*
* `EffectiveMembership.is_member` intentionally includes Explorer
* (`individual_academic`) and inherited memberships. It does not use the
* narrower API-access tier list.
*
* Resolver and database failures fail closed.
*/
export async function hasActiveMembershipForUser(
userId: string,
deps: ActiveMembershipCheckDeps = DEFAULT_DEPS,
): Promise<boolean> {
try {
const organizationIds = await deps.listOrganizationIdsForUser(userId);
if (organizationIds.length === 0) return false;

const memberships = await Promise.all(
organizationIds.map((orgId) => {
deps.invalidateMembershipCache(orgId);
return deps.resolveEffectiveMembership(orgId);
}),
);
return memberships.some((membership) => membership.is_member);
} catch (error) {
logger.error({ err: error, userId }, 'Failed to resolve active membership for user');
return false;
}
}
63 changes: 41 additions & 22 deletions server/src/services/working-group-membership-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,15 @@ import { inviteToChannel } from '../slack/client.js';
import { sendWgWelcomeMessage } from '../addie/services/wg-welcome.js';
import { getUserSeatType } from '../db/organization-db.js';
import { getWorkos } from '../auth/workos-client.js';
import { hasActiveMembershipForUser } from './active-membership-service.js';

const logger = createLogger('working-group-membership-service');

const workingGroupDb = new WorkingGroupDatabase();

export const MASTERMIND_COUNCIL_MEMBERSHIP_NOTICE =
'Our Mastermind Councils are for paying member tiers only. AgenticAdvertising.org membership starts at $50 annually.';

/**
* Caller identity. Both route (`req.user`) and Addie tool
* (`memberContext.workos_user`) shape into this; the service doesn't
Expand All @@ -60,13 +64,15 @@ export interface WorkingGroupServiceUser {
export type WorkingGroupMembershipErrorCode =
| 'group_not_found'
| 'group_private'
| 'council_membership_required'
| 'community_only_seat_blocked'
| 'already_member'
| 'no_interest_recorded';

export interface WorkingGroupMembershipErrorMetaByCode {
group_not_found: { slug: string };
group_private: { slug: string; groupName: string };
council_membership_required: { slug: string; groupName: string };
community_only_seat_blocked: {
slug: string;
groupName: string;
Expand Down Expand Up @@ -163,28 +169,41 @@ export async function joinWorkingGroup({ user, slug }: JoinWorkingGroupInput): P
});
}

// Community-only seats cannot join working groups or councils — they
// need a contributor seat upgrade. Surface enough metadata for the
// route to render a seat-request payload and the Addie tool to render
// a "ask your org admin" CTA.
const seatType = await getUserSeatType(user.id);
if (seatType === 'community_only') {
const orgRow = await query<{ workos_organization_id: string }>(
'SELECT workos_organization_id FROM organization_memberships WHERE workos_user_id = $1 LIMIT 1',
[user.id],
);
const userOrgId = orgRow.rows[0]?.workos_organization_id ?? null;
throw new WorkingGroupMembershipError(
'community_only_seat_blocked',
'Working group membership requires a contributor seat',
{
slug: group.slug,
groupName: group.name,
workingGroupId: group.id,
resourceType: group.committee_type === 'council' ? 'council' : 'working_group',
userOrgId,
},
);
if (group.committee_type === 'council') {
// Mastermind Councils are open to every active paid tier, including the
// $50 Explorer tier. This is deliberately separate from contributor-seat
// and API-access eligibility, both of which exclude Explorer.
const hasActiveMembership = await hasActiveMembershipForUser(user.id);
if (!hasActiveMembership) {
throw new WorkingGroupMembershipError(
'council_membership_required',
MASTERMIND_COUNCIL_MEMBERSHIP_NOTICE,
{ slug: group.slug, groupName: group.name },
);
}
} else {
// Community-only seats cannot join working groups — they need a
// contributor seat upgrade. Surface enough metadata for the route to
// render a seat-request payload and Addie to render an upgrade CTA.
const seatType = await getUserSeatType(user.id);
if (seatType === 'community_only') {
const orgRow = await query<{ workos_organization_id: string }>(
'SELECT workos_organization_id FROM organization_memberships WHERE workos_user_id = $1 LIMIT 1',
[user.id],
);
const userOrgId = orgRow.rows[0]?.workos_organization_id ?? null;
throw new WorkingGroupMembershipError(
'community_only_seat_blocked',
'Working group membership requires a contributor seat',
{
slug: group.slug,
groupName: group.name,
workingGroupId: group.id,
resourceType: 'working_group',
userOrgId,
},
);
}
}

const existingMembership = await workingGroupDb.getMembership(group.id, user.id);
Expand Down
Loading
Loading