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

@ -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