Tests, UX improvements.

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

View file

@ -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 = [

View file

@ -0,0 +1,46 @@
<template>
<div class="sidebar-layout">
<div class="sidebar-layout-main">
<slot />
</div>
<EventsMiniSidebar :events="upcomingEvents" />
</div>
</template>
<script setup>
const props = defineProps({
limit: { type: Number, default: 3 },
})
const { data: upcomingEvents } = await useFetch('/api/events', {
query: { limit: props.limit, upcoming: true },
default: () => [],
})
</script>
<style scoped>
.sidebar-layout {
flex: 1;
display: grid;
grid-template-columns: 1fr 200px;
align-items: stretch;
min-height: 0;
}
.sidebar-layout-main {
min-width: 0;
align-self: stretch;
display: flex;
flex-direction: column;
min-height: 0;
}
@media (max-width: 1024px) {
.sidebar-layout {
grid-template-columns: 1fr;
}
}
</style>
```
Now let me apply this to each page. Let me update all four in parallel:

View file

@ -1,191 +0,0 @@
<template>
<UCard variant="outline" class="update-card">
<div class="flex gap-4">
<!-- Avatar -->
<div class="flex-shrink-0">
<img
v-if="update.author?.avatar"
:src="`/ghosties/Ghost-${capitalize(update.author.avatar)}.png`"
:alt="update.author.name"
class="w-12 h-12 rounded-full"
@error="handleImageError"
/>
<div
v-else
class="w-12 h-12 rounded-full bg-guild-700 flex items-center justify-center text-guild-300 font-bold"
>
{{ update.author?.name?.charAt(0)?.toUpperCase() || "?" }}
</div>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<!-- Header -->
<div class="flex items-start justify-between gap-4 mb-2">
<div>
<h3 class="font-semibold text-guild-100">
<NuxtLink
v-if="update.author?._id"
:to="`/updates/user/${update.author._id}`"
class="hover:text-guild-300 transition-colors"
>
{{ update.author.name }}
</NuxtLink>
<span v-else>Unknown Member</span>
</h3>
<div class="flex items-center gap-2 text-sm text-guild-400">
<time :datetime="update.createdAt">
{{ formatDate(update.createdAt) }}
</time>
<span v-if="isEdited" class="text-guild-500">(edited)</span>
<span
v-if="update.privacy === 'private'"
class="px-2 py-0.5 bg-guild-700 text-guild-300 rounded text-xs"
>
Private
</span>
<span
v-if="update.privacy === 'public'"
class="px-2 py-0.5 bg-guild-700 text-guild-300 rounded text-xs"
>
Public
</span>
</div>
</div>
<!-- Actions (for author only) -->
<div v-if="isAuthor" class="flex gap-2">
<UButton
variant="ghost"
color="neutral"
size="xs"
icon="i-lucide-edit"
aria-label="Edit update"
@click="$emit('edit', update)"
/>
<UButton
variant="ghost"
color="neutral"
size="xs"
icon="i-lucide-trash-2"
aria-label="Delete update"
@click="$emit('delete', update)"
/>
</div>
</div>
<!-- Content -->
<div class="text-guild-200 whitespace-pre-wrap break-words mb-3">
<template v-if="showPreview && update.content.length > 300">
{{ update.content.substring(0, 300) }}...
<NuxtLink
:to="`/updates/${update._id}`"
class="text-guild-400 hover:text-guild-300 ml-1"
>
Read more
</NuxtLink>
</template>
<template v-else>
{{ update.content }}
</template>
</div>
<!-- Images (if any) -->
<div v-if="update.images?.length" class="mb-3 space-y-2">
<img
v-for="(image, index) in update.images"
:key="index"
:src="image.url"
:alt="image.alt || 'Update image'"
class="rounded-lg max-w-full h-auto"
/>
</div>
<!-- Footer actions -->
<div class="flex items-center gap-4 text-sm text-guild-400">
<NuxtLink
:to="`/updates/${update._id}`"
class="hover:text-guild-300 transition-colors"
>
View full update
</NuxtLink>
<span v-if="update.commentsEnabled" class="text-guild-500">
Comments (coming soon)
</span>
</div>
</div>
</div>
</UCard>
</template>
<script setup>
const props = defineProps({
update: {
type: Object,
required: true,
},
showPreview: {
type: Boolean,
default: true,
},
});
defineEmits(["edit", "delete"]);
const { memberData } = useAuth();
const isAuthor = computed(() => {
return memberData.value && props.update.author?._id === memberData.value.id;
});
const isEdited = computed(() => {
const created = new Date(props.update.createdAt).getTime();
const updated = new Date(props.update.updatedAt).getTime();
return updated - created > 1000; // More than 1 second difference
});
const capitalize = (str) => {
if (!str) return "";
return str.charAt(0).toUpperCase() + str.slice(1);
};
const handleImageError = (e) => {
e.target.src = "/ghosties/Ghost-Mild.png"; // Fallback ghost
};
const formatDate = (date) => {
const now = new Date();
const updateDate = new Date(date);
const diffInSeconds = Math.floor((now - updateDate) / 1000);
if (diffInSeconds < 60) return "just now";
if (diffInSeconds < 3600)
return `${Math.floor(diffInSeconds / 60)} minutes ago`;
if (diffInSeconds < 86400)
return `${Math.floor(diffInSeconds / 3600)} hours ago`;
if (diffInSeconds < 604800)
return `${Math.floor(diffInSeconds / 86400)} days ago`;
return updateDate.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year:
updateDate.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
};
</script>
<style scoped>
.update-card {
background-color: var(--color-guild-800);
border-color: var(--color-guild-600);
}
.update-card:hover {
border-color: var(--color-guild-500);
}
:deep(.card) {
background-color: var(--color-guild-800);
}
</style>

View file

@ -1,184 +0,0 @@
<template>
<div class="space-y-6">
<UFormField label="What's on your mind?" name="content" required>
<UTextarea
v-model="formData.content"
placeholder="Share your thoughts, updates, questions, or learnings with the community..."
:rows="8"
autoresize
:maxrows="20"
/>
</UFormField>
<!-- Privacy Settings -->
<div class="border border-guild-700 rounded-lg p-4 bg-guild-800/30">
<h3 class="text-sm font-medium text-guild-200 mb-4">Privacy Settings</h3>
<div class="space-y-3">
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="formData.privacy"
type="radio"
value="public"
class="w-4 h-4 text-guild-400"
/>
<div>
<div class="text-guild-200 font-medium">Public</div>
<div class="text-sm text-guild-400">
Visible to everyone, including non-members
</div>
</div>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="formData.privacy"
type="radio"
value="members"
class="w-4 h-4 text-guild-400"
/>
<div>
<div class="text-guild-200 font-medium">Members Only</div>
<div class="text-sm text-guild-400">
Only visible to Ghost Guild members
</div>
</div>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="formData.privacy"
type="radio"
value="private"
class="w-4 h-4 text-guild-400"
/>
<div>
<div class="text-guild-200 font-medium">Private</div>
<div class="text-sm text-guild-400">Only visible to you</div>
</div>
</label>
</div>
</div>
<!-- Image Upload (Future) -->
<!-- TODO: Add image upload integration with Cloudinary -->
<!-- Comments Toggle -->
<div class="flex items-center gap-3">
<USwitch v-model="formData.commentsEnabled" />
<div>
<div class="text-guild-200 font-medium">Enable Comments</div>
<div class="text-sm text-guild-400">
Allow members to comment on this update
</div>
</div>
</div>
<!-- Actions -->
<div
class="flex justify-between items-center pt-4 border-t border-guild-700"
>
<UButton variant="ghost" color="neutral" @click="$emit('cancel')">
Cancel
</UButton>
<UButton
:loading="submitting"
:disabled="!formData.content.trim()"
@click="handleSubmit"
>
{{ submitLabel }}
</UButton>
</div>
<!-- Error Message -->
<div
v-if="error"
class="bg-ember-900/20 border border-ember-400/30 rounded-lg p-4"
>
<p class="text-ember-400">{{ error }}</p>
</div>
</div>
</template>
<script setup>
const props = defineProps({
initialData: {
type: Object,
default: () => ({
content: "",
privacy: "members",
commentsEnabled: true,
images: [],
}),
},
submitLabel: {
type: String,
default: "Post Update",
},
submitting: {
type: Boolean,
default: false,
},
error: {
type: String,
default: null,
},
});
const emit = defineEmits(["submit", "cancel"]);
const formData = reactive({
content: props.initialData.content || "",
privacy: props.initialData.privacy || "members",
commentsEnabled: props.initialData.commentsEnabled ?? true,
images: props.initialData.images || [],
});
const handleSubmit = () => {
if (!formData.content.trim()) return;
emit("submit", { ...formData });
};
// Watch for initialData changes (for edit mode)
watch(
() => props.initialData,
(newData) => {
if (newData) {
formData.content = newData.content || "";
formData.privacy = newData.privacy || "members";
formData.commentsEnabled = newData.commentsEnabled ?? true;
formData.images = newData.images || [];
}
},
{ immediate: true },
);
</script>
<style scoped>
/* Field labels */
:deep(label) {
color: var(--color-guild-200) !important;
font-weight: 500;
}
/* Textarea styling */
:deep(textarea) {
background-color: var(--color-guild-800) !important;
color: var(--color-guild-200) !important;
border-color: var(--color-guild-600) !important;
}
:deep(textarea::placeholder) {
color: var(--color-guild-500) !important;
}
:deep(textarea:focus) {
border-color: var(--color-guild-400) !important;
background-color: var(--color-guild-700) !important;
}
/* Radio buttons */
input[type="radio"] {
accent-color: var(--color-candlelight-600);
}
</style>

View file

@ -2,9 +2,10 @@
<div class="site">
<a
href="#main-content"
class="sr-only focus:not-sr-only focus:absolute focus:z-[100] focus:p-3 focus:bg-[var(--bg)] focus:text-[var(--text)]"
class="sr-only focus:not-sr-only focus:absolute focus:z-100 focus:p-3 focus:bg-(--bg) focus:text-(--text)"
>Skip to content</a
>
<!-- Desktop Sidebar -->
<aside class="sidebar sidebar-desktop">
<NuxtLink to="/" class="sidebar-brand">Ghost Guild</NuxtLink>

View file

@ -31,103 +31,91 @@
</div>
<!-- CONTENT AREA WITH EVENTS SIDEBAR -->
<div class="content-area">
<div class="content-main">
<!-- THE CIRCLES -->
<div class="about-section" id="circles">
<div class="section-label">The Circles</div>
<div class="circles-grid">
<div id="community" class="circle-cell">
<h3 style="color: var(--c-community)">Community</h3>
<div class="circle-subtitle">"The open hall"</div>
<p>
For anyone exploring cooperative models. Wiki access, public
events, Slack community, monthly meetings.
</p>
</div>
<div id="founder" class="circle-cell">
<h3 style="color: var(--c-founder)">Founder</h3>
<div class="circle-subtitle">"The workshop"</div>
<p>
For people actively building cooperatives. Peer accelerator,
mentorship, governance templates.
</p>
</div>
<div id="practitioner" class="circle-cell">
<h3 style="color: var(--c-practitioner)">Practitioner</h3>
<div class="circle-subtitle">"The alcove"</div>
<p>
For experienced practitioners. Mentoring, teaching, shaping the
program direction.
</p>
</div>
</div>
</div>
<!-- TWO-COL: CONTRIBUTION + COMMUNITY -->
<div class="two-col-row">
<div class="about-section">
<div class="section-label">How Contribution Works</div>
<SidebarLayout>
<!-- THE CIRCLES -->
<div class="about-section" id="circles">
<div class="section-label">The Circles</div>
<div class="circles-grid">
<div id="community" class="circle-cell">
<h3 style="color: var(--c-community)">Community</h3>
<div class="circle-subtitle">"The open hall"</div>
<p>
Membership is $0&ndash;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.
</p>
<ul class="tier-list">
<li><span class="tier-amt">$0</span> I need support right now</li>
<li><span class="tier-amt">$5</span> I can contribute</li>
<li>
<span class="tier-amt">$15</span> I can sustain the community
</li>
<li>
<span class="tier-amt">$30</span> I can support others too
</li>
<li>
<span class="tier-amt">$50</span> I want to sponsor multiple
members
</li>
</ul>
</div>
<div class="about-section">
<div class="section-label">Community</div>
<div id="founder" class="circle-cell">
<h3 style="color: var(--c-founder)">Founder</h3>
<div class="circle-subtitle">"The workshop"</div>
<p>
We gather in Slack, at monthly meetings, and through peer support
sessions. The wiki is our shared knowledge base &mdash; 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.
</p>
</div>
<div id="practitioner" class="circle-cell">
<h3 style="color: var(--c-practitioner)">Practitioner</h3>
<div class="circle-subtitle">"The alcove"</div>
<p>
For experienced practitioners. Mentoring, teaching, shaping the
program direction.
</p>
<NuxtLink to="/join" class="cta">Join the Guild &rarr;</NuxtLink>
</div>
</div>
<!-- ABOUT BABY GHOSTS -->
<div class="about-section">
<div class="section-label">About Baby Ghosts</div>
<p>
Ghost Guild is a program of Baby Ghosts, a Canadian nonprofit
advancing cooperative models in game development. No tracking. No
ads. No venture capital.
</p>
<p>
<a href="https://babyghosts.fund" target="_blank"
>babyghosts.fund &rarr;</a
>
</p>
</div>
</div>
<!-- EVENTS MINI SIDEBAR -->
<EventsMiniSidebar :events="upcomingEvents" />
</div>
<!-- TWO-COL: CONTRIBUTION + COMMUNITY -->
<div class="two-col-row">
<div class="about-section">
<div class="section-label">How Contribution Works</div>
<p>
Membership is $0&ndash;50/month, pay what you can. Nobody is
excluded for lack of funds. Your contribution supports
infrastructure, events, and community resources.
</p>
<ul class="tier-list">
<li><span class="tier-amt">$0</span> I need support right now</li>
<li><span class="tier-amt">$5</span> I can contribute</li>
<li>
<span class="tier-amt">$15</span> I can sustain the community
</li>
<li><span class="tier-amt">$30</span> I can support others too</li>
<li>
<span class="tier-amt">$50</span> I want to sponsor multiple
members
</li>
</ul>
</div>
<div class="about-section">
<div class="section-label">Community</div>
<p>
We gather in Slack, at monthly meetings, and through peer support
sessions. The wiki is our shared knowledge base &mdash; growing as
members contribute. Events range from workshops to social hangs to
deep-dive series.
</p>
<NuxtLink to="/join" class="cta">Join the Guild &rarr;</NuxtLink>
</div>
</div>
<!-- ABOUT BABY GHOSTS -->
<div class="about-section">
<div class="section-label">About Baby Ghosts</div>
<p>
Ghost Guild is a program of Baby Ghosts, a Canadian nonprofit
advancing cooperative models in game development. No tracking. No ads.
No venture capital.
</p>
<p>
<a href="https://babyghosts.fund" target="_blank"
>babyghosts.fund &rarr;</a
>
</p>
</div>
</SidebarLayout>
</div>
</template>
<script setup>
const { data: upcomingEvents } = await useFetch("/api/events", {
query: { limit: 3, upcoming: true },
default: () => [],
});
</script>
<script setup></script>
<style scoped>
/* Flex chain from layout .main-body: hero + grid grow so sidebar column matches main height */
@ -176,24 +164,6 @@ const { data: upcomingEvents } = await useFetch("/api/events", {
margin-bottom: 10px;
}
/* ---- CONTENT AREA ---- */
.content-area {
flex: 1;
display: grid;
grid-template-columns: 1fr 200px;
align-items: stretch;
min-height: 0;
}
.content-main {
padding: 0;
min-width: 0;
align-self: stretch;
height: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
}
/* ---- SECTIONS ---- */
.about-section {
padding: 28px 32px;
@ -283,9 +253,6 @@ const { data: upcomingEvents } = await useFetch("/api/events", {
/* ---- RESPONSIVE ---- */
@media (max-width: 1024px) {
.content-area {
grid-template-columns: 1fr;
}
.circles-grid {
grid-template-columns: 1fr;
}

View file

@ -115,11 +115,83 @@
</div>
</dl>
</section>
<!-- Notification preferences -->
<section class="detail-section">
<div class="section-label">Notification preferences</div>
<dl class="meta-list">
<div class="meta-row">
<dt>Event reminders</dt>
<dd
:class="
member.notifications?.events !== false
? 'status-ok'
: 'status-dim'
"
>
{{ member.notifications?.events !== false ? "On" : "Off" }}
</dd>
</div>
<div class="meta-row">
<dt>Community updates</dt>
<dd
:class="
member.notifications?.updates !== false
? 'status-ok'
: 'status-dim'
"
>
{{ member.notifications?.updates !== false ? "On" : "Off" }}
</dd>
</div>
<div class="meta-row">
<dt>Peer support requests</dt>
<dd
:class="
member.notifications?.peerRequests !== false
? 'status-ok'
: 'status-dim'
"
>
{{ member.notifications?.peerRequests !== false ? "On" : "Off" }}
</dd>
</div>
</dl>
</section>
<!-- Full Activity Log -->
<section class="detail-section">
<div class="section-label">Full Activity Log</div>
<ClientOnly>
<div v-if="activityLoading && !activityEntries.length" class="loading-state">
<div class="spinner" />
Loading activity...
</div>
<div v-else-if="activityEntries.length" class="activity-log">
<div v-for="entry in activityEntries" :key="entry._id" class="al-item" :class="{ 'al-admin': entry.visibility === 'admin' }">
<UIcon :name="getActivity(entry).icon" class="al-icon" />
<span class="al-text">{{ getActivity(entry).text }}</span>
<span class="al-time">{{ formatDate(entry.timestamp) }}</span>
<span v-if="entry.visibility === 'admin'" class="al-vis-badge">admin-only</span>
</div>
<div v-if="activityHasMore" class="al-load-more">
<button class="btn" :disabled="activityLoadingMore" @click="loadMoreActivity">
{{ activityLoadingMore ? 'Loading...' : 'Load More' }}
</button>
</div>
</div>
<div v-else class="loading-state">
No activity recorded.
</div>
</ClientOnly>
</section>
</template>
</div>
</template>
<script setup>
import { formatActivity } from '~/utils/activityText'
definePageMeta({
layout: "admin",
middleware: "admin",
@ -225,6 +297,50 @@ function statusClass(status) {
if (status === "cancelled" || status === "suspended") return "status-error";
return "status-dim";
}
// Activity log
const activityEntries = ref([])
const activityLoading = ref(false)
const activityLoadingMore = ref(false)
const activityHasMore = ref(false)
const activityNextCursor = ref(null)
const getActivity = (entry) => formatActivity(entry)
async function loadActivity() {
activityLoading.value = true
try {
const data = await $fetch(`/api/admin/members/${route.params.id}/activity`, {
params: { limit: 20 }
})
activityEntries.value = data.entries
activityHasMore.value = data.hasMore
activityNextCursor.value = data.nextCursor
} catch (err) {
console.error('Failed to load activity:', err)
} finally {
activityLoading.value = false
}
}
async function loadMoreActivity() {
if (!activityNextCursor.value) return
activityLoadingMore.value = true
try {
const data = await $fetch(`/api/admin/members/${route.params.id}/activity`, {
params: { limit: 20, before: activityNextCursor.value }
})
activityEntries.value.push(...data.entries)
activityHasMore.value = data.hasMore
activityNextCursor.value = data.nextCursor
} catch (err) {
console.error('Failed to load more activity:', err)
} finally {
activityLoadingMore.value = false
}
}
onMounted(loadActivity)
</script>
<style scoped>
@ -381,6 +497,62 @@ function statusClass(status) {
}
}
/* ---- ACTIVITY LOG ---- */
.activity-log {
margin-top: 12px;
border: 1px dashed var(--border);
}
.al-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-bottom: 1px dashed var(--border);
font-size: 12px;
}
.al-item:last-child {
border-bottom: none;
}
.al-admin {
opacity: 0.7;
}
.al-icon {
width: 14px;
height: 14px;
color: var(--text-faint);
flex-shrink: 0;
}
.al-text {
flex: 1;
min-width: 0;
color: var(--text-dim);
}
.al-time {
color: var(--text-faint);
font-size: 11px;
flex-shrink: 0;
}
.al-vis-badge {
font-size: 9px;
color: var(--text-faint);
border: 1px dashed var(--border);
padding: 1px 4px;
flex-shrink: 0;
}
.al-load-more {
display: flex;
justify-content: center;
padding: 12px;
}
@media (max-width: 768px) {
.admin-member-detail {
padding: 20px 16px 40px;

View file

@ -12,7 +12,7 @@
<!-- FILTER BAR -->
<FilterBar v-model="activeFilter" :filters="filterOptions">
<label class="filter-toggle">
<input type="checkbox" v-model="includePastEvents" /> Show past events
<input v-model="includePastEvents" type="checkbox" /> Show past events
</label>
</FilterBar>

View file

@ -19,197 +19,187 @@
/>
<!-- CONTENT AREA WITH EVENTS SIDEBAR -->
<div class="content-area">
<div class="page-content">
<div class="account-columns">
<!-- LEFT COLUMN: Membership Status & Email -->
<div class="account-col-left">
<section class="account-section">
<div class="account-col-inset">
<div class="section-label">Current Membership</div>
<SidebarLayout>
<div class="account-columns">
<!-- LEFT COLUMN: Membership Status & Email -->
<div class="account-col-left">
<section class="account-section">
<div class="account-col-inset">
<div class="section-label">Current Membership</div>
<div class="membership-card">
<div class="membership-row">
<span class="membership-k">Status</span>
<span class="membership-v status-v">
<span
class="status-dot"
:class="memberData.status || 'active'"
></span>
<span>{{
formatStatus(memberData.status || "active")
}}</span>
</span>
</div>
<div class="membership-row">
<span class="membership-k">Circle</span>
<div class="membership-card">
<div class="membership-row">
<span class="membership-k">Status</span>
<span class="membership-v status-v">
<span
class="membership-v"
:style="{
color: `var(--c-${memberData.circle || 'community'})`,
}"
>
{{
memberData.circle
? capitalise(memberData.circle)
: "Community"
}}
</span>
</div>
<div class="membership-row">
<span class="membership-k">Contribution</span>
<span class="membership-v"
>${{ memberData.contributionTier || 0 }} / month</span
>
</div>
<div class="membership-row">
<span class="membership-k">Member since</span>
<span class="membership-v">{{
formatMemberSince(memberData.createdAt)
class="status-dot"
:class="memberData.status || 'active'"
></span>
<span>{{
formatStatus(memberData.status || "active")
}}</span>
</div>
</span>
</div>
<div class="membership-row">
<span class="membership-k">Circle</span>
<span
class="membership-v"
:style="{
color: `var(--c-${memberData.circle || 'community'})`,
}"
>
{{
memberData.circle
? capitalise(memberData.circle)
: "Community"
}}
</span>
</div>
<div class="membership-row">
<span class="membership-k">Contribution</span>
<span class="membership-v"
>${{ memberData.contributionTier || 0 }} / month</span
>
</div>
<div class="membership-row">
<span class="membership-k">Member since</span>
<span class="membership-v">{{
formatMemberSince(memberData.createdAt)
}}</span>
</div>
</div>
</section>
</div>
</section>
<section class="account-section">
<div class="account-col-inset">
<div class="section-label">Email</div>
<section class="account-section">
<div class="account-col-inset">
<div class="section-label">Email</div>
<div v-if="!showEmailEdit" class="email-display">
<span class="email-value">{{ memberData.email }}</span>
<div v-if="!showEmailEdit" class="email-display">
<span class="email-value">{{ memberData.email }}</span>
<button class="btn btn-inline" @click="showEmailEdit = true">
Change
</button>
</div>
<div v-else class="email-edit">
<div class="field">
<label>New email address</label>
<input
type="email"
v-model="newEmail"
placeholder="you@example.com"
@keydown.enter="handleUpdateEmail"
@keydown.escape="cancelEmailEdit"
autofocus
/>
</div>
<div class="email-edit-actions">
<button
class="btn btn-inline"
@click="showEmailEdit = true"
class="btn btn-primary"
@click="handleUpdateEmail"
:disabled="isUpdatingEmail || !newEmail.trim()"
>
Change
{{ isUpdatingEmail ? "Saving…" : "Save" }}
</button>
<button
class="btn"
@click="cancelEmailEdit"
:disabled="isUpdatingEmail"
>
Cancel
</button>
</div>
<div v-else class="email-edit">
<div class="field">
<label>New email address</label>
<input
type="email"
v-model="newEmail"
placeholder="you@example.com"
@keydown.enter="handleUpdateEmail"
@keydown.escape="cancelEmailEdit"
autofocus
/>
</div>
<div class="email-edit-actions">
<button
class="btn btn-primary"
@click="handleUpdateEmail"
:disabled="isUpdatingEmail || !newEmail.trim()"
>
{{ isUpdatingEmail ? "Saving…" : "Save" }}
</button>
<button
class="btn"
@click="cancelEmailEdit"
:disabled="isUpdatingEmail"
>
Cancel
</button>
</div>
</div>
<div class="email-hint">
Used for login magic links and notifications
</div>
</div>
</section>
<section class="account-section account-section--danger">
<div class="account-col-inset">
<div class="section-label danger">Danger Zone</div>
<div class="danger-zone">
<p>
Cancelling your membership will immediately revoke access
to member-only resources, events, and the Slack workspace.
<strong>This action cannot be easily undone.</strong>
<div class="email-hint">
Used for login magic links and notifications
</div>
</div>
</section>
<section class="account-section account-section--danger">
<div class="account-col-inset">
<div class="section-label danger">Danger Zone</div>
<div class="danger-zone">
<p>
Cancelling your membership will immediately revoke access to
member-only resources, events, and the Slack workspace.
<strong>This action cannot be easily undone.</strong>
</p>
<div v-if="showCancelConfirm" class="cancel-confirm">
<p class="cancel-confirm-prompt">
Are you sure? This cannot be easily undone.
</p>
<div v-if="showCancelConfirm" class="cancel-confirm">
<p class="cancel-confirm-prompt">
Are you sure? This cannot be easily undone.
</p>
<div class="cancel-confirm-actions">
<button
class="btn btn-danger"
@click="confirmCancelMembership"
:disabled="isCancelling"
>
{{ isCancelling ? "Cancelling…" : "Yes, Cancel" }}
</button>
<button class="btn" @click="showCancelConfirm = false">
Nevermind
</button>
</div>
<div class="cancel-confirm-actions">
<button
class="btn btn-danger"
@click="confirmCancelMembership"
:disabled="isCancelling"
>
{{ isCancelling ? "Cancelling…" : "Yes, Cancel" }}
</button>
<button class="btn" @click="showCancelConfirm = false">
Nevermind
</button>
</div>
<button
v-else
class="btn btn-danger"
@click="handleCancelMembership"
:disabled="isCancelling"
>
Cancel Membership
</button>
</div>
</div>
</section>
</div>
<!-- RIGHT COLUMN: Change Contribution & Circle -->
<div class="account-col-right">
<section class="account-section">
<div class="account-col-inset">
<div class="section-label">Change Contribution</div>
<TierPicker v-model="selectedTier" :tiers="tiers" />
<div class="tier-hint">
Changes take effect on your next billing cycle
</div>
<button
class="btn btn-primary btn-section"
@click="handleUpdateTier"
:disabled="
selectedTier ===
Number(memberData.contributionTier || 0) || isUpdating
"
v-else
class="btn btn-danger"
@click="handleCancelMembership"
:disabled="isCancelling"
>
{{ isUpdating ? "Updating…" : "Update Contribution" }}
Cancel Membership
</button>
</div>
</section>
</div>
</section>
</div>
<section class="account-section">
<div class="account-col-inset">
<div class="section-label">Change Circle</div>
<!-- RIGHT COLUMN: Change Contribution & Circle -->
<div class="account-col-right">
<section class="account-section">
<div class="account-col-inset">
<div class="section-label">Change Contribution</div>
<CirclePicker
v-model="selectedCircle"
:circles="circleOptions"
/>
<button
class="btn btn-primary btn-section"
@click="handleUpdateCircle"
:disabled="
selectedCircle === memberData.circle || isUpdating
"
>
{{ isUpdating ? "Updating…" : "Update Circle" }}
</button>
<TierPicker v-model="selectedTier" :tiers="tiers" />
<div class="tier-hint">
Changes take effect on your next billing cycle
</div>
</section>
</div>
<button
class="btn btn-primary btn-section"
@click="handleUpdateTier"
:disabled="
selectedTier === Number(memberData.contributionTier || 0) ||
isUpdating
"
>
{{ isUpdating ? "Updating…" : "Update Contribution" }}
</button>
</div>
</section>
<section class="account-section">
<div class="account-col-inset">
<div class="section-label">Change Circle</div>
<CirclePicker
v-model="selectedCircle"
:circles="circleOptions"
/>
<button
class="btn btn-primary btn-section"
@click="handleUpdateCircle"
:disabled="selectedCircle === memberData.circle || isUpdating"
>
{{ isUpdating ? "Updating…" : "Update Circle" }}
</button>
</div>
</section>
</div>
</div>
<!-- EVENTS MINI SIDEBAR -->
<EventsMiniSidebar :events="upcomingEvents" />
</div>
</SidebarLayout>
</div>
</div>
</template>
@ -281,11 +271,6 @@ watchEffect(() => {
}
});
const { data: upcomingEvents } = await useFetch("/api/events", {
query: { limit: 3, upcoming: true },
default: () => [],
});
const formatMemberSince = (dateStr) => {
if (!dateStr) return "";
return new Date(dateStr).toLocaleDateString("en-US", {
@ -420,23 +405,6 @@ const confirmCancelMembership = async () => {
min-height: 0;
}
/* ---- CONTENT AREA ---- */
.content-area {
flex: 1;
display: grid;
grid-template-columns: 1fr 200px;
align-items: stretch;
min-height: 0;
}
.page-content {
min-width: 0;
align-self: stretch;
height: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
}
/* ---- TWO-COLUMN LAYOUT ---- */
.account-columns {
flex: 1;
@ -644,9 +612,6 @@ const confirmCancelMembership = async () => {
/* ---- RESPONSIVE ---- */
@media (max-width: 1024px) {
.content-area {
grid-template-columns: 1fr;
}
.account-columns {
grid-template-columns: 1fr;
}

View file

@ -0,0 +1,341 @@
<template>
<div class="activity-page">
<PageHeader
title="Activity Log"
subtitle="Your activity and milestones in the Guild"
/>
<SidebarLayout :limit="5">
<ClientOnly>
<!-- Loading State -->
<div v-if="loading && !entries.length" class="state-box">
<div class="spinner"></div>
<p class="state-text">Loading activity...</p>
</div>
<!-- Timeline -->
<div v-else-if="entries.length" class="timeline-wrap">
<div class="timeline">
<div v-for="entry in entries" :key="entry._id" class="tl-item">
<div class="tl-dot">
<UIcon :name="getActivity(entry).icon" class="tl-icon" />
</div>
<div class="tl-time">{{ formatDate(entry.timestamp) }}</div>
<div class="tl-text">
<template v-if="getActivity(entry).link">
<span>{{ getActivity(entry).text.split(getActivity(entry).linkText)[0] }}</span>
<NuxtLink :to="getActivity(entry).link" class="tl-link">{{ getActivity(entry).linkText }}</NuxtLink>
</template>
<span v-else>{{ getActivity(entry).text }}</span>
<span v-if="entry.performedBy" class="tl-admin-badge">admin</span>
</div>
<!-- Email body expandable -->
<div v-if="entry.type === 'email_sent' && getActivity(entry).emailBody" class="tl-email">
<button class="tl-email-toggle" @click="toggleEmail(entry._id)">
{{ expandedEmails[entry._id] ? 'Hide email' : 'View email' }}
</button>
<div v-if="expandedEmails[entry._id]" class="dashed-box tl-email-body">
<pre>{{ getActivity(entry).emailBody }}</pre>
</div>
</div>
</div>
</div>
<!-- Load More -->
<div v-if="hasMore" class="load-more">
<button class="btn" :disabled="loadingMore" @click="loadMore">
{{ loadingMore ? 'Loading...' : 'Load More' }}
</button>
</div>
</div>
<!-- Empty State -->
<div v-else class="state-box">
<div class="state-icon">
<UIcon name="i-lucide-activity" />
</div>
<h2 class="state-heading">No activity yet</h2>
<p class="state-text">Your activity will appear here as you use the Guild</p>
</div>
<template #fallback>
<div class="state-box">
<div class="spinner"></div>
<p class="state-text">Loading activity...</p>
</div>
</template>
</ClientOnly>
</SidebarLayout>
</div>
</template>
<script setup>
import { formatActivity } from '~/utils/activityText'
const entries = ref([])
const loading = ref(false)
const loadingMore = ref(false)
const hasMore = ref(false)
const nextCursor = ref(null)
const expandedEmails = ref({})
const getActivity = (entry) => formatActivity(entry)
const toggleEmail = (id) => {
expandedEmails.value[id] = !expandedEmails.value[id]
}
const formatDate = (date) => {
const now = new Date()
const d = new Date(date)
const diffInSeconds = Math.floor((now - d) / 1000)
if (diffInSeconds < 60) return 'just now'
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} minutes ago`
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)} days ago`
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
})
}
const loadEntries = async () => {
loading.value = true
try {
const data = await $fetch('/api/members/me/activity', {
params: { limit: 20 }
})
entries.value = data.entries
hasMore.value = data.hasMore
nextCursor.value = data.nextCursor
} catch (err) {
console.error('Failed to load activity:', err)
} finally {
loading.value = false
}
}
const loadMore = async () => {
if (!nextCursor.value) return
loadingMore.value = true
try {
const data = await $fetch('/api/members/me/activity', {
params: { limit: 20, before: nextCursor.value }
})
entries.value.push(...data.entries)
hasMore.value = data.hasMore
nextCursor.value = data.nextCursor
} catch (err) {
console.error('Failed to load more activity:', err)
} finally {
loadingMore.value = false
}
}
onMounted(loadEntries)
useHead({ title: 'Activity Log - Ghost Guild' })
</script>
<style scoped>
.activity-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ---- STATE BOXES ---- */
.state-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 32px;
text-align: center;
}
.state-icon {
width: 48px;
height: 48px;
border: 1px dashed var(--border);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
color: var(--text-faint);
font-size: 20px;
}
.state-heading {
font-family: 'Brygada 1918', serif;
font-size: 18px;
font-weight: 600;
color: var(--text-bright);
margin-bottom: 6px;
}
.state-text {
font-size: 12px;
color: var(--text-dim);
margin-bottom: 20px;
max-width: 320px;
}
.spinner {
width: 20px;
height: 20px;
border: 2px dashed var(--candle);
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 12px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ---- TIMELINE ---- */
.timeline-wrap {
padding: 24px 32px 48px;
}
.timeline {
position: relative;
padding-left: 32px;
}
.timeline::before {
content: '';
position: absolute;
left: 11px;
top: 0;
bottom: 0;
width: 1px;
border-left: 1px dashed var(--border);
}
.tl-item {
position: relative;
padding: 0 0 24px;
}
.tl-item:last-child {
padding-bottom: 0;
}
.tl-dot {
position: absolute;
left: -32px;
top: 2px;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
border: 1px dashed var(--border);
font-size: 12px;
color: var(--text-dim);
}
.tl-icon {
width: 12px;
height: 12px;
}
.tl-time {
font-size: 11px;
color: var(--text-faint);
margin-bottom: 2px;
}
.tl-text {
font-size: 13px;
color: var(--text);
line-height: 1.5;
display: flex;
align-items: baseline;
gap: 4px;
flex-wrap: wrap;
}
.tl-link {
color: var(--candle);
text-decoration: none;
font-weight: 500;
}
.tl-link:hover {
text-decoration: underline;
}
.tl-admin-badge {
font-size: 10px;
color: var(--text-faint);
border: 1px dashed var(--border);
padding: 1px 5px;
margin-left: 4px;
}
/* ---- EMAIL EXPANDABLE ---- */
.tl-email {
margin-top: 4px;
}
.tl-email-toggle {
font-family: 'Commit Mono', monospace;
font-size: 11px;
color: var(--text-faint);
background: none;
border: none;
cursor: pointer;
padding: 0;
transition: color 0.15s;
}
.tl-email-toggle:hover {
color: var(--candle);
}
.tl-email-body {
margin-top: 6px;
padding: 12px;
font-size: 12px;
color: var(--text-dim);
line-height: 1.6;
max-height: 300px;
overflow-y: auto;
}
.tl-email-body pre {
white-space: pre-wrap;
word-break: break-word;
font-family: 'Commit Mono', monospace;
font-size: 12px;
margin: 0;
}
/* ---- LOAD MORE ---- */
.load-more {
display: flex;
justify-content: center;
padding-top: 8px;
}
/* ---- RESPONSIVE ---- */
@media (max-width: 768px) {
.timeline-wrap {
padding: 20px 20px 40px;
}
.state-box {
padding: 48px 20px;
}
}
</style>

View file

@ -26,7 +26,7 @@
<!-- Dashboard Content -->
<template v-else>
<div class="dashboard-with-sidebar">
<SidebarLayout :limit="5">
<div class="dashboard-body">
<!-- Member Status Banner -->
<MemberStatusBanner />
@ -204,8 +204,7 @@
</div>
</div>
</div>
<EventsMiniSidebar :events="upcomingEvents" />
</div>
</SidebarLayout>
</template>
<template #fallback>
@ -229,11 +228,6 @@ const loadingEvents = ref(false);
const calendarLinkCopied = ref(false);
const showCalendarInstructions = ref(false);
const { data: upcomingEvents } = await useFetch("/api/events", {
query: { limit: 5, upcoming: true },
default: () => [],
});
// Calendar subscription URL
const calendarUrl = computed(() => {
const memberId = memberData.value?._id || memberData.value?.id;
@ -483,15 +477,6 @@ useHead({
color: var(--text-dim);
}
/* ---- CONTENT GRID ---- */
.dashboard-with-sidebar {
flex: 1;
display: grid;
grid-template-columns: 1fr 200px;
align-items: stretch;
min-height: 0;
}
.dashboard-body {
flex: 1;
display: flex;
@ -722,17 +707,8 @@ useHead({
}
/* ---- RESPONSIVE ---- */
@media (max-width: 1024px) {
.dashboard-with-sidebar {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.dashboard-with-sidebar {
grid-template-columns: 1fr;
}
.content-row {
grid-template-columns: 1fr;
}

View file

@ -1,600 +0,0 @@
<template>
<div class="my-updates-page">
<PageHeader
title="My Updates"
subtitle="Your activity and milestones in the Guild"
/>
<!-- Content Area: two-column with events mini sidebar -->
<div class="content-area">
<!-- Main Content -->
<div class="content-main">
<ClientOnly>
<!-- Stats + New Update row -->
<div v-if="isAuthenticated && !pending" class="stats-row">
<span class="stats-count">
<strong>{{ total }}</strong> {{ total === 1 ? 'update' : 'updates' }} posted
</span>
<NuxtLink to="/updates/new" class="btn btn-primary">+ New Update</NuxtLink>
</div>
<!-- Loading State -->
<div v-if="pending && !updates.length" class="state-box">
<div class="spinner"></div>
<p class="state-text">Loading your updates...</p>
</div>
<!-- Unauthenticated State -->
<div v-else-if="!isAuthenticated" class="state-box">
<div class="state-icon">
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
</div>
<h2 class="state-heading">Sign in required</h2>
<p class="state-text">Please sign in to view your updates.</p>
<button
class="btn btn-primary"
@click="openLoginModal({ title: 'Sign in to view your updates', description: 'Enter your email to access your updates' })"
>
Sign In
</button>
</div>
<!-- Updates Timeline -->
<div v-else-if="updates.length" class="timeline-wrap">
<div class="timeline">
<div
v-for="update in updates"
:key="update._id"
class="tl-item"
>
<div class="tl-dot">&#9998;</div>
<div class="tl-time">{{ formatDate(update.createdAt) }}</div>
<div class="tl-text">
<NuxtLink :to="`/updates/${update._id}`" class="tl-title">
{{ getUpdateTitle(update) }}
</NuxtLink>
<span v-if="isEdited(update)" class="tl-edited">(edited)</span>
<span v-if="update.privacy === 'private'" class="badge">Private</span>
<span v-if="update.privacy === 'public'" class="badge">Public</span>
</div>
<div v-if="getUpdatePreview(update)" class="tl-detail">
{{ getUpdatePreview(update) }}
</div>
<!-- Images -->
<div v-if="update.images?.length" class="tl-images">
<img
v-for="(image, index) in update.images"
:key="index"
:src="image.url"
:alt="image.alt || 'Update image'"
class="tl-image"
/>
</div>
<!-- Actions -->
<div v-if="isAuthor(update)" class="tl-actions">
<button class="tl-action-btn" @click="handleEdit(update)">Edit</button>
<span class="tl-action-sep">&middot;</span>
<button class="tl-action-btn tl-action-danger" @click="handleDelete(update)">Delete</button>
</div>
</div>
</div>
<!-- Load More -->
<div v-if="hasMore" class="load-more">
<button
class="btn"
:disabled="loadingMore"
@click="loadMore"
>
{{ loadingMore ? 'Loading...' : 'Load More' }}
</button>
</div>
</div>
<!-- Empty State -->
<div v-else class="state-box">
<div class="state-icon">
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</div>
<h2 class="state-heading">No updates yet</h2>
<p class="state-text">Share your first update with the community</p>
<NuxtLink to="/updates/new" class="btn btn-primary">+ Post Your First Update</NuxtLink>
</div>
<template #fallback>
<div class="state-box">
<div class="spinner"></div>
<p class="state-text">Loading your updates...</p>
</div>
</template>
</ClientOnly>
</div>
<!-- Events Mini Sidebar -->
<EventsMiniSidebar :events="upcomingEvents" />
</div>
<!-- Delete Confirmation Modal -->
<Teleport to="body">
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal-box">
<h3 class="modal-heading">Delete Update?</h3>
<p class="modal-text">Are you sure you want to delete this update? This action cannot be undone.</p>
<div class="modal-actions">
<button class="btn" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" :disabled="deleting" @click="confirmDelete">
{{ deleting ? 'Deleting...' : 'Delete' }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
const { isAuthenticated, memberData, checkMemberStatus } = useAuth()
const { openLoginModal } = useLoginModal()
const updates = ref([])
const pending = ref(false)
const loadingMore = ref(false)
const hasMore = ref(false)
const total = ref(0)
const showDeleteModal = ref(false)
const updateToDelete = ref(null)
const deleting = ref(false)
const upcomingEvents = ref([])
// Check if current user is the author of an update
const isAuthor = (update) => {
return memberData.value && update.author?._id === memberData.value.id
}
// Check if update was edited
const isEdited = (update) => {
const created = new Date(update.createdAt).getTime()
const updated = new Date(update.updatedAt).getTime()
return updated - created > 1000
}
// Extract a title from update content (first line or first ~60 chars)
const getUpdateTitle = (update) => {
if (!update.content) return 'Untitled update'
const firstLine = update.content.split('\n')[0]
if (firstLine.length <= 80) return firstLine
return firstLine.substring(0, 80) + '...'
}
// Get a preview of the update content (after the first line)
const getUpdatePreview = (update) => {
if (!update.content) return ''
const lines = update.content.split('\n')
if (lines.length <= 1 && update.content.length <= 80) return ''
// If the first line was truncated, show the full content as preview
if (lines.length <= 1) return ''
const rest = lines.slice(1).join(' ').trim()
if (!rest) return ''
return rest.length > 200 ? rest.substring(0, 200) + '...' : rest
}
// Format date with relative time
const formatDate = (date) => {
const now = new Date()
const updateDate = new Date(date)
const diffInSeconds = Math.floor((now - updateDate) / 1000)
if (diffInSeconds < 60) return 'just now'
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} minutes ago`
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)} days ago`
return updateDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: updateDate.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
})
}
// Check authentication
onMounted(async () => {
if (!isAuthenticated.value) {
const authenticated = await checkMemberStatus()
if (!authenticated) {
openLoginModal({
title: 'Sign in to view your updates',
description: 'Enter your email to access your updates',
})
return
}
}
await Promise.all([loadUpdates(), loadUpcomingEvents()])
})
// Load updates
const loadUpdates = async () => {
pending.value = true
try {
const response = await $fetch('/api/updates/my-updates', {
params: { limit: 20, skip: 0 },
})
updates.value = response.updates
total.value = response.total
hasMore.value = response.hasMore
} catch (error) {
console.error('Failed to load updates:', error)
} finally {
pending.value = false
}
}
// Load upcoming events for sidebar
const loadUpcomingEvents = async () => {
try {
const response = await $fetch('/api/events', {
params: { limit: 3, upcoming: true },
})
upcomingEvents.value = response.events || response || []
} catch (error) {
console.error('Failed to load upcoming events:', error)
}
}
// Load more updates
const loadMore = async () => {
loadingMore.value = true
try {
const response = await $fetch('/api/updates/my-updates', {
params: { limit: 20, skip: updates.value.length },
})
updates.value.push(...response.updates)
hasMore.value = response.hasMore
} catch (error) {
console.error('Failed to load more updates:', error)
} finally {
loadingMore.value = false
}
}
// Handle edit
const handleEdit = (update) => {
navigateTo(`/updates/${update._id}/edit`)
}
// Handle delete
const handleDelete = (update) => {
updateToDelete.value = update
showDeleteModal.value = true
}
// Confirm delete
const confirmDelete = async () => {
if (!updateToDelete.value) return
deleting.value = true
try {
await $fetch(`/api/updates/${updateToDelete.value._id}`, {
method: 'DELETE',
})
// Remove from list
updates.value = updates.value.filter(
(u) => u._id !== updateToDelete.value._id,
)
total.value--
showDeleteModal.value = false
updateToDelete.value = null
} catch (error) {
console.error('Failed to delete update:', error)
alert('Failed to delete update. Please try again.')
} finally {
deleting.value = false
}
}
useHead({
title: 'My Updates - Ghost Guild',
})
</script>
<style scoped>
.my-updates-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ---- TWO-COLUMN LAYOUT ---- */
.content-area {
flex: 1;
display: grid;
grid-template-columns: 1fr 200px;
align-items: stretch;
min-height: 0;
}
.content-main {
min-width: 0;
}
/* ---- STATS ROW ---- */
.stats-row {
padding: 16px 32px;
border-bottom: 1px dashed var(--border);
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
}
.stats-count {
color: var(--text-dim);
}
.stats-count strong {
color: var(--text-bright);
font-size: 18px;
}
/* ---- STATE BOXES (loading, empty, unauth) ---- */
.state-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 32px;
text-align: center;
}
.state-icon {
width: 48px;
height: 48px;
border: 1px dashed var(--border);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
color: var(--text-faint);
}
.state-heading {
font-family: 'Brygada 1918', serif;
font-size: 18px;
font-weight: 600;
color: var(--text-bright);
margin-bottom: 6px;
}
.state-text {
font-size: 12px;
color: var(--text-dim);
margin-bottom: 20px;
max-width: 320px;
}
.spinner {
width: 20px;
height: 20px;
border: 2px dashed var(--candle);
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 12px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ---- TIMELINE ---- */
.timeline-wrap {
padding: 24px 32px 48px;
}
.timeline {
position: relative;
padding-left: 32px;
}
.timeline::before {
content: '';
position: absolute;
left: 11px;
top: 0;
bottom: 0;
width: 1px;
border-left: 1px dashed var(--border);
}
.tl-item {
position: relative;
padding: 0 0 24px;
}
.tl-item:last-child {
padding-bottom: 0;
}
.tl-dot {
position: absolute;
left: -32px;
top: 2px;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
border: 1px dashed var(--border);
font-size: 11px;
color: var(--text-dim);
}
.tl-time {
font-size: 11px;
color: var(--text-faint);
margin-bottom: 2px;
}
.tl-text {
font-size: 13px;
color: var(--text);
line-height: 1.5;
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.tl-title {
color: var(--text-bright);
text-decoration: none;
font-weight: 500;
}
.tl-title:hover {
color: var(--candle);
text-decoration: underline;
}
.tl-edited {
font-size: 11px;
color: var(--text-faint);
}
.tl-detail {
font-size: 12px;
color: var(--text-dim);
margin-top: 4px;
padding: 8px 12px;
border-left: 2px solid var(--border);
line-height: 1.6;
}
.tl-images {
margin-top: 8px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.tl-image {
max-width: 200px;
height: auto;
border: 1px dashed var(--border);
}
.tl-actions {
margin-top: 6px;
display: flex;
align-items: center;
gap: 6px;
}
.tl-action-btn {
font-family: 'Commit Mono', monospace;
font-size: 11px;
color: var(--text-faint);
background: none;
border: none;
cursor: pointer;
padding: 0;
transition: color 0.15s;
}
.tl-action-btn:hover {
color: var(--candle);
}
.tl-action-danger:hover {
color: var(--ember);
}
.tl-action-sep {
color: var(--border);
font-size: 10px;
}
/* ---- LOAD MORE ---- */
.load-more {
display: flex;
justify-content: center;
padding-top: 8px;
}
/* ---- MODAL ---- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(42, 32, 21, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.modal-box {
background: var(--bg);
border: 1px dashed var(--border);
padding: 28px 32px;
max-width: 400px;
width: 90%;
}
.modal-heading {
font-family: 'Brygada 1918', serif;
font-size: 18px;
font-weight: 600;
color: var(--text-bright);
margin-bottom: 8px;
}
.modal-text {
font-size: 12px;
color: var(--text-dim);
line-height: 1.7;
margin-bottom: 20px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* ---- RESPONSIVE ---- */
@media (max-width: 1024px) {
.content-area {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.stats-row {
padding: 12px 20px;
}
.timeline-wrap {
padding: 20px 20px 40px;
}
.state-box {
padding: 48px 20px;
}
}
</style>

View file

@ -180,6 +180,18 @@
</p>
</div>
<!-- Recent Activity -->
<div v-if="activityEntries.length" class="profile-section">
<div class="section-label">Recent Activity</div>
<div class="activity-list">
<div v-for="entry in activityEntries" :key="entry._id" class="activity-item">
<UIcon :name="getActivity(entry).icon" class="activity-icon" />
<span class="activity-text">{{ getActivity(entry).text }}</span>
<span class="activity-time">{{ formatRelativeDate(entry.timestamp) }}</span>
</div>
</div>
</div>
<!-- Auth Notice -->
<div v-if="!isAuthenticated" class="auth-notice">
<p>Sign in to see full profile details</p>
@ -206,6 +218,8 @@
</template>
<script setup>
import { formatActivity } from '~/utils/activityText'
const route = useRoute();
const { isAuthenticated } = useAuth();
const { openLoginModal } = useLoginModal();
@ -231,6 +245,26 @@ const getInitials = (name) => {
// Fetch member data no await so the component renders immediately (no Suspense)
const { data, pending, error: fetchError } = useFetch(`/api/members/${id}`);
// Fetch public activity
const { data: activityData } = useFetch(`/api/members/${id}/activity`, {
params: { limit: 5 },
default: () => ({ entries: [] })
})
const activityEntries = computed(() => activityData.value?.entries || [])
const getActivity = (entry) => formatActivity(entry)
const formatRelativeDate = (date) => {
const now = new Date()
const d = new Date(date)
const diffInSeconds = Math.floor((now - d) / 1000)
if (diffInSeconds < 60) return 'just now'
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)}d ago`
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
const member = computed(() => data.value?.member || null);
const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => "");
@ -467,6 +501,39 @@ useHead({
margin-bottom: 6px;
}
/* ---- ACTIVITY ---- */
.activity-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.activity-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.activity-icon {
width: 14px;
height: 14px;
color: var(--text-faint);
flex-shrink: 0;
}
.activity-text {
color: var(--text-dim);
flex: 1;
min-width: 0;
}
.activity-time {
color: var(--text-faint);
font-size: 11px;
flex-shrink: 0;
}
/* ---- AUTH NOTICE ---- */
.auth-notice {
padding: 20px;

View file

@ -1,157 +0,0 @@
<template>
<div>
<div class="back-link">
<NuxtLink to="/member/my-updates">&larr; Back to My Updates</NuxtLink>
</div>
<div v-if="pending" class="loading">Loading update...</div>
<template v-else-if="update">
<div class="form-header">
<h1>Edit Update</h1>
</div>
<form @submit.prevent="handleSubmit" class="update-form">
<div class="field">
<label class="section-label">Content</label>
<textarea
v-model="form.content"
rows="8"
required
:disabled="saving"
></textarea>
<div class="char-count">{{ form.content.length }} / 50000</div>
</div>
<div class="field">
<label class="section-label">Visibility</label>
<select v-model="form.privacy" :disabled="saving">
<option value="members">Members only</option>
<option value="public">Public</option>
<option value="private">Private (only you)</option>
</select>
</div>
<div class="form-actions">
<NuxtLink to="/member/my-updates" class="btn">Cancel</NuxtLink>
<button
type="submit"
class="btn btn-primary"
:disabled="saving || !form.content.trim()"
>
{{ saving ? 'Saving...' : 'Save Changes' }}
</button>
</div>
</form>
</template>
<div v-else class="loading">Update not found.</div>
</div>
</template>
<script setup>
definePageMeta({ middleware: 'auth' })
const route = useRoute()
const toast = useToast()
const { data: update, pending } = await useFetch(`/api/updates/${route.params.id}`)
const form = ref({
content: update.value?.content || '',
privacy: update.value?.privacy || 'members',
})
watch(update, (val) => {
if (val) {
form.value.content = val.content || ''
form.value.privacy = val.privacy || 'members'
}
})
const saving = ref(false)
const handleSubmit = async () => {
saving.value = true
try {
await $fetch(`/api/updates/${route.params.id}`, {
method: 'PATCH',
body: { content: form.value.content, privacy: form.value.privacy },
})
toast.add({ title: 'Update saved!', color: 'green' })
navigateTo('/member/my-updates')
} catch (err) {
toast.add({
title: 'Failed to save update',
description: err.data?.statusMessage || 'Please try again.',
color: 'red',
})
} finally {
saving.value = false
}
}
useHead({ title: 'Edit Update - Ghost Guild' })
</script>
<style scoped>
.back-link {
padding: 12px 32px;
border-bottom: 1px dashed var(--border);
font-size: 12px;
}
.back-link a { color: var(--candle); text-decoration: none; }
.loading {
padding: 48px 32px;
font-size: 12px;
color: var(--text-dim);
}
.form-header {
padding: 28px 32px 20px;
border-bottom: 1px dashed var(--border);
}
.form-header h1 {
font-family: 'Brygada 1918', serif;
font-size: 24px;
font-weight: 600;
color: var(--text-bright);
}
.update-form {
padding: 24px 32px;
max-width: 640px;
display: flex;
flex-direction: column;
gap: 20px;
}
.field { display: flex; flex-direction: column; gap: 6px; }
textarea, select {
font-family: 'Commit Mono', monospace;
font-size: 13px;
background: var(--surface);
border: 1px dashed var(--border);
color: var(--text);
padding: 10px 12px;
resize: vertical;
width: 100%;
}
textarea:focus, select:focus { outline: none; border-color: var(--candle); }
textarea:disabled, select:disabled { opacity: 0.6; }
.char-count {
font-size: 11px;
color: var(--text-faint);
text-align: right;
}
.form-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
padding-top: 4px;
}
</style>

View file

@ -1,139 +0,0 @@
<template>
<div>
<div class="back-link">
<NuxtLink to="/member/my-updates">&larr; Back to My Updates</NuxtLink>
</div>
<div class="form-header">
<h1>New Update</h1>
<p>Share what you're working on with the community</p>
</div>
<form @submit.prevent="handleSubmit" class="update-form">
<div class="field">
<label class="section-label">Content</label>
<textarea
v-model="form.content"
rows="8"
required
placeholder="What's on your mind? Share a project update, milestone, or thought..."
:disabled="saving"
></textarea>
<div class="char-count">{{ form.content.length }} / 50000</div>
</div>
<div class="field">
<label class="section-label">Visibility</label>
<select v-model="form.privacy" :disabled="saving">
<option value="members">Members only</option>
<option value="public">Public</option>
<option value="private">Private (only you)</option>
</select>
</div>
<div class="form-actions">
<NuxtLink to="/member/my-updates" class="btn">Cancel</NuxtLink>
<button
type="submit"
class="btn btn-primary"
:disabled="saving || !form.content.trim()"
>
{{ saving ? 'Posting...' : 'Post Update' }}
</button>
</div>
</form>
</div>
</template>
<script setup>
definePageMeta({ middleware: 'auth' })
const toast = useToast()
const form = ref({
content: '',
privacy: 'members',
})
const saving = ref(false)
const handleSubmit = async () => {
saving.value = true
try {
await $fetch('/api/updates', {
method: 'POST',
body: { content: form.value.content, privacy: form.value.privacy },
})
toast.add({ title: 'Update posted!', color: 'green' })
navigateTo('/member/my-updates')
} catch (err) {
toast.add({
title: 'Failed to post update',
description: err.data?.statusMessage || 'Please try again.',
color: 'red',
})
} finally {
saving.value = false
}
}
useHead({ title: 'New Update - Ghost Guild' })
</script>
<style scoped>
.back-link {
padding: 12px 32px;
border-bottom: 1px dashed var(--border);
font-size: 12px;
}
.back-link a { color: var(--candle); text-decoration: none; }
.form-header {
padding: 28px 32px 0;
border-bottom: 1px dashed var(--border);
padding-bottom: 20px;
}
.form-header h1 {
font-family: 'Brygada 1918', serif;
font-size: 24px;
font-weight: 600;
color: var(--text-bright);
margin-bottom: 4px;
}
.form-header p { font-size: 12px; color: var(--text-dim); }
.update-form {
padding: 24px 32px;
max-width: 640px;
display: flex;
flex-direction: column;
gap: 20px;
}
.field { display: flex; flex-direction: column; gap: 6px; }
textarea, select {
font-family: 'Commit Mono', monospace;
font-size: 13px;
background: var(--surface);
border: 1px dashed var(--border);
color: var(--text);
padding: 10px 12px;
resize: vertical;
width: 100%;
}
textarea:focus, select:focus { outline: none; border-color: var(--candle); }
textarea:disabled, select:disabled { opacity: 0.6; }
.char-count {
font-size: 11px;
color: var(--text-faint);
text-align: right;
}
.form-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
padding-top: 4px;
}
</style>

93
app/utils/activityText.js Normal file
View file

@ -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 || {})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

View file

@ -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)
})
})

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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)

View file

@ -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);

View file

@ -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)
}
}

View file

@ -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({

View file

@ -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({

View file

@ -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')
})
})
})

View file

@ -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',

View file

@ -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' })

View file

@ -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())