refactor(community): rename Community Connections → Community Ecology
Some checks failed
Test / vitest (push) Successful in 11m42s
Test / playwright (push) Failing after 9m27s
Test / visual (push) Failing after 9m53s
Test / Notify on failure (push) Successful in 2s

Simplify the feature to pure discovery (filter by topic, see matching
members, copy Slack handle). Drop the connection request/confirm flow
entirely — Connection model, 7 API endpoints, useConnections composable,
and TagInput component deleted.

- Rename communityConnections → communityEcology in schema, API, pages
- Delete legacy fields: offering, lookingFor, peerSupport
- New /ecology page, /api/ecology/suggestions, community-ecology.patch
- Nav: "Connections" → "Ecology", remove pending-count badge
- Fix auth/member.get.js missing craftTags + communityEcology
- Add community_ecology_updated activity log type
- Expose slackHandle conditionally when offerPeerSupport is true
- Add migration script at scripts/migrate-to-ecology.js (run before deploy)
This commit is contained in:
Jennie Robinson Faber 2026-04-09 09:07:15 +01:00
parent 9577929e0d
commit 0b3896d984
33 changed files with 1002 additions and 2635 deletions

View file

@ -21,14 +21,11 @@ export default defineEventHandler(async (event) => {
bio: member.bio,
location: member.location,
socialLinks: member.socialLinks,
offering: member.offering,
lookingFor: member.lookingFor,
craftTags: member.craftTags,
communityEcology: member.communityEcology,
showInDirectory: member.showInDirectory,
notifications: member.notifications,
privacy: member.privacy,
// Peer support
peerSupport: member.peerSupport,
// Timestamps
createdAt: member.createdAt,
};
});

View file

@ -1,54 +0,0 @@
import mongoose from 'mongoose'
import Connection from '../../../models/connection.js'
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 connectionId = getRouterParam(event, 'id')
if (!mongoose.Types.ObjectId.isValid(connectionId)) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid connection ID'
})
}
const connection = await Connection.findById(connectionId)
if (!connection) {
throw createError({
statusCode: 404,
statusMessage: 'Connection not found'
})
}
// Only the recipient can confirm
if (connection.recipient.toString() !== memberId.toString()) {
throw createError({
statusCode: 403,
statusMessage: 'Only the recipient can confirm a connection'
})
}
if (connection.status !== 'pending') {
throw createError({
statusCode: 400,
statusMessage: 'Connection is not pending'
})
}
connection.status = 'confirmed'
connection.confirmedAt = new Date()
await connection.save()
// Get initiator name for activity log
const initiator = await Member.findById(connection.initiator)
.select('name')
.lean()
logActivity(memberId, 'connection_confirmed', { memberName: initiator?.name || 'Unknown' })
logActivity(connection.initiator, 'connection_confirmed', { memberName: member.name })
return { connection }
})

View file

@ -1,48 +0,0 @@
import mongoose from 'mongoose'
import Connection from '../../../models/connection.js'
import { requireAuth } from '../../../utils/auth.js'
export default defineEventHandler(async (event) => {
const member = await requireAuth(event)
const memberId = member._id
const connectionId = getRouterParam(event, 'id')
if (!mongoose.Types.ObjectId.isValid(connectionId)) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid connection ID'
})
}
const connection = await Connection.findById(connectionId)
if (!connection) {
throw createError({
statusCode: 404,
statusMessage: 'Connection not found'
})
}
// Either party can hide
const isParty =
connection.initiator.toString() === memberId.toString() ||
connection.recipient.toString() === memberId.toString()
if (!isParty) {
throw createError({
statusCode: 403,
statusMessage: 'Not authorized to hide this connection'
})
}
// Add to hiddenBy if not already there
const alreadyHidden = connection.hiddenBy.some(
id => id.toString() === memberId.toString()
)
if (!alreadyHidden) {
connection.hiddenBy.push(memberId)
await connection.save()
}
return { success: true }
})

View file

@ -1,43 +0,0 @@
import mongoose from 'mongoose'
import Connection from '../../../models/connection.js'
import { requireAuth } from '../../../utils/auth.js'
export default defineEventHandler(async (event) => {
const member = await requireAuth(event)
const memberId = member._id
const connectionId = getRouterParam(event, 'id')
if (!mongoose.Types.ObjectId.isValid(connectionId)) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid connection ID'
})
}
const connection = await Connection.findById(connectionId)
if (!connection) {
throw createError({
statusCode: 404,
statusMessage: 'Connection not found'
})
}
// Only the initiator can withdraw
if (connection.initiator.toString() !== memberId.toString()) {
throw createError({
statusCode: 403,
statusMessage: 'Only the initiator can withdraw a connection request'
})
}
if (connection.status !== 'pending') {
throw createError({
statusCode: 400,
statusMessage: 'Can only withdraw pending connections'
})
}
await Connection.findByIdAndDelete(connectionId)
return { success: true }
})

View file

@ -1,45 +0,0 @@
import Connection from '../../models/connection.js'
import { requireAuth } from '../../utils/auth.js'
export default defineEventHandler(async (event) => {
const member = await requireAuth(event)
const memberId = member._id
const [confirmed, pendingOutgoing, pendingIncoming] = await Promise.all([
Connection.find({
status: 'confirmed',
hiddenBy: { $ne: memberId },
$or: [
{ initiator: memberId },
{ recipient: memberId }
]
})
.populate('initiator recipient', 'name avatar craftTags circle')
.sort({ confirmedAt: -1 })
.lean(),
Connection.find({
initiator: memberId,
status: 'pending',
hiddenBy: { $ne: memberId }
})
.populate('recipient', 'name avatar craftTags circle')
.sort({ createdAt: -1 })
.lean(),
Connection.find({
recipient: memberId,
status: 'pending',
hiddenBy: { $ne: memberId }
})
.populate('initiator', 'name avatar craftTags circle')
.sort({ createdAt: -1 })
.lean()
])
return {
confirmed,
pendingOutgoing,
pendingIncoming
}
})

View file

@ -1,108 +0,0 @@
import mongoose from 'mongoose'
import Member from '../../models/member.js'
import Connection from '../../models/connection.js'
import { requireAuth } from '../../utils/auth.js'
export default defineEventHandler(async (event) => {
const member = await requireAuth(event)
const memberId = member._id
const body = await readBody(event)
const { recipientId } = body || {}
if (!recipientId) {
throw createError({
statusCode: 400,
statusMessage: 'recipientId is required'
})
}
if (!mongoose.Types.ObjectId.isValid(recipientId)) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid recipientId'
})
}
if (recipientId === memberId.toString()) {
throw createError({
statusCode: 400,
statusMessage: 'Cannot connect with yourself'
})
}
// Verify recipient exists and is active
const recipient = await Member.findById(recipientId).lean()
if (!recipient || recipient.status !== 'active') {
throw createError({
statusCode: 404,
statusMessage: 'Recipient not found or not active'
})
}
// Check for existing connection in either direction
const existing = await Connection.findOne({
$or: [
{ initiator: memberId, recipient: recipientId },
{ initiator: recipientId, recipient: memberId }
]
})
if (existing) {
// If reverse pending connection exists, auto-confirm
if (
existing.status === 'pending' &&
existing.initiator.toString() === recipientId &&
existing.recipient.toString() === memberId.toString()
) {
existing.status = 'confirmed'
existing.confirmedAt = new Date()
await existing.save()
logActivity(memberId, 'connection_confirmed', { memberName: recipient.name })
logActivity(recipientId, 'connection_confirmed', { memberName: member.name })
return {
connection: existing,
autoConfirmed: true
}
}
throw createError({
statusCode: 409,
statusMessage: 'Connection already exists'
})
}
// Snapshot matching tags between the two members
const myTopics = member.communityConnections?.topics || []
const theirTopics = recipient.communityConnections?.topics || []
const myTopicMap = {}
for (const t of myTopics) {
myTopicMap[t.tagSlug] = t.state
}
const matchingTags = []
for (const t of theirTopics) {
const myState = myTopicMap[t.tagSlug]
if (myState) {
matchingTags.push({
tagSlug: t.tagSlug,
initiatorState: myState,
recipientState: t.state
})
}
}
const connection = await Connection.create({
initiator: memberId,
recipient: recipientId,
status: 'pending',
matchingTags
})
logActivity(memberId, 'connection_requested', { memberName: recipient.name })
logActivity(recipientId, 'connection_requested', { memberName: member.name })
return { connection }
})

