From 8112e5ea47f0c93886a4ea9d1e1090a753fd3445 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 5 Apr 2026 16:06:03 +0100 Subject: [PATCH 01/16] feat: add Tag, TagSuggestion, Connection models and extend Member schema Adds three new Mongoose models for the community connections feature. Extends Member with craftTags, communityConnections block, privacy fields for both, and a connectionRequests notification preference. --- server/models/connection.js | 22 ++++++++++++++++++++++ server/models/member.js | 26 ++++++++++++++++++++++++++ server/models/tag.js | 13 +++++++++++++ server/models/tagSuggestion.js | 11 +++++++++++ 4 files changed, 72 insertions(+) create mode 100644 server/models/connection.js create mode 100644 server/models/tag.js create mode 100644 server/models/tagSuggestion.js diff --git a/server/models/connection.js b/server/models/connection.js new file mode 100644 index 0000000..f328935 --- /dev/null +++ b/server/models/connection.js @@ -0,0 +1,22 @@ +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: String, + recipientState: String, + }, + ], + 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) diff --git a/server/models/member.js b/server/models/member.js index d267ee0..f0105c1 100644 --- a/server/models/member.js +++ b/server/models/member.js @@ -90,6 +90,21 @@ const memberSchema = new mongoose.Schema({ slackDMChannelId: String, // DM channel ID for direct messaging }, + craftTags: [String], + communityConnections: { + topics: [ + { + tagSlug: String, + state: { type: String, enum: ['help', 'interested', 'seeking'] }, + }, + ], + offerPeerSupport: { type: Boolean, default: false }, + availability: String, + slackHandle: String, + personalMessage: String, + details: String, + }, + // Privacy settings for profile fields privacy: { pronouns: { @@ -137,12 +152,23 @@ const memberSchema = new mongoose.Schema({ enum: ["public", "members", "private"], default: "members", }, + craftTags: { + type: String, + enum: ["public", "members", "private"], + default: "members", + }, + communityConnections: { + type: String, + enum: ["public", "members", "private"], + default: "members", + }, }, 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 }, diff --git a/server/models/tag.js b/server/models/tag.js new file mode 100644 index 0000000..f2972b2 --- /dev/null +++ b/server/models/tag.js @@ -0,0 +1,13 @@ +import mongoose from 'mongoose' + +const tagSchema = new mongoose.Schema({ + slug: { type: String, required: true, unique: true }, + label: { type: String, required: true }, + pool: { type: String, enum: ['craft', 'cooperative'], required: true }, + active: { type: Boolean, default: true }, + createdAt: { type: Date, default: Date.now }, +}) + +tagSchema.index({ pool: 1, active: 1 }) + +export default mongoose.models.Tag || mongoose.model('Tag', tagSchema) diff --git a/server/models/tagSuggestion.js b/server/models/tagSuggestion.js new file mode 100644 index 0000000..151c213 --- /dev/null +++ b/server/models/tagSuggestion.js @@ -0,0 +1,11 @@ +import mongoose from 'mongoose' + +const tagSuggestionSchema = new mongoose.Schema({ + label: { type: String, required: true }, + pool: { type: String, enum: ['craft', 'cooperative'], required: true }, + suggestedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'Member', required: true }, + status: { type: String, enum: ['pending', 'approved', 'rejected'], default: 'pending' }, + createdAt: { type: Date, default: Date.now }, +}) + +export default mongoose.models.TagSuggestion || mongoose.model('TagSuggestion', tagSuggestionSchema) From 4b6ff19d5fb184e235ae8756547ef0a6fa26818e Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 5 Apr 2026 16:09:20 +0100 Subject: [PATCH 02/16] fix: add state enums to Connection matchingTags, index to TagSuggestion --- server/models/connection.js | 4 ++-- server/models/tagSuggestion.js | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/server/models/connection.js b/server/models/connection.js index f328935..7fcdf1b 100644 --- a/server/models/connection.js +++ b/server/models/connection.js @@ -7,8 +7,8 @@ const connectionSchema = new mongoose.Schema({ matchingTags: [ { tagSlug: String, - initiatorState: String, - recipientState: String, + initiatorState: { type: String, enum: ['help', 'interested', 'seeking'] }, + recipientState: { type: String, enum: ['help', 'interested', 'seeking'] }, }, ], hiddenBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Member' }], diff --git a/server/models/tagSuggestion.js b/server/models/tagSuggestion.js index 151c213..976dd4e 100644 --- a/server/models/tagSuggestion.js +++ b/server/models/tagSuggestion.js @@ -8,4 +8,6 @@ const tagSuggestionSchema = new mongoose.Schema({ createdAt: { type: Date, default: Date.now }, }) +tagSuggestionSchema.index({ pool: 1, status: 1 }) + export default mongoose.models.TagSuggestion || mongoose.model('TagSuggestion', tagSuggestionSchema) From 1cb029a881be925e0a90cbf1d7271abb646ad734 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 5 Apr 2026 16:11:52 +0100 Subject: [PATCH 03/16] feat: add seed-tags and migrate-community-connections scripts Idempotent seed for 16 craft + 20 cooperative tags by slug. Migration maps existing offering/lookingFor tags to communityConnections.topics and copies peerSupport fields without deleting originals. --- scripts/seed-tags.js | 111 +++++++++ .../migrate-community-connections.js | 213 ++++++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 scripts/seed-tags.js create mode 100644 server/migrations/migrate-community-connections.js diff --git a/scripts/seed-tags.js b/scripts/seed-tags.js new file mode 100644 index 0000000..ab19ee2 --- /dev/null +++ b/scripts/seed-tags.js @@ -0,0 +1,111 @@ +/** + * Seed Script: Tags + * + * Upserts craft and cooperative tags by slug (idempotent). + * Safe to run multiple times. + */ + +import 'dotenv/config' +import mongoose from 'mongoose' +import Tag from '../server/models/tag.js' +import { connectDB } from '../server/utils/mongoose.js' + +// Convert a slug like "qa-and-testing" to "QA and Testing" +// Special-cases common abbreviations. +const ABBREVIATIONS = new Set(['qa', 'ux', 'ui', 'devops']) + +function slugToLabel(slug) { + return slug + .split('-') + .map((word) => { + if (ABBREVIATIONS.has(word)) return word.toUpperCase() + return word.charAt(0).toUpperCase() + word.slice(1) + }) + .join(' ') +} + +const CRAFT_SLUGS = [ + 'game-design', + 'programming', + 'narrative-design', + 'art-and-animation', + 'audio-and-music', + 'production-management', + 'qa-and-testing', + 'community-management', + 'marketing-and-comms', + 'ux-and-ui-design', + 'business-development', + 'devops-and-tools', + 'localization', + 'accessibility', + 'analytics-and-data', + 'education-and-mentoring', +] + +const COOPERATIVE_SLUGS = [ + 'governance', + 'finance-and-budgeting', + 'legal-structures', + 'conflict-resolution', + 'consensus-decision-making', + 'revenue-sharing', + 'cooperative-bylaws', + 'member-onboarding', + 'democratic-management', + 'worker-ownership', + 'platform-cooperativism', + 'cooperative-marketing', + 'shared-resources', + 'cooperative-funding', + 'community-building', + 'equity-and-inclusion', + 'cooperative-tech', + 'sustainability', + 'collective-bargaining', + 'inter-coop-collaboration', +] + +async function seedTags() { + await connectDB() + + const tagDefs = [ + ...CRAFT_SLUGS.map((slug) => ({ slug, pool: 'craft', label: slugToLabel(slug) })), + ...COOPERATIVE_SLUGS.map((slug) => ({ slug, pool: 'cooperative', label: slugToLabel(slug) })), + ] + + let upserted = 0 + let unchanged = 0 + + for (const { slug, pool, label } of tagDefs) { + const result = await Tag.updateOne( + { slug }, + { $setOnInsert: { slug, pool, label, active: true, createdAt: new Date() } }, + { upsert: true } + ) + if (result.upsertedCount > 0) { + console.log(` + Created [${pool}] ${label} (${slug})`) + upserted++ + } else { + unchanged++ + } + } + + console.log('\n=== Seed Complete ===') + console.log(` Total tags defined: ${tagDefs.length}`) + console.log(` Newly created: ${upserted}`) + console.log(` Already existed: ${unchanged}`) +} + +seedTags() + .then(() => { + console.log('\nTag seed completed successfully') + process.exit(0) + }) + .catch((err) => { + console.error('\nTag seed failed:', err) + process.exit(1) + }) + .finally(() => { + mongoose.connection.close() + }) diff --git a/server/migrations/migrate-community-connections.js b/server/migrations/migrate-community-connections.js new file mode 100644 index 0000000..e0ce594 --- /dev/null +++ b/server/migrations/migrate-community-connections.js @@ -0,0 +1,213 @@ +/** + * 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() + }) From 18b8106405048ddcf61d2ba7783493954e73df72 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 5 Apr 2026 16:13:23 +0100 Subject: [PATCH 04/16] fix: use Map for abbreviations to handle mixed-case DevOps label --- scripts/seed-tags.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/scripts/seed-tags.js b/scripts/seed-tags.js index ab19ee2..a656ca4 100644 --- a/scripts/seed-tags.js +++ b/scripts/seed-tags.js @@ -12,15 +12,17 @@ import { connectDB } from '../server/utils/mongoose.js' // Convert a slug like "qa-and-testing" to "QA and Testing" // Special-cases common abbreviations. -const ABBREVIATIONS = new Set(['qa', 'ux', 'ui', 'devops']) +const ABBREVIATIONS = new Map([ + ['qa', 'QA'], + ['ux', 'UX'], + ['ui', 'UI'], + ['devops', 'DevOps'], +]) function slugToLabel(slug) { return slug .split('-') - .map((word) => { - if (ABBREVIATIONS.has(word)) return word.toUpperCase() - return word.charAt(0).toUpperCase() + word.slice(1) - }) + .map((word) => ABBREVIATIONS.get(word) ?? word.charAt(0).toUpperCase() + word.slice(1)) .join(' ') } From 79d038c7249d600c8eb388bcb640769fc3d90830 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 5 Apr 2026 16:15:29 +0100 Subject: [PATCH 05/16] feat: add Tags API endpoints and validation schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/tags — public, filterable by ?pool=craft|cooperative, active only, sorted by label - POST /api/tags/suggest — auth-required, creates TagSuggestion doc - Add tagSuggestionSchema and communityConnectionsUpdateSchema to schemas.js - Extend memberProfileUpdateSchema with craftTags, craftTagsPrivacy, communityConnectionsPrivacy --- server/api/tags/index.get.js | 16 ++++++++++++++++ server/api/tags/suggest.post.js | 17 +++++++++++++++++ server/utils/schemas.js | 24 +++++++++++++++++++++++- 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 server/api/tags/index.get.js create mode 100644 server/api/tags/suggest.post.js diff --git a/server/api/tags/index.get.js b/server/api/tags/index.get.js new file mode 100644 index 0000000..43387f0 --- /dev/null +++ b/server/api/tags/index.get.js @@ -0,0 +1,16 @@ +import Tag from '../../models/tag.js' + +export default defineEventHandler(async (event) => { + await connectDB() + + const query = getQuery(event) + const filter = { active: true } + + if (query.pool) { + filter.pool = query.pool + } + + const tags = await Tag.find(filter).sort({ label: 1 }).lean() + + return { tags } +}) diff --git a/server/api/tags/suggest.post.js b/server/api/tags/suggest.post.js new file mode 100644 index 0000000..c71fbee --- /dev/null +++ b/server/api/tags/suggest.post.js @@ -0,0 +1,17 @@ +import TagSuggestion from '../../models/tagSuggestion.js' +import { tagSuggestionSchema } from '../../utils/schemas.js' + +export default defineEventHandler(async (event) => { + await connectDB() + + const member = await requireAuth(event) + const body = await validateBody(event, tagSuggestionSchema) + + await TagSuggestion.create({ + label: body.label, + pool: body.pool, + suggestedBy: member._id + }) + + return { success: true } +}) diff --git a/server/utils/schemas.js b/server/utils/schemas.js index 44f51d5..74f290b 100644 --- a/server/utils/schemas.js +++ b/server/utils/schemas.js @@ -48,7 +48,10 @@ export const memberProfileUpdateSchema = z.object({ locationPrivacy: privacyEnum.optional(), socialLinksPrivacy: privacyEnum.optional(), offeringPrivacy: privacyEnum.optional(), - lookingForPrivacy: privacyEnum.optional() + lookingForPrivacy: privacyEnum.optional(), + craftTags: z.array(z.string().max(100)).max(16).optional(), + craftTagsPrivacy: privacyEnum.optional(), + communityConnectionsPrivacy: privacyEnum.optional() }) export const eventRegistrationSchema = z.object({ @@ -346,3 +349,22 @@ export const memberInviteSchema = z.object({ memberIds: z.array(z.string().min(1)).min(1).max(100), emailTemplate: z.string().min(1).max(10000) }) + +// --- Tag schemas --- + +export const tagSuggestionSchema = z.object({ + label: z.string().min(1).max(100), + pool: z.enum(['craft', 'cooperative']) +}) + +export const communityConnectionsUpdateSchema = z.object({ + topics: z.array(z.object({ + tagSlug: z.string().min(1).max(100), + state: z.enum(['help', 'interested', 'seeking']) + })).max(20).optional(), + offerPeerSupport: z.boolean().optional(), + availability: z.string().max(500).optional(), + slackHandle: z.string().max(200).optional(), + personalMessage: z.string().max(2000).optional(), + details: z.string().max(300).optional() +}) From 06ee77592f187cf88d00f9a3d2372a7b03e84aa4 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 5 Apr 2026 16:17:25 +0100 Subject: [PATCH 06/16] feat: add community connections activity log types Adds COMMUNITY_CONNECTIONS_UPDATED, CONNECTION_REQUESTED, CONNECTION_CONFIRMED, and TAG_SUGGESTED to ACTIVITY_TYPES, ACTIVITY_TYPE_DEFAULTS, the Mongoose enum, and activityText formatters. All four default to member visibility. --- app/utils/activityText.js | 16 ++++++++++++++++ server/models/activityLog.js | 6 +++++- server/utils/activityLog.js | 12 ++++++++++-- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/app/utils/activityText.js b/app/utils/activityText.js index 5cca693..006d3ae 100644 --- a/app/utils/activityText.js +++ b/app/utils/activityText.js @@ -81,6 +81,22 @@ const formatters = { text: m.subject ? `Email: ${m.subject}` : 'Email sent', icon: 'i-lucide-mail', emailBody: m.body || null + }), + community_connections_updated: () => ({ + text: 'Updated community connections', + icon: 'i-lucide-users' + }), + connection_requested: (m) => ({ + text: `Sent connection request to ${m.memberName || 'a member'}`, + icon: 'i-lucide-user-plus' + }), + connection_confirmed: (m) => ({ + text: `Connected with ${m.memberName || 'a member'}`, + icon: 'i-lucide-handshake' + }), + tag_suggested: (m) => ({ + text: `Suggested tag: ${m.label || 'unknown'}`, + icon: 'i-lucide-tag' }) } diff --git a/server/models/activityLog.js b/server/models/activityLog.js index 6c9605d..da3acc2 100644 --- a/server/models/activityLog.js +++ b/server/models/activityLog.js @@ -17,7 +17,11 @@ const ACTIVITY_TYPES = [ 'role_changed', 'admin_profile_update', 'slack_invited', - 'email_sent' + 'email_sent', + 'community_connections_updated', + 'connection_requested', + 'connection_confirmed', + 'tag_suggested' ] const activityLogSchema = new mongoose.Schema({ diff --git a/server/utils/activityLog.js b/server/utils/activityLog.js index e9c3d69..6916fc0 100644 --- a/server/utils/activityLog.js +++ b/server/utils/activityLog.js @@ -17,7 +17,11 @@ export const ACTIVITY_TYPES = { ROLE_CHANGED: 'role_changed', ADMIN_PROFILE_UPDATE: 'admin_profile_update', SLACK_INVITED: 'slack_invited', - EMAIL_SENT: 'email_sent' + EMAIL_SENT: 'email_sent', + COMMUNITY_CONNECTIONS_UPDATED: 'community_connections_updated', + CONNECTION_REQUESTED: 'connection_requested', + CONNECTION_CONFIRMED: 'connection_confirmed', + TAG_SUGGESTED: 'tag_suggested' } export const ACTIVITY_TYPE_DEFAULTS = { @@ -37,7 +41,11 @@ export const ACTIVITY_TYPE_DEFAULTS = { role_changed: 'admin', admin_profile_update: 'admin', slack_invited: 'admin', - email_sent: 'member' + email_sent: 'member', + community_connections_updated: 'member', + connection_requested: 'member', + connection_confirmed: 'member', + tag_suggested: 'member' } /** From 3faa1f8e85535946641a487ab3190afafd4f9ac4 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 5 Apr 2026 16:19:49 +0100 Subject: [PATCH 07/16] feat: add community-connections API endpoint and update profile handler New PATCH /api/members/me/community-connections endpoint following peer-support.patch.js pattern (requireAuth, validateBody, dot-notation $set, Slack user lookup when offerPeerSupport+slackHandle set, logActivity). Profile endpoint updated with craftTags handling, craftTagsPrivacy and communityConnectionsPrivacy in privacy fields, and craftTags in response. --- .../members/me/community-connections.patch.js | 95 +++++++++++++++++++ server/api/members/profile.patch.js | 8 ++ 2 files changed, 103 insertions(+) create mode 100644 server/api/members/me/community-connections.patch.js diff --git a/server/api/members/me/community-connections.patch.js b/server/api/members/me/community-connections.patch.js new file mode 100644 index 0000000..df9e401 --- /dev/null +++ b/server/api/members/me/community-connections.patch.js @@ -0,0 +1,95 @@ +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', + }) + } +}) diff --git a/server/api/members/profile.patch.js b/server/api/members/profile.patch.js index dd70ac1..760c1d8 100644 --- a/server/api/members/profile.patch.js +++ b/server/api/members/profile.patch.js @@ -33,6 +33,8 @@ export default defineEventHandler(async (event) => { "socialLinksPrivacy", "offeringPrivacy", "lookingForPrivacy", + "craftTagsPrivacy", + "communityConnectionsPrivacy", ]; // Build update object from validated data @@ -44,6 +46,11 @@ export default defineEventHandler(async (event) => { } }); + // Handle craftTags (simple array) + if (body.craftTags !== undefined) { + updateData.craftTags = body.craftTags; + } + // Handle offering and lookingFor separately (nested objects) if (body.offering !== undefined) { updateData.offering = { @@ -102,6 +109,7 @@ export default defineEventHandler(async (event) => { socialLinks: member.socialLinks, offering: member.offering, lookingFor: member.lookingFor, + craftTags: member.craftTags, showInDirectory: member.showInDirectory, notifications: member.notifications, }; From 2c8529aed97b779f4ec176ec3d65790eaaa46840 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 5 Apr 2026 16:23:22 +0100 Subject: [PATCH 08/16] Add CraftTagSelector, CooperativeTagSelector, and TagSuggestModal components Pill-toggle grid for craft tags, 3-state segmented control for cooperative tags (matching PrivacyToggle visual pattern), and a minimal modal for submitting tag suggestions via /api/tags/suggest. --- app/components/CooperativeTagSelector.vue | 149 ++++++++++++++++++++++ app/components/CraftTagSelector.vue | 95 ++++++++++++++ app/components/TagSuggestModal.vue | 106 +++++++++++++++ 3 files changed, 350 insertions(+) create mode 100644 app/components/CooperativeTagSelector.vue create mode 100644 app/components/CraftTagSelector.vue create mode 100644 app/components/TagSuggestModal.vue diff --git a/app/components/CooperativeTagSelector.vue b/app/components/CooperativeTagSelector.vue new file mode 100644 index 0000000..46a82f0 --- /dev/null +++ b/app/components/CooperativeTagSelector.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/app/components/CraftTagSelector.vue b/app/components/CraftTagSelector.vue new file mode 100644 index 0000000..4fe494a --- /dev/null +++ b/app/components/CraftTagSelector.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/app/components/TagSuggestModal.vue b/app/components/TagSuggestModal.vue new file mode 100644 index 0000000..0175ab2 --- /dev/null +++ b/app/components/TagSuggestModal.vue @@ -0,0 +1,106 @@ + + + + + From 3551f1977202498d63480b678708e15afb7cd470 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 5 Apr 2026 16:25:10 +0100 Subject: [PATCH 09/16] fix: correct POST body field name and state enum values in tag components --- app/components/CooperativeTagSelector.vue | 4 ++-- app/components/TagSuggestModal.vue | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/components/CooperativeTagSelector.vue b/app/components/CooperativeTagSelector.vue index 46a82f0..1772c9c 100644 --- a/app/components/CooperativeTagSelector.vue +++ b/app/components/CooperativeTagSelector.vue @@ -31,9 +31,9 @@ const props = defineProps({ const emit = defineEmits(["update:modelValue", "suggest"]); const options = [ - { label: "Can help", value: "can_help" }, + { label: "Can help", value: "help" }, { label: "Interested", value: "interested" }, - { label: "Need help", value: "need_help" }, + { label: "Need help", value: "seeking" }, ]; function getState(slug) { diff --git a/app/components/TagSuggestModal.vue b/app/components/TagSuggestModal.vue index 0175ab2..bacf2b1 100644 --- a/app/components/TagSuggestModal.vue +++ b/app/components/TagSuggestModal.vue @@ -60,7 +60,7 @@ async function submit() { try { await $fetch("/api/tags/suggest", { method: "POST", - body: { name: tagName.value.trim(), pool: props.pool }, + body: { label: tagName.value.trim(), pool: props.pool }, }); success.value = true; } catch (e) { From 2aa29ba64bde192421585614cabb32c7a143826b Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 5 Apr 2026 16:28:34 +0100 Subject: [PATCH 10/16] feat: restructure profile page for community connections Replace Skills Exchange section with CraftTagSelector in About You. Replace Peer Support section with Community Connections using CooperativeTagSelector. Update form data, load/save logic, and notifications to use new field names with backward-compatible fallbacks to old peerSupport data. --- app/pages/member/profile.vue | 353 ++++++++++------------------------- 1 file changed, 97 insertions(+), 256 deletions(-) diff --git a/app/pages/member/profile.vue b/app/pages/member/profile.vue index e141981..0e861cc 100644 --- a/app/pages/member/profile.vue +++ b/app/pages/member/profile.vue @@ -140,45 +140,16 @@ - - - -
-
- +
- - What I Do + - -
-
- - -
- -
- - - -
-
- - +
@@ -203,10 +174,33 @@
- + + +
+ + + +
+ +
+ + +
+ {{ formData.communityConnectionsDetails?.length || 0 }} / 300 +
+
- +
Offer Peer Support
-
-
- - -
- Suggested from your offerings: - {{ tag }} -
-
- -
- -
- -
-
- +
@@ -264,7 +222,7 @@
@@ -273,13 +231,13 @@
- {{ formData.peerSupportMessage?.length || 0 }} / 200 + {{ formData.communityConnectionsPersonalMessage?.length || 0 }} / 200
@@ -311,9 +269,9 @@
- +
- Peer support requests + Connection requests When someone wants to connect @@ -345,6 +303,9 @@
+ + +
@@ -374,6 +335,20 @@ const availableGhosts = [ { value: "wtf", label: "WTF", image: "/ghosties/Ghost-WTF.png" }, ]; +// Fetch tags and split into pools +const { data: tagsData } = await useFetch("/api/tags"); + +const craftTags = computed(() => + (tagsData.value?.tags || []).filter((t) => t.pool === "craft"), +); +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 formData = reactive({ name: "", @@ -383,16 +358,18 @@ const formData = reactive({ studio: "", bio: "", location: "", - offering: { text: "", tags: [] }, - lookingFor: { text: "", tags: [] }, showInDirectory: true, - // Peer support - peerSupportEnabled: false, - peerSupportSkillTopics: [], - peerSupportSupportTopics: [], - peerSupportAvailability: "", - peerSupportMessage: "", - peerSupportSlackUsername: "", + // Craft tags + craftTags: [], + craftTagsPrivacy: "members", + // Community connections + communityConnectionsTopics: [], + communityConnectionsPrivacy: "members", + communityConnectionsDetails: "", + communityConnectionsOfferPeerSupport: false, + communityConnectionsAvailability: "", + communityConnectionsSlackHandle: "", + communityConnectionsPersonalMessage: "", // Privacy pronounsPrivacy: "members", timeZonePrivacy: "members", @@ -400,12 +377,11 @@ const formData = reactive({ studioPrivacy: "members", bioPrivacy: "members", locationPrivacy: "members", - offeringPrivacy: "members", - lookingForPrivacy: "members", // Notifications notifyEvents: true, notifyUpdates: true, notifyPeerRequests: true, + notifyConnectionRequests: true, }); const loading = ref(false); @@ -414,47 +390,11 @@ const saveSuccess = ref(false); const saveError = ref(null); const initialData = ref(null); -// Available conversational support topics -const availableSupportTopics = [ - "Co-founder relationships", - "Burnout prevention", - "Impostor syndrome", - "Work-life boundaries", - "Conflict resolution", - "General chat & support", -]; - // Computed const hasChanges = computed(() => { return JSON.stringify(formData) !== JSON.stringify(initialData.value); }); -const suggestedSkillTopics = computed(() => { - if (!formData.offering.tags?.length) return []; - return formData.offering.tags.filter( - (t) => !formData.peerSupportSkillTopics?.includes(t), - ); -}); - -// Toggle a support topic in/out of the selection -const toggleSupportTopic = (topic) => { - const idx = formData.peerSupportSupportTopics.indexOf(topic); - if (idx >= 0) { - formData.peerSupportSupportTopics.splice(idx, 1); - } else { - formData.peerSupportSupportTopics.push(topic); - } -}; - -const addSuggestedSkillTopic = (tag) => { - if (!Array.isArray(formData.peerSupportSkillTopics)) { - formData.peerSupportSkillTopics = []; - } - if (!formData.peerSupportSkillTopics.includes(tag)) { - formData.peerSupportSkillTopics.push(tag); - } -}; - // Load member data into form const loadProfile = () => { if (memberData.value) { @@ -466,66 +406,21 @@ const loadProfile = () => { formData.bio = memberData.value.bio || ""; formData.location = memberData.value.location || ""; - // Load offering (handle both old string and new object format) - if (typeof memberData.value.offering === "string") { - formData.offering.text = memberData.value.offering; - formData.offering.tags = []; - } else if (memberData.value.offering) { - formData.offering.text = memberData.value.offering?.text || ""; - formData.offering.tags = Array.isArray(memberData.value.offering?.tags) - ? [...memberData.value.offering.tags] - : []; - } else { - formData.offering.text = ""; - formData.offering.tags = []; - } - - // Load lookingFor (handle both old string and new object format) - if (typeof memberData.value.lookingFor === "string") { - formData.lookingFor.text = memberData.value.lookingFor; - formData.lookingFor.tags = []; - } else if (memberData.value.lookingFor) { - formData.lookingFor.text = memberData.value.lookingFor?.text || ""; - formData.lookingFor.tags = Array.isArray( - memberData.value.lookingFor?.tags, - ) - ? [...memberData.value.lookingFor.tags] - : []; - } else { - formData.lookingFor.text = ""; - formData.lookingFor.tags = []; - } - formData.showInDirectory = memberData.value.showInDirectory ?? true; - // Load peer support data - if (memberData.value.peerSupport) { - formData.peerSupportEnabled = - memberData.value.peerSupport.enabled || false; - formData.peerSupportSkillTopics = Array.isArray( - memberData.value.peerSupport.skillTopics, - ) - ? [...memberData.value.peerSupport.skillTopics] - : []; - formData.peerSupportSupportTopics = Array.isArray( - memberData.value.peerSupport.supportTopics, - ) - ? [...memberData.value.peerSupport.supportTopics] - : []; - formData.peerSupportAvailability = - memberData.value.peerSupport.availability || ""; - formData.peerSupportMessage = - memberData.value.peerSupport.personalMessage || ""; - formData.peerSupportSlackUsername = - memberData.value.peerSupport.slackUsername || ""; - } else { - formData.peerSupportEnabled = false; - formData.peerSupportSkillTopics = []; - formData.peerSupportSupportTopics = []; - formData.peerSupportAvailability = ""; - formData.peerSupportMessage = ""; - formData.peerSupportSlackUsername = ""; - } + // 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 || ""; // Load privacy settings (with defaults) const privacy = memberData.value.privacy || {}; @@ -535,14 +430,15 @@ const loadProfile = () => { formData.studioPrivacy = privacy.studio || "members"; formData.bioPrivacy = privacy.bio || "members"; formData.locationPrivacy = privacy.location || "members"; - formData.offeringPrivacy = privacy.offering || "members"; - formData.lookingForPrivacy = privacy.lookingFor || "members"; + formData.craftTagsPrivacy = privacy.craftTags || "members"; + formData.communityConnectionsPrivacy = privacy.communityConnections || "members"; // Load notification prefs const notifs = memberData.value.notifications || {}; formData.notifyEvents = notifs.events ?? true; formData.notifyUpdates = notifs.updates ?? true; formData.notifyPeerRequests = notifs.peerRequests ?? true; + formData.notifyConnectionRequests = notifs.connectionRequests ?? true; // Store initial state for change detection initialData.value = JSON.parse(JSON.stringify(formData)); @@ -556,29 +452,31 @@ const handleSubmit = async () => { saveError.value = null; try { - // Save profile data + // Save profile data (includes craft tags + privacy + notifications) await $fetch("/api/members/profile", { method: "PATCH", body: { ...formData, + craftTags: formData.craftTags, notifications: { events: formData.notifyEvents, updates: formData.notifyUpdates, peerRequests: formData.notifyPeerRequests, + connectionRequests: formData.notifyConnectionRequests, }, }, }); - // Save peer support data separately - await $fetch("/api/members/me/peer-support", { + // Save community connections data + await $fetch("/api/members/me/community-connections", { method: "PATCH", body: { - enabled: formData.peerSupportEnabled, - skillTopics: formData.peerSupportSkillTopics, - supportTopics: formData.peerSupportSupportTopics, - availability: formData.peerSupportAvailability, - personalMessage: formData.peerSupportMessage, - slackUsername: formData.peerSupportSlackUsername, + topics: formData.communityConnectionsTopics, + offerPeerSupport: formData.communityConnectionsOfferPeerSupport, + availability: formData.communityConnectionsAvailability, + slackHandle: formData.communityConnectionsSlackHandle, + personalMessage: formData.communityConnectionsPersonalMessage, + details: formData.communityConnectionsDetails, }, }); @@ -829,48 +727,8 @@ useHead({ margin-top: 1px; } -/* ---- CHECKBOX GRID ---- */ -.checkbox-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 3px 12px; -} - -.checkbox-item { - display: flex; - align-items: center; - gap: 5px; - font-size: 11px; - color: var(--text-dim); - cursor: pointer; - padding: 2px 0; - user-select: none; -} - -.checkbox-item .cb { - width: 13px; - height: 13px; - border: 1px dashed var(--border); - display: flex; - align-items: center; - justify-content: center; - font-size: 9px; - color: transparent; - flex-shrink: 0; -} - -.checkbox-item.checked .cb { - border-color: var(--candle); - border-style: solid; - color: var(--candle); -} - -.checkbox-item:hover { - color: var(--text); -} - -/* ---- PEER SUPPORT PANEL ---- */ -.peer-panel { +/* ---- CONNECTIONS PANEL ---- */ +.connections-panel { border: 1px dashed var(--border); padding: 12px 14px; margin-top: 4px; @@ -878,19 +736,6 @@ useHead({ background: var(--surface); } -.peer-panel .suggested { - font-size: 10px; - color: var(--text-faint); - margin-top: 4px; -} - -.peer-panel .suggested a { - color: var(--candle); - text-decoration: underline; - cursor: pointer; - margin-left: 4px; -} - /* ---- DISABLED BUTTON ---- */ .btn:disabled { opacity: 0.4; @@ -947,10 +792,6 @@ useHead({ grid-template-columns: 1fr; } - .checkbox-grid { - grid-template-columns: 1fr; - } - .profile-col-left .profile-col-inset, .profile-col-right .profile-col-inset { padding-left: 16px; From bd07172093a71412714c43b85a2d5760411fa768 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 5 Apr 2026 16:31:49 +0100 Subject: [PATCH 11/16] fix: add connectionRequests to notification schema, remove dead notifyPeerRequests --- app/pages/member/profile.vue | 5 +---- server/utils/schemas.js | 3 ++- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/app/pages/member/profile.vue b/app/pages/member/profile.vue index 0e861cc..26a2cf8 100644 --- a/app/pages/member/profile.vue +++ b/app/pages/member/profile.vue @@ -380,7 +380,6 @@ const formData = reactive({ // Notifications notifyEvents: true, notifyUpdates: true, - notifyPeerRequests: true, notifyConnectionRequests: true, }); @@ -437,8 +436,7 @@ const loadProfile = () => { const notifs = memberData.value.notifications || {}; formData.notifyEvents = notifs.events ?? true; formData.notifyUpdates = notifs.updates ?? true; - formData.notifyPeerRequests = notifs.peerRequests ?? true; - formData.notifyConnectionRequests = notifs.connectionRequests ?? true; + formData.notifyConnectionRequests = notifs.connectionRequests ?? notifs.peerRequests ?? true; // Store initial state for change detection initialData.value = JSON.parse(JSON.stringify(formData)); @@ -461,7 +459,6 @@ const handleSubmit = async () => { notifications: { events: formData.notifyEvents, updates: formData.notifyUpdates, - peerRequests: formData.notifyPeerRequests, connectionRequests: formData.notifyConnectionRequests, }, }, diff --git a/server/utils/schemas.js b/server/utils/schemas.js index 74f290b..dc545e3 100644 --- a/server/utils/schemas.js +++ b/server/utils/schemas.js @@ -38,7 +38,8 @@ export const memberProfileUpdateSchema = z.object({ notifications: z.object({ events: z.boolean().optional(), updates: z.boolean().optional(), - peerRequests: z.boolean().optional() + peerRequests: z.boolean().optional(), + connectionRequests: z.boolean().optional() }).optional(), pronounsPrivacy: privacyEnum.optional(), timeZonePrivacy: privacyEnum.optional(), From 896de2e7fd6e9d615b4fc57133be8c08fd4fa2d5 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 5 Apr 2026 16:40:10 +0100 Subject: [PATCH 12/16] feat: add craft tags and community connections to directory and profiles Update member directory and public profile APIs to include craftTags and communityConnections with privacy-aware filtering. Directory now uses predefined tags from the Tag model for filter bars and supports craftTag/connectionTag query filters. Frontend shows craft tag pills and cooperative topics with state labels, falling back to old offering/lookingFor fields. Add Connections nav item. --- app/components/AppNavigation.vue | 1 + app/pages/members/[id].vue | 132 +++++++++++--- app/pages/members/index.vue | 255 +++++++++++++++++++--------- server/api/members/[id].get.js | 17 +- server/api/members/directory.get.js | 100 +++++++---- 5 files changed, 367 insertions(+), 138 deletions(-) diff --git a/app/components/AppNavigation.vue b/app/components/AppNavigation.vue index f113c52..1fa8ec4 100644 --- a/app/components/AppNavigation.vue +++ b/app/components/AppNavigation.vue @@ -173,6 +173,7 @@ const youItems = [ const exploreItems = [ { label: "Events", path: "/events" }, { label: "Members", path: "/members" }, + { label: "Connections", path: "/connections" }, { label: "Wiki", path: "/wiki" }, { label: "About", path: "/about" }, ]; diff --git a/app/pages/members/[id].vue b/app/pages/members/[id].vue index 6b4e254..0b47108 100644 --- a/app/pages/members/[id].vue +++ b/app/pages/members/[id].vue @@ -60,40 +60,42 @@

- -
- -
+ +
+ +
{{ tag }}{{ tagLabel('craft', tag) }}
-

+

{{ member.offering.text }}

- +
- -
+ +
{{ tag }} + {{ stateLabel(topic.state) }} + {{ tagLabel('cooperative', topic.tagSlug || topic) }} +
-

+

+ {{ member.communityConnections.details }} +

+

{{ member.lookingFor.text }}

@@ -150,10 +152,10 @@
- -
+ +
-
+
Skills:
-
+
Topics:
-

- {{ member.peerSupport.availability }} +

+ {{ peerAvailability }}

@@ -233,6 +235,15 @@ const circleLabels = { practitioner: "Practitioner", }; +// State display text mapping +const stateLabels = { + help: "Can help", + interested: "Interested", + seeking: "Need help", +}; + +const stateLabel = (state) => stateLabels[state] || state || ""; + const getInitials = (name) => { if (!name) return "?"; return name @@ -246,6 +257,11 @@ const getInitials = (name) => { // Fetch member data — no await so the component renders immediately (no Suspense) const { data, pending, error: fetchError } = useFetch(`/api/members/${id}`); +// Fetch tags for slug-to-label lookup +const { data: tagsData } = useFetch("/api/tags", { + default: () => ({ tags: [] }), +}); + // Fetch public activity const { data: activityData } = useFetch(`/api/members/${id}/activity`, { params: { limit: 5 }, @@ -267,6 +283,56 @@ const formatRelativeDate = (date) => { } const member = computed(() => data.value?.member || null); +// Tag label lookup +const tagLabel = (pool, slug) => { + const tags = tagsData.value?.tags || []; + const found = tags.find((t) => t.slug === slug && t.pool === pool); + return found ? found.label : slug; +}; + +// Craft tags display: new field, falling back to offering.tags +const craftTagsDisplay = computed(() => { + if (!member.value) return []; + if (member.value.craftTags && member.value.craftTags.length > 0) { + return member.value.craftTags; + } + return member.value.offering?.tags || []; +}); + +// Connection topics display: new field, falling back to lookingFor.tags +const connectionTopicsDisplay = computed(() => { + if (!member.value) return []; + if ( + member.value.communityConnections?.topics && + member.value.communityConnections.topics.length > 0 + ) { + return member.value.communityConnections.topics; + } + if (member.value.lookingFor?.tags && member.value.lookingFor.tags.length > 0) { + return member.value.lookingFor.tags.map((tag) => ({ tagSlug: tag, state: null })); + } + return []; +}); + +// Peer support: check both new communityConnections and old peerSupport +const showPeerSupport = computed(() => { + if (!member.value) return false; + return ( + member.value.communityConnections?.offerPeerSupport || + member.value.peerSupport?.enabled + ); +}); + +// Peer availability: prefer new field, fall back to old +const peerAvailability = computed(() => { + if (!member.value) return ""; + return ( + member.value.communityConnections?.availability || + member.value.peerSupport?.availability || + "" + ); +}); + const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => ""); watch( member, @@ -442,7 +508,8 @@ useHead({ } .offering-text, -.looking-text { +.looking-text, +.connection-details { margin-top: 8px; } @@ -462,6 +529,19 @@ useHead({ white-space: nowrap; } +.connection-pill { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.connection-state { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-faint); +} + /* ---- SOCIAL LINKS ---- */ .social-links { display: flex; diff --git a/app/pages/members/index.vue b/app/pages/members/index.vue index a7d4658..dcf0cfd 100644 --- a/app/pages/members/index.vue +++ b/app/pages/members/index.vue @@ -41,64 +41,64 @@ >
- +
- Skills: + Craft:
- +
Topics:
@@ -117,16 +117,16 @@ Offering Peer Support - - {{ skill }} - + + {{ craftTagLabel(slug) }} + - - {{ topic }} - + + {{ connectionTagLabel(slug) }} +