-
Peer Support
-
-
- {{ member.board.personalMessage }}
-
-
- {{ member.board.availability }}
-
-
- Reach out on Slack: @{{ member.board.slackHandle }}
-
-
+
+
+
Board Posts
+
+ No posts yet.
+
+
+ -
+
+
{{ post.title }}
+ {{ postExcerpt(post) }}
+
+ {{ tagLabel('cooperative', tag) }}
+
+
+
+
@@ -217,15 +200,6 @@ const circleLabels = {
practitioner: "Practitioner",
};
-// State display text mapping
-const stateLabels = {
- help: "Can help",
- interested: "Interested",
- seeking: "Need help",
-};
-
-const stateLabel = (state) => stateLabels[state] || state || "";
-
const getInitials = (name) => {
if (!name) return "?";
return name
@@ -274,9 +248,18 @@ const tagLabel = (pool, slug) => {
const craftTagsDisplay = computed(() => member.value?.craftTags || []);
-const boardTopics = computed(
- () => member.value?.board?.topics || [],
-);
+// Board posts authored by this member
+const { data: postsData } = useFetch(`/api/board/posts`, {
+ params: { author: id },
+ default: () => ({ posts: [] }),
+})
+const memberPosts = computed(() => postsData.value?.posts || [])
+
+const postExcerpt = (post) => {
+ const text = post.seeking || post.offering || "";
+ if (text.length <= 80) return text;
+ return text.slice(0, 80).trimEnd() + "...";
+};
// Whether the member has any social links (for hero layout)
const hasSocialLinks = computed(() =>
@@ -365,7 +348,6 @@ useHead({
width: 96px;
height: 96px;
background: var(--surface);
- border: 1px dashed var(--border);
display: flex;
align-items: center;
justify-content: center;
@@ -507,22 +489,6 @@ useHead({
color: var(--ember);
}
-/* ====================================================
- TWO-COLUMN: Craft Tags + Board
- ==================================================== */
-
-.profile-two-col {
- display: grid;
- grid-template-columns: 1fr 1fr;
- border-bottom: 1px dashed var(--border);
-}
-.profile-two-col .profile-section {
- border-bottom: none;
-}
-.profile-two-col .profile-section:first-child {
- border-right: 1px dashed var(--border);
-}
-
/* ====================================================
SHARED SECTION ELEMENTS
==================================================== */
@@ -533,9 +499,6 @@ useHead({
line-height: 1.6;
margin: 0;
}
-.connection-details {
- margin-top: 10px;
-}
/* Tags */
.tag-list {
@@ -551,30 +514,47 @@ useHead({
border: 1px dashed var(--border);
white-space: nowrap;
}
-.connection-pill {
- display: inline-flex;
- align-items: center;
- gap: 4px;
-}
-.connection-state {
- font-size: 9px;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- color: var(--text-faint);
-}
/* ====================================================
- PEER SUPPORT
+ BOARD POSTS
==================================================== */
-.peer-availability {
- margin-top: 12px;
- padding-top: 12px;
+.posts-empty {
+ color: var(--text-faint);
+}
+.posts-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+.post-item {
border-top: 1px dashed var(--border);
}
-.slack-handle {
- font-family: "Commit Mono", monospace;
- color: var(--candle-dim);
+.post-item:last-child {
+ border-bottom: 1px dashed var(--border);
+}
+.post-link {
+ display: block;
+ padding: 10px 0;
+ text-decoration: none;
+ color: inherit;
+}
+.post-link:hover .post-title {
+ color: var(--candle);
+}
+.post-title {
+ font-size: 13px;
+ color: var(--text);
+ margin-bottom: 2px;
+ transition: color 0.15s;
+}
+.post-excerpt {
+ font-size: 11px;
+ color: var(--text-faint);
+ line-height: 1.4;
+}
+.post-tags {
+ margin-top: 6px;
}
/* ====================================================
@@ -666,17 +646,6 @@ useHead({
RESPONSIVE
==================================================== */
-@media (max-width: 1024px) {
- /* ColumnsLayout events-sidebar hides itself at ≤1024px */
- .profile-two-col {
- grid-template-columns: 1fr;
- }
- .profile-two-col .profile-section:first-child {
- border-right: none;
- border-bottom: 1px dashed var(--border);
- }
-}
-
@media (max-width: 768px) {
.profile-hero,
.profile-hero--with-links {
diff --git a/app/pages/members/index.vue b/app/pages/members/index.vue
index 9c072ff..eda0643 100644
--- a/app/pages/members/index.vue
+++ b/app/pages/members/index.vue
@@ -580,7 +580,6 @@ onMounted(async () => {
width: 32px;
height: 32px;
background: var(--surface);
- border: 1px dashed var(--border);
display: flex;
align-items: center;
justify-content: center;
diff --git a/e2e/admin-board-channels.spec.js b/e2e/admin-board-channels.spec.js
new file mode 100644
index 0000000..7a2d1e7
--- /dev/null
+++ b/e2e/admin-board-channels.spec.js
@@ -0,0 +1,64 @@
+import { test, expect } from './helpers/fixtures.js'
+
+test.describe('Admin board channels page', () => {
+ test('page loads for admin', async ({ adminPage }) => {
+ await adminPage.goto('/admin/board-channels')
+ await expect(adminPage.getByRole('heading', { name: 'Board Channels' })).toBeVisible({
+ timeout: 15000,
+ })
+ await expect(adminPage.getByRole('button', { name: '+ New Channel' })).toBeVisible()
+ })
+
+ test('create, edit, and delete a channel', async ({ adminPage }) => {
+ await adminPage.goto('/admin/board-channels')
+ await expect(adminPage.getByRole('heading', { name: 'Board Channels' })).toBeVisible({
+ timeout: 15000,
+ })
+
+ const suffix = Date.now().toString().slice(-6)
+ const channelName = `e2e-channel-${suffix}`
+ const editedName = `e2e-channel-${suffix}-edited`
+ const slackId = `C${suffix}XYZ`
+
+ // --- Create ---
+ await adminPage.getByRole('button', { name: '+ New Channel' }).click()
+ await expect(adminPage.getByRole('heading', { name: 'New Channel' })).toBeVisible()
+
+ await adminPage.locator('input[placeholder="e.g., #coop-formation"]').fill(channelName)
+ await adminPage.locator('input[placeholder="C0123456789"]').fill(slackId)
+
+ // Select the first available cooperative tag if any are present
+ const firstTagCheckbox = adminPage.locator('.tag-select input[type="checkbox"]').first()
+ if (await firstTagCheckbox.isVisible().catch(() => false)) {
+ await firstTagCheckbox.check()
+ }
+
+ await adminPage.getByRole('button', { name: 'Create Channel' }).click()
+
+ await expect(adminPage.getByRole('cell', { name: channelName })).toBeVisible({
+ timeout: 10000,
+ })
+
+ // --- Edit ---
+ const row = adminPage.locator('tr', { hasText: channelName })
+ await row.getByRole('button', { name: 'Edit' }).click()
+
+ await expect(adminPage.getByRole('heading', { name: 'Edit Channel' })).toBeVisible()
+ const nameInput = adminPage.locator('input[placeholder="e.g., #coop-formation"]')
+ await nameInput.fill(editedName)
+ await adminPage.getByRole('button', { name: 'Save Changes' }).click()
+
+ await expect(adminPage.getByRole('cell', { name: editedName })).toBeVisible({
+ timeout: 10000,
+ })
+
+ // --- Delete (confirm dialog) ---
+ adminPage.once('dialog', (dialog) => dialog.accept())
+ const editedRow = adminPage.locator('tr', { hasText: editedName })
+ await editedRow.getByRole('button', { name: 'Delete' }).click()
+
+ await expect(adminPage.getByRole('cell', { name: editedName })).not.toBeVisible({
+ timeout: 10000,
+ })
+ })
+})
diff --git a/e2e/board.spec.js b/e2e/board.spec.js
new file mode 100644
index 0000000..6f2fe7e
--- /dev/null
+++ b/e2e/board.spec.js
@@ -0,0 +1,87 @@
+import { test, expect } from './helpers/fixtures.js'
+
+test.describe('Board page', () => {
+ test('page loads for authenticated member', async ({ memberPage }) => {
+ await memberPage.goto('/board')
+ await expect(memberPage.getByRole('heading', { name: 'Board' })).toBeVisible({ timeout: 15000 })
+ await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible()
+ })
+
+ test('clicking New Post reveals the form', async ({ memberPage }) => {
+ await memberPage.goto('/board')
+ await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({
+ timeout: 15000,
+ })
+
+ await memberPage.getByRole('button', { name: '+ New Post' }).first().click()
+
+ await expect(memberPage.getByRole('heading', { name: 'New post' })).toBeVisible()
+ await expect(memberPage.locator('#post-title')).toBeVisible()
+ await expect(memberPage.locator('#post-seeking')).toBeVisible()
+ })
+
+ test('tags drawer toggles open and closed', async ({ memberPage }) => {
+ await memberPage.goto('/board')
+ await expect(memberPage.getByRole('heading', { name: 'Board' })).toBeVisible({ timeout: 15000 })
+
+ const drawerToggle = memberPage.getByRole('button', { name: /^Tags\.\.\./ })
+ // Drawer toggle only appears if cooperative tags exist — skip quietly if not
+ if (!(await drawerToggle.isVisible().catch(() => false))) {
+ test.skip(true, 'No cooperative tags seeded in this environment')
+ return
+ }
+
+ await drawerToggle.click()
+ await expect(memberPage.getByText('Filter:')).toBeVisible()
+
+ await drawerToggle.click()
+ await expect(memberPage.getByText('Filter:')).not.toBeVisible()
+ })
+
+ test('create, edit, and delete own post', async ({ memberPage }) => {
+ await memberPage.goto('/board')
+ await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({
+ timeout: 15000,
+ })
+
+ const uniqueSuffix = Date.now().toString().slice(-6)
+ const originalTitle = `E2E test post ${uniqueSuffix}`
+ const editedTitle = `E2E test post edited ${uniqueSuffix}`
+
+ // --- Create ---
+ await memberPage.getByRole('button', { name: '+ New Post' }).first().click()
+ await expect(memberPage.getByRole('heading', { name: 'New post' })).toBeVisible()
+
+ await memberPage.locator('#post-title').fill(originalTitle)
+ await memberPage.locator('#post-seeking').fill('Playwright test seeking text')
+
+ await memberPage.getByRole('button', { name: 'Post' }).click()
+
+ await expect(memberPage.getByRole('heading', { name: originalTitle })).toBeVisible({
+ timeout: 10000,
+ })
+
+ // --- Edit ---
+ // Find the post card containing our title, then click its Edit button
+ const postCard = memberPage.locator('article.board-post', { hasText: originalTitle })
+ await postCard.getByRole('button', { name: 'Edit' }).click()
+
+ await expect(memberPage.getByRole('heading', { name: 'Edit post' })).toBeVisible()
+ const titleInput = memberPage.locator('#post-title')
+ await titleInput.fill(editedTitle)
+ await memberPage.getByRole('button', { name: 'Save changes' }).click()
+
+ await expect(memberPage.getByRole('heading', { name: editedTitle })).toBeVisible({
+ timeout: 10000,
+ })
+
+ // --- Delete (confirm dialog) ---
+ memberPage.once('dialog', (dialog) => dialog.accept())
+ const editedCard = memberPage.locator('article.board-post', { hasText: editedTitle })
+ await editedCard.getByRole('button', { name: 'Delete' }).click()
+
+ await expect(memberPage.getByRole('heading', { name: editedTitle })).not.toBeVisible({
+ timeout: 10000,
+ })
+ })
+})
diff --git a/nuxt.config.ts b/nuxt.config.ts
index c3843cf..298a555 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -82,6 +82,7 @@ export default defineNuxtConfig({
resendApiKey: process.env.RESEND_API_KEY || "",
helcimApiToken: process.env.HELCIM_API_TOKEN || "", // also exposed to client via public.helcimToken
slackBotToken: process.env.SLACK_BOT_TOKEN || "",
+ slackAdminBotToken: process.env.SLACK_ADMIN_BOT_TOKEN || "",
slackSigningSecret: process.env.SLACK_SIGNING_SECRET || "",
slackVettingChannelId: process.env.SLACK_VETTING_CHANNEL_ID || "",
oidcClientId: process.env.OIDC_CLIENT_ID || "outline-wiki",
diff --git a/scripts/seed-members.js b/scripts/seed-members.js
index 89c3548..2acdb35 100644
--- a/scripts/seed-members.js
+++ b/scripts/seed-members.js
@@ -5,33 +5,6 @@ import dotenv from 'dotenv'
dotenv.config()
-const COOPERATIVE_SLUGS = [
- 'governance', 'finance-and-budgeting', 'legal-structures', 'conflict-resolution',
- 'consensus-decision-making', 'revenue-sharing', 'cooperative-bylaws', 'member-onboarding',
- 'democratic-management', 'worker-ownership', 'platform-cooperativism', 'cooperative-marketing',
- 'shared-resources', 'cooperative-funding', 'community-building', 'equity-and-inclusion',
- 'cooperative-tech', 'sustainability', 'collective-bargaining', 'inter-coop-collaboration',
-]
-
-const CRAFT_SLUGS = [
- 'game-design', 'programming', 'narrative-design', 'art-and-animation',
- 'audio-and-music', 'production-management', 'qa-and-testing', 'community-management',
- 'marketing-and-comms', 'ux-and-ui-design', 'business-development', 'devops-and-tools',
- 'localization', 'accessibility', 'analytics-and-data', 'education-and-mentoring',
-]
-
-const AVATARS = ['disbelieving', 'double-take', 'exasperated', 'mild', 'sweet', 'wtf']
-const STATES = ['help', 'interested', 'seeking']
-
-function pick(arr, n) {
- const shuffled = [...arr].sort(() => Math.random() - 0.5)
- return shuffled.slice(0, n)
-}
-
-function randomState() {
- return STATES[Math.floor(Math.random() * STATES.length)]
-}
-
const sampleMembers = [
{
email: 'alex.rivera@pixelcollective.coop',
@@ -42,16 +15,7 @@ const sampleMembers = [
avatar: 'sweet',
slackInvited: true,
craftTags: ['game-design', 'production-management', 'business-development'],
- board: {
- topics: [
- { tagSlug: 'governance', state: 'help' },
- { tagSlug: 'revenue-sharing', state: 'help' },
- { tagSlug: 'worker-ownership', state: 'interested' },
- { tagSlug: 'cooperative-bylaws', state: 'help' },
- ],
- offerPeerSupport: true,
- slackHandle: 'alex.rivera',
- },
+ board: { slackHandle: 'alex.rivera' },
createdAt: new Date('2024-01-15'),
lastLogin: new Date('2026-04-10'),
},
@@ -64,17 +28,7 @@ const sampleMembers = [
avatar: 'mild',
slackInvited: true,
craftTags: ['business-development', 'marketing-and-comms'],
- board: {
- topics: [
- { tagSlug: 'legal-structures', state: 'help' },
- { tagSlug: 'cooperative-bylaws', state: 'help' },
- { tagSlug: 'governance', state: 'interested' },
- { tagSlug: 'conflict-resolution', state: 'help' },
- { tagSlug: 'equity-and-inclusion', state: 'interested' },
- ],
- offerPeerSupport: true,
- slackHandle: 'sam.chen',
- },
+ board: { slackHandle: 'sam.chen' },
createdAt: new Date('2024-02-03'),
lastLogin: new Date('2026-04-08'),
},
@@ -89,16 +43,7 @@ const sampleMembers = [
helcimSubscriptionId: 'sub_67890',
slackInvited: true,
craftTags: ['programming', 'devops-and-tools', 'game-design', 'qa-and-testing'],
- board: {
- topics: [
- { tagSlug: 'cooperative-tech', state: 'help' },
- { tagSlug: 'platform-cooperativism', state: 'interested' },
- { tagSlug: 'shared-resources', state: 'help' },
- { tagSlug: 'democratic-management', state: 'seeking' },
- ],
- offerPeerSupport: true,
- slackHandle: 'maria.g',
- },
+ board: { slackHandle: 'maria.g' },
createdAt: new Date('2024-03-10'),
lastLogin: new Date('2026-04-12'),
},
@@ -111,16 +56,7 @@ const sampleMembers = [
avatar: 'exasperated',
slackInvited: true,
craftTags: ['business-development', 'analytics-and-data'],
- board: {
- topics: [
- { tagSlug: 'cooperative-funding', state: 'help' },
- { tagSlug: 'finance-and-budgeting', state: 'help' },
- { tagSlug: 'sustainability', state: 'interested' },
- { tagSlug: 'revenue-sharing', state: 'interested' },
- ],
- offerPeerSupport: true,
- slackHandle: 'david.park',
- },
+ board: { slackHandle: 'david.park' },
createdAt: new Date('2024-04-12'),
lastLogin: new Date('2026-04-09'),
},
@@ -133,15 +69,7 @@ const sampleMembers = [
avatar: 'disbelieving',
slackInvited: true,
craftTags: ['education-and-mentoring', 'community-management'],
- board: {
- topics: [
- { tagSlug: 'cooperative-funding', state: 'help' },
- { tagSlug: 'community-building', state: 'help' },
- { tagSlug: 'member-onboarding', state: 'interested' },
- { tagSlug: 'equity-and-inclusion', state: 'help' },
- ],
- offerPeerSupport: false,
- },
+ board: {},
createdAt: new Date('2024-05-08'),
lastLogin: new Date('2026-04-05'),
},
@@ -154,15 +82,7 @@ const sampleMembers = [
avatar: 'wtf',
slackInvited: true,
craftTags: ['programming', 'game-design', 'audio-and-music'],
- board: {
- topics: [
- { tagSlug: 'worker-ownership', state: 'seeking' },
- { tagSlug: 'governance', state: 'seeking' },
- { tagSlug: 'cooperative-tech', state: 'interested' },
- ],
- offerPeerSupport: true,
- slackHandle: 'jordan.lee',
- },
+ board: { slackHandle: 'jordan.lee' },
createdAt: new Date('2024-06-20'),
lastLogin: new Date('2026-04-07'),
},
@@ -175,14 +95,7 @@ const sampleMembers = [
avatar: 'sweet',
slackInvited: true,
craftTags: ['art-and-animation', 'ux-and-ui-design', 'accessibility'],
- board: {
- topics: [
- { tagSlug: 'equity-and-inclusion', state: 'interested' },
- { tagSlug: 'community-building', state: 'seeking' },
- { tagSlug: 'consensus-decision-making', state: 'seeking' },
- ],
- offerPeerSupport: false,
- },
+ board: {},
createdAt: new Date('2024-07-15'),
lastLogin: new Date('2026-04-01'),
},
@@ -196,17 +109,7 @@ const sampleMembers = [
helcimCustomerId: 'cust_54321',
slackInvited: true,
craftTags: ['programming', 'devops-and-tools', 'production-management'],
- board: {
- topics: [
- { tagSlug: 'cooperative-tech', state: 'help' },
- { tagSlug: 'shared-resources', state: 'help' },
- { tagSlug: 'platform-cooperativism', state: 'help' },
- { tagSlug: 'democratic-management', state: 'interested' },
- { tagSlug: 'inter-coop-collaboration', state: 'interested' },
- ],
- offerPeerSupport: true,
- slackHandle: 'casey.w',
- },
+ board: { slackHandle: 'casey.w' },
createdAt: new Date('2024-08-01'),
lastLogin: new Date('2026-04-11'),
},
@@ -219,14 +122,7 @@ const sampleMembers = [
avatar: 'double-take',
slackInvited: false,
craftTags: ['narrative-design', 'localization'],
- board: {
- topics: [
- { tagSlug: 'community-building', state: 'interested' },
- { tagSlug: 'consensus-decision-making', state: 'seeking' },
- { tagSlug: 'member-onboarding', state: 'seeking' },
- ],
- offerPeerSupport: false,
- },
+ board: {},
createdAt: new Date('2024-08-15'),
lastLogin: new Date('2026-03-28'),
},
@@ -241,18 +137,7 @@ const sampleMembers = [
helcimSubscriptionId: 'sub_13579',
slackInvited: true,
craftTags: ['game-design', 'production-management', 'marketing-and-comms', 'business-development'],
- board: {
- topics: [
- { tagSlug: 'governance', state: 'help' },
- { tagSlug: 'cooperative-bylaws', state: 'help' },
- { tagSlug: 'revenue-sharing', state: 'help' },
- { tagSlug: 'worker-ownership', state: 'help' },
- { tagSlug: 'collective-bargaining', state: 'interested' },
- { tagSlug: 'inter-coop-collaboration', state: 'interested' },
- ],
- offerPeerSupport: true,
- slackHandle: 'morgan.d',
- },
+ board: { slackHandle: 'morgan.d' },
createdAt: new Date('2024-09-01'),
lastLogin: new Date('2026-04-13'),
},
@@ -265,14 +150,7 @@ const sampleMembers = [
avatar: 'disbelieving',
slackInvited: false,
craftTags: ['programming', 'qa-and-testing'],
- board: {
- topics: [
- { tagSlug: 'cooperative-tech', state: 'seeking' },
- { tagSlug: 'worker-ownership', state: 'seeking' },
- { tagSlug: 'sustainability', state: 'interested' },
- ],
- offerPeerSupport: false,
- },
+ board: {},
createdAt: new Date('2024-10-10'),
lastLogin: new Date('2026-03-20'),
},
@@ -285,17 +163,7 @@ const sampleMembers = [
avatar: 'wtf',
slackInvited: true,
craftTags: ['community-management', 'education-and-mentoring', 'marketing-and-comms'],
- board: {
- topics: [
- { tagSlug: 'cooperative-marketing', state: 'help' },
- { tagSlug: 'community-building', state: 'help' },
- { tagSlug: 'equity-and-inclusion', state: 'help' },
- { tagSlug: 'member-onboarding', state: 'help' },
- { tagSlug: 'conflict-resolution', state: 'interested' },
- ],
- offerPeerSupport: true,
- slackHandle: 'phoenix.m',
- },
+ board: { slackHandle: 'phoenix.m' },
createdAt: new Date('2024-11-05'),
lastLogin: new Date('2026-04-06'),
},
@@ -308,16 +176,7 @@ const sampleMembers = [
avatar: 'sweet',
slackInvited: true,
craftTags: ['narrative-design', 'accessibility', 'education-and-mentoring'],
- board: {
- topics: [
- { tagSlug: 'equity-and-inclusion', state: 'interested' },
- { tagSlug: 'sustainability', state: 'seeking' },
- { tagSlug: 'community-building', state: 'interested' },
- { tagSlug: 'consensus-decision-making', state: 'seeking' },
- ],
- offerPeerSupport: true,
- slackHandle: 'sage.a',
- },
+ board: { slackHandle: 'sage.a' },
createdAt: new Date('2024-12-01'),
lastLogin: new Date('2026-04-02'),
},
@@ -330,17 +189,7 @@ const sampleMembers = [
avatar: 'mild',
slackInvited: true,
craftTags: ['game-design', 'art-and-animation', 'audio-and-music'],
- board: {
- topics: [
- { tagSlug: 'governance', state: 'interested' },
- { tagSlug: 'finance-and-budgeting', state: 'seeking' },
- { tagSlug: 'cooperative-bylaws', state: 'seeking' },
- { tagSlug: 'revenue-sharing', state: 'interested' },
- { tagSlug: 'democratic-management', state: 'interested' },
- ],
- offerPeerSupport: true,
- slackHandle: 'dakota.w',
- },
+ board: { slackHandle: 'dakota.w' },
createdAt: new Date('2025-01-10'),
lastLogin: new Date('2026-04-10'),
},
@@ -355,17 +204,7 @@ const sampleMembers = [
helcimSubscriptionId: 'sub_22222',
slackInvited: true,
craftTags: ['business-development', 'analytics-and-data', 'production-management'],
- board: {
- topics: [
- { tagSlug: 'finance-and-budgeting', state: 'help' },
- { tagSlug: 'cooperative-funding', state: 'help' },
- { tagSlug: 'collective-bargaining', state: 'help' },
- { tagSlug: 'sustainability', state: 'help' },
- { tagSlug: 'governance', state: 'interested' },
- ],
- offerPeerSupport: true,
- slackHandle: 'charlie.t',
- },
+ board: { slackHandle: 'charlie.t' },
createdAt: new Date('2025-02-14'),
lastLogin: new Date('2026-04-12'),
},
@@ -379,16 +218,7 @@ const sampleMembers = [
avatar: 'exasperated',
slackInvited: true,
craftTags: ['programming', 'game-design', 'devops-and-tools'],
- board: {
- topics: [
- { tagSlug: 'worker-ownership', state: 'help' },
- { tagSlug: 'cooperative-tech', state: 'help' },
- { tagSlug: 'platform-cooperativism', state: 'help' },
- { tagSlug: 'shared-resources', state: 'interested' },
- ],
- offerPeerSupport: true,
- slackHandle: 'robin.n',
- },
+ board: { slackHandle: 'robin.n' },
createdAt: new Date('2025-03-01'),
lastLogin: new Date('2026-04-13'),
},
@@ -401,16 +231,7 @@ const sampleMembers = [
avatar: 'wtf',
slackInvited: true,
craftTags: ['art-and-animation', 'community-management'],
- board: {
- topics: [
- { tagSlug: 'equity-and-inclusion', state: 'help' },
- { tagSlug: 'conflict-resolution', state: 'interested' },
- { tagSlug: 'community-building', state: 'help' },
- { tagSlug: 'consensus-decision-making', state: 'interested' },
- ],
- offerPeerSupport: true,
- slackHandle: 'emery.o',
- },
+ board: { slackHandle: 'emery.o' },
createdAt: new Date('2025-03-15'),
lastLogin: new Date('2026-04-11'),
},
@@ -423,17 +244,7 @@ const sampleMembers = [
avatar: 'disbelieving',
slackInvited: true,
craftTags: ['production-management', 'business-development', 'education-and-mentoring'],
- board: {
- topics: [
- { tagSlug: 'governance', state: 'help' },
- { tagSlug: 'democratic-management', state: 'help' },
- { tagSlug: 'cooperative-bylaws', state: 'interested' },
- { tagSlug: 'member-onboarding', state: 'help' },
- { tagSlug: 'inter-coop-collaboration', state: 'help' },
- ],
- offerPeerSupport: true,
- slackHandle: 'quinn.f',
- },
+ board: { slackHandle: 'quinn.f' },
createdAt: new Date('2025-04-01'),
lastLogin: new Date('2026-04-14'),
},
@@ -446,16 +257,7 @@ const sampleMembers = [
avatar: 'sweet',
slackInvited: true,
craftTags: ['ux-and-ui-design', 'accessibility', 'narrative-design'],
- board: {
- topics: [
- { tagSlug: 'platform-cooperativism', state: 'interested' },
- { tagSlug: 'cooperative-marketing', state: 'seeking' },
- { tagSlug: 'shared-resources', state: 'interested' },
- { tagSlug: 'sustainability', state: 'seeking' },
- { tagSlug: 'equity-and-inclusion', state: 'interested' },
- ],
- offerPeerSupport: false,
- },
+ board: {},
createdAt: new Date('2025-05-10'),
lastLogin: new Date('2026-04-09'),
},
@@ -468,35 +270,13 @@ const sampleMembers = [
avatar: 'mild',
slackInvited: true,
craftTags: ['audio-and-music', 'localization'],
- board: {
- topics: [
- { tagSlug: 'collective-bargaining', state: 'seeking' },
- { tagSlug: 'revenue-sharing', state: 'seeking' },
- { tagSlug: 'worker-ownership', state: 'interested' },
- ],
- offerPeerSupport: true,
- slackHandle: 'indigo.r',
- },
+ board: { slackHandle: 'indigo.r' },
createdAt: new Date('2025-06-01'),
lastLogin: new Date('2026-04-04'),
},
]
-// Board topics for the test admin so the logged-in user sees matches
const TEST_ADMIN_BOARD = {
- topics: [
- { tagSlug: 'governance', state: 'interested' },
- { tagSlug: 'worker-ownership', state: 'seeking' },
- { tagSlug: 'cooperative-tech', state: 'interested' },
- { tagSlug: 'community-building', state: 'seeking' },
- { tagSlug: 'equity-and-inclusion', state: 'interested' },
- { tagSlug: 'revenue-sharing', state: 'seeking' },
- { tagSlug: 'cooperative-funding', state: 'interested' },
- { tagSlug: 'sustainability', state: 'interested' },
- { tagSlug: 'consensus-decision-making', state: 'seeking' },
- { tagSlug: 'platform-cooperativism', state: 'interested' },
- ],
- offerPeerSupport: true,
slackHandle: 'test-admin',
}
@@ -508,7 +288,7 @@ async function seedMembers() {
await Member.deleteMany({ email: { $ne: 'test-admin@ghostguild.dev' } })
console.log('Cleared existing members (kept test admin)')
- // Update test admin with board topics so the Board page shows matches
+ // Update test admin with slack handle + craft tags
const adminUpdate = await Member.findOneAndUpdate(
{ email: 'test-admin@ghostguild.dev' },
{
@@ -519,7 +299,7 @@ async function seedMembers() {
},
)
if (adminUpdate) {
- console.log('Updated test admin with board topics')
+ console.log('Updated test admin with board + craft tags')
} else {
console.log('Test admin not found — run /api/dev/test-login first to create it')
}
@@ -539,11 +319,8 @@ async function seedMembers() {
console.log('\nBreakdown by circle:')
circleBreakdown.forEach((c) => console.log(` ${c._id}: ${c.count}`))
- const withTopics = await Member.countDocuments({ 'board.topics.0': { $exists: true } })
- console.log(`\nMembers with board topics: ${withTopics}`)
-
const withSlack = await Member.countDocuments({ 'board.slackHandle': { $exists: true, $ne: null } })
- console.log(`Members with slack handles: ${withSlack}`)
+ console.log(`\nMembers with slack handles: ${withSlack}`)
process.exit(0)
} catch (error) {
diff --git a/server/api/admin/board-channels.post.js b/server/api/admin/board-channels.post.js
new file mode 100644
index 0000000..eea3efd
--- /dev/null
+++ b/server/api/admin/board-channels.post.js
@@ -0,0 +1,64 @@
+import BoardChannel from '../../models/boardChannel.js'
+import { requireAdmin } from '../../utils/auth.js'
+import { validateBody } from '../../utils/validateBody.js'
+import { boardChannelCreateSchema } from '../../utils/schemas.js'
+import { getSlackAdminService } from '../../utils/slack.ts'
+
+export default defineEventHandler(async (event) => {
+ await requireAdmin(event)
+
+ const body = await validateBody(event, boardChannelCreateSchema)
+
+ if (body.tagSlugs && body.tagSlugs.length) {
+ const conflict = await BoardChannel.findOne({ tagSlugs: { $in: body.tagSlugs } }).lean()
+ if (conflict) {
+ const taken = (conflict.tagSlugs || []).filter((s) => body.tagSlugs.includes(s))
+ throw createError({
+ statusCode: 409,
+ statusMessage: `Tag${taken.length > 1 ? 's' : ''} already mapped to "${conflict.name}": ${taken.join(', ')}`,
+ })
+ }
+ }
+
+ let slackChannelId = body.slackChannelId
+ let channelName = body.name
+
+ if (!slackChannelId) {
+ const slack = getSlackAdminService()
+ if (!slack) {
+ throw createError({
+ statusCode: 500,
+ statusMessage: 'Slack integration not configured',
+ })
+ }
+ try {
+ const created = await slack.createChannel(body.name)
+ slackChannelId = created.id
+ channelName = created.name
+ } catch (err) {
+ throw createError({
+ statusCode: 502,
+ statusMessage: `Failed to create Slack channel: ${err.data?.error || err.message}`,
+ })
+ }
+ }
+
+ try {
+ const channel = await BoardChannel.create({
+ name: channelName,
+ slackChannelId,
+ tagSlugs: body.tagSlugs || []
+ })
+
+ setResponseStatus(event, 201)
+ return { channel: channel.toObject() }
+ } catch (err) {
+ if (err.code === 11000) {
+ throw createError({
+ statusCode: 409,
+ statusMessage: 'A channel with that Slack channel ID already exists'
+ })
+ }
+ throw err
+ }
+})
diff --git a/server/api/admin/board-channels/[id].delete.js b/server/api/admin/board-channels/[id].delete.js
new file mode 100644
index 0000000..cae3bca
--- /dev/null
+++ b/server/api/admin/board-channels/[id].delete.js
@@ -0,0 +1,14 @@
+import BoardChannel from '../../../models/boardChannel.js'
+import { requireAdmin } from '../../../utils/auth.js'
+
+export default defineEventHandler(async (event) => {
+ await requireAdmin(event)
+ const id = getRouterParam(event, 'id')
+
+ const channel = await BoardChannel.findByIdAndDelete(id)
+ if (!channel) {
+ throw createError({ statusCode: 404, statusMessage: 'Channel not found' })
+ }
+
+ return { success: true }
+})
diff --git a/server/api/admin/board-channels/[id].patch.js b/server/api/admin/board-channels/[id].patch.js
new file mode 100644
index 0000000..3f22e72
--- /dev/null
+++ b/server/api/admin/board-channels/[id].patch.js
@@ -0,0 +1,53 @@
+import BoardChannel from '../../../models/boardChannel.js'
+import { requireAdmin } from '../../../utils/auth.js'
+import { validateBody } from '../../../utils/validateBody.js'
+import { boardChannelUpdateSchema } from '../../../utils/schemas.js'
+
+export default defineEventHandler(async (event) => {
+ await requireAdmin(event)
+ const id = getRouterParam(event, 'id')
+
+ const body = await validateBody(event, boardChannelUpdateSchema)
+
+ const updateData = {}
+ if (body.name !== undefined) updateData.name = body.name
+ if (body.slackChannelId !== undefined) updateData.slackChannelId = body.slackChannelId
+ if (body.tagSlugs !== undefined) updateData.tagSlugs = body.tagSlugs
+
+ if (body.tagSlugs && body.tagSlugs.length) {
+ const conflict = await BoardChannel.findOne({
+ _id: { $ne: id },
+ tagSlugs: { $in: body.tagSlugs },
+ }).lean()
+ if (conflict) {
+ const taken = (conflict.tagSlugs || []).filter((s) => body.tagSlugs.includes(s))
+ throw createError({
+ statusCode: 409,
+ statusMessage: `Tag${taken.length > 1 ? 's' : ''} already mapped to "${conflict.name}": ${taken.join(', ')}`,
+ })
+ }
+ }
+
+ try {
+ const channel = await BoardChannel.findByIdAndUpdate(
+ id,
+ { $set: updateData },
+ { new: true, runValidators: true }
+ )
+
+ if (!channel) {
+ throw createError({ statusCode: 404, statusMessage: 'Channel not found' })
+ }
+
+ return { channel: channel.toObject() }
+ } catch (err) {
+ if (err.statusCode) throw err
+ if (err.code === 11000) {
+ throw createError({
+ statusCode: 409,
+ statusMessage: 'A channel with that Slack channel ID already exists'
+ })
+ }
+ throw err
+ }
+})
diff --git a/server/api/board/channels.get.js b/server/api/board/channels.get.js
new file mode 100644
index 0000000..98852cf
--- /dev/null
+++ b/server/api/board/channels.get.js
@@ -0,0 +1,10 @@
+import BoardChannel from '../../models/boardChannel.js'
+import { requireAuth } from '../../utils/auth.js'
+
+export default defineEventHandler(async (event) => {
+ await requireAuth(event)
+
+ const channels = await BoardChannel.find({}).sort({ name: 1 }).lean()
+
+ return { channels }
+})
diff --git a/server/api/board/posts.get.js b/server/api/board/posts.get.js
new file mode 100644
index 0000000..f170df4
--- /dev/null
+++ b/server/api/board/posts.get.js
@@ -0,0 +1,24 @@
+import BoardPost from '../../models/boardPost.js'
+import { requireAuth } from '../../utils/auth.js'
+
+export default defineEventHandler(async (event) => {
+ const member = await requireAuth(event)
+
+ const query = getQuery(event)
+ const dbQuery = {}
+
+ if (query.tag) {
+ dbQuery.tags = query.tag
+ }
+
+ if (query.author) {
+ dbQuery.author = query.author === 'me' ? member._id : query.author
+ }
+
+ const posts = await BoardPost.find(dbQuery)
+ .sort({ createdAt: -1 })
+ .populate('author', 'name avatar circle board.slackHandle')
+ .lean()
+
+ return { posts }
+})
diff --git a/server/api/board/posts.post.js b/server/api/board/posts.post.js
new file mode 100644
index 0000000..d14c5ee
--- /dev/null
+++ b/server/api/board/posts.post.js
@@ -0,0 +1,28 @@
+import BoardPost from '../../models/boardPost.js'
+import { requireAuth } from '../../utils/auth.js'
+import { validateBody } from '../../utils/validateBody.js'
+import { boardPostCreateSchema } from '../../utils/schemas.js'
+import { logActivity, ACTIVITY_TYPES } from '../../utils/activityLog.js'
+
+export default defineEventHandler(async (event) => {
+ const member = await requireAuth(event)
+
+ const body = await validateBody(event, boardPostCreateSchema)
+
+ const post = new BoardPost({
+ author: member._id,
+ title: body.title,
+ seeking: body.seeking,
+ offering: body.offering,
+ note: body.note,
+ tags: body.tags || []
+ })
+
+ await post.save()
+ await post.populate('author', 'name avatar circle board.slackHandle')
+
+ logActivity(member._id, ACTIVITY_TYPES.BOARD_POST_CREATED, { postId: post._id, title: post.title })
+
+ setResponseStatus(event, 201)
+ return { post: post.toObject() }
+})
diff --git a/server/api/board/posts/[id].delete.js b/server/api/board/posts/[id].delete.js
new file mode 100644
index 0000000..cb810ef
--- /dev/null
+++ b/server/api/board/posts/[id].delete.js
@@ -0,0 +1,20 @@
+import BoardPost from '../../../models/boardPost.js'
+import { requireAuth } from '../../../utils/auth.js'
+
+export default defineEventHandler(async (event) => {
+ const member = await requireAuth(event)
+ const id = getRouterParam(event, 'id')
+
+ const post = await BoardPost.findById(id)
+ if (!post) {
+ throw createError({ statusCode: 404, statusMessage: 'Post not found' })
+ }
+
+ if (post.author.toString() !== member._id.toString()) {
+ throw createError({ statusCode: 403, statusMessage: 'Not authorized to delete this post' })
+ }
+
+ await post.deleteOne()
+
+ return { success: true }
+})
diff --git a/server/api/board/posts/[id].patch.js b/server/api/board/posts/[id].patch.js
new file mode 100644
index 0000000..4b04fa9
--- /dev/null
+++ b/server/api/board/posts/[id].patch.js
@@ -0,0 +1,52 @@
+import BoardPost from '../../../models/boardPost.js'
+import { requireAuth } from '../../../utils/auth.js'
+import { validateBody } from '../../../utils/validateBody.js'
+import { boardPostUpdateSchema } from '../../../utils/schemas.js'
+
+export default defineEventHandler(async (event) => {
+ const member = await requireAuth(event)
+ const id = getRouterParam(event, 'id')
+
+ const body = await validateBody(event, boardPostUpdateSchema)
+
+ const post = await BoardPost.findById(id)
+ if (!post) {
+ throw createError({ statusCode: 404, statusMessage: 'Post not found' })
+ }
+
+ if (post.author.toString() !== member._id.toString()) {
+ throw createError({ statusCode: 403, statusMessage: 'Not authorized to edit this post' })
+ }
+
+ if (body.title !== undefined) post.title = body.title
+ if (body.seeking !== undefined) post.seeking = body.seeking
+ if (body.offering !== undefined) post.offering = body.offering
+ if (body.note !== undefined) post.note = body.note
+ if (body.tags !== undefined) post.tags = body.tags
+
+ const seeking = (post.seeking || '').trim()
+ const offering = (post.offering || '').trim()
+ if (!seeking && !offering) {
+ throw createError({
+ statusCode: 400,
+ statusMessage: 'At least one of seeking or offering must be provided'
+ })
+ }
+
+ try {
+ await post.save()
+ } catch (err) {
+ if (err.name === 'ValidationError') {
+ throw createError({
+ statusCode: 400,
+ statusMessage: 'Validation failed',
+ data: err.errors
+ })
+ }
+ throw err
+ }
+
+ await post.populate('author', 'name avatar circle board.slackHandle')
+
+ return { post: post.toObject() }
+})
diff --git a/server/api/board/suggestions.get.js b/server/api/board/suggestions.get.js
deleted file mode 100644
index 103a521..0000000
--- a/server/api/board/suggestions.get.js
+++ /dev/null
@@ -1,96 +0,0 @@
-import Member from '../../models/member.js'
-import { requireAuth } from '../../utils/auth.js'
-
-export default defineEventHandler(async (event) => {
- const member = await requireAuth(event)
- const memberId = member._id
-
- const topics = member.board?.topics || []
- if (!topics.length) {
- return { suggestions: [] }
- }
-
- const query = getQuery(event)
- const filterTag = query.tag || null
-
- let myTopics = topics
- if (filterTag) {
- myTopics = myTopics.filter((t) => t.tagSlug === filterTag)
- }
- if (!myTopics.length) {
- return { suggestions: [] }
- }
-
- const mySlugs = myTopics.map((t) => t.tagSlug)
-
- const candidates = await Member.find({
- _id: { $ne: memberId },
- status: 'active',
- 'board.topics.tagSlug': { $in: mySlugs },
- })
- .select('name avatar craftTags circle board privacy')
- .lean()
-
- if (!candidates.length) {
- return { suggestions: [] }
- }
-
- const myTopicMap = {}
- for (const t of myTopics) {
- myTopicMap[t.tagSlug] = t.state
- }
-
- const suggestions = []
- for (const candidate of candidates) {
- const theirTopics = candidate.board?.topics || []
- const matchingTags = []
-
- for (const theirTopic of theirTopics) {
- const myState = myTopicMap[theirTopic.tagSlug]
- if (!myState) continue
-
- matchingTags.push({
- tagSlug: theirTopic.tagSlug,
- yourState: myState,
- theirState: theirTopic.state,
- })
- }
-
- if (!matchingTags.length) continue
-
- // Privacy filter: only expose fields the candidate allows to other members
- const privacy = candidate.privacy || {}
- const filtered = {
- _id: candidate._id,
- name: candidate.name,
- circle: candidate.circle,
- }
-
- const avatarPrivacy = privacy.avatar || 'public'
- if (avatarPrivacy === 'public' || avatarPrivacy === 'members') {
- filtered.avatar = candidate.avatar
- }
-
- const craftTagsPrivacy = privacy.craftTags || 'members'
- if (craftTagsPrivacy === 'public' || craftTagsPrivacy === 'members') {
- filtered.craftTags = candidate.craftTags
- }
-
- // Expose slackHandle only when the candidate has opted into peer support.
- // Slack handle is the contact-in-place path — without it, there is no way
- // for the current member to reach out.
- if (candidate.board?.offerPeerSupport && candidate.board?.slackHandle) {
- filtered.slackHandle = candidate.board.slackHandle
- }
-
- suggestions.push({
- member: filtered,
- matchingTags,
- matchCount: matchingTags.length,
- })
- }
-
- suggestions.sort((a, b) => b.matchCount - a.matchCount)
-
- return { suggestions }
-})
diff --git a/server/api/members/[id].get.js b/server/api/members/[id].get.js
index c5d910d..02546f2 100644
--- a/server/api/members/[id].get.js
+++ b/server/api/members/[id].get.js
@@ -70,21 +70,9 @@ export default defineEventHandler(async (event) => {
if (isVisible("socialLinks")) filtered.socialLinks = member.socialLinks;
if (isVisible("craftTags")) filtered.craftTags = member.craftTags;
- if (isVisible("board")) {
- const board = member.board || {};
- filtered.board = {
- topics: board.topics,
- offerPeerSupport: board.offerPeerSupport,
- availability: board.availability,
- details: board.details,
- // Contact-in-place: surface the handle + personal message only when
- // the member has explicitly opted into peer support.
- ...(board.offerPeerSupport && {
- slackHandle: board.slackHandle,
- personalMessage: board.personalMessage,
- }),
- };
- }
+ filtered.board = {
+ slackHandle: member.board?.slackHandle,
+ };
return { member: filtered };
} catch (error) {
diff --git a/server/api/members/directory.get.js b/server/api/members/directory.get.js
index ecf8c3b..c0b7abc 100644
--- a/server/api/members/directory.get.js
+++ b/server/api/members/directory.get.js
@@ -22,9 +22,7 @@ export default defineEventHandler(async (event) => {
const query = getQuery(event);
const search = query.search || "";
const circle = query.circle || "";
- const peerSupport = query.peerSupport || "";
const craftTag = query.craftTag || "";
- const connectionTag = query.connectionTag || "";
const dbQuery = {
showInDirectory: true,
@@ -37,10 +35,6 @@ export default defineEventHandler(async (event) => {
const andConditions = [];
- if (peerSupport === "true") {
- dbQuery["board.offerPeerSupport"] = true;
- }
-
if (search) {
const escaped = escapeRegex(search);
andConditions.push({
@@ -55,10 +49,6 @@ export default defineEventHandler(async (event) => {
dbQuery.craftTags = craftTag;
}
- if (connectionTag) {
- dbQuery["board.topics.tagSlug"] = connectionTag;
- }
-
if (andConditions.length > 0) {
dbQuery.$and = andConditions;
}
@@ -96,17 +86,9 @@ export default defineEventHandler(async (event) => {
if (isVisible("socialLinks")) filtered.socialLinks = member.socialLinks;
if (isVisible("craftTags")) filtered.craftTags = member.craftTags;
- if (isVisible("board")) {
- const board = member.board || {};
- filtered.board = {
- topics: board.topics,
- offerPeerSupport: board.offerPeerSupport,
- availability: board.availability,
- ...(board.offerPeerSupport && {
- slackHandle: board.slackHandle,
- }),
- };
- }
+ filtered.board = {
+ slackHandle: member.board?.slackHandle,
+ };
return filtered;
});
diff --git a/server/api/members/me/board.patch.js b/server/api/members/me/board.patch.js
deleted file mode 100644
index 3955429..0000000
--- a/server/api/members/me/board.patch.js
+++ /dev/null
@@ -1,70 +0,0 @@
-import Member from '../../../models/member.js'
-import { connectDB } from '../../../utils/mongoose.js'
-
-export default defineEventHandler(async (event) => {
- await connectDB()
- const member = await requireAuth(event)
-
- const body = await validateBody(event, boardUpdateSchema)
-
- const updateData = {
- 'board.topics': body.topics || [],
- 'board.offerPeerSupport': body.offerPeerSupport || false,
- 'board.availability': body.availability || '',
- 'board.slackHandle': body.slackHandle || '',
- 'board.personalMessage': body.personalMessage || '',
- 'board.details': body.details || '',
- }
-
- if (body.offerPeerSupport && body.slackHandle) {
- try {
- const { getSlackService } = await import('../../../utils/slack.ts')
- const slackService = getSlackService()
-
- if (slackService) {
- const slackUserId = await slackService.findUserIdByUsername(body.slackHandle)
- if (slackUserId) {
- updateData.slackUserId = slackUserId
- } else {
- console.warn(
- `[Board] Could not find Slack user ID for handle: ${body.slackHandle}`,
- )
- }
- }
- } catch (error) {
- console.error('[Board] Error fetching Slack user ID:', error.message)
- }
- }
-
- try {
- const updated = await Member.findByIdAndUpdate(
- member._id,
- { $set: updateData },
- { new: true, runValidators: true },
- )
-
- if (!updated) {
- throw createError({
- statusCode: 404,
- statusMessage: 'Member not found',
- })
- }
-
- logActivity(member._id, 'board_updated', {
- topicCount: (body.topics || []).length,
- offerPeerSupport: body.offerPeerSupport || false,
- })
-
- return {
- success: true,
- board: updated.board,
- }
- } catch (error) {
- if (error.statusCode) throw error
- console.error('Board update error:', error)
- throw createError({
- statusCode: 500,
- statusMessage: 'Failed to update board settings',
- })
- }
-})
diff --git a/server/api/members/profile.patch.js b/server/api/members/profile.patch.js
index 7ce74d1..1cf266a 100644
--- a/server/api/members/profile.patch.js
+++ b/server/api/members/profile.patch.js
@@ -32,7 +32,6 @@ export default defineEventHandler(async (event) => {
"locationPrivacy",
"socialLinksPrivacy",
"craftTagsPrivacy",
- "boardPrivacy",
];
// Build update object from validated data
@@ -49,6 +48,11 @@ export default defineEventHandler(async (event) => {
updateData.craftTags = body.craftTags;
}
+ // Handle board slack handle
+ if (body.boardSlackHandle !== undefined) {
+ updateData["board.slackHandle"] = body.boardSlackHandle;
+ }
+
// Handle privacy settings
privacyFields.forEach((privacyField) => {
if (body[privacyField] !== undefined) {
diff --git a/server/api/onboarding/status.get.js b/server/api/onboarding/status.get.js
index 33efffe..c1aa1fa 100644
--- a/server/api/onboarding/status.get.js
+++ b/server/api/onboarding/status.get.js
@@ -1,18 +1,16 @@
import { requireAuth } from '../../utils/auth.js'
+import BoardPost from '../../models/boardPost.js'
export default defineEventHandler(async (event) => {
const member = await requireAuth(event)
- const hasProfileTags =
- member.craftTags.length > 0 &&
- (member.board?.topics || []).length > 0
+ const hasProfileTags = member.craftTags.length > 0
const hasVisitedEvent = !!member.onboarding?.eventPageVisited
- const topics = member.board?.topics || []
+ const hasPosted = await BoardPost.exists({ author: member._id })
const hasEngagedBoard =
- !!member.onboarding?.boardPageVisited &&
- topics.some((t) => ['help', 'interested', 'seeking'].includes(t.state))
+ !!member.onboarding?.boardPageVisited && !!hasPosted
const hasClickedWiki = !!member.onboarding?.wikiClicked
diff --git a/server/api/onboarding/track.post.js b/server/api/onboarding/track.post.js
index adbb7da..345d9f8 100644
--- a/server/api/onboarding/track.post.js
+++ b/server/api/onboarding/track.post.js
@@ -2,6 +2,7 @@ import { requireAuth } from '../../utils/auth.js'
import { validateBody } from '../../utils/validateBody.js'
import { onboardingTrackSchema } from '../../utils/schemas.js'
import Member from '../../models/member.js'
+import BoardPost from '../../models/boardPost.js'
import { logActivity } from '../../utils/activityLog.js'
export default defineEventHandler(async (event) => {
@@ -26,22 +27,24 @@ export default defineEventHandler(async (event) => {
// Log the individual goal completion
await logActivity(member._id, 'member_onboarding_goal_completed', { goal }, { visibility: 'admin' })
+ // Must have at least one board post to graduate
+ const hasPosted = await BoardPost.exists({ author: member._id })
+
// Graduation check — atomic so concurrent requests can't double-graduate
- const graduated = await Member.findOneAndUpdate(
- {
- _id: member._id,
- 'onboarding.completedAt': null,
- 'onboarding.eventPageVisited': true,
- 'onboarding.boardPageVisited': true,
- 'onboarding.wikiClicked': true,
- 'craftTags.0': { $exists: true },
- 'board.topics': {
- $elemMatch: { state: { $in: ['help', 'interested', 'seeking'] } },
- },
- },
- { $set: { 'onboarding.completedAt': new Date() } },
- { new: true }
- )
+ const graduated = hasPosted
+ ? await Member.findOneAndUpdate(
+ {
+ _id: member._id,
+ 'onboarding.completedAt': null,
+ 'onboarding.eventPageVisited': true,
+ 'onboarding.boardPageVisited': true,
+ 'onboarding.wikiClicked': true,
+ 'craftTags.0': { $exists: true },
+ },
+ { $set: { 'onboarding.completedAt': new Date() } },
+ { new: true }
+ )
+ : null
if (graduated) {
await logActivity(member._id, 'member_onboarding_completed', {}, { visibility: 'admin' })
diff --git a/server/models/boardChannel.js b/server/models/boardChannel.js
new file mode 100644
index 0000000..dc4a599
--- /dev/null
+++ b/server/models/boardChannel.js
@@ -0,0 +1,11 @@
+import mongoose from 'mongoose'
+
+const boardChannelSchema = new mongoose.Schema({
+ name: { type: String, required: true },
+ slackChannelId: { type: String, required: true },
+ tagSlugs: [String],
+}, { timestamps: true })
+
+boardChannelSchema.index({ slackChannelId: 1 }, { unique: true })
+
+export default mongoose.models.BoardChannel || mongoose.model('BoardChannel', boardChannelSchema)
diff --git a/server/models/boardPost.js b/server/models/boardPost.js
new file mode 100644
index 0000000..7ea7080
--- /dev/null
+++ b/server/models/boardPost.js
@@ -0,0 +1,28 @@
+import mongoose from 'mongoose'
+
+const boardPostSchema = new mongoose.Schema({
+ author: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'Member',
+ required: true,
+ },
+ title: { type: String, required: true, maxlength: 120 },
+ seeking: { type: String, maxlength: 500 },
+ offering: { type: String, maxlength: 500 },
+ note: { type: String, maxlength: 300 },
+ tags: [String],
+}, { timestamps: true })
+
+boardPostSchema.pre('validate', function (next) {
+ const seeking = (this.seeking || '').trim()
+ const offering = (this.offering || '').trim()
+ if (!seeking && !offering) {
+ this.invalidate('seeking', 'At least one of seeking or offering must be provided')
+ }
+ next()
+})
+
+boardPostSchema.index({ author: 1 })
+boardPostSchema.index({ createdAt: -1 })
+
+export default mongoose.models.BoardPost || mongoose.model('BoardPost', boardPostSchema)
diff --git a/server/models/member.js b/server/models/member.js
index 59367ab..3dd5e3f 100644
--- a/server/models/member.js
+++ b/server/models/member.js
@@ -73,17 +73,7 @@ const memberSchema = new mongoose.Schema({
craftTags: [String],
board: {
- topics: [
- {
- tagSlug: String,
- state: { type: String, enum: ['help', 'interested', 'seeking'] },
- },
- ],
- offerPeerSupport: { type: Boolean, default: false },
- availability: String,
slackHandle: String,
- personalMessage: String,
- details: String,
},
// Privacy settings for profile fields
@@ -128,11 +118,6 @@ const memberSchema = new mongoose.Schema({
enum: ["public", "members", "private"],
default: "members",
},
- board: {
- type: String,
- enum: ["public", "members", "private"],
- default: "members",
- },
},
notifications: {
diff --git a/server/utils/activityLog.js b/server/utils/activityLog.js
index 05bfc08..afbe5a2 100644
--- a/server/utils/activityLog.js
+++ b/server/utils/activityLog.js
@@ -18,6 +18,7 @@ export const ACTIVITY_TYPES = {
EMAIL_SENT: 'email_sent',
COMMUNITY_CONNECTIONS_UPDATED: 'community_connections_updated',
BOARD_UPDATED: 'board_updated',
+ BOARD_POST_CREATED: 'board_post_created',
CONNECTION_REQUESTED: 'connection_requested',
CONNECTION_CONFIRMED: 'connection_confirmed',
TAG_SUGGESTED: 'tag_suggested'
@@ -41,6 +42,7 @@ export const ACTIVITY_TYPE_DEFAULTS = {
email_sent: 'member',
community_connections_updated: 'member',
board_updated: 'member',
+ board_post_created: 'member',
connection_requested: 'member',
connection_confirmed: 'member',
tag_suggested: 'member'
diff --git a/server/utils/schemas.js b/server/utils/schemas.js
index f152988..34d9290 100644
--- a/server/utils/schemas.js
+++ b/server/utils/schemas.js
@@ -41,7 +41,7 @@ export const memberProfileUpdateSchema = z.object({
socialLinksPrivacy: privacyEnum.optional(),
craftTags: z.array(z.string().max(100)).max(16).optional(),
craftTagsPrivacy: privacyEnum.optional(),
- boardPrivacy: privacyEnum.optional()
+ boardSlackHandle: z.string().max(200).optional()
})
export const eventRegistrationSchema = z.object({
@@ -377,16 +377,37 @@ export const tagSuggestionSchema = z.object({
pool: z.enum(['craft', 'cooperative'])
})
-export const boardUpdateSchema = z.object({
- topics: z.array(z.object({
- tagSlug: z.string().min(1).max(100),
- state: z.enum(['help', 'interested', 'seeking'])
- })).max(20).optional(),
- offerPeerSupport: z.boolean().optional(),
- availability: z.string().max(500).optional(),
- slackHandle: z.string().max(200).optional(),
- personalMessage: z.string().max(2000).optional(),
- details: z.string().max(300).optional()
+// --- Board post / channel schemas ---
+
+export const boardPostCreateSchema = z.object({
+ title: z.string().trim().min(1).max(120),
+ seeking: z.string().max(500).optional(),
+ offering: z.string().max(500).optional(),
+ note: z.string().max(300).optional(),
+ tags: z.array(z.string().max(100)).optional().default([])
+}).refine(
+ (data) => (data.seeking || '').trim().length > 0 || (data.offering || '').trim().length > 0,
+ { message: 'At least one of seeking or offering must be provided', path: ['seeking'] }
+)
+
+export const boardPostUpdateSchema = z.object({
+ title: z.string().trim().min(1).max(120).optional(),
+ seeking: z.string().max(500).optional(),
+ offering: z.string().max(500).optional(),
+ note: z.string().max(300).optional(),
+ tags: z.array(z.string().max(100)).optional()
+})
+
+export const boardChannelCreateSchema = z.object({
+ name: z.string().trim().min(1).max(200),
+ slackChannelId: z.string().trim().min(1).max(50).regex(/^[A-Z0-9]+$/, 'Invalid Slack channel ID').optional(),
+ tagSlugs: z.array(z.string().max(100)).optional().default([])
+})
+
+export const boardChannelUpdateSchema = z.object({
+ name: z.string().trim().min(1).max(200).optional(),
+ slackChannelId: z.string().trim().min(1).max(50).regex(/^[A-Z0-9]+$/, 'Invalid Slack channel ID').optional(),
+ tagSlugs: z.array(z.string().max(100)).optional()
})
// --- Admin alert schemas ---
diff --git a/server/utils/slack.ts b/server/utils/slack.ts
index 875bae3..2137ba2 100644
--- a/server/utils/slack.ts
+++ b/server/utils/slack.ts
@@ -262,6 +262,33 @@ export class SlackService {
}
}
+ /**
+ * Create a new Slack channel. Returns the new channel id and normalized name.
+ */
+ async createChannel(
+ name: string,
+ isPrivate: boolean = false,
+ ): Promise<{ id: string; name: string }> {
+ const normalized = name
+ .trim()
+ .replace(/^#/, '')
+ .toLowerCase()
+ .replace(/[^a-z0-9_-]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 80)
+
+ const response = await this.client.conversations.create({
+ name: normalized,
+ is_private: isPrivate,
+ })
+
+ if (!response.ok || !response.channel?.id || !response.channel?.name) {
+ throw new Error(`Slack create failed: ${response.error || 'unknown'}`)
+ }
+
+ return { id: response.channel.id, name: response.channel.name }
+ }
+
/**
* Verify the Slack channel exists and bot has access
*/
@@ -292,3 +319,36 @@ export function getSlackService(): SlackService | null {
return new SlackService(config.slackBotToken, config.slackVettingChannelId);
}
+
+/**
+ * Get a SlackService for operations that don't need the vetting channel.
+ */
+export function getSlackServiceNoVetting(): SlackService | null {
+ const config = useRuntimeConfig();
+
+ if (!config.slackBotToken) {
+ console.warn("Slack integration not configured - missing bot token");
+ return null;
+ }
+
+ return new SlackService(config.slackBotToken, "");
+}
+
+/**
+ * Get a SlackService backed by the AdminGhost app token for admin-only
+ * operations like channel creation. Falls back to the main bot token if
+ * AdminGhost isn't configured.
+ */
+export function getSlackAdminService(): SlackService | null {
+ const config = useRuntimeConfig();
+
+ const token = config.slackAdminBotToken || config.slackBotToken;
+ if (!token) {
+ console.warn(
+ "Slack admin integration not configured - missing admin bot token",
+ );
+ return null;
+ }
+
+ return new SlackService(token, "");
+}
diff --git a/tests/client/composables/useOnboarding.test.js b/tests/client/composables/useOnboarding.test.js
index e48a481..def57f5 100644
--- a/tests/client/composables/useOnboarding.test.js
+++ b/tests/client/composables/useOnboarding.test.js
@@ -100,7 +100,6 @@ describe('useOnboarding', () => {
})
}
if (url === '/api/events/recommended') return Promise.resolve([])
- if (url === '/api/board/suggestions') return Promise.resolve({ suggestions: [] })
if (url === '/api/wiki/recommended') return Promise.resolve([])
return Promise.resolve(null)
})
@@ -252,7 +251,6 @@ describe('useOnboarding', () => {
})
}
if (url === '/api/events/recommended') return Promise.resolve([])
- if (url === '/api/board/suggestions') return Promise.resolve({ suggestions: [] })
if (url === '/api/wiki/recommended') return Promise.resolve([])
return Promise.resolve(null)
})
@@ -289,9 +287,6 @@ describe('useOnboarding', () => {
if (url === '/api/events/recommended') {
return Promise.resolve([{ _id: 'e1', title: 'Game Jam' }])
}
- if (url === '/api/board/suggestions') {
- return Promise.resolve({ suggestions: [{ name: 'Alex' }] })
- }
if (url === '/api/wiki/recommended') {
return Promise.resolve([{ title: 'Co-op Guide', url: 'https://wiki.example.com/coop' }])
}
@@ -329,9 +324,6 @@ describe('useOnboarding', () => {
if (url === '/api/events/recommended') {
return Promise.resolve([{ _id: 'e1', title: 'Game Jam' }])
}
- if (url === '/api/board/suggestions') {
- return Promise.resolve({ suggestions: [] })
- }
if (url === '/api/wiki/recommended') {
return Promise.resolve([])
}
@@ -373,7 +365,6 @@ describe('useOnboarding', () => {
})
}
if (url === '/api/events/recommended') return Promise.resolve([])
- if (url === '/api/board/suggestions') return Promise.resolve({ suggestions: [] })
if (url === '/api/wiki/recommended') return Promise.resolve([])
return Promise.resolve(null)
})
diff --git a/tests/server/api/board-channels.test.js b/tests/server/api/board-channels.test.js
new file mode 100644
index 0000000..a1cabc0
--- /dev/null
+++ b/tests/server/api/board-channels.test.js
@@ -0,0 +1,323 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { setResponseStatus } from 'h3'
+
+vi.stubGlobal('setResponseStatus', setResponseStatus)
+
+const { mockFind, mockFindOne, mockCreate, mockFindByIdAndUpdate, mockFindByIdAndDelete } = vi.hoisted(() => ({
+ mockFind: vi.fn(),
+ mockFindOne: vi.fn(),
+ mockCreate: vi.fn(),
+ mockFindByIdAndUpdate: vi.fn(),
+ mockFindByIdAndDelete: vi.fn(),
+}))
+
+vi.mock('../../../server/models/boardChannel.js', () => ({
+ default: {
+ find: mockFind,
+ findOne: mockFindOne,
+ create: mockCreate,
+ findByIdAndUpdate: mockFindByIdAndUpdate,
+ findByIdAndDelete: mockFindByIdAndDelete,
+ },
+}))
+
+vi.mock('../../../server/utils/auth.js', () => ({
+ requireAuth: vi.fn(),
+ requireAdmin: vi.fn(),
+}))
+
+vi.mock('../../../server/utils/validateBody.js', () => ({
+ validateBody: vi.fn(),
+}))
+
+vi.mock('../../../server/utils/schemas.js', () => ({
+ boardChannelCreateSchema: {},
+ boardChannelUpdateSchema: {},
+}))
+
+vi.mock('../../../server/utils/mongoose.js', () => ({
+ connectDB: vi.fn(),
+}))
+
+const { mockCreateSlackChannel } = vi.hoisted(() => ({
+ mockCreateSlackChannel: vi.fn(),
+}))
+
+vi.mock('../../../server/utils/slack.ts', () => ({
+ getSlackServiceNoVetting: () => ({
+ createChannel: mockCreateSlackChannel,
+ }),
+}))
+
+import { requireAuth, requireAdmin } from '../../../server/utils/auth.js'
+import { validateBody } from '../../../server/utils/validateBody.js'
+import getHandler from '../../../server/api/board/channels.get.js'
+import postHandler from '../../../server/api/admin/board-channels.post.js'
+import patchHandler from '../../../server/api/admin/board-channels/[id].patch.js'
+import deleteHandler from '../../../server/api/admin/board-channels/[id].delete.js'
+import { createMockEvent } from '../helpers/createMockEvent.js'
+
+describe('GET /api/board/channels', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ requireAuth.mockResolvedValue({ _id: 'member-1' })
+ })
+
+ it('returns channels for authenticated member', async () => {
+ const channels = [{ _id: 'c1', name: 'coop' }]
+ const chain = {
+ sort: vi.fn().mockReturnThis(),
+ lean: vi.fn().mockResolvedValue(channels),
+ }
+ mockFind.mockReturnValue(chain)
+
+ const event = createMockEvent({ method: 'GET', path: '/api/board/channels' })
+ const result = await getHandler(event)
+
+ expect(mockFind).toHaveBeenCalledWith({})
+ expect(chain.sort).toHaveBeenCalledWith({ name: 1 })
+ expect(result).toEqual({ channels })
+ })
+
+ it('requires auth (401)', async () => {
+ requireAuth.mockRejectedValue(
+ createError({ statusCode: 401, statusMessage: 'Unauthorized' }),
+ )
+ const event = createMockEvent({ method: 'GET', path: '/api/board/channels' })
+ await expect(getHandler(event)).rejects.toMatchObject({ statusCode: 401 })
+ })
+})
+
+describe('POST /api/admin/board-channels', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ requireAdmin.mockResolvedValue({ _id: 'admin-1', role: 'admin' })
+ mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue(null) })
+ })
+
+ it('creates channel when admin sends valid data', async () => {
+ validateBody.mockResolvedValue({
+ name: 'coop-formation',
+ slackChannelId: 'C01234ABC',
+ tagSlugs: ['coop-formation'],
+ })
+ const created = {
+ _id: 'new-channel',
+ name: 'coop-formation',
+ slackChannelId: 'C01234ABC',
+ tagSlugs: ['coop-formation'],
+ toObject() {
+ return {
+ _id: this._id,
+ name: this.name,
+ slackChannelId: this.slackChannelId,
+ tagSlugs: this.tagSlugs,
+ }
+ },
+ }
+ mockCreate.mockResolvedValue(created)
+
+ const event = createMockEvent({ method: 'POST', path: '/api/admin/board-channels' })
+ const result = await postHandler(event)
+
+ expect(mockCreate).toHaveBeenCalledWith({
+ name: 'coop-formation',
+ slackChannelId: 'C01234ABC',
+ tagSlugs: ['coop-formation'],
+ })
+ expect(result.channel.slackChannelId).toBe('C01234ABC')
+ })
+
+ it('rejects missing required fields with 400', async () => {
+ validateBody.mockRejectedValue(
+ createError({ statusCode: 400, statusMessage: 'Validation failed' }),
+ )
+
+ const event = createMockEvent({ method: 'POST', path: '/api/admin/board-channels' })
+ await expect(postHandler(event)).rejects.toMatchObject({ statusCode: 400 })
+ })
+
+ it('rejects non-admin with 403', async () => {
+ requireAdmin.mockRejectedValue(
+ createError({ statusCode: 403, statusMessage: 'Forbidden' }),
+ )
+
+ const event = createMockEvent({ method: 'POST', path: '/api/admin/board-channels' })
+ await expect(postHandler(event)).rejects.toMatchObject({ statusCode: 403 })
+ })
+
+ it('returns 409 on duplicate slackChannelId', async () => {
+ validateBody.mockResolvedValue({
+ name: 'x',
+ slackChannelId: 'C01234ABC',
+ tagSlugs: [],
+ })
+ const dupErr = Object.assign(new Error('dup'), { code: 11000 })
+ mockCreate.mockRejectedValue(dupErr)
+
+ const event = createMockEvent({ method: 'POST', path: '/api/admin/board-channels' })
+ await expect(postHandler(event)).rejects.toMatchObject({ statusCode: 409 })
+ })
+
+ it('creates Slack channel via API when slackChannelId not provided', async () => {
+ validateBody.mockResolvedValue({
+ name: 'coop-formation',
+ tagSlugs: [],
+ })
+ mockCreateSlackChannel.mockResolvedValue({ id: 'C_NEW_123', name: 'coop-formation' })
+ const created = {
+ _id: 'new-ch',
+ name: 'coop-formation',
+ slackChannelId: 'C_NEW_123',
+ tagSlugs: [],
+ toObject() {
+ return {
+ _id: this._id,
+ name: this.name,
+ slackChannelId: this.slackChannelId,
+ tagSlugs: this.tagSlugs,
+ }
+ },
+ }
+ mockCreate.mockResolvedValue(created)
+
+ const event = createMockEvent({ method: 'POST', path: '/api/admin/board-channels' })
+ const result = await postHandler(event)
+
+ expect(mockCreateSlackChannel).toHaveBeenCalledWith('coop-formation')
+ expect(mockCreate).toHaveBeenCalledWith({
+ name: 'coop-formation',
+ slackChannelId: 'C_NEW_123',
+ tagSlugs: [],
+ })
+ expect(result.channel.slackChannelId).toBe('C_NEW_123')
+ })
+
+ it('returns 409 when a tag is already mapped to another channel', async () => {
+ validateBody.mockResolvedValue({
+ name: 'new-ch',
+ slackChannelId: 'C99999',
+ tagSlugs: ['coop-formation'],
+ })
+ mockFindOne.mockReturnValue({
+ lean: vi.fn().mockResolvedValue({
+ _id: 'existing',
+ name: 'old-ch',
+ tagSlugs: ['coop-formation'],
+ }),
+ })
+
+ const event = createMockEvent({ method: 'POST', path: '/api/admin/board-channels' })
+ await expect(postHandler(event)).rejects.toMatchObject({ statusCode: 409 })
+ expect(mockCreate).not.toHaveBeenCalled()
+ })
+})
+
+describe('PATCH /api/admin/board-channels/[id]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ requireAdmin.mockResolvedValue({ _id: 'admin-1', role: 'admin' })
+ mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue(null) })
+ })
+
+ it('updates a channel', async () => {
+ validateBody.mockResolvedValue({ name: 'renamed' })
+ const updated = {
+ _id: 'c1',
+ name: 'renamed',
+ toObject() {
+ return { _id: this._id, name: this.name }
+ },
+ }
+ mockFindByIdAndUpdate.mockResolvedValue(updated)
+
+ const event = createMockEvent({ method: 'PATCH', path: '/api/admin/board-channels/c1' })
+ event.context = { params: { id: 'c1' } }
+
+ const result = await patchHandler(event)
+
+ expect(mockFindByIdAndUpdate).toHaveBeenCalledWith(
+ 'c1',
+ { $set: { name: 'renamed' } },
+ { new: true, runValidators: true },
+ )
+ expect(result.channel.name).toBe('renamed')
+ })
+
+ it('returns 404 when channel not found', async () => {
+ validateBody.mockResolvedValue({ name: 'x' })
+ mockFindByIdAndUpdate.mockResolvedValue(null)
+
+ const event = createMockEvent({ method: 'PATCH', path: '/api/admin/board-channels/missing' })
+ event.context = { params: { id: 'missing' } }
+
+ await expect(patchHandler(event)).rejects.toMatchObject({ statusCode: 404 })
+ })
+
+ it('returns 409 when PATCH assigns a tag already owned by another channel', async () => {
+ validateBody.mockResolvedValue({ tagSlugs: ['coop-formation'] })
+ mockFindOne.mockReturnValue({
+ lean: vi.fn().mockResolvedValue({
+ _id: 'other',
+ name: 'other-ch',
+ tagSlugs: ['coop-formation'],
+ }),
+ })
+
+ const event = createMockEvent({ method: 'PATCH', path: '/api/admin/board-channels/c1' })
+ event.context = { params: { id: 'c1' } }
+
+ await expect(patchHandler(event)).rejects.toMatchObject({ statusCode: 409 })
+ expect(mockFindByIdAndUpdate).not.toHaveBeenCalled()
+ })
+
+ it('rejects non-admin with 403', async () => {
+ requireAdmin.mockRejectedValue(
+ createError({ statusCode: 403, statusMessage: 'Forbidden' }),
+ )
+
+ const event = createMockEvent({ method: 'PATCH', path: '/api/admin/board-channels/c1' })
+ event.context = { params: { id: 'c1' } }
+
+ await expect(patchHandler(event)).rejects.toMatchObject({ statusCode: 403 })
+ })
+})
+
+describe('DELETE /api/admin/board-channels/[id]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ requireAdmin.mockResolvedValue({ _id: 'admin-1', role: 'admin' })
+ })
+
+ it('deletes a channel', async () => {
+ mockFindByIdAndDelete.mockResolvedValue({ _id: 'c1' })
+
+ const event = createMockEvent({ method: 'DELETE', path: '/api/admin/board-channels/c1' })
+ event.context = { params: { id: 'c1' } }
+
+ const result = await deleteHandler(event)
+
+ expect(mockFindByIdAndDelete).toHaveBeenCalledWith('c1')
+ expect(result).toEqual({ success: true })
+ })
+
+ it('returns 404 when channel not found', async () => {
+ mockFindByIdAndDelete.mockResolvedValue(null)
+
+ const event = createMockEvent({ method: 'DELETE', path: '/api/admin/board-channels/missing' })
+ event.context = { params: { id: 'missing' } }
+
+ await expect(deleteHandler(event)).rejects.toMatchObject({ statusCode: 404 })
+ })
+
+ it('rejects non-admin with 403', async () => {
+ requireAdmin.mockRejectedValue(
+ createError({ statusCode: 403, statusMessage: 'Forbidden' }),
+ )
+
+ const event = createMockEvent({ method: 'DELETE', path: '/api/admin/board-channels/c1' })
+ event.context = { params: { id: 'c1' } }
+
+ await expect(deleteHandler(event)).rejects.toMatchObject({ statusCode: 403 })
+ })
+})
diff --git a/tests/server/api/board-posts.test.js b/tests/server/api/board-posts.test.js
new file mode 100644
index 0000000..fc42b96
--- /dev/null
+++ b/tests/server/api/board-posts.test.js
@@ -0,0 +1,317 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { setResponseStatus } from 'h3'
+
+vi.stubGlobal('setResponseStatus', setResponseStatus)
+
+// --- Mocks ---
+const { mockFind, mockFindById, mockSaveInstance } = vi.hoisted(() => ({
+ mockFind: vi.fn(),
+ mockFindById: vi.fn(),
+ mockSaveInstance: vi.fn(),
+}))
+
+// Mock BoardPost as a constructor function with static methods
+vi.mock('../../../server/models/boardPost.js', () => {
+ function BoardPost(data) {
+ Object.assign(this, data)
+ this._id = data._id || 'new-post-id'
+ this.save = mockSaveInstance
+ this.populate = vi.fn().mockResolvedValue(this)
+ this.toObject = function () {
+ const { save, populate, toObject, deleteOne, ...rest } = this
+ return rest
+ }
+ this.deleteOne = vi.fn().mockResolvedValue({})
+ }
+ BoardPost.find = mockFind
+ BoardPost.findById = mockFindById
+ return { default: BoardPost }
+})
+
+vi.mock('../../../server/utils/auth.js', () => ({
+ requireAuth: vi.fn(),
+}))
+
+vi.mock('../../../server/utils/validateBody.js', () => ({
+ validateBody: vi.fn(),
+}))
+
+vi.mock('../../../server/utils/schemas.js', () => ({
+ boardPostCreateSchema: {},
+ boardPostUpdateSchema: {},
+}))
+
+vi.mock('../../../server/utils/activityLog.js', () => ({
+ logActivity: vi.fn(),
+ ACTIVITY_TYPES: { BOARD_POST_CREATED: 'board_post_created' },
+}))
+
+vi.mock('../../../server/utils/mongoose.js', () => ({
+ connectDB: vi.fn(),
+}))
+
+import { requireAuth } from '../../../server/utils/auth.js'
+import { validateBody } from '../../../server/utils/validateBody.js'
+import getHandler from '../../../server/api/board/posts.get.js'
+import postHandler from '../../../server/api/board/posts.post.js'
+import patchHandler from '../../../server/api/board/posts/[id].patch.js'
+import deleteHandler from '../../../server/api/board/posts/[id].delete.js'
+import { createMockEvent } from '../helpers/createMockEvent.js'
+
+const MEMBER_ID = 'member-abc'
+
+function buildFindChain(result) {
+ const chain = {
+ sort: vi.fn().mockReturnThis(),
+ populate: vi.fn().mockReturnThis(),
+ lean: vi.fn().mockResolvedValue(result),
+ }
+ mockFind.mockReturnValue(chain)
+ return chain
+}
+
+describe('GET /api/board/posts', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ requireAuth.mockResolvedValue({ _id: MEMBER_ID })
+ })
+
+ it('returns posts sorted by createdAt desc', async () => {
+ const posts = [
+ { _id: 'p2', title: 'newer', createdAt: '2026-04-02' },
+ { _id: 'p1', title: 'older', createdAt: '2026-04-01' },
+ ]
+ const chain = buildFindChain(posts)
+
+ const event = createMockEvent({ method: 'GET', path: '/api/board/posts' })
+ const result = await getHandler(event)
+
+ expect(mockFind).toHaveBeenCalledWith({})
+ expect(chain.sort).toHaveBeenCalledWith({ createdAt: -1 })
+ expect(result).toEqual({ posts })
+ })
+
+ it('filters by tag query param', async () => {
+ buildFindChain([])
+ const event = createMockEvent({ method: 'GET', path: '/api/board/posts?tag=coop' })
+ await getHandler(event)
+ expect(mockFind).toHaveBeenCalledWith({ tags: 'coop' })
+ })
+
+ it('filters by specific author id', async () => {
+ buildFindChain([])
+ const event = createMockEvent({ method: 'GET', path: '/api/board/posts?author=other-id' })
+ await getHandler(event)
+ expect(mockFind).toHaveBeenCalledWith({ author: 'other-id' })
+ })
+
+ it('filters by author=me using current member id', async () => {
+ buildFindChain([])
+ const event = createMockEvent({ method: 'GET', path: '/api/board/posts?author=me' })
+ await getHandler(event)
+ expect(mockFind).toHaveBeenCalledWith({ author: MEMBER_ID })
+ })
+
+ it('requires auth (401)', async () => {
+ requireAuth.mockRejectedValue(createError({ statusCode: 401, statusMessage: 'Unauthorized' }))
+ const event = createMockEvent({ method: 'GET', path: '/api/board/posts' })
+ await expect(getHandler(event)).rejects.toMatchObject({ statusCode: 401 })
+ })
+})
+
+describe('POST /api/board/posts', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ requireAuth.mockResolvedValue({ _id: MEMBER_ID })
+ mockSaveInstance.mockResolvedValue(undefined)
+ })
+
+ it('creates a post with valid data', async () => {
+ validateBody.mockResolvedValue({
+ title: 'Looking for co-op advice',
+ seeking: 'help with bylaws',
+ offering: '',
+ note: '',
+ tags: ['coop-formation'],
+ })
+
+ const event = createMockEvent({
+ method: 'POST',
+ path: '/api/board/posts',
+ body: { title: 'Looking for co-op advice', seeking: 'help with bylaws' },
+ })
+
+ const result = await postHandler(event)
+
+ expect(mockSaveInstance).toHaveBeenCalled()
+ expect(result.post.title).toBe('Looking for co-op advice')
+ expect(result.post.author).toBe(MEMBER_ID)
+ expect(result.post.tags).toEqual(['coop-formation'])
+ })
+
+ it('rejects when validation fails (both seeking/offering empty)', async () => {
+ validateBody.mockRejectedValue(
+ createError({ statusCode: 400, statusMessage: 'Validation failed' }),
+ )
+
+ const event = createMockEvent({
+ method: 'POST',
+ path: '/api/board/posts',
+ body: { title: 'x' },
+ })
+
+ await expect(postHandler(event)).rejects.toMatchObject({ statusCode: 400 })
+ })
+
+ it('requires auth (401)', async () => {
+ requireAuth.mockRejectedValue(createError({ statusCode: 401, statusMessage: 'Unauthorized' }))
+ const event = createMockEvent({
+ method: 'POST',
+ path: '/api/board/posts',
+ body: { title: 'x', seeking: 'y' },
+ })
+ await expect(postHandler(event)).rejects.toMatchObject({ statusCode: 401 })
+ })
+})
+
+describe('PATCH /api/board/posts/[id]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ requireAuth.mockResolvedValue({ _id: MEMBER_ID })
+ })
+
+ function mockPost(overrides = {}) {
+ const saveFn = vi.fn().mockResolvedValue(undefined)
+ const populateFn = vi.fn().mockResolvedValue(undefined)
+ const post = {
+ _id: 'post-1',
+ author: { toString: () => MEMBER_ID },
+ title: 'Original',
+ seeking: 'need help',
+ offering: '',
+ note: '',
+ tags: [],
+ save: saveFn,
+ populate: populateFn,
+ toObject() {
+ return {
+ _id: this._id,
+ title: this.title,
+ seeking: this.seeking,
+ offering: this.offering,
+ note: this.note,
+ tags: this.tags,
+ }
+ },
+ ...overrides,
+ }
+ mockFindById.mockResolvedValue(post)
+ return post
+ }
+
+ it('updates own post', async () => {
+ const post = mockPost()
+ validateBody.mockResolvedValue({ title: 'Updated title' })
+
+ const event = createMockEvent({
+ method: 'PATCH',
+ path: '/api/board/posts/post-1',
+ body: { title: 'Updated title' },
+ })
+ event.context = { params: { id: 'post-1' } }
+
+ const result = await patchHandler(event)
+
+ expect(post.save).toHaveBeenCalled()
+ expect(result.post.title).toBe('Updated title')
+ })
+
+ it('rejects editing another members post with 403', async () => {
+ mockPost({ author: { toString: () => 'other-member' } })
+ validateBody.mockResolvedValue({ title: 'Hack' })
+
+ const event = createMockEvent({
+ method: 'PATCH',
+ path: '/api/board/posts/post-1',
+ body: { title: 'Hack' },
+ })
+ event.context = { params: { id: 'post-1' } }
+
+ await expect(patchHandler(event)).rejects.toMatchObject({ statusCode: 403 })
+ })
+
+ it('rejects if merged result has neither seeking nor offering (400)', async () => {
+ mockPost({ seeking: 'current', offering: '' })
+ validateBody.mockResolvedValue({ seeking: '', offering: '' })
+
+ const event = createMockEvent({
+ method: 'PATCH',
+ path: '/api/board/posts/post-1',
+ body: { seeking: '', offering: '' },
+ })
+ event.context = { params: { id: 'post-1' } }
+
+ await expect(patchHandler(event)).rejects.toMatchObject({ statusCode: 400 })
+ })
+
+ it('returns 404 when post not found', async () => {
+ mockFindById.mockResolvedValue(null)
+ validateBody.mockResolvedValue({ title: 'x' })
+
+ const event = createMockEvent({
+ method: 'PATCH',
+ path: '/api/board/posts/missing',
+ body: { title: 'x' },
+ })
+ event.context = { params: { id: 'missing' } }
+
+ await expect(patchHandler(event)).rejects.toMatchObject({ statusCode: 404 })
+ })
+})
+
+describe('DELETE /api/board/posts/[id]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ requireAuth.mockResolvedValue({ _id: MEMBER_ID })
+ })
+
+ it('deletes own post', async () => {
+ const deleteOne = vi.fn().mockResolvedValue({})
+ mockFindById.mockResolvedValue({
+ _id: 'post-1',
+ author: { toString: () => MEMBER_ID },
+ deleteOne,
+ })
+
+ const event = createMockEvent({ method: 'DELETE', path: '/api/board/posts/post-1' })
+ event.context = { params: { id: 'post-1' } }
+
+ const result = await deleteHandler(event)
+
+ expect(deleteOne).toHaveBeenCalled()
+ expect(result).toEqual({ success: true })
+ })
+
+ it('rejects deleting another members post with 403', async () => {
+ const deleteOne = vi.fn()
+ mockFindById.mockResolvedValue({
+ _id: 'post-1',
+ author: { toString: () => 'someone-else' },
+ deleteOne,
+ })
+
+ const event = createMockEvent({ method: 'DELETE', path: '/api/board/posts/post-1' })
+ event.context = { params: { id: 'post-1' } }
+
+ await expect(deleteHandler(event)).rejects.toMatchObject({ statusCode: 403 })
+ expect(deleteOne).not.toHaveBeenCalled()
+ })
+
+ it('returns 404 when post not found', async () => {
+ mockFindById.mockResolvedValue(null)
+ const event = createMockEvent({ method: 'DELETE', path: '/api/board/posts/missing' })
+ event.context = { params: { id: 'missing' } }
+
+ await expect(deleteHandler(event)).rejects.toMatchObject({ statusCode: 404 })
+ })
+})
diff --git a/tests/server/api/board-suggestions.test.js b/tests/server/api/board-suggestions.test.js
deleted file mode 100644
index c52deae..0000000
--- a/tests/server/api/board-suggestions.test.js
+++ /dev/null
@@ -1,325 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest'
-
-const { mockFind, mockSelect, mockLean } = vi.hoisted(() => ({
- mockFind: vi.fn(),
- mockSelect: vi.fn(),
- mockLean: vi.fn()
-}))
-
-vi.mock('../../../server/models/member.js', () => ({
- default: { find: mockFind }
-}))
-
-vi.mock('../../../server/utils/mongoose.js', () => ({
- connectDB: vi.fn()
-}))
-
-vi.mock('../../../server/utils/auth.js', () => ({
- requireAuth: vi.fn()
-}))
-
-import { requireAuth } from '../../../server/utils/auth.js'
-import handler from '../../../server/api/board/suggestions.get.js'
-import { createMockEvent } from '../helpers/createMockEvent.js'
-
-function setupChain(result = []) {
- mockLean.mockResolvedValue(result)
- mockSelect.mockReturnValue({ lean: mockLean })
- mockFind.mockReturnValue({ select: mockSelect })
-}
-
-function makeMember(overrides = {}) {
- return {
- _id: 'member-1',
- board: { topics: [] },
- ...overrides
- }
-}
-
-function makeCandidate(overrides = {}) {
- return {
- _id: 'candidate-1',
- name: 'Test Candidate',
- circle: 'community',
- avatar: '/avatar.jpg',
- craftTags: ['game-design'],
- board: {
- topics: [
- { tagSlug: 'revenue-sharing', state: 'interested' }
- ],
- offerPeerSupport: false,
- slackHandle: ''
- },
- privacy: {},
- ...overrides
- }
-}
-
-describe('GET /api/board/suggestions', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- })
-
- it('returns empty suggestions when member has no topics', async () => {
- const member = makeMember({ board: { topics: [] } })
- requireAuth.mockResolvedValue(member)
-
- const event = createMockEvent({ method: 'GET', path: '/api/board/suggestions' })
- const result = await handler(event)
-
- expect(result).toEqual({ suggestions: [] })
- expect(mockFind).not.toHaveBeenCalled()
- })
-
- it('returns matching members with shared topics and correct state comparison', async () => {
- const member = makeMember({
- board: {
- topics: [
- { tagSlug: 'revenue-sharing', state: 'help' },
- { tagSlug: 'co-op-governance', state: 'seeking' }
- ]
- }
- })
- requireAuth.mockResolvedValue(member)
-
- const candidate = makeCandidate({
- board: {
- topics: [
- { tagSlug: 'revenue-sharing', state: 'interested' }
- ],
- offerPeerSupport: false
- }
- })
- setupChain([candidate])
-
- const event = createMockEvent({ method: 'GET', path: '/api/board/suggestions' })
- const result = await handler(event)
-
- expect(result.suggestions).toHaveLength(1)
- expect(result.suggestions[0].matchingTags).toEqual([
- { tagSlug: 'revenue-sharing', yourState: 'help', theirState: 'interested' }
- ])
- expect(result.suggestions[0].matchCount).toBe(1)
- })
-
- it('excludes the requesting member from results', async () => {
- const member = makeMember({
- _id: 'member-1',
- board: {
- topics: [{ tagSlug: 'revenue-sharing', state: 'help' }]
- }
- })
- requireAuth.mockResolvedValue(member)
-
- setupChain([])
-
- const event = createMockEvent({ method: 'GET', path: '/api/board/suggestions' })
- await handler(event)
-
- expect(mockFind).toHaveBeenCalledWith(
- expect.objectContaining({
- _id: { $ne: 'member-1' }
- })
- )
- })
-
- it('respects avatar privacy settings', async () => {
- const member = makeMember({
- board: {
- topics: [{ tagSlug: 'revenue-sharing', state: 'help' }]
- }
- })
- requireAuth.mockResolvedValue(member)
-
- const candidate = makeCandidate({
- privacy: { avatar: 'private' },
- avatar: '/secret-avatar.jpg',
- board: {
- topics: [{ tagSlug: 'revenue-sharing', state: 'interested' }],
- offerPeerSupport: false
- }
- })
- setupChain([candidate])
-
- const event = createMockEvent({ method: 'GET', path: '/api/board/suggestions' })
- const result = await handler(event)
-
- expect(result.suggestions[0].member.avatar).toBeUndefined()
- })
-
- it('respects craftTags privacy settings', async () => {
- const member = makeMember({
- board: {
- topics: [{ tagSlug: 'revenue-sharing', state: 'help' }]
- }
- })
- requireAuth.mockResolvedValue(member)
-
- const candidate = makeCandidate({
- privacy: { craftTags: 'private' },
- craftTags: ['game-design'],
- board: {
- topics: [{ tagSlug: 'revenue-sharing', state: 'interested' }],
- offerPeerSupport: false
- }
- })
- setupChain([candidate])
-
- const event = createMockEvent({ method: 'GET', path: '/api/board/suggestions' })
- const result = await handler(event)
-
- expect(result.suggestions[0].member.craftTags).toBeUndefined()
- })
-
- it('exposes avatar when privacy is public', async () => {
- const member = makeMember({
- board: {
- topics: [{ tagSlug: 'revenue-sharing', state: 'help' }]
- }
- })
- requireAuth.mockResolvedValue(member)
-
- const candidate = makeCandidate({
- privacy: { avatar: 'public' },
- avatar: '/public-avatar.jpg',
- board: {
- topics: [{ tagSlug: 'revenue-sharing', state: 'interested' }],
- offerPeerSupport: false
- }
- })
- setupChain([candidate])
-
- const event = createMockEvent({ method: 'GET', path: '/api/board/suggestions' })
- const result = await handler(event)
-
- expect(result.suggestions[0].member.avatar).toBe('/public-avatar.jpg')
- })
-
- it('only exposes slackHandle when offerPeerSupport is true AND slackHandle is set', async () => {
- const member = makeMember({
- board: {
- topics: [{ tagSlug: 'revenue-sharing', state: 'help' }]
- }
- })
- requireAuth.mockResolvedValue(member)
-
- // Case 1: offerPeerSupport false — no slackHandle
- const noSupport = makeCandidate({
- _id: 'c1',
- board: {
- topics: [{ tagSlug: 'revenue-sharing', state: 'interested' }],
- offerPeerSupport: false,
- slackHandle: 'someone'
- }
- })
-
- // Case 2: offerPeerSupport true but no slackHandle
- const supportNoHandle = makeCandidate({
- _id: 'c2',
- board: {
- topics: [{ tagSlug: 'revenue-sharing', state: 'interested' }],
- offerPeerSupport: true,
- slackHandle: ''
- }
- })
-
- // Case 3: offerPeerSupport true AND slackHandle set
- const supportWithHandle = makeCandidate({
- _id: 'c3',
- board: {
- topics: [{ tagSlug: 'revenue-sharing', state: 'interested' }],
- offerPeerSupport: true,
- slackHandle: 'helpfulperson'
- }
- })
-
- setupChain([noSupport, supportNoHandle, supportWithHandle])
-
- const event = createMockEvent({ method: 'GET', path: '/api/board/suggestions' })
- const result = await handler(event)
-
- expect(result.suggestions[0].member.slackHandle).toBeUndefined()
- expect(result.suggestions[1].member.slackHandle).toBeUndefined()
- expect(result.suggestions[2].member.slackHandle).toBe('helpfulperson')
- })
-
- it('filters by tag query param', async () => {
- const member = makeMember({
- board: {
- topics: [
- { tagSlug: 'revenue-sharing', state: 'help' },
- { tagSlug: 'co-op-governance', state: 'seeking' }
- ]
- }
- })
- requireAuth.mockResolvedValue(member)
-
- setupChain([])
-
- const event = createMockEvent({ method: 'GET', path: '/api/board/suggestions?tag=revenue-sharing' })
- await handler(event)
-
- // Should only query for the filtered tag
- expect(mockFind).toHaveBeenCalledWith(
- expect.objectContaining({
- 'board.topics.tagSlug': { $in: ['revenue-sharing'] }
- })
- )
- })
-
- it('sorts by matchCount descending', async () => {
- const member = makeMember({
- board: {
- topics: [
- { tagSlug: 'revenue-sharing', state: 'help' },
- { tagSlug: 'co-op-governance', state: 'seeking' },
- { tagSlug: 'profit-sharing', state: 'interested' }
- ]
- }
- })
- requireAuth.mockResolvedValue(member)
-
- const oneMatch = makeCandidate({
- _id: 'c1',
- name: 'One Match',
- board: {
- topics: [{ tagSlug: 'revenue-sharing', state: 'interested' }],
- offerPeerSupport: false
- }
- })
-
- const twoMatches = makeCandidate({
- _id: 'c2',
- name: 'Two Matches',
- board: {
- topics: [
- { tagSlug: 'revenue-sharing', state: 'help' },
- { tagSlug: 'co-op-governance', state: 'interested' }
- ],
- offerPeerSupport: false
- }
- })
-
- setupChain([oneMatch, twoMatches])
-
- const event = createMockEvent({ method: 'GET', path: '/api/board/suggestions' })
- const result = await handler(event)
-
- expect(result.suggestions[0].matchCount).toBe(2)
- expect(result.suggestions[0].member.name).toBe('Two Matches')
- expect(result.suggestions[1].matchCount).toBe(1)
- expect(result.suggestions[1].member.name).toBe('One Match')
- })
-
- it('requires auth (401)', async () => {
- requireAuth.mockRejectedValue(
- createError({ statusCode: 401, statusMessage: 'Unauthorized' })
- )
-
- const event = createMockEvent({ method: 'GET', path: '/api/board/suggestions' })
-
- await expect(handler(event)).rejects.toMatchObject({
- statusCode: 401
- })
- })
-})
diff --git a/tests/server/api/events-recommended.test.js b/tests/server/api/events-recommended.test.js
index 0cbea30..b3ca5c4 100644
--- a/tests/server/api/events-recommended.test.js
+++ b/tests/server/api/events-recommended.test.js
@@ -39,7 +39,6 @@ function makeMember(overrides = {}) {
return {
_id: 'member-1',
craftTags: [],
- board: { topics: [] },
...overrides
}
}
@@ -82,31 +81,6 @@ describe('GET /api/events/recommended', () => {
)
})
- it('returns events matching cooperative tags from board.topics', async () => {
- const member = makeMember({
- board: {
- topics: [
- { tagSlug: 'revenue-sharing', state: 'interested' },
- { tagSlug: 'co-op-governance', state: 'help' }
- ]
- }
- })
- requireAuth.mockResolvedValue(member)
-
- const events = [makeEvent({ tags: ['revenue-sharing'] })]
- setupChain(events)
-
- const event = createMockEvent({ method: 'GET', path: '/api/events/recommended' })
- const result = await handler(event)
-
- expect(result).toEqual(events)
- expect(mockFind).toHaveBeenCalledWith(
- expect.objectContaining({
- tags: { $in: expect.arrayContaining(['revenue-sharing', 'co-op-governance']) }
- })
- )
- })
-
it('returns empty array when no tag overlap', async () => {
const member = makeMember({ craftTags: ['audio'] })
requireAuth.mockResolvedValue(member)
diff --git a/tests/server/api/onboarding-status.test.js b/tests/server/api/onboarding-status.test.js
index b989b83..5030e5f 100644
--- a/tests/server/api/onboarding-status.test.js
+++ b/tests/server/api/onboarding-status.test.js
@@ -1,5 +1,9 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
+const { mockBoardPostExists } = vi.hoisted(() => ({
+ mockBoardPostExists: vi.fn()
+}))
+
vi.mock('../../../server/utils/auth.js', () => ({
requireAuth: vi.fn()
}))
@@ -8,6 +12,10 @@ vi.mock('../../../server/utils/mongoose.js', () => ({
connectDB: vi.fn()
}))
+vi.mock('../../../server/models/boardPost.js', () => ({
+ default: { exists: mockBoardPostExists }
+}))
+
import { requireAuth } from '../../../server/utils/auth.js'
import handler from '../../../server/api/onboarding/status.get.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
@@ -15,6 +23,7 @@ import { createMockEvent } from '../helpers/createMockEvent.js'
describe('GET /api/onboarding/status', () => {
beforeEach(() => {
vi.clearAllMocks()
+ mockBoardPostExists.mockResolvedValue(null)
})
// 1.1: Default state for new member — all false, completedAt null
@@ -22,7 +31,6 @@ describe('GET /api/onboarding/status', () => {
requireAuth.mockResolvedValue({
_id: 'member-1',
craftTags: [],
- board: { topics: [] },
onboarding: {
completedAt: null,
eventPageVisited: false,
@@ -45,14 +53,11 @@ describe('GET /api/onboarding/status', () => {
})
})
- // 1.2: hasProfileTags true when both tag types present
- it('hasProfileTags is true when member has both craft tags and board topics', async () => {
+ // 1.2: hasProfileTags true when craft tags present
+ it('hasProfileTags is true when member has craft tags', async () => {
requireAuth.mockResolvedValue({
_id: 'member-1',
craftTags: ['game-design'],
- board: {
- topics: [{ tagSlug: 'governance', state: 'interested' }],
- },
onboarding: {
completedAt: null,
eventPageVisited: false,
@@ -67,12 +72,11 @@ describe('GET /api/onboarding/status', () => {
expect(result.goals.hasProfileTags).toBe(true)
})
- // 1.3: hasProfileTags false when only craft tags
- it('hasProfileTags is false when member has craft tags but no board topics', async () => {
+ // 1.3: hasProfileTags false when no craft tags
+ it('hasProfileTags is false when member has no craft tags', async () => {
requireAuth.mockResolvedValue({
_id: 'member-1',
- craftTags: ['game-design'],
- board: { topics: [] },
+ craftTags: [],
onboarding: {
completedAt: null,
eventPageVisited: false,
@@ -87,14 +91,11 @@ describe('GET /api/onboarding/status', () => {
expect(result.goals.hasProfileTags).toBe(false)
})
- // 1.5: hasEngagedBoard true when visited AND has tag with engagement state
- it('hasEngagedBoard is true when page visited and has engaged topic', async () => {
+ // 1.5: hasEngagedBoard true when visited AND has a BoardPost
+ it('hasEngagedBoard is true when page visited and member has posted', async () => {
requireAuth.mockResolvedValue({
_id: 'member-1',
craftTags: [],
- board: {
- topics: [{ tagSlug: 'governance', state: 'help' }],
- },
onboarding: {
completedAt: null,
eventPageVisited: false,
@@ -102,19 +103,20 @@ describe('GET /api/onboarding/status', () => {
wikiClicked: false,
},
})
+ mockBoardPostExists.mockResolvedValue({ _id: 'post-1' })
const event = createMockEvent({ method: 'GET', path: '/api/onboarding/status' })
const result = await handler(event)
expect(result.goals.hasEngagedBoard).toBe(true)
+ expect(mockBoardPostExists).toHaveBeenCalledWith({ author: 'member-1' })
})
- // 1.6: hasEngagedBoard false when visited but no engagement state
- it('hasEngagedBoard is false when page visited but no topics have engagement state', async () => {
+ // 1.6: hasEngagedBoard false when visited but no posts
+ it('hasEngagedBoard is false when page visited but member has no posts', async () => {
requireAuth.mockResolvedValue({
_id: 'member-1',
craftTags: [],
- board: { topics: [] },
onboarding: {
completedAt: null,
eventPageVisited: false,
@@ -122,6 +124,7 @@ describe('GET /api/onboarding/status', () => {
wikiClicked: false,
},
})
+ mockBoardPostExists.mockResolvedValue(null)
const event = createMockEvent({ method: 'GET', path: '/api/onboarding/status' })
const result = await handler(event)
@@ -134,7 +137,6 @@ describe('GET /api/onboarding/status', () => {
requireAuth.mockResolvedValue({
_id: 'member-1',
craftTags: [],
- board: { topics: [] },
onboarding: {
completedAt: null,
eventPageVisited: true,
diff --git a/tests/server/api/onboarding-track.test.js b/tests/server/api/onboarding-track.test.js
index 80c329b..792d2bb 100644
--- a/tests/server/api/onboarding-track.test.js
+++ b/tests/server/api/onboarding-track.test.js
@@ -1,5 +1,13 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
+const { mockBoardPostExists } = vi.hoisted(() => ({
+ mockBoardPostExists: vi.fn()
+}))
+
+vi.mock('../../../server/models/boardPost.js', () => ({
+ default: { exists: mockBoardPostExists }
+}))
+
vi.mock('../../../server/utils/auth.js', () => ({
requireAuth: vi.fn()
}))
@@ -45,6 +53,7 @@ describe('POST /api/onboarding/track', () => {
})
Member.findByIdAndUpdate.mockResolvedValue({})
Member.findOneAndUpdate.mockResolvedValue(null) // no graduation by default
+ mockBoardPostExists.mockResolvedValue({ _id: 'post-1' })
})
// 2.1: Sets eventPageVisited to true