View file

@ -1,14 +0,0 @@
import Connection from '../../models/connection.js'
import { requireAuth } from '../../utils/auth.js'
export default defineEventHandler(async (event) => {
const member = await requireAuth(event)
const count = await Connection.countDocuments({
recipient: member._id,
status: 'pending',
hiddenBy: { $ne: member._id }
})
return { count }
})

View file

@ -1,131 +0,0 @@
import Member from '../../models/member.js'
import Connection from '../../models/connection.js'
import { requireAuth } from '../../utils/auth.js'
export default defineEventHandler(async (event) => {
const member = await requireAuth(event)
const memberId = member._id
const topics = member.communityConnections?.topics || []
if (!topics.length) {
return { suggestions: [] }
}
const query = getQuery(event)
const filterTag = query.tag || null
const filterState = query.state || null
const showHidden = query.showHidden === 'true'
// Build the set of tag slugs to match against
let myTopics = topics
if (filterTag) {
myTopics = myTopics.filter(t => t.tagSlug === filterTag)
}
if (filterState) {
myTopics = myTopics.filter(t => t.state === filterState)
}
if (!myTopics.length) {
return { suggestions: [] }
}
const mySlugs = myTopics.map(t => t.tagSlug)
// Find active members sharing at least one topic slug
const candidates = await Member.find({
_id: { $ne: memberId },
status: 'active',
'communityConnections.topics.tagSlug': { $in: mySlugs }
})
.select('name avatar craftTags circle communityConnections privacy')
.lean()
if (!candidates.length) {
return { suggestions: [] }
}
const candidateIds = candidates.map(c => c._id)
// Find existing connections (pending or confirmed) to exclude
const existingConnections = await Connection.find({
$or: [
{ initiator: memberId, recipient: { $in: candidateIds } },
{ recipient: memberId, initiator: { $in: candidateIds } }
]
})
.select('initiator recipient hiddenBy status')
.lean()
// Build sets for exclusion
const excludeIds = new Set()
for (const conn of existingConnections) {
const otherId = conn.initiator.toString() === memberId.toString()
? conn.recipient.toString()
: conn.initiator.toString()
// Exclude if confirmed or pending connection exists
if (conn.status === 'confirmed' || conn.status === 'pending') {
excludeIds.add(otherId)
}
// Exclude if current member has hidden this connection (unless showHidden)
if (!showHidden && conn.hiddenBy?.some(id => id.toString() === memberId.toString())) {
excludeIds.add(otherId)
}
}
// Build topic lookup for current member (using filtered topics)
const myTopicMap = {}
for (const t of myTopics) {
myTopicMap[t.tagSlug] = t.state
}
// Compute suggestions
const suggestions = []
for (const candidate of candidates) {
if (excludeIds.has(candidate._id.toString())) continue
const theirTopics = candidate.communityConnections?.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
// Apply privacy filtering — only expose fields the member allows for 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
}
suggestions.push({
member: filtered,
matchingTags,
matchCount: matchingTags.length
})
}
// Sort by overlap count descending
suggestions.sort((a, b) => b.matchCount - a.matchCount)
return { suggestions }
})

View file

@ -0,0 +1,96 @@
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.communityEcology?.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',
'communityEcology.topics.tagSlug': { $in: mySlugs },
})
.select('name avatar craftTags circle communityEcology 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.communityEcology?.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.communityEcology?.offerPeerSupport && candidate.communityEcology?.slackHandle) {
filtered.slackHandle = candidate.communityEcology.slackHandle
}
suggestions.push({
member: filtered,
matchingTags,
matchCount: matchingTags.length,
})
}
suggestions.sort((a, b) => b.matchCount - a.matchCount)
return { suggestions }
})

View file

