refactor(community): rename Community Connections → Community Ecology
Some checks failed
Test / vitest (push) Successful in 11m42s
Test / playwright (push) Failing after 9m27s
Test / visual (push) Failing after 9m53s
Test / Notify on failure (push) Successful in 2s

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:
Jennie Robinson Faber 2026-04-09 09:07:15 +01:00
parent 9577929e0d
commit 0b3896d984
33 changed files with 1002 additions and 2635 deletions

View file

@ -34,13 +34,8 @@
:to="item.path"
:class="{ active: isActive(item.path) }"
@click="handleNavigate"
>{{ item.label }}</NuxtLink
>
{{ item.label }}
<span
v-if="item.path === '/connections' && pendingCount > 0"
class="nav-badge"
>{{ pendingCount }}</span>
</NuxtLink>
</li>
</ul>
</template>
@ -134,21 +129,7 @@ const emit = defineEmits(["navigate"]);
const route = useRoute();
const { isAuthenticated, logout } = useAuth();
const { getPendingCount } = useConnections();
const isDev = import.meta.dev;
const pendingCount = ref(0);
// Fetch pending connection count for authenticated users
onMounted(async () => {
if (isAuthenticated.value) {
try {
const data = await getPendingCount();
pendingCount.value = data.count || 0;
} catch {
// Silently ignore badge is non-critical
}
}
});
const handleNavigate = () => {
if (props.isMobile) {
@ -192,7 +173,7 @@ const youItems = [
const exploreItems = [
{ label: "Events", path: "/events" },
{ label: "Members", path: "/members" },
{ label: "Connections", path: "/connections" },
{ label: "Ecology", path: "/ecology" },
{ label: "Wiki", path: "https://wiki.ghostguild.org" },
{ label: "About", path: "/about" },
];
@ -298,19 +279,4 @@ const exploreItems = [
.sidebar-meta a {
color: var(--candle-dim);
}
.nav-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
padding: 0 4px;
margin-left: 6px;
font-size: 10px;
line-height: 1;
color: var(--bg);
background: var(--candle);
border-radius: 0;
}
</style>

View file

@ -1,104 +0,0 @@
<template>
<div class="tags" @click="focusInput">
<span v-for="(tag, i) in modelValue" :key="tag" class="tag">
{{ tag }}
<span class="rm" @click.stop="removeTag(i)">&times;</span>
</span>
<input
ref="input"
v-model="newTag"
@keydown.enter.prevent="addTag"
@keydown.backspace="handleBackspace"
:placeholder="modelValue?.length ? '' : placeholder"
/>
</div>
</template>
<script setup>
const props = defineProps({
modelValue: { type: Array, default: () => [] },
placeholder: { type: String, default: "Add tag..." },
});
const emit = defineEmits(["update:modelValue"]);
const input = ref(null);
const newTag = ref("");
const focusInput = () => {
input.value?.focus();
};
const addTag = () => {
const tag = newTag.value.trim();
if (tag && !props.modelValue.includes(tag)) {
emit("update:modelValue", [...props.modelValue, tag]);
}
newTag.value = "";
};
const removeTag = (index) => {
const tags = [...props.modelValue];
tags.splice(index, 1);
emit("update:modelValue", tags);
};
const handleBackspace = () => {
if (!newTag.value && props.modelValue.length) {
removeTag(props.modelValue.length - 1);
}
};
</script>
<style scoped>
.tags {
border: 1px dashed var(--border);
padding: 3px 5px;
display: flex;
flex-wrap: wrap;
gap: 3px;
background: var(--input-bg);
min-height: 30px;
align-items: center;
cursor: text;
}
.tags:focus-within {
border-color: var(--candle);
border-style: solid;
}
.tag {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 6px;
border: 1px dashed var(--border-d);
font-size: 11px;
color: var(--text);
background: var(--surface);
}
.rm {
color: var(--text-faint);
cursor: pointer;
font-size: 12px;
line-height: 1;
}
.rm:hover {
color: var(--ember);
}
.tags input {
border: none;
background: transparent;
padding: 1px 4px;
font-size: 11px;
font-family: "Commit Mono", monospace;
color: var(--text);
flex: 1;
min-width: 80px;
outline: none;
}
</style>

View file

@ -1,32 +0,0 @@
export const useConnections = () => {
const getSuggestions = (params = {}) =>
$fetch('/api/connections/suggestions', { params })
const getMyConnections = () =>
$fetch('/api/connections')
const requestConnection = (recipientId) =>
$fetch('/api/connections', { method: 'POST', body: { recipientId } })
const confirmConnection = (id) =>
$fetch(`/api/connections/${id}/confirm`, { method: 'POST' })
const hideConnection = (id) =>
$fetch(`/api/connections/${id}/hide`, { method: 'POST' })
const withdrawConnection = (id) =>
$fetch(`/api/connections/${id}/withdraw`, { method: 'POST' })
const getPendingCount = () =>
$fetch('/api/connections/pending-count')
return {
getSuggestions,
getMyConnections,
requestConnection,
confirmConnection,
hideConnection,
withdrawConnection,
getPendingCount,
}
}

View file

@ -0,0 +1,6 @@
export const useEcology = () => {
const getSuggestions = (params = {}) =>
$fetch('/api/ecology/suggestions', { params })
return { getSuggestions }
}

View file

@ -143,12 +143,6 @@
{{ member.notifications?.updates !== false ? "On" : "Off" }}
</dd>
</div>
<div class="meta-row">
<dt>Peer support requests</dt>
<dd :class="member.notifications?.peerRequests !== false ? 'status-ok' : 'status-dim'">
{{ member.notifications?.peerRequests !== false ? "On" : "Off" }}
</dd>
</div>
</dl>
</section>
</div>

View file

@ -1,821 +1,4 @@
<template>
<PageShell
title="Connections"
subtitle="Find members who share your cooperative interests"
>
<ClientOnly>
<!-- Loading State -->
<div v-if="loading" class="loading-state">
<p>Loading connections...</p>
</div>
<template v-else>
<!-- Filter Bar -->
<div v-if="tagOptions.length > 0" class="filter-bar">
<select
v-model="filterTag"
class="filter-select"
@change="loadSuggestions"
>
<option value="">All topics</option>
<option v-for="tag in tagOptions" :key="tag.slug" :value="tag.slug">
{{ tag.label }}
</option>
</select>
<select
v-model="filterState"
class="filter-select"
@change="loadSuggestions"
>
<option value="">All states</option>
<option value="help">Can help</option>
<option value="interested">Interested</option>
<option value="seeking">Need help</option>
</select>
</div>
<!-- Suggested Connections -->
<div class="connections-section">
<div class="section-label">Suggested Connections</div>
<div v-if="suggestions.length > 0" class="connection-grid">
<div
v-for="suggestion in suggestions"
:key="suggestion.member._id"
class="connection-card"
>
<div class="cc-head">
<div class="cc-avatar">
<img
v-if="suggestion.member.avatar"
:src="`/ghosties/Ghost-${capitalize(suggestion.member.avatar)}.png`"
:alt="suggestion.member.name"
class="cc-avatar-img"
/>
<span v-else>{{ getInitials(suggestion.member.name) }}</span>
</div>
<div class="cc-info">
<div class="cc-name">
<NuxtLink :to="`/members/${suggestion.member._id}`">
{{ suggestion.member.name }}
</NuxtLink>
</div>
<div class="cc-meta">
<CircleBadge :circle="suggestion.member.circle || 'community'" />
</div>
</div>
</div>
<!-- Craft tags -->
<div
v-if="suggestion.member.craftTags?.length"
class="cc-craft-tags"
>
<span
v-for="tag in suggestion.member.craftTags.slice(0, 5)"
:key="tag"
class="craft-pill"
>{{ craftTagLabel(tag) }}</span>
</div>
<!-- Matching topics with state labels -->
<div class="cc-matches">
<div
v-for="match in suggestion.matchingTags"
:key="match.tagSlug"
class="match-row"
>
<span class="match-tag">{{ cooperativeTagLabel(match.tagSlug) }}</span>
<span class="match-states">
<span class="match-you">You: {{ stateLabel(match.yourState) }}</span>
<span class="match-sep">&middot;</span>
<span class="match-them">They: {{ stateLabel(match.theirState) }}</span>
</span>
</div>
</div>
<!-- Actions -->
<div class="cc-actions">
<button
class="btn btn-primary btn-sm"
:disabled="actionLoading === suggestion.member._id"
@click="handleConnect(suggestion.member._id)"
>
Mark as connected
</button>
<button
class="text-action"
:disabled="actionLoading === suggestion.member._id"
@click="handleHide(suggestion.member._id)"
>
Hide
</button>
</div>
</div>
</div>
<div v-else class="empty-state">
<p class="empty-title">No suggestions right now</p>
<p class="empty-sub">
Add cooperative topics to your
<NuxtLink to="/member/profile">profile</NuxtLink>
to find members with shared interests
</p>
</div>
</div>
<!-- Your Connections -->
<div class="connections-section">
<div class="section-label">Your Connections</div>
<!-- Pending Incoming -->
<template v-if="pendingIncoming.length > 0">
<div class="subsection-label">Pending Incoming</div>
<div class="connection-grid">
<div
v-for="conn in pendingIncoming"
:key="conn._id"
class="connection-card"
>
<div class="cc-head">
<div class="cc-avatar">
<img
v-if="getOtherMember(conn).avatar"
:src="`/ghosties/Ghost-${capitalize(getOtherMember(conn).avatar)}.png`"
:alt="getOtherMember(conn).name"
class="cc-avatar-img"
/>
<span v-else>{{ getInitials(getOtherMember(conn).name) }}</span>
</div>
<div class="cc-info">
<div class="cc-name">
<NuxtLink :to="`/members/${getOtherMember(conn)._id}`">
{{ getOtherMember(conn).name }}
</NuxtLink>
</div>
<div class="cc-meta">
<CircleBadge :circle="getOtherMember(conn).circle || 'community'" />
<span class="pending-label">Wants to connect</span>
</div>
</div>
</div>
<div v-if="conn.matchingTags?.length" class="cc-tags-inline">
<span
v-for="mt in conn.matchingTags"
:key="mt.tagSlug"
class="craft-pill"
>{{ cooperativeTagLabel(mt.tagSlug) }}</span>
</div>
<div class="cc-actions">
<button
class="btn btn-primary btn-sm"
:disabled="actionLoading === conn._id"
@click="handleConfirm(conn._id)"
>
Confirm
</button>
</div>
</div>
</div>
</template>
<!-- Pending Outgoing -->
<template v-if="pendingOutgoing.length > 0">
<div class="subsection-label">Pending Outgoing</div>
<div class="connection-grid">
<div
v-for="conn in pendingOutgoing"
:key="conn._id"
class="connection-card"
>
<div class="cc-head">
<div class="cc-avatar">
<img
v-if="getOtherMember(conn).avatar"
:src="`/ghosties/Ghost-${capitalize(getOtherMember(conn).avatar)}.png`"
:alt="getOtherMember(conn).name"
class="cc-avatar-img"
/>
<span v-else>{{ getInitials(getOtherMember(conn).name) }}</span>
</div>
<div class="cc-info">
<div class="cc-name">
<NuxtLink :to="`/members/${getOtherMember(conn)._id}`">
{{ getOtherMember(conn).name }}
</NuxtLink>
</div>
<div class="cc-meta">
<CircleBadge :circle="getOtherMember(conn).circle || 'community'" />
<span class="pending-label">Pending</span>
</div>
</div>
</div>
<div v-if="conn.matchingTags?.length" class="cc-tags-inline">
<span
v-for="mt in conn.matchingTags"
:key="mt.tagSlug"
class="craft-pill"
>{{ cooperativeTagLabel(mt.tagSlug) }}</span>
</div>
<div class="cc-actions">
<button
class="text-action"
:disabled="actionLoading === conn._id"
@click="handleWithdraw(conn._id)"
>
Withdraw
</button>
</div>
</div>
</div>
</template>
<!-- Confirmed -->
<template v-if="confirmed.length > 0">
<div class="subsection-label">Connected</div>
<div class="connection-grid">
<div
v-for="conn in confirmed"
:key="conn._id"
class="connection-card"
>
<div class="cc-head">
<div class="cc-avatar">
<img
v-if="getOtherMember(conn).avatar"
:src="`/ghosties/Ghost-${capitalize(getOtherMember(conn).avatar)}.png`"
:alt="getOtherMember(conn).name"
class="cc-avatar-img"
/>
<span v-else>{{ getInitials(getOtherMember(conn).name) }}</span>
</div>
<div class="cc-info">
<div class="cc-name">
<NuxtLink :to="`/members/${getOtherMember(conn)._id}`">
{{ getOtherMember(conn).name }}
</NuxtLink>
</div>
<div class="cc-meta">
<CircleBadge :circle="getOtherMember(conn).circle || 'community'" />
<span
v-if="getSlackHandle(conn)"
class="cc-slack"
>@{{ getSlackHandle(conn) }}</span>
</div>
</div>
</div>
<div v-if="conn.matchingTags?.length" class="cc-tags-inline">
<span
v-for="mt in conn.matchingTags"
:key="mt.tagSlug"
class="craft-pill"
>{{ cooperativeTagLabel(mt.tagSlug) }}</span>
</div>
</div>
</div>
</template>
<!-- No connections at all -->
<div
v-if="!confirmed.length && !pendingIncoming.length && !pendingOutgoing.length && !showHidden"
class="empty-state"
>
<p class="empty-sub">
You don't have any connections yet. Use the suggestions above to get started.
</p>
</div>
</div>
<!-- Show hidden toggle -->
<div v-if="hiddenCount > 0" class="hidden-toggle">
<label class="filter-toggle">
<input
type="checkbox"
v-model="showHidden"
@change="handleShowHiddenToggle"
/>
Show {{ hiddenCount }} hidden suggestion{{ hiddenCount === 1 ? '' : 's' }}
</label>
</div>
</template>
<template #fallback>
<div class="loading-state">
<p>Loading connections...</p>
</div>
</template>
</ClientOnly>
</PageShell>
</template>
<script setup>
definePageMeta({ middleware: 'auth' })
const { memberData } = useAuth()
const {
getSuggestions,
getMyConnections,
requestConnection,
confirmConnection,
hideConnection,
withdrawConnection,
} = useConnections()
// State
const loading = ref(true)
const actionLoading = ref(null)
const suggestions = ref([])
const confirmed = ref([])
const pendingIncoming = ref([])
const pendingOutgoing = ref([])
const filterTag = ref('')
const filterState = ref('')
const showHidden = ref(false)
const hiddenCount = ref(0)
const tagOptions = ref([])
const craftTagOptions = ref([])
// State display text
const stateLabels = {
help: 'Can help',
interested: 'Interested',
seeking: 'Need help',
}
const stateLabel = (state) => stateLabels[state] || state || ''
// Tag label lookups
const cooperativeTagLabel = (slug) => {
const found = tagOptions.value.find(t => t.slug === slug)
return found ? found.label : slug
}
const craftTagLabel = (slug) => {
const found = craftTagOptions.value.find(t => t.slug === slug)
return found ? found.label : slug
}
// Helpers
const getInitials = (name) => {
if (!name) return '?'
return name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2)
}
const capitalize = (str) => {
if (!str) return ''
return str.split('-')
.map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join('-')
}
const getOtherMember = (conn) => {
const myId = memberData.value?._id || memberData.value?.id
if (conn.initiator?._id === myId || conn.initiator?.id === myId) {
return conn.recipient || {}
}
return conn.initiator || {}
}
const getSlackHandle = (conn) => {
const other = getOtherMember(conn)
return other.communityConnections?.slackHandle || null
}
// Data loading
const loadSuggestions = async () => {
try {
const params = {}
if (filterTag.value) params.tag = filterTag.value
if (filterState.value) params.state = filterState.value
if (showHidden.value) params.showHidden = 'true'
const data = await getSuggestions(params)
suggestions.value = data.suggestions || []
} catch (error) {
console.error('Failed to load suggestions:', error)
suggestions.value = []
}
}
const loadConnections = async () => {
try {
const data = await getMyConnections()
confirmed.value = data.confirmed || []
pendingOutgoing.value = data.pendingOutgoing || []
pendingIncoming.value = data.pendingIncoming || []
} catch (error) {
console.error('Failed to load connections:', error)
}
}
const loadTags = async () => {
try {
const data = await $fetch('/api/tags')
const tags = data.tags || []
const cooperativeTags = tags
.filter(t => t.pool === 'cooperative')
.map(t => ({ slug: t.slug, label: t.label }))
tagOptions.value = cooperativeTags
craftTagOptions.value = tags
.filter(t => t.pool === 'craft')
.map(t => ({ slug: t.slug, label: t.label }))
// Filter tag options to only member's selected topics
const myTopicSlugs = (memberData.value?.communityConnections?.topics || [])
.map(t => t.tagSlug)
if (myTopicSlugs.length > 0) {
tagOptions.value = cooperativeTags.filter(t => myTopicSlugs.includes(t.slug))
}
} catch (error) {
console.error('Failed to load tags:', error)
}
}
// Count hidden suggestions (for the toggle label)
const countHidden = async () => {
// We don't have a dedicated hidden-count endpoint, so we track locally
// Hidden count increments when user hides, decrements on show
// This is approximate; it resets on page load
hiddenCount.value = 0
}
// Actions
const handleConnect = async (memberId) => {
actionLoading.value = memberId
try {
await requestConnection(memberId)
// Remove from suggestions, refresh connections
suggestions.value = suggestions.value.filter(s => s.member._id !== memberId)
await loadConnections()
} catch (error) {
console.error('Failed to create connection:', error)
} finally {
actionLoading.value = null
}
}
const handleHide = async (memberId) => {
actionLoading.value = memberId
try {
// The hide endpoint requires a connection ID. For suggestions (no connection yet),
// we create a pending connection first, then immediately hide it.
const result = await requestConnection(memberId)
const connId = result?.connection?._id
if (connId) {
await hideConnection(connId)
}
suggestions.value = suggestions.value.filter(s => s.member._id !== memberId)
hiddenCount.value++
} catch (error) {
console.error('Failed to hide suggestion:', error)
} finally {
actionLoading.value = null
}
}
const handleConfirm = async (connectionId) => {
actionLoading.value = connectionId
try {
await confirmConnection(connectionId)
await loadConnections()
} catch (error) {
console.error('Failed to confirm connection:', error)
} finally {
actionLoading.value = null
}
}
const handleWithdraw = async (connectionId) => {
actionLoading.value = connectionId
try {
await withdrawConnection(connectionId)
await loadConnections()
await loadSuggestions()
} catch (error) {
console.error('Failed to withdraw connection:', error)
} finally {
actionLoading.value = null
}
}
const handleShowHiddenToggle = async () => {
await loadSuggestions()
}
// Init
onMounted(async () => {
loading.value = true
try {
await loadTags()
await Promise.all([
loadSuggestions(),
loadConnections(),
])
countHidden()
} finally {
loading.value = false
}
})
useHead({
title: 'Connections - Ghost Guild',
meta: [
{
name: 'description',
content: 'Find and connect with Ghost Guild members who share your cooperative interests.',
},
],
})
definePageMeta({ middleware: "auth" });
await navigateTo("/ecology", { replace: true });
</script>
<style scoped>
/* ---- LOADING ---- */
.loading-state {
padding: 60px 24px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
}
/* ---- FILTER BAR ---- */
.filter-bar {
padding: 16px 24px;
border-bottom: 1px dashed var(--border);
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.filter-select {
font-family: "Commit Mono", monospace;
font-size: 11px;
padding: 5px 10px;
border: 1px dashed var(--border);
background: transparent;
color: var(--text-dim);
cursor: pointer;
outline: none;
-webkit-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%238a7e6a' stroke-width='1.2'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
padding-right: 26px;
}
.filter-select:focus {
border-color: var(--candle-faint);
}
/* ---- SECTIONS ---- */
.connections-section {
padding: 20px 24px;
border-bottom: 1px dashed var(--border);
}
.subsection-label {
font-size: 11px;
color: var(--text-dim);
margin: 16px 0 8px;
}
.subsection-label:first-of-type {
margin-top: 8px;
}
/* ---- CONNECTION GRID ---- */
.connection-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0;
}
.connection-card {
padding: 16px 20px;
border: 1px dashed var(--border);
margin: -1px 0 0 -1px;
transition: background 0.15s;
}
.connection-card:hover {
background: var(--surface);
}
/* ---- CARD HEADER ---- */
.cc-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
.cc-avatar {
width: 32px;
height: 32px;
background: var(--surface);
border: 1px dashed var(--border);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: var(--text-faint);
flex-shrink: 0;
overflow: hidden;
}
.cc-avatar-img {
width: 28px;
height: 28px;
object-fit: contain;
}
.cc-info {
min-width: 0;
}
.cc-name {
font-size: 13px;
font-weight: 600;
color: var(--text-bright);
}
.cc-name a {
color: var(--text-bright);
text-decoration: none;
}
.cc-name a:hover {
color: var(--candle);
text-decoration: underline;
}
.cc-meta {
font-size: 11px;
color: var(--text-dim);
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-top: 1px;
}
.cc-slack {
font-size: 11px;
color: var(--candle-dim);
}
.pending-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--candle-dim);
}
/* ---- CRAFT TAGS (small pills) ---- */
.cc-craft-tags {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin: 6px 0;
}
.craft-pill {
font-size: 10px;
color: var(--text-dim);
padding: 1px 6px;
border: 1px dashed var(--border);
white-space: nowrap;
}
/* ---- INLINE TAGS (for confirmed/pending) ---- */
.cc-tags-inline {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin: 6px 0;
}
/* ---- MATCH ROWS (suggestion cards) ---- */
.cc-matches {
margin: 8px 0;
}
.match-row {
display: flex;
align-items: baseline;
gap: 8px;
padding: 3px 0;
font-size: 11px;
}
.match-tag {
color: var(--text);
font-weight: 600;
min-width: 0;
}
.match-states {
color: var(--text-faint);
font-size: 10px;
display: flex;
gap: 4px;
align-items: baseline;
flex-wrap: wrap;
}
.match-sep {
color: var(--border);
}
.match-you,
.match-them {
white-space: nowrap;
}
/* ---- ACTIONS ---- */
.cc-actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 10px;
}
.btn-sm {
padding: 4px 12px;
font-size: 11px;
}
.text-action {
font-family: "Commit Mono", monospace;
font-size: 11px;
color: var(--text-faint);
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.text-action:hover {
color: var(--text);
text-decoration: underline;
}
.text-action:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ---- EMPTY STATE ---- */
.empty-state {
padding: 32px 0;
text-align: center;
}
.empty-title {
font-family: "Brygada 1918", serif;
font-size: 18px;
color: var(--text-dim);
margin-bottom: 6px;
}
.empty-sub {
font-size: 12px;
color: var(--text-faint);
}
.empty-sub a {
color: var(--candle);
}
/* ---- HIDDEN TOGGLE ---- */
.hidden-toggle {
padding: 16px 24px;
border-bottom: 1px dashed var(--border);
}
.filter-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-dim);
cursor: pointer;
}
.filter-toggle input {
accent-color: var(--candle-dim);
}
/* ---- RESPONSIVE ---- */
@media (max-width: 1024px) {
.connection-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.filter-bar {
flex-direction: column;
align-items: stretch;
padding: 14px 20px;
}
.connections-section {
padding: 16px 20px;
}
.connection-card {
padding: 14px 16px;
}
}
</style>

471
app/pages/ecology.vue Normal file
View file

@ -0,0 +1,471 @@
<template>
<PageShell
title="Community Ecology"
subtitle="Find members who share your cooperative interests"
>
<ClientOnly>
<div v-if="loading" class="loading-state">
<p>Loading ecology...</p>
</div>
<template v-else>
<div v-if="tagOptions.length > 0" class="filter-bar">
<select
v-model="filterTag"
class="filter-select"
@change="loadSuggestions"
>
<option value="">All topics</option>
<option v-for="tag in tagOptions" :key="tag.slug" :value="tag.slug">
{{ tag.label }}
</option>
</select>
</div>
<div class="connections-section">
<div class="section-label">Suggested Matches</div>
<div v-if="suggestions.length > 0" class="connection-grid">
<div
v-for="suggestion in suggestions"
:key="suggestion.member._id"
class="connection-card"
>
<div class="cc-head">
<div class="cc-avatar">
<img
v-if="suggestion.member.avatar"
:src="`/ghosties/Ghost-${capitalize(suggestion.member.avatar)}.png`"
:alt="suggestion.member.name"
class="cc-avatar-img"
/>
<span v-else>{{ getInitials(suggestion.member.name) }}</span>
</div>
<div class="cc-info">
<div class="cc-name">
<NuxtLink :to="`/members/${suggestion.member._id}`">
{{ suggestion.member.name }}
</NuxtLink>
</div>
<div class="cc-meta">
<CircleBadge :circle="suggestion.member.circle || 'community'" />
</div>
</div>
</div>
<div
v-if="suggestion.member.craftTags?.length"
class="cc-craft-tags"
>
<span
v-for="tag in suggestion.member.craftTags.slice(0, 5)"
:key="tag"
class="craft-pill"
>{{ craftTagLabel(tag) }}</span>
</div>
<div class="cc-matches">
<div
v-for="match in suggestion.matchingTags"
:key="match.tagSlug"
class="match-row"
>
<span class="match-tag">{{ cooperativeTagLabel(match.tagSlug) }}</span>
<span class="match-states">
<span class="match-you">You: {{ stateLabel(match.yourState) }}</span>
<span class="match-sep">&middot;</span>
<span class="match-them">They: {{ stateLabel(match.theirState) }}</span>
</span>
</div>
</div>
<div v-if="suggestion.member.slackHandle" class="cc-contact">
<span class="cc-slack">@{{ suggestion.member.slackHandle }}</span>
<button
type="button"
class="text-action"
@click="copyHandle(suggestion.member.slackHandle)"
>
{{ copiedHandle === suggestion.member.slackHandle ? 'Copied!' : 'Copy' }}
</button>
</div>
</div>
</div>
<div v-else class="empty-state">
<p class="empty-title">No matches yet</p>
<p class="empty-sub">
Add cooperative topics to your
<NuxtLink to="/member/profile">profile</NuxtLink>
to find members with shared interests.
</p>
</div>
</div>
</template>
<template #fallback>
<div class="loading-state">
<p>Loading ecology...</p>
</div>
</template>
</ClientOnly>
</PageShell>
</template>
<script setup>
definePageMeta({ middleware: 'auth' })
const { getSuggestions } = useEcology()
const loading = ref(true)
const suggestions = ref([])
const filterTag = ref('')
const tagOptions = ref([])
const craftTagOptions = ref([])
const copiedHandle = ref(null)
const stateLabels = {
help: 'Can help',
interested: 'Interested',
seeking: 'Need help',
}
const stateLabel = (state) => stateLabels[state] || state || ''
const cooperativeTagLabel = (slug) => {
const found = tagOptions.value.find((t) => t.slug === slug)
return found ? found.label : slug
}
const craftTagLabel = (slug) => {
const found = craftTagOptions.value.find((t) => t.slug === slug)
return found ? found.label : slug
}
const getInitials = (name) => {
if (!name) return '?'
return name
.split(' ')
.map((w) => w[0])
.join('')
.toUpperCase()
.slice(0, 2)
}
const capitalize = (str) => {
if (!str) return ''
return str
.split('-')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join('-')
}
const loadSuggestions = async () => {
try {
const params = {}
if (filterTag.value) params.tag = filterTag.value
const data = await getSuggestions(params)
suggestions.value = data.suggestions || []
} catch (error) {
console.error('Failed to load suggestions:', error)
suggestions.value = []
}
}
const { memberData } = useAuth()
const loadTags = async () => {
try {
const data = await $fetch('/api/tags')
const tags = data.tags || []
const cooperativeTags = tags
.filter((t) => t.pool === 'cooperative')
.map((t) => ({ slug: t.slug, label: t.label }))
craftTagOptions.value = tags
.filter((t) => t.pool === 'craft')
.map((t) => ({ slug: t.slug, label: t.label }))
const myTopicSlugs = (memberData.value?.communityEcology?.topics || []).map(
(t) => t.tagSlug,
)
tagOptions.value = myTopicSlugs.length
? cooperativeTags.filter((t) => myTopicSlugs.includes(t.slug))
: cooperativeTags
} catch (error) {
console.error('Failed to load tags:', error)
}
}
let copyTimer = null
const copyHandle = async (handle) => {
try {
await navigator.clipboard.writeText(`@${handle}`)
copiedHandle.value = handle
if (copyTimer) clearTimeout(copyTimer)
copyTimer = setTimeout(() => {
copiedHandle.value = null
copyTimer = null
}, 1500)
} catch (error) {
console.error('Clipboard write failed:', error)
}
}
onBeforeUnmount(() => {
if (copyTimer) clearTimeout(copyTimer)
})
onMounted(async () => {
loading.value = true
try {
await Promise.all([loadTags(), loadSuggestions()])
} finally {
loading.value = false
}
})
useHead({
title: 'Community Ecology - Ghost Guild',
meta: [
{
name: 'description',
content:
'Find Ghost Guild members who share your cooperative interests and reach out on Slack.',
},
],
})
</script>
<style scoped>
.loading-state {
padding: 60px 24px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
}
.filter-bar {
padding: 16px 24px;
border-bottom: 1px dashed var(--border);
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.filter-select {
font-family: "Commit Mono", monospace;
font-size: 11px;
padding: 5px 10px;
border: 1px dashed var(--border);
background: transparent;
color: var(--text-dim);
cursor: pointer;
outline: none;
-webkit-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%238a7e6a' stroke-width='1.2'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
padding-right: 26px;
}
.filter-select:focus {
border-color: var(--candle-faint);
}
.connections-section {
padding: 20px 24px;
border-bottom: 1px dashed var(--border);
}
.connection-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0;
}
.connection-card {
padding: 16px 20px;
border: 1px dashed var(--border);
margin: -1px 0 0 -1px;
transition: background 0.15s;
}
.connection-card:hover {
background: var(--surface);
}
.cc-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
.cc-avatar {
width: 32px;
height: 32px;
background: var(--surface);
border: 1px dashed var(--border);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: var(--text-faint);
flex-shrink: 0;
overflow: hidden;
}
.cc-avatar-img {
width: 28px;
height: 28px;
object-fit: contain;
}
.cc-info {
min-width: 0;
}
.cc-name {
font-size: 13px;
font-weight: 600;
color: var(--text-bright);
}
.cc-name a {
color: var(--text-bright);
text-decoration: none;
}
.cc-name a:hover {
color: var(--candle);
text-decoration: underline;
}
.cc-meta {
font-size: 11px;
color: var(--text-dim);
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-top: 1px;
}
.cc-craft-tags {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin: 6px 0;
}
.craft-pill {
font-size: 10px;
color: var(--text-dim);
padding: 1px 6px;
border: 1px dashed var(--border);
white-space: nowrap;
}
.cc-matches {
margin: 8px 0;
}
.match-row {
display: flex;
align-items: baseline;
gap: 8px;
padding: 3px 0;
font-size: 11px;
}
.match-tag {
color: var(--text);
font-weight: 600;
min-width: 0;
}
.match-states {
color: var(--text-faint);
font-size: 10px;
display: flex;
gap: 4px;
align-items: baseline;
flex-wrap: wrap;
}
.match-sep {
color: var(--border);
}
.match-you,
.match-them {
white-space: nowrap;
}
.cc-contact {
display: flex;
align-items: center;
gap: 12px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed var(--border);
}
.cc-slack {
font-size: 11px;
color: var(--candle-dim);
font-family: "Commit Mono", monospace;
}
.text-action {
font-family: "Commit Mono", monospace;
font-size: 11px;
color: var(--text-faint);
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.text-action:hover {
color: var(--text);
text-decoration: underline;
}
.text-action:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.empty-state {
padding: 32px 0;
text-align: center;
}
.empty-title {
font-family: "Brygada 1918", serif;
font-size: 18px;
color: var(--text-dim);
margin-bottom: 6px;
}
.empty-sub {
font-size: 12px;
color: var(--text-faint);
}
.empty-sub a {
color: var(--candle);
}
@media (max-width: 1024px) {
.connection-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.filter-bar {
flex-direction: column;
align-items: stretch;
padding: 14px 20px;
}
.connections-section {
padding: 16px 20px;
}
.connection-card {
padding: 14px 16px;
}
}
</style>

View file

@ -1,12 +1,10 @@
<template>
<PageShell as="form" @submit.prevent="handleSubmit">
<ClientOnly>
<!-- Loading State -->
<div v-if="loading" class="loading-state">
<p style="color: var(--text-faint)">Loading your profile...</p>
</div>
<!-- Unauthenticated State -->
<div v-else-if="!memberData" class="loading-state">
<p style="color: var(--text-faint); margin-bottom: 12px">
Please sign in to access your profile settings.
@ -32,21 +30,15 @@
subtitle="How you appear to other members"
>
<NuxtLink
v-if="
(memberData?._id || memberData?.id) &&
memberData?.status === 'active' &&
formData.showInDirectory
"
:to="`/members/${memberData?._id || memberData?.id}`"
v-if="memberId && memberData?.status === MEMBER_STATUSES.ACTIVE && formData.showInDirectory"
:to="`/members/${memberId}`"
class="view-profile-link"
>
View my public profile &rarr;
</NuxtLink>
</PageHeader>
<!-- TWO-COLUMN FORM BODY -->
<ColumnsLayout cols="2">
<!-- ======== LEFT COLUMN ======== -->
<template #left>
<PageSection>
<div class="section-label">Basics</div>
@ -101,7 +93,6 @@
</div>
</PageSection>
<!-- About You -->
<PageSection divider="top">
<div class="section-label">About You</div>
@ -140,19 +131,17 @@
<PrivacyToggle v-model="formData.bioPrivacy" />
</div>
<!-- What I Do (craft tags) -->
<div class="field">
<label>What I Do</label>
<CraftTagSelector
v-model="formData.craftTags"
:tags="craftTags"
@suggest="tagSuggestPool = 'craft'; showTagSuggestModal = true"
@suggest="openTagSuggest('craft')"
/>
<PrivacyToggle v-model="formData.craftTagsPrivacy" />
</div>
</PageSection>
<!-- Visibility -->
<PageSection divider="top">
<div class="section-label">Visibility</div>
@ -164,60 +153,58 @@
<div class="toggle-label">
Show in Member Directory
<span class="toggle-sub"
>Your profile will appear in the public member
listing</span
>Your profile will appear in the public member listing</span
>
</div>
</div>
</PageSection>
</template>
<!-- ======== RIGHT COLUMN ======== -->
<template #right>
<PageSection>
<div class="section-label">Community Connections</div>
<div class="section-label">Community Ecology</div>
<div class="field">
<label>Topics</label>
<CooperativeTagSelector
v-model="formData.communityConnectionsTopics"
v-model="formData.communityEcologyTopics"
:tags="cooperativeTags"
@suggest="tagSuggestPool = 'cooperative'; showTagSuggestModal = true"
@suggest="openTagSuggest('cooperative')"
/>
<PrivacyToggle v-model="formData.communityConnectionsPrivacy" />
<PrivacyToggle v-model="formData.communityEcologyPrivacy" />
</div>
<div class="field">
<label>Details</label>
<textarea
v-model="formData.communityConnectionsDetails"
v-model="formData.communityEcologyDetails"
rows="3"
placeholder="What are you hoping to connect about?"
maxlength="300"
></textarea>
<div class="char-count">
{{ formData.communityConnectionsDetails?.length || 0 }} / 300
{{ formData.communityEcologyDetails?.length || 0 }} / 300
</div>
</div>
<div class="toggle-field">
<USwitch
v-model="formData.communityConnectionsOfferPeerSupport"
v-model="formData.communityEcologyOfferPeerSupport"
aria-label="Offer Peer Support"
/>
<div class="toggle-label">
Offer Peer Support
<span class="toggle-sub"
>Let other members request 1:1 time with you</span
>Share your Slack handle so other members can reach out</span
>
</div>
</div>
<div v-if="formData.communityConnectionsOfferPeerSupport" class="connections-panel">
<div v-if="formData.communityEcologyOfferPeerSupport" class="connections-panel">
<div class="field">
<label>Availability</label>
<textarea
v-model="formData.communityConnectionsAvailability"
v-model="formData.communityEcologyAvailability"
rows="3"
placeholder="e.g. Weekday afternoons ET"
></textarea>
@ -226,7 +213,7 @@
<div class="field">
<label>Slack Handle</label>
<input
v-model="formData.communityConnectionsSlackHandle"
v-model="formData.communityEcologySlackHandle"
type="text"
placeholder="@yourslackname"
/>
@ -235,58 +222,33 @@
<div class="field">
<label>Personal Message</label>
<textarea
v-model="formData.communityConnectionsPersonalMessage"
v-model="formData.communityEcologyPersonalMessage"
rows="3"
maxlength="200"
placeholder="Brief note shown to people requesting time with you"
placeholder="Brief note shown alongside your Slack handle"
></textarea>
<div class="char-count">
{{ formData.communityConnectionsPersonalMessage?.length || 0 }} / 200
{{ formData.communityEcologyPersonalMessage?.length || 0 }} / 200
</div>
</div>
</div>
</PageSection>
<!-- Notifications -->
<PageSection divider="top">
<div class="section-label">Notifications</div>
<div class="toggle-field">
<div
v-for="toggle in notificationToggles"
:key="toggle.key"
class="toggle-field"
>
<USwitch
v-model="formData.notifyEvents"
aria-label="Event reminders"
v-model="formData.notifications[toggle.key]"
:aria-label="toggle.label"
/>
<div class="toggle-label">
Event reminders
<span class="toggle-sub"
>Get notified about upcoming events</span
>
</div>
</div>
<div class="toggle-field">
<USwitch
v-model="formData.notifyUpdates"
aria-label="Community updates"
/>
<div class="toggle-label">
Community updates
<span class="toggle-sub"
>New posts from members you follow</span
>
</div>
</div>
<div class="toggle-field">
<USwitch
v-model="formData.notifyConnectionRequests"
aria-label="Connection requests"
/>
<div class="toggle-label">
Connection requests
<span class="toggle-sub"
>When someone wants to connect</span
>
{{ toggle.label }}
<span class="toggle-sub">{{ toggle.sub }}</span>
</div>
</div>
</PageSection>
@ -313,14 +275,21 @@
}}</span>
</div>
</template>
<template #fallback>
<div class="loading-state">
<p style="color: var(--text-faint)">Loading your profile...</p>
</div>
</template>
</ClientOnly>
<!-- Tag Suggest Modal -->
<TagSuggestModal v-model:open="showTagSuggestModal" :pool="tagSuggestPool" />
</PageShell>
</template>
<script setup>
import { MEMBER_STATUSES } from "~/composables/useMemberStatus";
definePageMeta({
middleware: 'auth',
});
@ -328,29 +297,20 @@ definePageMeta({
const { memberData, checkMemberStatus } = useAuth();
const { openLoginModal } = useLoginModal();
// Available ghost avatars
const availableGhosts = [
{
value: "disbelieving",
label: "Disbelieving",
image: "/ghosties/Ghost-Disbelieving.png",
},
{
value: "double-take",
label: "Double Take",
image: "/ghosties/Ghost-Double-Take.png",
},
{
value: "exasperated",
label: "Exasperated",
image: "/ghosties/Ghost-Exasperated.png",
},
{ value: "disbelieving", label: "Disbelieving", image: "/ghosties/Ghost-Disbelieving.png" },
{ value: "double-take", label: "Double Take", image: "/ghosties/Ghost-Double-Take.png" },
{ value: "exasperated", label: "Exasperated", image: "/ghosties/Ghost-Exasperated.png" },
{ value: "mild", label: "Mild", image: "/ghosties/Ghost-Mild.png" },
{ value: "sweet", label: "Sweet", image: "/ghosties/Ghost-Sweet.png" },
{ value: "wtf", label: "WTF", image: "/ghosties/Ghost-WTF.png" },
];
// Fetch tags and split into pools
const notificationToggles = [
{ key: "events", label: "Event reminders", sub: "Get notified about upcoming events" },
{ key: "updates", label: "Community updates", sub: "New posts from members you follow" },
];
const { data: tagsData } = await useFetch("/api/tags");
const craftTags = computed(() =>
@ -360,11 +320,14 @@ const cooperativeTags = computed(() =>
(tagsData.value?.tags || []).filter((t) => t.pool === "cooperative"),
);
// Tag suggest modal state
const showTagSuggestModal = ref(false);
const tagSuggestPool = ref("");
// Form state
const openTagSuggest = (pool) => {
tagSuggestPool.value = pool;
showTagSuggestModal.value = true;
};
const formData = reactive({
name: "",
pronouns: "",
@ -374,28 +337,25 @@ const formData = reactive({
bio: "",
location: "",
showInDirectory: true,
// Craft tags
craftTags: [],
craftTagsPrivacy: "members",
// Community connections
communityConnectionsTopics: [],
communityConnectionsPrivacy: "members",
communityConnectionsDetails: "",
communityConnectionsOfferPeerSupport: false,
communityConnectionsAvailability: "",
communityConnectionsSlackHandle: "",
communityConnectionsPersonalMessage: "",
// Privacy
communityEcologyTopics: [],
communityEcologyPrivacy: "members",
communityEcologyDetails: "",
communityEcologyOfferPeerSupport: false,
communityEcologyAvailability: "",
communityEcologySlackHandle: "",
communityEcologyPersonalMessage: "",
pronounsPrivacy: "members",
timeZonePrivacy: "members",
avatarPrivacy: "members",
studioPrivacy: "members",
bioPrivacy: "members",
locationPrivacy: "members",
// Notifications
notifyEvents: true,
notifyUpdates: true,
notifyConnectionRequests: true,
notifications: {
events: true,
updates: true,
},
});
const loading = ref(false);
@ -403,15 +363,17 @@ const saving = ref(false);
const saveSuccess = ref(false);
const saveError = ref(null);
const initialData = ref(null);
let saveSuccessTimer = null;
const memberId = computed(() => memberData.value?._id || memberData.value?.id);
// Computed
const hasChanges = computed(() => {
return JSON.stringify(formData) !== JSON.stringify(initialData.value);
});
// Load member data into form
const loadProfile = () => {
if (memberData.value) {
if (!memberData.value) return;
formData.name = memberData.value.name || "";
formData.pronouns = memberData.value.pronouns || "";
formData.timeZone = memberData.value.timeZone || "";
@ -419,24 +381,20 @@ const loadProfile = () => {
formData.studio = memberData.value.studio || "";
formData.bio = memberData.value.bio || "";
formData.location = memberData.value.location || "";
formData.showInDirectory = memberData.value.showInDirectory ?? true;
// Load craft tags
formData.craftTags = Array.isArray(memberData.value.craftTags)
? [...memberData.value.craftTags]
: [];
// Load community connections (with fallback to old peerSupport fields)
const cc = memberData.value.communityConnections || {};
formData.communityConnectionsTopics = Array.isArray(cc.topics) ? [...cc.topics] : [];
formData.communityConnectionsOfferPeerSupport = cc.offerPeerSupport ?? memberData.value.peerSupport?.enabled ?? false;
formData.communityConnectionsAvailability = cc.availability || memberData.value.peerSupport?.availability || "";
formData.communityConnectionsSlackHandle = cc.slackHandle || memberData.value.peerSupport?.slackUsername || "";
formData.communityConnectionsPersonalMessage = cc.personalMessage || memberData.value.peerSupport?.personalMessage || "";
formData.communityConnectionsDetails = cc.details || "";
const ecology = memberData.value.communityEcology || {};
formData.communityEcologyTopics = Array.isArray(ecology.topics) ? [...ecology.topics] : [];
formData.communityEcologyOfferPeerSupport = ecology.offerPeerSupport ?? false;
formData.communityEcologyAvailability = ecology.availability || "";
formData.communityEcologySlackHandle = ecology.slackHandle || "";
formData.communityEcologyPersonalMessage = ecology.personalMessage || "";
formData.communityEcologyDetails = ecology.details || "";
// Load privacy settings (with defaults)
const privacy = memberData.value.privacy || {};
formData.pronounsPrivacy = privacy.pronouns || "members";
formData.timeZonePrivacy = privacy.timeZone || "members";
@ -445,61 +403,48 @@ const loadProfile = () => {
formData.bioPrivacy = privacy.bio || "members";
formData.locationPrivacy = privacy.location || "members";
formData.craftTagsPrivacy = privacy.craftTags || "members";
formData.communityConnectionsPrivacy = privacy.communityConnections || "members";
formData.communityEcologyPrivacy = privacy.communityEcology || "members";
// Load notification prefs
const notifs = memberData.value.notifications || {};
formData.notifyEvents = notifs.events ?? true;
formData.notifyUpdates = notifs.updates ?? true;
formData.notifyConnectionRequests = notifs.connectionRequests ?? notifs.peerRequests ?? true;
formData.notifications.events = notifs.events ?? true;
formData.notifications.updates = notifs.updates ?? true;
// Store initial state for change detection
initialData.value = JSON.parse(JSON.stringify(formData));
}
};
// Handle form submission
const handleSubmit = async () => {
saving.value = true;
saveSuccess.value = false;
saveError.value = null;
try {
// Save profile data (includes craft tags + privacy + notifications)
await $fetch("/api/members/profile", {
await Promise.all([
$fetch("/api/members/profile", {
method: "PATCH",
body: { ...formData },
}),
$fetch("/api/members/me/community-ecology", {
method: "PATCH",
body: {
...formData,
craftTags: formData.craftTags,
notifications: {
events: formData.notifyEvents,
updates: formData.notifyUpdates,
connectionRequests: formData.notifyConnectionRequests,
topics: formData.communityEcologyTopics,
offerPeerSupport: formData.communityEcologyOfferPeerSupport,
availability: formData.communityEcologyAvailability,
slackHandle: formData.communityEcologySlackHandle,
personalMessage: formData.communityEcologyPersonalMessage,
details: formData.communityEcologyDetails,
},
},
});
// Save community connections data
await $fetch("/api/members/me/community-connections", {
method: "PATCH",
body: {
topics: formData.communityConnectionsTopics,
offerPeerSupport: formData.communityConnectionsOfferPeerSupport,
availability: formData.communityConnectionsAvailability,
slackHandle: formData.communityConnectionsSlackHandle,
personalMessage: formData.communityConnectionsPersonalMessage,
details: formData.communityConnectionsDetails,
},
});
}),
]);
saveSuccess.value = true;
// Refresh member data
await checkMemberStatus();
loadProfile();
setTimeout(() => {
if (saveSuccessTimer) clearTimeout(saveSuccessTimer);
saveSuccessTimer = setTimeout(() => {
saveSuccess.value = false;
saveSuccessTimer = null;
}, 3000);
} catch (error) {
console.error("Profile save error:", error);
@ -510,14 +455,12 @@ const handleSubmit = async () => {
}
};
// Reset form to initial state
const resetForm = () => {
loadProfile();
saveSuccess.value = false;
saveError.value = null;
};
// Initialize on mount
onMounted(async () => {
if (!memberData.value) {
loading.value = true;
@ -536,6 +479,10 @@ onMounted(async () => {
loadProfile();
});
onBeforeUnmount(() => {
if (saveSuccessTimer) clearTimeout(saveSuccessTimer);
});
useHead({
title: "Edit Profile - Ghost Guild",
});

View file

@ -108,9 +108,9 @@
<div class="profile-bio" v-html="renderMarkdown(member.bio)"></div>
</div>
<!-- Two-column: Craft Tags + Community Connections -->
<!-- Two-column: Craft Tags + Community Ecology -->
<div
v-if="craftTagsDisplay.length > 0 || member.offering?.text || connectionTopicsDisplay.length > 0 || member.lookingFor?.text || member.communityConnections?.details"
v-if="craftTagsDisplay.length > 0 || ecologyTopics.length > 0 || member.communityEcology?.details"
class="profile-two-col"
>
<!-- Left: What I Do -->
@ -123,59 +123,39 @@
class="tag-pill"
>{{ tagLabel('craft', tag) }}</span>
</div>
<p v-if="member.offering?.text" class="profile-detail offering-text">
{{ member.offering.text }}
</p>
</div>
<!-- Right: Community Connections -->
<!-- Right: Community Ecology -->
<div class="profile-section">
<div class="section-label">Community Connections</div>
<div v-if="connectionTopicsDisplay.length > 0" class="tag-list">
<div class="section-label">Community Ecology</div>
<div v-if="ecologyTopics.length > 0" class="tag-list">
<span
v-for="topic in connectionTopicsDisplay"
:key="topic.tagSlug || topic"
v-for="topic in ecologyTopics"
:key="topic.tagSlug"
class="tag-pill connection-pill"
>
<span v-if="topic.state" class="connection-state">{{ stateLabel(topic.state) }}</span>
{{ tagLabel('cooperative', topic.tagSlug || topic) }}
{{ tagLabel('cooperative', topic.tagSlug) }}
</span>
</div>
<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 }}
<p v-if="member.communityEcology?.details" class="profile-detail connection-details">
{{ member.communityEcology.details }}
</p>
</div>
</div>
<!-- Peer Support -->
<div v-if="showPeerSupport" class="profile-section">
<div v-if="member.communityEcology?.offerPeerSupport" class="profile-section">
<div class="section-label">Peer Support</div>
<div class="dashed-box no-hover">
<div v-if="member.peerSupport?.skillTopics?.length" class="peer-group">
<span class="peer-label">Skills</span>
<div class="tag-list">
<span
v-for="topic in member.peerSupport.skillTopics"
:key="topic"
class="tag-pill"
>{{ topic }}</span>
</div>
</div>
<div v-if="member.peerSupport?.supportTopics?.length" class="peer-group">
<span class="peer-label">Topics</span>
<div class="tag-list">
<span
v-for="topic in member.peerSupport.supportTopics"
:key="topic"
class="tag-pill"
>{{ topic }}</span>
</div>
</div>
<p v-if="peerAvailability" class="profile-detail peer-availability">
{{ peerAvailability }}
<p v-if="member.communityEcology?.personalMessage" class="profile-detail">
{{ member.communityEcology.personalMessage }}
</p>
<p v-if="member.communityEcology?.availability" class="profile-detail peer-availability">
{{ member.communityEcology.availability }}
</p>
<p v-if="member.communityEcology?.slackHandle" class="profile-detail peer-availability">
Reach out on Slack: <span class="slack-handle">@{{ member.communityEcology.slackHandle }}</span>
</p>
</div>
</div>
@ -290,48 +270,11 @@ const tagLabel = (pool, slug) => {
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 || [];
});
const craftTagsDisplay = computed(() => member.value?.craftTags || []);
// 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 ecologyTopics = computed(
() => member.value?.communityEcology?.topics || [],
);
// Whether the member has any social links (for hero layout)
const hasSocialLinks = computed(() =>
@ -587,8 +530,6 @@ useHead({
line-height: 1.6;
margin: 0;
}
.offering-text,
.looking-text,
.connection-details {
margin-top: 10px;
}
@ -623,26 +564,15 @@ useHead({
PEER SUPPORT
==================================================== */
.peer-group {
margin-bottom: 14px;
}
.peer-group:last-of-type {
margin-bottom: 0;
}
.peer-label {
font-family: "Commit Mono", monospace;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-faint);
display: block;
margin-bottom: 8px;
}
.peer-availability {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed var(--border);
}
.slack-handle {
font-family: "Commit Mono", monospace;
color: var(--candle-dim);
}
/* ====================================================
ACTIVITY TIMELINE

View file

@ -183,32 +183,30 @@
}}
</div>
<!-- Craft tags (fall back to offering.tags) -->
<div
v-if="getMemberCraftTags(member).length > 0"
v-if="member.craftTags?.length > 0"
class="mc-tags"
>
<span class="tag-label">Craft:</span>
<span
v-for="tag in getMemberCraftTags(member)"
v-for="tag in member.craftTags"
:key="tag"
class="skill-tag"
>{{ craftTagLabel(tag) }}</span
>
</div>
<!-- Community connections topics (fall back to lookingFor.tags) -->
<div
v-if="getMemberConnectionTopics(member).length > 0"
v-if="member.communityEcology?.topics?.length > 0"
class="mc-looking"
>
<span
v-for="topic in getMemberConnectionTopics(member)"
:key="topic.tagSlug || topic"
v-for="topic in member.communityEcology.topics"
:key="topic.tagSlug"
class="connection-topic"
>
<span class="connection-state">{{ stateLabel(topic.state) }}</span>
{{ connectionTagLabel(topic.tagSlug || topic) }}
{{ connectionTagLabel(topic.tagSlug) }}
</span>
</div>
@ -319,36 +317,8 @@ const connectionTagLabel = (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;
};
const showPeerSupport = (member) =>
!!member.communityEcology?.offerPeerSupport;
// Computed: has active filters
const hasActiveFilters = computed(() => {
@ -486,10 +456,7 @@ const clearAllFilters = () => {
// Slack DM functionality
const openSlackDM = async (member) => {
const username =
member.communityConnections?.slackHandle ||
member.peerSupport?.slackUsername ||
member.name;
const username = member.communityEcology?.slackHandle || member.name;
try {
await navigator.clipboard.writeText(username);

View file

@ -76,6 +76,10 @@ const formatters = {
text: 'Updated community connections',
icon: 'i-lucide-users'
}),
community_ecology_updated: () => ({
text: 'Updated community ecology',
icon: 'i-lucide-users'
}),
connection_requested: (m) => ({
text: `Sent connection request to ${m.memberName || 'a member'}`,
icon: 'i-lucide-user-plus'

View file

@ -1,163 +0,0 @@
/**
* Migration Script: Profile Fields Restructure
*
* This script migrates member data from the old schema to the new schema:
* - Removes `skills` field
* - Converts `offering` from String to { text: String, tags: [String] }
* - Converts `lookingFor` from String to { text: String, tags: [String] }
* - Converts `peerSupport.topics` to `peerSupport.skillTopics` and `peerSupport.supportTopics`
* - Removes `privacy.skills`
*/
import mongoose from 'mongoose';
import Member from '../server/models/member.js';
import { connectDB } from '../server/utils/mongoose.js';
// Curated list of conversational support topics
const CONVERSATIONAL_TOPICS = [
'Co-founder relationships',
'Burnout prevention',
'Impostor syndrome',
'Work-life boundaries',
'Conflict resolution',
'General chat & support',
];
async function migrateProfileFields() {
try {
await connectDB();
console.log('Connected to database');
// Find all members
const members = await Member.find({});
console.log(`Found ${members.length} members to migrate`);
let migratedCount = 0;
let skippedCount = 0;
for (const member of members) {
let needsUpdate = false;
const updates = {};
// Migrate skills -> offering.tags (if offering doesn't have tags yet)
if (member.skills && member.skills.length > 0) {
console.log(`\nMember ${member.name} (${member.email}):`);
console.log(` - Has skills: ${member.skills.join(', ')}`);
// If offering is still a string, convert it and add skills as tags
if (typeof member.offering === 'string') {
updates['offering'] = {
text: member.offering || '',
tags: member.skills, // Move skills to offering tags
};
console.log(` - Migrating skills to offering.tags`);
needsUpdate = true;
}
// Remove skills field
updates.$unset = { skills: 1 };
needsUpdate = true;
}
// Migrate offering from string to object (if not already done)
if (typeof member.offering === 'string' && !updates['offering']) {
updates['offering'] = {
text: member.offering || '',
tags: [],
};
console.log(` - Converting offering to object structure`);
needsUpdate = true;
}
// Migrate lookingFor from string to object
if (typeof member.lookingFor === 'string') {
updates['lookingFor'] = {
text: member.lookingFor || '',
tags: [],
};
console.log(` - Converting lookingFor to object structure`);
needsUpdate = true;
}
// Migrate peer support topics
if (member.peerSupport?.topics && member.peerSupport.topics.length > 0) {
const skillTopics = [];
const supportTopics = [];
// Split topics into skill-based and conversational
for (const topic of member.peerSupport.topics) {
if (CONVERSATIONAL_TOPICS.includes(topic)) {
supportTopics.push(topic);
} else {
skillTopics.push(topic);
}
}
updates['peerSupport.skillTopics'] = skillTopics;
updates['peerSupport.supportTopics'] = supportTopics;
updates['$unset'] = {
...(updates['$unset'] || {}),
'peerSupport.topics': 1
};
console.log(` - Splitting peer support topics:`);
console.log(` Skill topics: ${skillTopics.join(', ') || 'none'}`);
console.log(` Support topics: ${supportTopics.join(', ') || 'none'}`);
needsUpdate = true;
}
// Remove privacy.skills if it exists
if (member.privacy?.skills) {
updates['$unset'] = {
...(updates['$unset'] || {}),
'privacy.skills': 1
};
needsUpdate = true;
}
// Apply updates
if (needsUpdate) {
const updateOps = { ...updates };
const unsetOps = updateOps.$unset;
delete updateOps.$unset;
const finalUpdate = {};
if (Object.keys(updateOps).length > 0) {
finalUpdate.$set = updateOps;
}
if (unsetOps && Object.keys(unsetOps).length > 0) {
finalUpdate.$unset = unsetOps;
}
await Member.updateOne({ _id: member._id }, finalUpdate);
console.log(` ✓ Updated`);
migratedCount++;
} else {
skippedCount++;
}
}
console.log('\n=== Migration Complete ===');
console.log(`Total members: ${members.length}`);
console.log(`Migrated: ${migratedCount}`);
console.log(`Skipped (already migrated): ${skippedCount}`);
} catch (error) {
console.error('Migration error:', error);
throw error;
} finally {
await mongoose.connection.close();
console.log('\nDatabase connection closed');
}
}
// Run migration
migrateProfileFields()
.then(() => {
console.log('\n✓ Migration script completed successfully');
process.exit(0);
})
.catch((error) => {
console.error('\n✗ Migration script failed:', error);
process.exit(1);
});

View file

@ -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,
};
});

View file

@ -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 }
})

View file

@ -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 }
})

View file

@ -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 }
})

View file

@ -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
}
})

View file

@ -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 }
})

View file

@ -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 }
})

View file

@ -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 }
})

View 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 }
})

View file

@ -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,
}),
};
}

View file

@ -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,

View file

@ -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',
})
}
})

View 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',
})
}
})

View file

@ -32,7 +32,7 @@ export default defineEventHandler(async (event) => {
"locationPrivacy",
"socialLinksPrivacy",
"craftTagsPrivacy",
"communityConnectionsPrivacy",
"communityEcologyPrivacy",
];
// Build update object from validated data

View file

@ -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();

View file

@ -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()
})

View file

@ -17,6 +17,7 @@ const ACTIVITY_TYPES = [
'slack_invited',
'email_sent',
'community_connections_updated',
'community_ecology_updated',
'connection_requested',
'connection_confirmed',
'tag_suggested'

View file

@ -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)

View file

@ -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 },

View file

@ -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'

View file

@ -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'])