Add peer support functionality and UI
This commit is contained in:
parent
2b55ca4104
commit
1b8dacf92a
11 changed files with 1159 additions and 35 deletions
|
|
@ -1,46 +1,54 @@
|
||||||
export const useAuth = () => {
|
export const useAuth = () => {
|
||||||
const memberData = useState('auth.member', () => null)
|
const memberData = useState("auth.member", () => null);
|
||||||
|
|
||||||
const isAuthenticated = computed(() => !!memberData.value)
|
const isAuthenticated = computed(() => !!memberData.value);
|
||||||
|
|
||||||
const isMember = computed(() => !!memberData.value)
|
const isMember = computed(() => !!memberData.value);
|
||||||
|
|
||||||
const checkMemberStatus = async () => {
|
const checkMemberStatus = async () => {
|
||||||
console.log('🔍 checkMemberStatus called')
|
console.log("🔍 checkMemberStatus called");
|
||||||
console.log(' - Current memberData:', !!memberData.value)
|
console.log(" - Current memberData:", !!memberData.value);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(' - Making API call to /api/auth/member...')
|
console.log(" - Making API call to /api/auth/member...");
|
||||||
const response = await $fetch('/api/auth/member')
|
const response = await $fetch("/api/auth/member");
|
||||||
console.log(' - API response received:', { email: response.email, id: response.id })
|
console.log(" - API response received:", {
|
||||||
memberData.value = response
|
email: response.email,
|
||||||
console.log(' - ✅ Member authenticated successfully')
|
id: response.id,
|
||||||
return true
|
});
|
||||||
|
memberData.value = response;
|
||||||
|
console.log(" - ✅ Member authenticated successfully");
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(' - ❌ Failed to fetch member status:', error.statusCode, error.statusMessage)
|
console.error(
|
||||||
memberData.value = null
|
" - ❌ Failed to fetch member status:",
|
||||||
console.log(' - Cleared memberData')
|
error.statusCode,
|
||||||
return false
|
error.statusMessage,
|
||||||
}
|
);
|
||||||
|
memberData.value = null;
|
||||||
|
console.log(" - Cleared memberData");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
try {
|
try {
|
||||||
await $fetch('/api/auth/logout', {
|
await $fetch("/api/auth/logout", {
|
||||||
method: 'POST'
|
method: "POST",
|
||||||
})
|
});
|
||||||
memberData.value = null
|
memberData.value = null;
|
||||||
await navigateTo('/')
|
await navigateTo("/");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Logout failed:', error)
|
console.error("Logout failed:", error);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isAuthenticated: readonly(isAuthenticated),
|
isAuthenticated: readonly(isAuthenticated),
|
||||||
isMember: readonly(isMember),
|
isMember: readonly(isMember),
|
||||||
memberData: readonly(memberData),
|
memberData: readonly(memberData),
|
||||||
checkMemberStatus,
|
checkMemberStatus,
|
||||||
logout
|
fetchMember: checkMemberStatus, // Alias for consistency
|
||||||
}
|
logout,
|
||||||
}
|
};
|
||||||
|
};
|
||||||
|
|
|
||||||
16
app/composables/usePeerSupport.js
Normal file
16
app/composables/usePeerSupport.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
export const usePeerSupport = () => {
|
||||||
|
const updateSettings = async (settings) => {
|
||||||
|
return await $fetch('/api/members/me/peer-support', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: settings
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSupporters = async (topic) => {
|
||||||
|
return await $fetch('/api/peer-support', {
|
||||||
|
query: topic ? { topic } : {}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { updateSettings, getSupporters };
|
||||||
|
};
|
||||||
|
|
@ -456,6 +456,114 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #peer-support>
|
||||||
|
<div class="space-y-8 mt-8 max-w-4xl">
|
||||||
|
<div
|
||||||
|
class="backdrop-blur-sm bg-purple-500/10 border border-purple-500/30 rounded-lg p-6"
|
||||||
|
>
|
||||||
|
<h3 class="text-lg font-semibold text-purple-200 mb-2">
|
||||||
|
About Peer Support
|
||||||
|
</h3>
|
||||||
|
<p class="text-stone-300 text-sm leading-relaxed mb-4">
|
||||||
|
Peer support allows you to offer guidance to fellow members
|
||||||
|
or find peers who can help you. Enable peer support to
|
||||||
|
appear in the directory, or browse available supporters.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-3 flex-wrap">
|
||||||
|
<UButton to="/member/settings/peer-support">
|
||||||
|
Configure Peer Support
|
||||||
|
</UButton>
|
||||||
|
<UButton to="/peer-support" variant="outline">
|
||||||
|
Browse Peer Supporters
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current Status -->
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
class="text-2xl font-semibold mb-6 text-stone-100 ethereal-text"
|
||||||
|
>
|
||||||
|
Your Peer Support Status
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="backdrop-blur-sm bg-stone-800/50 border border-stone-700 rounded-lg p-6"
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-stone-400">Status</p>
|
||||||
|
<p class="text-lg font-medium">
|
||||||
|
<span
|
||||||
|
v-if="memberData.peerSupport?.enabled"
|
||||||
|
class="text-green-400"
|
||||||
|
>
|
||||||
|
✓ Active - You're offering peer support
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-stone-400">
|
||||||
|
Not currently offering peer support
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="memberData.peerSupport?.enabled">
|
||||||
|
<div
|
||||||
|
v-if="memberData.peerSupport.topics?.length"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-stone-400 mb-2">
|
||||||
|
Topics you help with:
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
v-for="topic in memberData.peerSupport.topics"
|
||||||
|
:key="topic"
|
||||||
|
class="px-3 py-1 bg-purple-500/20 text-purple-300 rounded-full text-sm border border-purple-500/30"
|
||||||
|
>
|
||||||
|
{{ topic }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="memberData.peerSupport.availability">
|
||||||
|
<p class="text-sm text-stone-400 mb-1">
|
||||||
|
Your availability:
|
||||||
|
</p>
|
||||||
|
<p class="text-stone-200">
|
||||||
|
{{ memberData.peerSupport.availability }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="memberData.peerSupport.personalMessage"
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-stone-400 mb-1">
|
||||||
|
Your personal message:
|
||||||
|
</p>
|
||||||
|
<p class="text-stone-200 italic">
|
||||||
|
"{{ memberData.peerSupport.personalMessage }}"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="memberData.peerSupport.slackUsername"
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-stone-400 mb-1">
|
||||||
|
Slack username:
|
||||||
|
</p>
|
||||||
|
<p class="text-stone-200">
|
||||||
|
{{ memberData.peerSupport.slackUsername }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #account>
|
<template #account>
|
||||||
<div class="space-y-8 mt-8">
|
<div class="space-y-8 mt-8">
|
||||||
<!-- Current Membership -->
|
<!-- Current Membership -->
|
||||||
|
|
@ -713,6 +821,11 @@ const tabItems = [
|
||||||
slot: "updates",
|
slot: "updates",
|
||||||
value: "updates",
|
value: "updates",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Peer Support",
|
||||||
|
slot: "peer-support",
|
||||||
|
value: "peer-support",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Account",
|
label: "Account",
|
||||||
slot: "account",
|
slot: "account",
|
||||||
|
|
|
||||||
366
app/pages/member/settings/peer-support.vue
Normal file
366
app/pages/member/settings/peer-support.vue
Normal file
|
|
@ -0,0 +1,366 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Peer Support Settings"
|
||||||
|
subtitle="Offer guidance and support to fellow Ghost Guild members"
|
||||||
|
theme="purple"
|
||||||
|
size="medium"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section class="py-12">
|
||||||
|
<UContainer>
|
||||||
|
<div
|
||||||
|
v-if="loading && !formData"
|
||||||
|
class="flex justify-center items-center py-20"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<div
|
||||||
|
class="w-8 h-8 border-4 border-purple-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
|
||||||
|
></div>
|
||||||
|
<p class="text-stone-400">Loading settings...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="max-w-3xl">
|
||||||
|
<!-- Info Box -->
|
||||||
|
<div
|
||||||
|
class="mb-8 backdrop-blur-sm bg-purple-500/10 border border-purple-500/30 rounded-lg p-6"
|
||||||
|
>
|
||||||
|
<h3 class="text-lg font-semibold text-purple-200 mb-2">
|
||||||
|
About Peer Support
|
||||||
|
</h3>
|
||||||
|
<p class="text-stone-300 text-sm leading-relaxed">
|
||||||
|
Peer support is a way to share your knowledge and experience with
|
||||||
|
fellow members. When enabled, you'll appear in the
|
||||||
|
<NuxtLink
|
||||||
|
to="/peer-support"
|
||||||
|
class="text-purple-400 hover:text-purple-300 underline"
|
||||||
|
>
|
||||||
|
Peer Support directory
|
||||||
|
</NuxtLink>
|
||||||
|
where members can reach out to you for guidance on topics you're
|
||||||
|
comfortable discussing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form -->
|
||||||
|
<UForm :state="formData" @submit="handleSubmit" class="space-y-8">
|
||||||
|
<!-- Enable Toggle -->
|
||||||
|
<UFormField
|
||||||
|
label="Offer Peer Support"
|
||||||
|
name="enabled"
|
||||||
|
description="Make yourself available to support other members"
|
||||||
|
>
|
||||||
|
<USwitch v-model="formData.enabled" size="lg" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<!-- Conditional Fields -->
|
||||||
|
<div
|
||||||
|
v-if="formData.enabled"
|
||||||
|
class="space-y-6 pl-4 border-l-2 border-purple-500/30"
|
||||||
|
>
|
||||||
|
<!-- Topics -->
|
||||||
|
<UFormField
|
||||||
|
label="Topics You Can Help With"
|
||||||
|
name="topics"
|
||||||
|
description="Select all topics where you can offer guidance"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<div class="space-y-2 mt-2">
|
||||||
|
<label
|
||||||
|
v-for="topic in availableTopics"
|
||||||
|
:key="topic"
|
||||||
|
class="flex items-center gap-3 p-3 rounded-lg border border-stone-700 hover:border-purple-500/50 transition-colors cursor-pointer"
|
||||||
|
:class="
|
||||||
|
formData.topics.includes(topic)
|
||||||
|
? 'bg-purple-500/10 border-purple-500/50'
|
||||||
|
: 'bg-stone-900/50'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:value="topic"
|
||||||
|
v-model="formData.topics"
|
||||||
|
class="rounded border-stone-600 text-purple-500 focus:ring-purple-500 focus:ring-offset-0 bg-stone-800"
|
||||||
|
/>
|
||||||
|
<span class="text-stone-200">{{ topic }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<!-- Availability -->
|
||||||
|
<UFormField
|
||||||
|
label="Your Availability"
|
||||||
|
name="availability"
|
||||||
|
description="When are you generally available for peer support sessions?"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<UTextarea
|
||||||
|
v-model="formData.availability"
|
||||||
|
placeholder="e.g., Weekday evenings (6-9pm EST), weekends flexible, or by appointment"
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<!-- Personal Message -->
|
||||||
|
<UFormField
|
||||||
|
label="Personal Message to Potential Chatters"
|
||||||
|
name="personalMessage"
|
||||||
|
description="A friendly message to encourage people to reach out"
|
||||||
|
>
|
||||||
|
<UTextarea
|
||||||
|
v-model="formData.personalMessage"
|
||||||
|
placeholder="e.g., I love talking about this stuff! No question is too basic."
|
||||||
|
rows="2"
|
||||||
|
maxlength="200"
|
||||||
|
/>
|
||||||
|
<template #hint>
|
||||||
|
<span class="text-xs text-stone-500">
|
||||||
|
{{ formData.personalMessage?.length || 0 }}/200 characters
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<!-- Slack Username -->
|
||||||
|
<UFormField
|
||||||
|
label="Slack Username"
|
||||||
|
name="slackUsername"
|
||||||
|
description="Your Slack username so members can message you (required)"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
v-model="formData.slackUsername"
|
||||||
|
placeholder="@yourslackname"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<!-- Preview -->
|
||||||
|
<div
|
||||||
|
class="mt-8 p-6 bg-stone-900/50 border border-stone-700/50 rounded-lg"
|
||||||
|
>
|
||||||
|
<h3 class="text-sm font-semibold text-stone-400 mb-4">
|
||||||
|
Preview: How you'll appear in the directory
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
class="backdrop-blur-sm bg-stone-800/50 border border-stone-700/50 rounded-lg p-6"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 rounded-lg bg-stone-800 border border-stone-700 flex items-center justify-center flex-shrink-0"
|
||||||
|
>
|
||||||
|
<span class="text-xl">👻</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="font-semibold text-stone-100">
|
||||||
|
{{ memberData?.name || "Your Name" }}
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
class="px-2 py-0.5 bg-purple-500/20 text-purple-300 rounded text-xs border border-purple-500/30 inline-block"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
memberData?.circle
|
||||||
|
? circleLabels[memberData.circle]
|
||||||
|
: "Your Circle"
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="formData.topics.length > 0" class="mb-4">
|
||||||
|
<div class="text-xs text-stone-500 mb-2">Topics:</div>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="topic in formData.topics"
|
||||||
|
:key="topic"
|
||||||
|
class="px-2 py-0.5 bg-stone-800/50 text-stone-300 rounded text-xs border border-stone-700"
|
||||||
|
>
|
||||||
|
{{ topic }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="formData.availability"
|
||||||
|
class="mb-4 text-sm text-stone-400"
|
||||||
|
>
|
||||||
|
<div class="text-xs text-stone-500 mb-1">Availability:</div>
|
||||||
|
{{ formData.availability }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="formData.personalMessage"
|
||||||
|
class="mb-4 text-sm text-stone-300 italic"
|
||||||
|
>
|
||||||
|
"{{ formData.personalMessage }}"
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="formData.slackUsername"
|
||||||
|
class="text-sm text-stone-400"
|
||||||
|
>
|
||||||
|
<div class="text-xs text-stone-500 mb-1">Slack:</div>
|
||||||
|
{{ formData.slackUsername }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex gap-3 pt-6 border-t border-stone-700">
|
||||||
|
<UButton
|
||||||
|
type="submit"
|
||||||
|
:loading="saving"
|
||||||
|
:disabled="
|
||||||
|
formData.enabled &&
|
||||||
|
(!formData.topics.length ||
|
||||||
|
!formData.availability ||
|
||||||
|
!formData.slackUsername)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Save Settings
|
||||||
|
</UButton>
|
||||||
|
<UButton variant="outline" @click="resetForm" :disabled="saving">
|
||||||
|
Cancel
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</UForm>
|
||||||
|
|
||||||
|
<!-- Success Message -->
|
||||||
|
<div
|
||||||
|
v-if="showSuccess"
|
||||||
|
class="mt-6 p-4 bg-green-500/10 border border-green-500/30 rounded-lg text-green-300 text-sm"
|
||||||
|
>
|
||||||
|
✓ Peer support settings saved successfully!
|
||||||
|
<NuxtLink
|
||||||
|
to="/peer-support"
|
||||||
|
class="underline hover:text-green-200 ml-2"
|
||||||
|
>
|
||||||
|
View the directory
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Message -->
|
||||||
|
<div
|
||||||
|
v-if="error"
|
||||||
|
class="mt-6 p-4 bg-red-500/10 border border-red-500/30 rounded-lg text-red-300 text-sm"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UContainer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
middleware: "auth",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isAuthenticated, memberData, fetchMember } = useAuth();
|
||||||
|
const { updateSettings } = usePeerSupport();
|
||||||
|
|
||||||
|
// State
|
||||||
|
const loading = ref(true);
|
||||||
|
const saving = ref(false);
|
||||||
|
const showSuccess = ref(false);
|
||||||
|
const error = ref(null);
|
||||||
|
|
||||||
|
// Available topics
|
||||||
|
const availableTopics = [
|
||||||
|
"Governance & decision-making",
|
||||||
|
"Financial modeling",
|
||||||
|
"Fundraising",
|
||||||
|
"Team building",
|
||||||
|
"Legal structures",
|
||||||
|
"Marketing & outreach",
|
||||||
|
"Conflict resolution",
|
||||||
|
"General chat & support",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Circle labels
|
||||||
|
const circleLabels = {
|
||||||
|
community: "Community",
|
||||||
|
founder: "Founder",
|
||||||
|
practitioner: "Practitioner",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Form data
|
||||||
|
const formData = ref({
|
||||||
|
enabled: false,
|
||||||
|
topics: [],
|
||||||
|
availability: "",
|
||||||
|
personalMessage: "",
|
||||||
|
slackUsername: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize form with existing data
|
||||||
|
const initializeForm = () => {
|
||||||
|
if (memberData.value?.peerSupport) {
|
||||||
|
formData.value = {
|
||||||
|
enabled: memberData.value.peerSupport.enabled || false,
|
||||||
|
topics: memberData.value.peerSupport.topics || [],
|
||||||
|
availability: memberData.value.peerSupport.availability || "",
|
||||||
|
personalMessage: memberData.value.peerSupport.personalMessage || "",
|
||||||
|
slackUsername: memberData.value.peerSupport.slackUsername || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
const resetForm = () => {
|
||||||
|
initializeForm();
|
||||||
|
showSuccess.value = false;
|
||||||
|
error.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
saving.value = true;
|
||||||
|
error.value = null;
|
||||||
|
showSuccess.value = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateSettings({
|
||||||
|
enabled: formData.value.enabled,
|
||||||
|
topics: formData.value.topics,
|
||||||
|
availability: formData.value.availability,
|
||||||
|
personalMessage: formData.value.personalMessage,
|
||||||
|
slackUsername: formData.value.slackUsername,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh member data
|
||||||
|
await fetchMember();
|
||||||
|
|
||||||
|
showSuccess.value = true;
|
||||||
|
|
||||||
|
// Scroll to top to show success message
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update peer support settings:", err);
|
||||||
|
error.value = "Failed to save settings. Please try again.";
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load on mount
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!memberData.value) {
|
||||||
|
await fetchMember();
|
||||||
|
}
|
||||||
|
initializeForm();
|
||||||
|
});
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: "Peer Support Settings - Ghost Guild",
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
content:
|
||||||
|
"Configure your peer support settings and offer guidance to Ghost Guild members.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
</script>
|
||||||
338
app/pages/peer-support.vue
Normal file
338
app/pages/peer-support.vue
Normal file
|
|
@ -0,0 +1,338 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Peer Support"
|
||||||
|
subtitle="Connect with fellow members for 1:1 guidance and support"
|
||||||
|
theme="purple"
|
||||||
|
size="medium"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section class="py-12 px-4">
|
||||||
|
<UContainer class="px-4">
|
||||||
|
<!-- Intro Text -->
|
||||||
|
<div
|
||||||
|
class="mb-8 backdrop-blur-sm bg-stone-900/50 border border-stone-700/50 rounded-lg p-6"
|
||||||
|
>
|
||||||
|
<p class="text-stone-300 mb-2">
|
||||||
|
Ghost Guild members offering peer support on various topics. Reach
|
||||||
|
out to schedule a conversation, ask questions, or get feedback on
|
||||||
|
your cooperative journey.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-stone-400">
|
||||||
|
Interested in offering peer support?
|
||||||
|
<NuxtLink
|
||||||
|
to="/member/settings/peer-support"
|
||||||
|
class="text-purple-400 hover:text-purple-300 underline"
|
||||||
|
>
|
||||||
|
Enable it in your settings
|
||||||
|
</NuxtLink>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Topic Filter -->
|
||||||
|
<div v-if="availableTopics.length > 0" class="mb-8">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span class="text-sm text-stone-400 mr-2 self-center"
|
||||||
|
>Filter by topic:</span
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-3 py-1 rounded-full text-sm transition-all border"
|
||||||
|
:class="
|
||||||
|
!selectedTopic
|
||||||
|
? 'bg-purple-500/20 text-purple-300 border-purple-500/50'
|
||||||
|
: 'bg-stone-800/50 text-stone-400 border-stone-700 hover:border-stone-600'
|
||||||
|
"
|
||||||
|
@click="
|
||||||
|
selectedTopic = null;
|
||||||
|
loadSupporters();
|
||||||
|
"
|
||||||
|
>
|
||||||
|
All Topics
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="topic in availableTopics"
|
||||||
|
:key="topic"
|
||||||
|
type="button"
|
||||||
|
class="px-3 py-1 rounded-full text-sm transition-all border"
|
||||||
|
:class="
|
||||||
|
selectedTopic === topic
|
||||||
|
? 'bg-purple-500/20 text-purple-300 border-purple-500/50'
|
||||||
|
: 'bg-stone-800/50 text-stone-400 border-stone-700 hover:border-stone-600'
|
||||||
|
"
|
||||||
|
@click="
|
||||||
|
selectedTopic = topic;
|
||||||
|
loadSupporters();
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ topic }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div
|
||||||
|
v-if="loading && !supporters.length"
|
||||||
|
class="flex justify-center items-center py-20"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<div
|
||||||
|
class="w-8 h-8 border-4 border-purple-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
|
||||||
|
></div>
|
||||||
|
<p class="text-stone-400">Loading peer supporters...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Supporters Grid -->
|
||||||
|
<div v-else-if="supporters.length > 0">
|
||||||
|
<div class="mb-4 text-stone-400 text-sm">
|
||||||
|
{{ totalCount }}
|
||||||
|
{{ totalCount === 1 ? "peer supporter" : "peer supporters" }}
|
||||||
|
available
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4 max-w-3xl">
|
||||||
|
<div
|
||||||
|
v-for="supporter in supporters"
|
||||||
|
:key="supporter._id"
|
||||||
|
class="backdrop-blur-sm bg-stone-900/50 border border-stone-700/50 rounded-lg p-6 hover:border-purple-500/50 transition-all group"
|
||||||
|
>
|
||||||
|
<!-- Avatar and Name -->
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 rounded-lg bg-stone-800 border border-stone-700 flex items-center justify-center flex-shrink-0 group-hover:border-purple-500/50 transition-colors"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="supporter.avatar"
|
||||||
|
:src="`/ghosties/Ghost-${supporter.avatar.charAt(0).toUpperCase() + supporter.avatar.slice(1)}.png`"
|
||||||
|
:alt="supporter.name"
|
||||||
|
class="w-8 h-8 object-contain"
|
||||||
|
/>
|
||||||
|
<span v-else class="text-xl text-stone-600">👻</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="font-semibold text-stone-100 truncate">
|
||||||
|
{{ supporter.name }}
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
class="px-2 py-0.5 bg-purple-500/20 text-purple-300 rounded text-xs border border-purple-500/30 inline-block"
|
||||||
|
>
|
||||||
|
{{ circleLabels[supporter.circle] }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Topics -->
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
supporter.peerSupport?.topics &&
|
||||||
|
supporter.peerSupport.topics.length > 0
|
||||||
|
"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
<div class="text-xs text-stone-500 mb-2">Topics:</div>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="topic in supporter.peerSupport.topics"
|
||||||
|
:key="topic"
|
||||||
|
class="px-2 py-0.5 bg-stone-800/50 text-stone-300 rounded text-xs border border-stone-700"
|
||||||
|
>
|
||||||
|
{{ topic }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Availability -->
|
||||||
|
<div
|
||||||
|
v-if="supporter.peerSupport?.availability"
|
||||||
|
class="mb-4 text-sm text-stone-400"
|
||||||
|
>
|
||||||
|
<div class="text-xs text-stone-500 mb-1">Availability:</div>
|
||||||
|
{{ supporter.peerSupport.availability }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Personal Message -->
|
||||||
|
<div
|
||||||
|
v-if="supporter.peerSupport?.personalMessage"
|
||||||
|
class="mb-4 text-sm text-stone-300 italic bg-stone-800/30 rounded-lg p-3 border-l-2 border-purple-500/50"
|
||||||
|
>
|
||||||
|
"{{ supporter.peerSupport.personalMessage }}"
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex gap-2 mt-4">
|
||||||
|
<a
|
||||||
|
v-if="supporter.peerSupport?.slackUsername"
|
||||||
|
:href="getSlackDMLink(supporter)"
|
||||||
|
@click.prevent="openSlackDM(supporter)"
|
||||||
|
class="flex-1 px-3 py-2 bg-purple-500/20 text-purple-300 rounded border border-purple-500/30 hover:bg-purple-500/30 transition-colors text-center text-sm font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
Message {{ supporter.peerSupport.slackUsername }} on Slack
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
v-else
|
||||||
|
href="slack://open"
|
||||||
|
@click.prevent="openSlackApp"
|
||||||
|
class="flex-1 px-3 py-2 bg-stone-800/50 text-stone-300 rounded border border-stone-700 hover:border-stone-600 transition-colors text-center text-sm font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
Find on Slack
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else class="text-center py-20">
|
||||||
|
<div class="w-16 h-16 mx-auto mb-4 opacity-50">
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="text-stone-600"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-medium text-stone-300 mb-2">
|
||||||
|
No peer supporters yet
|
||||||
|
</h3>
|
||||||
|
<p class="text-stone-400 mb-6">
|
||||||
|
Be the first to offer peer support to the community!
|
||||||
|
</p>
|
||||||
|
<UButton to="/member/settings/peer-support">
|
||||||
|
Enable Peer Support
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA for Members -->
|
||||||
|
<div
|
||||||
|
v-if="isAuthenticated && supporters.length > 0"
|
||||||
|
class="mt-8 backdrop-blur-sm bg-purple-500/10 border border-purple-500/30 rounded-lg p-6 text-center"
|
||||||
|
>
|
||||||
|
<p class="text-purple-200 mb-4">
|
||||||
|
💜 Want to offer peer support to fellow members?
|
||||||
|
</p>
|
||||||
|
<UButton to="/member/settings/peer-support" variant="outline">
|
||||||
|
Set Up Peer Support
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Not Authenticated Notice -->
|
||||||
|
<div
|
||||||
|
v-if="!isAuthenticated"
|
||||||
|
class="mt-8 backdrop-blur-sm bg-purple-500/10 border border-purple-500/30 rounded-lg p-6 text-center"
|
||||||
|
>
|
||||||
|
<p class="text-purple-200 mb-4">
|
||||||
|
🔒 Peer support is available to Ghost Guild members
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-3 justify-center">
|
||||||
|
<UButton to="/login" variant="outline"> Log In </UButton>
|
||||||
|
<UButton to="/join"> Join Ghost Guild </UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UContainer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
const { getSupporters } = usePeerSupport();
|
||||||
|
|
||||||
|
// State
|
||||||
|
const supporters = ref([]);
|
||||||
|
const totalCount = ref(0);
|
||||||
|
const availableTopics = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const selectedTopic = ref(null);
|
||||||
|
|
||||||
|
// Gamma Space Slack team ID
|
||||||
|
const slackTeamId = "T03A96LV4";
|
||||||
|
|
||||||
|
// Circle labels
|
||||||
|
const circleLabels = {
|
||||||
|
community: "Community",
|
||||||
|
founder: "Founder",
|
||||||
|
practitioner: "Practitioner",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get Slack DM deep link
|
||||||
|
const getSlackDMLink = (supporter) => {
|
||||||
|
// If we have the DM channel ID, use it for direct DM
|
||||||
|
if (supporter.peerSupport?.slackDMChannelId) {
|
||||||
|
return `slack://channel?team=${slackTeamId}&id=${supporter.peerSupport.slackDMChannelId}`;
|
||||||
|
}
|
||||||
|
// Otherwise fall back to opening workspace
|
||||||
|
return "slack://open";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Open Slack DM
|
||||||
|
const openSlackDM = async (supporter) => {
|
||||||
|
console.log("Opening Slack DM for supporter:", supporter);
|
||||||
|
const username = supporter.peerSupport?.slackUsername || supporter.name;
|
||||||
|
|
||||||
|
// Copy username to clipboard
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(username);
|
||||||
|
console.log("Copied username to clipboard:", username);
|
||||||
|
} catch (err) {
|
||||||
|
console.log("Could not copy to clipboard:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show a toast/alert (you can replace this with a proper toast notification)
|
||||||
|
alert(
|
||||||
|
`Opening Slack...\n\nSearch for: ${username}\n\n(Username copied to clipboard)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open Slack workspace
|
||||||
|
window.open("https://gammaspace.slack.com", "_blank");
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSlackApp = () => {
|
||||||
|
// Try to open Slack app
|
||||||
|
window.location.href = "slack://open";
|
||||||
|
|
||||||
|
// Fallback to web after a short delay if app doesn't open
|
||||||
|
setTimeout(() => {
|
||||||
|
window.open("https://gammaspace.slack.com", "_blank");
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load supporters
|
||||||
|
const loadSupporters = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await getSupporters(selectedTopic.value);
|
||||||
|
supporters.value = data.supporters;
|
||||||
|
totalCount.value = data.totalCount;
|
||||||
|
availableTopics.value = data.filters.availableTopics;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load peer supporters:", error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load on mount
|
||||||
|
onMounted(() => {
|
||||||
|
loadSupporters();
|
||||||
|
});
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: "Peer Support - Ghost Guild",
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
content:
|
||||||
|
"Connect with Ghost Guild members for 1:1 guidance on governance, fundraising, team building, and more.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
@ -48,6 +48,8 @@ export default defineEventHandler(async (event) => {
|
||||||
lookingFor: member.lookingFor,
|
lookingFor: member.lookingFor,
|
||||||
showInDirectory: member.showInDirectory,
|
showInDirectory: member.showInDirectory,
|
||||||
privacy: member.privacy,
|
privacy: member.privacy,
|
||||||
|
// Peer support
|
||||||
|
peerSupport: member.peerSupport,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Token verification error:", err);
|
console.error("Token verification error:", err);
|
||||||
|
|
|
||||||
119
server/api/members/me/peer-support.patch.js
Normal file
119
server/api/members/me/peer-support.patch.js
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import Member from "../../../models/member.js";
|
||||||
|
import { connectDB } from "../../../utils/mongoose.js";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await connectDB();
|
||||||
|
|
||||||
|
const token = getCookie(event, "auth-token");
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: "Not authenticated",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let memberId;
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
memberId = decoded.memberId;
|
||||||
|
} catch (err) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: "Invalid or expired token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody(event);
|
||||||
|
|
||||||
|
// Build update object for peer support settings
|
||||||
|
const updateData = {
|
||||||
|
"peerSupport.enabled": body.enabled || false,
|
||||||
|
"peerSupport.topics": body.topics || [],
|
||||||
|
"peerSupport.availability": body.availability || "",
|
||||||
|
"peerSupport.personalMessage": body.personalMessage || "",
|
||||||
|
"peerSupport.slackUsername": body.slackUsername || "",
|
||||||
|
};
|
||||||
|
|
||||||
|
// If Slack username provided and peer support enabled, try to fetch Slack user ID
|
||||||
|
if (body.enabled && body.slackUsername) {
|
||||||
|
try {
|
||||||
|
console.log(
|
||||||
|
`[Peer Support] Attempting to fetch Slack user ID for: ${body.slackUsername}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dynamically import the Slack service
|
||||||
|
const { getSlackService } = await import("../../../utils/slack.ts");
|
||||||
|
const slackService = getSlackService();
|
||||||
|
|
||||||
|
if (slackService) {
|
||||||
|
console.log(
|
||||||
|
"[Peer Support] Slack service initialized, looking up user...",
|
||||||
|
);
|
||||||
|
const slackUserId = await slackService.findUserIdByUsername(
|
||||||
|
body.slackUsername,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (slackUserId) {
|
||||||
|
updateData["slackUserId"] = slackUserId;
|
||||||
|
console.log(
|
||||||
|
`[Peer Support] ✓ Found Slack user ID for ${body.slackUsername}: ${slackUserId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Now get/create the DM channel
|
||||||
|
console.log("[Peer Support] Opening DM channel...");
|
||||||
|
const dmChannelId = await slackService.openDMChannel(slackUserId);
|
||||||
|
|
||||||
|
if (dmChannelId) {
|
||||||
|
updateData["peerSupport.slackDMChannelId"] = dmChannelId;
|
||||||
|
console.log(`[Peer Support] ✓ Got DM channel ID: ${dmChannelId}`);
|
||||||
|
} else {
|
||||||
|
console.warn("[Peer Support] Could not get DM channel ID");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`[Peer Support] Could not find Slack user ID for username: ${body.slackUsername}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
"[Peer Support] Slack service not configured, skipping user ID lookup",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"[Peer Support] Error fetching Slack user ID:",
|
||||||
|
error.message,
|
||||||
|
);
|
||||||
|
console.error("[Peer Support] Stack trace:", error.stack);
|
||||||
|
// Continue anyway - we'll still save the username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const member = await Member.findByIdAndUpdate(
|
||||||
|
memberId,
|
||||||
|
{ $set: updateData },
|
||||||
|
{ new: true, runValidators: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: "Member not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
peerSupport: member.peerSupport,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Peer support update error:", error);
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "Failed to update peer support settings",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
63
server/api/peer-support.get.js
Normal file
63
server/api/peer-support.get.js
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import Member from "../models/member.js";
|
||||||
|
import { connectDB } from "../utils/mongoose.js";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await connectDB();
|
||||||
|
|
||||||
|
// Check if user is authenticated (optional for this endpoint)
|
||||||
|
const token = getCookie(event, "auth-token");
|
||||||
|
let isAuthenticated = false;
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
isAuthenticated = true;
|
||||||
|
} catch (err) {
|
||||||
|
isAuthenticated = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = getQuery(event);
|
||||||
|
const topic = query.topic;
|
||||||
|
|
||||||
|
// Build query for peer supporters
|
||||||
|
const dbQuery = {
|
||||||
|
"peerSupport.enabled": true,
|
||||||
|
status: "active",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter by topic if specified
|
||||||
|
if (topic) {
|
||||||
|
dbQuery["peerSupport.topics"] = topic;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supporters = await Member.find(dbQuery)
|
||||||
|
.select(
|
||||||
|
"name avatar circle peerSupport slackUserId createdAt"
|
||||||
|
)
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
// Get unique topics for filter options
|
||||||
|
const allTopics = supporters
|
||||||
|
.flatMap((supporter) => supporter.peerSupport?.topics || [])
|
||||||
|
.filter((topic, index, self) => self.indexOf(topic) === index)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
return {
|
||||||
|
supporters,
|
||||||
|
totalCount: supporters.length,
|
||||||
|
filters: {
|
||||||
|
availableTopics: allTopics,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Peer support fetch error:", error);
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "Failed to fetch peer supporters",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
28
server/api/test/peer-support-debug.get.js
Normal file
28
server/api/test/peer-support-debug.get.js
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import Member from "../../models/member.js";
|
||||||
|
import { connectDB } from "../../utils/mongoose.js";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await connectDB();
|
||||||
|
|
||||||
|
const token = getCookie(event, "auth-token");
|
||||||
|
if (!token) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: "Not authenticated" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let memberId;
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
memberId = decoded.memberId;
|
||||||
|
} catch (err) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: "Invalid token" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await Member.findById(memberId).select("name peerSupport slackUserId");
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: member.name,
|
||||||
|
peerSupport: member.peerSupport,
|
||||||
|
slackUserId: member.slackUserId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
@ -63,6 +63,16 @@ const memberSchema = new mongoose.Schema({
|
||||||
lookingFor: String,
|
lookingFor: String,
|
||||||
showInDirectory: { type: Boolean, default: true },
|
showInDirectory: { type: Boolean, default: true },
|
||||||
|
|
||||||
|
// Peer support settings
|
||||||
|
peerSupport: {
|
||||||
|
enabled: { type: Boolean, default: false },
|
||||||
|
topics: [String],
|
||||||
|
availability: String,
|
||||||
|
personalMessage: String,
|
||||||
|
slackUsername: String,
|
||||||
|
slackDMChannelId: String, // DM channel ID for direct messaging
|
||||||
|
},
|
||||||
|
|
||||||
// Privacy settings for profile fields
|
// Privacy settings for profile fields
|
||||||
privacy: {
|
privacy: {
|
||||||
pronouns: {
|
pronouns: {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export class SlackService {
|
||||||
*/
|
*/
|
||||||
async inviteUserToSlack(
|
async inviteUserToSlack(
|
||||||
email: string,
|
email: string,
|
||||||
realName: string
|
realName: string,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
|
@ -34,7 +34,7 @@ export class SlackService {
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Successfully invited existing user ${email} to vetting channel`
|
`Successfully invited existing user ${email} to vetting channel`,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -65,7 +65,7 @@ export class SlackService {
|
||||||
|
|
||||||
if (inviteResponse.ok && inviteResponse.user) {
|
if (inviteResponse.ok && inviteResponse.user) {
|
||||||
console.log(
|
console.log(
|
||||||
`Successfully invited ${email} to workspace as single-channel guest`
|
`Successfully invited ${email} to workspace as single-channel guest`,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -79,7 +79,7 @@ export class SlackService {
|
||||||
console.log(
|
console.log(
|
||||||
`Admin API not available or failed: ${
|
`Admin API not available or failed: ${
|
||||||
adminError.data?.error || adminError.message
|
adminError.data?.error || adminError.message
|
||||||
}`
|
}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fall back to manual process
|
// Fall back to manual process
|
||||||
|
|
@ -113,6 +113,67 @@ export class SlackService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find user ID by username (display name or real name)
|
||||||
|
*/
|
||||||
|
async findUserIdByUsername(username: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const cleanUsername = username.replace("@", "").toLowerCase();
|
||||||
|
|
||||||
|
// List all users and search for matching username
|
||||||
|
const response = await this.client.users.list();
|
||||||
|
|
||||||
|
if (!response.members) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for user by name or display_name
|
||||||
|
const user = response.members.find((member: any) => {
|
||||||
|
const name = member.name?.toLowerCase() || "";
|
||||||
|
const realName = member.real_name?.toLowerCase() || "";
|
||||||
|
const displayName = member.profile?.display_name?.toLowerCase() || "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
name === cleanUsername ||
|
||||||
|
displayName === cleanUsername ||
|
||||||
|
realName.includes(cleanUsername)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return user?.id || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error looking up Slack user by username:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open/get a DM channel with a user and return the channel ID
|
||||||
|
* This creates or opens a DM conversation and returns the channel ID (starts with D)
|
||||||
|
*/
|
||||||
|
async openDMChannel(userId: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const response = await this.client.conversations.open({
|
||||||
|
users: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok && response.channel?.id) {
|
||||||
|
console.log(
|
||||||
|
`Opened DM channel for user ${userId}: ${response.channel.id}`,
|
||||||
|
);
|
||||||
|
return response.channel.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(
|
||||||
|
"Error opening DM channel:",
|
||||||
|
error.data?.error || error.message,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a notification to the vetting channel about a new member
|
* Send a notification to the vetting channel about a new member
|
||||||
*/
|
*/
|
||||||
|
|
@ -121,7 +182,7 @@ export class SlackService {
|
||||||
memberEmail: string,
|
memberEmail: string,
|
||||||
circle: string,
|
circle: string,
|
||||||
contributionTier: string,
|
contributionTier: string,
|
||||||
invitationStatus: string = "manual_invitation_required"
|
invitationStatus: string = "manual_invitation_required",
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
let statusMessage = "";
|
let statusMessage = "";
|
||||||
|
|
@ -224,7 +285,7 @@ export function getSlackService(): SlackService | null {
|
||||||
|
|
||||||
if (!config.slackBotToken || !config.slackVettingChannelId) {
|
if (!config.slackBotToken || !config.slackVettingChannelId) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"Slack integration not configured - missing bot token or channel ID"
|
"Slack integration not configured - missing bot token or channel ID",
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue