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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue