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

@ -173,6 +173,7 @@ const youItems = [
const exploreItems = [ const exploreItems = [
{ label: "Events", path: "/events" }, { label: "Events", path: "/events" },
{ label: "Members", path: "/members" }, { label: "Members", path: "/members" },
{ label: "Connections", path: "/connections" },
{ label: "Wiki", path: "/wiki" }, { label: "Wiki", path: "/wiki" },
{ label: "About", path: "/about" }, { label: "About", path: "/about" },
]; ];

View file

@ -60,40 +60,42 @@
</p> </p>
</div> </div>
<!-- Offering Section --> <!-- What I Do (craft tags, falling back to offering) -->
<div <div v-if="craftTagsDisplay.length > 0 || member.offering?.text" class="profile-section">
v-if="member.offering?.tags?.length || member.offering?.text" <div class="section-label">What I Do</div>
class="profile-section" <div v-if="craftTagsDisplay.length > 0" class="tag-list">
>
<div class="section-label">Offering</div>
<div v-if="member.offering.tags?.length" class="tag-list">
<span <span
v-for="tag in member.offering.tags" v-for="tag in craftTagsDisplay"
:key="tag" :key="tag"
class="tag-pill" class="tag-pill"
>{{ tag }}</span >{{ tagLabel('craft', tag) }}</span
> >
</div> </div>
<p v-if="member.offering.text" class="profile-detail offering-text"> <p v-if="member.offering?.text" class="profile-detail offering-text">
{{ member.offering.text }} {{ member.offering.text }}
</p> </p>
</div> </div>
<!-- Looking For Section --> <!-- Community Connections (cooperative topics with states, falling back to lookingFor) -->
<div <div
v-if="member.lookingFor?.tags?.length || member.lookingFor?.text" v-if="connectionTopicsDisplay.length > 0 || member.lookingFor?.text || member.communityConnections?.details"
class="profile-section" class="profile-section"
> >
<div class="section-label">Looking for</div> <div class="section-label">Community Connections</div>
<div v-if="member.lookingFor.tags?.length" class="tag-list"> <div v-if="connectionTopicsDisplay.length > 0" class="tag-list">
<span <span
v-for="tag in member.lookingFor.tags" v-for="topic in connectionTopicsDisplay"
:key="tag" :key="topic.tagSlug || topic"
class="tag-pill" class="tag-pill connection-pill"
>{{ tag }}</span
> >
<span v-if="topic.state" class="connection-state">{{ stateLabel(topic.state) }}</span>
{{ tagLabel('cooperative', topic.tagSlug || topic) }}
</span>
</div> </div>
<p v-if="member.lookingFor.text" class="profile-detail looking-text"> <p v-if="member.communityConnections?.details" class="profile-detail connection-details">
{{ member.communityConnections.details }}
</p>
<p v-else-if="member.lookingFor?.text" class="profile-detail looking-text">
{{ member.lookingFor.text }} {{ member.lookingFor.text }}
</p> </p>
</div> </div>
@ -150,10 +152,10 @@
</div> </div>
</div> </div>
<!-- Peer Support Section --> <!-- Peer Support Section (reads from communityConnections, falls back to peerSupport) -->
<div v-if="member.peerSupport?.enabled" class="profile-section"> <div v-if="showPeerSupport" class="profile-section">
<div class="section-label">Peer Support</div> <div class="section-label">Peer Support</div>
<div v-if="member.peerSupport.skillTopics?.length" class="peer-group"> <div v-if="member.peerSupport?.skillTopics?.length" class="peer-group">
<span class="peer-label">Skills:</span> <span class="peer-label">Skills:</span>
<div class="tag-list"> <div class="tag-list">
<span <span
@ -164,7 +166,7 @@
> >
</div> </div>
</div> </div>
<div v-if="member.peerSupport.supportTopics?.length" class="peer-group"> <div v-if="member.peerSupport?.supportTopics?.length" class="peer-group">
<span class="peer-label">Topics:</span> <span class="peer-label">Topics:</span>
<div class="tag-list"> <div class="tag-list">
<span <span
@ -175,8 +177,8 @@
> >
</div> </div>
</div> </div>
<p v-if="member.peerSupport.availability" class="profile-detail"> <p v-if="peerAvailability" class="profile-detail">
{{ member.peerSupport.availability }} {{ peerAvailability }}
</p> </p>
</div> </div>
@ -233,6 +235,15 @@ const circleLabels = {
practitioner: "Practitioner", practitioner: "Practitioner",
}; };
// State display text mapping
const stateLabels = {
help: "Can help",
interested: "Interested",
seeking: "Need help",
};
const stateLabel = (state) => stateLabels[state] || state || "";
const getInitials = (name) => { const getInitials = (name) => {
if (!name) return "?"; if (!name) return "?";
return name return name
@ -246,6 +257,11 @@ const getInitials = (name) => {
// Fetch member data no await so the component renders immediately (no Suspense) // Fetch member data no await so the component renders immediately (no Suspense)
const { data, pending, error: fetchError } = useFetch(`/api/members/${id}`); const { data, pending, error: fetchError } = useFetch(`/api/members/${id}`);
// Fetch tags for slug-to-label lookup
const { data: tagsData } = useFetch("/api/tags", {
default: () => ({ tags: [] }),
});
// Fetch public activity // Fetch public activity
const { data: activityData } = useFetch(`/api/members/${id}/activity`, { const { data: activityData } = useFetch(`/api/members/${id}/activity`, {
params: { limit: 5 }, params: { limit: 5 },
@ -267,6 +283,56 @@ const formatRelativeDate = (date) => {
} }
const member = computed(() => data.value?.member || null); const member = computed(() => data.value?.member || null);
// Tag label lookup
const tagLabel = (pool, slug) => {
const tags = tagsData.value?.tags || [];
const found = tags.find((t) => t.slug === slug && t.pool === pool);
return found ? found.label : slug;
};
// Craft tags display: new field, falling back to offering.tags
const craftTagsDisplay = computed(() => {
if (!member.value) return [];
if (member.value.craftTags && member.value.craftTags.length > 0) {
return member.value.craftTags;
}
return member.value.offering?.tags || [];
});
// Connection topics display: new field, falling back to lookingFor.tags
const connectionTopicsDisplay = computed(() => {
if (!member.value) return [];
if (
member.value.communityConnections?.topics &&
member.value.communityConnections.topics.length > 0
) {
return member.value.communityConnections.topics;
}
if (member.value.lookingFor?.tags && member.value.lookingFor.tags.length > 0) {
return member.value.lookingFor.tags.map((tag) => ({ tagSlug: tag, state: null }));
}
return [];
});
// Peer support: check both new communityConnections and old peerSupport
const showPeerSupport = computed(() => {
if (!member.value) return false;
return (
member.value.communityConnections?.offerPeerSupport ||
member.value.peerSupport?.enabled
);
});
// Peer availability: prefer new field, fall back to old
const peerAvailability = computed(() => {
if (!member.value) return "";
return (
member.value.communityConnections?.availability ||
member.value.peerSupport?.availability ||
""
);
});
const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => ""); const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => "");
watch( watch(
member, member,
@ -442,7 +508,8 @@ useHead({
} }
.offering-text, .offering-text,
.looking-text { .looking-text,
.connection-details {
margin-top: 8px; margin-top: 8px;
} }
@ -462,6 +529,19 @@ useHead({
white-space: nowrap; white-space: nowrap;
} }
.connection-pill {
display: inline-flex;
align-items: center;
gap: 4px;
}
.connection-state {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-faint);
}
/* ---- SOCIAL LINKS ---- */ /* ---- SOCIAL LINKS ---- */
.social-links { .social-links {
display: flex; display: flex;

View file

@ -41,64 +41,64 @@
> >
</div> </div>
<!-- Skills Filter --> <!-- Craft Tags Filter -->
<div <div
v-if="availableSkills && availableSkills.length > 0" v-if="craftTagOptions.length > 0"
class="skills-bar" class="skills-bar"
> >
<span class="tag-label">Skills:</span> <span class="tag-label">Craft:</span>
<button <button
v-for="skill in (availableSkills || []).slice( v-for="tag in craftTagOptions.slice(
0, 0,
showAllSkills ? undefined : 10, showAllCraftTags ? undefined : 10,
)" )"
:key="skill" :key="tag.slug"
type="button" type="button"
class="skill-tag" class="skill-tag"
:class="{ active: selectedSkills.includes(skill) }" :class="{ active: selectedCraftTags.includes(tag.slug) }"
@click="toggleSkill(skill)" @click="toggleCraftTag(tag.slug)"
> >
{{ skill }} {{ tag.label }}
</button> </button>
<button <button
v-if="availableSkills && availableSkills.length > 10" v-if="craftTagOptions.length > 10"
type="button" type="button"
class="more-btn" class="more-btn"
@click="showAllSkills = !showAllSkills" @click="showAllCraftTags = !showAllCraftTags"
> >
{{ {{
showAllSkills ? "Show less" : `+${availableSkills.length - 10} more` showAllCraftTags ? "Show less" : `+${craftTagOptions.length - 10} more`
}} }}
</button> </button>
</div> </div>
<!-- Topics Filter --> <!-- Connection Tags Filter -->
<div <div
v-if="availableTopics && availableTopics.length > 0" v-if="connectionTagOptions.length > 0"
class="skills-bar" class="skills-bar"
> >
<span class="tag-label">Topics:</span> <span class="tag-label">Topics:</span>
<button <button
v-for="topic in (availableTopics || []).slice( v-for="tag in connectionTagOptions.slice(
0, 0,
showAllTopics ? undefined : 10, showAllConnectionTags ? undefined : 10,
)" )"
:key="topic" :key="tag.slug"
type="button" type="button"
class="skill-tag" class="skill-tag"
:class="{ active: selectedTopics.includes(topic) }" :class="{ active: selectedConnectionTags.includes(tag.slug) }"
@click="toggleTopic(topic)" @click="toggleConnectionTag(tag.slug)"
> >
{{ topic }} {{ tag.label }}
</button> </button>
<button <button
v-if="availableTopics && availableTopics.length > 10" v-if="connectionTagOptions.length > 10"
type="button" type="button"
class="more-btn" class="more-btn"
@click="showAllTopics = !showAllTopics" @click="showAllConnectionTags = !showAllConnectionTags"
> >
{{ {{
showAllTopics ? "Show less" : `+${availableTopics.length - 10} more` showAllConnectionTags ? "Show less" : `+${connectionTagOptions.length - 10} more`
}} }}
</button> </button>
</div> </div>
@ -117,16 +117,16 @@
Offering Peer Support Offering Peer Support
<button type="button" @click="clearPeerSupportFilter">&times;</button> <button type="button" @click="clearPeerSupportFilter">&times;</button>
</span> </span>
<span v-for="skill in selectedSkills" :key="'s-' + skill" class="af-tag"> <span v-for="slug in selectedCraftTags" :key="'c-' + slug" class="af-tag">
{{ skill }} {{ craftTagLabel(slug) }}
<button type="button" @click="toggleSkill(skill)">&times;</button> <button type="button" @click="toggleCraftTag(slug)">&times;</button>
</span> </span>
<span v-for="topic in selectedTopics" :key="'t-' + topic" class="af-tag"> <span v-for="slug in selectedConnectionTags" :key="'t-' + slug" class="af-tag">
{{ topic }} {{ connectionTagLabel(slug) }}
<button type="button" @click="toggleTopic(topic)">&times;</button> <button type="button" @click="toggleConnectionTag(slug)">&times;</button>
</span> </span>
<button <button
v-if="selectedSkills.length > 0 || selectedTopics.length > 0" v-if="selectedCraftTags.length > 0 || selectedConnectionTags.length > 0"
type="button" type="button"
class="clear-all-btn" class="clear-all-btn"
@click="clearAllFilters" @click="clearAllFilters"
@ -186,33 +186,38 @@
}} }}
</div> </div>
<!-- Skills tags --> <!-- Craft tags (fall back to offering.tags) -->
<div <div
v-if="member.offering?.tags && member.offering.tags.length > 0" v-if="getMemberCraftTags(member).length > 0"
class="mc-tags" class="mc-tags"
> >
<span class="tag-label">Skills:</span> <span class="tag-label">Craft:</span>
<span <span
v-for="tag in member.offering.tags" v-for="tag in getMemberCraftTags(member)"
:key="tag" :key="tag"
class="skill-tag" class="skill-tag"
>{{ tag }}</span >{{ craftTagLabel(tag) }}</span
> >
</div> </div>
<!-- Looking for --> <!-- Community connections topics (fall back to lookingFor.tags) -->
<div <div
v-if="member.lookingFor?.tags && member.lookingFor.tags.length > 0" v-if="getMemberConnectionTopics(member).length > 0"
class="mc-looking" class="mc-looking"
> >
Looking for: {{ member.lookingFor.tags.join(", ") }} <span
v-for="topic in getMemberConnectionTopics(member)"
:key="topic.tagSlug || topic"
class="connection-topic"
>
<span class="connection-state">{{ stateLabel(topic.state) }}</span>
{{ connectionTagLabel(topic.tagSlug || topic) }}
</span>
</div> </div>
<!-- Peer support session link --> <!-- Peer support session link -->
<a <a
v-if=" v-if="showPeerSupport(member)"
member.peerSupport?.enabled && member.peerSupport?.slackUsername
"
href="#" href="#"
class="mc-session" class="mc-session"
@click.prevent="openSlackDM(member)" @click.prevent="openSlackDM(member)"
@ -270,16 +275,27 @@ const { render: renderMarkdown } = useMarkdown();
// State // State
const members = ref([]); const members = ref([]);
const totalCount = ref(0); const totalCount = ref(0);
const availableSkills = ref([]);
const availableTopics = ref([]);
const loading = ref(true); const loading = ref(true);
const searchQuery = ref(""); const searchQuery = ref("");
const selectedCircle = ref("all"); const selectedCircle = ref("all");
const peerSupportFilter = ref("all"); const peerSupportFilter = ref("all");
const selectedSkills = ref([]); const selectedCraftTags = ref([]);
const selectedTopics = ref([]); const selectedConnectionTags = ref([]);
const showAllSkills = ref(false); const showAllCraftTags = ref(false);
const showAllTopics = ref(false); const showAllConnectionTags = ref(false);
// Tag options from API
const craftTagOptions = ref([]);
const connectionTagOptions = ref([]);
// State display text mapping
const stateLabels = {
help: "Can help",
interested: "Interested",
seeking: "Need help",
};
const stateLabel = (state) => stateLabels[state] || state || "";
// Circle options // Circle options
const circleOptions = [ const circleOptions = [
@ -295,19 +311,55 @@ const circleLabels = {
practitioner: "Practitioner", practitioner: "Practitioner",
}; };
// Peer support filter options // Tag slug-to-label lookups
const peerSupportOptions = [ const craftTagLabel = (slug) => {
{ label: "All Members", value: "all" }, const found = craftTagOptions.value.find((t) => t.slug === slug);
{ label: "Offering Peer Support", value: "true" }, return found ? found.label : slug;
]; };
const connectionTagLabel = (slug) => {
const found = connectionTagOptions.value.find((t) => t.slug === slug);
return found ? found.label : slug;
};
// Get craft tags for a member (new field, falling back to offering.tags)
const getMemberCraftTags = (member) => {
if (member.craftTags && member.craftTags.length > 0) {
return member.craftTags;
}
return member.offering?.tags || [];
};
// Get connection topics for a member (new field, falling back to lookingFor.tags)
const getMemberConnectionTopics = (member) => {
if (
member.communityConnections?.topics &&
member.communityConnections.topics.length > 0
) {
return member.communityConnections.topics;
}
// Fallback: wrap old lookingFor.tags as plain strings
if (member.lookingFor?.tags && member.lookingFor.tags.length > 0) {
return member.lookingFor.tags.map((tag) => ({ tagSlug: tag, state: null }));
}
return [];
};
// Show peer support link (check both old and new fields)
const showPeerSupport = (member) => {
if (member.communityConnections?.offerPeerSupport) return true;
if (member.peerSupport?.enabled && member.peerSupport?.slackUsername)
return true;
return false;
};
// Computed: has active filters // Computed: has active filters
const hasActiveFilters = computed(() => { const hasActiveFilters = computed(() => {
return ( return (
(selectedCircle.value && selectedCircle.value !== "all") || (selectedCircle.value && selectedCircle.value !== "all") ||
(peerSupportFilter.value && peerSupportFilter.value !== "all") || (peerSupportFilter.value && peerSupportFilter.value !== "all") ||
selectedSkills.value.length > 0 || selectedCraftTags.value.length > 0 ||
selectedTopics.value.length > 0 selectedConnectionTags.value.length > 0
); );
}); });
@ -333,28 +385,51 @@ const loadMembers = async () => {
params.circle = selectedCircle.value; params.circle = selectedCircle.value;
if (peerSupportFilter.value && peerSupportFilter.value !== "all") if (peerSupportFilter.value && peerSupportFilter.value !== "all")
params.peerSupport = peerSupportFilter.value; params.peerSupport = peerSupportFilter.value;
if (selectedSkills.value.length > 0) if (selectedCraftTags.value.length === 1)
params.skills = selectedSkills.value.join(","); params.craftTag = selectedCraftTags.value[0];
if (selectedTopics.value.length > 0) if (selectedConnectionTags.value.length === 1)
params.topics = selectedTopics.value.join(","); params.connectionTag = selectedConnectionTags.value[0];
const data = await $fetch("/api/members/directory", { params }); const data = await $fetch("/api/members/directory", { params });
members.value = data.members || []; members.value = data.members || [];
totalCount.value = data.totalCount || 0; totalCount.value = data.totalCount || 0;
availableSkills.value = data.filters?.availableSkills || [];
availableTopics.value = data.filters?.availableTopics || []; // Update tag options from API response (only on initial load or if empty)
if (data.filters?.craftTags && craftTagOptions.value.length === 0) {
craftTagOptions.value = data.filters.craftTags;
}
if (
data.filters?.cooperativeTags &&
connectionTagOptions.value.length === 0
) {
connectionTagOptions.value = data.filters.cooperativeTags;
}
} catch (error) { } catch (error) {
console.error("Failed to load members:", error); console.error("Failed to load members:", error);
members.value = []; members.value = [];
totalCount.value = 0; totalCount.value = 0;
availableSkills.value = [];
availableTopics.value = [];
} finally { } finally {
loading.value = false; loading.value = false;
} }
}; };
// Fetch tag options from API on mount
const loadTagOptions = async () => {
try {
const data = await $fetch("/api/tags");
const tags = data.tags || [];
craftTagOptions.value = tags
.filter((t) => t.pool === "craft")
.map((t) => ({ slug: t.slug, label: t.label }));
connectionTagOptions.value = tags
.filter((t) => t.pool === "cooperative")
.map((t) => ({ slug: t.slug, label: t.label }));
} catch (error) {
console.error("Failed to load tags:", error);
}
};
// Toggle peer support checkbox // Toggle peer support checkbox
const togglePeerSupport = (e) => { const togglePeerSupport = (e) => {
peerSupportFilter.value = e.target.checked ? "true" : "all"; peerSupportFilter.value = e.target.checked ? "true" : "all";
@ -370,24 +445,24 @@ const debouncedSearch = () => {
}, 300); }, 300);
}; };
// Toggle skill filter // Toggle craft tag filter
const toggleSkill = (skill) => { const toggleCraftTag = (slug) => {
const index = selectedSkills.value.indexOf(skill); const index = selectedCraftTags.value.indexOf(slug);
if (index > -1) { if (index > -1) {
selectedSkills.value.splice(index, 1); selectedCraftTags.value.splice(index, 1);
} else { } else {
selectedSkills.value.push(skill); selectedCraftTags.value = [slug]; // single-select for API query param
} }
loadMembers(); loadMembers();
}; };
// Toggle topic filter // Toggle connection tag filter
const toggleTopic = (topic) => { const toggleConnectionTag = (slug) => {
const index = selectedTopics.value.indexOf(topic); const index = selectedConnectionTags.value.indexOf(slug);
if (index > -1) { if (index > -1) {
selectedTopics.value.splice(index, 1); selectedConnectionTags.value.splice(index, 1);
} else { } else {
selectedTopics.value.push(topic); selectedConnectionTags.value = [slug]; // single-select for API query param
} }
loadMembers(); loadMembers();
}; };
@ -407,14 +482,17 @@ const clearAllFilters = () => {
searchQuery.value = ""; searchQuery.value = "";
selectedCircle.value = "all"; selectedCircle.value = "all";
peerSupportFilter.value = "all"; peerSupportFilter.value = "all";
selectedSkills.value = []; selectedCraftTags.value = [];
selectedTopics.value = []; selectedConnectionTags.value = [];
loadMembers(); loadMembers();
}; };
// Slack DM functionality // Slack DM functionality
const openSlackDM = async (member) => { const openSlackDM = async (member) => {
const username = member.peerSupport?.slackUsername || member.name; const username =
member.communityConnections?.slackHandle ||
member.peerSupport?.slackUsername ||
member.name;
try { try {
await navigator.clipboard.writeText(username); await navigator.clipboard.writeText(username);
@ -429,12 +507,13 @@ const openSlackDM = async (member) => {
}; };
// Load on mount and handle query params // Load on mount and handle query params
onMounted(() => { onMounted(async () => {
const route = useRoute(); const route = useRoute();
if (route.query.peerSupport === "true") { if (route.query.peerSupport === "true") {
peerSupportFilter.value = "true"; peerSupportFilter.value = "true";
} }
await loadTagOptions();
loadMembers(); loadMembers();
}); });
@ -756,10 +835,28 @@ useHead({
} }
.mc-looking { .mc-looking {
font-size: 11px; display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 6px;
}
.connection-topic {
font-size: 10px;
color: var(--text-dim);
padding: 1px 6px;
border: 1px dashed var(--border);
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 4px;
}
.connection-state {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-faint); color: var(--text-faint);
font-style: italic;
margin-top: 4px;
} }
.mc-session { .mc-session {

View file

@ -30,7 +30,7 @@ export default defineEventHandler(async (event) => {
status: "active", status: "active",
}) })
.select( .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",
) )
.lean(); .lean();
@ -70,6 +70,21 @@ export default defineEventHandler(async (event) => {
if (isVisible("offering")) filtered.offering = member.offering; if (isVisible("offering")) filtered.offering = member.offering;
if (isVisible("lookingFor")) filtered.lookingFor = member.lookingFor; if (isVisible("lookingFor")) filtered.lookingFor = member.lookingFor;
// 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 // Peer support: expose only fields needed for matching/contact UX
// slackUserId, slackDMChannelId, slackUsername, personalMessage are internal // slackUserId, slackDMChannelId, slackUsername, personalMessage are internal
if (member.peerSupport?.enabled) { if (member.peerSupport?.enabled) {

View file

@ -1,5 +1,6 @@
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import Member from "../../models/member.js"; import Member from "../../models/member.js";
import Tag from "../../models/tag.js";
import { connectDB } from "../../utils/mongoose.js"; import { connectDB } from "../../utils/mongoose.js";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@ -27,6 +28,8 @@ export default defineEventHandler(async (event) => {
const tags = query.tags ? query.tags.split(",") : []; const tags = query.tags ? query.tags.split(",") : [];
const peerSupport = query.peerSupport || ""; const peerSupport = query.peerSupport || "";
const topics = query.topics ? query.topics.split(",") : []; const topics = query.topics ? query.topics.split(",") : [];
const craftTag = query.craftTag || "";
const connectionTag = query.connectionTag || "";
// Build query // Build query
const dbQuery = { const dbQuery = {
@ -39,46 +42,39 @@ export default defineEventHandler(async (event) => {
dbQuery.circle = circle; 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") { if (peerSupport === "true") {
dbQuery["peerSupport.enabled"] = true; andConditions.push({
$or: [
{ "peerSupport.enabled": true },
{ "communityConnections.offerPeerSupport": true },
],
});
} }
// Search by name or bio // Search by name or bio
if (search) { if (search) {
// Escape special regex characters to prevent ReDoS // Escape special regex characters to prevent ReDoS
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
dbQuery.$or = [ andConditions.push({
$or: [
{ name: { $regex: escaped, $options: "i" } }, { name: { $regex: escaped, $options: "i" } },
{ bio: { $regex: escaped, $options: "i" } }, { bio: { $regex: escaped, $options: "i" } },
]; ],
});
} }
// Filter by tags (search in offering.tags or lookingFor.tags) // Filter by tags (search in offering.tags or lookingFor.tags)
if (tags.length > 0) { if (tags.length > 0) {
dbQuery.$or = [ andConditions.push({
{ "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: [ $or: [
{ "offering.tags": { $in: tags } }, { "offering.tags": { $in: tags } },
{ "lookingFor.tags": { $in: tags } }, { "lookingFor.tags": { $in: tags } },
], ],
}, });
];
delete dbQuery.$or;
}
} }
// Filter by peer support topics // Filter by peer support topics
@ -86,10 +82,25 @@ export default defineEventHandler(async (event) => {
dbQuery["peerSupport.topics"] = { $in: topics }; 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 { try {
const members = await Member.find(dbQuery) const members = await Member.find(dbQuery)
.select( .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 }) .sort({ createdAt: -1 })
.lean(); .lean();
@ -124,6 +135,20 @@ export default defineEventHandler(async (event) => {
if (isVisible("offering")) filtered.offering = member.offering; if (isVisible("offering")) filtered.offering = member.offering;
if (isVisible("lookingFor")) filtered.lookingFor = member.lookingFor; 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 // Peer support: expose only fields needed for matching/contact UX
// slackUserId, slackDMChannelId, slackUsername, personalMessage are internal // slackUserId, slackDMChannelId, slackUsername, personalMessage are internal
if (member.peerSupport?.enabled) { if (member.peerSupport?.enabled) {
@ -138,7 +163,7 @@ export default defineEventHandler(async (event) => {
return filtered; 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 const allTags = members
.flatMap((m) => [ .flatMap((m) => [
...(m.offering?.tags || []), ...(m.offering?.tags || []),
@ -154,12 +179,23 @@ export default defineEventHandler(async (event) => {
.filter((topic, index, self) => self.indexOf(topic) === index) .filter((topic, index, self) => self.indexOf(topic) === index)
.sort(); .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 { return {
members: filteredMembers, members: filteredMembers,
totalCount: filteredMembers.length, totalCount: filteredMembers.length,
filters: { filters: {
availableSkills: allTags, availableSkills: allTags,
availableTopics: allTopics, availableTopics: allTopics,
craftTags: craftTags.map((t) => ({ slug: t.slug, label: t.label })),
cooperativeTags: cooperativeTags.map((t) => ({
slug: t.slug,
label: t.label,
})),
}, },
}; };
} catch (error) { } catch (error) {