Tests, UX improvements.

This commit is contained in:
Jennie Robinson Faber 2026-04-05 14:25:29 +01:00
parent 4e6f5d36b8
commit 0ae18f495e
63 changed files with 1384 additions and 2330 deletions

View file

@ -2,7 +2,7 @@ import Member from '../../../models/member.js'
import { connectDB } from '../../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const admin = await requireAdmin(event)
const body = await validateBody(event, adminMemberUpdateSchema)
const memberId = getRouterParam(event, 'id')
@ -30,6 +30,29 @@ export default defineEventHandler(async (event) => {
status: body.status,
}, { new: true })
// Log admin profile update
const changedFields = []
if (existing.name !== body.name) changedFields.push('name')
if (existing.email !== body.email) changedFields.push('email')
if (existing.circle !== body.circle) changedFields.push('circle')
if (existing.contributionTier !== body.contributionTier) changedFields.push('contributionTier')
if (existing.status !== body.status) changedFields.push('status')
if (changedFields.length) {
logActivity(memberId, 'admin_profile_update', {
fields: changedFields,
changedBy: admin.name
}, { performedBy: admin._id })
}
// Log status change separately for admin-only visibility
if (existing.status !== body.status) {
logActivity(memberId, 'status_changed', {
from: existing.status,
to: body.status
}, { performedBy: admin._id })
}
return {
_id: updated._id,
name: updated.name,

View file

@ -0,0 +1,28 @@
import ActivityLog from '../../../../models/activityLog.js'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const id = getRouterParam(event, 'id')
const query = getQuery(event)
const limit = Math.min(parseInt(query.limit) || 20, 50)
const before = query.before ? new Date(query.before) : null
const filter = { member: id }
if (before) filter.timestamp = { $lt: before }
const entries = await ActivityLog.find(filter)
.sort({ timestamp: -1 })
.limit(limit + 1)
.lean()
const hasMore = entries.length > limit
if (hasMore) entries.pop()
const nextCursor = hasMore && entries.length
? entries[entries.length - 1].timestamp.toISOString()
: null
return { entries, hasMore, nextCursor }
})

View file

@ -18,18 +18,25 @@ export default defineEventHandler(async (event) => {
})
}
const member = await Member.findByIdAndUpdate(
memberId,
{ role },
{ new: true }
)
if (!member) {
const existing = await Member.findById(memberId)
if (!existing) {
throw createError({
statusCode: 404,
statusMessage: 'Member not found.'
})
}
const oldRole = existing.role
const member = await Member.findByIdAndUpdate(
memberId,
{ role },
{ new: true }
)
logActivity(memberId, 'role_changed', {
from: oldRole,
to: role
}, { performedBy: admin._id })
return { success: true, member }
})

View file

