diff --git a/app/components/AppNavigation.vue b/app/components/AppNavigation.vue
index 8fef28e..f113c52 100644
--- a/app/components/AppNavigation.vue
+++ b/app/components/AppNavigation.vue
@@ -167,7 +167,7 @@ const youItems = [
{ label: "Dashboard", path: "/member/dashboard" },
{ label: "Profile", path: "/member/profile" },
{ label: "Account", path: "/member/account" },
- { label: "My Updates", path: "/member/my-updates" },
+ { label: "Activity Log", path: "/member/activity" },
];
const exploreItems = [
diff --git a/app/components/SidebarLayout.vue b/app/components/SidebarLayout.vue
new file mode 100644
index 0000000..62f3de7
--- /dev/null
+++ b/app/components/SidebarLayout.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+```
+
+Now let me apply this to each page. Let me update all four in parallel:
diff --git a/app/components/UpdateCard.vue b/app/components/UpdateCard.vue
deleted file mode 100644
index b7772de..0000000
--- a/app/components/UpdateCard.vue
+++ /dev/null
@@ -1,191 +0,0 @@
-
-
-
-
-
-
-
- {{ update.author?.name?.charAt(0)?.toUpperCase() || "?" }}
-
-
-
-
-
-
-
-
-
-
- {{ update.author.name }}
-
- Unknown Member
-
-
-
- {{ formatDate(update.createdAt) }}
-
- (edited)
-
- Private
-
-
- Public
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ update.content.substring(0, 300) }}...
-
- Read more
-
-
-
- {{ update.content }}
-
-
-
-
-
-
-
-
-
-
-
- View full update
-
-
- Comments (coming soon)
-
-
-
-
-
-
-
-
-
-
diff --git a/app/components/UpdateForm.vue b/app/components/UpdateForm.vue
deleted file mode 100644
index c8b9d98..0000000
--- a/app/components/UpdateForm.vue
+++ /dev/null
@@ -1,184 +0,0 @@
-
-
-
-
-
-
-
-
-
Privacy Settings
-
-
-
-
-
Public
-
- Visible to everyone, including non-members
-
-
-
-
-
-
-
-
Members Only
-
- Only visible to Ghost Guild members
-
-
-
-
-
-
-
-
Private
-
Only visible to you
-
-
-
-
-
-
-
-
-
-
-
-
-
Enable Comments
-
- Allow members to comment on this update
-
-
-
-
-
-
-
- Cancel
-
-
-
- {{ submitLabel }}
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/layouts/admin.vue b/app/layouts/admin.vue
index 91d20b6..285d7ab 100644
--- a/app/layouts/admin.vue
+++ b/app/layouts/admin.vue
@@ -2,9 +2,10 @@
Sign in to see full profile details
@@ -206,6 +218,8 @@
-
-
diff --git a/app/pages/updates/new.vue b/app/pages/updates/new.vue
deleted file mode 100644
index 71daa7c..0000000
--- a/app/pages/updates/new.vue
+++ /dev/null
@@ -1,139 +0,0 @@
-
-
-
- ← Back to My Updates
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/utils/activityText.js b/app/utils/activityText.js
new file mode 100644
index 0000000..5cca693
--- /dev/null
+++ b/app/utils/activityText.js
@@ -0,0 +1,93 @@
+const circleLabel = (c) => c ? c.charAt(0).toUpperCase() + c.slice(1) : c
+
+const formatters = {
+ member_joined: (m) => ({
+ text: 'Joined Ghost Guild',
+ icon: 'i-lucide-star'
+ }),
+ event_registered: (m) => ({
+ text: `Registered for ${m.eventTitle || 'an event'}`,
+ icon: 'i-lucide-calendar',
+ link: m.eventSlug ? `/events/${m.eventSlug}` : null,
+ linkText: m.eventTitle
+ }),
+ event_cancelled: (m) => ({
+ text: `Cancelled registration for ${m.eventTitle || 'an event'}`,
+ icon: 'i-lucide-calendar-x',
+ link: m.eventSlug ? `/events/${m.eventSlug}` : null,
+ linkText: m.eventTitle
+ }),
+ event_waitlisted: (m) => ({
+ text: `Joined waitlist for ${m.eventTitle || 'an event'}`,
+ icon: 'i-lucide-calendar-clock',
+ link: null,
+ linkText: null
+ }),
+ peer_support_enabled: (m) => ({
+ text: m.topics?.length
+ ? `Enabled peer support (${m.topics.join(', ')})`
+ : 'Enabled peer support',
+ icon: 'i-lucide-users'
+ }),
+ peer_support_disabled: () => ({
+ text: 'Disabled peer support',
+ icon: 'i-lucide-users'
+ }),
+ circle_changed: (m) => ({
+ text: `Changed circle from ${circleLabel(m.from)} to ${circleLabel(m.to)}`,
+ icon: 'i-lucide-refresh-cw'
+ }),
+ contribution_changed: (m) => ({
+ text: `Changed contribution from $${m.from}/mo to $${m.to}/mo`,
+ icon: 'i-lucide-coins'
+ }),
+ email_changed: (m) => ({
+ text: `Changed email address`,
+ icon: 'i-lucide-mail'
+ }),
+ profile_updated: (m) => ({
+ text: m.fields?.length
+ ? `Updated profile (${m.fields.join(', ')})`
+ : 'Updated profile',
+ icon: 'i-lucide-user-pen'
+ }),
+ subscription_created: (m) => ({
+ text: m.tier ? `Started $${m.tier}/mo subscription` : 'Started subscription',
+ icon: 'i-lucide-credit-card'
+ }),
+ subscription_cancelled: () => ({
+ text: 'Cancelled subscription',
+ icon: 'i-lucide-credit-card'
+ }),
+ status_changed: (m) => ({
+ text: `Status changed from ${m.from} to ${m.to}${m.reason ? ` (${m.reason})` : ''}`,
+ icon: 'i-lucide-shield'
+ }),
+ role_changed: (m) => ({
+ text: `Role changed from ${m.from} to ${m.to}`,
+ icon: 'i-lucide-shield'
+ }),
+ admin_profile_update: (m) => ({
+ text: m.fields?.length
+ ? `Profile updated by admin (${m.fields.join(', ')})`
+ : 'Profile updated by admin',
+ icon: 'i-lucide-user-pen'
+ }),
+ slack_invited: (m) => ({
+ text: `Slack invitation: ${m.status || 'sent'}`,
+ icon: 'i-lucide-message-square'
+ }),
+ email_sent: (m) => ({
+ text: m.subject ? `Email: ${m.subject}` : 'Email sent',
+ icon: 'i-lucide-mail',
+ emailBody: m.body || null
+ })
+}
+
+export function formatActivity(entry) {
+ const formatter = formatters[entry.type]
+ if (!formatter) {
+ return { text: entry.type.replace(/_/g, ' '), icon: 'i-lucide-activity' }
+ }
+ return formatter(entry.metadata || {})
+}
diff --git a/e2e/__screenshots__/visual/pages.spec.js-snapshots/admin-events-create-desktop-chromium-darwin.png b/e2e/__screenshots__/visual/pages.spec.js-snapshots/admin-events-create-desktop-chromium-darwin.png
new file mode 100644
index 0000000..75685fb
Binary files /dev/null and b/e2e/__screenshots__/visual/pages.spec.js-snapshots/admin-events-create-desktop-chromium-darwin.png differ
diff --git a/e2e/__screenshots__/visual/pages.spec.js-snapshots/admin-members-desktop-chromium-darwin.png b/e2e/__screenshots__/visual/pages.spec.js-snapshots/admin-members-desktop-chromium-darwin.png
new file mode 100644
index 0000000..01f4a3d
Binary files /dev/null and b/e2e/__screenshots__/visual/pages.spec.js-snapshots/admin-members-desktop-chromium-darwin.png differ
diff --git a/e2e/__screenshots__/visual/pages.spec.js-snapshots/coming-soon-desktop-chromium-darwin.png b/e2e/__screenshots__/visual/pages.spec.js-snapshots/coming-soon-desktop-chromium-darwin.png
new file mode 100644
index 0000000..a2a5f90
Binary files /dev/null and b/e2e/__screenshots__/visual/pages.spec.js-snapshots/coming-soon-desktop-chromium-darwin.png differ
diff --git a/e2e/__screenshots__/visual/pages.spec.js-snapshots/coming-soon-mobile-chromium-darwin.png b/e2e/__screenshots__/visual/pages.spec.js-snapshots/coming-soon-mobile-chromium-darwin.png
new file mode 100644
index 0000000..ef37676
Binary files /dev/null and b/e2e/__screenshots__/visual/pages.spec.js-snapshots/coming-soon-mobile-chromium-darwin.png differ
diff --git a/e2e/__screenshots__/visual/pages.spec.js-snapshots/events-desktop-chromium-darwin.png b/e2e/__screenshots__/visual/pages.spec.js-snapshots/events-desktop-chromium-darwin.png
new file mode 100644
index 0000000..777fab4
Binary files /dev/null and b/e2e/__screenshots__/visual/pages.spec.js-snapshots/events-desktop-chromium-darwin.png differ
diff --git a/e2e/__screenshots__/visual/pages.spec.js-snapshots/events-mobile-chromium-darwin.png b/e2e/__screenshots__/visual/pages.spec.js-snapshots/events-mobile-chromium-darwin.png
new file mode 100644
index 0000000..822b4a0
Binary files /dev/null and b/e2e/__screenshots__/visual/pages.spec.js-snapshots/events-mobile-chromium-darwin.png differ
diff --git a/e2e/__screenshots__/visual/pages.spec.js-snapshots/home-desktop-chromium-darwin.png b/e2e/__screenshots__/visual/pages.spec.js-snapshots/home-desktop-chromium-darwin.png
new file mode 100644
index 0000000..7e1899e
Binary files /dev/null and b/e2e/__screenshots__/visual/pages.spec.js-snapshots/home-desktop-chromium-darwin.png differ
diff --git a/e2e/__screenshots__/visual/pages.spec.js-snapshots/home-mobile-chromium-darwin.png b/e2e/__screenshots__/visual/pages.spec.js-snapshots/home-mobile-chromium-darwin.png
new file mode 100644
index 0000000..8356210
Binary files /dev/null and b/e2e/__screenshots__/visual/pages.spec.js-snapshots/home-mobile-chromium-darwin.png differ
diff --git a/e2e/__screenshots__/visual/pages.spec.js-snapshots/join-desktop-chromium-darwin.png b/e2e/__screenshots__/visual/pages.spec.js-snapshots/join-desktop-chromium-darwin.png
new file mode 100644
index 0000000..9de8ccf
Binary files /dev/null and b/e2e/__screenshots__/visual/pages.spec.js-snapshots/join-desktop-chromium-darwin.png differ
diff --git a/e2e/__screenshots__/visual/pages.spec.js-snapshots/join-mobile-chromium-darwin.png b/e2e/__screenshots__/visual/pages.spec.js-snapshots/join-mobile-chromium-darwin.png
new file mode 100644
index 0000000..2fcc0d0
Binary files /dev/null and b/e2e/__screenshots__/visual/pages.spec.js-snapshots/join-mobile-chromium-darwin.png differ
diff --git a/e2e/__screenshots__/visual/pages.spec.js-snapshots/member-dashboard-desktop-chromium-darwin.png b/e2e/__screenshots__/visual/pages.spec.js-snapshots/member-dashboard-desktop-chromium-darwin.png
new file mode 100644
index 0000000..208517f
Binary files /dev/null and b/e2e/__screenshots__/visual/pages.spec.js-snapshots/member-dashboard-desktop-chromium-darwin.png differ
diff --git a/e2e/__screenshots__/visual/pages.spec.js-snapshots/member-profile-desktop-chromium-darwin.png b/e2e/__screenshots__/visual/pages.spec.js-snapshots/member-profile-desktop-chromium-darwin.png
new file mode 100644
index 0000000..5e2a693
Binary files /dev/null and b/e2e/__screenshots__/visual/pages.spec.js-snapshots/member-profile-desktop-chromium-darwin.png differ
diff --git a/e2e/updates.spec.js b/e2e/updates.spec.js
deleted file mode 100644
index 21df40d..0000000
--- a/e2e/updates.spec.js
+++ /dev/null
@@ -1,123 +0,0 @@
-import { test, expect } from './helpers/fixtures.js'
-
-test.describe('My Updates page', () => {
- test('authenticated user sees the my-updates page', async ({ adminPage }) => {
- await adminPage.goto('/member/my-updates')
-
- await expect(adminPage.locator('h1', { hasText: 'My Updates' })).toBeVisible({
- timeout: 10000,
- })
- })
-
- test('authenticated user sees the new update link', async ({ adminPage }) => {
- await adminPage.goto('/member/my-updates')
-
- // Wait for ClientOnly content to hydrate
- await expect(adminPage.locator('h1', { hasText: 'My Updates' })).toBeVisible({
- timeout: 10000,
- })
-
- // The page shows either the "+ New Update" button (stats row) or
- // the "+ Post Your First Update" link (empty state) — both go to /updates/new
- const newUpdateLink = adminPage.locator('a[href="/updates/new"]')
- await expect(newUpdateLink.first()).toBeVisible({ timeout: 10000 })
- })
-
- test('unauthenticated user sees sign-in prompt', async ({ browser }) => {
- const context = await browser.newContext()
- const page = await context.newPage()
-
- await page.goto('/member/my-updates')
-
- await expect(
- page
- .getByText('Sign in required')
- .or(page.getByText('Sign in to view your updates'))
- ).toBeVisible({ timeout: 10000 })
-
- await context.close()
- })
-})
-
-test.describe('New Update page', () => {
- test('loads the new update form', async ({ adminPage }) => {
- await adminPage.goto('/updates/new')
-
- await expect(adminPage.locator('h1', { hasText: 'New Update' })).toBeVisible({
- timeout: 10000,
- })
-
- // Form elements are present
- await expect(adminPage.locator('textarea')).toBeVisible()
- await expect(adminPage.locator('select')).toBeVisible()
-
- // Submit button exists and starts disabled (empty textarea)
- const submitBtn = adminPage.locator('button[type="submit"]')
- await expect(submitBtn).toBeVisible()
- await expect(submitBtn).toBeDisabled()
- })
-
- test('submit button enables when content is entered', async ({ adminPage }) => {
- await adminPage.goto('/updates/new')
-
- await expect(adminPage.locator('h1', { hasText: 'New Update' })).toBeVisible({
- timeout: 10000,
- })
-
- const textarea = adminPage.locator('textarea')
- const submitBtn = adminPage.locator('button[type="submit"]')
-
- await expect(submitBtn).toBeDisabled()
- await textarea.fill('Test update content')
- await expect(submitBtn).toBeEnabled()
- })
-
- test('privacy selector defaults to members and has all options', async ({ adminPage }) => {
- await adminPage.goto('/updates/new')
-
- await expect(adminPage.locator('h1', { hasText: 'New Update' })).toBeVisible({
- timeout: 10000,
- })
-
- const select = adminPage.locator('select')
- await expect(select).toHaveValue('members')
-
- // Verify all three privacy options exist
- await expect(select.locator('option[value="members"]')).toBeAttached()
- await expect(select.locator('option[value="public"]')).toBeAttached()
- await expect(select.locator('option[value="private"]')).toBeAttached()
- })
-
- test('cancel link navigates back to my-updates', async ({ adminPage }) => {
- await adminPage.goto('/updates/new')
-
- await expect(adminPage.locator('h1', { hasText: 'New Update' })).toBeVisible({
- timeout: 10000,
- })
-
- const cancelLink = adminPage.locator('a', { hasText: 'Cancel' })
- await expect(cancelLink).toHaveAttribute('href', '/member/my-updates')
- })
-
- test('back link points to my-updates', async ({ adminPage }) => {
- await adminPage.goto('/updates/new')
-
- const backLink = adminPage.locator('.back-link a')
- await expect(backLink).toBeVisible({ timeout: 10000 })
- await expect(backLink).toHaveAttribute('href', '/member/my-updates')
- })
-})
-
-test.describe('Updates API (public access)', () => {
- test('public updates endpoint returns data', async ({ page }) => {
- const response = await page.request.get('/api/updates')
-
- expect(response.ok()).toBe(true)
-
- const data = await response.json()
- expect(data).toHaveProperty('updates')
- expect(data).toHaveProperty('total')
- expect(data).toHaveProperty('hasMore')
- expect(Array.isArray(data.updates)).toBe(true)
- })
-})
diff --git a/server/api/admin/members/[id].put.js b/server/api/admin/members/[id].put.js
index fa5aac2..826fe76 100644
--- a/server/api/admin/members/[id].put.js
+++ b/server/api/admin/members/[id].put.js
@@ -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,
diff --git a/server/api/admin/members/[id]/activity.get.js b/server/api/admin/members/[id]/activity.get.js
new file mode 100644
index 0000000..7719b4b
--- /dev/null
+++ b/server/api/admin/members/[id]/activity.get.js
@@ -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 }
+})
diff --git a/server/api/admin/members/[id]/role.patch.js b/server/api/admin/members/[id]/role.patch.js
index 652f64e..c79bcef 100644
--- a/server/api/admin/members/[id]/role.patch.js
+++ b/server/api/admin/members/[id]/role.patch.js
@@ -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 }
})
diff --git a/server/api/admin/members/invite.post.js b/server/api/admin/members/invite.post.js
index 8918fb3..f2f2f0d 100644
--- a/server/api/admin/members/invite.post.js
+++ b/server/api/admin/members/invite.post.js
@@ -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 })
diff --git a/server/api/auth/login.post.js b/server/api/auth/login.post.js
index cf3bf34..d710d28 100644
--- a/server/api/auth/login.post.js
+++ b/server/api/auth/login.post.js
@@ -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
",
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,
diff --git a/server/api/events/[id]/cancel-registration.post.js b/server/api/events/[id]/cancel-registration.post.js
index db20c62..4138f0f 100644
--- a/server/api/events/[id]/cancel-registration.post.js
+++ b/server/api/events/[id]/cancel-registration.post.js
@@ -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) {
diff --git a/server/api/events/[id]/register.post.js b/server/api/events/[id]/register.post.js
index e82b845..a49a737 100644
--- a/server/api/events/[id]/register.post.js
+++ b/server/api/events/[id]/register.post.js
@@ -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 {
diff --git a/server/api/helcim/subscription.post.js b/server/api/helcim/subscription.post.js
index 1bb9f32..f7d214f 100644
--- a/server/api/helcim/subscription.post.js
+++ b/server/api/helcim/subscription.post.js
@@ -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)
diff --git a/server/api/members/[id]/activity.get.js b/server/api/members/[id]/activity.get.js
new file mode 100644
index 0000000..c1f1397
--- /dev/null
+++ b/server/api/members/[id]/activity.get.js
@@ -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 }
+})
diff --git a/server/api/members/cancel-subscription.post.js b/server/api/members/cancel-subscription.post.js
index 83c5b04..6f6b405 100644
--- a/server/api/members/cancel-subscription.post.js
+++ b/server/api/members/cancel-subscription.post.js
@@ -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",
diff --git a/server/api/members/create.post.js b/server/api/members/create.post.js
index 15ee5e7..33b1fed 100644
--- a/server/api/members/create.post.js
+++ b/server/api/members/create.post.js
@@ -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)
}
diff --git a/server/api/members/me/activity.get.js b/server/api/members/me/activity.get.js
new file mode 100644
index 0000000..bcca811
--- /dev/null
+++ b/server/api/members/me/activity.get.js
@@ -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 }
+})
diff --git a/server/api/members/me/peer-support.patch.js b/server/api/members/me/peer-support.patch.js
index c3d51ea..b56b27b 100644
--- a/server/api/members/me/peer-support.patch.js
+++ b/server/api/members/me/peer-support.patch.js
@@ -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,
diff --git a/server/api/members/profile.patch.js b/server/api/members/profile.patch.js
index 5e9946a..dd70ac1 100644
--- a/server/api/members/profile.patch.js
+++ b/server/api/members/profile.patch.js
@@ -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,
diff --git a/server/api/members/update-circle.post.js b/server/api/members/update-circle.post.js
index c3a685b..ac0822f 100644
--- a/server/api/members/update-circle.post.js
+++ b/server/api/members/update-circle.post.js
@@ -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}`,
diff --git a/server/api/members/update-contribution.post.js b/server/api/members/update-contribution.post.js
index 92e76cb..5627340 100644
--- a/server/api/members/update-contribution.post.js
+++ b/server/api/members/update-contribution.post.js
@@ -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",
diff --git a/server/api/members/update-email.post.js b/server/api/members/update-email.post.js
index ed41d2e..4b41fd1 100644
--- a/server/api/members/update-email.post.js
+++ b/server/api/members/update-email.post.js
@@ -62,6 +62,8 @@ export default defineEventHandler(async (event) => {
{ runValidators: false }
)
+ logActivity(member._id, 'email_changed', { previousEmail: oldEmail })
+
return {
success: true,
email: newEmail,
diff --git a/server/api/series/[id]/tickets/purchase.post.js b/server/api/series/[id]/tickets/purchase.post.js
index 740a752..227c183 100644
--- a/server/api/series/[id]/tickets/purchase.post.js
+++ b/server/api/series/[id]/tickets/purchase.post.js
@@ -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:",
diff --git a/server/api/updates/[id].delete.js b/server/api/updates/[id].delete.js
deleted file mode 100644
index 9b899ae..0000000
--- a/server/api/updates/[id].delete.js
+++ /dev/null
@@ -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",
- });
- }
-});
diff --git a/server/api/updates/[id].get.js b/server/api/updates/[id].get.js
deleted file mode 100644
index 84dc4d7..0000000
--- a/server/api/updates/[id].get.js
+++ /dev/null
@@ -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",
- });
- }
-});
diff --git a/server/api/updates/[id].patch.js b/server/api/updates/[id].patch.js
deleted file mode 100644
index d4bd824..0000000
--- a/server/api/updates/[id].patch.js
+++ /dev/null
@@ -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",
- });
- }
-});
diff --git a/server/api/updates/index.get.js b/server/api/updates/index.get.js
deleted file mode 100644
index 517f77e..0000000
--- a/server/api/updates/index.get.js
+++ /dev/null
@@ -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",
- });
- }
-});
diff --git a/server/api/updates/index.post.js b/server/api/updates/index.post.js
deleted file mode 100644
index 29c3a84..0000000
--- a/server/api/updates/index.post.js
+++ /dev/null
@@ -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",
- });
- }
-});
diff --git a/server/api/updates/my-updates.get.js b/server/api/updates/my-updates.get.js
deleted file mode 100644
index 37a9b3d..0000000
--- a/server/api/updates/my-updates.get.js
+++ /dev/null
@@ -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",
- });
- }
-});
diff --git a/server/api/updates/user/[id].get.js b/server/api/updates/user/[id].get.js
deleted file mode 100644
index 527195e..0000000
--- a/server/api/updates/user/[id].get.js
+++ /dev/null
@@ -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",
- });
- }
-});
diff --git a/server/models/activityLog.js b/server/models/activityLog.js
new file mode 100644
index 0000000..6c9605d
--- /dev/null
+++ b/server/models/activityLog.js
@@ -0,0 +1,58 @@
+import mongoose from 'mongoose'
+
+const ACTIVITY_TYPES = [
+ 'member_joined',
+ 'event_registered',
+ 'event_cancelled',
+ 'event_waitlisted',
+ 'peer_support_enabled',
+ 'peer_support_disabled',
+ 'circle_changed',
+ 'contribution_changed',
+ 'email_changed',
+ 'profile_updated',
+ 'subscription_created',
+ 'subscription_cancelled',
+ 'status_changed',
+ 'role_changed',
+ 'admin_profile_update',
+ 'slack_invited',
+ 'email_sent'
+]
+
+const activityLogSchema = new mongoose.Schema({
+ member: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'Member',
+ required: true
+ },
+ type: {
+ type: String,
+ enum: ACTIVITY_TYPES,
+ required: true
+ },
+ visibility: {
+ type: String,
+ enum: ['member', 'admin', 'public'],
+ default: 'member'
+ },
+ metadata: {
+ type: mongoose.Schema.Types.Mixed,
+ default: () => ({})
+ },
+ performedBy: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'Member'
+ },
+ timestamp: {
+ type: Date,
+ default: Date.now
+ }
+})
+
+// Indexes
+activityLogSchema.index({ member: 1, timestamp: -1 })
+activityLogSchema.index({ member: 1, visibility: 1, timestamp: -1 })
+activityLogSchema.index({ type: 1, timestamp: -1 })
+
+export default mongoose.models.ActivityLog || mongoose.model('ActivityLog', activityLogSchema)
diff --git a/server/models/update.js b/server/models/update.js
deleted file mode 100644
index 0e23991..0000000
--- a/server/models/update.js
+++ /dev/null
@@ -1,50 +0,0 @@
-import mongoose from "mongoose";
-
-const updateSchema = new mongoose.Schema({
- author: {
- type: mongoose.Schema.Types.ObjectId,
- ref: "Member",
- required: true,
- },
- content: {
- type: String,
- required: true,
- },
- images: [
- {
- url: String,
- publicId: String,
- alt: String,
- },
- ],
- privacy: {
- type: String,
- enum: ["public", "members", "private"],
- default: "members",
- },
- commentsEnabled: {
- type: Boolean,
- default: true,
- },
- createdAt: {
- type: Date,
- default: Date.now,
- },
- updatedAt: {
- type: Date,
- default: Date.now,
- },
-});
-
-// Update the updatedAt timestamp on save
-updateSchema.pre("save", function (next) {
- this.updatedAt = Date.now();
- next();
-});
-
-// Indexes for performance
-updateSchema.index({ createdAt: -1 }); // For sorting by date
-updateSchema.index({ privacy: 1, createdAt: -1 }); // Compound index for filtering and sorting
-updateSchema.index({ author: 1 }); // For author lookups
-
-export default mongoose.models.Update || mongoose.model("Update", updateSchema);
diff --git a/server/utils/activityLog.js b/server/utils/activityLog.js
new file mode 100644
index 0000000..e9c3d69
--- /dev/null
+++ b/server/utils/activityLog.js
@@ -0,0 +1,71 @@
+import ActivityLog from '../models/activityLog.js'
+
+export const ACTIVITY_TYPES = {
+ MEMBER_JOINED: 'member_joined',
+ EVENT_REGISTERED: 'event_registered',
+ EVENT_CANCELLED: 'event_cancelled',
+ EVENT_WAITLISTED: 'event_waitlisted',
+ PEER_SUPPORT_ENABLED: 'peer_support_enabled',
+ PEER_SUPPORT_DISABLED: 'peer_support_disabled',
+ CIRCLE_CHANGED: 'circle_changed',
+ CONTRIBUTION_CHANGED: 'contribution_changed',
+ EMAIL_CHANGED: 'email_changed',
+ PROFILE_UPDATED: 'profile_updated',
+ SUBSCRIPTION_CREATED: 'subscription_created',
+ SUBSCRIPTION_CANCELLED: 'subscription_cancelled',
+ STATUS_CHANGED: 'status_changed',
+ ROLE_CHANGED: 'role_changed',
+ ADMIN_PROFILE_UPDATE: 'admin_profile_update',
+ SLACK_INVITED: 'slack_invited',
+ EMAIL_SENT: 'email_sent'
+}
+
+export const ACTIVITY_TYPE_DEFAULTS = {
+ member_joined: 'public',
+ event_registered: 'public',
+ event_cancelled: 'member',
+ event_waitlisted: 'member',
+ peer_support_enabled: 'public',
+ peer_support_disabled: 'member',
+ circle_changed: 'member',
+ contribution_changed: 'member',
+ email_changed: 'member',
+ profile_updated: 'member',
+ subscription_created: 'member',
+ subscription_cancelled: 'member',
+ status_changed: 'admin',
+ role_changed: 'admin',
+ admin_profile_update: 'admin',
+ slack_invited: 'admin',
+ email_sent: 'member'
+}
+
+/**
+ * Log an activity for a member. Fire-and-forget — catches errors
+ * and logs to console without blocking the request.
+ *
+ * @param {string|ObjectId} memberId
+ * @param {string} type - one of ACTIVITY_TYPES values
+ * @param {object} [metadata={}]
+ * @param {object} [options]
+ * @param {string|ObjectId} [options.performedBy] - admin who initiated the action
+ * @param {string} [options.visibility] - override default visibility
+ * @param {Date} [options.timestamp] - override Date.now (for backfill)
+ */
+export async function logActivity(memberId, type, metadata = {}, options = {}) {
+ try {
+ const visibility = options.visibility || ACTIVITY_TYPE_DEFAULTS[type] || 'member'
+ const doc = {
+ member: memberId,
+ type,
+ visibility,
+ metadata
+ }
+ if (options.performedBy) doc.performedBy = options.performedBy
+ if (options.timestamp) doc.timestamp = options.timestamp
+
+ return await ActivityLog.create(doc)
+ } catch (err) {
+ console.error(`[activityLog] Failed to log ${type} for member ${memberId}:`, err)
+ }
+}
diff --git a/server/utils/schemas.js b/server/utils/schemas.js
index a4d79fb..44f51d5 100644
--- a/server/utils/schemas.js
+++ b/server/utils/schemas.js
@@ -57,13 +57,6 @@ export const eventRegistrationSchema = z.object({
dietary: z.boolean().optional()
})
-export const updateCreateSchema = z.object({
- content: z.string().min(1).max(50000),
- images: z.array(z.string().url()).max(20).optional(),
- privacy: z.enum(['public', 'members', 'private']).optional(),
- commentsEnabled: z.boolean().optional()
-})
-
export const paymentVerifySchema = z.object({
cardToken: z.string().min(1),
customerId: z.union([z.string(), z.number()]).transform(String)
@@ -179,15 +172,6 @@ export const peerSupportUpdateSchema = z.object({
slackUsername: z.string().max(200).optional()
})
-// --- Update schemas ---
-
-export const updatePatchSchema = z.object({
- content: z.string().min(1).max(50000).optional(),
- images: z.array(z.string().url()).max(20).optional(),
- privacy: z.enum(['public', 'members', 'private']).optional(),
- commentsEnabled: z.boolean().optional()
-})
-
// --- Series ticket schemas ---
export const seriesTicketPurchaseSchema = z.object({
diff --git a/tests/server/api/admin-role-patch.test.js b/tests/server/api/admin-role-patch.test.js
index f8b64f7..1bbe65a 100644
--- a/tests/server/api/admin-role-patch.test.js
+++ b/tests/server/api/admin-role-patch.test.js
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
vi.mock('../../../server/models/member.js', () => ({
- default: { findByIdAndUpdate: vi.fn() }
+ default: { findById: vi.fn(), findByIdAndUpdate: vi.fn() }
}))
vi.mock('../../../server/utils/mongoose.js', () => ({
@@ -104,7 +104,7 @@ describe('admin role PATCH endpoint', () => {
describe('member not found', () => {
it('returns 404 when member does not exist', async () => {
- Member.findByIdAndUpdate.mockResolvedValue(null)
+ Member.findById.mockResolvedValue(null)
const event = createMockEvent({
method: 'PATCH',
@@ -122,7 +122,9 @@ describe('admin role PATCH endpoint', () => {
describe('successful role changes', () => {
it('promotes a member to admin', async () => {
validateBody.mockResolvedValue({ role: 'admin' })
+ const existingMember = { _id: 'target-member-id', role: 'member', name: 'Test User' }
const updatedMember = { _id: 'target-member-id', role: 'admin', name: 'Test User' }
+ Member.findById.mockResolvedValue(existingMember)
Member.findByIdAndUpdate.mockResolvedValue(updatedMember)
const event = createMockEvent({
@@ -143,7 +145,9 @@ describe('admin role PATCH endpoint', () => {
it('demotes a member to regular role', async () => {
validateBody.mockResolvedValue({ role: 'member' })
+ const existingMember = { _id: 'target-member-id', role: 'admin', name: 'Test User' }
const updatedMember = { _id: 'target-member-id', role: 'member', name: 'Test User' }
+ Member.findById.mockResolvedValue(existingMember)
Member.findByIdAndUpdate.mockResolvedValue(updatedMember)
const event = createMockEvent({
diff --git a/tests/server/api/updates-auth.test.js b/tests/server/api/updates-auth.test.js
deleted file mode 100644
index 1a54b72..0000000
--- a/tests/server/api/updates-auth.test.js
+++ /dev/null
@@ -1,112 +0,0 @@
-import { describe, it, expect } from 'vitest'
-import { readFileSync } from 'node:fs'
-import { resolve } from 'node:path'
-
-const updatesDir = resolve(import.meta.dirname, '../../../server/api/updates')
-
-describe('Updates API auth guards', () => {
- describe('index.post.js (create)', () => {
- const source = readFileSync(resolve(updatesDir, 'index.post.js'), 'utf-8')
-
- it('requires auth via requireAuth(event)', () => {
- expect(source).toContain('requireAuth(event)')
- })
-
- it('does not wrap requireAuth in a try/catch (auth is mandatory)', () => {
- // The public GET routes wrap requireAuth in try/catch to make it optional.
- // The create route must NOT do that — auth failure should halt the request.
- const lines = source.split('\n')
- const authLine = lines.findIndex(l => l.includes('requireAuth(event)'))
- expect(authLine).toBeGreaterThan(-1)
- // Check the line before requireAuth is not a try {
- const preceding = lines.slice(Math.max(0, authLine - 2), authLine).join(' ')
- expect(preceding).not.toMatch(/try\s*\{/)
- })
- })
-
- describe('[id].patch.js (edit)', () => {
- const source = readFileSync(resolve(updatesDir, '[id].patch.js'), 'utf-8')
-
- it('requires auth via requireAuth(event)', () => {
- expect(source).toContain('requireAuth(event)')
- })
-
- it('does not wrap requireAuth in a try/catch (auth is mandatory)', () => {
- const lines = source.split('\n')
- const authLine = lines.findIndex(l => l.includes('requireAuth(event)'))
- expect(authLine).toBeGreaterThan(-1)
- const preceding = lines.slice(Math.max(0, authLine - 2), authLine).join(' ')
- expect(preceding).not.toMatch(/try\s*\{/)
- })
-
- it('verifies ownership by comparing update.author with authenticated member ID', () => {
- expect(source).toContain('update.author.toString() !== memberId')
- })
-
- it('throws 403 when user is not the author', () => {
- expect(source).toContain('statusCode: 403')
- })
- })
-
- describe('[id].delete.js (delete)', () => {
- const source = readFileSync(resolve(updatesDir, '[id].delete.js'), 'utf-8')
-
- it('requires auth via requireAuth(event)', () => {
- expect(source).toContain('requireAuth(event)')
- })
-
- it('does not wrap requireAuth in a try/catch (auth is mandatory)', () => {
- const lines = source.split('\n')
- const authLine = lines.findIndex(l => l.includes('requireAuth(event)'))
- expect(authLine).toBeGreaterThan(-1)
- const preceding = lines.slice(Math.max(0, authLine - 2), authLine).join(' ')
- expect(preceding).not.toMatch(/try\s*\{/)
- })
-
- it('verifies ownership by comparing update.author with authenticated member ID', () => {
- expect(source).toContain('update.author.toString() !== memberId')
- })
-
- it('throws 403 when user is not the author', () => {
- expect(source).toContain('statusCode: 403')
- })
- })
-
- describe('index.get.js (list — public)', () => {
- const source = readFileSync(resolve(updatesDir, 'index.get.js'), 'utf-8')
-
- it('does NOT enforce requireAuth (public access allowed)', () => {
- // The route uses requireAuth inside a try/catch so unauthenticated
- // users can still access it — auth failure is caught and ignored.
- const lines = source.split('\n')
- const authLine = lines.findIndex(l => l.includes('requireAuth(event)'))
- // If requireAuth is present, it must be wrapped in try/catch
- if (authLine > -1) {
- const preceding = lines.slice(Math.max(0, authLine - 2), authLine).join(' ')
- expect(preceding).toMatch(/try\s*\{/)
- }
- // Either way, the route must not throw on unauthenticated access
- })
-
- it('does not call requireAdmin', () => {
- expect(source).not.toContain('requireAdmin')
- })
- })
-
- describe('[id].get.js (get — public)', () => {
- const source = readFileSync(resolve(updatesDir, '[id].get.js'), 'utf-8')
-
- it('does NOT enforce requireAuth (public access allowed)', () => {
- const lines = source.split('\n')
- const authLine = lines.findIndex(l => l.includes('requireAuth(event)'))
- if (authLine > -1) {
- const preceding = lines.slice(Math.max(0, authLine - 2), authLine).join(' ')
- expect(preceding).toMatch(/try\s*\{/)
- }
- })
-
- it('does not call requireAdmin', () => {
- expect(source).not.toContain('requireAdmin')
- })
- })
-})
diff --git a/tests/server/api/validation-phase3.test.js b/tests/server/api/validation-phase3.test.js
index 41882f4..cdf1218 100644
--- a/tests/server/api/validation-phase3.test.js
+++ b/tests/server/api/validation-phase3.test.js
@@ -16,7 +16,6 @@ import {
eventPaymentSchema,
updateContributionSchema,
peerSupportUpdateSchema,
- updatePatchSchema,
seriesTicketPurchaseSchema,
seriesTicketEligibilitySchema,
adminSeriesCreateSchema,
@@ -329,28 +328,6 @@ describe('peerSupportUpdateSchema', () => {
})
})
-// --- Update schemas ---
-
-describe('updatePatchSchema', () => {
- it('accepts valid update patch', () => {
- const result = updatePatchSchema.safeParse({
- content: 'Updated content',
- privacy: 'members'
- })
- expect(result.success).toBe(true)
- })
-
- it('accepts empty object (all optional)', () => {
- const result = updatePatchSchema.safeParse({})
- expect(result.success).toBe(true)
- })
-
- it('rejects invalid privacy enum', () => {
- const result = updatePatchSchema.safeParse({ privacy: 'invalid' })
- expect(result.success).toBe(false)
- })
-})
-
// --- Series schemas ---
describe('seriesTicketPurchaseSchema', () => {
@@ -529,7 +506,6 @@ describe('validateBody migration coverage', () => {
'events/[id]/payment.post.js',
'members/update-contribution.post.js',
'members/me/peer-support.patch.js',
- 'updates/[id].patch.js',
'series/[id]/tickets/purchase.post.js',
'series/[id]/tickets/check-eligibility.post.js',
'admin/series.post.js',
diff --git a/tests/server/api/validation.test.js b/tests/server/api/validation.test.js
index 5d1ac9e..7861cd7 100644
--- a/tests/server/api/validation.test.js
+++ b/tests/server/api/validation.test.js
@@ -5,7 +5,6 @@ import {
memberCreateSchema,
memberProfileUpdateSchema,
eventRegistrationSchema,
- updateCreateSchema,
paymentVerifySchema,
adminEventCreateSchema
} from '../../../server/utils/schemas.js'
@@ -120,55 +119,6 @@ describe('eventRegistrationSchema', () => {
})
})
-describe('updateCreateSchema', () => {
- it('accepts valid content', () => {
- const result = updateCreateSchema.safeParse({ content: 'Hello world' })
- expect(result.success).toBe(true)
- })
-
- it('rejects empty content', () => {
- const result = updateCreateSchema.safeParse({ content: '' })
- expect(result.success).toBe(false)
- })
-
- it('rejects content exceeding 50000 chars', () => {
- const result = updateCreateSchema.safeParse({ content: 'a'.repeat(50001) })
- expect(result.success).toBe(false)
- })
-
- it('accepts content at exactly 50000 chars', () => {
- const result = updateCreateSchema.safeParse({ content: 'a'.repeat(50000) })
- expect(result.success).toBe(true)
- })
-
- it('validates images are URLs', () => {
- const result = updateCreateSchema.safeParse({
- content: 'test',
- images: ['not-a-url']
- })
- expect(result.success).toBe(false)
- })
-
- it('accepts valid images array', () => {
- const result = updateCreateSchema.safeParse({
- content: 'test',
- images: ['https://example.com/img.png']
- })
- expect(result.success).toBe(true)
- })
-
- it('rejects more than 20 images', () => {
- const images = Array.from({ length: 21 }, (_, i) => `https://example.com/img${i}.png`)
- const result = updateCreateSchema.safeParse({ content: 'test', images })
- expect(result.success).toBe(false)
- })
-
- it('validates privacy enum', () => {
- const result = updateCreateSchema.safeParse({ content: 'test', privacy: 'invalid' })
- expect(result.success).toBe(false)
- })
-})
-
describe('paymentVerifySchema', () => {
it('accepts valid card token and customer ID', () => {
const result = paymentVerifySchema.safeParse({ cardToken: 'tok_123', customerId: 'cust_456' })
diff --git a/tests/server/setup.js b/tests/server/setup.js
index 3c01933..50fc8ef 100644
--- a/tests/server/setup.js
+++ b/tests/server/setup.js
@@ -40,3 +40,4 @@ vi.stubGlobal('useRuntimeConfig', () => ({
vi.stubGlobal('requireAuth', vi.fn())
vi.stubGlobal('requireAdmin', vi.fn())
vi.stubGlobal('validateBody', vi.fn(async (event) => readBody(event)))
+vi.stubGlobal('logActivity', vi.fn())