Merge branch 'board-classifieds-redesign'
This commit is contained in:
commit
08fc3884da
46 changed files with 3050 additions and 1597 deletions
|
|
@ -14,6 +14,8 @@ RESEND_FROM_EMAIL=noreply@ghostguild.org
|
||||||
# Slack Integration
|
# Slack Integration
|
||||||
SLACK_WEBHOOK_URL=your-slack-webhook-url
|
SLACK_WEBHOOK_URL=your-slack-webhook-url
|
||||||
SLACK_OAUTH_TOKEN=your-slack-oauth-token
|
SLACK_OAUTH_TOKEN=your-slack-oauth-token
|
||||||
|
# AdminGhost bot token — used for admin-only channel creation. Falls back to SLACK_BOT_TOKEN if unset.
|
||||||
|
SLACK_ADMIN_BOT_TOKEN=xoxb-adminghost-token
|
||||||
|
|
||||||
# JWT Secret for authentication
|
# JWT Secret for authentication
|
||||||
JWT_SECRET=your-jwt-secret-key-change-this-in-production
|
JWT_SECRET=your-jwt-secret-key-change-this-in-production
|
||||||
|
|
|
||||||
344
app/components/BoardPostCard.vue
Normal file
344
app/components/BoardPostCard.vue
Normal file
|
|
@ -0,0 +1,344 @@
|
||||||
|
<template>
|
||||||
|
<article class="board-post">
|
||||||
|
<header class="post-header">
|
||||||
|
<span class="post-meta">{{ typeLabel }}</span>
|
||||||
|
<div v-if="editable" class="post-actions">
|
||||||
|
<button type="button" class="action-btn" @click="$emit('edit', post)">Edit</button>
|
||||||
|
<button type="button" class="action-btn danger" @click="$emit('delete', post)">Delete</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<h3 class="post-title">{{ post.title }}</h3>
|
||||||
|
|
||||||
|
<div v-if="post.seeking" class="post-block">
|
||||||
|
<div class="block-label">Seeking</div>
|
||||||
|
<p class="block-text">{{ post.seeking }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="post.offering" class="post-block">
|
||||||
|
<div class="block-label">Offering</div>
|
||||||
|
<p class="block-text">{{ post.offering }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="post.note" class="post-note">{{ post.note }}</p>
|
||||||
|
|
||||||
|
<div v-if="post.tags && post.tags.length" class="post-tags">
|
||||||
|
<span v-for="slug in post.tags" :key="slug" class="tag-pill">{{ tagLabel(slug) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="post-footer">
|
||||||
|
<div class="author">
|
||||||
|
<img
|
||||||
|
v-if="authorAvatar"
|
||||||
|
:src="authorAvatar"
|
||||||
|
:alt="post.author.name"
|
||||||
|
class="author-avatar"
|
||||||
|
>
|
||||||
|
<span v-else class="author-avatar avatar-placeholder" />
|
||||||
|
<span class="author-name">{{ post.author ? post.author.name : 'Unknown' }}</span>
|
||||||
|
<span v-if="slackHandle" class="slack-handle-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="slack-handle"
|
||||||
|
:title="copied ? 'Copied!' : 'Click to copy Slack handle'"
|
||||||
|
@click="copySlackHandle"
|
||||||
|
>@{{ slackHandle }}</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="copy-link"
|
||||||
|
:class="{ copied }"
|
||||||
|
@click="copySlackHandle"
|
||||||
|
>{{ copied ? 'Copied!' : 'Copy' }}</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
v-if="slackLinks.length === 1"
|
||||||
|
:href="slackLinks[0].url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="slack-link"
|
||||||
|
>Discuss in #{{ slackLinks[0].name }} →</a>
|
||||||
|
<details v-else-if="slackLinks.length > 1" class="slack-menu">
|
||||||
|
<summary class="slack-link">Discuss on Slack ▾</summary>
|
||||||
|
<ul class="slack-menu-list">
|
||||||
|
<li v-for="link in slackLinks" :key="link.id">
|
||||||
|
<a :href="link.url" target="_blank" rel="noopener" class="slack-link">#{{ link.name }}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
post: { type: Object, required: true },
|
||||||
|
channels: { type: Array, default: () => [] },
|
||||||
|
tags: { type: Array, default: () => [] },
|
||||||
|
editable: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['edit', 'delete'])
|
||||||
|
|
||||||
|
const capitalizeAvatar = (str) => {
|
||||||
|
if (str.toLowerCase() === 'wtf') return 'WTF'
|
||||||
|
return str
|
||||||
|
.split('-')
|
||||||
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||||||
|
.join('-')
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorAvatar = computed(() => {
|
||||||
|
const a = props.post.author?.avatar
|
||||||
|
if (!a) return null
|
||||||
|
return `/ghosties/Ghost-${capitalizeAvatar(a)}.png`
|
||||||
|
})
|
||||||
|
|
||||||
|
const slackHandle = computed(() => props.post.author?.board?.slackHandle || '')
|
||||||
|
|
||||||
|
const copied = ref(false)
|
||||||
|
const copySlackHandle = async () => {
|
||||||
|
if (!slackHandle.value) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(`@${slackHandle.value}`)
|
||||||
|
copied.value = true
|
||||||
|
setTimeout(() => { copied.value = false }, 1500)
|
||||||
|
} catch {
|
||||||
|
// clipboard unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagLabelMap = computed(() => {
|
||||||
|
const map = {}
|
||||||
|
for (const t of props.tags) map[t.slug] = t.label || t.name || t.slug
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
const tagLabel = (slug) => tagLabelMap.value[slug] || slug
|
||||||
|
|
||||||
|
const hasSeeking = computed(() => !!(props.post.seeking && props.post.seeking.trim()))
|
||||||
|
const hasOffering = computed(() => !!(props.post.offering && props.post.offering.trim()))
|
||||||
|
|
||||||
|
const typeLabel = computed(() => {
|
||||||
|
if (hasSeeking.value && hasOffering.value) return 'SEEKING + OFFERING'
|
||||||
|
if (hasSeeking.value) return 'SEEKING'
|
||||||
|
if (hasOffering.value) return 'OFFERING'
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const slackLinks = computed(() => {
|
||||||
|
const postTags = props.post.tags || []
|
||||||
|
if (!postTags.length) return []
|
||||||
|
return props.channels
|
||||||
|
.filter((c) => {
|
||||||
|
if (!c.slackChannelId) return false
|
||||||
|
const slugs = c.tagSlugs || []
|
||||||
|
return slugs.some((s) => postTags.includes(s))
|
||||||
|
})
|
||||||
|
.map((c) => ({
|
||||||
|
id: c.slackChannelId,
|
||||||
|
name: c.slackChannelName || c.name || c.slackChannelId,
|
||||||
|
url: `https://gammaspace.slack.com/archives/${c.slackChannelId}`,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.board-post {
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
padding: 18px 22px;
|
||||||
|
background: var(--surface);
|
||||||
|
break-inside: avoid;
|
||||||
|
-webkit-column-break-inside: avoid;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-meta {
|
||||||
|
font-family: "Commit Mono", monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.action-btn {
|
||||||
|
font-family: "Commit Mono", monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.12s;
|
||||||
|
}
|
||||||
|
.action-btn:hover {
|
||||||
|
color: var(--text-bright);
|
||||||
|
border-color: var(--border-d);
|
||||||
|
}
|
||||||
|
.action-btn.danger:hover {
|
||||||
|
color: var(--ember);
|
||||||
|
border-color: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-title {
|
||||||
|
font-family: "Brygada 1918", serif;
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-bright);
|
||||||
|
margin: 0 0 12px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-block {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.block-label {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.block-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-note {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-style: italic;
|
||||||
|
margin: 8px 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.tag-pill {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: "Commit Mono", monospace;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-footer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.author {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px 8px;
|
||||||
|
}
|
||||||
|
.author-avatar {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.avatar-placeholder {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.author-name {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: "Commit Mono", monospace;
|
||||||
|
}
|
||||||
|
.slack-handle-wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.slack-handle {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-family: "Commit Mono", monospace;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.slack-handle:hover {
|
||||||
|
color: var(--candle);
|
||||||
|
}
|
||||||
|
.copy-link {
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: "Commit Mono", monospace;
|
||||||
|
color: var(--candle);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.copy-link:hover {
|
||||||
|
color: var(--candle-dim);
|
||||||
|
}
|
||||||
|
.copy-link.copied {
|
||||||
|
color: var(--candle);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slack-menu {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.slack-menu > summary {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.slack-menu > summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.slack-menu-list {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 100%;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
list-style: none;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.slack-link {
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: "Commit Mono", monospace;
|
||||||
|
color: var(--candle);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px dashed var(--candle-faint);
|
||||||
|
}
|
||||||
|
.slack-link:hover {
|
||||||
|
color: var(--candle-dim);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom-style: solid;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
269
app/components/BoardPostForm.vue
Normal file
269
app/components/BoardPostForm.vue
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
<template>
|
||||||
|
<form class="post-form" @submit.prevent="handleSubmit">
|
||||||
|
<div class="form-header">
|
||||||
|
<h3 class="form-title">{{ isEdit ? 'Edit post' : 'New post' }}</h3>
|
||||||
|
<p class="form-hint">Fill in <em>Seeking</em> or <em>Offering</em> (or both).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="post-title">Title</label>
|
||||||
|
<input
|
||||||
|
id="post-title"
|
||||||
|
v-model="form.title"
|
||||||
|
type="text"
|
||||||
|
maxlength="120"
|
||||||
|
placeholder="Short summary"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label for="post-seeking">Seeking <span class="opt">(optional)</span></label>
|
||||||
|
<textarea
|
||||||
|
id="post-seeking"
|
||||||
|
v-model="form.seeking"
|
||||||
|
rows="2"
|
||||||
|
maxlength="500"
|
||||||
|
placeholder="What are you looking for?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="post-offering">Offering <span class="opt">(optional)</span></label>
|
||||||
|
<textarea
|
||||||
|
id="post-offering"
|
||||||
|
v-model="form.offering"
|
||||||
|
rows="2"
|
||||||
|
maxlength="500"
|
||||||
|
placeholder="What can you offer?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="post-note">Note <span class="opt">(optional)</span></label>
|
||||||
|
<textarea
|
||||||
|
id="post-note"
|
||||||
|
v-model="form.note"
|
||||||
|
rows="2"
|
||||||
|
maxlength="300"
|
||||||
|
placeholder="Anything else to add?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="tags.length" class="field">
|
||||||
|
<label>Tags</label>
|
||||||
|
<div class="pill-grid">
|
||||||
|
<button
|
||||||
|
v-for="tag in tags"
|
||||||
|
:key="tag.slug"
|
||||||
|
type="button"
|
||||||
|
class="pill"
|
||||||
|
:class="{ selected: form.tags.includes(tag.slug) }"
|
||||||
|
@click="toggleTag(tag.slug)"
|
||||||
|
>{{ tag.label || tag.name || tag.slug }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="form-error">{{ error }}</p>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn" @click="$emit('cancel')">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
{{ isEdit ? 'Save changes' : 'Post' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
post: { type: Object, default: null },
|
||||||
|
tags: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['submit', 'cancel'])
|
||||||
|
|
||||||
|
const isEdit = computed(() => !!props.post)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
title: props.post?.title || '',
|
||||||
|
seeking: props.post?.seeking || '',
|
||||||
|
offering: props.post?.offering || '',
|
||||||
|
note: props.post?.note || '',
|
||||||
|
tags: Array.isArray(props.post?.tags) ? [...props.post.tags] : [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
watch(() => props.post, (p) => {
|
||||||
|
form.title = p?.title || ''
|
||||||
|
form.seeking = p?.seeking || ''
|
||||||
|
form.offering = p?.offering || ''
|
||||||
|
form.note = p?.note || ''
|
||||||
|
form.tags = Array.isArray(p?.tags) ? [...p.tags] : []
|
||||||
|
}, { immediate: false })
|
||||||
|
|
||||||
|
function toggleTag(slug) {
|
||||||
|
const idx = form.tags.indexOf(slug)
|
||||||
|
if (idx === -1) form.tags.push(slug)
|
||||||
|
else form.tags.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
error.value = ''
|
||||||
|
const title = form.title.trim()
|
||||||
|
const seeking = form.seeking.trim()
|
||||||
|
const offering = form.offering.trim()
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
error.value = 'Title is required.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!seeking && !offering) {
|
||||||
|
error.value = 'Add at least one of Seeking or Offering.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('submit', {
|
||||||
|
title,
|
||||||
|
seeking,
|
||||||
|
offering,
|
||||||
|
note: form.note.trim(),
|
||||||
|
tags: [...form.tags],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.post-form {
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-header {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.form-title {
|
||||||
|
font-family: "Brygada 1918", serif;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
.form-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-family: "Commit Mono", monospace;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.form-hint em {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
.field label .opt {
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
font-size: 9px;
|
||||||
|
margin-left: 4px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.field input,
|
||||||
|
.field textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-family: "Commit Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-bright);
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
outline: none;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.field input:focus,
|
||||||
|
.field textarea:focus {
|
||||||
|
border-color: var(--candle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-count {
|
||||||
|
font-size: 9px;
|
||||||
|
font-family: "Commit Mono", monospace;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-align: right;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 9px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: "Commit Mono", monospace;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: all 0.12s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pill:hover {
|
||||||
|
color: var(--text-dim);
|
||||||
|
border-color: var(--border-d);
|
||||||
|
}
|
||||||
|
.pill.selected {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-bright);
|
||||||
|
border-color: var(--candle);
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ember);
|
||||||
|
margin: 8px 0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px dashed var(--ember);
|
||||||
|
background: var(--ember-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.field-row {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,23 +1,17 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="coop-tag-selector">
|
<div class="coop-tag-selector">
|
||||||
<div
|
<div class="pill-grid">
|
||||||
|
<button
|
||||||
v-for="tag in tags"
|
v-for="tag in tags"
|
||||||
:key="tag.slug"
|
:key="tag.slug"
|
||||||
class="coop-row"
|
type="button"
|
||||||
>
|
class="pill"
|
||||||
<span class="tag-label">{{ tag.label }}</span>
|
:class="{ selected: modelValue.includes(tag.slug) }"
|
||||||
<div class="segmented">
|
@click="toggle(tag.slug)"
|
||||||
<span
|
>{{ tag.label || tag.name || tag.slug }}</button>
|
||||||
v-for="opt in options"
|
|
||||||
:key="opt.value"
|
|
||||||
class="seg-option"
|
|
||||||
:class="{ on: getState(tag.slug) === opt.value }"
|
|
||||||
@click="toggleState(tag.slug, opt.value)"
|
|
||||||
>{{ opt.label }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="suggest-link">
|
<div class="suggest-link">
|
||||||
<span @click="$emit('suggest')">Don't see what you're looking for?</span>
|
<button type="button" class="suggest-btn" @click="$emit('suggest')">Don't see what you're looking for?</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -30,32 +24,15 @@ const props = defineProps({
|
||||||
|
|
||||||
const emit = defineEmits(["update:modelValue", "suggest"]);
|
const emit = defineEmits(["update:modelValue", "suggest"]);
|
||||||
|
|
||||||
const options = [
|
function toggle(slug) {
|
||||||
{ label: "Can help", value: "help" },
|
|
||||||
{ label: "Interested", value: "interested" },
|
|
||||||
{ label: "Need help", value: "seeking" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function getState(slug) {
|
|
||||||
const entry = props.modelValue.find((e) => e.tagSlug === slug);
|
|
||||||
return entry ? entry.state : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleState(slug, value) {
|
|
||||||
const current = [...props.modelValue];
|
const current = [...props.modelValue];
|
||||||
const idx = current.findIndex((e) => e.tagSlug === slug);
|
const idx = current.indexOf(slug);
|
||||||
const existingState = idx !== -1 ? current[idx].state : null;
|
if (idx === -1) {
|
||||||
|
emit("update:modelValue", [...current, slug]);
|
||||||
if (existingState === value) {
|
|
||||||
// clicking active state deselects it
|
|
||||||
if (idx !== -1) current.splice(idx, 1);
|
|
||||||
} else if (idx !== -1) {
|
|
||||||
current[idx] = { tagSlug: slug, state: value };
|
|
||||||
} else {
|
} else {
|
||||||
current.push({ tagSlug: slug, state: value });
|
current.splice(idx, 1);
|
||||||
}
|
|
||||||
|
|
||||||
emit("update:modelValue", current);
|
emit("update:modelValue", current);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -63,87 +40,60 @@ function toggleState(slug, value) {
|
||||||
.coop-tag-selector {
|
.coop-tag-selector {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coop-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 4px 0;
|
|
||||||
border-bottom: 1px dashed var(--border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.coop-row:first-child {
|
.pill-grid {
|
||||||
border-top: 1px dashed var(--border);
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-label {
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 9px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-faint);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: "Commit Mono", monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
color: var(--text-dim);
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.segmented {
|
|
||||||
display: inline-flex;
|
|
||||||
gap: 0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.seg-option {
|
|
||||||
padding: 2px 7px;
|
|
||||||
height: 18px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border: 1px dashed var(--border);
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-size: 9px;
|
|
||||||
font-family: "Commit Mono", monospace;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.12s;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
transition: all 0.12s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.seg-option + .seg-option {
|
.pill:hover {
|
||||||
margin-left: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.seg-option:hover {
|
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
|
border-color: var(--border-d);
|
||||||
}
|
}
|
||||||
|
|
||||||
.seg-option.on {
|
.pill.selected {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
border-color: var(--candle);
|
border-color: var(--candle);
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
z-index: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggest-link {
|
.suggest-link {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggest-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-family: "Commit Mono", monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggest-link span {
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggest-link span:hover {
|
.suggest-btn:hover {
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
export const useBoard = () => {
|
|
||||||
const getSuggestions = (params = {}) =>
|
|
||||||
$fetch('/api/board/suggestions', { params })
|
|
||||||
|
|
||||||
return { getSuggestions }
|
|
||||||
}
|
|
||||||
33
app/composables/useBoardChannels.js
Normal file
33
app/composables/useBoardChannels.js
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
/**
|
||||||
|
* Board Channels Composable
|
||||||
|
* Shared state + helpers for mapping board tags to Slack channels.
|
||||||
|
*/
|
||||||
|
export function useBoardChannels() {
|
||||||
|
const channels = useState('board.channels', () => [])
|
||||||
|
|
||||||
|
async function fetchChannels() {
|
||||||
|
const result = await $fetch('/api/board/channels')
|
||||||
|
channels.value = result?.channels || []
|
||||||
|
return channels.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTagChannel(tagSlugs = []) {
|
||||||
|
if (!tagSlugs?.length) return null
|
||||||
|
return (
|
||||||
|
channels.value.find((channel) =>
|
||||||
|
(channel.tagSlugs || []).some((slug) => tagSlugs.includes(slug))
|
||||||
|
) || null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function slackUrl(channelId) {
|
||||||
|
return `https://gammaspace.slack.com/archives/${channelId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
channels: readonly(channels),
|
||||||
|
fetchChannels,
|
||||||
|
resolveTagChannel,
|
||||||
|
slackUrl,
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/composables/useBoardPosts.js
Normal file
54
app/composables/useBoardPosts.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
/**
|
||||||
|
* Board Posts Composable
|
||||||
|
* Shared state + CRUD for board posts.
|
||||||
|
*/
|
||||||
|
export function useBoardPosts() {
|
||||||
|
const posts = useState('board.posts', () => [])
|
||||||
|
const loading = useState('board.loading', () => false)
|
||||||
|
|
||||||
|
async function fetchPosts(params = {}) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await $fetch('/api/board/posts', { params })
|
||||||
|
posts.value = result?.posts || []
|
||||||
|
return posts.value
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPost(body, refreshParams = {}) {
|
||||||
|
const created = await $fetch('/api/board/posts', {
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
await fetchPosts(refreshParams)
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePost(id, body, refreshParams = {}) {
|
||||||
|
const updated = await $fetch(`/api/board/posts/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
await fetchPosts(refreshParams)
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePost(id, refreshParams = {}) {
|
||||||
|
const result = await $fetch(`/api/board/posts/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
await fetchPosts(refreshParams)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
posts: readonly(posts),
|
||||||
|
loading: readonly(loading),
|
||||||
|
fetchPosts,
|
||||||
|
createPost,
|
||||||
|
updatePost,
|
||||||
|
deletePost,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,6 @@ export function useOnboarding(options = {}) {
|
||||||
const loading = useState('onboarding.loading', () => false)
|
const loading = useState('onboarding.loading', () => false)
|
||||||
const recommendations = useState('onboarding.recommendations', () => ({
|
const recommendations = useState('onboarding.recommendations', () => ({
|
||||||
events: [],
|
events: [],
|
||||||
board: [],
|
|
||||||
wiki: [],
|
wiki: [],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -72,7 +71,7 @@ export function useOnboarding(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Graduated — suggestion mode
|
// Graduated — suggestion mode
|
||||||
const cats = ['events', 'board', 'wiki'].filter(
|
const cats = ['events', 'wiki'].filter(
|
||||||
(c) => recommendations.value[c]?.length > 0
|
(c) => recommendations.value[c]?.length > 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -99,14 +98,6 @@ export function useOnboarding(options = {}) {
|
||||||
actionText: 'View event',
|
actionText: 'View event',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (category === 'board') {
|
|
||||||
return {
|
|
||||||
key: 'board',
|
|
||||||
text: `Connect with ${item.name || 'a member'} on the board`,
|
|
||||||
action: '/board',
|
|
||||||
actionText: 'Explore board',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (category === 'wiki') {
|
if (category === 'wiki') {
|
||||||
return {
|
return {
|
||||||
key: 'wiki',
|
key: 'wiki',
|
||||||
|
|
@ -144,14 +135,12 @@ export function useOnboarding(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchRecommendations() {
|
async function fetchRecommendations() {
|
||||||
const [events, board, wiki] = await Promise.allSettled([
|
const [events, wiki] = await Promise.allSettled([
|
||||||
$fetch('/api/events/recommended'),
|
$fetch('/api/events/recommended'),
|
||||||
$fetch('/api/board/suggestions'),
|
|
||||||
$fetch('/api/wiki/recommended'),
|
$fetch('/api/wiki/recommended'),
|
||||||
])
|
])
|
||||||
recommendations.value = {
|
recommendations.value = {
|
||||||
events: events.status === 'fulfilled' ? (events.value || []) : [],
|
events: events.status === 'fulfilled' ? (events.value || []) : [],
|
||||||
board: board.status === 'fulfilled' ? (board.value?.suggestions || []) : [],
|
|
||||||
wiki: wiki.status === 'fulfilled' ? (wiki.value || []) : [],
|
wiki: wiki.status === 'fulfilled' ? (wiki.value || []) : [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,14 @@
|
||||||
Wiki
|
Wiki
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
to="/admin/board-channels"
|
||||||
|
:class="{ active: route.path.startsWith('/admin/board-channels') }"
|
||||||
|
>
|
||||||
|
Board Channels
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="sidebar-section">Site</div>
|
<div class="sidebar-section">Site</div>
|
||||||
|
|
@ -153,6 +161,15 @@
|
||||||
Wiki
|
Wiki
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
to="/admin/board-channels"
|
||||||
|
:class="{ active: route.path.startsWith('/admin/board-channels') }"
|
||||||
|
@click="isMobileMenuOpen = false"
|
||||||
|
>
|
||||||
|
Board Channels
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="sidebar-section">Site</div>
|
<div class="sidebar-section">Site</div>
|
||||||
|
|
|
||||||
596
app/pages/admin/board-channels.vue
Normal file
596
app/pages/admin/board-channels.vue
Normal file
|
|
@ -0,0 +1,596 @@
|
||||||
|
<template>
|
||||||
|
<div class="admin-board-channels">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="header-row">
|
||||||
|
<div>
|
||||||
|
<h1>Board Channels</h1>
|
||||||
|
<p>Create Slack channels for cooperative tags. New channels are created in Slack when you click Create Channel.</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="btn btn-primary" @click="openCreateModal">+ New Channel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unmapped Tags Indicator -->
|
||||||
|
<div v-if="unmappedTags.length > 0" class="unmapped-block">
|
||||||
|
<div class="section-label">Unmapped Cooperative Tags</div>
|
||||||
|
<p class="unmapped-hint">These cooperative tags are not yet mapped to any board channel:</p>
|
||||||
|
<div class="tag-pills">
|
||||||
|
<span v-for="tag in unmappedTags" :key="tag.slug" class="tag-pill tag-pill-warning">
|
||||||
|
{{ tag.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Channels List -->
|
||||||
|
<div class="channels-list">
|
||||||
|
<div v-if="!channels.length" class="empty-state">
|
||||||
|
<p>No board channels configured yet.</p>
|
||||||
|
<p class="empty-hint">Click "+ New Channel" to create your first board channel in Slack.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table v-else class="channels-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Channel</th>
|
||||||
|
<th>Mapped Tags</th>
|
||||||
|
<th class="actions-col">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="channel in channels" :key="channel._id">
|
||||||
|
<td class="name-cell">
|
||||||
|
<div class="channel-name">{{ channel.name }}</div>
|
||||||
|
<div class="channel-id">{{ channel.slackChannelId }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="tag-pills">
|
||||||
|
<span
|
||||||
|
v-for="slug in channel.tagSlugs || []"
|
||||||
|
:key="slug"
|
||||||
|
class="tag-pill"
|
||||||
|
>
|
||||||
|
{{ tagLabel(slug) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="!(channel.tagSlugs || []).length" class="tag-empty">—</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<button class="link-btn" @click="openEditModal(channel)">Edit</button>
|
||||||
|
<button class="link-btn link-btn-danger" @click="deleteChannel(channel)">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create / Edit Modal -->
|
||||||
|
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>{{ editingId ? 'Edit Channel' : 'New Channel' }}</h2>
|
||||||
|
<button class="modal-close" @click="closeModal">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>Name</label>
|
||||||
|
<input v-model="formData.name" type="text" placeholder="e.g., coop-formation" />
|
||||||
|
<p v-if="!editingId" class="help-text">A new Slack channel will be created with this name. Lowercase, letters/numbers/dashes only.</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="editingId" class="field">
|
||||||
|
<label>Slack Channel ID</label>
|
||||||
|
<input v-model="formData.slackChannelId" type="text" placeholder="C0123456789" />
|
||||||
|
<p class="help-text">The Slack channel ID (starts with C).</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Mapped Tags</label>
|
||||||
|
<p class="help-text">Cooperative tags that route posts to this channel.</p>
|
||||||
|
<div class="pill-grid">
|
||||||
|
<button
|
||||||
|
v-for="tag in cooperativeTags"
|
||||||
|
:key="tag.slug"
|
||||||
|
type="button"
|
||||||
|
class="pill"
|
||||||
|
:class="{
|
||||||
|
selected: formData.tagSlugs.includes(tag.slug),
|
||||||
|
disabled: tagOwner(tag.slug) && !formData.tagSlugs.includes(tag.slug),
|
||||||
|
}"
|
||||||
|
:disabled="!!(tagOwner(tag.slug) && !formData.tagSlugs.includes(tag.slug))"
|
||||||
|
:title="tagOwner(tag.slug) && !formData.tagSlugs.includes(tag.slug)
|
||||||
|
? `Already mapped to ${tagOwner(tag.slug)}`
|
||||||
|
: ''"
|
||||||
|
@click="toggleTag(tag.slug)"
|
||||||
|
>{{ tag.label }}<span
|
||||||
|
v-if="tagOwner(tag.slug) && !formData.tagSlugs.includes(tag.slug)"
|
||||||
|
class="pill-owner"
|
||||||
|
> · {{ tagOwner(tag.slug) }}</span></button>
|
||||||
|
<p v-if="!cooperativeTags.length" class="help-text">No cooperative tags available.</p>
|
||||||
|
</div>
|
||||||
|
<p class="help-text">Each tag can only be mapped to one channel.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn" @click="closeModal">Cancel</button>
|
||||||
|
<button class="btn btn-primary" :disabled="saving" @click="saveChannel">
|
||||||
|
{{ saving ? 'Saving...' : (editingId ? 'Save Changes' : 'Create Channel') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin',
|
||||||
|
middleware: 'admin',
|
||||||
|
})
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const { channels, fetchChannels } = useBoardChannels()
|
||||||
|
|
||||||
|
const { data: tagsData } = await useFetch('/api/tags')
|
||||||
|
|
||||||
|
const cooperativeTags = computed(() =>
|
||||||
|
(tagsData.value?.tags || []).filter((t) => t.pool === 'cooperative'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tagLabelMap = computed(() => {
|
||||||
|
const map = {}
|
||||||
|
for (const tag of tagsData.value?.tags || []) {
|
||||||
|
map[tag.slug] = tag.label
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const tagLabel = (slug) => tagLabelMap.value[slug] || slug
|
||||||
|
|
||||||
|
const mappedSlugs = computed(() => {
|
||||||
|
const set = new Set()
|
||||||
|
for (const ch of channels.value) {
|
||||||
|
for (const slug of ch.tagSlugs || []) set.add(slug)
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
})
|
||||||
|
|
||||||
|
// Map of slug -> channel name, EXCLUDING the channel currently being edited.
|
||||||
|
const otherChannelTagMap = computed(() => {
|
||||||
|
const map = {}
|
||||||
|
for (const ch of channels.value) {
|
||||||
|
if (editingId.value && String(ch._id) === String(editingId.value)) continue
|
||||||
|
for (const slug of ch.tagSlugs || []) map[slug] = ch.name
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const tagOwner = (slug) => otherChannelTagMap.value[slug] || ''
|
||||||
|
|
||||||
|
const unmappedTags = computed(() =>
|
||||||
|
cooperativeTags.value.filter((t) => !mappedSlugs.value.has(t.slug)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Modal State ----
|
||||||
|
const showModal = ref(false)
|
||||||
|
const editingId = ref(null)
|
||||||
|
const saving = ref(false)
|
||||||
|
const formData = reactive({
|
||||||
|
name: '',
|
||||||
|
slackChannelId: '',
|
||||||
|
tagSlugs: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
formData.name = ''
|
||||||
|
formData.slackChannelId = ''
|
||||||
|
formData.tagSlugs = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
editingId.value = null
|
||||||
|
resetForm()
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditModal = (channel) => {
|
||||||
|
editingId.value = channel._id
|
||||||
|
formData.name = channel.name || ''
|
||||||
|
formData.slackChannelId = channel.slackChannelId || ''
|
||||||
|
formData.tagSlugs = [...(channel.tagSlugs || [])]
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
showModal.value = false
|
||||||
|
editingId.value = null
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleTag = (slug) => {
|
||||||
|
const idx = formData.tagSlugs.indexOf(slug)
|
||||||
|
if (idx === -1) formData.tagSlugs.push(slug)
|
||||||
|
else formData.tagSlugs.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveChannel = async () => {
|
||||||
|
if (!formData.name.trim()) {
|
||||||
|
toast.add({
|
||||||
|
title: 'Missing fields',
|
||||||
|
description: 'Name is required.',
|
||||||
|
color: 'red',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (editingId.value && !formData.slackChannelId.trim()) {
|
||||||
|
toast.add({
|
||||||
|
title: 'Missing fields',
|
||||||
|
description: 'Slack channel ID is required.',
|
||||||
|
color: 'red',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
name: formData.name.trim(),
|
||||||
|
tagSlugs: formData.tagSlugs,
|
||||||
|
}
|
||||||
|
if (formData.slackChannelId.trim()) {
|
||||||
|
body.slackChannelId = formData.slackChannelId.trim()
|
||||||
|
}
|
||||||
|
if (editingId.value) {
|
||||||
|
await $fetch(`/api/admin/board-channels/${editingId.value}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
toast.add({ title: 'Channel updated', color: 'green' })
|
||||||
|
} else {
|
||||||
|
await $fetch('/api/admin/board-channels', {
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
toast.add({ title: 'Channel created', color: 'green' })
|
||||||
|
}
|
||||||
|
await fetchChannels()
|
||||||
|
closeModal()
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({
|
||||||
|
title: 'Save failed',
|
||||||
|
description: err.data?.statusMessage || err.message,
|
||||||
|
color: 'red',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteChannel = async (channel) => {
|
||||||
|
if (!window.confirm(`Delete channel "${channel.name}"? This cannot be undone.`)) return
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/admin/board-channels/${channel._id}`, { method: 'DELETE' })
|
||||||
|
toast.add({ title: 'Channel deleted', color: 'green' })
|
||||||
|
await fetchChannels()
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({
|
||||||
|
title: 'Delete failed',
|
||||||
|
description: err.data?.statusMessage || err.message,
|
||||||
|
color: 'red',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchChannels()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-board-channels {
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 1100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
font-family: 'Brygada 1918', serif;
|
||||||
|
font-size: 28px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header p {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Unmapped Indicator ---- */
|
||||||
|
.unmapped-block {
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unmapped-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin: 4px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Tag Pills ---- */
|
||||||
|
.tag-pills {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: "Commit Mono", monospace;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-pill-warning {
|
||||||
|
border-color: var(--ember);
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-empty {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Table ---- */
|
||||||
|
.channels-list {
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-table th,
|
||||||
|
.channels-table td {
|
||||||
|
padding: 10px 14px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-table thead th {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-weight: normal;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.channel-id {
|
||||||
|
font-family: 'Commit Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-col {
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-cell {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--candle-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn:hover {
|
||||||
|
color: var(--candle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn-danger {
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn-danger:hover {
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 40px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Modal ---- */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
font-family: 'Brygada 1918', serif;
|
||||||
|
font-size: 20px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-dim);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--candle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 9px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: "Commit Mono", monospace;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: all 0.12s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pill:hover {
|
||||||
|
color: var(--text-dim);
|
||||||
|
border-color: var(--border-d);
|
||||||
|
}
|
||||||
|
.pill.selected {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-bright);
|
||||||
|
border-color: var(--candle);
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
.pill.disabled,
|
||||||
|
.pill:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.pill.disabled:hover {
|
||||||
|
color: var(--text-faint);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
.pill-owner {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,127 +1,81 @@
|
||||||
<template>
|
<template>
|
||||||
<PageShell title="Board" :subtitle="pageSubtitle">
|
<PageShell title="Bulletin Board" :subtitle="pageSubtitle">
|
||||||
|
<!-- Action bar -->
|
||||||
|
<div class="action-bar">
|
||||||
|
<button type="button" class="new-post-btn" @click="openNewForm">
|
||||||
|
+ New Post
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tags Drawer Toggle -->
|
<!-- Tags Drawer Toggle -->
|
||||||
<div v-if="boardTagOptions.length > 0" class="tags-drawer-toggle">
|
<div v-if="cooperativeTags.length > 0" class="tags-drawer-toggle">
|
||||||
<button type="button" class="drawer-btn" @click="showTagsDrawer = !showTagsDrawer">
|
<button type="button" class="drawer-btn" @click="showTagsDrawer = !showTagsDrawer">
|
||||||
Tags...
|
Tags...
|
||||||
<span v-if="boardFilterTags.length > 0" class="tag-count-badge">{{ boardFilterTags.length }}</span>
|
<span v-if="activeTagFilter" class="tag-count-badge">1</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tags Drawer -->
|
<!-- Tags Drawer -->
|
||||||
<div v-if="showTagsDrawer && boardTagOptions.length > 0" class="tags-drawer">
|
<div v-if="showTagsDrawer && cooperativeTags.length > 0" class="tags-drawer">
|
||||||
<div class="skills-bar">
|
<div class="skills-bar">
|
||||||
<span class="tag-label">Topics:</span>
|
<span class="tag-label">Filter:</span>
|
||||||
<button
|
<button
|
||||||
v-for="tag in visibleTagOptions"
|
v-for="tag in visibleTagOptions"
|
||||||
:key="tag.slug"
|
:key="tag.slug"
|
||||||
type="button"
|
type="button"
|
||||||
class="skill-tag"
|
class="skill-tag"
|
||||||
:class="{ active: boardFilterTags.includes(tag.slug) }"
|
:class="{ active: activeTagFilter === tag.slug }"
|
||||||
@click="toggleTag(tag.slug)"
|
@click="toggleTagFilter(tag.slug)"
|
||||||
>
|
>
|
||||||
{{ tag.label }}
|
{{ tag.label || tag.name }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="boardTagOptions.length > 10"
|
v-if="cooperativeTags.length > 10"
|
||||||
type="button"
|
type="button"
|
||||||
class="more-btn"
|
class="more-btn"
|
||||||
@click="showAllTags = !showAllTags"
|
@click="showAllTags = !showAllTags"
|
||||||
>
|
>
|
||||||
{{ showAllTags ? 'Show less' : `+${boardTagOptions.length - 10} more` }}
|
{{ showAllTags ? 'Show less' : `+${cooperativeTags.length - 10} more` }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Board Content -->
|
<!-- Inline form -->
|
||||||
|
<div v-if="showForm" class="form-wrapper">
|
||||||
|
<BoardPostForm
|
||||||
|
:post="editingPost"
|
||||||
|
:tags="cooperativeTags"
|
||||||
|
@submit="handleSubmit"
|
||||||
|
@cancel="closeForm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<div v-if="loading" class="loading-state">
|
<div v-if="loading" class="loading-state">
|
||||||
<p>Loading board...</p>
|
<p>Loading board...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- No topics empty state -->
|
<div v-if="posts.length === 0" class="empty-state">
|
||||||
<div v-if="hasNoTopics" class="empty-state">
|
<p class="empty-title">No posts yet.</p>
|
||||||
<p class="empty-title">No topics yet</p>
|
<p class="empty-sub">Be the first to post.</p>
|
||||||
<p class="empty-sub">
|
<button type="button" class="new-post-btn" @click="openNewForm">
|
||||||
<NuxtLink to="/member/profile">Add topics to your profile</NuxtLink> to find connections.
|
+ New Post
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Suggestions grid -->
|
|
||||||
<div v-else-if="filteredSuggestions.length > 0" class="member-grid">
|
|
||||||
<div
|
|
||||||
v-for="suggestion in filteredSuggestions"
|
|
||||||
:key="suggestion.member._id"
|
|
||||||
class="member-card board-card"
|
|
||||||
>
|
|
||||||
<div class="mc-head">
|
|
||||||
<div class="mc-avatar">
|
|
||||||
<img
|
|
||||||
v-if="suggestion.member.avatar"
|
|
||||||
:src="`/ghosties/Ghost-${capitalize(suggestion.member.avatar)}.png`"
|
|
||||||
:alt="suggestion.member.name"
|
|
||||||
class="mc-avatar-img"
|
|
||||||
/>
|
|
||||||
<span v-else>{{ getInitials(suggestion.member.name) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="mc-info">
|
|
||||||
<div class="cc-name">
|
|
||||||
<NuxtLink :to="`/members/${suggestion.member._id}`">
|
|
||||||
{{ suggestion.member.name }}
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
<div class="cc-meta">
|
|
||||||
<CircleBadge :circle="suggestion.member.circle || 'community'" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="suggestion.member.craftTags?.length" class="cc-craft-tags">
|
|
||||||
<span
|
|
||||||
v-for="tag in suggestion.member.craftTags.slice(0, 5)"
|
|
||||||
:key="tag"
|
|
||||||
class="craft-pill"
|
|
||||||
>{{ craftTagLabel(tag) }}</span>
|
|
||||||
<span v-if="suggestion.member.craftTags.length > 5" class="tag-overflow">+{{ suggestion.member.craftTags.length - 5 }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cc-matches">
|
|
||||||
<div
|
|
||||||
v-for="match in suggestion.matchingTags"
|
|
||||||
:key="match.tagSlug"
|
|
||||||
class="match-row"
|
|
||||||
>
|
|
||||||
<span class="match-tag">{{ boardTagLabel(match.tagSlug) }}</span>
|
|
||||||
<span class="match-states">
|
|
||||||
<span class="match-you">You: {{ stateLabel(match.yourState) }}</span>
|
|
||||||
<span class="match-sep">·</span>
|
|
||||||
<span class="match-them">They: {{ stateLabel(match.theirState) }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="suggestion.member.slackHandle" class="cc-contact">
|
|
||||||
<span class="cc-slack">@{{ suggestion.member.slackHandle }}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="text-action"
|
|
||||||
@click="copyHandle(suggestion.member.slackHandle)"
|
|
||||||
>
|
|
||||||
{{ copiedHandle === suggestion.member.slackHandle ? 'Copied!' : 'Copy' }}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- No matches -->
|
<div v-else class="post-grid">
|
||||||
<div v-else class="empty-state">
|
<BoardPostCard
|
||||||
<p class="empty-title">No matches yet</p>
|
v-for="post in posts"
|
||||||
<p class="empty-sub">
|
:key="post._id"
|
||||||
Add cooperative topics to your
|
:post="post"
|
||||||
<NuxtLink to="/member/profile">profile</NuxtLink>
|
:channels="channels"
|
||||||
to find members with shared interests.
|
:tags="cooperativeTags"
|
||||||
</p>
|
:editable="isAuthor(post)"
|
||||||
|
@edit="handleEdit"
|
||||||
|
@delete="handleDelete"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -138,176 +92,139 @@
|
||||||
definePageMeta({ middleware: ['members-auth'] })
|
definePageMeta({ middleware: ['members-auth'] })
|
||||||
|
|
||||||
const { memberData } = useAuth()
|
const { memberData } = useAuth()
|
||||||
const { getSuggestions } = useBoard()
|
const { posts, loading, fetchPosts, createPost, updatePost, deletePost } = useBoardPosts()
|
||||||
const { trackGoal, isComplete } = useOnboarding()
|
const { channels, fetchChannels } = useBoardChannels()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
// ---- State ----
|
const cooperativeTags = ref([])
|
||||||
const suggestions = ref([])
|
|
||||||
const loading = ref(false)
|
|
||||||
const boardTagOptions = ref([])
|
|
||||||
const boardFilterTags = ref([])
|
|
||||||
const copiedHandle = ref(null)
|
|
||||||
const showAllTags = ref(false)
|
|
||||||
const showTagsDrawer = ref(false)
|
const showTagsDrawer = ref(false)
|
||||||
const craftTagOptions = ref([])
|
const showAllTags = ref(false)
|
||||||
const cooperativeTagOptions = ref([])
|
const activeTagFilter = ref(null)
|
||||||
|
|
||||||
// ---- Helpers ----
|
const showForm = ref(false)
|
||||||
const stateLabels = {
|
const editingPost = ref(null)
|
||||||
help: 'Can help',
|
|
||||||
interested: 'Interested',
|
|
||||||
seeking: 'Need help',
|
|
||||||
}
|
|
||||||
const stateLabel = (state) => stateLabels[state] || state || ''
|
|
||||||
|
|
||||||
const craftTagLabel = (slug) => {
|
const currentMemberId = computed(() => memberData.value?._id || null)
|
||||||
const found = craftTagOptions.value.find((t) => t.slug === slug)
|
|
||||||
return found ? found.label : slug
|
|
||||||
}
|
|
||||||
|
|
||||||
const boardTagLabel = (slug) => {
|
|
||||||
const found = boardTagOptions.value.find((t) => t.slug === slug)
|
|
||||||
if (found) return found.label
|
|
||||||
const fallback = cooperativeTagOptions.value.find((t) => t.slug === slug)
|
|
||||||
return fallback ? fallback.label : slug
|
|
||||||
}
|
|
||||||
|
|
||||||
const getInitials = (name) => {
|
|
||||||
if (!name) return '?'
|
|
||||||
return name
|
|
||||||
.split(' ')
|
|
||||||
.map((w) => w[0])
|
|
||||||
.join('')
|
|
||||||
.toUpperCase()
|
|
||||||
.slice(0, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
const capitalize = (str) => {
|
|
||||||
if (!str) return ''
|
|
||||||
return str
|
|
||||||
.split('-')
|
|
||||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
|
||||||
.join('-')
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Computed ----
|
|
||||||
const visibleTagOptions = computed(() =>
|
|
||||||
showAllTags.value ? boardTagOptions.value : boardTagOptions.value.slice(0, 10)
|
|
||||||
)
|
|
||||||
|
|
||||||
const hasNoTopics = computed(() => {
|
|
||||||
if (!memberData.value) return false
|
|
||||||
const topics = memberData.value?.board?.topics
|
|
||||||
return !topics || topics.length === 0
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredSuggestions = computed(() => {
|
|
||||||
if (boardFilterTags.value.length === 0) return suggestions.value
|
|
||||||
return suggestions.value.filter((s) =>
|
|
||||||
s.matchingTags.some((m) => boardFilterTags.value.includes(m.tagSlug))
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const pageSubtitle = computed(() => {
|
const pageSubtitle = computed(() => {
|
||||||
const count = filteredSuggestions.value.length
|
const count = posts.value.length
|
||||||
return `${count} connection${count === 1 ? '' : 's'}`
|
return `${count} post${count === 1 ? '' : 's'}`
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- Tag toggle ----
|
const visibleTagOptions = computed(() =>
|
||||||
const toggleTag = (slug) => {
|
showAllTags.value ? cooperativeTags.value : cooperativeTags.value.slice(0, 10)
|
||||||
const idx = boardFilterTags.value.indexOf(slug)
|
)
|
||||||
if (idx > -1) {
|
|
||||||
boardFilterTags.value.splice(idx, 1)
|
const isAuthor = (post) => {
|
||||||
|
if (!currentMemberId.value || !post.author) return false
|
||||||
|
const authorId = typeof post.author === 'object' ? post.author._id : post.author
|
||||||
|
return String(authorId) === String(currentMemberId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleTagFilter = async (slug) => {
|
||||||
|
activeTagFilter.value = activeTagFilter.value === slug ? null : slug
|
||||||
|
await fetchPosts(activeTagFilter.value ? { tag: activeTagFilter.value } : {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const openNewForm = () => {
|
||||||
|
editingPost.value = null
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeForm = () => {
|
||||||
|
showForm.value = false
|
||||||
|
editingPost.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (post) => {
|
||||||
|
editingPost.value = post
|
||||||
|
showForm.value = true
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (post) => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
const ok = window.confirm('Delete this post? This cannot be undone.')
|
||||||
|
if (!ok) return
|
||||||
|
try {
|
||||||
|
await deletePost(post._id)
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({
|
||||||
|
title: 'Failed to delete post',
|
||||||
|
description: err?.data?.message || err?.message || 'Please try again.',
|
||||||
|
color: 'red',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (body) => {
|
||||||
|
try {
|
||||||
|
if (editingPost.value) {
|
||||||
|
await updatePost(editingPost.value._id, body)
|
||||||
} else {
|
} else {
|
||||||
boardFilterTags.value.push(slug)
|
await createPost(body)
|
||||||
|
}
|
||||||
|
closeForm()
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({
|
||||||
|
title: editingPost.value ? 'Failed to update post' : 'Failed to create post',
|
||||||
|
description: err?.data?.message || err?.message || 'Please try again.',
|
||||||
|
color: 'red',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Load tags ----
|
const loadTags = async () => {
|
||||||
const loadTagOptions = async () => {
|
|
||||||
try {
|
|
||||||
const data = await $fetch('/api/tags')
|
const data = await $fetch('/api/tags')
|
||||||
const tags = data.tags || []
|
cooperativeTags.value = (data.tags || []).filter((t) => t.pool === 'cooperative')
|
||||||
craftTagOptions.value = tags
|
|
||||||
.filter((t) => t.pool === 'craft')
|
|
||||||
.map((t) => ({ slug: t.slug, label: t.label }))
|
|
||||||
cooperativeTagOptions.value = tags
|
|
||||||
.filter((t) => t.pool === 'cooperative')
|
|
||||||
.map((t) => ({ slug: t.slug, label: t.label }))
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load tags:', error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Load suggestions ----
|
|
||||||
const loadBoard = async () => {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const data = await getSuggestions()
|
|
||||||
suggestions.value = data.suggestions || []
|
|
||||||
|
|
||||||
// Build board tag options from user's own topics
|
|
||||||
const allCoopTags = cooperativeTagOptions.value
|
|
||||||
const myTopicSlugs = (memberData.value?.board?.topics || []).map((t) => t.tagSlug)
|
|
||||||
boardTagOptions.value = myTopicSlugs.length
|
|
||||||
? allCoopTags.filter((t) => myTopicSlugs.includes(t.slug))
|
|
||||||
: allCoopTags
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load board:', error)
|
|
||||||
suggestions.value = []
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Clipboard ----
|
|
||||||
let copyTimer = null
|
|
||||||
const copyHandle = async (handle) => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(`@${handle}`)
|
|
||||||
copiedHandle.value = handle
|
|
||||||
if (copyTimer) clearTimeout(copyTimer)
|
|
||||||
copyTimer = setTimeout(() => {
|
|
||||||
copiedHandle.value = null
|
|
||||||
copyTimer = null
|
|
||||||
}, 1500)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Clipboard write failed:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (copyTimer) clearTimeout(copyTimer)
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---- Head ----
|
|
||||||
useHead({
|
useHead({
|
||||||
title: 'Board - Ghost Guild',
|
title: 'Board - Ghost Guild',
|
||||||
meta: [
|
meta: [
|
||||||
{
|
{
|
||||||
name: 'description',
|
name: 'description',
|
||||||
content: 'Find Ghost Guild members who share your cooperative interests and reach out on Slack.',
|
content: 'Share what you are seeking and offering with the Ghost Guild community.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- Init ----
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!isComplete.value) {
|
await Promise.allSettled([loadTags(), fetchPosts(), fetchChannels()])
|
||||||
trackGoal('boardPageVisited')
|
|
||||||
}
|
|
||||||
await loadTagOptions()
|
|
||||||
await loadBoard()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.action-bar {
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-post-btn {
|
||||||
|
font-family: "Commit Mono", monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--candle);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed var(--candle-faint);
|
||||||
|
padding: 4px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.new-post-btn:hover {
|
||||||
|
border-style: solid;
|
||||||
|
background: rgba(154, 116, 32, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- TAGS DRAWER ---- */
|
/* ---- TAGS DRAWER ---- */
|
||||||
.tags-drawer-toggle {
|
.tags-drawer-toggle {
|
||||||
padding: 8px 24px;
|
padding: 8px 24px;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-btn {
|
.drawer-btn {
|
||||||
font-family: "Commit Mono", monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|
@ -325,7 +242,6 @@ onMounted(async () => {
|
||||||
border-color: var(--candle-faint);
|
border-color: var(--candle-faint);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-count-badge {
|
.tag-count-badge {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
background: var(--candle-faint);
|
background: var(--candle-faint);
|
||||||
|
|
@ -334,11 +250,9 @@ onMounted(async () => {
|
||||||
min-width: 14px;
|
min-width: 14px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tags-drawer {
|
.tags-drawer {
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.skills-bar {
|
.skills-bar {
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -346,7 +260,6 @@ onMounted(async () => {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skills-bar .tag-label {
|
.skills-bar .tag-label {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
|
|
@ -354,7 +267,6 @@ onMounted(async () => {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skills-bar .skill-tag {
|
.skills-bar .skill-tag {
|
||||||
font-family: "Commit Mono", monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
|
|
@ -376,7 +288,6 @@ onMounted(async () => {
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
background: rgba(154, 116, 32, 0.08);
|
background: rgba(154, 116, 32, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.more-btn {
|
.more-btn {
|
||||||
font-family: "Commit Mono", monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
|
|
@ -390,183 +301,44 @@ onMounted(async () => {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- LOADING ---- */
|
/* ---- FORM WRAPPER ---- */
|
||||||
|
.form-wrapper {
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- POST GRID (masonry via CSS columns) ---- */
|
||||||
|
.post-grid {
|
||||||
|
column-count: 2;
|
||||||
|
column-gap: 16px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
.post-grid > * {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
@media (min-width: 1400px) {
|
||||||
|
.post-grid {
|
||||||
|
column-count: 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- LOADING / EMPTY ---- */
|
||||||
.loading-state {
|
.loading-state {
|
||||||
padding: 60px 24px;
|
padding: 60px 24px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- MEMBER GRID ---- */
|
|
||||||
.member-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-card {
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-bottom: 1px dashed var(--border);
|
|
||||||
border-right: 1px dashed var(--border);
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.member-card:hover {
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
.member-card:nth-child(2n) {
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- CARD LAYOUT ---- */
|
|
||||||
.mc-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mc-avatar {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px dashed var(--border);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
flex-shrink: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mc-avatar-img {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mc-info {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-name {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-bright);
|
|
||||||
}
|
|
||||||
.cc-name a {
|
|
||||||
color: var(--text-bright);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.cc-name a:hover {
|
|
||||||
color: var(--candle);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-meta {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-craft-tags {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin: 6px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.craft-pill {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
padding: 1px 6px;
|
|
||||||
border: 1px dashed var(--border);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-overflow {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-matches {
|
|
||||||
margin: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.match-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 3px 0;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.match-tag {
|
|
||||||
color: var(--text);
|
|
||||||
font-weight: 600;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.match-states {
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-size: 10px;
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
align-items: baseline;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.match-sep {
|
|
||||||
color: var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.match-you,
|
|
||||||
.match-them {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-contact {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 10px;
|
|
||||||
padding-top: 10px;
|
|
||||||
border-top: 1px dashed var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-slack {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--candle-dim);
|
|
||||||
font-family: "Commit Mono", monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-action {
|
|
||||||
font-family: "Commit Mono", monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.text-action:hover {
|
|
||||||
color: var(--text);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- EMPTY STATE ---- */
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
padding: 60px 24px;
|
padding: 60px 24px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.empty-title {
|
.empty-title {
|
||||||
font-family: "Brygada 1918", serif;
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 18px;
|
font-size: 20px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
@ -575,29 +347,23 @@ onMounted(async () => {
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
.empty-sub a {
|
|
||||||
color: var(--candle);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- RESPONSIVE ---- */
|
/* ---- RESPONSIVE ---- */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.member-grid {
|
.post-grid {
|
||||||
grid-template-columns: 1fr;
|
column-count: 1;
|
||||||
}
|
|
||||||
.member-card {
|
|
||||||
border-right: none;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.skills-bar {
|
|
||||||
padding: 10px 20px;
|
|
||||||
}
|
|
||||||
.tags-drawer-toggle {
|
.tags-drawer-toggle {
|
||||||
padding: 8px 20px;
|
padding: 8px 20px;
|
||||||
}
|
}
|
||||||
.member-card {
|
.skills-bar {
|
||||||
padding: 14px 16px;
|
padding: 10px 20px;
|
||||||
|
}
|
||||||
|
.post-grid,
|
||||||
|
.form-wrapper {
|
||||||
|
padding: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -164,52 +164,6 @@
|
||||||
<PageSection>
|
<PageSection>
|
||||||
<div class="section-label">Board</div>
|
<div class="section-label">Board</div>
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label>Topics</label>
|
|
||||||
<CooperativeTagSelector
|
|
||||||
v-model="formData.boardTopics"
|
|
||||||
:tags="cooperativeTags"
|
|
||||||
@suggest="openTagSuggest('cooperative')"
|
|
||||||
/>
|
|
||||||
<PrivacyToggle v-model="formData.boardPrivacy" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label>Details</label>
|
|
||||||
<textarea
|
|
||||||
v-model="formData.boardDetails"
|
|
||||||
rows="3"
|
|
||||||
placeholder="What are you hoping to connect about?"
|
|
||||||
maxlength="300"
|
|
||||||
></textarea>
|
|
||||||
<div class="char-count">
|
|
||||||
{{ formData.boardDetails?.length || 0 }} / 300
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="toggle-field">
|
|
||||||
<USwitch
|
|
||||||
v-model="formData.boardOfferPeerSupport"
|
|
||||||
aria-label="Offer Peer Support"
|
|
||||||
/>
|
|
||||||
<div class="toggle-label">
|
|
||||||
Offer Peer Support
|
|
||||||
<span class="toggle-sub"
|
|
||||||
>Share your Slack handle so other members can reach out</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="formData.boardOfferPeerSupport" class="connections-panel">
|
|
||||||
<div class="field">
|
|
||||||
<label>Availability</label>
|
|
||||||
<textarea
|
|
||||||
v-model="formData.boardAvailability"
|
|
||||||
rows="3"
|
|
||||||
placeholder="e.g. Weekday afternoons ET"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Slack Handle</label>
|
<label>Slack Handle</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -217,21 +171,42 @@
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="@yourslackname"
|
placeholder="@yourslackname"
|
||||||
/>
|
/>
|
||||||
|
<div class="field-help">
|
||||||
|
Shown on your board posts so other members can reach out.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="posts-header">
|
||||||
<label>Personal Message</label>
|
<div class="posts-heading">Your Posts</div>
|
||||||
<textarea
|
<NuxtLink to="/board" class="posts-new-link">+ New Post</NuxtLink>
|
||||||
v-model="formData.boardPersonalMessage"
|
|
||||||
rows="3"
|
|
||||||
maxlength="200"
|
|
||||||
placeholder="Brief note shown alongside your Slack handle"
|
|
||||||
></textarea>
|
|
||||||
<div class="char-count">
|
|
||||||
{{ formData.boardPersonalMessage?.length || 0 }} / 200
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="myPosts.length === 0" class="posts-empty">
|
||||||
|
No posts yet.
|
||||||
|
<NuxtLink to="/board" class="posts-empty-link">
|
||||||
|
Visit the Board
|
||||||
|
</NuxtLink>
|
||||||
|
to share what you're seeking or offering.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ul v-else class="posts-list">
|
||||||
|
<li v-for="post in myPosts" :key="post._id" class="post-item">
|
||||||
|
<div class="post-body">
|
||||||
|
<div class="post-title">{{ post.title }}</div>
|
||||||
|
<div class="post-excerpt">{{ postExcerpt(post) }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="post-actions">
|
||||||
|
<NuxtLink to="/board" class="post-action">Edit</NuxtLink>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="post-action post-action-danger"
|
||||||
|
@click="handleDeletePost(post)"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</PageSection>
|
</PageSection>
|
||||||
|
|
||||||
<PageSection divider="top">
|
<PageSection divider="top">
|
||||||
|
|
@ -296,6 +271,8 @@ definePageMeta({
|
||||||
|
|
||||||
const { memberData, checkMemberStatus } = useAuth();
|
const { memberData, checkMemberStatus } = useAuth();
|
||||||
const { openLoginModal } = useLoginModal();
|
const { openLoginModal } = useLoginModal();
|
||||||
|
const { posts: myPosts, fetchPosts, deletePost } = useBoardPosts();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
const availableGhosts = [
|
const availableGhosts = [
|
||||||
{ value: "disbelieving", label: "Disbelieving", image: "/ghosties/Ghost-Disbelieving.png" },
|
{ value: "disbelieving", label: "Disbelieving", image: "/ghosties/Ghost-Disbelieving.png" },
|
||||||
|
|
@ -316,9 +293,6 @@ const { data: tagsData } = await useFetch("/api/tags");
|
||||||
const craftTags = computed(() =>
|
const craftTags = computed(() =>
|
||||||
(tagsData.value?.tags || []).filter((t) => t.pool === "craft"),
|
(tagsData.value?.tags || []).filter((t) => t.pool === "craft"),
|
||||||
);
|
);
|
||||||
const cooperativeTags = computed(() =>
|
|
||||||
(tagsData.value?.tags || []).filter((t) => t.pool === "cooperative"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const showTagSuggestModal = ref(false);
|
const showTagSuggestModal = ref(false);
|
||||||
const tagSuggestPool = ref("");
|
const tagSuggestPool = ref("");
|
||||||
|
|
@ -339,13 +313,7 @@ const formData = reactive({
|
||||||
showInDirectory: true,
|
showInDirectory: true,
|
||||||
craftTags: [],
|
craftTags: [],
|
||||||
craftTagsPrivacy: "members",
|
craftTagsPrivacy: "members",
|
||||||
boardTopics: [],
|
|
||||||
boardPrivacy: "members",
|
|
||||||
boardDetails: "",
|
|
||||||
boardOfferPeerSupport: false,
|
|
||||||
boardAvailability: "",
|
|
||||||
boardSlackHandle: "",
|
boardSlackHandle: "",
|
||||||
boardPersonalMessage: "",
|
|
||||||
pronounsPrivacy: "members",
|
pronounsPrivacy: "members",
|
||||||
timeZonePrivacy: "members",
|
timeZonePrivacy: "members",
|
||||||
avatarPrivacy: "members",
|
avatarPrivacy: "members",
|
||||||
|
|
@ -388,12 +356,7 @@ const loadProfile = () => {
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const board = memberData.value.board || {};
|
const board = memberData.value.board || {};
|
||||||
formData.boardTopics = Array.isArray(board.topics) ? [...board.topics] : [];
|
|
||||||
formData.boardOfferPeerSupport = board.offerPeerSupport ?? false;
|
|
||||||
formData.boardAvailability = board.availability || "";
|
|
||||||
formData.boardSlackHandle = board.slackHandle || "";
|
formData.boardSlackHandle = board.slackHandle || "";
|
||||||
formData.boardPersonalMessage = board.personalMessage || "";
|
|
||||||
formData.boardDetails = board.details || "";
|
|
||||||
|
|
||||||
const privacy = memberData.value.privacy || {};
|
const privacy = memberData.value.privacy || {};
|
||||||
formData.pronounsPrivacy = privacy.pronouns || "members";
|
formData.pronounsPrivacy = privacy.pronouns || "members";
|
||||||
|
|
@ -403,7 +366,6 @@ const loadProfile = () => {
|
||||||
formData.bioPrivacy = privacy.bio || "members";
|
formData.bioPrivacy = privacy.bio || "members";
|
||||||
formData.locationPrivacy = privacy.location || "members";
|
formData.locationPrivacy = privacy.location || "members";
|
||||||
formData.craftTagsPrivacy = privacy.craftTags || "members";
|
formData.craftTagsPrivacy = privacy.craftTags || "members";
|
||||||
formData.boardPrivacy = privacy.board || "members";
|
|
||||||
|
|
||||||
const notifs = memberData.value.notifications || {};
|
const notifs = memberData.value.notifications || {};
|
||||||
formData.notifications.events = notifs.events ?? true;
|
formData.notifications.events = notifs.events ?? true;
|
||||||
|
|
@ -418,23 +380,10 @@ const handleSubmit = async () => {
|
||||||
saveError.value = null;
|
saveError.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await $fetch("/api/members/profile", {
|
||||||
$fetch("/api/members/profile", {
|
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: { ...formData },
|
body: { ...formData },
|
||||||
}),
|
});
|
||||||
$fetch("/api/members/me/board", {
|
|
||||||
method: "PATCH",
|
|
||||||
body: {
|
|
||||||
topics: formData.boardTopics,
|
|
||||||
offerPeerSupport: formData.boardOfferPeerSupport,
|
|
||||||
availability: formData.boardAvailability,
|
|
||||||
slackHandle: formData.boardSlackHandle,
|
|
||||||
personalMessage: formData.boardPersonalMessage,
|
|
||||||
details: formData.boardDetails,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
saveSuccess.value = true;
|
saveSuccess.value = true;
|
||||||
|
|
||||||
|
|
@ -477,8 +426,32 @@ onMounted(async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
loadProfile();
|
loadProfile();
|
||||||
|
|
||||||
|
if (memberId.value) {
|
||||||
|
await fetchPosts({ author: memberId.value });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const postExcerpt = (post) => {
|
||||||
|
const text = post.seeking || post.offering || "";
|
||||||
|
if (text.length <= 80) return text;
|
||||||
|
return text.slice(0, 80).trimEnd() + "...";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePost = async (post) => {
|
||||||
|
if (!window.confirm(`Delete "${post.title}"?`)) return;
|
||||||
|
try {
|
||||||
|
await deletePost(post._id, { author: memberId.value });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Delete post error:", error);
|
||||||
|
toast.add({
|
||||||
|
title: "Failed to delete post",
|
||||||
|
description: error.data?.message || "Please try again.",
|
||||||
|
color: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (saveSuccessTimer) clearTimeout(saveSuccessTimer);
|
if (saveSuccessTimer) clearTimeout(saveSuccessTimer);
|
||||||
});
|
});
|
||||||
|
|
@ -555,7 +528,6 @@ useHead({
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: 1px dashed var(--border);
|
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
|
|
@ -612,13 +584,111 @@ useHead({
|
||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- CONNECTIONS PANEL ---- */
|
/* ---- FIELD HELPER TEXT ---- */
|
||||||
.connections-panel {
|
.field-help {
|
||||||
border: 1px dashed var(--border);
|
font-size: 11px;
|
||||||
padding: 12px 16px;
|
color: var(--text-faint);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
margin-bottom: 12px;
|
}
|
||||||
background: var(--surface);
|
|
||||||
|
/* ---- YOUR POSTS LIST ---- */
|
||||||
|
.posts-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.posts-heading {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.posts-new-link {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--candle);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.posts-new-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.posts-empty {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
padding: 12px 0;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.posts-empty-link {
|
||||||
|
color: var(--candle);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.posts-empty-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.posts-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-title {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-excerpt {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-action {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--candle);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-action:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-action-danger {
|
||||||
|
color: var(--ember);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- DISABLED BUTTON ---- */
|
/* ---- DISABLED BUTTON ---- */
|
||||||
|
|
|
||||||
|
|
@ -108,15 +108,10 @@
|
||||||
<div class="profile-bio" v-html="renderMarkdown(member.bio)"></div>
|
<div class="profile-bio" v-html="renderMarkdown(member.bio)"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Two-column: Craft Tags + Board -->
|
<!-- Craft Tags -->
|
||||||
<div
|
<div v-if="craftTagsDisplay.length > 0" class="profile-section">
|
||||||
v-if="craftTagsDisplay.length > 0 || boardTopics.length > 0 || member.board?.details"
|
|
||||||
class="profile-two-col"
|
|
||||||
>
|
|
||||||
<!-- Left: What I Do -->
|
|
||||||
<div class="profile-section">
|
|
||||||
<div class="section-label">What I Do</div>
|
<div class="section-label">What I Do</div>
|
||||||
<div v-if="craftTagsDisplay.length > 0" class="tag-list">
|
<div class="tag-list">
|
||||||
<span
|
<span
|
||||||
v-for="tag in craftTagsDisplay"
|
v-for="tag in craftTagsDisplay"
|
||||||
:key="tag"
|
:key="tag"
|
||||||
|
|
@ -125,39 +120,27 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: Board -->
|
<!-- Board Posts -->
|
||||||
<div class="profile-section">
|
<div class="profile-section">
|
||||||
<div class="section-label">Board</div>
|
<div class="section-label">Board Posts</div>
|
||||||
<div v-if="boardTopics.length > 0" class="tag-list">
|
<p v-if="memberPosts.length === 0" class="profile-detail posts-empty">
|
||||||
|
No posts yet.
|
||||||
|
</p>
|
||||||
|
<ul v-else class="posts-list">
|
||||||
|
<li v-for="post in memberPosts" :key="post._id" class="post-item">
|
||||||
|
<NuxtLink to="/board" class="post-link">
|
||||||
|
<div class="post-title">{{ post.title }}</div>
|
||||||
|
<div class="post-excerpt">{{ postExcerpt(post) }}</div>
|
||||||
|
<div v-if="post.tags && post.tags.length" class="tag-list post-tags">
|
||||||
<span
|
<span
|
||||||
v-for="topic in boardTopics"
|
v-for="tag in post.tags"
|
||||||
:key="topic.tagSlug"
|
:key="tag"
|
||||||
class="tag-pill connection-pill"
|
class="tag-pill"
|
||||||
>
|
>{{ tagLabel('cooperative', tag) }}</span>
|
||||||
<span v-if="topic.state" class="connection-state">{{ stateLabel(topic.state) }}</span>
|
|
||||||
{{ tagLabel('cooperative', topic.tagSlug) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p v-if="member.board?.details" class="profile-detail connection-details">
|
|
||||||
{{ member.board.details }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Peer Support -->
|
|
||||||
<div v-if="member.board?.offerPeerSupport" class="profile-section">
|
|
||||||
<div class="section-label">Peer Support</div>
|
|
||||||
<div class="dashed-box no-hover">
|
|
||||||
<p v-if="member.board?.personalMessage" class="profile-detail">
|
|
||||||
{{ member.board.personalMessage }}
|
|
||||||
</p>
|
|
||||||
<p v-if="member.board?.availability" class="profile-detail peer-availability">
|
|
||||||
{{ member.board.availability }}
|
|
||||||
</p>
|
|
||||||
<p v-if="member.board?.slackHandle" class="profile-detail peer-availability">
|
|
||||||
Reach out on Slack: <span class="slack-handle">@{{ member.board.slackHandle }}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recent Activity -->
|
<!-- Recent Activity -->
|
||||||
|
|
@ -217,15 +200,6 @@ const circleLabels = {
|
||||||
practitioner: "Practitioner",
|
practitioner: "Practitioner",
|
||||||
};
|
};
|
||||||
|
|
||||||
// State display text mapping
|
|
||||||
const stateLabels = {
|
|
||||||
help: "Can help",
|
|
||||||
interested: "Interested",
|
|
||||||
seeking: "Need help",
|
|
||||||
};
|
|
||||||
|
|
||||||
const stateLabel = (state) => stateLabels[state] || state || "";
|
|
||||||
|
|
||||||
const getInitials = (name) => {
|
const getInitials = (name) => {
|
||||||
if (!name) return "?";
|
if (!name) return "?";
|
||||||
return name
|
return name
|
||||||
|
|
@ -274,9 +248,18 @@ const tagLabel = (pool, slug) => {
|
||||||
|
|
||||||
const craftTagsDisplay = computed(() => member.value?.craftTags || []);
|
const craftTagsDisplay = computed(() => member.value?.craftTags || []);
|
||||||
|
|
||||||
const boardTopics = computed(
|
// Board posts authored by this member
|
||||||
() => member.value?.board?.topics || [],
|
const { data: postsData } = useFetch(`/api/board/posts`, {
|
||||||
);
|
params: { author: id },
|
||||||
|
default: () => ({ posts: [] }),
|
||||||
|
})
|
||||||
|
const memberPosts = computed(() => postsData.value?.posts || [])
|
||||||
|
|
||||||
|
const postExcerpt = (post) => {
|
||||||
|
const text = post.seeking || post.offering || "";
|
||||||
|
if (text.length <= 80) return text;
|
||||||
|
return text.slice(0, 80).trimEnd() + "...";
|
||||||
|
};
|
||||||
|
|
||||||
// Whether the member has any social links (for hero layout)
|
// Whether the member has any social links (for hero layout)
|
||||||
const hasSocialLinks = computed(() =>
|
const hasSocialLinks = computed(() =>
|
||||||
|
|
@ -365,7 +348,6 @@ useHead({
|
||||||
width: 96px;
|
width: 96px;
|
||||||
height: 96px;
|
height: 96px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px dashed var(--border);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -507,22 +489,6 @@ useHead({
|
||||||
color: var(--ember);
|
color: var(--ember);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ====================================================
|
|
||||||
TWO-COLUMN: Craft Tags + Board
|
|
||||||
==================================================== */
|
|
||||||
|
|
||||||
.profile-two-col {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
border-bottom: 1px dashed var(--border);
|
|
||||||
}
|
|
||||||
.profile-two-col .profile-section {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
.profile-two-col .profile-section:first-child {
|
|
||||||
border-right: 1px dashed var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ====================================================
|
/* ====================================================
|
||||||
SHARED SECTION ELEMENTS
|
SHARED SECTION ELEMENTS
|
||||||
==================================================== */
|
==================================================== */
|
||||||
|
|
@ -533,9 +499,6 @@ useHead({
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.connection-details {
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tags */
|
/* Tags */
|
||||||
.tag-list {
|
.tag-list {
|
||||||
|
|
@ -551,30 +514,47 @@ useHead({
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.connection-pill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
.connection-state {
|
|
||||||
font-size: 9px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
color: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ====================================================
|
/* ====================================================
|
||||||
PEER SUPPORT
|
BOARD POSTS
|
||||||
==================================================== */
|
==================================================== */
|
||||||
|
|
||||||
.peer-availability {
|
.posts-empty {
|
||||||
margin-top: 12px;
|
color: var(--text-faint);
|
||||||
padding-top: 12px;
|
}
|
||||||
|
.posts-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.post-item {
|
||||||
border-top: 1px dashed var(--border);
|
border-top: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
.slack-handle {
|
.post-item:last-child {
|
||||||
font-family: "Commit Mono", monospace;
|
border-bottom: 1px dashed var(--border);
|
||||||
color: var(--candle-dim);
|
}
|
||||||
|
.post-link {
|
||||||
|
display: block;
|
||||||
|
padding: 10px 0;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.post-link:hover .post-title {
|
||||||
|
color: var(--candle);
|
||||||
|
}
|
||||||
|
.post-title {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.post-excerpt {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.post-tags {
|
||||||
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ====================================================
|
/* ====================================================
|
||||||
|
|
@ -666,17 +646,6 @@ useHead({
|
||||||
RESPONSIVE
|
RESPONSIVE
|
||||||
==================================================== */
|
==================================================== */
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
/* ColumnsLayout events-sidebar hides itself at ≤1024px */
|
|
||||||
.profile-two-col {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.profile-two-col .profile-section:first-child {
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px dashed var(--border);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.profile-hero,
|
.profile-hero,
|
||||||
.profile-hero--with-links {
|
.profile-hero--with-links {
|
||||||
|
|
|
||||||
|
|
@ -580,7 +580,6 @@ onMounted(async () => {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px dashed var(--border);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
|
||||||
64
e2e/admin-board-channels.spec.js
Normal file
64
e2e/admin-board-channels.spec.js
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { test, expect } from './helpers/fixtures.js'
|
||||||
|
|
||||||
|
test.describe('Admin board channels page', () => {
|
||||||
|
test('page loads for admin', async ({ adminPage }) => {
|
||||||
|
await adminPage.goto('/admin/board-channels')
|
||||||
|
await expect(adminPage.getByRole('heading', { name: 'Board Channels' })).toBeVisible({
|
||||||
|
timeout: 15000,
|
||||||
|
})
|
||||||
|
await expect(adminPage.getByRole('button', { name: '+ New Channel' })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('create, edit, and delete a channel', async ({ adminPage }) => {
|
||||||
|
await adminPage.goto('/admin/board-channels')
|
||||||
|
await expect(adminPage.getByRole('heading', { name: 'Board Channels' })).toBeVisible({
|
||||||
|
timeout: 15000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const suffix = Date.now().toString().slice(-6)
|
||||||
|
const channelName = `e2e-channel-${suffix}`
|
||||||
|
const editedName = `e2e-channel-${suffix}-edited`
|
||||||
|
const slackId = `C${suffix}XYZ`
|
||||||
|
|
||||||
|
// --- Create ---
|
||||||
|
await adminPage.getByRole('button', { name: '+ New Channel' }).click()
|
||||||
|
await expect(adminPage.getByRole('heading', { name: 'New Channel' })).toBeVisible()
|
||||||
|
|
||||||
|
await adminPage.locator('input[placeholder="e.g., #coop-formation"]').fill(channelName)
|
||||||
|
await adminPage.locator('input[placeholder="C0123456789"]').fill(slackId)
|
||||||
|
|
||||||
|
// Select the first available cooperative tag if any are present
|
||||||
|
const firstTagCheckbox = adminPage.locator('.tag-select input[type="checkbox"]').first()
|
||||||
|
if (await firstTagCheckbox.isVisible().catch(() => false)) {
|
||||||
|
await firstTagCheckbox.check()
|
||||||
|
}
|
||||||
|
|
||||||
|
await adminPage.getByRole('button', { name: 'Create Channel' }).click()
|
||||||
|
|
||||||
|
await expect(adminPage.getByRole('cell', { name: channelName })).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Edit ---
|
||||||
|
const row = adminPage.locator('tr', { hasText: channelName })
|
||||||
|
await row.getByRole('button', { name: 'Edit' }).click()
|
||||||
|
|
||||||
|
await expect(adminPage.getByRole('heading', { name: 'Edit Channel' })).toBeVisible()
|
||||||
|
const nameInput = adminPage.locator('input[placeholder="e.g., #coop-formation"]')
|
||||||
|
await nameInput.fill(editedName)
|
||||||
|
await adminPage.getByRole('button', { name: 'Save Changes' }).click()
|
||||||
|
|
||||||
|
await expect(adminPage.getByRole('cell', { name: editedName })).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Delete (confirm dialog) ---
|
||||||
|
adminPage.once('dialog', (dialog) => dialog.accept())
|
||||||
|
const editedRow = adminPage.locator('tr', { hasText: editedName })
|
||||||
|
await editedRow.getByRole('button', { name: 'Delete' }).click()
|
||||||
|
|
||||||
|
await expect(adminPage.getByRole('cell', { name: editedName })).not.toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
87
e2e/board.spec.js
Normal file
87
e2e/board.spec.js
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { test, expect } from './helpers/fixtures.js'
|
||||||
|
|
||||||
|
test.describe('Board page', () => {
|
||||||
|
test('page loads for authenticated member', async ({ memberPage }) => {
|
||||||
|
await memberPage.goto('/board')
|
||||||
|
await expect(memberPage.getByRole('heading', { name: 'Board' })).toBeVisible({ timeout: 15000 })
|
||||||
|
await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicking New Post reveals the form', async ({ memberPage }) => {
|
||||||
|
await memberPage.goto('/board')
|
||||||
|
await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({
|
||||||
|
timeout: 15000,
|
||||||
|
})
|
||||||
|
|
||||||
|
await memberPage.getByRole('button', { name: '+ New Post' }).first().click()
|
||||||
|
|
||||||
|
await expect(memberPage.getByRole('heading', { name: 'New post' })).toBeVisible()
|
||||||
|
await expect(memberPage.locator('#post-title')).toBeVisible()
|
||||||
|
await expect(memberPage.locator('#post-seeking')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('tags drawer toggles open and closed', async ({ memberPage }) => {
|
||||||
|
await memberPage.goto('/board')
|
||||||
|
await expect(memberPage.getByRole('heading', { name: 'Board' })).toBeVisible({ timeout: 15000 })
|
||||||
|
|
||||||
|
const drawerToggle = memberPage.getByRole('button', { name: /^Tags\.\.\./ })
|
||||||
|
// Drawer toggle only appears if cooperative tags exist — skip quietly if not
|
||||||
|
if (!(await drawerToggle.isVisible().catch(() => false))) {
|
||||||
|
test.skip(true, 'No cooperative tags seeded in this environment')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await drawerToggle.click()
|
||||||
|
await expect(memberPage.getByText('Filter:')).toBeVisible()
|
||||||
|
|
||||||
|
await drawerToggle.click()
|
||||||
|
await expect(memberPage.getByText('Filter:')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('create, edit, and delete own post', async ({ memberPage }) => {
|
||||||
|
await memberPage.goto('/board')
|
||||||
|
await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({
|
||||||
|
timeout: 15000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const uniqueSuffix = Date.now().toString().slice(-6)
|
||||||
|
const originalTitle = `E2E test post ${uniqueSuffix}`
|
||||||
|
const editedTitle = `E2E test post edited ${uniqueSuffix}`
|
||||||
|
|
||||||
|
// --- Create ---
|
||||||
|
await memberPage.getByRole('button', { name: '+ New Post' }).first().click()
|
||||||
|
await expect(memberPage.getByRole('heading', { name: 'New post' })).toBeVisible()
|
||||||
|
|
||||||
|
await memberPage.locator('#post-title').fill(originalTitle)
|
||||||
|
await memberPage.locator('#post-seeking').fill('Playwright test seeking text')
|
||||||
|
|
||||||
|
await memberPage.getByRole('button', { name: 'Post' }).click()
|
||||||
|
|
||||||
|
await expect(memberPage.getByRole('heading', { name: originalTitle })).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Edit ---
|
||||||
|
// Find the post card containing our title, then click its Edit button
|
||||||
|
const postCard = memberPage.locator('article.board-post', { hasText: originalTitle })
|
||||||
|
await postCard.getByRole('button', { name: 'Edit' }).click()
|
||||||
|
|
||||||
|
await expect(memberPage.getByRole('heading', { name: 'Edit post' })).toBeVisible()
|
||||||
|
const titleInput = memberPage.locator('#post-title')
|
||||||
|
await titleInput.fill(editedTitle)
|
||||||
|
await memberPage.getByRole('button', { name: 'Save changes' }).click()
|
||||||
|
|
||||||
|
await expect(memberPage.getByRole('heading', { name: editedTitle })).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Delete (confirm dialog) ---
|
||||||
|
memberPage.once('dialog', (dialog) => dialog.accept())
|
||||||
|
const editedCard = memberPage.locator('article.board-post', { hasText: editedTitle })
|
||||||
|
await editedCard.getByRole('button', { name: 'Delete' }).click()
|
||||||
|
|
||||||
|
await expect(memberPage.getByRole('heading', { name: editedTitle })).not.toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -82,6 +82,7 @@ export default defineNuxtConfig({
|
||||||
resendApiKey: process.env.RESEND_API_KEY || "",
|
resendApiKey: process.env.RESEND_API_KEY || "",
|
||||||
helcimApiToken: process.env.HELCIM_API_TOKEN || "", // also exposed to client via public.helcimToken
|
helcimApiToken: process.env.HELCIM_API_TOKEN || "", // also exposed to client via public.helcimToken
|
||||||
slackBotToken: process.env.SLACK_BOT_TOKEN || "",
|
slackBotToken: process.env.SLACK_BOT_TOKEN || "",
|
||||||
|
slackAdminBotToken: process.env.SLACK_ADMIN_BOT_TOKEN || "",
|
||||||
slackSigningSecret: process.env.SLACK_SIGNING_SECRET || "",
|
slackSigningSecret: process.env.SLACK_SIGNING_SECRET || "",
|
||||||
slackVettingChannelId: process.env.SLACK_VETTING_CHANNEL_ID || "",
|
slackVettingChannelId: process.env.SLACK_VETTING_CHANNEL_ID || "",
|
||||||
oidcClientId: process.env.OIDC_CLIENT_ID || "outline-wiki",
|
oidcClientId: process.env.OIDC_CLIENT_ID || "outline-wiki",
|
||||||
|
|
|
||||||
|
|
@ -5,33 +5,6 @@ import dotenv from 'dotenv'
|
||||||
|
|
||||||
dotenv.config()
|
dotenv.config()
|
||||||
|
|
||||||
const COOPERATIVE_SLUGS = [
|
|
||||||
'governance', 'finance-and-budgeting', 'legal-structures', 'conflict-resolution',
|
|
||||||
'consensus-decision-making', 'revenue-sharing', 'cooperative-bylaws', 'member-onboarding',
|
|
||||||
'democratic-management', 'worker-ownership', 'platform-cooperativism', 'cooperative-marketing',
|
|
||||||
'shared-resources', 'cooperative-funding', 'community-building', 'equity-and-inclusion',
|
|
||||||
'cooperative-tech', 'sustainability', 'collective-bargaining', 'inter-coop-collaboration',
|
|
||||||
]
|
|
||||||
|
|
||||||
const CRAFT_SLUGS = [
|
|
||||||
'game-design', 'programming', 'narrative-design', 'art-and-animation',
|
|
||||||
'audio-and-music', 'production-management', 'qa-and-testing', 'community-management',
|
|
||||||
'marketing-and-comms', 'ux-and-ui-design', 'business-development', 'devops-and-tools',
|
|
||||||
'localization', 'accessibility', 'analytics-and-data', 'education-and-mentoring',
|
|
||||||
]
|
|
||||||
|
|
||||||
const AVATARS = ['disbelieving', 'double-take', 'exasperated', 'mild', 'sweet', 'wtf']
|
|
||||||
const STATES = ['help', 'interested', 'seeking']
|
|
||||||
|
|
||||||
function pick(arr, n) {
|
|
||||||
const shuffled = [...arr].sort(() => Math.random() - 0.5)
|
|
||||||
return shuffled.slice(0, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
function randomState() {
|
|
||||||
return STATES[Math.floor(Math.random() * STATES.length)]
|
|
||||||
}
|
|
||||||
|
|
||||||
const sampleMembers = [
|
const sampleMembers = [
|
||||||
{
|
{
|
||||||
email: 'alex.rivera@pixelcollective.coop',
|
email: 'alex.rivera@pixelcollective.coop',
|
||||||
|
|
@ -42,16 +15,7 @@ const sampleMembers = [
|
||||||
avatar: 'sweet',
|
avatar: 'sweet',
|
||||||
slackInvited: true,
|
slackInvited: true,
|
||||||
craftTags: ['game-design', 'production-management', 'business-development'],
|
craftTags: ['game-design', 'production-management', 'business-development'],
|
||||||
board: {
|
board: { slackHandle: 'alex.rivera' },
|
||||||
topics: [
|
|
||||||
{ tagSlug: 'governance', state: 'help' },
|
|
||||||
{ tagSlug: 'revenue-sharing', state: 'help' },
|
|
||||||
{ tagSlug: 'worker-ownership', state: 'interested' },
|
|
||||||
{ tagSlug: 'cooperative-bylaws', state: 'help' },
|
|
||||||
],
|
|
||||||
offerPeerSupport: true,
|
|
||||||
slackHandle: 'alex.rivera',
|
|
||||||
},
|
|
||||||
createdAt: new Date('2024-01-15'),
|
createdAt: new Date('2024-01-15'),
|
||||||
lastLogin: new Date('2026-04-10'),
|
lastLogin: new Date('2026-04-10'),
|
||||||
},
|
},
|
||||||
|
|
@ -64,17 +28,7 @@ const sampleMembers = [
|
||||||
avatar: 'mild',
|
avatar: 'mild',
|
||||||
slackInvited: true,
|
slackInvited: true,
|
||||||
craftTags: ['business-development', 'marketing-and-comms'],
|
craftTags: ['business-development', 'marketing-and-comms'],
|
||||||
board: {
|
board: { slackHandle: 'sam.chen' },
|
||||||
topics: [
|
|
||||||
{ tagSlug: 'legal-structures', state: 'help' },
|
|
||||||
{ tagSlug: 'cooperative-bylaws', state: 'help' },
|
|
||||||
{ tagSlug: 'governance', state: 'interested' },
|
|
||||||
{ tagSlug: 'conflict-resolution', state: 'help' },
|
|
||||||
{ tagSlug: 'equity-and-inclusion', state: 'interested' },
|
|
||||||
],
|
|
||||||
offerPeerSupport: true,
|
|
||||||
slackHandle: 'sam.chen',
|
|
||||||
},
|
|
||||||
createdAt: new Date('2024-02-03'),
|
createdAt: new Date('2024-02-03'),
|
||||||
lastLogin: new Date('2026-04-08'),
|
lastLogin: new Date('2026-04-08'),
|
||||||
},
|
},
|
||||||
|
|
@ -89,16 +43,7 @@ const sampleMembers = [
|
||||||
helcimSubscriptionId: 'sub_67890',
|
helcimSubscriptionId: 'sub_67890',
|
||||||
slackInvited: true,
|
slackInvited: true,
|
||||||
craftTags: ['programming', 'devops-and-tools', 'game-design', 'qa-and-testing'],
|
craftTags: ['programming', 'devops-and-tools', 'game-design', 'qa-and-testing'],
|
||||||
board: {
|
board: { slackHandle: 'maria.g' },
|
||||||
topics: [
|
|
||||||
{ tagSlug: 'cooperative-tech', state: 'help' },
|
|
||||||
{ tagSlug: 'platform-cooperativism', state: 'interested' },
|
|
||||||
{ tagSlug: 'shared-resources', state: 'help' },
|
|
||||||
{ tagSlug: 'democratic-management', state: 'seeking' },
|
|
||||||
],
|
|
||||||
offerPeerSupport: true,
|
|
||||||
slackHandle: 'maria.g',
|
|
||||||
},
|
|
||||||
createdAt: new Date('2024-03-10'),
|
createdAt: new Date('2024-03-10'),
|
||||||
lastLogin: new Date('2026-04-12'),
|
lastLogin: new Date('2026-04-12'),
|
||||||
},
|
},
|
||||||
|
|
@ -111,16 +56,7 @@ const sampleMembers = [
|
||||||
avatar: 'exasperated',
|
avatar: 'exasperated',
|
||||||
slackInvited: true,
|
slackInvited: true,
|
||||||
craftTags: ['business-development', 'analytics-and-data'],
|
craftTags: ['business-development', 'analytics-and-data'],
|
||||||
board: {
|
board: { slackHandle: 'david.park' },
|
||||||
topics: [
|
|
||||||
{ tagSlug: 'cooperative-funding', state: 'help' },
|
|
||||||
{ tagSlug: 'finance-and-budgeting', state: 'help' },
|
|
||||||
{ tagSlug: 'sustainability', state: 'interested' },
|
|
||||||
{ tagSlug: 'revenue-sharing', state: 'interested' },
|
|
||||||
],
|
|
||||||
offerPeerSupport: true,
|
|
||||||
slackHandle: 'david.park',
|
|
||||||
},
|
|
||||||
createdAt: new Date('2024-04-12'),
|
createdAt: new Date('2024-04-12'),
|
||||||
lastLogin: new Date('2026-04-09'),
|
lastLogin: new Date('2026-04-09'),
|
||||||
},
|
},
|
||||||
|
|
@ -133,15 +69,7 @@ const sampleMembers = [
|
||||||
avatar: 'disbelieving',
|
avatar: 'disbelieving',
|
||||||
slackInvited: true,
|
slackInvited: true,
|
||||||
craftTags: ['education-and-mentoring', 'community-management'],
|
craftTags: ['education-and-mentoring', 'community-management'],
|
||||||
board: {
|
board: {},
|
||||||
topics: [
|
|
||||||
{ tagSlug: 'cooperative-funding', state: 'help' },
|
|
||||||
{ tagSlug: 'community-building', state: 'help' },
|
|
||||||
{ tagSlug: 'member-onboarding', state: 'interested' },
|
|
||||||
{ tagSlug: 'equity-and-inclusion', state: 'help' },
|
|
||||||
],
|
|
||||||
offerPeerSupport: false,
|
|
||||||
},
|
|
||||||
createdAt: new Date('2024-05-08'),
|
createdAt: new Date('2024-05-08'),
|
||||||
lastLogin: new Date('2026-04-05'),
|
lastLogin: new Date('2026-04-05'),
|
||||||
},
|
},
|
||||||
|
|
@ -154,15 +82,7 @@ const sampleMembers = [
|
||||||
avatar: 'wtf',
|
avatar: 'wtf',
|
||||||
slackInvited: true,
|
slackInvited: true,
|
||||||
craftTags: ['programming', 'game-design', 'audio-and-music'],
|
craftTags: ['programming', 'game-design', 'audio-and-music'],
|
||||||
board: {
|
board: { slackHandle: 'jordan.lee' },
|
||||||
topics: [
|
|
||||||
{ tagSlug: 'worker-ownership', state: 'seeking' },
|
|
||||||
{ tagSlug: 'governance', state: 'seeking' },
|
|
||||||
{ tagSlug: 'cooperative-tech', state: 'interested' },
|
|
||||||
],
|
|
||||||
offerPeerSupport: true,
|
|
||||||
slackHandle: 'jordan.lee',
|
|
||||||
},
|
|
||||||
createdAt: new Date('2024-06-20'),
|
createdAt: new Date('2024-06-20'),
|
||||||
lastLogin: new Date('2026-04-07'),
|
lastLogin: new Date('2026-04-07'),
|
||||||
},
|
},
|
||||||
|
|
@ -175,14 +95,7 @@ const sampleMembers = [
|
||||||
avatar: 'sweet',
|
avatar: 'sweet',
|
||||||
slackInvited: true,
|
slackInvited: true,
|
||||||
craftTags: ['art-and-animation', 'ux-and-ui-design', 'accessibility'],
|
craftTags: ['art-and-animation', 'ux-and-ui-design', 'accessibility'],
|
||||||
board: {
|
board: {},
|
||||||
topics: [
|
|
||||||
{ tagSlug: 'equity-and-inclusion', state: 'interested' },
|
|
||||||
{ tagSlug: 'community-building', state: 'seeking' },
|
|
||||||
{ tagSlug: 'consensus-decision-making', state: 'seeking' },
|
|
||||||
],
|
|
||||||
offerPeerSupport: false,
|
|
||||||
},
|
|
||||||
createdAt: new Date('2024-07-15'),
|
createdAt: new Date('2024-07-15'),
|
||||||
lastLogin: new Date('2026-04-01'),
|
lastLogin: new Date('2026-04-01'),
|
||||||
},
|
},
|
||||||
|
|
@ -196,17 +109,7 @@ const sampleMembers = [
|
||||||
helcimCustomerId: 'cust_54321',
|
helcimCustomerId: 'cust_54321',
|
||||||
slackInvited: true,
|
slackInvited: true,
|
||||||
craftTags: ['programming', 'devops-and-tools', 'production-management'],
|
craftTags: ['programming', 'devops-and-tools', 'production-management'],
|
||||||
board: {
|
board: { slackHandle: 'casey.w' },
|
||||||
topics: [
|
|
||||||
{ tagSlug: 'cooperative-tech', state: 'help' },
|
|
||||||
{ tagSlug: 'shared-resources', state: 'help' },
|
|
||||||
{ tagSlug: 'platform-cooperativism', state: 'help' },
|
|
||||||
{ tagSlug: 'democratic-management', state: 'interested' },
|
|
||||||
{ tagSlug: 'inter-coop-collaboration', state: 'interested' },
|
|
||||||
],
|
|
||||||
offerPeerSupport: true,
|
|
||||||
slackHandle: 'casey.w',
|
|
||||||
},
|
|
||||||
createdAt: new Date('2024-08-01'),
|
createdAt: new Date('2024-08-01'),
|
||||||
lastLogin: new Date('2026-04-11'),
|
lastLogin: new Date('2026-04-11'),
|
||||||
},
|
},
|
||||||
|
|
@ -219,14 +122,7 @@ const sampleMembers = [
|
||||||
avatar: 'double-take',
|
avatar: 'double-take',
|
||||||
slackInvited: false,
|
slackInvited: false,
|
||||||
craftTags: ['narrative-design', 'localization'],
|
craftTags: ['narrative-design', 'localization'],
|
||||||
board: {
|
board: {},
|
||||||
topics: [
|
|
||||||
{ tagSlug: 'community-building', state: 'interested' },
|
|
||||||
{ tagSlug: 'consensus-decision-making', state: 'seeking' },
|
|
||||||
{ tagSlug: 'member-onboarding', state: 'seeking' },
|
|
||||||
],
|
|
||||||
offerPeerSupport: false,
|
|
||||||
},
|
|
||||||
createdAt: new Date('2024-08-15'),
|
createdAt: new Date('2024-08-15'),
|
||||||
lastLogin: new Date('2026-03-28'),
|
lastLogin: new Date('2026-03-28'),
|
||||||
},
|
},
|
||||||
|
|
@ -241,18 +137,7 @@ const sampleMembers = [
|
||||||
helcimSubscriptionId: 'sub_13579',
|
helcimSubscriptionId: 'sub_13579',
|
||||||
slackInvited: true,
|
slackInvited: true,
|
||||||
craftTags: ['game-design', 'production-management', 'marketing-and-comms', 'business-development'],
|
craftTags: ['game-design', 'production-management', 'marketing-and-comms', 'business-development'],
|
||||||
board: {
|
board: { slackHandle: 'morgan.d' },
|
||||||
topics: [
|
|
||||||
{ tagSlug: 'governance', state: 'help' },
|
|
||||||
{ tagSlug: 'cooperative-bylaws', state: 'help' },
|
|
||||||
{ tagSlug: 'revenue-sharing', state: 'help' },
|
|
||||||
{ tagSlug: 'worker-ownership', state: 'help' },
|
|
||||||
{ tagSlug: 'collective-bargaining', state: 'interested' },
|
|
||||||
{ tagSlug: 'inter-coop-collaboration', state: 'interested' },
|
|
||||||
],
|
|
||||||
offerPeerSupport: true,
|
|
||||||
slackHandle: 'morgan.d',
|
|
||||||
},
|
|
||||||
createdAt: new Date('2024-09-01'),
|
createdAt: new Date('2024-09-01'),
|
||||||
lastLogin: new Date('2026-04-13'),
|
lastLogin: new Date('2026-04-13'),
|
||||||
},
|
},
|
||||||
|
|
@ -265,14 +150,7 @@ const sampleMembers = [
|
||||||
avatar: 'disbelieving',
|
avatar: 'disbelieving',
|
||||||
slackInvited: false,
|
slackInvited: false,
|
||||||
craftTags: ['programming', 'qa-and-testing'],
|
craftTags: ['programming', 'qa-and-testing'],
|
||||||
board: {
|
board: {},
|
||||||
topics: [
|
|
||||||
{ tagSlug: 'cooperative-tech', state: 'seeking' },
|
|
||||||
{ tagSlug: 'worker-ownership', state: 'seeking' },
|
|
||||||
{ tagSlug: 'sustainability', state: 'interested' },
|
|
||||||
],
|
|
||||||
offerPeerSupport: false,
|
|
||||||
},
|
|
||||||
createdAt: new Date('2024-10-10'),
|
createdAt: new Date('2024-10-10'),
|
||||||
lastLogin: new Date('2026-03-20'),
|
lastLogin: new Date('2026-03-20'),
|
||||||
},
|
},
|
||||||
|
|
@ -285,17 +163,7 @@ const sampleMembers = [
|
||||||
avatar: 'wtf',
|
avatar: 'wtf',
|
||||||
slackInvited: true,
|
slackInvited: true,
|
||||||
craftTags: ['community-management', 'education-and-mentoring', 'marketing-and-comms'],
|
craftTags: ['community-management', 'education-and-mentoring', 'marketing-and-comms'],
|
||||||
board: {
|
board: { slackHandle: 'phoenix.m' },
|
||||||
topics: [
|
|
||||||
{ tagSlug: 'cooperative-marketing', state: 'help' },
|
|
||||||
{ tagSlug: 'community-building', state: 'help' },
|
|
||||||
{ tagSlug: 'equity-and-inclusion', state: 'help' },
|
|
||||||
{ tagSlug: 'member-onboarding', state: 'help' },
|
|
||||||
{ tagSlug: 'conflict-resolution', state: 'interested' },
|
|
||||||
],
|
|
||||||
offerPeerSupport: true,
|
|
||||||
slackHandle: 'phoenix.m',
|
|
||||||
},
|
|
||||||
createdAt: new Date('2024-11-05'),
|
createdAt: new Date('2024-11-05'),
|
||||||
lastLogin: new Date('2026-04-06'),
|
lastLogin: new Date('2026-04-06'),
|
||||||
},
|
},
|
||||||
|
|
@ -308,16 +176,7 @@ const sampleMembers = [
|
||||||
avatar: 'sweet',
|
avatar: 'sweet',
|
||||||
slackInvited: true,
|
slackInvited: true,
|
||||||
craftTags: ['narrative-design', 'accessibility', 'education-and-mentoring'],
|
craftTags: ['narrative-design', 'accessibility', 'education-and-mentoring'],
|
||||||
board: {
|
board: { slackHandle: 'sage.a' },
|
||||||
topics: [
|
|
||||||
{ tagSlug: 'equity-and-inclusion', state: 'interested' },
|
|
||||||
{ tagSlug: 'sustainability', state: 'seeking' },
|
|
||||||
{ tagSlug: 'community-building', state: 'interested' },
|
|
||||||
{ tagSlug: 'consensus-decision-making', state: 'seeking' },
|
|
||||||
],
|
|
||||||
offerPeerSupport: true,
|
|
||||||
slackHandle: 'sage.a',
|
|
||||||
},
|
|
||||||
createdAt: new Date('2024-12-01'),
|
createdAt: new Date('2024-12-01'),
|
||||||
lastLogin: new Date('2026-04-02'),
|
lastLogin: new Date('2026-04-02'),
|
||||||
},
|
},
|
||||||
|
|
@ -330,17 +189,7 @@ const sampleMembers = [
|
||||||
avatar: 'mild',
|
avatar: 'mild',
|
||||||
slackInvited: true,
|
slackInvited: true,
|
||||||
craftTags: ['game-design', 'art-and-animation', 'audio-and-music'],
|
craftTags: ['game-design', 'art-and-animation', 'audio-and-music'],
|
||||||
board: {
|
board: { slackHandle: 'dakota.w' },
|
||||||
topics: [
|
|
||||||
{ tagSlug: 'governance', state: 'interested' },
|
|
||||||
{ tagSlug: 'finance-and-budgeting', state: 'seeking' },
|
|
||||||
{ tagSlug: 'cooperative-bylaws', state: 'seeking' },
|
|
||||||
{ tagSlug: 'revenue-sharing', state: 'interested' },
|
|
||||||
{ tagSlug: 'democratic-management', state: 'interested' },
|
|
||||||
],
|
|
||||||
offerPeerSupport: true,
|
|
||||||
slackHandle: 'dakota.w',
|
|
||||||
},
|
|
||||||
createdAt: new Date('2025-01-10'),
|
createdAt: new Date('2025-01-10'),
|
||||||
lastLogin: new Date('2026-04-10'),
|
lastLogin: new Date('2026-04-10'),
|
||||||
},
|
},
|
||||||
|
|
@ -355,17 +204,7 @@ const sampleMembers = [
|
||||||
helcimSubscriptionId: 'sub_22222',
|
helcimSubscriptionId: 'sub_22222',
|
||||||
slackInvited: true,
|
slackInvited: true,
|
||||||
craftTags: ['business-development', 'analytics-and-data', 'production-management'],
|
craftTags: ['business-development', 'analytics-and-data', 'production-management'],
|
||||||
board: {
|
board: { slackHandle: 'charlie.t' },
|
||||||
topics: [
|
|
||||||
{ tagSlug: 'finance-and-budgeting', state: 'help' },
|
|
||||||
{ tagSlug: 'cooperative-funding', state: 'help' },
|
|
||||||
{ tagSlug: 'collective-bargaining', state: 'help' },
|
|
||||||
{ tagSlug: 'sustainability', state: 'help' },
|
|
||||||
{ tagSlug: 'governance', state: 'interested' },
|
|
||||||
],
|
|
||||||
offerPeerSupport: true,
|
|
||||||
slackHandle: 'charlie.t',
|
|
||||||
},
|
|
||||||
createdAt: new Date('2025-02-14'),
|
createdAt: new Date('2025-02-14'),
|
||||||
lastLogin: new Date('2026-04-12'),
|
lastLogin: new Date('2026-04-12'),
|
||||||
},
|
},
|
||||||
|
|
@ -379,16 +218,7 @@ const sampleMembers = [
|
||||||
avatar: 'exasperated',
|
avatar: 'exasperated',
|
||||||
slackInvited: true,
|
slackInvited: true,
|
||||||
craftTags: ['programming', 'game-design', 'devops-and-tools'],
|
craftTags: ['programming', 'game-design', 'devops-and-tools'],
|
||||||
board: {
|
board: { slackHandle: 'robin.n' },
|
||||||
topics: [
|
|
||||||
{ tagSlug: 'worker-ownership', state: 'help' },
|
|
||||||
{ tagSlug: 'cooperative-tech', state: 'help' },
|
|
||||||
{ tagSlug: 'platform-cooperativism', state: 'help' },
|
|
||||||
{ tagSlug: 'shared-resources', state: 'interested' },
|
|
||||||
],
|
|
||||||
offerPeerSupport: true,
|
|
||||||
slackHandle: 'robin.n',
|
|
||||||
},
|
|
||||||
createdAt: new Date('2025-03-01'),
|
createdAt: new Date('2025-03-01'),
|
||||||
lastLogin: new Date('2026-04-13'),
|
lastLogin: new Date('2026-04-13'),
|
||||||
},
|
},
|
||||||
|
|
@ -401,16 +231,7 @@ const sampleMembers = [
|
||||||
avatar: 'wtf',
|
avatar: 'wtf',
|
||||||
slackInvited: true,
|
slackInvited: true,
|
||||||
craftTags: ['art-and-animation', 'community-management'],
|
craftTags: ['art-and-animation', 'community-management'],
|
||||||
board: {
|
board: { slackHandle: 'emery.o' },
|
||||||
topics: [
|
|
||||||
{ tagSlug: 'equity-and-inclusion', state: 'help' },
|
|
||||||
{ tagSlug: 'conflict-resolution', state: 'interested' },
|
|
||||||
{ tagSlug: 'community-building', state: 'help' },
|
|
||||||
{ tagSlug: 'consensus-decision-making', state: 'interested' },
|
|
||||||
],
|
|
||||||
offerPeerSupport: true,
|
|
||||||
slackHandle: 'emery.o',
|
|
||||||
},
|
|
||||||
createdAt: new Date('2025-03-15'),
|
createdAt: new Date('2025-03-15'),
|
||||||
lastLogin: new Date('2026-04-11'),
|
lastLogin: new Date('2026-04-11'),
|
||||||
},
|
},
|
||||||
|
|
@ -423,17 +244,7 @@ const sampleMembers = [
|
||||||
avatar: 'disbelieving',
|
avatar: 'disbelieving',
|
||||||
slackInvited: true,
|
slackInvited: true,
|
||||||
craftTags: ['production-management', 'business-development', 'education-and-mentoring'],
|
craftTags: ['production-management', 'business-development', 'education-and-mentoring'],
|
||||||
board: {
|
board: { slackHandle: 'quinn.f' },
|
||||||
topics: [
|
|
||||||
{ tagSlug: 'governance', state: 'help' },
|
|
||||||
{ tagSlug: 'democratic-management', state: 'help' },
|
|
||||||
{ tagSlug: 'cooperative-bylaws', state: 'interested' },
|
|
||||||
{ tagSlug: 'member-onboarding', state: 'help' },
|
|
||||||
{ tagSlug: 'inter-coop-collaboration', state: 'help' },
|
|
||||||
],
|
|
||||||
offerPeerSupport: true,
|
|
||||||
slackHandle: 'quinn.f',
|
|
||||||
},
|
|
||||||
createdAt: new Date('2025-04-01'),
|
createdAt: new Date('2025-04-01'),
|
||||||
lastLogin: new Date('2026-04-14'),
|
lastLogin: new Date('2026-04-14'),
|
||||||
},
|
},
|
||||||
|
|
@ -446,16 +257,7 @@ const sampleMembers = [
|
||||||
avatar: 'sweet',
|
avatar: 'sweet',
|
||||||
slackInvited: true,
|
slackInvited: true,
|
||||||
craftTags: ['ux-and-ui-design', 'accessibility', 'narrative-design'],
|
craftTags: ['ux-and-ui-design', 'accessibility', 'narrative-design'],
|
||||||
board: {
|
board: {},
|
||||||
topics: [
|
|
||||||
{ tagSlug: 'platform-cooperativism', state: 'interested' },
|
|
||||||
{ tagSlug: 'cooperative-marketing', state: 'seeking' },
|
|
||||||
{ tagSlug: 'shared-resources', state: 'interested' },
|
|
||||||
{ tagSlug: 'sustainability', state: 'seeking' },
|
|
||||||
{ tagSlug: 'equity-and-inclusion', state: 'interested' },
|
|
||||||
],
|
|
||||||
offerPeerSupport: false,
|
|
||||||
},
|
|
||||||
createdAt: new Date('2025-05-10'),
|
createdAt: new Date('2025-05-10'),
|
||||||
lastLogin: new Date('2026-04-09'),
|
lastLogin: new Date('2026-04-09'),
|
||||||
},
|
},
|
||||||
|
|
@ -468,35 +270,13 @@ const sampleMembers = [
|
||||||
avatar: 'mild',
|
avatar: 'mild',
|
||||||
slackInvited: true,
|
slackInvited: true,
|
||||||
craftTags: ['audio-and-music', 'localization'],
|
craftTags: ['audio-and-music', 'localization'],
|
||||||
board: {
|
board: { slackHandle: 'indigo.r' },
|
||||||
topics: [
|
|
||||||
{ tagSlug: 'collective-bargaining', state: 'seeking' },
|
|
||||||
{ tagSlug: 'revenue-sharing', state: 'seeking' },
|
|
||||||
{ tagSlug: 'worker-ownership', state: 'interested' },
|
|
||||||
],
|
|
||||||
offerPeerSupport: true,
|
|
||||||
slackHandle: 'indigo.r',
|
|
||||||
},
|
|
||||||
createdAt: new Date('2025-06-01'),
|
createdAt: new Date('2025-06-01'),
|
||||||
lastLogin: new Date('2026-04-04'),
|
lastLogin: new Date('2026-04-04'),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
// Board topics for the test admin so the logged-in user sees matches
|
|
||||||
const TEST_ADMIN_BOARD = {
|
const TEST_ADMIN_BOARD = {
|
||||||
topics: [
|
|
||||||
{ tagSlug: 'governance', state: 'interested' },
|
|
||||||
{ tagSlug: 'worker-ownership', state: 'seeking' },
|
|
||||||
{ tagSlug: 'cooperative-tech', state: 'interested' },
|
|
||||||
{ tagSlug: 'community-building', state: 'seeking' },
|
|
||||||
{ tagSlug: 'equity-and-inclusion', state: 'interested' },
|
|
||||||
{ tagSlug: 'revenue-sharing', state: 'seeking' },
|
|
||||||
{ tagSlug: 'cooperative-funding', state: 'interested' },
|
|
||||||
{ tagSlug: 'sustainability', state: 'interested' },
|
|
||||||
{ tagSlug: 'consensus-decision-making', state: 'seeking' },
|
|
||||||
{ tagSlug: 'platform-cooperativism', state: 'interested' },
|
|
||||||
],
|
|
||||||
offerPeerSupport: true,
|
|
||||||
slackHandle: 'test-admin',
|
slackHandle: 'test-admin',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -508,7 +288,7 @@ async function seedMembers() {
|
||||||
await Member.deleteMany({ email: { $ne: 'test-admin@ghostguild.dev' } })
|
await Member.deleteMany({ email: { $ne: 'test-admin@ghostguild.dev' } })
|
||||||
console.log('Cleared existing members (kept test admin)')
|
console.log('Cleared existing members (kept test admin)')
|
||||||
|
|
||||||
// Update test admin with board topics so the Board page shows matches
|
// Update test admin with slack handle + craft tags
|
||||||
const adminUpdate = await Member.findOneAndUpdate(
|
const adminUpdate = await Member.findOneAndUpdate(
|
||||||
{ email: 'test-admin@ghostguild.dev' },
|
{ email: 'test-admin@ghostguild.dev' },
|
||||||
{
|
{
|
||||||
|
|
@ -519,7 +299,7 @@ async function seedMembers() {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if (adminUpdate) {
|
if (adminUpdate) {
|
||||||
console.log('Updated test admin with board topics')
|
console.log('Updated test admin with board + craft tags')
|
||||||
} else {
|
} else {
|
||||||
console.log('Test admin not found — run /api/dev/test-login first to create it')
|
console.log('Test admin not found — run /api/dev/test-login first to create it')
|
||||||
}
|
}
|
||||||
|
|
@ -539,11 +319,8 @@ async function seedMembers() {
|
||||||
console.log('\nBreakdown by circle:')
|
console.log('\nBreakdown by circle:')
|
||||||
circleBreakdown.forEach((c) => console.log(` ${c._id}: ${c.count}`))
|
circleBreakdown.forEach((c) => console.log(` ${c._id}: ${c.count}`))
|
||||||
|
|
||||||
const withTopics = await Member.countDocuments({ 'board.topics.0': { $exists: true } })
|
|
||||||
console.log(`\nMembers with board topics: ${withTopics}`)
|
|
||||||
|
|
||||||
const withSlack = await Member.countDocuments({ 'board.slackHandle': { $exists: true, $ne: null } })
|
const withSlack = await Member.countDocuments({ 'board.slackHandle': { $exists: true, $ne: null } })
|
||||||
console.log(`Members with slack handles: ${withSlack}`)
|
console.log(`\nMembers with slack handles: ${withSlack}`)
|
||||||
|
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
64
server/api/admin/board-channels.post.js
Normal file
64
server/api/admin/board-channels.post.js
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import BoardChannel from '../../models/boardChannel.js'
|
||||||
|
import { requireAdmin } from '../../utils/auth.js'
|
||||||
|
import { validateBody } from '../../utils/validateBody.js'
|
||||||
|
import { boardChannelCreateSchema } from '../../utils/schemas.js'
|
||||||
|
import { getSlackAdminService } from '../../utils/slack.ts'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireAdmin(event)
|
||||||
|
|
||||||
|
const body = await validateBody(event, boardChannelCreateSchema)
|
||||||
|
|
||||||
|
if (body.tagSlugs && body.tagSlugs.length) {
|
||||||
|
const conflict = await BoardChannel.findOne({ tagSlugs: { $in: body.tagSlugs } }).lean()
|
||||||
|
if (conflict) {
|
||||||
|
const taken = (conflict.tagSlugs || []).filter((s) => body.tagSlugs.includes(s))
|
||||||
|
throw createError({
|
||||||
|
statusCode: 409,
|
||||||
|
statusMessage: `Tag${taken.length > 1 ? 's' : ''} already mapped to "${conflict.name}": ${taken.join(', ')}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let slackChannelId = body.slackChannelId
|
||||||
|
let channelName = body.name
|
||||||
|
|
||||||
|
if (!slackChannelId) {
|
||||||
|
const slack = getSlackAdminService()
|
||||||
|
if (!slack) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Slack integration not configured',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const created = await slack.createChannel(body.name)
|
||||||
|
slackChannelId = created.id
|
||||||
|
channelName = created.name
|
||||||
|
} catch (err) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 502,
|
||||||
|
statusMessage: `Failed to create Slack channel: ${err.data?.error || err.message}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const channel = await BoardChannel.create({
|
||||||
|
name: channelName,
|
||||||
|
slackChannelId,
|
||||||
|
tagSlugs: body.tagSlugs || []
|
||||||
|
})
|
||||||
|
|
||||||
|
setResponseStatus(event, 201)
|
||||||
|
return { channel: channel.toObject() }
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 11000) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 409,
|
||||||
|
statusMessage: 'A channel with that Slack channel ID already exists'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
})
|
||||||
14
server/api/admin/board-channels/[id].delete.js
Normal file
14
server/api/admin/board-channels/[id].delete.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import BoardChannel from '../../../models/boardChannel.js'
|
||||||
|
import { requireAdmin } from '../../../utils/auth.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireAdmin(event)
|
||||||
|
const id = getRouterParam(event, 'id')
|
||||||
|
|
||||||
|
const channel = await BoardChannel.findByIdAndDelete(id)
|
||||||
|
if (!channel) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Channel not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
53
server/api/admin/board-channels/[id].patch.js
Normal file
53
server/api/admin/board-channels/[id].patch.js
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import BoardChannel from '../../../models/boardChannel.js'
|
||||||
|
import { requireAdmin } from '../../../utils/auth.js'
|
||||||
|
import { validateBody } from '../../../utils/validateBody.js'
|
||||||
|
import { boardChannelUpdateSchema } from '../../../utils/schemas.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireAdmin(event)
|
||||||
|
const id = getRouterParam(event, 'id')
|
||||||
|
|
||||||
|
const body = await validateBody(event, boardChannelUpdateSchema)
|
||||||
|
|
||||||
|
const updateData = {}
|
||||||
|
if (body.name !== undefined) updateData.name = body.name
|
||||||
|
if (body.slackChannelId !== undefined) updateData.slackChannelId = body.slackChannelId
|
||||||
|
if (body.tagSlugs !== undefined) updateData.tagSlugs = body.tagSlugs
|
||||||
|
|
||||||
|
if (body.tagSlugs && body.tagSlugs.length) {
|
||||||
|
const conflict = await BoardChannel.findOne({
|
||||||
|
_id: { $ne: id },
|
||||||
|
tagSlugs: { $in: body.tagSlugs },
|
||||||
|
}).lean()
|
||||||
|
if (conflict) {
|
||||||
|
const taken = (conflict.tagSlugs || []).filter((s) => body.tagSlugs.includes(s))
|
||||||
|
throw createError({
|
||||||
|
statusCode: 409,
|
||||||
|
statusMessage: `Tag${taken.length > 1 ? 's' : ''} already mapped to "${conflict.name}": ${taken.join(', ')}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const channel = await BoardChannel.findByIdAndUpdate(
|
||||||
|
id,
|
||||||
|
{ $set: updateData },
|
||||||
|
{ new: true, runValidators: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!channel) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Channel not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { channel: channel.toObject() }
|
||||||
|
} catch (err) {
|
||||||
|
if (err.statusCode) throw err
|
||||||
|
if (err.code === 11000) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 409,
|
||||||
|
statusMessage: 'A channel with that Slack channel ID already exists'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
})
|
||||||
10
server/api/board/channels.get.js
Normal file
10
server/api/board/channels.get.js
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import BoardChannel from '../../models/boardChannel.js'
|
||||||
|
import { requireAuth } from '../../utils/auth.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireAuth(event)
|
||||||
|
|
||||||
|
const channels = await BoardChannel.find({}).sort({ name: 1 }).lean()
|
||||||
|
|
||||||
|
return { channels }
|
||||||
|
})
|
||||||
24
server/api/board/posts.get.js
Normal file
24
server/api/board/posts.get.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import BoardPost from '../../models/boardPost.js'
|
||||||
|
import { requireAuth } from '../../utils/auth.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const member = await requireAuth(event)
|
||||||
|
|
||||||
|
const query = getQuery(event)
|
||||||
|
const dbQuery = {}
|
||||||
|
|
||||||
|
if (query.tag) {
|
||||||
|
dbQuery.tags = query.tag
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.author) {
|
||||||
|
dbQuery.author = query.author === 'me' ? member._id : query.author
|
||||||
|
}
|
||||||
|
|
||||||
|
const posts = await BoardPost.find(dbQuery)
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.populate('author', 'name avatar circle board.slackHandle')
|
||||||
|
.lean()
|
||||||
|
|
||||||
|
return { posts }
|
||||||
|
})
|
||||||
28
server/api/board/posts.post.js
Normal file
28
server/api/board/posts.post.js
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import BoardPost from '../../models/boardPost.js'
|
||||||
|
import { requireAuth } from '../../utils/auth.js'
|
||||||
|
import { validateBody } from '../../utils/validateBody.js'
|
||||||
|
import { boardPostCreateSchema } from '../../utils/schemas.js'
|
||||||
|
import { logActivity, ACTIVITY_TYPES } from '../../utils/activityLog.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const member = await requireAuth(event)
|
||||||
|
|
||||||
|
const body = await validateBody(event, boardPostCreateSchema)
|
||||||
|
|
||||||
|
const post = new BoardPost({
|
||||||
|
author: member._id,
|
||||||
|
title: body.title,
|
||||||
|
seeking: body.seeking,
|
||||||
|
offering: body.offering,
|
||||||
|
note: body.note,
|
||||||
|
tags: body.tags || []
|
||||||
|
})
|
||||||
|
|
||||||
|
await post.save()
|
||||||
|
await post.populate('author', 'name avatar circle board.slackHandle')
|
||||||
|
|
||||||
|
logActivity(member._id, ACTIVITY_TYPES.BOARD_POST_CREATED, { postId: post._id, title: post.title })
|
||||||
|
|
||||||
|
setResponseStatus(event, 201)
|
||||||
|
return { post: post.toObject() }
|
||||||
|
})
|
||||||
20
server/api/board/posts/[id].delete.js
Normal file
20
server/api/board/posts/[id].delete.js
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import BoardPost from '../../../models/boardPost.js'
|
||||||
|
import { requireAuth } from '../../../utils/auth.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const member = await requireAuth(event)
|
||||||
|
const id = getRouterParam(event, 'id')
|
||||||
|
|
||||||
|
const post = await BoardPost.findById(id)
|
||||||
|
if (!post) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Post not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (post.author.toString() !== member._id.toString()) {
|
||||||
|
throw createError({ statusCode: 403, statusMessage: 'Not authorized to delete this post' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await post.deleteOne()
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
52
server/api/board/posts/[id].patch.js
Normal file
52
server/api/board/posts/[id].patch.js
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import BoardPost from '../../../models/boardPost.js'
|
||||||
|
import { requireAuth } from '../../../utils/auth.js'
|
||||||
|
import { validateBody } from '../../../utils/validateBody.js'
|
||||||
|
import { boardPostUpdateSchema } from '../../../utils/schemas.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const member = await requireAuth(event)
|
||||||
|
const id = getRouterParam(event, 'id')
|
||||||
|
|
||||||
|
const body = await validateBody(event, boardPostUpdateSchema)
|
||||||
|
|
||||||
|
const post = await BoardPost.findById(id)
|
||||||
|
if (!post) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Post not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (post.author.toString() !== member._id.toString()) {
|
||||||
|
throw createError({ statusCode: 403, statusMessage: 'Not authorized to edit this post' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.title !== undefined) post.title = body.title
|
||||||
|
if (body.seeking !== undefined) post.seeking = body.seeking
|
||||||
|
if (body.offering !== undefined) post.offering = body.offering
|
||||||
|
if (body.note !== undefined) post.note = body.note
|
||||||
|
if (body.tags !== undefined) post.tags = body.tags
|
||||||
|
|
||||||
|
const seeking = (post.seeking || '').trim()
|
||||||
|
const offering = (post.offering || '').trim()
|
||||||
|
if (!seeking && !offering) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'At least one of seeking or offering must be provided'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await post.save()
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'ValidationError') {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Validation failed',
|
||||||
|
data: err.errors
|
||||||
|
})
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
await post.populate('author', 'name avatar circle board.slackHandle')
|
||||||
|
|
||||||
|
return { post: post.toObject() }
|
||||||
|
})
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
import Member from '../../models/member.js'
|
|
||||||
import { requireAuth } from '../../utils/auth.js'
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
const member = await requireAuth(event)
|
|
||||||
const memberId = member._id
|
|
||||||
|
|
||||||
const topics = member.board?.topics || []
|
|
||||||
if (!topics.length) {
|
|
||||||
return { suggestions: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = getQuery(event)
|
|
||||||
const filterTag = query.tag || null
|
|
||||||
|
|
||||||
let myTopics = topics
|
|
||||||
if (filterTag) {
|
|
||||||
myTopics = myTopics.filter((t) => t.tagSlug === filterTag)
|
|
||||||
}
|
|
||||||
if (!myTopics.length) {
|
|
||||||
return { suggestions: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
const mySlugs = myTopics.map((t) => t.tagSlug)
|
|
||||||
|
|
||||||
const candidates = await Member.find({
|
|
||||||
_id: { $ne: memberId },
|
|
||||||
status: 'active',
|
|
||||||
'board.topics.tagSlug': { $in: mySlugs },
|
|
||||||
})
|
|
||||||
.select('name avatar craftTags circle board privacy')
|
|
||||||
.lean()
|
|
||||||
|
|
||||||
if (!candidates.length) {
|
|
||||||
return { suggestions: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
const myTopicMap = {}
|
|
||||||
for (const t of myTopics) {
|
|
||||||
myTopicMap[t.tagSlug] = t.state
|
|
||||||
}
|
|
||||||
|
|
||||||
const suggestions = []
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
const theirTopics = candidate.board?.topics || []
|
|
||||||
const matchingTags = []
|
|
||||||
|
|
||||||
for (const theirTopic of theirTopics) {
|
|
||||||
const myState = myTopicMap[theirTopic.tagSlug]
|
|
||||||
if (!myState) continue
|
|
||||||
|
|
||||||
matchingTags.push({
|
|
||||||
tagSlug: theirTopic.tagSlug,
|
|
||||||
yourState: myState,
|
|
||||||
theirState: theirTopic.state,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!matchingTags.length) continue
|
|
||||||
|
|
||||||
// Privacy filter: only expose fields the candidate allows to other members
|
|
||||||
const privacy = candidate.privacy || {}
|
|
||||||
const filtered = {
|
|
||||||
_id: candidate._id,
|
|
||||||
name: candidate.name,
|
|
||||||
circle: candidate.circle,
|
|
||||||
}
|
|
||||||
|
|
||||||
const avatarPrivacy = privacy.avatar || 'public'
|
|
||||||
if (avatarPrivacy === 'public' || avatarPrivacy === 'members') {
|
|
||||||
filtered.avatar = candidate.avatar
|
|
||||||
}
|
|
||||||
|
|
||||||
const craftTagsPrivacy = privacy.craftTags || 'members'
|
|
||||||
if (craftTagsPrivacy === 'public' || craftTagsPrivacy === 'members') {
|
|
||||||
filtered.craftTags = candidate.craftTags
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expose slackHandle only when the candidate has opted into peer support.
|
|
||||||
// Slack handle is the contact-in-place path — without it, there is no way
|
|
||||||
// for the current member to reach out.
|
|
||||||
if (candidate.board?.offerPeerSupport && candidate.board?.slackHandle) {
|
|
||||||
filtered.slackHandle = candidate.board.slackHandle
|
|
||||||
}
|
|
||||||
|
|
||||||
suggestions.push({
|
|
||||||
member: filtered,
|
|
||||||
matchingTags,
|
|
||||||
matchCount: matchingTags.length,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
suggestions.sort((a, b) => b.matchCount - a.matchCount)
|
|
||||||
|
|
||||||
return { suggestions }
|
|
||||||
})
|
|
||||||
|
|
@ -70,21 +70,9 @@ export default defineEventHandler(async (event) => {
|
||||||
if (isVisible("socialLinks")) filtered.socialLinks = member.socialLinks;
|
if (isVisible("socialLinks")) filtered.socialLinks = member.socialLinks;
|
||||||
if (isVisible("craftTags")) filtered.craftTags = member.craftTags;
|
if (isVisible("craftTags")) filtered.craftTags = member.craftTags;
|
||||||
|
|
||||||
if (isVisible("board")) {
|
|
||||||
const board = member.board || {};
|
|
||||||
filtered.board = {
|
filtered.board = {
|
||||||
topics: board.topics,
|
slackHandle: member.board?.slackHandle,
|
||||||
offerPeerSupport: board.offerPeerSupport,
|
|
||||||
availability: board.availability,
|
|
||||||
details: board.details,
|
|
||||||
// Contact-in-place: surface the handle + personal message only when
|
|
||||||
// the member has explicitly opted into peer support.
|
|
||||||
...(board.offerPeerSupport && {
|
|
||||||
slackHandle: board.slackHandle,
|
|
||||||
personalMessage: board.personalMessage,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
return { member: filtered };
|
return { member: filtered };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,7 @@ export default defineEventHandler(async (event) => {
|
||||||
const query = getQuery(event);
|
const query = getQuery(event);
|
||||||
const search = query.search || "";
|
const search = query.search || "";
|
||||||
const circle = query.circle || "";
|
const circle = query.circle || "";
|
||||||
const peerSupport = query.peerSupport || "";
|
|
||||||
const craftTag = query.craftTag || "";
|
const craftTag = query.craftTag || "";
|
||||||
const connectionTag = query.connectionTag || "";
|
|
||||||
|
|
||||||
const dbQuery = {
|
const dbQuery = {
|
||||||
showInDirectory: true,
|
showInDirectory: true,
|
||||||
|
|
@ -37,10 +35,6 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
const andConditions = [];
|
const andConditions = [];
|
||||||
|
|
||||||
if (peerSupport === "true") {
|
|
||||||
dbQuery["board.offerPeerSupport"] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
const escaped = escapeRegex(search);
|
const escaped = escapeRegex(search);
|
||||||
andConditions.push({
|
andConditions.push({
|
||||||
|
|
@ -55,10 +49,6 @@ export default defineEventHandler(async (event) => {
|
||||||
dbQuery.craftTags = craftTag;
|
dbQuery.craftTags = craftTag;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connectionTag) {
|
|
||||||
dbQuery["board.topics.tagSlug"] = connectionTag;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (andConditions.length > 0) {
|
if (andConditions.length > 0) {
|
||||||
dbQuery.$and = andConditions;
|
dbQuery.$and = andConditions;
|
||||||
}
|
}
|
||||||
|
|
@ -96,17 +86,9 @@ export default defineEventHandler(async (event) => {
|
||||||
if (isVisible("socialLinks")) filtered.socialLinks = member.socialLinks;
|
if (isVisible("socialLinks")) filtered.socialLinks = member.socialLinks;
|
||||||
if (isVisible("craftTags")) filtered.craftTags = member.craftTags;
|
if (isVisible("craftTags")) filtered.craftTags = member.craftTags;
|
||||||
|
|
||||||
if (isVisible("board")) {
|
|
||||||
const board = member.board || {};
|
|
||||||
filtered.board = {
|
filtered.board = {
|
||||||
topics: board.topics,
|
slackHandle: member.board?.slackHandle,
|
||||||
offerPeerSupport: board.offerPeerSupport,
|
|
||||||
availability: board.availability,
|
|
||||||
...(board.offerPeerSupport && {
|
|
||||||
slackHandle: board.slackHandle,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
import Member from '../../../models/member.js'
|
|
||||||
import { connectDB } from '../../../utils/mongoose.js'
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
await connectDB()
|
|
||||||
const member = await requireAuth(event)
|
|
||||||
|
|
||||||
const body = await validateBody(event, boardUpdateSchema)
|
|
||||||
|
|
||||||
const updateData = {
|
|
||||||
'board.topics': body.topics || [],
|
|
||||||
'board.offerPeerSupport': body.offerPeerSupport || false,
|
|
||||||
'board.availability': body.availability || '',
|
|
||||||
'board.slackHandle': body.slackHandle || '',
|
|
||||||
'board.personalMessage': body.personalMessage || '',
|
|
||||||
'board.details': body.details || '',
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.offerPeerSupport && body.slackHandle) {
|
|
||||||
try {
|
|
||||||
const { getSlackService } = await import('../../../utils/slack.ts')
|
|
||||||
const slackService = getSlackService()
|
|
||||||
|
|
||||||
if (slackService) {
|
|
||||||
const slackUserId = await slackService.findUserIdByUsername(body.slackHandle)
|
|
||||||
if (slackUserId) {
|
|
||||||
updateData.slackUserId = slackUserId
|
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
`[Board] Could not find Slack user ID for handle: ${body.slackHandle}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Board] Error fetching Slack user ID:', error.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updated = await Member.findByIdAndUpdate(
|
|
||||||
member._id,
|
|
||||||
{ $set: updateData },
|
|
||||||
{ new: true, runValidators: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!updated) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 404,
|
|
||||||
statusMessage: 'Member not found',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
logActivity(member._id, 'board_updated', {
|
|
||||||
topicCount: (body.topics || []).length,
|
|
||||||
offerPeerSupport: body.offerPeerSupport || false,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
board: updated.board,
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error.statusCode) throw error
|
|
||||||
console.error('Board update error:', error)
|
|
||||||
throw createError({
|
|
||||||
statusCode: 500,
|
|
||||||
statusMessage: 'Failed to update board settings',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -32,7 +32,6 @@ export default defineEventHandler(async (event) => {
|
||||||
"locationPrivacy",
|
"locationPrivacy",
|
||||||
"socialLinksPrivacy",
|
"socialLinksPrivacy",
|
||||||
"craftTagsPrivacy",
|
"craftTagsPrivacy",
|
||||||
"boardPrivacy",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Build update object from validated data
|
// Build update object from validated data
|
||||||
|
|
@ -49,6 +48,11 @@ export default defineEventHandler(async (event) => {
|
||||||
updateData.craftTags = body.craftTags;
|
updateData.craftTags = body.craftTags;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle board slack handle
|
||||||
|
if (body.boardSlackHandle !== undefined) {
|
||||||
|
updateData["board.slackHandle"] = body.boardSlackHandle;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle privacy settings
|
// Handle privacy settings
|
||||||
privacyFields.forEach((privacyField) => {
|
privacyFields.forEach((privacyField) => {
|
||||||
if (body[privacyField] !== undefined) {
|
if (body[privacyField] !== undefined) {
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,16 @@
|
||||||
import { requireAuth } from '../../utils/auth.js'
|
import { requireAuth } from '../../utils/auth.js'
|
||||||
|
import BoardPost from '../../models/boardPost.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const member = await requireAuth(event)
|
const member = await requireAuth(event)
|
||||||
|
|
||||||
const hasProfileTags =
|
const hasProfileTags = member.craftTags.length > 0
|
||||||
member.craftTags.length > 0 &&
|
|
||||||
(member.board?.topics || []).length > 0
|
|
||||||
|
|
||||||
const hasVisitedEvent = !!member.onboarding?.eventPageVisited
|
const hasVisitedEvent = !!member.onboarding?.eventPageVisited
|
||||||
|
|
||||||
const topics = member.board?.topics || []
|
const hasPosted = await BoardPost.exists({ author: member._id })
|
||||||
const hasEngagedBoard =
|
const hasEngagedBoard =
|
||||||
!!member.onboarding?.boardPageVisited &&
|
!!member.onboarding?.boardPageVisited && !!hasPosted
|
||||||
topics.some((t) => ['help', 'interested', 'seeking'].includes(t.state))
|
|
||||||
|
|
||||||
const hasClickedWiki = !!member.onboarding?.wikiClicked
|
const hasClickedWiki = !!member.onboarding?.wikiClicked
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { requireAuth } from '../../utils/auth.js'
|
||||||
import { validateBody } from '../../utils/validateBody.js'
|
import { validateBody } from '../../utils/validateBody.js'
|
||||||
import { onboardingTrackSchema } from '../../utils/schemas.js'
|
import { onboardingTrackSchema } from '../../utils/schemas.js'
|
||||||
import Member from '../../models/member.js'
|
import Member from '../../models/member.js'
|
||||||
|
import BoardPost from '../../models/boardPost.js'
|
||||||
import { logActivity } from '../../utils/activityLog.js'
|
import { logActivity } from '../../utils/activityLog.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
|
|
@ -26,8 +27,12 @@ export default defineEventHandler(async (event) => {
|
||||||
// Log the individual goal completion
|
// Log the individual goal completion
|
||||||
await logActivity(member._id, 'member_onboarding_goal_completed', { goal }, { visibility: 'admin' })
|
await logActivity(member._id, 'member_onboarding_goal_completed', { goal }, { visibility: 'admin' })
|
||||||
|
|
||||||
|
// Must have at least one board post to graduate
|
||||||
|
const hasPosted = await BoardPost.exists({ author: member._id })
|
||||||
|
|
||||||
// Graduation check — atomic so concurrent requests can't double-graduate
|
// Graduation check — atomic so concurrent requests can't double-graduate
|
||||||
const graduated = await Member.findOneAndUpdate(
|
const graduated = hasPosted
|
||||||
|
? await Member.findOneAndUpdate(
|
||||||
{
|
{
|
||||||
_id: member._id,
|
_id: member._id,
|
||||||
'onboarding.completedAt': null,
|
'onboarding.completedAt': null,
|
||||||
|
|
@ -35,13 +40,11 @@ export default defineEventHandler(async (event) => {
|
||||||
'onboarding.boardPageVisited': true,
|
'onboarding.boardPageVisited': true,
|
||||||
'onboarding.wikiClicked': true,
|
'onboarding.wikiClicked': true,
|
||||||
'craftTags.0': { $exists: true },
|
'craftTags.0': { $exists: true },
|
||||||
'board.topics': {
|
|
||||||
$elemMatch: { state: { $in: ['help', 'interested', 'seeking'] } },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{ $set: { 'onboarding.completedAt': new Date() } },
|
{ $set: { 'onboarding.completedAt': new Date() } },
|
||||||
{ new: true }
|
{ new: true }
|
||||||
)
|
)
|
||||||
|
: null
|
||||||
|
|
||||||
if (graduated) {
|
if (graduated) {
|
||||||
await logActivity(member._id, 'member_onboarding_completed', {}, { visibility: 'admin' })
|
await logActivity(member._id, 'member_onboarding_completed', {}, { visibility: 'admin' })
|
||||||
|
|
|
||||||
11
server/models/boardChannel.js
Normal file
11
server/models/boardChannel.js
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import mongoose from 'mongoose'
|
||||||
|
|
||||||
|
const boardChannelSchema = new mongoose.Schema({
|
||||||
|
name: { type: String, required: true },
|
||||||
|
slackChannelId: { type: String, required: true },
|
||||||
|
tagSlugs: [String],
|
||||||
|
}, { timestamps: true })
|
||||||
|
|
||||||
|
boardChannelSchema.index({ slackChannelId: 1 }, { unique: true })
|
||||||
|
|
||||||
|
export default mongoose.models.BoardChannel || mongoose.model('BoardChannel', boardChannelSchema)
|
||||||
28
server/models/boardPost.js
Normal file
28
server/models/boardPost.js
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import mongoose from 'mongoose'
|
||||||
|
|
||||||
|
const boardPostSchema = new mongoose.Schema({
|
||||||
|
author: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'Member',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
title: { type: String, required: true, maxlength: 120 },
|
||||||
|
seeking: { type: String, maxlength: 500 },
|
||||||
|
offering: { type: String, maxlength: 500 },
|
||||||
|
note: { type: String, maxlength: 300 },
|
||||||
|
tags: [String],
|
||||||
|
}, { timestamps: true })
|
||||||
|
|
||||||
|
boardPostSchema.pre('validate', function (next) {
|
||||||
|
const seeking = (this.seeking || '').trim()
|
||||||
|
const offering = (this.offering || '').trim()
|
||||||
|
if (!seeking && !offering) {
|
||||||
|
this.invalidate('seeking', 'At least one of seeking or offering must be provided')
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
boardPostSchema.index({ author: 1 })
|
||||||
|
boardPostSchema.index({ createdAt: -1 })
|
||||||
|
|
||||||
|
export default mongoose.models.BoardPost || mongoose.model('BoardPost', boardPostSchema)
|
||||||
|
|
@ -73,17 +73,7 @@ const memberSchema = new mongoose.Schema({
|
||||||
|
|
||||||
craftTags: [String],
|
craftTags: [String],
|
||||||
board: {
|
board: {
|
||||||
topics: [
|
|
||||||
{
|
|
||||||
tagSlug: String,
|
|
||||||
state: { type: String, enum: ['help', 'interested', 'seeking'] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
offerPeerSupport: { type: Boolean, default: false },
|
|
||||||
availability: String,
|
|
||||||
slackHandle: String,
|
slackHandle: String,
|
||||||
personalMessage: String,
|
|
||||||
details: String,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Privacy settings for profile fields
|
// Privacy settings for profile fields
|
||||||
|
|
@ -128,11 +118,6 @@ const memberSchema = new mongoose.Schema({
|
||||||
enum: ["public", "members", "private"],
|
enum: ["public", "members", "private"],
|
||||||
default: "members",
|
default: "members",
|
||||||
},
|
},
|
||||||
board: {
|
|
||||||
type: String,
|
|
||||||
enum: ["public", "members", "private"],
|
|
||||||
default: "members",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
notifications: {
|
notifications: {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export const ACTIVITY_TYPES = {
|
||||||
EMAIL_SENT: 'email_sent',
|
EMAIL_SENT: 'email_sent',
|
||||||
COMMUNITY_CONNECTIONS_UPDATED: 'community_connections_updated',
|
COMMUNITY_CONNECTIONS_UPDATED: 'community_connections_updated',
|
||||||
BOARD_UPDATED: 'board_updated',
|
BOARD_UPDATED: 'board_updated',
|
||||||
|
BOARD_POST_CREATED: 'board_post_created',
|
||||||
CONNECTION_REQUESTED: 'connection_requested',
|
CONNECTION_REQUESTED: 'connection_requested',
|
||||||
CONNECTION_CONFIRMED: 'connection_confirmed',
|
CONNECTION_CONFIRMED: 'connection_confirmed',
|
||||||
TAG_SUGGESTED: 'tag_suggested'
|
TAG_SUGGESTED: 'tag_suggested'
|
||||||
|
|
@ -41,6 +42,7 @@ export const ACTIVITY_TYPE_DEFAULTS = {
|
||||||
email_sent: 'member',
|
email_sent: 'member',
|
||||||
community_connections_updated: 'member',
|
community_connections_updated: 'member',
|
||||||
board_updated: 'member',
|
board_updated: 'member',
|
||||||
|
board_post_created: 'member',
|
||||||
connection_requested: 'member',
|
connection_requested: 'member',
|
||||||
connection_confirmed: 'member',
|
connection_confirmed: 'member',
|
||||||
tag_suggested: 'member'
|
tag_suggested: 'member'
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export const memberProfileUpdateSchema = z.object({
|
||||||
socialLinksPrivacy: privacyEnum.optional(),
|
socialLinksPrivacy: privacyEnum.optional(),
|
||||||
craftTags: z.array(z.string().max(100)).max(16).optional(),
|
craftTags: z.array(z.string().max(100)).max(16).optional(),
|
||||||
craftTagsPrivacy: privacyEnum.optional(),
|
craftTagsPrivacy: privacyEnum.optional(),
|
||||||
boardPrivacy: privacyEnum.optional()
|
boardSlackHandle: z.string().max(200).optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
export const eventRegistrationSchema = z.object({
|
export const eventRegistrationSchema = z.object({
|
||||||
|
|
@ -377,16 +377,37 @@ export const tagSuggestionSchema = z.object({
|
||||||
pool: z.enum(['craft', 'cooperative'])
|
pool: z.enum(['craft', 'cooperative'])
|
||||||
})
|
})
|
||||||
|
|
||||||
export const boardUpdateSchema = z.object({
|
// --- Board post / channel schemas ---
|
||||||
topics: z.array(z.object({
|
|
||||||
tagSlug: z.string().min(1).max(100),
|
export const boardPostCreateSchema = z.object({
|
||||||
state: z.enum(['help', 'interested', 'seeking'])
|
title: z.string().trim().min(1).max(120),
|
||||||
})).max(20).optional(),
|
seeking: z.string().max(500).optional(),
|
||||||
offerPeerSupport: z.boolean().optional(),
|
offering: z.string().max(500).optional(),
|
||||||
availability: z.string().max(500).optional(),
|
note: z.string().max(300).optional(),
|
||||||
slackHandle: z.string().max(200).optional(),
|
tags: z.array(z.string().max(100)).optional().default([])
|
||||||
personalMessage: z.string().max(2000).optional(),
|
}).refine(
|
||||||
details: z.string().max(300).optional()
|
(data) => (data.seeking || '').trim().length > 0 || (data.offering || '').trim().length > 0,
|
||||||
|
{ message: 'At least one of seeking or offering must be provided', path: ['seeking'] }
|
||||||
|
)
|
||||||
|
|
||||||
|
export const boardPostUpdateSchema = z.object({
|
||||||
|
title: z.string().trim().min(1).max(120).optional(),
|
||||||
|
seeking: z.string().max(500).optional(),
|
||||||
|
offering: z.string().max(500).optional(),
|
||||||
|
note: z.string().max(300).optional(),
|
||||||
|
tags: z.array(z.string().max(100)).optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
export const boardChannelCreateSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(200),
|
||||||
|
slackChannelId: z.string().trim().min(1).max(50).regex(/^[A-Z0-9]+$/, 'Invalid Slack channel ID').optional(),
|
||||||
|
tagSlugs: z.array(z.string().max(100)).optional().default([])
|
||||||
|
})
|
||||||
|
|
||||||
|
export const boardChannelUpdateSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(200).optional(),
|
||||||
|
slackChannelId: z.string().trim().min(1).max(50).regex(/^[A-Z0-9]+$/, 'Invalid Slack channel ID').optional(),
|
||||||
|
tagSlugs: z.array(z.string().max(100)).optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- Admin alert schemas ---
|
// --- Admin alert schemas ---
|
||||||
|
|
|
||||||
|
|
@ -262,6 +262,33 @@ export class SlackService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new Slack channel. Returns the new channel id and normalized name.
|
||||||
|
*/
|
||||||
|
async createChannel(
|
||||||
|
name: string,
|
||||||
|
isPrivate: boolean = false,
|
||||||
|
): Promise<{ id: string; name: string }> {
|
||||||
|
const normalized = name
|
||||||
|
.trim()
|
||||||
|
.replace(/^#/, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9_-]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 80)
|
||||||
|
|
||||||
|
const response = await this.client.conversations.create({
|
||||||
|
name: normalized,
|
||||||
|
is_private: isPrivate,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok || !response.channel?.id || !response.channel?.name) {
|
||||||
|
throw new Error(`Slack create failed: ${response.error || 'unknown'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: response.channel.id, name: response.channel.name }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify the Slack channel exists and bot has access
|
* Verify the Slack channel exists and bot has access
|
||||||
*/
|
*/
|
||||||
|
|
@ -292,3 +319,36 @@ export function getSlackService(): SlackService | null {
|
||||||
|
|
||||||
return new SlackService(config.slackBotToken, config.slackVettingChannelId);
|
return new SlackService(config.slackBotToken, config.slackVettingChannelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a SlackService for operations that don't need the vetting channel.
|
||||||
|
*/
|
||||||
|
export function getSlackServiceNoVetting(): SlackService | null {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
|
||||||
|
if (!config.slackBotToken) {
|
||||||
|
console.warn("Slack integration not configured - missing bot token");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SlackService(config.slackBotToken, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a SlackService backed by the AdminGhost app token for admin-only
|
||||||
|
* operations like channel creation. Falls back to the main bot token if
|
||||||
|
* AdminGhost isn't configured.
|
||||||
|
*/
|
||||||
|
export function getSlackAdminService(): SlackService | null {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
|
||||||
|
const token = config.slackAdminBotToken || config.slackBotToken;
|
||||||
|
if (!token) {
|
||||||
|
console.warn(
|
||||||
|
"Slack admin integration not configured - missing admin bot token",
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SlackService(token, "");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,6 @@ describe('useOnboarding', () => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (url === '/api/events/recommended') return Promise.resolve([])
|
if (url === '/api/events/recommended') return Promise.resolve([])
|
||||||
if (url === '/api/board/suggestions') return Promise.resolve({ suggestions: [] })
|
|
||||||
if (url === '/api/wiki/recommended') return Promise.resolve([])
|
if (url === '/api/wiki/recommended') return Promise.resolve([])
|
||||||
return Promise.resolve(null)
|
return Promise.resolve(null)
|
||||||
})
|
})
|
||||||
|
|
@ -252,7 +251,6 @@ describe('useOnboarding', () => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (url === '/api/events/recommended') return Promise.resolve([])
|
if (url === '/api/events/recommended') return Promise.resolve([])
|
||||||
if (url === '/api/board/suggestions') return Promise.resolve({ suggestions: [] })
|
|
||||||
if (url === '/api/wiki/recommended') return Promise.resolve([])
|
if (url === '/api/wiki/recommended') return Promise.resolve([])
|
||||||
return Promise.resolve(null)
|
return Promise.resolve(null)
|
||||||
})
|
})
|
||||||
|
|
@ -289,9 +287,6 @@ describe('useOnboarding', () => {
|
||||||
if (url === '/api/events/recommended') {
|
if (url === '/api/events/recommended') {
|
||||||
return Promise.resolve([{ _id: 'e1', title: 'Game Jam' }])
|
return Promise.resolve([{ _id: 'e1', title: 'Game Jam' }])
|
||||||
}
|
}
|
||||||
if (url === '/api/board/suggestions') {
|
|
||||||
return Promise.resolve({ suggestions: [{ name: 'Alex' }] })
|
|
||||||
}
|
|
||||||
if (url === '/api/wiki/recommended') {
|
if (url === '/api/wiki/recommended') {
|
||||||
return Promise.resolve([{ title: 'Co-op Guide', url: 'https://wiki.example.com/coop' }])
|
return Promise.resolve([{ title: 'Co-op Guide', url: 'https://wiki.example.com/coop' }])
|
||||||
}
|
}
|
||||||
|
|
@ -329,9 +324,6 @@ describe('useOnboarding', () => {
|
||||||
if (url === '/api/events/recommended') {
|
if (url === '/api/events/recommended') {
|
||||||
return Promise.resolve([{ _id: 'e1', title: 'Game Jam' }])
|
return Promise.resolve([{ _id: 'e1', title: 'Game Jam' }])
|
||||||
}
|
}
|
||||||
if (url === '/api/board/suggestions') {
|
|
||||||
return Promise.resolve({ suggestions: [] })
|
|
||||||
}
|
|
||||||
if (url === '/api/wiki/recommended') {
|
if (url === '/api/wiki/recommended') {
|
||||||
return Promise.resolve([])
|
return Promise.resolve([])
|
||||||
}
|
}
|
||||||
|
|
@ -373,7 +365,6 @@ describe('useOnboarding', () => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (url === '/api/events/recommended') return Promise.resolve([])
|
if (url === '/api/events/recommended') return Promise.resolve([])
|
||||||
if (url === '/api/board/suggestions') return Promise.resolve({ suggestions: [] })
|
|
||||||
if (url === '/api/wiki/recommended') return Promise.resolve([])
|
if (url === '/api/wiki/recommended') return Promise.resolve([])
|
||||||
return Promise.resolve(null)
|
return Promise.resolve(null)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
323
tests/server/api/board-channels.test.js
Normal file
323
tests/server/api/board-channels.test.js
Normal file
|
|
@ -0,0 +1,323 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { setResponseStatus } from 'h3'
|
||||||
|
|
||||||
|
vi.stubGlobal('setResponseStatus', setResponseStatus)
|
||||||
|
|
||||||
|
const { mockFind, mockFindOne, mockCreate, mockFindByIdAndUpdate, mockFindByIdAndDelete } = vi.hoisted(() => ({
|
||||||
|
mockFind: vi.fn(),
|
||||||
|
mockFindOne: vi.fn(),
|
||||||
|
mockCreate: vi.fn(),
|
||||||
|
mockFindByIdAndUpdate: vi.fn(),
|
||||||
|
mockFindByIdAndDelete: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../server/models/boardChannel.js', () => ({
|
||||||
|
default: {
|
||||||
|
find: mockFind,
|
||||||
|
findOne: mockFindOne,
|
||||||
|
create: mockCreate,
|
||||||
|
findByIdAndUpdate: mockFindByIdAndUpdate,
|
||||||
|
findByIdAndDelete: mockFindByIdAndDelete,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../server/utils/auth.js', () => ({
|
||||||
|
requireAuth: vi.fn(),
|
||||||
|
requireAdmin: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../server/utils/validateBody.js', () => ({
|
||||||
|
validateBody: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../server/utils/schemas.js', () => ({
|
||||||
|
boardChannelCreateSchema: {},
|
||||||
|
boardChannelUpdateSchema: {},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../server/utils/mongoose.js', () => ({
|
||||||
|
connectDB: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { mockCreateSlackChannel } = vi.hoisted(() => ({
|
||||||
|
mockCreateSlackChannel: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../server/utils/slack.ts', () => ({
|
||||||
|
getSlackServiceNoVetting: () => ({
|
||||||
|
createChannel: mockCreateSlackChannel,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { requireAuth, requireAdmin } from '../../../server/utils/auth.js'
|
||||||
|
import { validateBody } from '../../../server/utils/validateBody.js'
|
||||||
|
import getHandler from '../../../server/api/board/channels.get.js'
|
||||||
|
import postHandler from '../../../server/api/admin/board-channels.post.js'
|
||||||
|
import patchHandler from '../../../server/api/admin/board-channels/[id].patch.js'
|
||||||
|
import deleteHandler from '../../../server/api/admin/board-channels/[id].delete.js'
|
||||||
|
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||||
|
|
||||||
|
describe('GET /api/board/channels', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
requireAuth.mockResolvedValue({ _id: 'member-1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns channels for authenticated member', async () => {
|
||||||
|
const channels = [{ _id: 'c1', name: 'coop' }]
|
||||||
|
const chain = {
|
||||||
|
sort: vi.fn().mockReturnThis(),
|
||||||
|
lean: vi.fn().mockResolvedValue(channels),
|
||||||
|
}
|
||||||
|
mockFind.mockReturnValue(chain)
|
||||||
|
|
||||||
|
const event = createMockEvent({ method: 'GET', path: '/api/board/channels' })
|
||||||
|
const result = await getHandler(event)
|
||||||
|
|
||||||
|
expect(mockFind).toHaveBeenCalledWith({})
|
||||||
|
expect(chain.sort).toHaveBeenCalledWith({ name: 1 })
|
||||||
|
expect(result).toEqual({ channels })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires auth (401)', async () => {
|
||||||
|
requireAuth.mockRejectedValue(
|
||||||
|
createError({ statusCode: 401, statusMessage: 'Unauthorized' }),
|
||||||
|
)
|
||||||
|
const event = createMockEvent({ method: 'GET', path: '/api/board/channels' })
|
||||||
|
await expect(getHandler(event)).rejects.toMatchObject({ statusCode: 401 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('POST /api/admin/board-channels', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
requireAdmin.mockResolvedValue({ _id: 'admin-1', role: 'admin' })
|
||||||
|
mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue(null) })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates channel when admin sends valid data', async () => {
|
||||||
|
validateBody.mockResolvedValue({
|
||||||
|
name: 'coop-formation',
|
||||||
|
slackChannelId: 'C01234ABC',
|
||||||
|
tagSlugs: ['coop-formation'],
|
||||||
|
})
|
||||||
|
const created = {
|
||||||
|
_id: 'new-channel',
|
||||||
|
name: 'coop-formation',
|
||||||
|
slackChannelId: 'C01234ABC',
|
||||||
|
tagSlugs: ['coop-formation'],
|
||||||
|
toObject() {
|
||||||
|
return {
|
||||||
|
_id: this._id,
|
||||||
|
name: this.name,
|
||||||
|
slackChannelId: this.slackChannelId,
|
||||||
|
tagSlugs: this.tagSlugs,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mockCreate.mockResolvedValue(created)
|
||||||
|
|
||||||
|
const event = createMockEvent({ method: 'POST', path: '/api/admin/board-channels' })
|
||||||
|
const result = await postHandler(event)
|
||||||
|
|
||||||
|
expect(mockCreate).toHaveBeenCalledWith({
|
||||||
|
name: 'coop-formation',
|
||||||
|
slackChannelId: 'C01234ABC',
|
||||||
|
tagSlugs: ['coop-formation'],
|
||||||
|
})
|
||||||
|
expect(result.channel.slackChannelId).toBe('C01234ABC')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects missing required fields with 400', async () => {
|
||||||
|
validateBody.mockRejectedValue(
|
||||||
|
createError({ statusCode: 400, statusMessage: 'Validation failed' }),
|
||||||
|
)
|
||||||
|
|
||||||
|
const event = createMockEvent({ method: 'POST', path: '/api/admin/board-channels' })
|
||||||
|
await expect(postHandler(event)).rejects.toMatchObject({ statusCode: 400 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-admin with 403', async () => {
|
||||||
|
requireAdmin.mockRejectedValue(
|
||||||
|
createError({ statusCode: 403, statusMessage: 'Forbidden' }),
|
||||||
|
)
|
||||||
|
|
||||||
|
const event = createMockEvent({ method: 'POST', path: '/api/admin/board-channels' })
|
||||||
|
await expect(postHandler(event)).rejects.toMatchObject({ statusCode: 403 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 409 on duplicate slackChannelId', async () => {
|
||||||
|
validateBody.mockResolvedValue({
|
||||||
|
name: 'x',
|
||||||
|
slackChannelId: 'C01234ABC',
|
||||||
|
tagSlugs: [],
|
||||||
|
})
|
||||||
|
const dupErr = Object.assign(new Error('dup'), { code: 11000 })
|
||||||
|
mockCreate.mockRejectedValue(dupErr)
|
||||||
|
|
||||||
|
const event = createMockEvent({ method: 'POST', path: '/api/admin/board-channels' })
|
||||||
|
await expect(postHandler(event)).rejects.toMatchObject({ statusCode: 409 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates Slack channel via API when slackChannelId not provided', async () => {
|
||||||
|
validateBody.mockResolvedValue({
|
||||||
|
name: 'coop-formation',
|
||||||
|
tagSlugs: [],
|
||||||
|
})
|
||||||
|
mockCreateSlackChannel.mockResolvedValue({ id: 'C_NEW_123', name: 'coop-formation' })
|
||||||
|
const created = {
|
||||||
|
_id: 'new-ch',
|
||||||
|
name: 'coop-formation',
|
||||||
|
slackChannelId: 'C_NEW_123',
|
||||||
|
tagSlugs: [],
|
||||||
|
toObject() {
|
||||||
|
return {
|
||||||
|
_id: this._id,
|
||||||
|
name: this.name,
|
||||||
|
slackChannelId: this.slackChannelId,
|
||||||
|
tagSlugs: this.tagSlugs,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mockCreate.mockResolvedValue(created)
|
||||||
|
|
||||||
|
const event = createMockEvent({ method: 'POST', path: '/api/admin/board-channels' })
|
||||||
|
const result = await postHandler(event)
|
||||||
|
|
||||||
|
expect(mockCreateSlackChannel).toHaveBeenCalledWith('coop-formation')
|
||||||
|
expect(mockCreate).toHaveBeenCalledWith({
|
||||||
|
name: 'coop-formation',
|
||||||
|
slackChannelId: 'C_NEW_123',
|
||||||
|
tagSlugs: [],
|
||||||
|
})
|
||||||
|
expect(result.channel.slackChannelId).toBe('C_NEW_123')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 409 when a tag is already mapped to another channel', async () => {
|
||||||
|
validateBody.mockResolvedValue({
|
||||||
|
name: 'new-ch',
|
||||||
|
slackChannelId: 'C99999',
|
||||||
|
tagSlugs: ['coop-formation'],
|
||||||
|
})
|
||||||
|
mockFindOne.mockReturnValue({
|
||||||
|
lean: vi.fn().mockResolvedValue({
|
||||||
|
_id: 'existing',
|
||||||
|
name: 'old-ch',
|
||||||
|
tagSlugs: ['coop-formation'],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = createMockEvent({ method: 'POST', path: '/api/admin/board-channels' })
|
||||||
|
await expect(postHandler(event)).rejects.toMatchObject({ statusCode: 409 })
|
||||||
|
expect(mockCreate).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('PATCH /api/admin/board-channels/[id]', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
requireAdmin.mockResolvedValue({ _id: 'admin-1', role: 'admin' })
|
||||||
|
mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue(null) })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates a channel', async () => {
|
||||||
|
validateBody.mockResolvedValue({ name: 'renamed' })
|
||||||
|
const updated = {
|
||||||
|
_id: 'c1',
|
||||||
|
name: 'renamed',
|
||||||
|
toObject() {
|
||||||
|
return { _id: this._id, name: this.name }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mockFindByIdAndUpdate.mockResolvedValue(updated)
|
||||||
|
|
||||||
|
const event = createMockEvent({ method: 'PATCH', path: '/api/admin/board-channels/c1' })
|
||||||
|
event.context = { params: { id: 'c1' } }
|
||||||
|
|
||||||
|
const result = await patchHandler(event)
|
||||||
|
|
||||||
|
expect(mockFindByIdAndUpdate).toHaveBeenCalledWith(
|
||||||
|
'c1',
|
||||||
|
{ $set: { name: 'renamed' } },
|
||||||
|
{ new: true, runValidators: true },
|
||||||
|
)
|
||||||
|
expect(result.channel.name).toBe('renamed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 404 when channel not found', async () => {
|
||||||
|
validateBody.mockResolvedValue({ name: 'x' })
|
||||||
|
mockFindByIdAndUpdate.mockResolvedValue(null)
|
||||||
|
|
||||||
|
const event = createMockEvent({ method: 'PATCH', path: '/api/admin/board-channels/missing' })
|
||||||
|
event.context = { params: { id: 'missing' } }
|
||||||
|
|
||||||
|
await expect(patchHandler(event)).rejects.toMatchObject({ statusCode: 404 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 409 when PATCH assigns a tag already owned by another channel', async () => {
|
||||||
|
validateBody.mockResolvedValue({ tagSlugs: ['coop-formation'] })
|
||||||
|
mockFindOne.mockReturnValue({
|
||||||
|
lean: vi.fn().mockResolvedValue({
|
||||||
|
_id: 'other',
|
||||||
|
name: 'other-ch',
|
||||||
|
tagSlugs: ['coop-formation'],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = createMockEvent({ method: 'PATCH', path: '/api/admin/board-channels/c1' })
|
||||||
|
event.context = { params: { id: 'c1' } }
|
||||||
|
|
||||||
|
await expect(patchHandler(event)).rejects.toMatchObject({ statusCode: 409 })
|
||||||
|
expect(mockFindByIdAndUpdate).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-admin with 403', async () => {
|
||||||
|
requireAdmin.mockRejectedValue(
|
||||||
|
createError({ statusCode: 403, statusMessage: 'Forbidden' }),
|
||||||
|
)
|
||||||
|
|
||||||
|
const event = createMockEvent({ method: 'PATCH', path: '/api/admin/board-channels/c1' })
|
||||||
|
event.context = { params: { id: 'c1' } }
|
||||||
|
|
||||||
|
await expect(patchHandler(event)).rejects.toMatchObject({ statusCode: 403 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('DELETE /api/admin/board-channels/[id]', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
requireAdmin.mockResolvedValue({ _id: 'admin-1', role: 'admin' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes a channel', async () => {
|
||||||
|
mockFindByIdAndDelete.mockResolvedValue({ _id: 'c1' })
|
||||||
|
|
||||||
|
const event = createMockEvent({ method: 'DELETE', path: '/api/admin/board-channels/c1' })
|
||||||
|
event.context = { params: { id: 'c1' } }
|
||||||
|
|
||||||
|
const result = await deleteHandler(event)
|
||||||
|
|
||||||
|
expect(mockFindByIdAndDelete).toHaveBeenCalledWith('c1')
|
||||||
|
expect(result).toEqual({ success: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 404 when channel not found', async () => {
|
||||||
|
mockFindByIdAndDelete.mockResolvedValue(null)
|
||||||
|
|
||||||
|
const event = createMockEvent({ method: 'DELETE', path: '/api/admin/board-channels/missing' })
|
||||||
|
event.context = { params: { id: 'missing' } }
|
||||||
|
|
||||||
|
await expect(deleteHandler(event)).rejects.toMatchObject({ statusCode: 404 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-admin with 403', async () => {
|
||||||
|
requireAdmin.mockRejectedValue(
|
||||||
|
createError({ statusCode: 403, statusMessage: 'Forbidden' }),
|
||||||
|
)
|
||||||
|
|
||||||
|
const event = createMockEvent({ method: 'DELETE', path: '/api/admin/board-channels/c1' })
|
||||||
|
event.context = { params: { id: 'c1' } }
|
||||||
|
|
||||||
|
await expect(deleteHandler(event)).rejects.toMatchObject({ statusCode: 403 })
|
||||||
|
})
|
||||||
|
})
|
||||||
317
tests/server/api/board-posts.test.js
Normal file
317
tests/server/api/board-posts.test.js
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { setResponseStatus } from 'h3'
|
||||||
|
|
||||||
|
vi.stubGlobal('setResponseStatus', setResponseStatus)
|
||||||
|
|
||||||
|
// --- Mocks ---
|
||||||
|
const { mockFind, mockFindById, mockSaveInstance } = vi.hoisted(() => ({
|
||||||
|
mockFind: vi.fn(),
|
||||||
|
mockFindById: vi.fn(),
|
||||||
|
mockSaveInstance: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock BoardPost as a constructor function with static methods
|
||||||
|
vi.mock('../../../server/models/boardPost.js', () => {
|
||||||
|
function BoardPost(data) {
|
||||||
|
Object.assign(this, data)
|
||||||
|
this._id = data._id || 'new-post-id'
|
||||||
|
this.save = mockSaveInstance
|
||||||
|
this.populate = vi.fn().mockResolvedValue(this)
|
||||||
|
this.toObject = function () {
|
||||||
|
const { save, populate, toObject, deleteOne, ...rest } = this
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
this.deleteOne = vi.fn().mockResolvedValue({})
|
||||||
|
}
|
||||||
|
BoardPost.find = mockFind
|
||||||
|
BoardPost.findById = mockFindById
|
||||||
|
return { default: BoardPost }
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('../../../server/utils/auth.js', () => ({
|
||||||
|
requireAuth: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../server/utils/validateBody.js', () => ({
|
||||||
|
validateBody: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../server/utils/schemas.js', () => ({
|
||||||
|
boardPostCreateSchema: {},
|
||||||
|
boardPostUpdateSchema: {},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../server/utils/activityLog.js', () => ({
|
||||||
|
logActivity: vi.fn(),
|
||||||
|
ACTIVITY_TYPES: { BOARD_POST_CREATED: 'board_post_created' },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../server/utils/mongoose.js', () => ({
|
||||||
|
connectDB: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { requireAuth } from '../../../server/utils/auth.js'
|
||||||
|
import { validateBody } from '../../../server/utils/validateBody.js'
|
||||||
|
import getHandler from '../../../server/api/board/posts.get.js'
|
||||||
|
import postHandler from '../../../server/api/board/posts.post.js'
|
||||||
|
import patchHandler from '../../../server/api/board/posts/[id].patch.js'
|
||||||
|
import deleteHandler from '../../../server/api/board/posts/[id].delete.js'
|
||||||
|
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||||
|
|
||||||
|
const MEMBER_ID = 'member-abc'
|
||||||
|
|
||||||
|
function buildFindChain(result) {
|
||||||
|
const chain = {
|
||||||
|
sort: vi.fn().mockReturnThis(),
|
||||||
|
populate: vi.fn().mockReturnThis(),
|
||||||
|
lean: vi.fn().mockResolvedValue(result),
|
||||||
|
}
|
||||||
|
mockFind.mockReturnValue(chain)
|
||||||
|
return chain
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET /api/board/posts', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
requireAuth.mockResolvedValue({ _id: MEMBER_ID })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns posts sorted by createdAt desc', async () => {
|
||||||
|
const posts = [
|
||||||
|
{ _id: 'p2', title: 'newer', createdAt: '2026-04-02' },
|
||||||
|
{ _id: 'p1', title: 'older', createdAt: '2026-04-01' },
|
||||||
|
]
|
||||||
|
const chain = buildFindChain(posts)
|
||||||
|
|
||||||
|
const event = createMockEvent({ method: 'GET', path: '/api/board/posts' })
|
||||||
|
const result = await getHandler(event)
|
||||||
|
|
||||||
|
expect(mockFind).toHaveBeenCalledWith({})
|
||||||
|
expect(chain.sort).toHaveBeenCalledWith({ createdAt: -1 })
|
||||||
|
expect(result).toEqual({ posts })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by tag query param', async () => {
|
||||||
|
buildFindChain([])
|
||||||
|
const event = createMockEvent({ method: 'GET', path: '/api/board/posts?tag=coop' })
|
||||||
|
await getHandler(event)
|
||||||
|
expect(mockFind).toHaveBeenCalledWith({ tags: 'coop' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by specific author id', async () => {
|
||||||
|
buildFindChain([])
|
||||||
|
const event = createMockEvent({ method: 'GET', path: '/api/board/posts?author=other-id' })
|
||||||
|
await getHandler(event)
|
||||||
|
expect(mockFind).toHaveBeenCalledWith({ author: 'other-id' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by author=me using current member id', async () => {
|
||||||
|
buildFindChain([])
|
||||||
|
const event = createMockEvent({ method: 'GET', path: '/api/board/posts?author=me' })
|
||||||
|
await getHandler(event)
|
||||||
|
expect(mockFind).toHaveBeenCalledWith({ author: MEMBER_ID })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires auth (401)', async () => {
|
||||||
|
requireAuth.mockRejectedValue(createError({ statusCode: 401, statusMessage: 'Unauthorized' }))
|
||||||
|
const event = createMockEvent({ method: 'GET', path: '/api/board/posts' })
|
||||||
|
await expect(getHandler(event)).rejects.toMatchObject({ statusCode: 401 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('POST /api/board/posts', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
requireAuth.mockResolvedValue({ _id: MEMBER_ID })
|
||||||
|
mockSaveInstance.mockResolvedValue(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates a post with valid data', async () => {
|
||||||
|
validateBody.mockResolvedValue({
|
||||||
|
title: 'Looking for co-op advice',
|
||||||
|
seeking: 'help with bylaws',
|
||||||
|
offering: '',
|
||||||
|
note: '',
|
||||||
|
tags: ['coop-formation'],
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/board/posts',
|
||||||
|
body: { title: 'Looking for co-op advice', seeking: 'help with bylaws' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await postHandler(event)
|
||||||
|
|
||||||
|
expect(mockSaveInstance).toHaveBeenCalled()
|
||||||
|
expect(result.post.title).toBe('Looking for co-op advice')
|
||||||
|
expect(result.post.author).toBe(MEMBER_ID)
|
||||||
|
expect(result.post.tags).toEqual(['coop-formation'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects when validation fails (both seeking/offering empty)', async () => {
|
||||||
|
validateBody.mockRejectedValue(
|
||||||
|
createError({ statusCode: 400, statusMessage: 'Validation failed' }),
|
||||||
|
)
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/board/posts',
|
||||||
|
body: { title: 'x' },
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(postHandler(event)).rejects.toMatchObject({ statusCode: 400 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires auth (401)', async () => {
|
||||||
|
requireAuth.mockRejectedValue(createError({ statusCode: 401, statusMessage: 'Unauthorized' }))
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/board/posts',
|
||||||
|
body: { title: 'x', seeking: 'y' },
|
||||||
|
})
|
||||||
|
await expect(postHandler(event)).rejects.toMatchObject({ statusCode: 401 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('PATCH /api/board/posts/[id]', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
requireAuth.mockResolvedValue({ _id: MEMBER_ID })
|
||||||
|
})
|
||||||
|
|
||||||
|
function mockPost(overrides = {}) {
|
||||||
|
const saveFn = vi.fn().mockResolvedValue(undefined)
|
||||||
|
const populateFn = vi.fn().mockResolvedValue(undefined)
|
||||||
|
const post = {
|
||||||
|
_id: 'post-1',
|
||||||
|
author: { toString: () => MEMBER_ID },
|
||||||
|
title: 'Original',
|
||||||
|
seeking: 'need help',
|
||||||
|
offering: '',
|
||||||
|
note: '',
|
||||||
|
tags: [],
|
||||||
|
save: saveFn,
|
||||||
|
populate: populateFn,
|
||||||
|
toObject() {
|
||||||
|
return {
|
||||||
|
_id: this._id,
|
||||||
|
title: this.title,
|
||||||
|
seeking: this.seeking,
|
||||||
|
offering: this.offering,
|
||||||
|
note: this.note,
|
||||||
|
tags: this.tags,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
mockFindById.mockResolvedValue(post)
|
||||||
|
return post
|
||||||
|
}
|
||||||
|
|
||||||
|
it('updates own post', async () => {
|
||||||
|
const post = mockPost()
|
||||||
|
validateBody.mockResolvedValue({ title: 'Updated title' })
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'PATCH',
|
||||||
|
path: '/api/board/posts/post-1',
|
||||||
|
body: { title: 'Updated title' },
|
||||||
|
})
|
||||||
|
event.context = { params: { id: 'post-1' } }
|
||||||
|
|
||||||
|
const result = await patchHandler(event)
|
||||||
|
|
||||||
|
expect(post.save).toHaveBeenCalled()
|
||||||
|
expect(result.post.title).toBe('Updated title')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects editing another members post with 403', async () => {
|
||||||
|
mockPost({ author: { toString: () => 'other-member' } })
|
||||||
|
validateBody.mockResolvedValue({ title: 'Hack' })
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'PATCH',
|
||||||
|
path: '/api/board/posts/post-1',
|
||||||
|
body: { title: 'Hack' },
|
||||||
|
})
|
||||||
|
event.context = { params: { id: 'post-1' } }
|
||||||
|
|
||||||
|
await expect(patchHandler(event)).rejects.toMatchObject({ statusCode: 403 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects if merged result has neither seeking nor offering (400)', async () => {
|
||||||
|
mockPost({ seeking: 'current', offering: '' })
|
||||||
|
validateBody.mockResolvedValue({ seeking: '', offering: '' })
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'PATCH',
|
||||||
|
path: '/api/board/posts/post-1',
|
||||||
|
body: { seeking: '', offering: '' },
|
||||||
|
})
|
||||||
|
event.context = { params: { id: 'post-1' } }
|
||||||
|
|
||||||
|
await expect(patchHandler(event)).rejects.toMatchObject({ statusCode: 400 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 404 when post not found', async () => {
|
||||||
|
mockFindById.mockResolvedValue(null)
|
||||||
|
validateBody.mockResolvedValue({ title: 'x' })
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'PATCH',
|
||||||
|
path: '/api/board/posts/missing',
|
||||||
|
body: { title: 'x' },
|
||||||
|
})
|
||||||
|
event.context = { params: { id: 'missing' } }
|
||||||
|
|
||||||
|
await expect(patchHandler(event)).rejects.toMatchObject({ statusCode: 404 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('DELETE /api/board/posts/[id]', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
requireAuth.mockResolvedValue({ _id: MEMBER_ID })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes own post', async () => {
|
||||||
|
const deleteOne = vi.fn().mockResolvedValue({})
|
||||||
|
mockFindById.mockResolvedValue({
|
||||||
|
_id: 'post-1',
|
||||||
|
author: { toString: () => MEMBER_ID },
|
||||||
|
deleteOne,
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = createMockEvent({ method: 'DELETE', path: '/api/board/posts/post-1' })
|
||||||
|
event.context = { params: { id: 'post-1' } }
|
||||||
|
|
||||||
|
const result = await deleteHandler(event)
|
||||||
|
|
||||||
|
expect(deleteOne).toHaveBeenCalled()
|
||||||
|
expect(result).toEqual({ success: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects deleting another members post with 403', async () => {
|
||||||
|
const deleteOne = vi.fn()
|
||||||
|
mockFindById.mockResolvedValue({
|
||||||
|
_id: 'post-1',
|
||||||
|
author: { toString: () => 'someone-else' },
|
||||||
|
deleteOne,
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = createMockEvent({ method: 'DELETE', path: '/api/board/posts/post-1' })
|
||||||
|
event.context = { params: { id: 'post-1' } }
|
||||||
|
|
||||||
|
await expect(deleteHandler(event)).rejects.toMatchObject({ statusCode: 403 })
|
||||||
|
expect(deleteOne).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 404 when post not found', async () => {
|
||||||
|
mockFindById.mockResolvedValue(null)
|
||||||
|
const event = createMockEvent({ method: 'DELETE', path: '/api/board/posts/missing' })
|
||||||
|
event.context = { params: { id: 'missing' } }
|
||||||
|
|
||||||
|
await expect(deleteHandler(event)).rejects.toMatchObject({ statusCode: 404 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,325 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
const { mockFind, mockSelect, mockLean } = vi.hoisted(() => ({
|
|
||||||
mockFind: vi.fn(),
|
|
||||||
mockSelect: vi.fn(),
|
|
||||||
mockLean: vi.fn()
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('../../../server/models/member.js', () => ({
|
|
||||||
default: { find: mockFind }
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('../../../server/utils/mongoose.js', () => ({
|
|
||||||
connectDB: vi.fn()
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('../../../server/utils/auth.js', () => ({
|
|
||||||
requireAuth: vi.fn()
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { requireAuth } from '../../../server/utils/auth.js'
|
|
||||||
import handler from '../../../server/api/board/suggestions.get.js'
|
|
||||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
|
||||||
|
|
||||||
function setupChain(result = []) {
|
|
||||||
mockLean.mockResolvedValue(result)
|
|
||||||
mockSelect.mockReturnValue({ lean: mockLean })
|
|
||||||
mockFind.mockReturnValue({ select: mockSelect })
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeMember(overrides = {}) {
|
|
||||||
return {
|
|
||||||
_id: 'member-1',
|
|
||||||
board: { topics: [] },
|
|
||||||
...overrides
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeCandidate(overrides = {}) {
|
|
||||||
return {
|
|
||||||
_id: 'candidate-1',
|
|
||||||
name: 'Test Candidate',
|
|
||||||
circle: 'community',
|
|
||||||
avatar: '/avatar.jpg',
|
|
||||||
craftTags: ['game-design'],
|
|
||||||
board: {
|
|
||||||
topics: [
|
|
||||||
{ tagSlug: 'revenue-sharing', state: 'interested' }
|
|
||||||
],
|
|
||||||
offerPeerSupport: false,
|
|
||||||
slackHandle: ''
|
|
||||||
},
|
|
||||||
privacy: {},
|
|
||||||
...overrides
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('GET /api/board/suggestions', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns empty suggestions when member has no topics', async () => {
|
|
||||||
const member = makeMember({ board: { topics: [] } })
|
|
||||||
requireAuth.mockResolvedValue(member)
|
|
||||||
|
|
||||||
const event = createMockEvent({ method: 'GET', path: '/api/board/suggestions' })
|
|
||||||
const result = await handler(event)
|
|
||||||
|
|
||||||
expect(result).toEqual({ suggestions: [] })
|
|
||||||
expect(mockFind).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns matching members with shared topics and correct state comparison', async () => {
|
|
||||||
const member = makeMember({
|
|
||||||
board: {
|
|
||||||
topics: [
|
|
||||||
{ tagSlug: 'revenue-sharing', state: 'help' },
|
|
||||||
{ tagSlug: 'co-op-governance', state: 'seeking' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
requireAuth.mockResolvedValue(member)
|
|
||||||
|
|
||||||
const candidate = makeCandidate({
|
|
||||||
board: {
|
|
||||||
topics: [
|
|
||||||
{ tagSlug: 'revenue-sharing', state: 'interested' }
|
|
||||||
],
|
|
||||||
offerPeerSupport: false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
setupChain([candidate])
|
|
||||||
|
|
||||||
const event = createMockEvent({ method: 'GET', path: '/api/board/suggestions' })
|
|
||||||
const result = await handler(event)
|
|
||||||
|
|
||||||
expect(result.suggestions).toHaveLength(1)
|
|
||||||
expect(result.suggestions[0].matchingTags).toEqual([
|
|
||||||
{ tagSlug: 'revenue-sharing', yourState: 'help', theirState: 'interested' }
|
|
||||||
])
|
|
||||||
expect(result.suggestions[0].matchCount).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('excludes the requesting member from results', async () => {
|
|
||||||
const member = makeMember({
|
|
||||||
_id: 'member-1',
|
|
||||||
board: {
|
|
||||||
topics: [{ tagSlug: 'revenue-sharing', state: 'help' }]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
requireAuth.mockResolvedValue(member)
|
|
||||||
|
|
||||||
setupChain([])
|
|
||||||
|
|
||||||
const event = createMockEvent({ method: 'GET', path: '/api/board/suggestions' })
|
|
||||||
await handler(event)
|
|
||||||
|
|
||||||
expect(mockFind).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
_id: { $ne: 'member-1' }
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('respects avatar privacy settings', async () => {
|
|
||||||
const member = makeMember({
|
|
||||||
board: {
|
|
||||||
topics: [{ tagSlug: 'revenue-sharing', state: 'help' }]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
requireAuth.mockResolvedValue(member)
|
|
||||||
|
|
||||||
const candidate = makeCandidate({
|
|
||||||
privacy: { avatar: 'private' },
|
|
||||||
avatar: '/secret-avatar.jpg',
|
|
||||||
board: {
|
|
||||||
topics: [{ tagSlug: 'revenue-sharing', state: 'interested' }],
|
|
||||||
offerPeerSupport: false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
setupChain([candidate])
|
|
||||||
|
|
||||||
const event = createMockEvent({ method: 'GET', path: '/api/board/suggestions' })
|
|
||||||
const result = await handler(event)
|
|
||||||
|
|
||||||
expect(result.suggestions[0].member.avatar).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('respects craftTags privacy settings', async () => {
|
|
||||||
const member = makeMember({
|
|
||||||
board: {
|
|
||||||
topics: [{ tagSlug: 'revenue-sharing', state: 'help' }]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
requireAuth.mockResolvedValue(member)
|
|
||||||
|
|
||||||
const candidate = makeCandidate({
|
|
||||||
privacy: { craftTags: 'private' },
|
|
||||||
craftTags: ['game-design'],
|
|
||||||
board: {
|
|
||||||
topics: [{ tagSlug: 'revenue-sharing', state: 'interested' }],
|
|
||||||
offerPeerSupport: false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
setupChain([candidate])
|
|
||||||
|
|
||||||
const event = createMockEvent({ method: 'GET', path: '/api/board/suggestions' })
|
|
||||||
const result = await handler(event)
|
|
||||||
|
|
||||||
expect(result.suggestions[0].member.craftTags).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('exposes avatar when privacy is public', async () => {
|
|
||||||
const member = makeMember({
|
|
||||||
board: {
|
|
||||||
topics: [{ tagSlug: 'revenue-sharing', state: 'help' }]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
requireAuth.mockResolvedValue(member)
|
|
||||||
|
|
||||||
const candidate = makeCandidate({
|
|
||||||
privacy: { avatar: 'public' },
|
|
||||||
avatar: '/public-avatar.jpg',
|
|
||||||
board: {
|
|
||||||
topics: [{ tagSlug: 'revenue-sharing', state: 'interested' }],
|
|
||||||
offerPeerSupport: false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
setupChain([candidate])
|
|
||||||
|
|
||||||
const event = createMockEvent({ method: 'GET', path: '/api/board/suggestions' })
|
|
||||||
const result = await handler(event)
|
|
||||||
|
|
||||||
expect(result.suggestions[0].member.avatar).toBe('/public-avatar.jpg')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('only exposes slackHandle when offerPeerSupport is true AND slackHandle is set', async () => {
|
|
||||||
const member = makeMember({
|
|
||||||
board: {
|
|
||||||
topics: [{ tagSlug: 'revenue-sharing', state: 'help' }]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
requireAuth.mockResolvedValue(member)
|
|
||||||
|
|
||||||
// Case 1: offerPeerSupport false — no slackHandle
|
|
||||||
const noSupport = makeCandidate({
|
|
||||||
_id: 'c1',
|
|
||||||
board: {
|
|
||||||
topics: [{ tagSlug: 'revenue-sharing', state: 'interested' }],
|
|
||||||
offerPeerSupport: false,
|
|
||||||
slackHandle: 'someone'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Case 2: offerPeerSupport true but no slackHandle
|
|
||||||
const supportNoHandle = makeCandidate({
|
|
||||||
_id: 'c2',
|
|
||||||
board: {
|
|
||||||
topics: [{ tagSlug: 'revenue-sharing', state: 'interested' }],
|
|
||||||
offerPeerSupport: true,
|
|
||||||
slackHandle: ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Case 3: offerPeerSupport true AND slackHandle set
|
|
||||||
const supportWithHandle = makeCandidate({
|
|
||||||
_id: 'c3',
|
|
||||||
board: {
|
|
||||||
topics: [{ tagSlug: 'revenue-sharing', state: 'interested' }],
|
|
||||||
offerPeerSupport: true,
|
|
||||||
slackHandle: 'helpfulperson'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
setupChain([noSupport, supportNoHandle, supportWithHandle])
|
|
||||||
|
|
||||||
const event = createMockEvent({ method: 'GET', path: '/api/board/suggestions' })
|
|
||||||
const result = await handler(event)
|
|
||||||
|
|
||||||
expect(result.suggestions[0].member.slackHandle).toBeUndefined()
|
|
||||||
expect(result.suggestions[1].member.slackHandle).toBeUndefined()
|
|
||||||
expect(result.suggestions[2].member.slackHandle).toBe('helpfulperson')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('filters by tag query param', async () => {
|
|
||||||
const member = makeMember({
|
|
||||||
board: {
|
|
||||||
topics: [
|
|
||||||
{ tagSlug: 'revenue-sharing', state: 'help' },
|
|
||||||
{ tagSlug: 'co-op-governance', state: 'seeking' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
requireAuth.mockResolvedValue(member)
|
|
||||||
|
|
||||||
setupChain([])
|
|
||||||
|
|
||||||
const event = createMockEvent({ method: 'GET', path: '/api/board/suggestions?tag=revenue-sharing' })
|
|
||||||
await handler(event)
|
|
||||||
|
|
||||||
// Should only query for the filtered tag
|
|
||||||
expect(mockFind).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
'board.topics.tagSlug': { $in: ['revenue-sharing'] }
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sorts by matchCount descending', async () => {
|
|
||||||
const member = makeMember({
|
|
||||||
board: {
|
|
||||||
topics: [
|
|
||||||
{ tagSlug: 'revenue-sharing', state: 'help' },
|
|
||||||
{ tagSlug: 'co-op-governance', state: 'seeking' },
|
|
||||||
{ tagSlug: 'profit-sharing', state: 'interested' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
requireAuth.mockResolvedValue(member)
|
|
||||||
|
|
||||||
const oneMatch = makeCandidate({
|
|
||||||
_id: 'c1',
|
|
||||||
name: 'One Match',
|
|
||||||
board: {
|
|
||||||
topics: [{ tagSlug: 'revenue-sharing', state: 'interested' }],
|
|
||||||
offerPeerSupport: false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const twoMatches = makeCandidate({
|
|
||||||
_id: 'c2',
|
|
||||||
name: 'Two Matches',
|
|
||||||
board: {
|
|
||||||
topics: [
|
|
||||||
{ tagSlug: 'revenue-sharing', state: 'help' },
|
|
||||||
{ tagSlug: 'co-op-governance', state: 'interested' }
|
|
||||||
],
|
|
||||||
offerPeerSupport: false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
setupChain([oneMatch, twoMatches])
|
|
||||||
|
|
||||||
const event = createMockEvent({ method: 'GET', path: '/api/board/suggestions' })
|
|
||||||
const result = await handler(event)
|
|
||||||
|
|
||||||
expect(result.suggestions[0].matchCount).toBe(2)
|
|
||||||
expect(result.suggestions[0].member.name).toBe('Two Matches')
|
|
||||||
expect(result.suggestions[1].matchCount).toBe(1)
|
|
||||||
expect(result.suggestions[1].member.name).toBe('One Match')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('requires auth (401)', async () => {
|
|
||||||
requireAuth.mockRejectedValue(
|
|
||||||
createError({ statusCode: 401, statusMessage: 'Unauthorized' })
|
|
||||||
)
|
|
||||||
|
|
||||||
const event = createMockEvent({ method: 'GET', path: '/api/board/suggestions' })
|
|
||||||
|
|
||||||
await expect(handler(event)).rejects.toMatchObject({
|
|
||||||
statusCode: 401
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -39,7 +39,6 @@ function makeMember(overrides = {}) {
|
||||||
return {
|
return {
|
||||||
_id: 'member-1',
|
_id: 'member-1',
|
||||||
craftTags: [],
|
craftTags: [],
|
||||||
board: { topics: [] },
|
|
||||||
...overrides
|
...overrides
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -82,31 +81,6 @@ describe('GET /api/events/recommended', () => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns events matching cooperative tags from board.topics', async () => {
|
|
||||||
const member = makeMember({
|
|
||||||
board: {
|
|
||||||
topics: [
|
|
||||||
{ tagSlug: 'revenue-sharing', state: 'interested' },
|
|
||||||
{ tagSlug: 'co-op-governance', state: 'help' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
requireAuth.mockResolvedValue(member)
|
|
||||||
|
|
||||||
const events = [makeEvent({ tags: ['revenue-sharing'] })]
|
|
||||||
setupChain(events)
|
|
||||||
|
|
||||||
const event = createMockEvent({ method: 'GET', path: '/api/events/recommended' })
|
|
||||||
const result = await handler(event)
|
|
||||||
|
|
||||||
expect(result).toEqual(events)
|
|
||||||
expect(mockFind).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
tags: { $in: expect.arrayContaining(['revenue-sharing', 'co-op-governance']) }
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns empty array when no tag overlap', async () => {
|
it('returns empty array when no tag overlap', async () => {
|
||||||
const member = makeMember({ craftTags: ['audio'] })
|
const member = makeMember({ craftTags: ['audio'] })
|
||||||
requireAuth.mockResolvedValue(member)
|
requireAuth.mockResolvedValue(member)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
const { mockBoardPostExists } = vi.hoisted(() => ({
|
||||||
|
mockBoardPostExists: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('../../../server/utils/auth.js', () => ({
|
vi.mock('../../../server/utils/auth.js', () => ({
|
||||||
requireAuth: vi.fn()
|
requireAuth: vi.fn()
|
||||||
}))
|
}))
|
||||||
|
|
@ -8,6 +12,10 @@ vi.mock('../../../server/utils/mongoose.js', () => ({
|
||||||
connectDB: vi.fn()
|
connectDB: vi.fn()
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../server/models/boardPost.js', () => ({
|
||||||
|
default: { exists: mockBoardPostExists }
|
||||||
|
}))
|
||||||
|
|
||||||
import { requireAuth } from '../../../server/utils/auth.js'
|
import { requireAuth } from '../../../server/utils/auth.js'
|
||||||
import handler from '../../../server/api/onboarding/status.get.js'
|
import handler from '../../../server/api/onboarding/status.get.js'
|
||||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||||
|
|
@ -15,6 +23,7 @@ import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||||
describe('GET /api/onboarding/status', () => {
|
describe('GET /api/onboarding/status', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
mockBoardPostExists.mockResolvedValue(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 1.1: Default state for new member — all false, completedAt null
|
// 1.1: Default state for new member — all false, completedAt null
|
||||||
|
|
@ -22,7 +31,6 @@ describe('GET /api/onboarding/status', () => {
|
||||||
requireAuth.mockResolvedValue({
|
requireAuth.mockResolvedValue({
|
||||||
_id: 'member-1',
|
_id: 'member-1',
|
||||||
craftTags: [],
|
craftTags: [],
|
||||||
board: { topics: [] },
|
|
||||||
onboarding: {
|
onboarding: {
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
eventPageVisited: false,
|
eventPageVisited: false,
|
||||||
|
|
@ -45,14 +53,11 @@ describe('GET /api/onboarding/status', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 1.2: hasProfileTags true when both tag types present
|
// 1.2: hasProfileTags true when craft tags present
|
||||||
it('hasProfileTags is true when member has both craft tags and board topics', async () => {
|
it('hasProfileTags is true when member has craft tags', async () => {
|
||||||
requireAuth.mockResolvedValue({
|
requireAuth.mockResolvedValue({
|
||||||
_id: 'member-1',
|
_id: 'member-1',
|
||||||
craftTags: ['game-design'],
|
craftTags: ['game-design'],
|
||||||
board: {
|
|
||||||
topics: [{ tagSlug: 'governance', state: 'interested' }],
|
|
||||||
},
|
|
||||||
onboarding: {
|
onboarding: {
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
eventPageVisited: false,
|
eventPageVisited: false,
|
||||||
|
|
@ -67,12 +72,11 @@ describe('GET /api/onboarding/status', () => {
|
||||||
expect(result.goals.hasProfileTags).toBe(true)
|
expect(result.goals.hasProfileTags).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 1.3: hasProfileTags false when only craft tags
|
// 1.3: hasProfileTags false when no craft tags
|
||||||
it('hasProfileTags is false when member has craft tags but no board topics', async () => {
|
it('hasProfileTags is false when member has no craft tags', async () => {
|
||||||
requireAuth.mockResolvedValue({
|
requireAuth.mockResolvedValue({
|
||||||
_id: 'member-1',
|
_id: 'member-1',
|
||||||
craftTags: ['game-design'],
|
craftTags: [],
|
||||||
board: { topics: [] },
|
|
||||||
onboarding: {
|
onboarding: {
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
eventPageVisited: false,
|
eventPageVisited: false,
|
||||||
|
|
@ -87,14 +91,11 @@ describe('GET /api/onboarding/status', () => {
|
||||||
expect(result.goals.hasProfileTags).toBe(false)
|
expect(result.goals.hasProfileTags).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 1.5: hasEngagedBoard true when visited AND has tag with engagement state
|
// 1.5: hasEngagedBoard true when visited AND has a BoardPost
|
||||||
it('hasEngagedBoard is true when page visited and has engaged topic', async () => {
|
it('hasEngagedBoard is true when page visited and member has posted', async () => {
|
||||||
requireAuth.mockResolvedValue({
|
requireAuth.mockResolvedValue({
|
||||||
_id: 'member-1',
|
_id: 'member-1',
|
||||||
craftTags: [],
|
craftTags: [],
|
||||||
board: {
|
|
||||||
topics: [{ tagSlug: 'governance', state: 'help' }],
|
|
||||||
},
|
|
||||||
onboarding: {
|
onboarding: {
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
eventPageVisited: false,
|
eventPageVisited: false,
|
||||||
|
|
@ -102,19 +103,20 @@ describe('GET /api/onboarding/status', () => {
|
||||||
wikiClicked: false,
|
wikiClicked: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
mockBoardPostExists.mockResolvedValue({ _id: 'post-1' })
|
||||||
|
|
||||||
const event = createMockEvent({ method: 'GET', path: '/api/onboarding/status' })
|
const event = createMockEvent({ method: 'GET', path: '/api/onboarding/status' })
|
||||||
const result = await handler(event)
|
const result = await handler(event)
|
||||||
|
|
||||||
expect(result.goals.hasEngagedBoard).toBe(true)
|
expect(result.goals.hasEngagedBoard).toBe(true)
|
||||||
|
expect(mockBoardPostExists).toHaveBeenCalledWith({ author: 'member-1' })
|
||||||
})
|
})
|
||||||
|
|
||||||
// 1.6: hasEngagedBoard false when visited but no engagement state
|
// 1.6: hasEngagedBoard false when visited but no posts
|
||||||
it('hasEngagedBoard is false when page visited but no topics have engagement state', async () => {
|
it('hasEngagedBoard is false when page visited but member has no posts', async () => {
|
||||||
requireAuth.mockResolvedValue({
|
requireAuth.mockResolvedValue({
|
||||||
_id: 'member-1',
|
_id: 'member-1',
|
||||||
craftTags: [],
|
craftTags: [],
|
||||||
board: { topics: [] },
|
|
||||||
onboarding: {
|
onboarding: {
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
eventPageVisited: false,
|
eventPageVisited: false,
|
||||||
|
|
@ -122,6 +124,7 @@ describe('GET /api/onboarding/status', () => {
|
||||||
wikiClicked: false,
|
wikiClicked: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
mockBoardPostExists.mockResolvedValue(null)
|
||||||
|
|
||||||
const event = createMockEvent({ method: 'GET', path: '/api/onboarding/status' })
|
const event = createMockEvent({ method: 'GET', path: '/api/onboarding/status' })
|
||||||
const result = await handler(event)
|
const result = await handler(event)
|
||||||
|
|
@ -134,7 +137,6 @@ describe('GET /api/onboarding/status', () => {
|
||||||
requireAuth.mockResolvedValue({
|
requireAuth.mockResolvedValue({
|
||||||
_id: 'member-1',
|
_id: 'member-1',
|
||||||
craftTags: [],
|
craftTags: [],
|
||||||
board: { topics: [] },
|
|
||||||
onboarding: {
|
onboarding: {
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
eventPageVisited: true,
|
eventPageVisited: true,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
const { mockBoardPostExists } = vi.hoisted(() => ({
|
||||||
|
mockBoardPostExists: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../server/models/boardPost.js', () => ({
|
||||||
|
default: { exists: mockBoardPostExists }
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('../../../server/utils/auth.js', () => ({
|
vi.mock('../../../server/utils/auth.js', () => ({
|
||||||
requireAuth: vi.fn()
|
requireAuth: vi.fn()
|
||||||
}))
|
}))
|
||||||
|
|
@ -45,6 +53,7 @@ describe('POST /api/onboarding/track', () => {
|
||||||
})
|
})
|
||||||
Member.findByIdAndUpdate.mockResolvedValue({})
|
Member.findByIdAndUpdate.mockResolvedValue({})
|
||||||
Member.findOneAndUpdate.mockResolvedValue(null) // no graduation by default
|
Member.findOneAndUpdate.mockResolvedValue(null) // no graduation by default
|
||||||
|
mockBoardPostExists.mockResolvedValue({ _id: 'post-1' })
|
||||||
})
|
})
|
||||||
|
|
||||||
// 2.1: Sets eventPageVisited to true
|
// 2.1: Sets eventPageVisited to true
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue