feat: add seed-tags and migrate-community-connections scripts

Idempotent seed for 16 craft + 20 cooperative tags by slug. Migration
maps existing offering/lookingFor tags to communityConnections.topics
and copies peerSupport fields without deleting originals.
This commit is contained in:
Jennie Robinson Faber 2026-04-05 16:11:52 +01:00
parent 4b6ff19d5f
commit 1cb029a881
2 changed files with 324 additions and 0 deletions

View file

@ -0,0 +1,213 @@
/**
* 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()
})