@ -100,6 +100,12 @@ export default defineEventHandler(async (event) => {
{ runValidators: false }
)
logActivity(member._id, 'email_sent', {
emailType: 'invite',
subject: "You're invited to Ghost Guild",
body: emailText
})
results.push({ memberId: member._id, email: member.email, success: true })
} catch (err) {
results.push({ memberId: member._id, email: member.email, success: false, error: err.message })

View file

@ -52,19 +52,23 @@ export default defineEventHandler(async (event) => {
// Token goes in the fragment — never sent to server, never logged
const magicLink = `${baseUrl}/verify#${token}`;
const emailSubject = "Your Ghost Guild login link";
const emailBody = `Hi,\n\nSign in to Ghost Guild:\n${magicLink}\n\nThis link expires in 15 minutes. If you didn't request it, ignore this email.`;
try {
await resend.emails.send({
from: "Ghost Guild <ghostguild@babyghosts.org>",
to: email,
subject: "Your Ghost Guild login link",
text: `Hi,
Sign in to Ghost Guild:
${magicLink}
This link expires in 15 minutes. If you didn't request it, ignore this email.`,
subject: emailSubject,
text: emailBody,
});
logActivity(member._id, 'email_sent', {
emailType: 'magic_link',
subject: emailSubject,
body: emailBody
})
return {
success: true,
message: GENERIC_MESSAGE,

View file

@ -1,4 +1,5 @@
import Event from "../../../models/event";
import Member from "../../../models/member";
import {
sendEventCancellationEmail,
sendWaitlistNotificationEmail,
@ -56,19 +57,40 @@ export default defineEventHandler(async (event) => {
$pull: { registrations: { email: registration.email } },
$inc: { registeredCount: -1 },
},
{ runValidators: false }
{ runValidators: false },
);
// Send cancellation confirmation email
// Log activity + send cancellation confirmation email
const cancellingMember = await Member.findOne({
email: registration.email,
}).lean();
if (cancellingMember) {
logActivity(cancellingMember._id, 'event_cancelled', {
eventId: eventDoc._id,
eventTitle: eventDoc.title,
eventSlug: eventDoc.slug
})
}
try {
const eventData = {
title: eventDoc.title,
slug: eventDoc.slug,
_id: eventDoc._id,
};
await sendEventCancellationEmail(registration, eventData);
const shouldSendCancellation =
!cancellingMember || cancellingMember.notifications?.events !== false;
if (shouldSendCancellation) {
const eventData = {
title: eventDoc.title,
slug: eventDoc.slug,
_id: eventDoc._id,
};
await sendEventCancellationEmail(registration, eventData);
if (cancellingMember) {
logActivity(cancellingMember._id, 'email_sent', {
emailType: 'event_cancellation',
subject: `Registration cancelled for ${eventDoc.title}`
})
}
}
} catch (emailError) {
// Log error but don't fail the cancellation
console.error("Failed to send cancellation email:", emailError);
}
@ -89,18 +111,30 @@ export default defineEventHandler(async (event) => {
// Notify the first person on the waitlist who hasn't been notified yet
const waitlistEntry = eventDoc.tickets.waitlist.entries.find(
(entry) => !entry.notified
(entry) => !entry.notified,
);
if (waitlistEntry) {
await sendWaitlistNotificationEmail(waitlistEntry, eventData);
// Mark as notified using findByIdAndUpdate to avoid re-validating the document
const entryIndex = eventDoc.tickets.waitlist.entries.indexOf(waitlistEntry);
const waitlistedMember = await Member.findOne({
email: waitlistEntry.email,
}).lean();
const shouldNotifyWaitlist =
!waitlistedMember ||
waitlistedMember.notifications?.events !== false;
if (shouldNotifyWaitlist) {
await sendWaitlistNotificationEmail(waitlistEntry, eventData);
}
// Always mark as notified so we move on regardless
const entryIndex =
eventDoc.tickets.waitlist.entries.indexOf(waitlistEntry);
await Event.findByIdAndUpdate(
eventDoc._id,
{ $set: { [`tickets.waitlist.entries.${entryIndex}.notified`]: true } },
{ runValidators: false }
{
$set: {
[`tickets.waitlist.entries.${entryIndex}.notified`]: true,
},
},
{ runValidators: false },
);
}
} catch (waitlistError) {

View file

@ -113,16 +113,35 @@ export default defineEventHandler(async (event) => {
const result = await Event.findByIdAndUpdate(
eventData._id,
{ $push: { registrations: registration } },
{ new: true, runValidators: false }
{ new: true, runValidators: false },
);
const newRegistration = result.registrations[result.registrations.length - 1];
const newRegistration =
result.registrations[result.registrations.length - 1];
// Send confirmation email using Resend
try {
await sendEventRegistrationEmail(registration, eventData);
} catch (emailError) {
// Log error but don't fail the registration
console.error("Failed to send confirmation email:", emailError);
// Log activity
if (member) {
logActivity(member._id, 'event_registered', {
eventId: eventData._id,
eventTitle: eventData.title,
eventSlug: eventData.slug
})
}
// Send confirmation email — respect member notification preferences
const shouldSendEventEmail =
!member || member.notifications?.events !== false;
if (shouldSendEventEmail) {
try {
await sendEventRegistrationEmail(registration, eventData);
if (member) {
logActivity(member._id, 'email_sent', {
emailType: 'event_registration',
subject: `You're registered for ${eventData.title}`
})
}
} catch (emailError) {
console.error("Failed to send confirmation email:", emailError);
}
}
return {

View file

@ -91,9 +91,11 @@ export default defineEventHandler(async (event) => {
{ new: true }
)
logActivity(member._id, 'subscription_created', { tier: body.contributionTier })
// Send Slack invitation for free tier members
await inviteToSlack(member)
return {
success: true,
subscription: null,
@ -262,6 +264,8 @@ export default defineEventHandler(async (event) => {
{ new: true }
)
logActivity(member._id, 'subscription_created', { tier: body.contributionTier })
// Send Slack invitation for paid tier members
await inviteToSlack(member)

View file

@ -0,0 +1,43 @@
import Member from '../../../models/member.js'
import ActivityLog from '../../../models/activityLog.js'
import { connectDB } from '../../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
await connectDB()
const id = getRouterParam(event, 'id')
const member = await Member.findOne({
_id: id,
showInDirectory: true,
status: 'active'
}).lean()
if (!member) {
throw createError({ statusCode: 404, statusMessage: 'Member not found' })
}
const query = getQuery(event)
const limit = Math.min(parseInt(query.limit) || 5, 20)
const before = query.before ? new Date(query.before) : null
const filter = {
member: member._id,
visibility: 'public'
}
if (before) filter.timestamp = { $lt: before }
const entries = await ActivityLog.find(filter)
.sort({ timestamp: -1 })
.limit(limit + 1)
.lean()
const hasMore = entries.length > limit
if (hasMore) entries.pop()
const nextCursor = hasMore && entries.length
? entries[entries.length - 1].timestamp.toISOString()
: null
return { entries, hasMore, nextCursor }
})

View file

@ -62,6 +62,10 @@ export default defineEventHandler(async (event) => {
{ runValidators: false }
);
logActivity(member._id, 'subscription_cancelled', {
effectiveDate: new Date().toISOString()
})
return {
success: true,
message: "Subscription cancelled successfully",

View file

@ -100,10 +100,15 @@ export default defineEventHandler(async (event) => {
const member = new Member(validatedData)
await member.save()
// Log member joined
logActivity(member._id, 'member_joined', {
circle: member.circle
}, { timestamp: member.createdAt })
// Send Slack invitation for new members
await inviteToSlack(member)
// TODO: Process payment with Helcim if not free tier
if (requiresPayment(validatedData.contributionTier)) {
// Payment processing will be added here
@ -112,6 +117,10 @@ export default defineEventHandler(async (event) => {
// Send welcome email (non-blocking)
try {
await sendWelcomeEmail(member)
logActivity(member._id, 'email_sent', {
emailType: 'welcome',
subject: 'Welcome to Ghost Guild'
})
} catch (emailError) {
console.error('Failed to send welcome email:', emailError)
}

View file

@ -0,0 +1,29 @@
import ActivityLog from '../../../models/activityLog.js'
export default defineEventHandler(async (event) => {
const member = await requireAuth(event)
const query = getQuery(event)
const limit = Math.min(parseInt(query.limit) || 20, 50)
const before = query.before ? new Date(query.before) : null
const filter = {
member: member._id,
visibility: { $in: ['member', 'public'] }
}
if (before) filter.timestamp = { $lt: before }
const entries = await ActivityLog.find(filter)
.sort({ timestamp: -1 })
.limit(limit + 1)
.lean()
const hasMore = entries.length > limit
if (hasMore) entries.pop()
const nextCursor = hasMore && entries.length
? entries[entries.length - 1].timestamp.toISOString()
: null
return { entries, hasMore, nextCursor }
})

View file

@ -75,6 +75,14 @@ export default defineEventHandler(async (event) => {
})
}
if (body.enabled) {
logActivity(member._id, 'peer_support_enabled', {
topics: [...(body.skillTopics || []), ...(body.supportTopics || [])]
})
} else {
logActivity(member._id, 'peer_support_disabled', {})
}
return {
success: true,
peerSupport: updated.peerSupport,

View file

@ -80,6 +80,12 @@ export default defineEventHandler(async (event) => {
});
}
// Log which fields were updated
const changedFields = Object.keys(body).filter(k => body[k] !== undefined && !k.endsWith('Privacy'))
if (changedFields.length) {
logActivity(memberId, 'profile_updated', { fields: changedFields })
}
// Return sanitized member data
return {
id: member._id,

View file

@ -13,12 +13,19 @@ export default defineEventHandler(async (event) => {
return { success: true, message: 'Already in this circle' }
}
const oldCircle = member.circle
await Member.findByIdAndUpdate(
member._id,
{ $set: { circle: body.circle } },
{ runValidators: false }
)
logActivity(member._id, 'circle_changed', {
from: oldCircle,
to: body.circle
})
return {
success: true,
message: `Circle updated to ${body.circle}`,

View file

@ -26,6 +26,11 @@ export default defineEventHandler(async (event) => {
};
}
// Log contribution change (fire-and-forget, at the top so it logs regardless of which case path executes)
const logContributionChange = () => {
logActivity(member._id, 'contribution_changed', { from: oldTier, to: newTier })
}
const helcimToken = config.helcimApiToken;
const oldRequiresPayment = requiresPayment(oldTier);
const newRequiresPayment = requiresPayment(newTier);
@ -160,6 +165,8 @@ export default defineEventHandler(async (event) => {
{ runValidators: false }
);
logContributionChange()
return {
success: true,
message: "Successfully upgraded to paid tier",
@ -216,6 +223,8 @@ export default defineEventHandler(async (event) => {
{ runValidators: false }
);
logContributionChange()
return {
success: true,
message: "Successfully downgraded to free tier",
@ -279,6 +288,8 @@ export default defineEventHandler(async (event) => {
{ runValidators: false }
);
logContributionChange()
return {
success: true,
message: "Successfully updated contribution level",
@ -300,6 +311,8 @@ export default defineEventHandler(async (event) => {
{ runValidators: false }
);
logContributionChange()
return {
success: true,
message: "Successfully updated contribution level",

View file

@ -62,6 +62,8 @@ export default defineEventHandler(async (event) => {
{ runValidators: false }
)
logActivity(member._id, 'email_changed', { previousEmail: oldEmail })
return {
success: true,
email: newEmail,

View file

@ -139,6 +139,12 @@ export default defineEventHandler(async (event) => {
})),
paymentId,
});
if (member) {
logActivity(member._id, 'email_sent', {
emailType: 'series_pass',
subject: `Series pass: ${series.title}`
})
}
} catch (emailError) {
console.error(
"Failed to send series pass confirmation email:",

View file

@ -1,38 +0,0 @@
import Update from "../../models/update.js";
export default defineEventHandler(async (event) => {
const member = await requireAuth(event);
const memberId = member._id.toString();
const id = getRouterParam(event, "id");
try {
const update = await Update.findById(id);
if (!update) {
throw createError({
statusCode: 404,
statusMessage: "Update not found",
});
}
// Check if user is the author
if (update.author.toString() !== memberId) {
throw createError({
statusCode: 403,
statusMessage: "You can only delete your own updates",
});
}
await Update.findByIdAndDelete(id);
return { success: true };
} catch (error) {
if (error.statusCode) throw error;
console.error("Delete update error:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to delete update",
});
}
});

View file

@ -1,51 +0,0 @@
import Update from "../../models/update.js";
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id");
let memberId = null
try {
const member = await requireAuth(event)
memberId = member._id.toString()
} catch {
// Not authenticated — continue with public-only access
}
try {
const update = await Update.findById(id).populate("author", "name avatar");
if (!update) {
throw createError({
statusCode: 404,
statusMessage: "Update not found",
});
}
// Check privacy permissions
if (update.privacy === "private") {
// Only author can view private updates
if (!memberId || update.author._id.toString() !== memberId) {
throw createError({
statusCode: 403,
statusMessage: "You don't have permission to view this update",
});
}
} else if (update.privacy === "members") {
// Must be authenticated to view members-only updates
if (!memberId) {
throw createError({
statusCode: 403,
statusMessage: "You must be a member to view this update",
});
}
}
return update;
} catch (error) {
if (error.statusCode) throw error;
console.error("Get update error:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to fetch update",
});
}
});

View file

@ -1,47 +0,0 @@
import Update from "../../models/update.js";
export default defineEventHandler(async (event) => {
const member = await requireAuth(event);
const memberId = member._id.toString();
const id = getRouterParam(event, "id");
const body = await validateBody(event, updatePatchSchema);
try {
const update = await Update.findById(id);
if (!update) {
throw createError({
statusCode: 404,
statusMessage: "Update not found",
});
}
// Check if user is the author
if (update.author.toString() !== memberId) {
throw createError({
statusCode: 403,
statusMessage: "You can only edit your own updates",
});
}
// Update allowed fields
if (body.content !== undefined) update.content = body.content;
if (body.images !== undefined) update.images = body.images;
if (body.privacy !== undefined) update.privacy = body.privacy;
if (body.commentsEnabled !== undefined)
update.commentsEnabled = body.commentsEnabled;
await update.save();
await update.populate("author", "name avatar");
return update;
} catch (error) {
if (error.statusCode) throw error;
console.error("Update edit error:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to update",
});
}
});

View file

@ -1,47 +0,0 @@
import Update from "../../models/update.js";
export default defineEventHandler(async (event) => {
let memberId = null
try {
const member = await requireAuth(event)
memberId = member._id.toString()
} catch {
// Not authenticated — continue with public-only access
}
const query = getQuery(event);
const limit = parseInt(query.limit) || 20;
const skip = parseInt(query.skip) || 0;
try {
// Build privacy filter
let privacyFilter;
if (!memberId) {
// Not authenticated - only show public updates
privacyFilter = { privacy: "public" };
} else {
// Authenticated member - show public and members-only updates
privacyFilter = { privacy: { $in: ["public", "members"] } };
}
const updates = await Update.find(privacyFilter)
.populate("author", "name avatar")
.sort({ createdAt: -1 })
.limit(limit)
.skip(skip);
const total = await Update.countDocuments(privacyFilter);
return {
updates,
total,
hasMore: skip + limit < total,
};
} catch (error) {
console.error("Get updates error:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to fetch updates",
});
}
});

View file

@ -1,32 +0,0 @@
import Update from "../../models/update.js";
import { validateBody } from "../../utils/validateBody.js";
import { updateCreateSchema } from "../../utils/schemas.js";
export default defineEventHandler(async (event) => {
const member = await requireAuth(event);
const memberId = member._id.toString();
const body = await validateBody(event, updateCreateSchema);
try {
const update = await Update.create({
author: memberId,
content: body.content,
images: body.images || [],
privacy: body.privacy || "members",
commentsEnabled: body.commentsEnabled ?? true,
});
// Populate author details
await update.populate("author", "name avatar");
return update;
} catch (error) {
if (error.statusCode) throw error;
console.error("Create update error:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to create update",
});
}
});

View file

@ -1,32 +0,0 @@
import Update from "../../models/update.js";
export default defineEventHandler(async (event) => {
const member = await requireAuth(event);
const memberId = member._id.toString();
const query = getQuery(event);
const limit = parseInt(query.limit) || 20;
const skip = parseInt(query.skip) || 0;
try {
const updates = await Update.find({ author: memberId })
.populate("author", "name avatar")
.sort({ createdAt: -1 })
.limit(limit)
.skip(skip);
const total = await Update.countDocuments({ author: memberId });
return {
updates,
total,
hasMore: skip + limit < total,
};
} catch (error) {
console.error("Get my updates error:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to fetch updates",
});
}
});

View file

@ -1,67 +0,0 @@
import Update from "../../../models/update.js";
import Member from "../../../models/member.js";
export default defineEventHandler(async (event) => {
const userId = getRouterParam(event, "id");
let currentMemberId = null
try {
const member = await requireAuth(event)
currentMemberId = member._id.toString()
} catch {
// Not authenticated — continue with public-only access
}
const query = getQuery(event);
const limit = parseInt(query.limit) || 20;
const skip = parseInt(query.skip) || 0;
try {
// Verify the user exists
const user = await Member.findById(userId);
if (!user) {
throw createError({
statusCode: 404,
statusMessage: "User not found",
});
}
// Build privacy filter
let privacyFilter;
if (!currentMemberId) {
// Not authenticated - only show public updates
privacyFilter = { author: userId, privacy: "public" };
} else if (currentMemberId === userId) {
// Viewing own updates - show all
privacyFilter = { author: userId };
} else {
// Authenticated member viewing another's updates - show public and members-only
privacyFilter = { author: userId, privacy: { $in: ["public", "members"] } };
}
const updates = await Update.find(privacyFilter)
.populate("author", "name avatar")
.sort({ createdAt: -1 })
.limit(limit)
.skip(skip);
const total = await Update.countDocuments(privacyFilter);
return {
updates,
total,
hasMore: skip + limit < total,
user: {
_id: user._id,
name: user.name,
avatar: user.avatar,
},
};
} catch (error) {
if (error.statusCode) throw error;
console.error("Get user updates error:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to fetch user updates",
});
}
});