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 @@ - - - - - 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 @@ - - - - - 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 @@
Skip to content +
-
-
- -
- -
-
-

Community

-
"The open hall"
-

- For anyone exploring cooperative models. Wiki access, public - events, Slack community, monthly meetings. -

-
-
-

Founder

-
"The workshop"
-

- For people actively building cooperatives. Peer accelerator, - mentorship, governance templates. -

-
-
-

Practitioner

-
"The alcove"
-

- For experienced practitioners. Mentoring, teaching, shaping the - program direction. -

-
-
-
- - -
-
- + + +
+ +
+
+

Community

+
"The open hall"

- Membership is $0–50/month, pay what you can. Nobody is - excluded for lack of funds. Your contribution supports - infrastructure, events, and community resources. + For anyone exploring cooperative models. Wiki access, public + events, Slack community, monthly meetings.

-
    -
  • $0 I need support right now
  • -
  • $5 I can contribute
  • -
  • - $15 I can sustain the community -
  • -
  • - $30 I can support others too -
  • -
  • - $50 I want to sponsor multiple - members -
  • -
-
- +
+

Founder

+
"The workshop"

- We gather in Slack, at monthly meetings, and through peer support - sessions. The wiki is our shared knowledge base — growing as - members contribute. Events range from workshops to social hangs to - deep-dive series. + For people actively building cooperatives. Peer accelerator, + mentorship, governance templates. +

+
+
+

Practitioner

+
"The alcove"
+

+ For experienced practitioners. Mentoring, teaching, shaping the + program direction.

- Join the Guild →
-
- - -
- -

- Ghost Guild is a program of Baby Ghosts, a Canadian nonprofit - advancing cooperative models in game development. No tracking. No - ads. No venture capital. -

-

- babyghosts.fund → -

- - -
+ +
+
+ +

+ Membership is $0–50/month, pay what you can. Nobody is + excluded for lack of funds. Your contribution supports + infrastructure, events, and community resources. +

+
    +
  • $0 I need support right now
  • +
  • $5 I can contribute
  • +
  • + $15 I can sustain the community +
  • +
  • $30 I can support others too
  • +
  • + $50 I want to sponsor multiple + members +
  • +
+
+
+ +

+ We gather in Slack, at monthly meetings, and through peer support + sessions. The wiki is our shared knowledge base — growing as + members contribute. Events range from workshops to social hangs to + deep-dive series. +

+ Join the Guild → +
+
+ + +
+ +

+ Ghost Guild is a program of Baby Ghosts, a Canadian nonprofit + advancing cooperative models in game development. No tracking. No ads. + No venture capital. +

+

+ babyghosts.fund → +

+
+
- + diff --git a/app/pages/member/dashboard.vue b/app/pages/member/dashboard.vue index ab8c3c0..bf2de63 100644 --- a/app/pages/member/dashboard.vue +++ b/app/pages/member/dashboard.vue @@ -26,7 +26,7 @@ - - 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 @@ - - - - - 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())