feat: add Mattermost bot integration and user notification configuration settings

This commit is contained in:
Firman Ramdhani
2026-09-16 17:18:55 +07:00
parent 7c3c5e2249
commit 2814e3cab8
5 changed files with 348 additions and 11 deletions
+15 -2
View File
@@ -19,8 +19,9 @@ model User {
updated_at DateTime @updatedAt
orders Order[] @relation("CreatedOrders")
purchases Submission[]
creator_withdrawals BalanceWithdrawal[] @relation("CreatorWithdrawals")
user_withdrawals BalanceWithdrawal[] @relation("UserWithdrawals")
creator_withdrawals BalanceWithdrawal[] @relation("CreatorWithdrawals")
user_withdrawals BalanceWithdrawal[] @relation("UserWithdrawals")
notification_config UserNotificationConfig?
}
model Order {
@@ -91,3 +92,15 @@ model BalanceWithdrawal {
creator User @relation("CreatorWithdrawals", fields: [creator_id], references: [id], onDelete: Cascade)
user User @relation("UserWithdrawals", fields: [user_id], references: [id], onDelete: Cascade)
}
model UserNotificationConfig {
id String @id @default(uuid())
user_id String @unique
mattermost_channel_id String?
telegram_chat_id String?
whatsapp_number String?
is_active Boolean @default(true)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
}
+83 -5
View File
@@ -1,7 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { getSessionUser, updateProfile, changePassword } from '@/app/actions'
import { getSessionUser, updateProfile, changePassword, getSettings, getUserNotificationConfig, updateUserNotificationConfig } from '@/app/actions'
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -21,6 +21,7 @@ import {
UserCircle,
KeyRound,
LockKeyhole,
MessageCircle,
} from 'lucide-react'
export default function ProfilePage() {
@@ -29,6 +30,13 @@ export default function ProfilePage() {
const [photo, setPhoto] = useState('')
const [loading, setLoading] = useState(true)
// Notification Config state
const [globalNotifEnabled, setGlobalNotifEnabled] = useState(false)
const [mmChannelId, setMmChannelId] = useState('')
const [mmActive, setMmActive] = useState(true)
const [savingNotif, setSavingNotif] = useState(false)
const [notifSuccess, setNotifSuccess] = useState(false)
// Profile state
const [savingProfile, setSavingProfile] = useState(false)
const [profileError, setProfileError] = useState('')
@@ -54,6 +62,20 @@ export default function ProfilePage() {
setUser(sessionUser)
setName(sessionUser.name)
setPhoto(sessionUser.photo || '')
const [globalSettings, notifConfig] = await Promise.all([
getSettings(),
getUserNotificationConfig(sessionUser.id)
])
if (globalSettings?.MATTERMOST_NOTIF_ENABLED === 'true') {
setGlobalNotifEnabled(true)
}
if (notifConfig?.success && notifConfig.config) {
setMmChannelId(notifConfig.config.mattermost_channel_id || '')
setMmActive(notifConfig.config.is_active ?? true)
}
}
setLoading(false)
}
@@ -69,14 +91,20 @@ export default function ProfilePage() {
setProfileSuccess(false)
setSavingProfile(true)
const res = await updateProfile(user.id, name.trim(), photo.trim() || null)
const [res, notifRes] = await Promise.all([
updateProfile(user.id, name.trim(), photo.trim() || null),
globalNotifEnabled ? updateUserNotificationConfig(user.id, {
mattermost_channel_id: mmChannelId,
is_active: mmActive
}) : Promise.resolve({ success: true, error: null })
])
if (res.success) {
if (res.success && notifRes.success) {
setUser(res.user)
setProfileSuccess(true)
setTimeout(() => setProfileSuccess(false), 3000)
} else {
setProfileError(res.error || 'Terjadi kesalahan')
setProfileError(res.error || notifRes.error || 'Terjadi kesalahan')
}
setSavingProfile(false)
}
@@ -215,6 +243,55 @@ export default function ProfilePage() {
/>
</div>
{globalNotifEnabled && (
<div className="space-y-6 pt-6 mt-6 border-t border-slate-100 dark:border-slate-800">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400">
<MessageCircle className="h-5 w-5" />
</div>
<div>
<h3 className="text-sm font-bold text-slate-900 dark:text-white">
Notifikasi Mattermost
</h3>
<p className="mt-0.5 text-[11px] text-slate-500">
Terima update pesanan PO langsung di DM Mattermost Anda.
</p>
</div>
</div>
<div className="flex flex-row items-start space-x-3 space-y-0 rounded-md border border-slate-200 p-4 dark:border-slate-800">
<input
type="checkbox"
id="mmActive"
checked={mmActive}
onChange={(e) => setMmActive(e.target.checked)}
className="mt-1 h-4 w-4 rounded border-slate-300 text-[#1B2CC1] focus:ring-[#1B2CC1]"
/>
<div className="space-y-1 leading-none">
<Label htmlFor="mmActive" className="text-sm font-bold">
Aktifkan Notifikasi
</Label>
<p className="text-[11px] text-slate-500">
Kirim notifikasi setiap kali ada yang merubah pesanannya di PO Anda.
</p>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="mm-channel" className="text-xs font-bold tracking-wider text-slate-700 uppercase dark:text-slate-300">
Username / Channel ID
</Label>
<Input
id="mm-channel"
value={mmChannelId}
onChange={(e) => setMmChannelId(e.target.value)}
placeholder="Contoh: @firman atau 9dpxnitm..."
className="h-11 rounded-xl"
/>
</div>
</div>
)}
{profileError && (
<div className="rounded-xl bg-red-50 p-3 text-xs font-semibold text-red-600">
{profileError}
@@ -223,7 +300,7 @@ export default function ProfilePage() {
{profileSuccess && (
<div className="flex items-center gap-2 rounded-xl bg-emerald-50 p-3 text-xs font-bold text-emerald-700">
<CheckCircle className="h-4 w-4" />
<span>Profil berhasil diperbarui!</span>
<span>Perubahan berhasil disimpan!</span>
</div>
)}
</CardContent>
@@ -244,6 +321,7 @@ export default function ProfilePage() {
</div>
</form>
</Card>
</div>
{/* Right 1 Col: Account Details */}
+51 -4
View File
@@ -8,11 +8,15 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
export default function IntegrationsPage() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [settings, setSettings] = useState({
MATTERMOST_NOTIF_ENABLED: 'false',
MATTERMOST_BOT_TOKEN: '',
MATTERMOST_API_URL: '',
MATTERMOST_WEBHOOK_URL: '',
MATTERMOST_TEMPLATE: '',
WHATSAPP_TEMPLATE: '',
@@ -23,6 +27,9 @@ export default function IntegrationsPage() {
const data = await getSettings()
if (data) {
setSettings({
MATTERMOST_NOTIF_ENABLED: data.MATTERMOST_NOTIF_ENABLED || 'false',
MATTERMOST_BOT_TOKEN: data.MATTERMOST_BOT_TOKEN || '',
MATTERMOST_API_URL: data.MATTERMOST_API_URL || '',
MATTERMOST_WEBHOOK_URL: data.MATTERMOST_WEBHOOK_URL || '',
MATTERMOST_TEMPLATE:
data.MATTERMOST_TEMPLATE ||
@@ -80,14 +87,54 @@ export default function IntegrationsPage() {
<div className="space-y-4 rounded-2xl border border-slate-200 bg-slate-50/50 p-5 dark:border-slate-800 dark:bg-slate-900/50">
<div className="flex items-center gap-2 font-black text-indigo-600 dark:text-indigo-400">
<MessageSquare className="h-5 w-5" />
<h3>Mattermost Webhook</h3>
<h3>Konfigurasi Mattermost</h3>
</div>
<p className="text-xs text-slate-500">
Gunakan URL Incoming Webhook dari Mattermost untuk mengirim broadcast PO baru secara
otomatis.
Atur integrasi Mattermost. Bagian atas untuk Notifikasi Personal (DM) ke Kreator, sedangkan bagian bawah untuk fitur Share (Broadcast) PO menggunakan Webhook.
</p>
<div className="space-y-1.5">
<div className="flex flex-row items-start space-x-3 space-y-0 rounded-md border border-slate-200 p-4 dark:border-slate-800 bg-white dark:bg-slate-950">
<Checkbox
id="mattermostNotifEnabled"
checked={settings.MATTERMOST_NOTIF_ENABLED === 'true'}
onCheckedChange={(checked) => handleChange('MATTERMOST_NOTIF_ENABLED', checked ? 'true' : 'false')}
/>
<div className="space-y-1 leading-none w-full">
<Label htmlFor="mattermostNotifEnabled" className="text-sm font-bold">
Aktifkan DM Kreator saat Update Pesanan (Menggunakan Bot API)
</Label>
<p className="text-xs text-slate-500 mb-3">
Kirim notifikasi otomatis ke channel/DM kreator PO tiap ada update pesanan. (Membutuhkan Bot Token).
</p>
{settings.MATTERMOST_NOTIF_ENABLED === 'true' && (
<div className="space-y-4 mt-4 pt-4 border-t border-slate-100 dark:border-slate-800">
<div className="space-y-1.5">
<Label className="text-[11px] font-bold text-slate-700 uppercase">API URL POST</Label>
<Input
value={settings.MATTERMOST_API_URL}
onChange={(e) => handleChange('MATTERMOST_API_URL', e.target.value)}
className="h-10 rounded-xl"
placeholder="https://mattermost.domain.com/api/v4/posts"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] font-bold text-slate-700 uppercase">Bot Bearer Token</Label>
<Input
type="password"
value={settings.MATTERMOST_BOT_TOKEN}
onChange={(e) => handleChange('MATTERMOST_BOT_TOKEN', e.target.value)}
className="h-10 rounded-xl"
placeholder="Masukan Bearer Token"
/>
</div>
</div>
)}
</div>
</div>
<div className="space-y-1.5 mt-6 pt-6 border-t border-slate-200 dark:border-slate-800">
<h4 className="text-sm font-bold text-slate-800 dark:text-slate-200 mb-3">Broadcast via Incoming Webhook</h4>
<Label className="text-xs font-bold text-slate-700 uppercase">Webhook URL</Label>
<Input
value={settings.MATTERMOST_WEBHOOK_URL}
+122
View File
@@ -5,6 +5,7 @@ import { revalidatePath } from 'next/cache'
import { hashPassword, verifyPassword, encrypt, decrypt } from '@/lib/auth'
import { cookies } from 'next/headers'
import { sendMattermostNotification } from '@/lib/mattermost'
// === AUTHENTICATION ACTIONS ===
export async function loginUser(username: string, password: string) {
@@ -606,6 +607,10 @@ export async function submitOrder(data: {
},
})
}
// Trigger notification without awaiting so it doesn't block
notifyCreatorOnSubmission(data.order_id, data.user_id, existing ? 'update' : 'create').catch(console.error)
revalidatePath('/')
revalidatePath('/my-purchases')
@@ -853,6 +858,10 @@ export async function deleteSubmission(submission_id: string, user_id: string) {
await prisma.submission.delete({
where: { id: submission_id },
})
// Trigger notification
notifyCreatorOnSubmission(submission.order_id, user_id, 'delete').catch(console.error)
return { success: true }
} catch (error) {
return { success: false, error: 'Gagal membatalkan pesanan' }
@@ -971,3 +980,116 @@ export async function getPiutangSummary(creator_id: string) {
return Array.from(grouped.values()).filter((g) => g.totalPiutang > 0)
}
// === SETTINGS ACTIONS ===
export async function getSystemSettings() {
try {
const settings = await prisma.setting.findMany()
const config: Record<string, string> = {}
settings.forEach((s: any) => { config[s.key] = s.value })
return { success: true, config }
} catch (e: any) {
return { success: false, error: e.message }
}
}
export async function updateSystemSettings(key: string, value: string) {
try {
await prisma.setting.upsert({
where: { key },
update: { value },
create: { key, value }
})
return { success: true }
} catch (e: any) {
return { success: false, error: e.message }
}
}
// === USER NOTIFICATION CONFIG ACTIONS ===
export async function getUserNotificationConfig(userId: string) {
try {
const config = await prisma.userNotificationConfig.findUnique({
where: { user_id: userId }
})
return { success: true, config }
} catch (e: any) {
return { success: false, error: e.message }
}
}
export async function updateUserNotificationConfig(userId: string, data: { mattermost_channel_id?: string, is_active?: boolean }) {
try {
const config = await prisma.userNotificationConfig.upsert({
where: { user_id: userId },
update: data,
create: { user_id: userId, ...data }
})
return { success: true, config }
} catch (e: any) {
return { success: false, error: e.message }
}
}
export async function notifyCreatorOnSubmission(orderId: string, submittorId: string, actionType: 'create' | 'update' | 'delete' = 'update') {
try {
const settings = await getSettings()
if (!settings || settings.MATTERMOST_NOTIF_ENABLED !== 'true') return
const order = await prisma.order.findUnique({
where: { id: orderId },
include: {
creator: {
include: { notification_config: true }
},
submissions: {
include: { user: true, items: true }
}
}
})
if (!order) return
const config = order.creator.notification_config
if (!config?.is_active || !config?.mattermost_channel_id) return
const submittor = await prisma.user.findUnique({ where: { id: submittorId } })
if (!submittor) return
const actionText = actionType === 'delete' ? 'membatalkan pesanan' : actionType === 'create' ? 'menambahkan pesanan baru' : 'merubah pesanan'
const headerMessage = `Ada yang ${actionText} dari **${submittor.name}** di PO **${order.title}**!`
let summaryByPerson = `**Rekap per Orang:**\n\`\`\`text\n${order.title}\n`
const itemCounts: Record<string, number> = {}
order.submissions?.forEach((sub: any) => {
const itemStrings = sub.items.map(
(i: any) => `${i.name} ${i.qty}x${i.note ? ` (${i.note})` : ''}`
)
summaryByPerson += `- ${sub.user?.name || 'Unknown'} : ${itemStrings.join(', ')}\n`
sub.items.forEach((i: any) => {
const key = i.note ? `${i.name} (${i.note})` : i.name
itemCounts[key] = (itemCounts[key] || 0) + i.qty
})
})
summaryByPerson += `\`\`\``
let summaryByItem = `**Rekap per Item (Akumulasi):**\n\`\`\`text\n${order.title}\n`
Object.entries(itemCounts).forEach(([name, qty]) => {
summaryByItem += `- ${name} : ${qty} pcs\n`
})
summaryByItem += `\`\`\``
const finalMessage = `${headerMessage}\n\n${summaryByPerson}\n\n${summaryByItem}`
await sendMattermostNotification(
config.mattermost_channel_id,
finalMessage,
settings.MATTERMOST_BOT_TOKEN,
settings.MATTERMOST_API_URL
)
} catch (error) {
console.error('Failed to notify creator:', error)
}
}
+77
View File
@@ -0,0 +1,77 @@
export async function sendMattermostNotification(targetIdentifier: string, message: string, botToken?: string, apiUrl?: string) {
try {
const token = botToken || process.env.MATTERMOST_BOT_TOKEN || '5zubexudb38uuradfa36qy98ca'
// Ensure we get the base URL by stripping '/posts' if it exists in the configured URL
let baseUrl = apiUrl || process.env.MATTERMOST_API_URL || 'https://mattermost.eigen.co.id/api/v4/posts'
if (baseUrl.endsWith('/posts')) {
baseUrl = baseUrl.replace('/posts', '')
}
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
}
let finalChannelId = targetIdentifier
let targetUserId = targetIdentifier
// 1. If it's a username (starts with @ or doesn't look like a 26-char ID), look up the User ID
if (targetIdentifier.startsWith('@') || targetIdentifier.length !== 26) {
const username = targetIdentifier.replace('@', '').trim()
const userRes = await fetch(`${baseUrl}/users/usernames`, {
method: 'POST',
headers,
body: JSON.stringify([username]),
})
if (userRes.ok) {
const users = await userRes.json()
if (users && users.length > 0) {
targetUserId = users[0].id
}
}
}
// 2. Try to create a DM channel if we have a valid 26-char User ID
if (targetUserId.length === 26) {
// Fetch Bot's own User ID
const meRes = await fetch(`${baseUrl}/users/me`, { headers })
if (meRes.ok) {
const me = await meRes.json()
const botId = me.id
// Create Direct Message channel between Bot and Target User
const dmRes = await fetch(`${baseUrl}/channels/direct`, {
method: 'POST',
headers,
body: JSON.stringify([botId, targetUserId]),
})
if (dmRes.ok) {
const dmChannel = await dmRes.json()
finalChannelId = dmChannel.id
}
}
}
// 3. Post the message to the final resolved channel ID
const res = await fetch(`${baseUrl}/posts`, {
method: 'POST',
headers,
body: JSON.stringify({
channel_id: finalChannelId,
message,
}),
})
if (!res.ok) {
const errorText = await res.text()
console.error(`[Mattermost Error] Failed to send message to ${targetIdentifier}:`, errorText)
return { success: false, error: errorText }
}
return { success: true }
} catch (error: any) {
console.error(`[Mattermost Exception] Failed to send message to ${targetIdentifier}:`, error.message)
return { success: false, error: error.message }
}
}