@ -30,7 +30,7 @@ export default defineEventHandler(async (event) => {
status: "active",
})
.select(
"name pronouns timeZone avatar studio bio location socialLinks offering lookingFor privacy circle peerSupport craftTags communityConnections createdAt memberNumber",
"name pronouns timeZone avatar studio bio location socialLinks privacy circle craftTags communityEcology createdAt memberNumber",
)
.lean();
@ -68,32 +68,21 @@ export default defineEventHandler(async (event) => {
if (isVisible("bio")) filtered.bio = member.bio;
if (isVisible("location")) filtered.location = member.location;
if (isVisible("socialLinks")) filtered.socialLinks = member.socialLinks;
if (isVisible("offering")) filtered.offering = member.offering;
if (isVisible("lookingFor")) filtered.lookingFor = member.lookingFor;
if (isVisible("craftTags")) filtered.craftTags = member.craftTags;
// Craft tags
if (isVisible("craftTags")) {
filtered.craftTags = member.craftTags;
}
// Community connections (expose only public-safe fields)
if (isVisible("communityConnections")) {
filtered.communityConnections = {
topics: member.communityConnections?.topics,
offerPeerSupport: member.communityConnections?.offerPeerSupport,
availability: member.communityConnections?.availability,
details: member.communityConnections?.details,
};
}
// Peer support: expose only fields needed for matching/contact UX
// slackUserId, slackDMChannelId, slackUsername, personalMessage are internal
if (member.peerSupport?.enabled) {
filtered.peerSupport = {
enabled: true,
skillTopics: member.peerSupport.skillTopics,
supportTopics: member.peerSupport.supportTopics,
availability: member.peerSupport.availability,
if (isVisible("communityEcology")) {
const ecology = member.communityEcology || {};
filtered.communityEcology = {
topics: ecology.topics,
offerPeerSupport: ecology.offerPeerSupport,
availability: ecology.availability,
details: ecology.details,
// Contact-in-place: surface the handle + personal message only when
// the member has explicitly opted into peer support.
...(ecology.offerPeerSupport && {
slackHandle: ecology.slackHandle,
personalMessage: ecology.personalMessage,
}),
};
}

View file

@ -9,15 +9,12 @@ export default defineEventHandler(async (event) => {
// Check if user is authenticated
const token = getCookie(event, "auth-token");
let isAuthenticated = false;
let currentMemberId = null;
if (token) {
try {
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
currentMemberId = decoded.memberId;
jwt.verify(token, useRuntimeConfig().jwtSecret);
isAuthenticated = true;
} catch (err) {
// Invalid token, treat as public
isAuthenticated = false;
}
}
@ -25,39 +22,27 @@ export default defineEventHandler(async (event) => {
const query = getQuery(event);
const search = query.search || "";
const circle = query.circle || "";
const tags = query.tags ? query.tags.split(",") : [];
const peerSupport = query.peerSupport || "";
const topics = query.topics ? query.topics.split(",") : [];
const craftTag = query.craftTag || "";
const connectionTag = query.connectionTag || "";
// Build query
const dbQuery = {
showInDirectory: true,
status: "active",
};
// Filter by circle if specified
if (circle) {
dbQuery.circle = circle;
}
// Collect $and conditions for combining multiple filters
const andConditions = [];
// Filter by peer support availability (check both old and new fields)
if (peerSupport === "true") {
andConditions.push({
$or: [
{ "peerSupport.enabled": true },
{ "communityConnections.offerPeerSupport": true },
],
});
dbQuery["communityEcology.offerPeerSupport"] = true;
}
// Search by name or bio
if (search) {
// Escape special regex characters to prevent ReDoS
// Escape regex metacharacters to prevent ReDoS
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
andConditions.push({
$or: [
@ -67,32 +52,14 @@ export default defineEventHandler(async (event) => {
});
}
// Filter by tags (search in offering.tags or lookingFor.tags)
if (tags.length > 0) {
andConditions.push({
$or: [
{ "offering.tags": { $in: tags } },
{ "lookingFor.tags": { $in: tags } },
],
});
}
// Filter by peer support topics
if (topics.length > 0) {
dbQuery["peerSupport.topics"] = { $in: topics };
}
// Filter by craft tag
if (craftTag) {
dbQuery.craftTags = craftTag;
}
// Filter by connection tag
if (connectionTag) {
dbQuery["communityConnections.topics.tagSlug"] = connectionTag;
dbQuery["communityEcology.topics.tagSlug"] = connectionTag;
}
// Apply combined $and conditions
if (andConditions.length > 0) {
dbQuery.$and = andConditions;
}
@ -100,12 +67,11 @@ export default defineEventHandler(async (event) => {
try {
const members = await Member.find(dbQuery)
.select(
"name pronouns timeZone avatar studio bio location socialLinks offering lookingFor privacy circle peerSupport craftTags communityConnections createdAt",
"name pronouns timeZone avatar studio bio location socialLinks privacy circle craftTags communityEcology createdAt",
)
.sort({ createdAt: -1 })
.lean();
// Filter fields based on privacy settings
const filteredMembers = members.map((member) => {
const privacy = member.privacy || {};
const filtered = {
@ -115,16 +81,13 @@ export default defineEventHandler(async (event) => {
createdAt: member.createdAt,
};
// Helper function to check if field should be visible
const isVisible = (field) => {
const privacySetting = privacy[field] || "members";
if (privacySetting === "public") return true;
if (privacySetting === "members" && isAuthenticated) return true;
if (privacySetting === "private") return false;
return false;
};
// Add fields based on privacy settings
if (isVisible("avatar")) filtered.avatar = member.avatar;
if (isVisible("pronouns")) filtered.pronouns = member.pronouns;
if (isVisible("timeZone")) filtered.timeZone = member.timeZone;
@ -132,54 +95,23 @@ export default defineEventHandler(async (event) => {
if (isVisible("bio")) filtered.bio = member.bio;
if (isVisible("location")) filtered.location = member.location;
if (isVisible("socialLinks")) filtered.socialLinks = member.socialLinks;
if (isVisible("offering")) filtered.offering = member.offering;
if (isVisible("lookingFor")) filtered.lookingFor = member.lookingFor;
if (isVisible("craftTags")) filtered.craftTags = member.craftTags;
// Craft tags (with fallback to offering.tags for backward compat)
if (isVisible("craftTags")) {
filtered.craftTags = member.craftTags;
}
// Community connections (expose only public-safe fields)
if (isVisible("communityConnections")) {
filtered.communityConnections = {
topics: member.communityConnections?.topics,
offerPeerSupport: member.communityConnections?.offerPeerSupport,
availability: member.communityConnections?.availability,
};
}
// Peer support: expose only fields needed for matching/contact UX
// slackUserId, slackDMChannelId, slackUsername, personalMessage are internal
if (member.peerSupport?.enabled) {
filtered.peerSupport = {
enabled: true,
skillTopics: member.peerSupport.skillTopics,
supportTopics: member.peerSupport.supportTopics,
availability: member.peerSupport.availability,
if (isVisible("communityEcology")) {
const ecology = member.communityEcology || {};
filtered.communityEcology = {
topics: ecology.topics,
offerPeerSupport: ecology.offerPeerSupport,
availability: ecology.availability,
...(ecology.offerPeerSupport && {
slackHandle: ecology.slackHandle,
}),
};
}
return filtered;
});
// Get unique tags for filter options (from both offering and lookingFor) — backward compat
const allTags = members
.flatMap((m) => [
...(m.offering?.tags || []),
...(m.lookingFor?.tags || []),
])
.filter((tag, index, self) => self.indexOf(tag) === index)
.sort();
// Get unique peer support topics
const allTopics = members
.filter((m) => m.peerSupport?.enabled)
.flatMap((m) => m.peerSupport?.topics || [])
.filter((topic, index, self) => self.indexOf(topic) === index)
.sort();
// Fetch predefined tags from Tag model for filter bars
const [craftTags, cooperativeTags] = await Promise.all([
Tag.find({ pool: "craft", active: true }).sort({ label: 1 }).lean(),
Tag.find({ pool: "cooperative", active: true }).sort({ label: 1 }).lean(),
@ -189,8 +121,6 @@ export default defineEventHandler(async (event) => {
members: filteredMembers,
totalCount: filteredMembers.length,
filters: {
availableSkills: allTags,
availableTopics: allTopics,
craftTags: craftTags.map((t) => ({ slug: t.slug, label: t.label })),
cooperativeTags: cooperativeTags.map((t) => ({
slug: t.slug,

View file

@ -1,95 +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, communityConnectionsUpdateSchema)
// Build update object for community connections settings
const updateData = {
'communityConnections.topics': body.topics || [],
'communityConnections.offerPeerSupport': body.offerPeerSupport || false,
'communityConnections.availability': body.availability || '',
'communityConnections.slackHandle': body.slackHandle || '',
'communityConnections.personalMessage': body.personalMessage || '',
'communityConnections.details': body.details || '',
}
// If Slack handle provided and peer support offered, try to fetch Slack user ID and open DM
if (body.offerPeerSupport && body.slackHandle) {
try {
console.log(
`[Community Connections] Attempting to fetch Slack user ID for: ${body.slackHandle}`,
)
const { getSlackService } = await import('../../../utils/slack.ts')
const slackService = getSlackService()
if (slackService) {
console.log('[Community Connections] Slack service initialized, looking up user...')
const slackUserId = await slackService.findUserIdByUsername(body.slackHandle)
if (slackUserId) {
updateData['slackUserId'] = slackUserId
console.log(
`[Community Connections] ✓ Found Slack user ID for ${body.slackHandle}: ${slackUserId}`,
)
console.log('[Community Connections] Opening DM channel...')
const dmChannelId = await slackService.openDMChannel(slackUserId)
if (dmChannelId) {
updateData['communityConnections.slackDMChannelId'] = dmChannelId
console.log(`[Community Connections] ✓ Got DM channel ID: ${dmChannelId}`)
} else {
console.warn('[Community Connections] Could not get DM channel ID')
}
} else {
console.warn(
`[Community Connections] Could not find Slack user ID for handle: ${body.slackHandle}`,
)
}
} else {
console.log('[Community Connections] Slack service not configured, skipping user ID lookup')
}
} catch (error) {
console.error('[Community Connections] Error fetching Slack user ID:', error.message)
console.error('[Community Connections] Stack trace:', error.stack)
// Continue anyway - we'll still save the handle
}
}
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, 'community_connections_updated', {
topicCount: (body.topics || []).length,
offerPeerSupport: body.offerPeerSupport || false,
})
return {
success: true,
communityConnections: updated.communityConnections,
}
} catch (error) {
if (error.statusCode) throw error
console.error('Community connections update error:', error)
throw createError({
statusCode: 500,
statusMessage: 'Failed to update community connections settings',
})
}
})

View file

@ -0,0 +1,70 @@
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, communityEcologyUpdateSchema)
const updateData = {
'communityEcology.topics': body.topics || [],
'communityEcology.offerPeerSupport': body.offerPeerSupport || false,
'communityEcology.availability': body.availability || '',
'communityEcology.slackHandle': body.slackHandle || '',
'communityEcology.personalMessage': body.personalMessage || '',
'communityEcology.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(
`[Community Ecology] Could not find Slack user ID for handle: ${body.slackHandle}`,
)
}
}
} catch (error) {
console.error('[Community Ecology] 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, 'community_ecology_updated', {
topicCount: (body.topics || []).length,
offerPeerSupport: body.offerPeerSupport || false,
})
return {
success: true,
communityEcology: updated.communityEcology,
}
} catch (error) {
if (error.statusCode) throw error
console.error('Community ecology update error:', error)
throw createError({
statusCode: 500,
statusMessage: 'Failed to update community ecology settings',
})
}
})

View file

@ -32,7 +32,7 @@ export default defineEventHandler(async (event) => {
"locationPrivacy",
"socialLinksPrivacy",
"craftTagsPrivacy",
"communityConnectionsPrivacy",
"communityEcologyPrivacy",
];
// Build update object from validated data