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]"
+ />
+
+
+ Aktifkan Notifikasi
+
+
+ Kirim notifikasi setiap kali ada yang merubah pesanannya di PO Anda.
+
+
+
+
+
+
+ Username / Channel ID
+
+ 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')}
+ />
+
+
+ Aktifkan DM Kreator saat Update Pesanan (Menggunakan Bot API)
+
+
+ Kirim notifikasi otomatis ke channel/DM kreator PO tiap ada update pesanan. (Membutuhkan Bot Token).
+
+
+ {settings.MATTERMOST_NOTIF_ENABLED === 'true' && (
+
+ )}
+
+
+
+
+
Broadcast via Incoming Webhook
Webhook URL
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 }
+ }
+}
+