From 1da59021a3e71f585c8949b38c605b8fbdb0de85 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Tue, 14 Apr 2026 16:21:04 +0100 Subject: [PATCH 01/18] feat(board): add BoardPost + BoardChannel models and zod schemas - Add BoardPost model (author, title, seeking/offering, note, tags) with validator requiring at least one of seeking/offering - Add BoardChannel model (name, slackChannelId, tagSlugs) - Add boardPost/boardChannel create+update Zod schemas - Trim Member.board subdoc to only slackHandle (drop topics, details, offerPeerSupport, availability, personalMessage) - Remove old boardUpdateSchema --- server/models/boardChannel.js | 9 ++++++++ server/models/boardPost.js | 28 ++++++++++++++++++++++++ server/models/member.js | 10 --------- server/utils/schemas.js | 41 ++++++++++++++++++++++++++--------- 4 files changed, 68 insertions(+), 20 deletions(-) create mode 100644 server/models/boardChannel.js create mode 100644 server/models/boardPost.js diff --git a/server/models/boardChannel.js b/server/models/boardChannel.js new file mode 100644 index 0000000..d3b1255 --- /dev/null +++ b/server/models/boardChannel.js @@ -0,0 +1,9 @@ +import mongoose from 'mongoose' + +const boardChannelSchema = new mongoose.Schema({ + name: { type: String, required: true }, + slackChannelId: { type: String, required: true }, + tagSlugs: [String], +}, { timestamps: 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..9de5188 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 diff --git a/server/utils/schemas.js b/server/utils/schemas.js index f152988..018cae3 100644 --- a/server/utils/schemas.js +++ b/server/utils/schemas.js @@ -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'), + 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 --- From 8e5f4a2d7c0b6c1cc8343d333e3ec9858f21d56f Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Tue, 14 Apr 2026 16:23:23 +0100 Subject: [PATCH 02/18] add unique index on slackChannelId in BoardChannel model --- server/models/boardChannel.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/models/boardChannel.js b/server/models/boardChannel.js index d3b1255..dc4a599 100644 --- a/server/models/boardChannel.js +++ b/server/models/boardChannel.js @@ -6,4 +6,6 @@ const boardChannelSchema = new mongoose.Schema({ tagSlugs: [String], }, { timestamps: true }) +boardChannelSchema.index({ slackChannelId: 1 }, { unique: true }) + export default mongoose.models.BoardChannel || mongoose.model('BoardChannel', boardChannelSchema) From 6a440a846d399d06083ec56f412166a1fd5b03f0 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Tue, 14 Apr 2026 16:25:42 +0100 Subject: [PATCH 03/18] feat: board post + channel API routes Implements Phase 2a of board classifieds redesign: - GET/POST /api/board/posts (list with tag/author filters, create) - PATCH/DELETE /api/board/posts/:id (author-only) - GET /api/board/channels (member) - POST /api/admin/board-channels (admin) - PATCH/DELETE /api/admin/board-channels/:id (admin) Adds board_post_created activity type. --- server/api/admin/board-channels.post.js | 29 +++++++++++ .../api/admin/board-channels/[id].delete.js | 14 +++++ server/api/admin/board-channels/[id].patch.js | 39 ++++++++++++++ server/api/board/channels.get.js | 10 ++++ server/api/board/posts.get.js | 24 +++++++++ server/api/board/posts.post.js | 28 ++++++++++ server/api/board/posts/[id].delete.js | 20 +++++++ server/api/board/posts/[id].patch.js | 52 +++++++++++++++++++ server/utils/activityLog.js | 2 + 9 files changed, 218 insertions(+) create mode 100644 server/api/admin/board-channels.post.js create mode 100644 server/api/admin/board-channels/[id].delete.js create mode 100644 server/api/admin/board-channels/[id].patch.js create mode 100644 server/api/board/channels.get.js create mode 100644 server/api/board/posts.get.js create mode 100644 server/api/board/posts.post.js create mode 100644 server/api/board/posts/[id].delete.js create mode 100644 server/api/board/posts/[id].patch.js diff --git a/server/api/admin/board-channels.post.js b/server/api/admin/board-channels.post.js new file mode 100644 index 0000000..ef09370 --- /dev/null +++ b/server/api/admin/board-channels.post.js @@ -0,0 +1,29 @@ +import BoardChannel from '../../models/boardChannel.js' +import { requireAdmin } from '../../utils/auth.js' +import { validateBody } from '../../utils/validateBody.js' +import { boardChannelCreateSchema } from '../../utils/schemas.js' + +export default defineEventHandler(async (event) => { + await requireAdmin(event) + + const body = await validateBody(event, boardChannelCreateSchema) + + try { + const channel = await BoardChannel.create({ + name: body.name, + slackChannelId: body.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..b90644d --- /dev/null +++ b/server/api/admin/board-channels/[id].patch.js @@ -0,0 +1,39 @@ +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 + + 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/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' From 1fc937a26a7a1ced6929de22c67f5f4ab4d6249f Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Tue, 14 Apr 2026 16:29:45 +0100 Subject: [PATCH 04/18] refactor(board): delete old board routes, absorb slackHandle into profile PATCH - Delete server/api/members/me/board.patch.js and server/api/board/suggestions.get.js - Add boardSlackHandle to memberProfileUpdateSchema; remove boardPrivacy - profile.patch.js: write boardSlackHandle -> board.slackHandle; drop boardPrivacy - Remove privacy.board field from Member model - onboarding/status.get.js: hasProfileTags now requires only craftTags; hasEngagedBoard uses BoardPost.exists - onboarding/track.post.js: graduation check uses BoardPost.exists instead of board.topics elemMatch - members/[id].get.js and directory.get.js: reduce board response to slackHandle only; drop connectionTag and peerSupport filters --- server/api/board/suggestions.get.js | 96 ---------------------------- server/api/members/[id].get.js | 18 +----- server/api/members/directory.get.js | 24 +------ server/api/members/me/board.patch.js | 70 -------------------- server/api/members/profile.patch.js | 6 +- server/api/onboarding/status.get.js | 10 ++- server/api/onboarding/track.post.js | 33 +++++----- server/models/member.js | 5 -- server/utils/schemas.js | 2 +- 9 files changed, 34 insertions(+), 230 deletions(-) delete mode 100644 server/api/board/suggestions.get.js delete mode 100644 server/api/members/me/board.patch.js 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/member.js b/server/models/member.js index 9de5188..3dd5e3f 100644 --- a/server/models/member.js +++ b/server/models/member.js @@ -118,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/schemas.js b/server/utils/schemas.js index 018cae3..7fbd224 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({ From 78db4be7ba1885e44aaeed375f3936086e0394c4 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Tue, 14 Apr 2026 17:02:07 +0100 Subject: [PATCH 05/18] feat: add useBoardPosts + useBoardChannels composables, remove useBoard - useBoardPosts: CRUD with useState('board.posts','board.loading') - useBoardChannels: fetch + resolveTagChannel + slackUrl helpers - useBoard.js removed (old suggestions wrapper); only app/pages/board.vue still imports it, will be rewritten in Phase 5 --- app/composables/useBoard.js | 6 ---- app/composables/useBoardChannels.js | 33 ++++++++++++++++++ app/composables/useBoardPosts.js | 54 +++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 6 deletions(-) delete mode 100644 app/composables/useBoard.js create mode 100644 app/composables/useBoardChannels.js create mode 100644 app/composables/useBoardPosts.js diff --git a/app/composables/useBoard.js b/app/composables/useBoard.js deleted file mode 100644 index 9e1fc47..0000000 --- a/app/composables/useBoard.js +++ /dev/null @@ -1,6 +0,0 @@ -export const useBoard = () => { - const getSuggestions = (params = {}) => - $fetch('/api/board/suggestions', { params }) - - return { getSuggestions } -} diff --git a/app/composables/useBoardChannels.js b/app/composables/useBoardChannels.js new file mode 100644 index 0000000..c64d103 --- /dev/null +++ b/app/composables/useBoardChannels.js @@ -0,0 +1,33 @@ +/** + * Board Channels Composable + * Shared state + helpers for mapping board tags to Slack channels. + */ +export function useBoardChannels() { + const channels = useState('board.channels', () => []) + + async function fetchChannels() { + const result = await $fetch('/api/board/channels') + channels.value = result || [] + return channels.value + } + + function resolveTagChannel(tagSlugs = []) { + if (!tagSlugs?.length) return null + return ( + channels.value.find((channel) => + (channel.tagSlugs || []).some((slug) => tagSlugs.includes(slug)) + ) || null + ) + } + + function slackUrl(channelId) { + return `https://gammaspace.slack.com/archives/${channelId}` + } + + return { + channels: readonly(channels), + fetchChannels, + resolveTagChannel, + slackUrl, + } +} diff --git a/app/composables/useBoardPosts.js b/app/composables/useBoardPosts.js new file mode 100644 index 0000000..e8a7087 --- /dev/null +++ b/app/composables/useBoardPosts.js @@ -0,0 +1,54 @@ +/** + * Board Posts Composable + * Shared state + CRUD for board posts. + */ +export function useBoardPosts() { + const posts = useState('board.posts', () => []) + const loading = useState('board.loading', () => false) + + async function fetchPosts(params = {}) { + loading.value = true + try { + const result = await $fetch('/api/board/posts', { params }) + posts.value = result || [] + return posts.value + } finally { + loading.value = false + } + } + + async function createPost(body) { + const created = await $fetch('/api/board/posts', { + method: 'POST', + body, + }) + await fetchPosts() + return created + } + + async function updatePost(id, body) { + const updated = await $fetch(`/api/board/posts/${id}`, { + method: 'PATCH', + body, + }) + await fetchPosts() + return updated + } + + async function deletePost(id) { + const result = await $fetch(`/api/board/posts/${id}`, { + method: 'DELETE', + }) + await fetchPosts() + return result + } + + return { + posts: readonly(posts), + loading: readonly(loading), + fetchPosts, + createPost, + updatePost, + deletePost, + } +} From 33d27c5d9e63eaec8ce2321dcbe6797ece0f470b Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Tue, 14 Apr 2026 17:06:25 +0100 Subject: [PATCH 06/18] feat(board): BoardPostCard, BoardPostForm, simplify CooperativeTagSelector --- app/components/BoardPostCard.vue | 245 ++++++++++++++++++++++ app/components/BoardPostForm.vue | 231 ++++++++++++++++++++ app/components/CooperativeTagSelector.vue | 116 +++------- 3 files changed, 507 insertions(+), 85 deletions(-) create mode 100644 app/components/BoardPostCard.vue create mode 100644 app/components/BoardPostForm.vue diff --git a/app/components/BoardPostCard.vue b/app/components/BoardPostCard.vue new file mode 100644 index 0000000..2af1d55 --- /dev/null +++ b/app/components/BoardPostCard.vue @@ -0,0 +1,245 @@ + + + + + diff --git a/app/components/BoardPostForm.vue b/app/components/BoardPostForm.vue new file mode 100644 index 0000000..2228572 --- /dev/null +++ b/app/components/BoardPostForm.vue @@ -0,0 +1,231 @@ +