refactor(community): rename Community Connections → Community Ecology
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:
parent
9577929e0d
commit
0b3896d984
33 changed files with 1002 additions and 2635 deletions
|
|
@ -21,14 +21,11 @@ export default defineEventHandler(async (event) => {
|
|||
bio: member.bio,
|
||||
location: member.location,
|
||||
socialLinks: member.socialLinks,
|
||||
offering: member.offering,
|
||||
lookingFor: member.lookingFor,
|
||||
craftTags: member.craftTags,
|
||||
communityEcology: member.communityEcology,
|
||||
showInDirectory: member.showInDirectory,
|
||||
notifications: member.notifications,
|
||||
privacy: member.privacy,
|
||||
// Peer support
|
||||
peerSupport: member.peerSupport,
|
||||
// Timestamps
|
||||
createdAt: member.createdAt,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
import mongoose from 'mongoose'
|
||||
import Connection from '../../../models/connection.js'
|
||||
import Member from '../../../models/member.js'
|
||||
import { requireAuth } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const member = await requireAuth(event)
|
||||
const memberId = member._id
|
||||
const connectionId = getRouterParam(event, 'id')
|
||||
|
||||
if (!mongoose.Types.ObjectId.isValid(connectionId)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid connection ID'
|
||||
})
|
||||
}
|
||||
|
||||
const connection = await Connection.findById(connectionId)
|
||||
if (!connection) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Connection not found'
|
||||
})
|
||||
}
|
||||
|
||||
// Only the recipient can confirm
|
||||
if (connection.recipient.toString() !== memberId.toString()) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Only the recipient can confirm a connection'
|
||||
})
|
||||
}
|
||||
|
||||
if (connection.status !== 'pending') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Connection is not pending'
|
||||
})
|
||||
}
|
||||
|
||||
connection.status = 'confirmed'
|
||||
connection.confirmedAt = new Date()
|
||||
await connection.save()
|
||||
|
||||
// Get initiator name for activity log
|
||||
const initiator = await Member.findById(connection.initiator)
|
||||
.select('name')
|
||||
.lean()
|
||||
|
||||
logActivity(memberId, 'connection_confirmed', { memberName: initiator?.name || 'Unknown' })
|
||||
logActivity(connection.initiator, 'connection_confirmed', { memberName: member.name })
|
||||
|
||||
return { connection }
|
||||
})
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import mongoose from 'mongoose'
|
||||
import Connection from '../../../models/connection.js'
|
||||
import { requireAuth } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const member = await requireAuth(event)
|
||||
const memberId = member._id
|
||||
const connectionId = getRouterParam(event, 'id')
|
||||
|
||||
if (!mongoose.Types.ObjectId.isValid(connectionId)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid connection ID'
|
||||
})
|
||||
}
|
||||
|
||||
const connection = await Connection.findById(connectionId)
|
||||
if (!connection) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Connection not found'
|
||||
})
|
||||
}
|
||||
|
||||
// Either party can hide
|
||||
const isParty =
|
||||
connection.initiator.toString() === memberId.toString() ||
|
||||
connection.recipient.toString() === memberId.toString()
|
||||
|
||||
if (!isParty) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Not authorized to hide this connection'
|
||||
})
|
||||
}
|
||||
|
||||
// Add to hiddenBy if not already there
|
||||
const alreadyHidden = connection.hiddenBy.some(
|
||||
id => id.toString() === memberId.toString()
|
||||
)
|
||||
|
||||
if (!alreadyHidden) {
|
||||
connection.hiddenBy.push(memberId)
|
||||
await connection.save()
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import mongoose from 'mongoose'
|
||||
import Connection from '../../../models/connection.js'
|
||||
import { requireAuth } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const member = await requireAuth(event)
|
||||
const memberId = member._id
|
||||
const connectionId = getRouterParam(event, 'id')
|
||||
|
||||
if (!mongoose.Types.ObjectId.isValid(connectionId)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid connection ID'
|
||||
})
|
||||
}
|
||||
|
||||
const connection = await Connection.findById(connectionId)
|
||||
if (!connection) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Connection not found'
|
||||
})
|
||||
}
|
||||
|
||||
// Only the initiator can withdraw
|
||||
if (connection.initiator.toString() !== memberId.toString()) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Only the initiator can withdraw a connection request'
|
||||
})
|
||||
}
|
||||
|
||||
if (connection.status !== 'pending') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Can only withdraw pending connections'
|
||||
})
|
||||
}
|
||||
|
||||
await Connection.findByIdAndDelete(connectionId)
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import Connection from '../../models/connection.js'
|
||||
import { requireAuth } from '../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const member = await requireAuth(event)
|
||||
const memberId = member._id
|
||||
|
||||
const [confirmed, pendingOutgoing, pendingIncoming] = await Promise.all([
|
||||
Connection.find({
|
||||
status: 'confirmed',
|
||||
hiddenBy: { $ne: memberId },
|
||||
$or: [
|
||||
{ initiator: memberId },
|
||||
{ recipient: memberId }
|
||||
]
|
||||
})
|
||||
.populate('initiator recipient', 'name avatar craftTags circle')
|
||||
.sort({ confirmedAt: -1 })
|
||||
.lean(),
|
||||
|
||||
Connection.find({
|
||||
initiator: memberId,
|
||||
status: 'pending',
|
||||
hiddenBy: { $ne: memberId }
|
||||
})
|
||||
.populate('recipient', 'name avatar craftTags circle')
|
||||
.sort({ createdAt: -1 })
|
||||
.lean(),
|
||||
|
||||
Connection.find({
|
||||
recipient: memberId,
|
||||
status: 'pending',
|
||||
hiddenBy: { $ne: memberId }
|
||||
})
|
||||
.populate('initiator', 'name avatar craftTags circle')
|
||||
.sort({ createdAt: -1 })
|
||||
.lean()
|
||||
])
|
||||
|
||||
return {
|
||||
confirmed,
|
||||
pendingOutgoing,
|
||||
pendingIncoming
|
||||
}
|
||||
})
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
import mongoose from 'mongoose'
|
||||
import Member from '../../models/member.js'
|
||||
import Connection from '../../models/connection.js'
|
||||
import { requireAuth } from '../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const member = await requireAuth(event)
|
||||
const memberId = member._id
|
||||
|
||||
const body = await readBody(event)
|
||||
const { recipientId } = body || {}
|
||||
|
||||
if (!recipientId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'recipientId is required'
|
||||
})
|
||||
}
|
||||
|
||||
if (!mongoose.Types.ObjectId.isValid(recipientId)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid recipientId'
|
||||
})
|
||||
}
|
||||
|
||||
if (recipientId === memberId.toString()) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Cannot connect with yourself'
|
||||
})
|
||||
}
|
||||
|
||||
// Verify recipient exists and is active
|
||||
const recipient = await Member.findById(recipientId).lean()
|
||||
if (!recipient || recipient.status !== 'active') {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Recipient not found or not active'
|
||||
})
|
||||
}
|
||||
|
||||
// Check for existing connection in either direction
|
||||
const existing = await Connection.findOne({
|
||||
$or: [
|
||||
{ initiator: memberId, recipient: recipientId },
|
||||
{ initiator: recipientId, recipient: memberId }
|
||||
]
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
// If reverse pending connection exists, auto-confirm
|
||||
if (
|
||||
existing.status === 'pending' &&
|
||||
existing.initiator.toString() === recipientId &&
|
||||
existing.recipient.toString() === memberId.toString()
|
||||
) {
|
||||
existing.status = 'confirmed'
|
||||
existing.confirmedAt = new Date()
|
||||
await existing.save()
|
||||
|
||||
logActivity(memberId, 'connection_confirmed', { memberName: recipient.name })
|
||||
logActivity(recipientId, 'connection_confirmed', { memberName: member.name })
|
||||
|
||||
return {
|
||||
connection: existing,
|
||||
autoConfirmed: true
|
||||
}
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: 'Connection already exists'
|
||||
})
|
||||
}
|
||||
|
||||
// Snapshot matching tags between the two members
|
||||
const myTopics = member.communityConnections?.topics || []
|
||||
const theirTopics = recipient.communityConnections?.topics || []
|
||||
const myTopicMap = {}
|
||||
for (const t of myTopics) {
|
||||
myTopicMap[t.tagSlug] = t.state
|
||||
}
|
||||
|
||||
const matchingTags = []
|
||||
for (const t of theirTopics) {
|
||||
const myState = myTopicMap[t.tagSlug]
|
||||
if (myState) {
|
||||
matchingTags.push({
|
||||
tagSlug: t.tagSlug,
|
||||
initiatorState: myState,
|
||||
recipientState: t.state
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const connection = await Connection.create({
|
||||
initiator: memberId,
|
||||
recipient: recipientId,
|
||||
status: 'pending',
|
||||
matchingTags
|
||||
})
|
||||
|
||||
logActivity(memberId, 'connection_requested', { memberName: recipient.name })
|
||||
logActivity(recipientId, 'connection_requested', { memberName: member.name })
|
||||
|
||||
return { connection }
|
||||
})
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import Connection from '../../models/connection.js'
|
||||
import { requireAuth } from '../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const member = await requireAuth(event)
|
||||
|
||||
const count = await Connection.countDocuments({
|
||||
recipient: member._id,
|
||||
status: 'pending',
|
||||
hiddenBy: { $ne: member._id }
|
||||
})
|
||||
|
||||
return { count }
|
||||
})
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
import Member from '../../models/member.js'
|
||||
import Connection from '../../models/connection.js'
|
||||
import { requireAuth } from '../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const member = await requireAuth(event)
|
||||
const memberId = member._id
|
||||
|
||||
const topics = member.communityConnections?.topics || []
|
||||
if (!topics.length) {
|
||||
return { suggestions: [] }
|
||||
}
|
||||
|
||||
const query = getQuery(event)
|
||||
const filterTag = query.tag || null
|
||||
const filterState = query.state || null
|
||||
const showHidden = query.showHidden === 'true'
|
||||
|
||||
// Build the set of tag slugs to match against
|
||||
let myTopics = topics
|
||||
if (filterTag) {
|
||||
myTopics = myTopics.filter(t => t.tagSlug === filterTag)
|
||||
}
|
||||
if (filterState) {
|
||||
myTopics = myTopics.filter(t => t.state === filterState)
|
||||
}
|
||||
if (!myTopics.length) {
|
||||
return { suggestions: [] }
|
||||
}
|
||||
|
||||
const mySlugs = myTopics.map(t => t.tagSlug)
|
||||
|
||||
// Find active members sharing at least one topic slug
|
||||
const candidates = await Member.find({
|
||||
_id: { $ne: memberId },
|
||||
status: 'active',
|
||||
'communityConnections.topics.tagSlug': { $in: mySlugs }
|
||||
})
|
||||
.select('name avatar craftTags circle communityConnections privacy')
|
||||
.lean()
|
||||
|
||||
if (!candidates.length) {
|
||||
return { suggestions: [] }
|
||||
}
|
||||
|
||||
const candidateIds = candidates.map(c => c._id)
|
||||
|
||||
// Find existing connections (pending or confirmed) to exclude
|
||||
const existingConnections = await Connection.find({
|
||||
$or: [
|
||||
{ initiator: memberId, recipient: { $in: candidateIds } },
|
||||
{ recipient: memberId, initiator: { $in: candidateIds } }
|
||||
]
|
||||
})
|
||||
.select('initiator recipient hiddenBy status')
|
||||
.lean()
|
||||
|
||||
// Build sets for exclusion
|
||||
const excludeIds = new Set()
|
||||
for (const conn of existingConnections) {
|
||||
const otherId = conn.initiator.toString() === memberId.toString()
|
||||
? conn.recipient.toString()
|
||||
: conn.initiator.toString()
|
||||
|
||||
// Exclude if confirmed or pending connection exists
|
||||
if (conn.status === 'confirmed' || conn.status === 'pending') {
|
||||
excludeIds.add(otherId)
|
||||
}
|
||||
// Exclude if current member has hidden this connection (unless showHidden)
|
||||
if (!showHidden && conn.hiddenBy?.some(id => id.toString() === memberId.toString())) {
|
||||
excludeIds.add(otherId)
|
||||
}
|
||||
}
|
||||
|
||||
// Build topic lookup for current member (using filtered topics)
|
||||
const myTopicMap = {}
|
||||
for (const t of myTopics) {
|
||||
myTopicMap[t.tagSlug] = t.state
|
||||
}
|
||||
|
||||
// Compute suggestions
|
||||
const suggestions = []
|
||||
for (const candidate of candidates) {
|
||||
if (excludeIds.has(candidate._id.toString())) continue
|
||||
|
||||
const theirTopics = candidate.communityConnections?.topics || []
|
||||
const matchingTags = []
|
||||
|
||||
for (const theirTopic of theirTopics) {
|
||||
const myState = myTopicMap[theirTopic.tagSlug]
|
||||
if (!myState) continue
|
||||
|
||||
matchingTags.push({
|
||||
tagSlug: theirTopic.tagSlug,
|
||||
yourState: myState,
|
||||
theirState: theirTopic.state
|
||||
})
|
||||
}
|
||||
|
||||
if (!matchingTags.length) continue
|
||||
|
||||
// Apply privacy filtering — only expose fields the member allows for other members
|
||||
const privacy = candidate.privacy || {}
|
||||
const filtered = {
|
||||
_id: candidate._id,
|
||||
name: candidate.name,
|
||||
circle: candidate.circle,
|
||||
}
|
||||
|
||||
const avatarPrivacy = privacy.avatar || 'public'
|
||||
if (avatarPrivacy === 'public' || avatarPrivacy === 'members') {
|
||||
filtered.avatar = candidate.avatar
|
||||
}
|
||||
|
||||
const craftTagsPrivacy = privacy.craftTags || 'members'
|
||||
if (craftTagsPrivacy === 'public' || craftTagsPrivacy === 'members') {
|
||||
filtered.craftTags = candidate.craftTags
|
||||
}
|
||||
|
||||
suggestions.push({
|
||||
member: filtered,
|
||||
matchingTags,
|
||||
matchCount: matchingTags.length
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by overlap count descending
|
||||
suggestions.sort((a, b) => b.matchCount - a.matchCount)
|
||||
|
||||
return { suggestions }
|
||||
})
|
||||
96
server/api/ecology/suggestions.get.js
Normal file
96
server/api/ecology/suggestions.get.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import Member from '../../models/member.js'
|
||||
import { requireAuth } from '../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const member = await requireAuth(event)
|
||||
const memberId = member._id
|
||||
|
||||
const topics = member.communityEcology?.topics || []
|
||||
if (!topics.length) {
|
||||
return { suggestions: [] }
|
||||
}
|
||||
|
||||
const query = getQuery(event)
|
||||
const filterTag = query.tag || null
|
||||
|
||||
let myTopics = topics
|
||||
if (filterTag) {
|
||||
myTopics = myTopics.filter((t) => t.tagSlug === filterTag)
|
||||
}
|
||||
if (!myTopics.length) {
|
||||
return { suggestions: [] }
|
||||
}
|
||||
|
||||
const mySlugs = myTopics.map((t) => t.tagSlug)
|
||||
|
||||
const candidates = await Member.find({
|
||||
_id: { $ne: memberId },
|
||||
status: 'active',
|
||||
'communityEcology.topics.tagSlug': { $in: mySlugs },
|
||||
})
|
||||
.select('name avatar craftTags circle communityEcology privacy')
|
||||
.lean()
|
||||
|
||||
if (!candidates.length) {
|
||||
return { suggestions: [] }
|
||||
}
|
||||
|
||||
const myTopicMap = {}
|
||||
for (const t of myTopics) {
|
||||
myTopicMap[t.tagSlug] = t.state
|
||||
}
|
||||
|
||||
const suggestions = []
|
||||
for (const candidate of candidates) {
|
||||
const theirTopics = candidate.communityEcology?.topics || []
|
||||
const matchingTags = []
|
||||
|
||||
for (const theirTopic of theirTopics) {
|
||||
const myState = myTopicMap[theirTopic.tagSlug]
|
||||
if (!myState) continue
|
||||
|
||||
matchingTags.push({
|
||||
tagSlug: theirTopic.tagSlug,
|
||||
yourState: myState,
|
||||
theirState: theirTopic.state,
|
||||
})
|
||||
}
|
||||
|
||||
if (!matchingTags.length) continue
|
||||
|
||||
// Privacy filter: only expose fields the candidate allows to other members
|
||||
const privacy = candidate.privacy || {}
|
||||
const filtered = {
|
||||
_id: candidate._id,
|
||||
name: candidate.name,
|
||||
circle: candidate.circle,
|
||||
}
|
||||
|
||||
const avatarPrivacy = privacy.avatar || 'public'
|
||||
if (avatarPrivacy === 'public' || avatarPrivacy === 'members') {
|
||||
filtered.avatar = candidate.avatar
|
||||
}
|
||||
|
||||
const craftTagsPrivacy = privacy.craftTags || 'members'
|
||||
if (craftTagsPrivacy === 'public' || craftTagsPrivacy === 'members') {
|
||||
filtered.craftTags = candidate.craftTags
|
||||
}
|
||||
|
||||
// Expose slackHandle only when the candidate has opted into peer support.
|
||||
// Slack handle is the contact-in-place path — without it, there is no way
|
||||
// for the current member to reach out.
|
||||
if (candidate.communityEcology?.offerPeerSupport && candidate.communityEcology?.slackHandle) {
|
||||
filtered.slackHandle = candidate.communityEcology.slackHandle
|
||||
}
|
||||
|
||||
suggestions.push({
|
||||
member: filtered,
|
||||
matchingTags,
|
||||
matchCount: matchingTags.length,
|
||||
})
|
||||
}
|
||||
|
||||
suggestions.sort((a, b) => b.matchCount - a.matchCount)
|
||||
|
||||
return { suggestions }
|
||||
})
|
||||
|
|
@ -30,7 +30,7 @@ export default defineEventHandler(async (event) => {
|
|||
status: "active",
|
||||
})
|
||||
.select(
|
||||
"name pronouns timeZone avatar studio bio location socialLinks offering lookingFor privacy circle peerSupport craftTags communityConnections createdAt memberNumber",
|
||||
"name pronouns timeZone avatar studio bio location socialLinks privacy circle craftTags communityEcology createdAt memberNumber",
|
||||
)
|
||||
.lean();
|
||||
|
||||
|
|
@ -68,32 +68,21 @@ export default defineEventHandler(async (event) => {
|
|||
if (isVisible("bio")) filtered.bio = member.bio;
|
||||
if (isVisible("location")) filtered.location = member.location;
|
||||
if (isVisible("socialLinks")) filtered.socialLinks = member.socialLinks;
|
||||
if (isVisible("offering")) filtered.offering = member.offering;
|
||||
if (isVisible("lookingFor")) filtered.lookingFor = member.lookingFor;
|
||||
if (isVisible("craftTags")) filtered.craftTags = member.craftTags;
|
||||
|
||||
// Craft tags
|
||||
if (isVisible("craftTags")) {
|
||||
filtered.craftTags = member.craftTags;
|
||||
}
|
||||
|
||||
// Community connections (expose only public-safe fields)
|
||||
if (isVisible("communityConnections")) {
|
||||
filtered.communityConnections = {
|
||||
topics: member.communityConnections?.topics,
|
||||
offerPeerSupport: member.communityConnections?.offerPeerSupport,
|
||||
availability: member.communityConnections?.availability,
|
||||
details: member.communityConnections?.details,
|
||||
};
|
||||
}
|
||||
|
||||
// Peer support: expose only fields needed for matching/contact UX
|
||||
// slackUserId, slackDMChannelId, slackUsername, personalMessage are internal
|
||||
if (member.peerSupport?.enabled) {
|
||||
filtered.peerSupport = {
|
||||
enabled: true,
|
||||
skillTopics: member.peerSupport.skillTopics,
|
||||
supportTopics: member.peerSupport.supportTopics,
|
||||
availability: member.peerSupport.availability,
|
||||
if (isVisible("communityEcology")) {
|
||||
const ecology = member.communityEcology || {};
|
||||
filtered.communityEcology = {
|
||||
topics: ecology.topics,
|
||||
offerPeerSupport: ecology.offerPeerSupport,
|
||||
availability: ecology.availability,
|
||||
details: ecology.details,
|
||||
// Contact-in-place: surface the handle + personal message only when
|
||||
// the member has explicitly opted into peer support.
|
||||
...(ecology.offerPeerSupport && {
|
||||
slackHandle: ecology.slackHandle,
|
||||
personalMessage: ecology.personalMessage,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,15 +9,12 @@ export default defineEventHandler(async (event) => {
|
|||
// Check if user is authenticated
|
||||
const token = getCookie(event, "auth-token");
|
||||
let isAuthenticated = false;
|
||||
let currentMemberId = null;
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
|
||||
currentMemberId = decoded.memberId;
|
||||
jwt.verify(token, useRuntimeConfig().jwtSecret);
|
||||
isAuthenticated = true;
|
||||
} catch (err) {
|
||||
// Invalid token, treat as public
|
||||
isAuthenticated = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -25,39 +22,27 @@ export default defineEventHandler(async (event) => {
|
|||
const query = getQuery(event);
|
||||
const search = query.search || "";
|
||||
const circle = query.circle || "";
|
||||
const tags = query.tags ? query.tags.split(",") : [];
|
||||
const peerSupport = query.peerSupport || "";
|
||||
const topics = query.topics ? query.topics.split(",") : [];
|
||||
const craftTag = query.craftTag || "";
|
||||
const connectionTag = query.connectionTag || "";
|
||||
|
||||
// Build query
|
||||
const dbQuery = {
|
||||
showInDirectory: true,
|
||||
status: "active",
|
||||
};
|
||||
|
||||
// Filter by circle if specified
|
||||
if (circle) {
|
||||
dbQuery.circle = circle;
|
||||
}
|
||||
|
||||
// Collect $and conditions for combining multiple filters
|
||||
const andConditions = [];
|
||||
|
||||
// Filter by peer support availability (check both old and new fields)
|
||||
if (peerSupport === "true") {
|
||||
andConditions.push({
|
||||
$or: [
|
||||
{ "peerSupport.enabled": true },
|
||||
{ "communityConnections.offerPeerSupport": true },
|
||||
],
|
||||
});
|
||||
dbQuery["communityEcology.offerPeerSupport"] = true;
|
||||
}
|
||||
|
||||
// Search by name or bio
|
||||
if (search) {
|
||||
// Escape special regex characters to prevent ReDoS
|
||||
// Escape regex metacharacters to prevent ReDoS
|
||||
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
andConditions.push({
|
||||
$or: [
|
||||
|
|
@ -67,32 +52,14 @@ export default defineEventHandler(async (event) => {
|
|||
});
|
||||
}
|
||||
|
||||
// Filter by tags (search in offering.tags or lookingFor.tags)
|
||||
if (tags.length > 0) {
|
||||
andConditions.push({
|
||||
$or: [
|
||||
{ "offering.tags": { $in: tags } },
|
||||
{ "lookingFor.tags": { $in: tags } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by peer support topics
|
||||
if (topics.length > 0) {
|
||||
dbQuery["peerSupport.topics"] = { $in: topics };
|
||||
}
|
||||
|
||||
// Filter by craft tag
|
||||
if (craftTag) {
|
||||
dbQuery.craftTags = craftTag;
|
||||
}
|
||||
|
||||
// Filter by connection tag
|
||||
if (connectionTag) {
|
||||
dbQuery["communityConnections.topics.tagSlug"] = connectionTag;
|
||||
dbQuery["communityEcology.topics.tagSlug"] = connectionTag;
|
||||
}
|
||||
|
||||
// Apply combined $and conditions
|
||||
if (andConditions.length > 0) {
|
||||
dbQuery.$and = andConditions;
|
||||
}
|
||||
|
|
@ -100,12 +67,11 @@ export default defineEventHandler(async (event) => {
|
|||
try {
|
||||
const members = await Member.find(dbQuery)
|
||||
.select(
|
||||
"name pronouns timeZone avatar studio bio location socialLinks offering lookingFor privacy circle peerSupport craftTags communityConnections createdAt",
|
||||
"name pronouns timeZone avatar studio bio location socialLinks privacy circle craftTags communityEcology createdAt",
|
||||
)
|
||||
.sort({ createdAt: -1 })
|
||||
.lean();
|
||||
|
||||
// Filter fields based on privacy settings
|
||||
const filteredMembers = members.map((member) => {
|
||||
const privacy = member.privacy || {};
|
||||
const filtered = {
|
||||
|
|
@ -115,16 +81,13 @@ export default defineEventHandler(async (event) => {
|
|||
createdAt: member.createdAt,
|
||||
};
|
||||
|
||||
// Helper function to check if field should be visible
|
||||
const isVisible = (field) => {
|
||||
const privacySetting = privacy[field] || "members";
|
||||
if (privacySetting === "public") return true;
|
||||
if (privacySetting === "members" && isAuthenticated) return true;
|
||||
if (privacySetting === "private") return false;
|
||||
return false;
|
||||
};
|
||||
|
||||
// Add fields based on privacy settings
|
||||
if (isVisible("avatar")) filtered.avatar = member.avatar;
|
||||
if (isVisible("pronouns")) filtered.pronouns = member.pronouns;
|
||||
if (isVisible("timeZone")) filtered.timeZone = member.timeZone;
|
||||
|
|
@ -132,54 +95,23 @@ export default defineEventHandler(async (event) => {
|
|||
if (isVisible("bio")) filtered.bio = member.bio;
|
||||
if (isVisible("location")) filtered.location = member.location;
|
||||
if (isVisible("socialLinks")) filtered.socialLinks = member.socialLinks;
|
||||
if (isVisible("offering")) filtered.offering = member.offering;
|
||||
if (isVisible("lookingFor")) filtered.lookingFor = member.lookingFor;
|
||||
if (isVisible("craftTags")) filtered.craftTags = member.craftTags;
|
||||
|
||||
// Craft tags (with fallback to offering.tags for backward compat)
|
||||
if (isVisible("craftTags")) {
|
||||
filtered.craftTags = member.craftTags;
|
||||
}
|
||||
|
||||
// Community connections (expose only public-safe fields)
|
||||
if (isVisible("communityConnections")) {
|
||||
filtered.communityConnections = {
|
||||
topics: member.communityConnections?.topics,
|
||||
offerPeerSupport: member.communityConnections?.offerPeerSupport,
|
||||
availability: member.communityConnections?.availability,
|
||||
};
|
||||
}
|
||||
|
||||
// Peer support: expose only fields needed for matching/contact UX
|
||||
// slackUserId, slackDMChannelId, slackUsername, personalMessage are internal
|
||||
if (member.peerSupport?.enabled) {
|
||||
filtered.peerSupport = {
|
||||
enabled: true,
|
||||
skillTopics: member.peerSupport.skillTopics,
|
||||
supportTopics: member.peerSupport.supportTopics,
|
||||
availability: member.peerSupport.availability,
|
||||
if (isVisible("communityEcology")) {
|
||||
const ecology = member.communityEcology || {};
|
||||
filtered.communityEcology = {
|
||||
topics: ecology.topics,
|
||||
offerPeerSupport: ecology.offerPeerSupport,
|
||||
availability: ecology.availability,
|
||||
...(ecology.offerPeerSupport && {
|
||||
slackHandle: ecology.slackHandle,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
// Get unique tags for filter options (from both offering and lookingFor) — backward compat
|
||||
const allTags = members
|
||||
.flatMap((m) => [
|
||||
...(m.offering?.tags || []),
|
||||
...(m.lookingFor?.tags || []),
|
||||
])
|
||||
.filter((tag, index, self) => self.indexOf(tag) === index)
|
||||
.sort();
|
||||
|
||||
// Get unique peer support topics
|
||||
const allTopics = members
|
||||
.filter((m) => m.peerSupport?.enabled)
|
||||
.flatMap((m) => m.peerSupport?.topics || [])
|
||||
.filter((topic, index, self) => self.indexOf(topic) === index)
|
||||
.sort();
|
||||
|
||||
// Fetch predefined tags from Tag model for filter bars
|
||||
const [craftTags, cooperativeTags] = await Promise.all([
|
||||
Tag.find({ pool: "craft", active: true }).sort({ label: 1 }).lean(),
|
||||
Tag.find({ pool: "cooperative", active: true }).sort({ label: 1 }).lean(),
|
||||
|
|
@ -189,8 +121,6 @@ export default defineEventHandler(async (event) => {
|
|||
members: filteredMembers,
|
||||
totalCount: filteredMembers.length,
|
||||
filters: {
|
||||
availableSkills: allTags,
|
||||
availableTopics: allTopics,
|
||||
craftTags: craftTags.map((t) => ({ slug: t.slug, label: t.label })),
|
||||
cooperativeTags: cooperativeTags.map((t) => ({
|
||||
slug: t.slug,
|
||||
|
|
|
|||
|
|
@ -1,95 +0,0 @@
|
|||
import Member from '../../../models/member.js'
|
||||
import { connectDB } from '../../../utils/mongoose.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await connectDB()
|
||||
const member = await requireAuth(event)
|
||||
|
||||
const body = await validateBody(event, communityConnectionsUpdateSchema)
|
||||
|
||||
// Build update object for community connections settings
|
||||
const updateData = {
|
||||
'communityConnections.topics': body.topics || [],
|
||||
'communityConnections.offerPeerSupport': body.offerPeerSupport || false,
|
||||
'communityConnections.availability': body.availability || '',
|
||||
'communityConnections.slackHandle': body.slackHandle || '',
|
||||
'communityConnections.personalMessage': body.personalMessage || '',
|
||||
'communityConnections.details': body.details || '',
|
||||
}
|
||||
|
||||
// If Slack handle provided and peer support offered, try to fetch Slack user ID and open DM
|
||||
if (body.offerPeerSupport && body.slackHandle) {
|
||||
try {
|
||||
console.log(
|
||||
`[Community Connections] Attempting to fetch Slack user ID for: ${body.slackHandle}`,
|
||||
)
|
||||
|
||||
const { getSlackService } = await import('../../../utils/slack.ts')
|
||||
const slackService = getSlackService()
|
||||
|
||||
if (slackService) {
|
||||
console.log('[Community Connections] Slack service initialized, looking up user...')
|
||||
const slackUserId = await slackService.findUserIdByUsername(body.slackHandle)
|
||||
|
||||
if (slackUserId) {
|
||||
updateData['slackUserId'] = slackUserId
|
||||
console.log(
|
||||
`[Community Connections] ✓ Found Slack user ID for ${body.slackHandle}: ${slackUserId}`,
|
||||
)
|
||||
|
||||
console.log('[Community Connections] Opening DM channel...')
|
||||
const dmChannelId = await slackService.openDMChannel(slackUserId)
|
||||
|
||||
if (dmChannelId) {
|
||||
updateData['communityConnections.slackDMChannelId'] = dmChannelId
|
||||
console.log(`[Community Connections] ✓ Got DM channel ID: ${dmChannelId}`)
|
||||
} else {
|
||||
console.warn('[Community Connections] Could not get DM channel ID')
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
`[Community Connections] Could not find Slack user ID for handle: ${body.slackHandle}`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
console.log('[Community Connections] Slack service not configured, skipping user ID lookup')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Community Connections] Error fetching Slack user ID:', error.message)
|
||||
console.error('[Community Connections] Stack trace:', error.stack)
|
||||
// Continue anyway - we'll still save the handle
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await Member.findByIdAndUpdate(
|
||||
member._id,
|
||||
{ $set: updateData },
|
||||
{ new: true, runValidators: true },
|
||||
)
|
||||
|
||||
if (!updated) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Member not found',
|
||||
})
|
||||
}
|
||||
|
||||
logActivity(member._id, 'community_connections_updated', {
|
||||
topicCount: (body.topics || []).length,
|
||||
offerPeerSupport: body.offerPeerSupport || false,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
communityConnections: updated.communityConnections,
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.statusCode) throw error
|
||||
console.error('Community connections update error:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to update community connections settings',
|
||||
})
|
||||
}
|
||||
})
|
||||
70
server/api/members/me/community-ecology.patch.js
Normal file
70
server/api/members/me/community-ecology.patch.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import Member from '../../../models/member.js'
|
||||
import { connectDB } from '../../../utils/mongoose.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await connectDB()
|
||||
const member = await requireAuth(event)
|
||||
|
||||
const body = await validateBody(event, communityEcologyUpdateSchema)
|
||||
|
||||
const updateData = {
|
||||
'communityEcology.topics': body.topics || [],
|
||||
'communityEcology.offerPeerSupport': body.offerPeerSupport || false,
|
||||
'communityEcology.availability': body.availability || '',
|
||||
'communityEcology.slackHandle': body.slackHandle || '',
|
||||
'communityEcology.personalMessage': body.personalMessage || '',
|
||||
'communityEcology.details': body.details || '',
|
||||
}
|
||||
|
||||
if (body.offerPeerSupport && body.slackHandle) {
|
||||
try {
|
||||
const { getSlackService } = await import('../../../utils/slack.ts')
|
||||
const slackService = getSlackService()
|
||||
|
||||
if (slackService) {
|
||||
const slackUserId = await slackService.findUserIdByUsername(body.slackHandle)
|
||||
if (slackUserId) {
|
||||
updateData.slackUserId = slackUserId
|
||||
} else {
|
||||
console.warn(
|
||||
`[Community Ecology] Could not find Slack user ID for handle: ${body.slackHandle}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Community Ecology] Error fetching Slack user ID:', error.message)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await Member.findByIdAndUpdate(
|
||||
member._id,
|
||||
{ $set: updateData },
|
||||
{ new: true, runValidators: true },
|
||||
)
|
||||
|
||||
if (!updated) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Member not found',
|
||||
})
|
||||
}
|
||||
|
||||
logActivity(member._id, 'community_ecology_updated', {
|
||||
topicCount: (body.topics || []).length,
|
||||
offerPeerSupport: body.offerPeerSupport || false,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
communityEcology: updated.communityEcology,
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.statusCode) throw error
|
||||
console.error('Community ecology update error:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to update community ecology settings',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -32,7 +32,7 @@ export default defineEventHandler(async (event) => {
|
|||
"locationPrivacy",
|
||||
"socialLinksPrivacy",
|
||||
"craftTagsPrivacy",
|
||||
"communityConnectionsPrivacy",
|
||||
"communityEcologyPrivacy",
|
||||
];
|
||||
|
||||
// Build update object from validated data
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
|
@ -17,6 +17,7 @@ const ACTIVITY_TYPES = [
|
|||
'slack_invited',
|
||||
'email_sent',
|
||||
'community_connections_updated',
|
||||
'community_ecology_updated',
|
||||
'connection_requested',
|
||||
'connection_confirmed',
|
||||
'tag_suggested'
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
import mongoose from 'mongoose'
|
||||
|
||||
const connectionSchema = new mongoose.Schema({
|
||||
initiator: { type: mongoose.Schema.Types.ObjectId, ref: 'Member', required: true },
|
||||
recipient: { type: mongoose.Schema.Types.ObjectId, ref: 'Member', required: true },
|
||||
status: { type: String, enum: ['pending', 'confirmed'], default: 'pending' },
|
||||
matchingTags: [
|
||||
{
|
||||
tagSlug: String,
|
||||
initiatorState: { type: String, enum: ['help', 'interested', 'seeking'] },
|
||||
recipientState: { type: String, enum: ['help', 'interested', 'seeking'] },
|
||||
},
|
||||
],
|
||||
hiddenBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Member' }],
|
||||
createdAt: { type: Date, default: Date.now },
|
||||
confirmedAt: Date,
|
||||
})
|
||||
|
||||
connectionSchema.index({ initiator: 1, recipient: 1 }, { unique: true })
|
||||
connectionSchema.index({ recipient: 1, status: 1 })
|
||||
|
||||
export default mongoose.models.Connection || mongoose.model('Connection', connectionSchema)
|
||||
|
|
@ -69,29 +69,10 @@ const memberSchema = new mongoose.Schema({
|
|||
website: String,
|
||||
other: String,
|
||||
},
|
||||
offering: {
|
||||
text: String,
|
||||
tags: [String],
|
||||
},
|
||||
lookingFor: {
|
||||
text: String,
|
||||
tags: [String],
|
||||
},
|
||||
showInDirectory: { type: Boolean, default: true },
|
||||
|
||||
// Peer support settings
|
||||
peerSupport: {
|
||||
enabled: { type: Boolean, default: false },
|
||||
skillTopics: [String], // Auto-populated from offering.tags, editable
|
||||
supportTopics: [String], // Curated conversational/emotional support topics
|
||||
availability: String,
|
||||
personalMessage: String,
|
||||
slackUsername: String,
|
||||
slackDMChannelId: String, // DM channel ID for direct messaging
|
||||
},
|
||||
|
||||
craftTags: [String],
|
||||
communityConnections: {
|
||||
communityEcology: {
|
||||
topics: [
|
||||
{
|
||||
tagSlug: String,
|
||||
|
|
@ -142,22 +123,12 @@ const memberSchema = new mongoose.Schema({
|
|||
enum: ["public", "members", "private"],
|
||||
default: "members",
|
||||
},
|
||||
offering: {
|
||||
type: String,
|
||||
enum: ["public", "members", "private"],
|
||||
default: "members",
|
||||
},
|
||||
lookingFor: {
|
||||
type: String,
|
||||
enum: ["public", "members", "private"],
|
||||
default: "members",
|
||||
},
|
||||
craftTags: {
|
||||
type: String,
|
||||
enum: ["public", "members", "private"],
|
||||
default: "members",
|
||||
},
|
||||
communityConnections: {
|
||||
communityEcology: {
|
||||
type: String,
|
||||
enum: ["public", "members", "private"],
|
||||
default: "members",
|
||||
|
|
@ -167,8 +138,6 @@ const memberSchema = new mongoose.Schema({
|
|||
notifications: {
|
||||
events: { type: Boolean, default: true },
|
||||
updates: { type: Boolean, default: true },
|
||||
peerRequests: { type: Boolean, default: true },
|
||||
connectionRequests: { type: Boolean, default: true },
|
||||
},
|
||||
|
||||
inviteEmailSent: { type: Boolean, default: false },
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export const ACTIVITY_TYPES = {
|
|||
SLACK_INVITED: 'slack_invited',
|
||||
EMAIL_SENT: 'email_sent',
|
||||
COMMUNITY_CONNECTIONS_UPDATED: 'community_connections_updated',
|
||||
COMMUNITY_ECOLOGY_UPDATED: 'community_ecology_updated',
|
||||
CONNECTION_REQUESTED: 'connection_requested',
|
||||
CONNECTION_CONFIRMED: 'connection_confirmed',
|
||||
TAG_SUGGESTED: 'tag_suggested'
|
||||
|
|
@ -39,6 +40,7 @@ export const ACTIVITY_TYPE_DEFAULTS = {
|
|||
slack_invited: 'admin',
|
||||
email_sent: 'member',
|
||||
community_connections_updated: 'member',
|
||||
community_ecology_updated: 'member',
|
||||
connection_requested: 'member',
|
||||
connection_confirmed: 'member',
|
||||
tag_suggested: 'member'
|
||||
|
|
|
|||
|
|
@ -27,20 +27,10 @@ export const memberProfileUpdateSchema = z.object({
|
|||
website: z.string().max(300).optional(),
|
||||
other: z.string().max(300).optional()
|
||||
}).optional(),
|
||||
offering: z.object({
|
||||
text: z.string().max(2000).optional(),
|
||||
tags: z.array(z.string().max(100)).max(20).optional()
|
||||
}).optional(),
|
||||
lookingFor: z.object({
|
||||
text: z.string().max(2000).optional(),
|
||||
tags: z.array(z.string().max(100)).max(20).optional()
|
||||
}).optional(),
|
||||
showInDirectory: z.boolean().optional(),
|
||||
notifications: z.object({
|
||||
events: z.boolean().optional(),
|
||||
updates: z.boolean().optional(),
|
||||
peerRequests: z.boolean().optional(),
|
||||
connectionRequests: z.boolean().optional()
|
||||
updates: z.boolean().optional()
|
||||
}).optional(),
|
||||
pronounsPrivacy: privacyEnum.optional(),
|
||||
timeZonePrivacy: privacyEnum.optional(),
|
||||
|
|
@ -49,11 +39,9 @@ export const memberProfileUpdateSchema = z.object({
|
|||
bioPrivacy: privacyEnum.optional(),
|
||||
locationPrivacy: privacyEnum.optional(),
|
||||
socialLinksPrivacy: privacyEnum.optional(),
|
||||
offeringPrivacy: privacyEnum.optional(),
|
||||
lookingForPrivacy: privacyEnum.optional(),
|
||||
craftTags: z.array(z.string().max(100)).max(16).optional(),
|
||||
craftTagsPrivacy: privacyEnum.optional(),
|
||||
communityConnectionsPrivacy: privacyEnum.optional()
|
||||
communityEcologyPrivacy: privacyEnum.optional()
|
||||
})
|
||||
|
||||
export const eventRegistrationSchema = z.object({
|
||||
|
|
@ -168,15 +156,6 @@ export const updateCircleSchema = z.object({
|
|||
circle: z.enum(['community', 'founder', 'practitioner'])
|
||||
})
|
||||
|
||||
export const peerSupportUpdateSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
skillTopics: z.array(z.string().max(200)).max(20).optional(),
|
||||
supportTopics: z.array(z.string().max(200)).max(20).optional(),
|
||||
availability: z.string().max(500).optional(),
|
||||
personalMessage: z.string().max(2000).optional(),
|
||||
slackUsername: z.string().max(200).optional()
|
||||
})
|
||||
|
||||
// --- Series ticket schemas ---
|
||||
|
||||
export const seriesTicketPurchaseSchema = z.object({
|
||||
|
|
@ -392,7 +371,7 @@ export const tagSuggestionSchema = z.object({
|
|||
pool: z.enum(['craft', 'cooperative'])
|
||||
})
|
||||
|
||||
export const communityConnectionsUpdateSchema = z.object({
|
||||
export const communityEcologyUpdateSchema = z.object({
|
||||
topics: z.array(z.object({
|
||||
tagSlug: z.string().min(1).max(100),
|
||||
state: z.enum(['help', 'interested', 'seeking'])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue