From 1cb029a881be925e0a90cbf1d7271abb646ad734 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 5 Apr 2026 16:11:52 +0100 Subject: [PATCH] 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. --- scripts/seed-tags.js | 111 +++++++++ .../migrate-community-connections.js | 213 ++++++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 scripts/seed-tags.js create mode 100644 server/migrations/migrate-community-connections.js diff --git a/scripts/seed-tags.js b/scripts/seed-tags.js new file mode 100644 index 0000000..ab19ee2 --- /dev/null +++ b/scripts/seed-tags.js @@ -0,0 +1,111 @@ +/** + * Seed Script: Tags + * + * Upserts craft and cooperative tags by slug (idempotent). + * Safe to run multiple times. + */ + +import 'dotenv/config' +import mongoose from 'mongoose' +import Tag from '../server/models/tag.js' +import { connectDB } from '../server/utils/mongoose.js' + +// Convert a slug like "qa-and-testing" to "QA and Testing" +// Special-cases common abbreviations. +const ABBREVIATIONS = new Set(['qa', 'ux', 'ui', 'devops']) + +function slugToLabel(slug) { + return slug + .split('-') + .map((word) => { + if (ABBREVIATIONS.has(word)) return word.toUpperCase() + return word.charAt(0).toUpperCase() + word.slice(1) + }) + .join(' ') +} + +const CRAFT_SLUGS = [ + 'game-design', + 'programming', + 'narrative-design', + 'art-and-animation', + 'audio-and-music', + 'production-management', + 'qa-and-testing', + 'community-management', + 'marketing-and-comms', + 'ux-and-ui-design', + 'business-development', + 'devops-and-tools', + 'localization', + 'accessibility', + 'analytics-and-data', + 'education-and-mentoring', +] + +const COOPERATIVE_SLUGS = [ + 'governance', + 'finance-and-budgeting', + 'legal-structures', + 'conflict-resolution', + 'consensus-decision-making', + 'revenue-sharing', + 'cooperative-bylaws', + 'member-onboarding', + 'democratic-management', + 'worker-ownership', + 'platform-cooperativism', + 'cooperative-marketing', + 'shared-resources', + 'cooperative-funding', + 'community-building', + 'equity-and-inclusion', + 'cooperative-tech', + 'sustainability', + 'collective-bargaining', + 'inter-coop-collaboration', +] + +async function seedTags() { + await connectDB() + + const tagDefs = [ + ...CRAFT_SLUGS.map((slug) => ({ slug, pool: 'craft', label: slugToLabel(slug) })), + ...COOPERATIVE_SLUGS.map((slug) => ({ slug, pool: 'cooperative', label: slugToLabel(slug) })), + ] + + let upserted = 0 + let unchanged = 0 + + for (const { slug, pool, label } of tagDefs) { + const result = await Tag.updateOne( + { slug }, + { $setOnInsert: { slug, pool, label, active: true, createdAt: new Date() } }, + { upsert: true } + ) + if (result.upsertedCount > 0) { + console.log(` + Created [${pool}] ${label} (${slug})`) + upserted++ + } else { + unchanged++ + } + } + + console.log('\n=== Seed Complete ===') + console.log(` Total tags defined: ${tagDefs.length}`) + console.log(` Newly created: ${upserted}`) + console.log(` Already existed: ${unchanged}`) +} + +seedTags() + .then(() => { + console.log('\nTag seed completed successfully') + process.exit(0) + }) + .catch((err) => { + console.error('\nTag seed failed:', err) + process.exit(1) + }) + .finally(() => { + mongoose.connection.close() + }) diff --git a/server/migrations/migrate-community-connections.js b/server/migrations/migrate-community-connections.js new file mode 100644 index 0000000..e0ce594 --- /dev/null +++ b/server/migrations/migrate-community-connections.js @@ -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() + })