feat: add craft tags and community connections to directory and profiles

Update member directory and public profile APIs to include craftTags
and communityConnections with privacy-aware filtering. Directory now
uses predefined tags from the Tag model for filter bars and supports
craftTag/connectionTag query filters. Frontend shows craft tag pills
and cooperative topics with state labels, falling back to old
offering/lookingFor fields. Add Connections nav item.
This commit is contained in:
Jennie Robinson Faber 2026-04-05 16:40:10 +01:00
parent bd07172093
commit 896de2e7fd
5 changed files with 367 additions and 138 deletions

View file

@ -1,5 +1,6 @@
import jwt from "jsonwebtoken";
import Member from "../../models/member.js";
import Tag from "../../models/tag.js";
import { connectDB } from "../../utils/mongoose.js";
export default defineEventHandler(async (event) => {
@ -27,6 +28,8 @@ export default defineEventHandler(async (event) => {
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 = {
@ -39,46 +42,39 @@ export default defineEventHandler(async (event) => {
dbQuery.circle = circle;
}
// Filter by peer support availability
// Collect $and conditions for combining multiple filters
const andConditions = [];
// Filter by peer support availability (check both old and new fields)
if (peerSupport === "true") {
dbQuery["peerSupport.enabled"] = true;
andConditions.push({
$or: [
{ "peerSupport.enabled": true },
{ "communityConnections.offerPeerSupport": true },
],
});
}
// Search by name or bio
if (search) {
// Escape special regex characters to prevent ReDoS
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
dbQuery.$or = [
{ name: { $regex: escaped, $options: "i" } },
{ bio: { $regex: escaped, $options: "i" } },
];
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
andConditions.push({
$or: [
{ name: { $regex: escaped, $options: "i" } },
{ bio: { $regex: escaped, $options: "i" } },
],
});
}
// Filter by tags (search in offering.tags or lookingFor.tags)
if (tags.length > 0) {
dbQuery.$or = [
{ "offering.tags": { $in: tags } },
{ "lookingFor.tags": { $in: tags } },
];
// If search is also present, combine with AND
if (search) {
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
dbQuery.$and = [
{
$or: [
{ name: { $regex: escaped, $options: "i" } },
{ bio: { $regex: escaped, $options: "i" } },
],
},
{
$or: [
{ "offering.tags": { $in: tags } },
{ "lookingFor.tags": { $in: tags } },
],
},
];
delete dbQuery.$or;
}
andConditions.push({
$or: [
{ "offering.tags": { $in: tags } },
{ "lookingFor.tags": { $in: tags } },
],
});
}
// Filter by peer support topics
@ -86,10 +82,25 @@ export default defineEventHandler(async (event) => {
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;
}
// Apply combined $and conditions
if (andConditions.length > 0) {
dbQuery.$and = andConditions;
}
try {
const members = await Member.find(dbQuery)
.select(
"name pronouns timeZone avatar studio bio location socialLinks offering lookingFor privacy circle peerSupport createdAt",
"name pronouns timeZone avatar studio bio location socialLinks offering lookingFor privacy circle peerSupport craftTags communityConnections createdAt",
)
.sort({ createdAt: -1 })
.lean();
@ -124,6 +135,20 @@ export default defineEventHandler(async (event) => {
if (isVisible("offering")) filtered.offering = member.offering;
if (isVisible("lookingFor")) filtered.lookingFor = member.lookingFor;
// 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) {
@ -138,7 +163,7 @@ export default defineEventHandler(async (event) => {
return filtered;
});
// Get unique tags for filter options (from both offering and lookingFor)
// Get unique tags for filter options (from both offering and lookingFor) — backward compat
const allTags = members
.flatMap((m) => [
...(m.offering?.tags || []),
@ -154,12 +179,23 @@ export default defineEventHandler(async (event) => {
.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(),
]);
return {
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,
label: t.label,
})),
},
};
} catch (error) {