/** * 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() })