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

@ -1,62 +0,0 @@
// Migration to fix offering and lookingFor field structure
// Run this once to convert string values to object structure
import mongoose from "mongoose";
import Member from "../models/member.js";
import { connectDB } from "../utils/mongoose.js";
async function migrateOfferingLookingFor() {
await connectDB();
console.log("Starting migration: fixing offering and lookingFor structure...");
try {
// Find all members where offering or lookingFor is a string (not an object)
const members = await Member.find({
$or: [
{ offering: { $type: "string" } },
{ lookingFor: { $type: "string" } },
],
});
console.log(`Found ${members.length} members to migrate`);
for (const member of members) {
const updates = {};
// Convert offering if it's a string
if (typeof member.offering === "string") {
updates.offering = {
text: member.offering,
tags: [],
};
console.log(
`Converting offering for member ${member._id}: "${member.offering}"`,
);
}
// Convert lookingFor if it's a string
if (typeof member.lookingFor === "string") {
updates.lookingFor = {
text: member.lookingFor,
tags: [],
};
console.log(
`Converting lookingFor for member ${member._id}: "${member.lookingFor}"`,
);
}
// Update the member
if (Object.keys(updates).length > 0) {
await Member.findByIdAndUpdate(member._id, { $set: updates });
}
}
console.log("Migration completed successfully!");
process.exit(0);
} catch (error) {
console.error("Migration failed:", error);
process.exit(1);
}
}
migrateOfferingLookingFor();

View file

@ -1,213 +0,0 @@
/**
* Migration Script: Community Connections
*
* Migrates existing member peer support and tag data to the new
* communityConnections schema.
*
* What this does:
* 1. Builds a slug lookup from all cooperative tags in the database
* 2. For each member with offering.tags or lookingFor.tags:
* - Maps offering.tags communityConnections.topics with state "help"
* - Maps lookingFor.tags communityConnections.topics with state "seeking"
* 3. Copies peerSupport.enabled communityConnections.offerPeerSupport
* 4. Copies peerSupport.availability, peerSupport.personalMessage,
* peerSupport.slackUsername communityConnections.availability,
* .personalMessage, .slackHandle
* 5. Does NOT delete old fields (non-destructive)
*
* Safe to re-run: skips members whose communityConnections is already populated.
*/
import 'dotenv/config'
import mongoose from 'mongoose'
import Tag from '../models/tag.js'
import Member from '../models/member.js'
import { connectDB } from '../utils/mongoose.js'
async function buildCoopTagLookup() {
const coopTags = await Tag.find({ pool: 'cooperative', active: true }).lean()
// Maps normalized label → slug, and slug → slug (for direct slug matches)
const lookup = new Map()
for (const tag of coopTags) {
lookup.set(tag.label.toLowerCase(), tag.slug)
lookup.set(tag.slug.toLowerCase(), tag.slug)
}
return lookup
}
function resolveTagSlugs(rawTags, lookup) {
const matched = []
const unmatched = []
for (const raw of rawTags) {
const normalized = raw.toLowerCase().trim()
if (lookup.has(normalized)) {
matched.push(lookup.get(normalized))
} else {
unmatched.push(raw)
}
}
return { matched, unmatched }
}
async function migrateCommunityConnections() {
await connectDB()
console.log('Building cooperative tag lookup...')
const coopLookup = await buildCoopTagLookup()
console.log(` Loaded ${coopLookup.size / 2} cooperative tags`)
// Find members that have anything to migrate and haven't been migrated yet.
// A member is considered already migrated if communityConnections.topics has entries
// or offerPeerSupport is explicitly set.
const members = await Member.find({
$or: [
{ 'offering.tags': { $exists: true, $ne: [] } },
{ 'lookingFor.tags': { $exists: true, $ne: [] } },
{ 'peerSupport.enabled': { $exists: true } },
{ 'peerSupport.availability': { $exists: true } },
{ 'peerSupport.personalMessage': { $exists: true } },
{ 'peerSupport.slackUsername': { $exists: true } },
],
}).lean()
console.log(`\nFound ${members.length} member(s) with data to migrate`)
let migratedCount = 0
let skippedCount = 0
let totalTagsMatched = 0
const allUnmatched = []
for (const member of members) {
const label = `${member.name || member.email} (${member._id})`
// Skip if already migrated (topics array has entries or offerPeerSupport is set)
const cc = member.communityConnections || {}
const alreadyMigrated =
(cc.topics && cc.topics.length > 0) ||
cc.offerPeerSupport === true ||
cc.availability ||
cc.slackHandle ||
cc.personalMessage
if (alreadyMigrated) {
console.log(` skip ${label} — communityConnections already populated`)
skippedCount++
continue
}
const topics = []
const memberUnmatched = []
// Map offering.tags → state "help"
const offeringTags = member.offering?.tags || []
if (offeringTags.length > 0) {
const { matched, unmatched } = resolveTagSlugs(offeringTags, coopLookup)
for (const slug of matched) {
// Avoid duplicates
if (!topics.find((t) => t.tagSlug === slug)) {
topics.push({ tagSlug: slug, state: 'help' })
}
}
totalTagsMatched += matched.length
if (unmatched.length > 0) {
memberUnmatched.push(...unmatched.map((t) => `offering: "${t}"`))
}
}
// Map lookingFor.tags → state "seeking"
const lookingForTags = member.lookingFor?.tags || []
if (lookingForTags.length > 0) {
const { matched, unmatched } = resolveTagSlugs(lookingForTags, coopLookup)
for (const slug of matched) {
const existing = topics.find((t) => t.tagSlug === slug)
if (existing) {
// Upgrade "help" to "seeking" if it appears in both (or keep as-is — use seeking)
existing.state = 'seeking'
} else {
topics.push({ tagSlug: slug, state: 'seeking' })
}
}
totalTagsMatched += matched.length
if (unmatched.length > 0) {
memberUnmatched.push(...unmatched.map((t) => `lookingFor: "${t}"`))
}
}
if (memberUnmatched.length > 0) {
allUnmatched.push({ member: label, tags: memberUnmatched })
}
// Build communityConnections update
const ccUpdate = {}
if (topics.length > 0) {
ccUpdate['communityConnections.topics'] = topics
}
if (typeof member.peerSupport?.enabled === 'boolean') {
ccUpdate['communityConnections.offerPeerSupport'] = member.peerSupport.enabled
}
if (member.peerSupport?.availability) {
ccUpdate['communityConnections.availability'] = member.peerSupport.availability
}
if (member.peerSupport?.personalMessage) {
ccUpdate['communityConnections.personalMessage'] = member.peerSupport.personalMessage
}
if (member.peerSupport?.slackUsername) {
ccUpdate['communityConnections.slackHandle'] = member.peerSupport.slackUsername
}
if (Object.keys(ccUpdate).length === 0) {
console.log(` skip ${label} — nothing to migrate`)
skippedCount++
continue
}
await Member.findByIdAndUpdate(
member._id,
{ $set: ccUpdate },
{ runValidators: false }
)
console.log(
` migrated ${label}` +
(topics.length > 0 ? `${topics.length} topic(s)` : '') +
(memberUnmatched.length > 0 ? `${memberUnmatched.length} unmatched` : '')
)
migratedCount++
}
console.log('\n=== Migration Summary ===')
console.log(` Total candidates: ${members.length}`)
console.log(` Migrated: ${migratedCount}`)
console.log(` Skipped: ${skippedCount}`)
console.log(` Tags matched: ${totalTagsMatched}`)
if (allUnmatched.length > 0) {
console.log(`\n Unmatched tags (${allUnmatched.length} member(s)):`)
for (const { member, tags } of allUnmatched) {
console.log(` ${member}`)
for (const t of tags) {
console.log(` - ${t}`)
}
}
} else {
console.log(' Unmatched tags: none')
}
}
migrateCommunityConnections()
.then(() => {
console.log('\nMigration completed successfully')
process.exit(0)
})
.catch((err) => {
console.error('\nMigration failed:', err)
process.exit(1)
})
.finally(() => {
mongoose.connection.close()
})