@@ -713,6 +821,11 @@ const tabItems = [
slot: "updates",
value: "updates",
},
+ {
+ label: "Peer Support",
+ slot: "peer-support",
+ value: "peer-support",
+ },
{
label: "Account",
slot: "account",
diff --git a/app/pages/member/settings/peer-support.vue b/app/pages/member/settings/peer-support.vue
new file mode 100644
index 0000000..2dbd5e1
--- /dev/null
+++ b/app/pages/member/settings/peer-support.vue
@@ -0,0 +1,366 @@
+
+
+
+
+
+
+
+
+
+
Loading settings...
+
+
+
+
+
+
+
+ About Peer Support
+
+
+ Peer support is a way to share your knowledge and experience with
+ fellow members. When enabled, you'll appear in the
+
+ Peer Support directory
+
+ where members can reach out to you for guidance on topics you're
+ comfortable discussing.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ topic }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formData.personalMessage?.length || 0 }}/200 characters
+
+
+
+
+
+
+
+
+
+
+
+
+ Preview: How you'll appear in the directory
+
+
+
+
+ 👻
+
+
+
+ {{ memberData?.name || "Your Name" }}
+
+
+ {{
+ memberData?.circle
+ ? circleLabels[memberData.circle]
+ : "Your Circle"
+ }}
+
+
+
+
+
+
Topics:
+
+
+ {{ topic }}
+
+
+
+
+
+
Availability:
+ {{ formData.availability }}
+
+
+
+ "{{ formData.personalMessage }}"
+
+
+
+
Slack:
+ {{ formData.slackUsername }}
+
+
+
+
+
+
+
+
+ Save Settings
+
+
+ Cancel
+
+
+
+
+
+
+ ✓ Peer support settings saved successfully!
+
+ View the directory
+
+
+
+
+
+ {{ error }}
+
+
+
+
+
+
+
+
diff --git a/app/pages/peer-support.vue b/app/pages/peer-support.vue
new file mode 100644
index 0000000..f948b39
--- /dev/null
+++ b/app/pages/peer-support.vue
@@ -0,0 +1,338 @@
+
+
+
+
+
+
+
+
+
+ Ghost Guild members offering peer support on various topics. Reach
+ out to schedule a conversation, ask questions, or get feedback on
+ your cooperative journey.
+
+
+ Interested in offering peer support?
+
+ Enable it in your settings
+
+
+
+
+
+
+
+ Filter by topic:
+
+ All Topics
+
+
+ {{ topic }}
+
+
+
+
+
+
+
+
+
Loading peer supporters...
+
+
+
+
+
+
+ {{ totalCount }}
+ {{ totalCount === 1 ? "peer supporter" : "peer supporters" }}
+ available
+
+
+
+
+
+
+
+
+
👻
+
+
+
+ {{ supporter.name }}
+
+
+ {{ circleLabels[supporter.circle] }}
+
+
+
+
+
+
+
Topics:
+
+
+ {{ topic }}
+
+
+
+
+
+
+
Availability:
+ {{ supporter.peerSupport.availability }}
+
+
+
+
+ "{{ supporter.peerSupport.personalMessage }}"
+
+
+
+
+
+
+
+
+
+
+
+
+ No peer supporters yet
+
+
+ Be the first to offer peer support to the community!
+
+
+ Enable Peer Support
+
+
+
+
+
+
+ 💜 Want to offer peer support to fellow members?
+
+
+ Set Up Peer Support
+
+
+
+
+
+
+ 🔒 Peer support is available to Ghost Guild members
+
+
+ Log In
+ Join Ghost Guild
+
+
+
+
+
+
+
+
diff --git a/server/api/auth/member.get.js b/server/api/auth/member.get.js
index fdbbac4..d0a8dd3 100644
--- a/server/api/auth/member.get.js
+++ b/server/api/auth/member.get.js
@@ -48,6 +48,8 @@ export default defineEventHandler(async (event) => {
lookingFor: member.lookingFor,
showInDirectory: member.showInDirectory,
privacy: member.privacy,
+ // Peer support
+ peerSupport: member.peerSupport,
};
} catch (err) {
console.error("Token verification error:", err);
diff --git a/server/api/members/me/peer-support.patch.js b/server/api/members/me/peer-support.patch.js
new file mode 100644
index 0000000..0bed798
--- /dev/null
+++ b/server/api/members/me/peer-support.patch.js
@@ -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",
+ });
+ }
+});
diff --git a/server/api/peer-support.get.js b/server/api/peer-support.get.js
new file mode 100644
index 0000000..04c5977
--- /dev/null
+++ b/server/api/peer-support.get.js
@@ -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",
+ });
+ }
+});
diff --git a/server/api/test/peer-support-debug.get.js b/server/api/test/peer-support-debug.get.js
new file mode 100644
index 0000000..09c169d
--- /dev/null
+++ b/server/api/test/peer-support-debug.get.js
@@ -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,
+ };
+});
diff --git a/server/models/member.js b/server/models/member.js
index 0212ce4..b3530da 100644
--- a/server/models/member.js
+++ b/server/models/member.js
@@ -63,6 +63,16 @@ const memberSchema = new mongoose.Schema({
lookingFor: String,
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: {
pronouns: {
diff --git a/server/utils/slack.ts b/server/utils/slack.ts
index 3547694..875bae3 100644
--- a/server/utils/slack.ts
+++ b/server/utils/slack.ts
@@ -14,7 +14,7 @@ export class SlackService {
*/
async inviteUserToSlack(
email: string,
- realName: string
+ realName: string,
): Promise<{
success: boolean;
userId?: string;
@@ -34,7 +34,7 @@ export class SlackService {
});
console.log(
- `Successfully invited existing user ${email} to vetting channel`
+ `Successfully invited existing user ${email} to vetting channel`,
);
return {
success: true,
@@ -65,7 +65,7 @@ export class SlackService {
if (inviteResponse.ok && inviteResponse.user) {
console.log(
- `Successfully invited ${email} to workspace as single-channel guest`
+ `Successfully invited ${email} to workspace as single-channel guest`,
);
return {
success: true,
@@ -79,7 +79,7 @@ export class SlackService {
console.log(
`Admin API not available or failed: ${
adminError.data?.error || adminError.message
- }`
+ }`,
);
// 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
{
+ 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 {
+ 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
*/
@@ -121,7 +182,7 @@ export class SlackService {
memberEmail: string,
circle: string,
contributionTier: string,
- invitationStatus: string = "manual_invitation_required"
+ invitationStatus: string = "manual_invitation_required",
): Promise {
try {
let statusMessage = "";
@@ -224,7 +285,7 @@ export function getSlackService(): SlackService | null {
if (!config.slackBotToken || !config.slackVettingChannelId) {
console.warn(
- "Slack integration not configured - missing bot token or channel ID"
+ "Slack integration not configured - missing bot token or channel ID",
);
return null;
}