Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c9df31507 | ||
|
|
d932d9af1c | ||
|
|
6d6d127126 | ||
|
|
da9fdc25c4 | ||
|
|
5b71752014 | ||
|
|
b614be79c1 | ||
|
|
2814e3cab8 |
+15
-2
@@ -19,8 +19,9 @@ model User {
|
|||||||
updated_at DateTime @updatedAt
|
updated_at DateTime @updatedAt
|
||||||
orders Order[] @relation("CreatedOrders")
|
orders Order[] @relation("CreatedOrders")
|
||||||
purchases Submission[]
|
purchases Submission[]
|
||||||
creator_withdrawals BalanceWithdrawal[] @relation("CreatorWithdrawals")
|
creator_withdrawals BalanceWithdrawal[] @relation("CreatorWithdrawals")
|
||||||
user_withdrawals BalanceWithdrawal[] @relation("UserWithdrawals")
|
user_withdrawals BalanceWithdrawal[] @relation("UserWithdrawals")
|
||||||
|
notification_config UserNotificationConfig?
|
||||||
}
|
}
|
||||||
|
|
||||||
model Order {
|
model Order {
|
||||||
@@ -91,3 +92,15 @@ model BalanceWithdrawal {
|
|||||||
creator User @relation("CreatorWithdrawals", fields: [creator_id], references: [id], onDelete: Cascade)
|
creator User @relation("CreatorWithdrawals", fields: [creator_id], references: [id], onDelete: Cascade)
|
||||||
user User @relation("UserWithdrawals", fields: [user_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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -166,13 +166,13 @@ export default function BalancesPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="animate-in fade-in space-y-6 pb-12 duration-500">
|
<div className="animate-in fade-in space-y-6 pb-12 duration-500">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-semibold text-slate-400">Keuangan / Saldo</span>
|
<span className="text-xs font-semibold text-slate-400">Keuangan / Kas</span>
|
||||||
<h2 className="flex items-center gap-2 text-xl font-black tracking-tight text-slate-900 dark:text-white">
|
<h2 className="flex items-center gap-2 text-xl font-black tracking-tight text-slate-900 dark:text-white">
|
||||||
<Wallet className="h-6 w-6 text-[#1B2CC1]" />
|
<Wallet className="h-6 w-6 text-[#1B2CC1]" />
|
||||||
Buku Saldo
|
Kas & Hutang
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1 text-sm text-slate-500">
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
Pantau riwayat saldo lebih atau kurang dari transaksi pesanan.
|
Pantau riwayat uang deposit (lebih bayar) dan hutang (kurang bayar).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -186,7 +186,7 @@ export default function BalancesPage() {
|
|||||||
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'
|
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Saldo Saya (Penitip)
|
Kas Anda
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('CREATOR')}
|
onClick={() => setActiveTab('CREATOR')}
|
||||||
@@ -197,7 +197,7 @@ export default function BalancesPage() {
|
|||||||
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'
|
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Saldo Orang (Kreator)
|
Kas Penitip
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -210,10 +210,10 @@ export default function BalancesPage() {
|
|||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="rounded-xl border border-blue-100 bg-blue-50 p-4 dark:border-blue-900/50 dark:bg-blue-950/30">
|
<div className="rounded-xl border border-blue-100 bg-blue-50 p-4 dark:border-blue-900/50 dark:bg-blue-950/30">
|
||||||
<h3 className="mb-1 text-sm font-bold text-blue-900 dark:text-blue-100">
|
<h3 className="mb-1 text-sm font-bold text-blue-900 dark:text-blue-100">
|
||||||
Saldo Anda di Kreator Lain
|
Deposit & Hutang Anda
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs leading-relaxed text-blue-700 dark:text-blue-300">
|
<p className="text-xs leading-relaxed text-blue-700 dark:text-blue-300">
|
||||||
Jika saldo <strong>positif</strong> (hijau), Anda memiliki deposit yang bisa digunakan
|
Jika kas <strong>positif</strong> (hijau), Anda memiliki deposit yang bisa digunakan
|
||||||
untuk pesanan berikutnya. Jika <strong>negatif</strong> (merah), Anda berhutang.
|
untuk pesanan berikutnya. Jika <strong>negatif</strong> (merah), Anda berhutang.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -223,9 +223,9 @@ export default function BalancesPage() {
|
|||||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 dark:bg-slate-800">
|
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 dark:bg-slate-800">
|
||||||
<Wallet className="h-7 w-7 text-slate-300 dark:text-slate-600" />
|
<Wallet className="h-7 w-7 text-slate-300 dark:text-slate-600" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-semibold text-slate-500">Belum ada catatan saldo</p>
|
<p className="text-sm font-semibold text-slate-500">Belum ada catatan kas</p>
|
||||||
<p className="mt-1 text-xs text-slate-400">
|
<p className="mt-1 text-xs text-slate-400">
|
||||||
Saldo muncul saat ada sisa atau kekurangan bayar dari PO.
|
Kas muncul saat ada deposit atau hutang dari PO.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -295,7 +295,7 @@ export default function BalancesPage() {
|
|||||||
{/* Amount */}
|
{/* Amount */}
|
||||||
<div className="shrink-0 text-right">
|
<div className="shrink-0 text-right">
|
||||||
<p className="text-[10px] font-semibold tracking-wider text-slate-400 uppercase">
|
<p className="text-[10px] font-semibold tracking-wider text-slate-400 uppercase">
|
||||||
Total Saldo
|
{isPositive ? 'Total Deposit' : 'Total Hutang'}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -354,12 +354,12 @@ export default function BalancesPage() {
|
|||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="rounded-xl border border-amber-100 bg-amber-50 p-4 dark:border-amber-900/50 dark:bg-amber-950/30">
|
<div className="rounded-xl border border-amber-100 bg-amber-50 p-4 dark:border-amber-900/50 dark:bg-amber-950/30">
|
||||||
<h3 className="mb-1 text-sm font-bold text-amber-900 dark:text-amber-100">
|
<h3 className="mb-1 text-sm font-bold text-amber-900 dark:text-amber-100">
|
||||||
Saldo Orang Lain di Anda
|
Kas & Hutang Penitip di Anda
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs leading-relaxed text-amber-700 dark:text-amber-300">
|
<p className="text-xs leading-relaxed text-amber-700 dark:text-amber-300">
|
||||||
Saldo <strong>positif</strong> = Anda memegang uang lebih milik penitip. Klik{' '}
|
Kas <strong>positif</strong> = Anda memegang uang deposit milik penitip. Klik{' '}
|
||||||
<strong>Kembalikan</strong> untuk mencatatnya. Saldo <strong>negatif</strong> =
|
<strong>Kembalikan</strong> untuk mencatatnya. Kas <strong>negatif</strong> = penitip
|
||||||
penitip masih berhutang ke Anda.
|
masih berhutang ke Anda.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -368,9 +368,9 @@ export default function BalancesPage() {
|
|||||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 dark:bg-slate-800">
|
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 dark:bg-slate-800">
|
||||||
<Wallet className="h-7 w-7 text-slate-300 dark:text-slate-600" />
|
<Wallet className="h-7 w-7 text-slate-300 dark:text-slate-600" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-semibold text-slate-500">Tidak ada catatan saldo</p>
|
<p className="text-sm font-semibold text-slate-500">Tidak ada catatan kas</p>
|
||||||
<p className="mt-1 text-xs text-slate-400">
|
<p className="mt-1 text-xs text-slate-400">
|
||||||
Saldo muncul saat ada kelebihan atau kekurangan bayar.
|
Kas muncul saat ada deposit atau hutang dari PO.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -443,7 +443,7 @@ export default function BalancesPage() {
|
|||||||
{/* Amount */}
|
{/* Amount */}
|
||||||
<div className="shrink-0 text-right">
|
<div className="shrink-0 text-right">
|
||||||
<p className="text-[10px] font-semibold tracking-wider text-slate-400 uppercase">
|
<p className="text-[10px] font-semibold tracking-wider text-slate-400 uppercase">
|
||||||
{isPositive ? 'Anda Hutang' : 'Piutang Anda'}
|
{isPositive ? 'Total Deposit' : 'Total Piutang'}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -506,11 +506,13 @@ function SubmissionRow({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentBalance !== undefined) {
|
if (currentBalance !== undefined) {
|
||||||
let balanceBeforeThisOrder = currentBalance
|
let balanceBeforeThisOrder = currentBalance
|
||||||
if (sub.payment_status === 'LUNAS') {
|
const shouldCountBill = sub.bill != null && (isClosed || sub.payment_status === 'LUNAS')
|
||||||
|
if (shouldCountBill) {
|
||||||
const diff = (sub.paid_amount || 0) - (sub.bill || 0)
|
const diff = (sub.paid_amount || 0) - (sub.bill || 0)
|
||||||
balanceBeforeThisOrder -= diff
|
balanceBeforeThisOrder -= diff
|
||||||
} else {
|
} else {
|
||||||
balanceBeforeThisOrder += sub.saldo_used || 0
|
const diff = (sub.paid_amount || 0) - (sub.saldo_used || 0)
|
||||||
|
balanceBeforeThisOrder -= diff
|
||||||
}
|
}
|
||||||
setAvailableBalance(Math.max(0, balanceBeforeThisOrder))
|
setAvailableBalance(Math.max(0, balanceBeforeThisOrder))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
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 { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -21,6 +28,7 @@ import {
|
|||||||
UserCircle,
|
UserCircle,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
LockKeyhole,
|
LockKeyhole,
|
||||||
|
MessageCircle,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
@@ -29,6 +37,13 @@ export default function ProfilePage() {
|
|||||||
const [photo, setPhoto] = useState('')
|
const [photo, setPhoto] = useState('')
|
||||||
const [loading, setLoading] = useState(true)
|
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
|
// Profile state
|
||||||
const [savingProfile, setSavingProfile] = useState(false)
|
const [savingProfile, setSavingProfile] = useState(false)
|
||||||
const [profileError, setProfileError] = useState('')
|
const [profileError, setProfileError] = useState('')
|
||||||
@@ -54,6 +69,20 @@ export default function ProfilePage() {
|
|||||||
setUser(sessionUser)
|
setUser(sessionUser)
|
||||||
setName(sessionUser.name)
|
setName(sessionUser.name)
|
||||||
setPhoto(sessionUser.photo || '')
|
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)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -69,14 +98,22 @@ export default function ProfilePage() {
|
|||||||
setProfileSuccess(false)
|
setProfileSuccess(false)
|
||||||
setSavingProfile(true)
|
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)
|
setUser(res.user)
|
||||||
setProfileSuccess(true)
|
setProfileSuccess(true)
|
||||||
setTimeout(() => setProfileSuccess(false), 3000)
|
setTimeout(() => setProfileSuccess(false), 3000)
|
||||||
} else {
|
} else {
|
||||||
setProfileError(res.error || 'Terjadi kesalahan')
|
setProfileError(res.error || notifRes.error || 'Terjadi kesalahan')
|
||||||
}
|
}
|
||||||
setSavingProfile(false)
|
setSavingProfile(false)
|
||||||
}
|
}
|
||||||
@@ -215,6 +252,58 @@ export default function ProfilePage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{globalNotifEnabled && (
|
||||||
|
<div className="mt-6 space-y-6 border-t border-slate-100 pt-6 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-y-0 space-x-3 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: budisantoso atau 9dpxnitm..."
|
||||||
|
className="h-11 rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{profileError && (
|
{profileError && (
|
||||||
<div className="rounded-xl bg-red-50 p-3 text-xs font-semibold text-red-600">
|
<div className="rounded-xl bg-red-50 p-3 text-xs font-semibold text-red-600">
|
||||||
{profileError}
|
{profileError}
|
||||||
@@ -223,7 +312,7 @@ export default function ProfilePage() {
|
|||||||
{profileSuccess && (
|
{profileSuccess && (
|
||||||
<div className="flex items-center gap-2 rounded-xl bg-emerald-50 p-3 text-xs font-bold text-emerald-700">
|
<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" />
|
<CheckCircle className="h-4 w-4" />
|
||||||
<span>Profil berhasil diperbarui!</span>
|
<span>Perubahan berhasil disimpan!</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
|
||||||
export default function IntegrationsPage() {
|
export default function IntegrationsPage() {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [settings, setSettings] = useState({
|
const [settings, setSettings] = useState({
|
||||||
|
MATTERMOST_NOTIF_ENABLED: 'false',
|
||||||
|
MATTERMOST_BOT_TOKEN: '',
|
||||||
|
MATTERMOST_API_URL: '',
|
||||||
MATTERMOST_WEBHOOK_URL: '',
|
MATTERMOST_WEBHOOK_URL: '',
|
||||||
MATTERMOST_TEMPLATE: '',
|
MATTERMOST_TEMPLATE: '',
|
||||||
WHATSAPP_TEMPLATE: '',
|
WHATSAPP_TEMPLATE: '',
|
||||||
@@ -23,6 +27,9 @@ export default function IntegrationsPage() {
|
|||||||
const data = await getSettings()
|
const data = await getSettings()
|
||||||
if (data) {
|
if (data) {
|
||||||
setSettings({
|
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_WEBHOOK_URL: data.MATTERMOST_WEBHOOK_URL || '',
|
||||||
MATTERMOST_TEMPLATE:
|
MATTERMOST_TEMPLATE:
|
||||||
data.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="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">
|
<div className="flex items-center gap-2 font-black text-indigo-600 dark:text-indigo-400">
|
||||||
<MessageSquare className="h-5 w-5" />
|
<MessageSquare className="h-5 w-5" />
|
||||||
<h3>Mattermost Webhook</h3>
|
<h3>Konfigurasi Mattermost</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-slate-500">
|
||||||
Gunakan URL Incoming Webhook dari Mattermost untuk mengirim broadcast PO baru secara
|
Atur integrasi Mattermost. Bagian atas untuk Notifikasi Personal (DM) ke Kreator, sedangkan bagian bawah untuk fitur Share (Broadcast) PO menggunakan Webhook.
|
||||||
otomatis.
|
|
||||||
</p>
|
</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>
|
<Label className="text-xs font-bold text-slate-700 uppercase">Webhook URL</Label>
|
||||||
<Input
|
<Input
|
||||||
value={settings.MATTERMOST_WEBHOOK_URL}
|
value={settings.MATTERMOST_WEBHOOK_URL}
|
||||||
|
|||||||
+152
-13
@@ -3,8 +3,10 @@
|
|||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath } from 'next/cache'
|
||||||
|
|
||||||
import { hashPassword, verifyPassword, encrypt, decrypt } from '@/lib/auth'
|
import { encrypt, decrypt } from '@/lib/auth'
|
||||||
|
import { hashPassword, verifyPassword } from '@/lib/password'
|
||||||
import { cookies } from 'next/headers'
|
import { cookies } from 'next/headers'
|
||||||
|
import { sendMattermostNotification } from '@/lib/mattermost'
|
||||||
|
|
||||||
// === AUTHENTICATION ACTIONS ===
|
// === AUTHENTICATION ACTIONS ===
|
||||||
export async function loginUser(username: string, password: string) {
|
export async function loginUser(username: string, password: string) {
|
||||||
@@ -437,6 +439,19 @@ export async function processBulkPayment(data: {
|
|||||||
// Sort by order date ascending (oldest first)
|
// Sort by order date ascending (oldest first)
|
||||||
submissions.sort((a, b) => new Date(a.order.date).getTime() - new Date(b.order.date).getTime())
|
submissions.sort((a, b) => new Date(a.order.date).getTime() - new Date(b.order.date).getTime())
|
||||||
|
|
||||||
|
// Reverse the effect of these submissions on currentBalance to get true available balance
|
||||||
|
if (data.use_balance) {
|
||||||
|
for (const sub of submissions) {
|
||||||
|
const shouldCountBill = sub.bill != null && (sub.order.status === 'CLOSE' || sub.payment_status === 'LUNAS')
|
||||||
|
if (shouldCountBill) {
|
||||||
|
currentBalance -= ((sub.paid_amount || 0) - (sub.bill || 0))
|
||||||
|
} else {
|
||||||
|
currentBalance -= ((sub.paid_amount || 0) - (sub.saldo_used || 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentBalance = Math.max(0, currentBalance)
|
||||||
|
}
|
||||||
|
|
||||||
let remainingBalance = currentBalance
|
let remainingBalance = currentBalance
|
||||||
let remainingCash = data.cash_amount
|
let remainingCash = data.cash_amount
|
||||||
let totalAvailable = remainingBalance + remainingCash
|
let totalAvailable = remainingBalance + remainingCash
|
||||||
@@ -607,6 +622,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('/')
|
||||||
revalidatePath('/my-purchases')
|
revalidatePath('/my-purchases')
|
||||||
revalidatePath(`/my-orders/${data.order_id}`)
|
revalidatePath(`/my-orders/${data.order_id}`)
|
||||||
@@ -664,7 +683,10 @@ export async function getBalancesAsCreator(creator_id: string) {
|
|||||||
const [submissions, withdrawals] = await Promise.all([
|
const [submissions, withdrawals] = await Promise.all([
|
||||||
prisma.submission.findMany({
|
prisma.submission.findMany({
|
||||||
where: { order: { creator_id } },
|
where: { order: { creator_id } },
|
||||||
include: { user: { select: { id: true, name: true, photo: true } } },
|
include: {
|
||||||
|
user: { select: { id: true, name: true, photo: true } },
|
||||||
|
order: { select: { status: true } },
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
prisma.balanceWithdrawal.findMany({
|
prisma.balanceWithdrawal.findMany({
|
||||||
where: { creator_id },
|
where: { creator_id },
|
||||||
@@ -676,12 +698,12 @@ export async function getBalancesAsCreator(creator_id: string) {
|
|||||||
|
|
||||||
submissions.forEach((sub) => {
|
submissions.forEach((sub) => {
|
||||||
let diff = 0
|
let diff = 0
|
||||||
if (sub.payment_status === 'LUNAS') {
|
const shouldCountBill = sub.bill != null && (sub.order.status === 'CLOSE' || sub.payment_status === 'LUNAS')
|
||||||
if (sub.bill != null) {
|
|
||||||
diff = (sub.paid_amount || 0) - sub.bill
|
if (shouldCountBill) {
|
||||||
}
|
diff = (sub.paid_amount || 0) - sub.bill!
|
||||||
} else {
|
} else {
|
||||||
diff = -(sub.saldo_used || 0)
|
diff = (sub.paid_amount || 0) - (sub.saldo_used || 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (diff !== 0) {
|
if (diff !== 0) {
|
||||||
@@ -727,12 +749,12 @@ export async function getBalancesAsSubmittor(user_id: string) {
|
|||||||
|
|
||||||
submissions.forEach((sub) => {
|
submissions.forEach((sub) => {
|
||||||
let diff = 0
|
let diff = 0
|
||||||
if (sub.payment_status === 'LUNAS') {
|
const shouldCountBill = sub.bill != null && (sub.order.status === 'CLOSE' || sub.payment_status === 'LUNAS')
|
||||||
if (sub.bill != null) {
|
|
||||||
diff = (sub.paid_amount || 0) - sub.bill
|
if (shouldCountBill) {
|
||||||
}
|
diff = (sub.paid_amount || 0) - sub.bill!
|
||||||
} else {
|
} else {
|
||||||
diff = -(sub.saldo_used || 0)
|
diff = (sub.paid_amount || 0) - (sub.saldo_used || 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (diff !== 0) {
|
if (diff !== 0) {
|
||||||
@@ -853,6 +875,10 @@ export async function deleteSubmission(submission_id: string, user_id: string) {
|
|||||||
await prisma.submission.delete({
|
await prisma.submission.delete({
|
||||||
where: { id: submission_id },
|
where: { id: submission_id },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Trigger notification
|
||||||
|
notifyCreatorOnSubmission(submission.order_id, user_id, 'delete').catch(console.error)
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: 'Gagal membatalkan pesanan' }
|
return { success: false, error: 'Gagal membatalkan pesanan' }
|
||||||
@@ -936,7 +962,7 @@ export async function getPiutangSummary(creator_id: string) {
|
|||||||
select: { id: true, name: true, photo: true },
|
select: { id: true, name: true, photo: true },
|
||||||
},
|
},
|
||||||
order: {
|
order: {
|
||||||
select: { id: true, title: true, date: true },
|
select: { id: true, title: true, date: true, status: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
@@ -971,3 +997,116 @@ export async function getPiutangSummary(creator_id: string) {
|
|||||||
|
|
||||||
return Array.from(grouped.values()).filter((g) => g.totalPiutang > 0)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -96,7 +96,21 @@ export default function PiutangManager({ creatorId }: { creatorId: string }) {
|
|||||||
u.user.name.toLowerCase().includes(searchQuery.toLowerCase())
|
u.user.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
)
|
)
|
||||||
|
|
||||||
const targetUserBalance = balancesData.find((b) => b.user.id === target.userId)?.amount || 0
|
let rawTargetUserBalance = balancesData.find((b) => b.user.id === target.userId)?.amount || 0
|
||||||
|
const userObj = data.find((u) => u.user.id === target.userId)
|
||||||
|
|
||||||
|
if (userObj) {
|
||||||
|
userObj.submissions.forEach((sub: any) => {
|
||||||
|
const shouldCountBill = sub.bill != null && (sub.order.status === 'CLOSE' || sub.payment_status === 'LUNAS')
|
||||||
|
if (shouldCountBill) {
|
||||||
|
rawTargetUserBalance -= ((sub.paid_amount || 0) - (sub.bill || 0))
|
||||||
|
} else {
|
||||||
|
rawTargetUserBalance -= ((sub.paid_amount || 0) - (sub.saldo_used || 0))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUserBalance = Math.max(0, rawTargetUserBalance)
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export default function ReportByPersonGrid({
|
|||||||
const unpaid = Math.max(0, bill - paid)
|
const unpaid = Math.max(0, bill - paid)
|
||||||
obj.totalPiutang += unpaid
|
obj.totalPiutang += unpaid
|
||||||
obj.totalPaid += paid
|
obj.totalPaid += paid
|
||||||
obj.submissions.push({ ...sub, orderTitle: order.title, orderDate: order.date, paid, unpaid })
|
obj.submissions.push({ ...sub, orderTitle: order.title, orderDate: order.date, orderStatus: order.status, paid, unpaid })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -148,8 +148,24 @@ export default function ReportByPersonGrid({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetUserBalanceObj = balancesData.find((b) => b.user.id === target.userId)
|
let rawTargetUserBalance = balancesData.find((b) => b.user.id === target.userId)?.amount || 0
|
||||||
const targetUserBalance = targetUserBalanceObj ? targetUserBalanceObj.amount : 0
|
const userObj = userList.find((u) => u.user.id === target.userId)
|
||||||
|
|
||||||
|
if (userObj) {
|
||||||
|
userObj.submissions.forEach((sub: any) => {
|
||||||
|
// Only add back submissions that are part of the payment target
|
||||||
|
if (target.ids.includes(sub.id)) {
|
||||||
|
const shouldCountBill = sub.bill != null && (sub.orderStatus === 'CLOSE' || sub.payment_status === 'LUNAS')
|
||||||
|
if (shouldCountBill) {
|
||||||
|
rawTargetUserBalance -= ((sub.paid_amount || 0) - (sub.bill || 0))
|
||||||
|
} else {
|
||||||
|
rawTargetUserBalance -= ((sub.paid_amount || 0) - (sub.saldo_used || 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUserBalance = Math.max(0, rawTargetUserBalance)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const menuItems = [
|
|||||||
desc: 'Kelola PO buatan Anda',
|
desc: 'Kelola PO buatan Anda',
|
||||||
},
|
},
|
||||||
{ name: 'Pesanan Saya', href: '/my-purchases', icon: ShoppingBag, desc: 'Riwayat titipan Anda' },
|
{ name: 'Pesanan Saya', href: '/my-purchases', icon: ShoppingBag, desc: 'Riwayat titipan Anda' },
|
||||||
{ name: 'Buku Saldo', href: '/balances', icon: Wallet, desc: 'Pantau riwayat saldo' },
|
{ name: 'Kas & Hutang', href: '/balances', icon: Wallet, desc: 'Pantau kas dan hutang' },
|
||||||
{ name: 'Laporan', href: '/reports', icon: BarChart2, desc: 'Ringkasan transaksi' },
|
{ name: 'Laporan', href: '/reports', icon: BarChart2, desc: 'Ringkasan transaksi' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,9 @@
|
|||||||
import { SignJWT, jwtVerify } from 'jose'
|
import { SignJWT, jwtVerify } from 'jose'
|
||||||
import bcrypt from 'bcryptjs'
|
|
||||||
import { cookies } from 'next/headers'
|
import { cookies } from 'next/headers'
|
||||||
|
|
||||||
const secretKey = process.env.JWT_SECRET
|
const secretKey = process.env.JWT_SECRET
|
||||||
const key = new TextEncoder().encode(secretKey)
|
const key = new TextEncoder().encode(secretKey)
|
||||||
|
|
||||||
export async function hashPassword(password: string) {
|
|
||||||
return await bcrypt.hash(password, 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyPassword(password: string, hash: string) {
|
|
||||||
return await bcrypt.compare(password, hash)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function encrypt(payload: any) {
|
export async function encrypt(payload: any) {
|
||||||
return await new SignJWT(payload).setProtectedHeader({ alg: 'HS256' }).setIssuedAt().sign(key) // No expiration as requested
|
return await new SignJWT(payload).setProtectedHeader({ alg: 'HS256' }).setIssuedAt().sign(key) // No expiration as requested
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
|
||||||
|
export async function hashPassword(password: string) {
|
||||||
|
return await bcrypt.hash(password, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(password: string, hash: string) {
|
||||||
|
return await bcrypt.compare(password, hash)
|
||||||
|
}
|
||||||
+7
-4
@@ -2,14 +2,17 @@ import { NextResponse } from 'next/server'
|
|||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { decrypt } from '@/lib/auth'
|
import { decrypt } from '@/lib/auth'
|
||||||
|
|
||||||
const protectedRoutes = ['/', '/my-orders', '/my-purchases', '/reports', '/profile', '/users']
|
const protectedRoutes = ['/', '/my-orders', '/my-purchases', '/reports', '/profile', '/users', '/balances']
|
||||||
|
const protectedPrefixes = ['/my-orders/', '/order/', '/settings/']
|
||||||
const publicRoutes = ['/login', '/register']
|
const publicRoutes = ['/login', '/register']
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
export async function middleware(request: NextRequest) {
|
||||||
const path = request.nextUrl.pathname
|
const path = request.nextUrl.pathname
|
||||||
|
|
||||||
// Also protect dynamic routes like /my-orders/[id]
|
const isProtectedRoute =
|
||||||
const isProtectedRoute = protectedRoutes.includes(path) || path.startsWith('/my-orders/')
|
protectedRoutes.includes(path) ||
|
||||||
|
protectedPrefixes.some(prefix => path.startsWith(prefix))
|
||||||
|
|
||||||
const isPublicRoute = publicRoutes.includes(path)
|
const isPublicRoute = publicRoutes.includes(path)
|
||||||
|
|
||||||
const cookie = request.cookies.get('session')?.value
|
const cookie = request.cookies.get('session')?.value
|
||||||
@@ -23,7 +26,7 @@ export async function middleware(request: NextRequest) {
|
|||||||
return NextResponse.redirect(new URL('/', request.nextUrl))
|
return NextResponse.redirect(new URL('/', request.nextUrl))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path.startsWith('/users') && session?.role !== 'superadmin') {
|
if (path.startsWith('/settings') && session?.role !== 'superadmin') {
|
||||||
return NextResponse.redirect(new URL('/', request.nextUrl))
|
return NextResponse.redirect(new URL('/', request.nextUrl))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user