From 2814e3cab82e917723e81962a0fb0d119ca977ea Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:18:55 +0700 Subject: [PATCH] feat: add Mattermost bot integration and user notification configuration settings --- prisma/schema.prisma | 17 ++- src/app/(app)/profile/page.tsx | 88 ++++++++++++- src/app/(app)/settings/integrations/page.tsx | 55 ++++++++- src/app/actions.ts | 122 +++++++++++++++++++ src/lib/mattermost.ts | 77 ++++++++++++ 5 files changed, 348 insertions(+), 11 deletions(-) create mode 100644 src/lib/mattermost.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1b97687..047c79a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) +} diff --git a/src/app/(app)/profile/page.tsx b/src/app/(app)/profile/page.tsx index 3831f0c..e1c44c2 100644 --- a/src/app/(app)/profile/page.tsx +++ b/src/app/(app)/profile/page.tsx @@ -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() { /> + {globalNotifEnabled && ( +
+
+
+ +
+
+

+ Notifikasi Mattermost +

+

+ Terima update pesanan PO langsung di DM Mattermost Anda. +

+
+
+ +
+ setMmActive(e.target.checked)} + className="mt-1 h-4 w-4 rounded border-slate-300 text-[#1B2CC1] focus:ring-[#1B2CC1]" + /> +
+ +

+ Kirim notifikasi setiap kali ada yang merubah pesanannya di PO Anda. +

+
+
+ +
+ + setMmChannelId(e.target.value)} + placeholder="Contoh: @firman atau 9dpxnitm..." + className="h-11 rounded-xl" + /> +
+
+ )} + {profileError && (
{profileError} @@ -223,7 +300,7 @@ export default function ProfilePage() { {profileSuccess && (
- Profil berhasil diperbarui! + Perubahan berhasil disimpan!
)} @@ -244,6 +321,7 @@ export default function ProfilePage() {
+ {/* Right 1 Col: Account Details */} diff --git a/src/app/(app)/settings/integrations/page.tsx b/src/app/(app)/settings/integrations/page.tsx index f3bfde2..06fc721 100644 --- a/src/app/(app)/settings/integrations/page.tsx +++ b/src/app/(app)/settings/integrations/page.tsx @@ -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() {
-

Mattermost Webhook

+

Konfigurasi Mattermost

- 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.

-
+
+ handleChange('MATTERMOST_NOTIF_ENABLED', checked ? 'true' : 'false')} + /> +
+ +

+ Kirim notifikasi otomatis ke channel/DM kreator PO tiap ada update pesanan. (Membutuhkan Bot Token). +

+ + {settings.MATTERMOST_NOTIF_ENABLED === 'true' && ( +
+
+ + handleChange('MATTERMOST_API_URL', e.target.value)} + className="h-10 rounded-xl" + placeholder="https://mattermost.domain.com/api/v4/posts" + /> +
+
+ + handleChange('MATTERMOST_BOT_TOKEN', e.target.value)} + className="h-10 rounded-xl" + placeholder="Masukan Bearer Token" + /> +
+
+ )} +
+
+ +
+

Broadcast via Incoming Webhook

g.totalPiutang > 0) } + +// === SETTINGS ACTIONS === +export async function getSystemSettings() { + try { + const settings = await prisma.setting.findMany() + const config: Record = {} + 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 = {} + + 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) + } +} diff --git a/src/lib/mattermost.ts b/src/lib/mattermost.ts new file mode 100644 index 0000000..5a153a8 --- /dev/null +++ b/src/lib/mattermost.ts @@ -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 } + } +} +