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:
parent
4b6ff19d5f
commit
1cb029a881
2 changed files with 324 additions and 0 deletions
213
server/migrations/migrate-community-connections.js
Normal file
213
server/migrations/migrate-community-connections.js
Normal 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()
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue