feat(board): redesign classifieds + Slack channel creation
Adds AdminGhost bot token for admin-only Slack channel creation, refreshes BoardPostCard/Form layouts, and expands admin board-channels management.
This commit is contained in:
parent
6f3d088763
commit
9a560f2a3b
14 changed files with 544 additions and 158 deletions
|
|
@ -2,16 +2,51 @@ 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: body.name,
|
||||
slackChannelId: body.slackChannelId,
|
||||
name: channelName,
|
||||
slackChannelId,
|
||||
tagSlugs: body.tagSlugs || []
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,20 @@ export default defineEventHandler(async (event) => {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -400,7 +400,7 @@ export const boardPostUpdateSchema = z.object({
|
|||
|
||||
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'),
|
||||
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([])
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
@ -292,3 +319,36 @@ export function getSlackService(): SlackService | null {
|
|||
|
||||
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, "");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue