From dcb80e6006c9ca67290c7a94836d015cc68588b2 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 5 Apr 2026 16:48:10 +0100 Subject: [PATCH] feat: add connection API endpoints Suggestions, create/confirm/hide/withdraw actions, my connections list, and pending count for nav badge. --- server/api/connections/[id]/confirm.post.js | 54 ++++++++ server/api/connections/[id]/hide.post.js | 48 +++++++ server/api/connections/[id]/withdraw.post.js | 43 ++++++ server/api/connections/index.get.js | 45 +++++++ server/api/connections/index.post.js | 108 +++++++++++++++ server/api/connections/pending-count.get.js | 14 ++ server/api/connections/suggestions.get.js | 130 +++++++++++++++++++ 7 files changed, 442 insertions(+) create mode 100644 server/api/connections/[id]/confirm.post.js create mode 100644 server/api/connections/[id]/hide.post.js create mode 100644 server/api/connections/[id]/withdraw.post.js create mode 100644 server/api/connections/index.get.js create mode 100644 server/api/connections/index.post.js create mode 100644 server/api/connections/pending-count.get.js create mode 100644 server/api/connections/suggestions.get.js diff --git a/server/api/connections/[id]/confirm.post.js b/server/api/connections/[id]/confirm.post.js new file mode 100644 index 0000000..48da26a --- /dev/null +++ b/server/api/connections/[id]/confirm.post.js @@ -0,0 +1,54 @@ +import mongoose from 'mongoose' +import Connection from '../../../models/connection.js' +import Member from '../../../models/member.js' +import { requireAuth } from '../../../utils/auth.js' + +export default defineEventHandler(async (event) => { + const member = await requireAuth(event) + const memberId = member._id + const connectionId = getRouterParam(event, 'id') + + if (!mongoose.Types.ObjectId.isValid(connectionId)) { + throw createError({ + statusCode: 400, + statusMessage: 'Invalid connection ID' + }) + } + + const connection = await Connection.findById(connectionId) + if (!connection) { + throw createError({ + statusCode: 404, + statusMessage: 'Connection not found' + }) + } + + // Only the recipient can confirm + if (connection.recipient.toString() !== memberId.toString()) { + throw createError({ + statusCode: 403, + statusMessage: 'Only the recipient can confirm a connection' + }) + } + + if (connection.status !== 'pending') { + throw createError({ + statusCode: 400, + statusMessage: 'Connection is not pending' + }) + } + + connection.status = 'confirmed' + connection.confirmedAt = new Date() + await connection.save() + + // Get initiator name for activity log + const initiator = await Member.findById(connection.initiator) + .select('name') + .lean() + + logActivity(memberId, 'connection_confirmed', { memberName: initiator?.name || 'Unknown' }) + logActivity(connection.initiator, 'connection_confirmed', { memberName: member.name }) + + return { connection } +}) diff --git a/server/api/connections/[id]/hide.post.js b/server/api/connections/[id]/hide.post.js new file mode 100644 index 0000000..680495a --- /dev/null +++ b/server/api/connections/[id]/hide.post.js @@ -0,0 +1,48 @@ +import mongoose from 'mongoose' +import Connection from '../../../models/connection.js' +import { requireAuth } from '../../../utils/auth.js' + +export default defineEventHandler(async (event) => { + const member = await requireAuth(event) + const memberId = member._id + const connectionId = getRouterParam(event, 'id') + + if (!mongoose.Types.ObjectId.isValid(connectionId)) { + throw createError({ + statusCode: 400, + statusMessage: 'Invalid connection ID' + }) + } + + const connection = await Connection.findById(connectionId) + if (!connection) { + throw createError({ + statusCode: 404, + statusMessage: 'Connection not found' + }) + } + + // Either party can hide + const isParty = + connection.initiator.toString() === memberId.toString() || + connection.recipient.toString() === memberId.toString() + + if (!isParty) { + throw createError({ + statusCode: 403, + statusMessage: 'Not authorized to hide this connection' + }) + } + + // Add to hiddenBy if not already there + const alreadyHidden = connection.hiddenBy.some( + id => id.toString() === memberId.toString() + ) + + if (!alreadyHidden) { + connection.hiddenBy.push(memberId) + await connection.save() + } + + return { success: true } +}) diff --git a/server/api/connections/[id]/withdraw.post.js b/server/api/connections/[id]/withdraw.post.js new file mode 100644 index 0000000..ce52b43 --- /dev/null +++ b/server/api/connections/[id]/withdraw.post.js @@ -0,0 +1,43 @@ +import mongoose from 'mongoose' +import Connection from '../../../models/connection.js' +import { requireAuth } from '../../../utils/auth.js' + +export default defineEventHandler(async (event) => { + const member = await requireAuth(event) + const memberId = member._id + const connectionId = getRouterParam(event, 'id') + + if (!mongoose.Types.ObjectId.isValid(connectionId)) { + throw createError({ + statusCode: 400, + statusMessage: 'Invalid connection ID' + }) + } + + const connection = await Connection.findById(connectionId) + if (!connection) { + throw createError({ + statusCode: 404, + statusMessage: 'Connection not found' + }) + } + + // Only the initiator can withdraw + if (connection.initiator.toString() !== memberId.toString()) { + throw createError({ + statusCode: 403, + statusMessage: 'Only the initiator can withdraw a connection request' + }) + } + + if (connection.status !== 'pending') { + throw createError({ + statusCode: 400, + statusMessage: 'Can only withdraw pending connections' + }) + } + + await Connection.findByIdAndDelete(connectionId) + + return { success: true } +}) diff --git a/server/api/connections/index.get.js b/server/api/connections/index.get.js new file mode 100644 index 0000000..584723f --- /dev/null +++ b/server/api/connections/index.get.js @@ -0,0 +1,45 @@ +import Connection from '../../models/connection.js' +import { requireAuth } from '../../utils/auth.js' + +export default defineEventHandler(async (event) => { + const member = await requireAuth(event) + const memberId = member._id + + const [confirmed, pendingOutgoing, pendingIncoming] = await Promise.all([ + Connection.find({ + status: 'confirmed', + hiddenBy: { $ne: memberId }, + $or: [ + { initiator: memberId }, + { recipient: memberId } + ] + }) + .populate('initiator recipient', 'name avatar craftTags circle') + .sort({ confirmedAt: -1 }) + .lean(), + + Connection.find({ + initiator: memberId, + status: 'pending', + hiddenBy: { $ne: memberId } + }) + .populate('recipient', 'name avatar craftTags circle') + .sort({ createdAt: -1 }) + .lean(), + + Connection.find({ + recipient: memberId, + status: 'pending', + hiddenBy: { $ne: memberId } + }) + .populate('initiator', 'name avatar craftTags circle') + .sort({ createdAt: -1 }) + .lean() + ]) + + return { + confirmed, + pendingOutgoing, + pendingIncoming + } +}) diff --git a/server/api/connections/index.post.js b/server/api/connections/index.post.js new file mode 100644 index 0000000..549f7cf --- /dev/null +++ b/server/api/connections/index.post.js @@ -0,0 +1,108 @@ +import mongoose from 'mongoose' +import Member from '../../models/member.js' +import Connection from '../../models/connection.js' +import { requireAuth } from '../../utils/auth.js' + +export default defineEventHandler(async (event) => { + const member = await requireAuth(event) + const memberId = member._id + + const body = await readBody(event) + const { recipientId } = body || {} + + if (!recipientId) { + throw createError({ + statusCode: 400, + statusMessage: 'recipientId is required' + }) + } + + if (!mongoose.Types.ObjectId.isValid(recipientId)) { + throw createError({ + statusCode: 400, + statusMessage: 'Invalid recipientId' + }) + } + + if (recipientId === memberId.toString()) { + throw createError({ + statusCode: 400, + statusMessage: 'Cannot connect with yourself' + }) + } + + // Verify recipient exists and is active + const recipient = await Member.findById(recipientId).lean() + if (!recipient || recipient.status !== 'active') { + throw createError({ + statusCode: 404, + statusMessage: 'Recipient not found or not active' + }) + } + + // Check for existing connection in either direction + const existing = await Connection.findOne({ + $or: [ + { initiator: memberId, recipient: recipientId }, + { initiator: recipientId, recipient: memberId } + ] + }) + + if (existing) { + // If reverse pending connection exists, auto-confirm + if ( + existing.status === 'pending' && + existing.initiator.toString() === recipientId && + existing.recipient.toString() === memberId.toString() + ) { + existing.status = 'confirmed' + existing.confirmedAt = new Date() + await existing.save() + + logActivity(memberId, 'connection_confirmed', { memberName: recipient.name }) + logActivity(recipientId, 'connection_confirmed', { memberName: member.name }) + + return { + connection: existing, + autoConfirmed: true + } + } + + throw createError({ + statusCode: 409, + statusMessage: 'Connection already exists' + }) + } + + // Snapshot matching tags between the two members + const myTopics = member.communityConnections?.topics || [] + const theirTopics = recipient.communityConnections?.topics || [] + const myTopicMap = {} + for (const t of myTopics) { + myTopicMap[t.tagSlug] = t.state + } + + const matchingTags = [] + for (const t of theirTopics) { + const myState = myTopicMap[t.tagSlug] + if (myState) { + matchingTags.push({ + tagSlug: t.tagSlug, + initiatorState: myState, + recipientState: t.state + }) + } + } + + const connection = await Connection.create({ + initiator: memberId, + recipient: recipientId, + status: 'pending', + matchingTags + }) + + logActivity(memberId, 'connection_requested', { memberName: recipient.name }) + logActivity(recipientId, 'connection_requested', { memberName: member.name }) + + return { connection } +}) diff --git a/server/api/connections/pending-count.get.js b/server/api/connections/pending-count.get.js new file mode 100644 index 0000000..58e403c --- /dev/null +++ b/server/api/connections/pending-count.get.js @@ -0,0 +1,14 @@ +import Connection from '../../models/connection.js' +import { requireAuth } from '../../utils/auth.js' + +export default defineEventHandler(async (event) => { + const member = await requireAuth(event) + + const count = await Connection.countDocuments({ + recipient: member._id, + status: 'pending', + hiddenBy: { $ne: member._id } + }) + + return { count } +}) diff --git a/server/api/connections/suggestions.get.js b/server/api/connections/suggestions.get.js new file mode 100644 index 0000000..b0684b6 --- /dev/null +++ b/server/api/connections/suggestions.get.js @@ -0,0 +1,130 @@ +import Member from '../../models/member.js' +import Connection from '../../models/connection.js' +import { requireAuth } from '../../utils/auth.js' + +export default defineEventHandler(async (event) => { + const member = await requireAuth(event) + const memberId = member._id + + const topics = member.communityConnections?.topics || [] + if (!topics.length) { + return { suggestions: [] } + } + + const query = getQuery(event) + const filterTag = query.tag || null + const filterState = query.state || null + + // Build the set of tag slugs to match against + let myTopics = topics + if (filterTag) { + myTopics = myTopics.filter(t => t.tagSlug === filterTag) + } + if (filterState) { + myTopics = myTopics.filter(t => t.state === filterState) + } + if (!myTopics.length) { + return { suggestions: [] } + } + + const mySlugs = myTopics.map(t => t.tagSlug) + + // Find active members sharing at least one topic slug + const candidates = await Member.find({ + _id: { $ne: memberId }, + status: 'active', + 'communityConnections.topics.tagSlug': { $in: mySlugs } + }) + .select('name avatar craftTags circle communityConnections privacy') + .lean() + + if (!candidates.length) { + return { suggestions: [] } + } + + const candidateIds = candidates.map(c => c._id) + + // Find existing connections (pending or confirmed) to exclude + const existingConnections = await Connection.find({ + $or: [ + { initiator: memberId, recipient: { $in: candidateIds } }, + { recipient: memberId, initiator: { $in: candidateIds } } + ] + }) + .select('initiator recipient hiddenBy status') + .lean() + + // Build sets for exclusion + const excludeIds = new Set() + for (const conn of existingConnections) { + const otherId = conn.initiator.toString() === memberId.toString() + ? conn.recipient.toString() + : conn.initiator.toString() + + // Exclude if confirmed or pending connection exists + if (conn.status === 'confirmed' || conn.status === 'pending') { + excludeIds.add(otherId) + } + // Exclude if current member has hidden this connection + if (conn.hiddenBy?.some(id => id.toString() === memberId.toString())) { + excludeIds.add(otherId) + } + } + + // Build topic lookup for current member (using filtered topics) + const myTopicMap = {} + for (const t of myTopics) { + myTopicMap[t.tagSlug] = t.state + } + + // Compute suggestions + const suggestions = [] + for (const candidate of candidates) { + if (excludeIds.has(candidate._id.toString())) continue + + const theirTopics = candidate.communityConnections?.topics || [] + const matchingTags = [] + + for (const theirTopic of theirTopics) { + const myState = myTopicMap[theirTopic.tagSlug] + if (!myState) continue + + matchingTags.push({ + tagSlug: theirTopic.tagSlug, + yourState: myState, + theirState: theirTopic.state + }) + } + + if (!matchingTags.length) continue + + // Apply privacy filtering — only expose fields the member allows for other members + const privacy = candidate.privacy || {} + const filtered = { + _id: candidate._id, + name: candidate.name, + circle: candidate.circle, + } + + const avatarPrivacy = privacy.avatar || 'public' + if (avatarPrivacy === 'public' || avatarPrivacy === 'members') { + filtered.avatar = candidate.avatar + } + + const craftTagsPrivacy = privacy.craftTags || 'members' + if (craftTagsPrivacy === 'public' || craftTagsPrivacy === 'members') { + filtered.craftTags = candidate.craftTags + } + + suggestions.push({ + member: filtered, + matchingTags, + matchCount: matchingTags.length + }) + } + + // Sort by overlap count descending + suggestions.sort((a, b) => b.matchCount - a.matchCount) + + return { suggestions } +})