Compare commits

...

24 Commits

Author SHA1 Message Date
144f4d554a upd: add village active check on login and mobile user api
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 16:43:05 +08:00
860e9e74c4 Merge pull request 'upd: update api monitoring' (#37) from amalia/21-apr-26 into join
Reviewed-on: #37
2026-04-21 17:33:52 +08:00
dd6f27cf2b upd: update api monitoring 2026-04-21 17:29:47 +08:00
02cf404bc9 Merge pull request 'upd: claude' (#36) from amalia/20-apr-26 into join
Reviewed-on: #36
2026-04-20 17:36:16 +08:00
545e668bef upd: claude 2026-04-20 17:28:12 +08:00
ad6c5157e9 Merge pull request 'upd: fix route laporan divisi' (#35) from amalia/17-apr-26 into join
Reviewed-on: #35
2026-04-17 17:39:26 +08:00
73b19e0dd1 upd: fix route laporan divisi 2026-04-17 15:27:33 +08:00
abcbb3cd7f Merge pull request 'upd : api monitoring' (#34) from amalia/13-apr-26 into join
Reviewed-on: #34
2026-04-13 17:19:01 +08:00
ea3bf2cc3c upd : api monitoring 2026-04-13 11:36:26 +08:00
6b17378679 Merge pull request 'upd: fx api monitoring' (#33) from amalia/10-apr-26 into join
Reviewed-on: #33
2026-04-10 13:45:08 +08:00
d861a3ea86 upd: fx api monitoring 2026-04-10 13:44:15 +08:00
2f97ce81e4 Merge pull request 'upd : api monitoring' (#32) from amalia/09-apr-26 into join
Reviewed-on: #32
2026-04-09 17:34:25 +08:00
3c0a5639b6 upd : api monitoring 2026-04-09 17:33:21 +08:00
3ce650a27d Merge pull request 'amalia/08-apr-26' (#31) from amalia/08-apr-26 into join
Reviewed-on: #31
2026-04-08 17:27:06 +08:00
5efb96a92a upd: api monitoring--user 2026-04-08 17:24:50 +08:00
93ae77d335 upd: api monitoring log activity 2026-04-08 14:50:12 +08:00
0c131b80ef Merge pull request 'amalia/07-apr-26' (#30) from amalia/07-apr-26 into join
Reviewed-on: #30
2026-04-07 17:31:04 +08:00
5fd5c15394 upd: api monitoring detail desa 2026-04-07 17:25:14 +08:00
cb565ba0bd upd: api monitoring menu desa 2026-04-07 14:52:46 +08:00
940fa5a5b7 Merge pull request 'upd: api monitoring' (#29) from amalia/06-apr-26 into join
Reviewed-on: #29
2026-04-06 17:35:18 +08:00
0b9f07e543 upd: api monitoring 2026-04-06 17:23:32 +08:00
8440374424 Merge pull request 'upd: url otp' (#28) from amalia/27-mar-26 into join
Reviewed-on: #28
2026-03-27 14:09:48 +08:00
eaa1a74290 upd: url otp 2026-03-27 14:07:35 +08:00
1326338335 Merge pull request 'upd: api noc' (#27) from amalia/25-mar-26 into join
Reviewed-on: #27
2026-03-25 17:05:36 +08:00
13 changed files with 1782 additions and 54 deletions

81
CLAUDE.md Normal file
View File

@@ -0,0 +1,81 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# Development
bun install # Install dependencies
bun run dev # Dev server with experimental HTTPS (localhost:3000)
bun run build # Production build
bun run start # Start production server
bun run lint # Run ESLint
# Database
npx prisma migrate dev # Run/create migrations
npx prisma db seed # Seed with initial data
npx prisma generate # Regenerate Prisma client after schema changes
```
## Architecture
**Sistem Desa Mandiri** is a village administration platform built on Next.js 14 (App Router) with PostgreSQL.
### Key Layers
- **`src/app/(application)/`** — Auth-protected pages grouped by feature (announcement, division, project, discussion, member, profile, home, group)
- **`src/app/(auth)/`** — Login/register pages
- **`src/app/api/`** — REST API endpoints; subdirectories map to resource types (`/api/announcement`, `/api/project`, `/api/task`, etc.). Mobile-specific endpoints live under `/api/mobile/`
- **`src/module/`** — Business logic modules, one per feature (19 modules). Each module contains hooks, components, and API call functions for that domain
- **`src/lib/`** — Shared utilities: Prisma client singleton (`prisma.ts`), Firebase init, route definitions (`routes.ts`), push notification hooks
### Data Access Pattern
All DB access goes through the Prisma client singleton in `src/lib/prisma.ts`. Prisma schema is at `prisma/schema.prisma` (40+ models). Migrations live in `prisma/migrations/`.
### State Management
- **Hookstate** (`@hookstate/core` + `@hookstate/localstored`) for client-side global state with localStorage persistence
- **Iron-session** for server-side session management / auth
- **Jose** for JWT handling
### UI Stack
- **Mantine 7** is the primary UI library (components, forms, modals, notifications, charts, dates, etc.)
- **Tailwind CSS** for utility classes — used alongside Mantine
- **PostCSS** configured with Mantine preset (`postcss.config.mjs`)
### Real-time & Notifications
- **Firebase FCM** (`src/lib/firebase/`) for mobile push notifications
- **Web Push + VAPID keys** (`src/lib/usePushNotifications.ts`) for browser push
- **wibu-realtime** (custom library) for WebSocket-based real-time updates
### User Roles
Five roles with distinct access levels (see `PANDUAN PENGGUNAAN.md`):
1. **Super Admin** — full system access
2. **Admin Desa** — village-level administration
3. **Ketua Divisi** — division leader
4. **Anggota Divisi** — division member
5. **Warga/Perangkat Desa** — village resident/official
## Environment Variables
Copy `.env.example` to `.env`. Required variables:
| Variable | Purpose |
|---|---|
| `DATABASE_URL` | PostgreSQL connection string |
| `GOOGLE_PROJECT_ID`, `GOOGLE_CLIENT_EMAIL`, `GOOGLE_PRIVATE_KEY` | Firebase Admin SDK (FCM) |
| `NEXT_PUBLIC_VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` | Web Push |
| `WS_APIKEY` | WebSocket/file storage API key |
| `WIBU_REALTIME_KEY` | Real-time communication |
| `FCM_KEY` | Firebase Cloud Messaging |
## Deployment
Docker images are built via `.github/workflows/publish.yml` and pushed to GHCR (`ghcr.io`). Portainer redeploys via `.github/workflows/re-pull.yml`. Supports `dev`, `stg`, and `prod` stacks.
The Dockerfile uses a two-stage build: Bun builder → Bun runner (non-root user, port 3000).

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Village" ADD COLUMN "isDummy" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -51,6 +51,7 @@ model Village {
name String
desc String @db.Text
isActive Boolean @default(true)
isDummy Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Group Group[]

View File

@@ -7,7 +7,7 @@ export async function POST(req: NextRequest) {
const { phone }: ILogin = await req.json();
const user = await prisma.user.findUnique({
where: { phone, isActive: true },
select: { id: true, phone: true, isWithoutOTP: true },
select: { id: true, phone: true, isWithoutOTP: true, Village: { select: { isActive: true } } },
});
if (!user) {
@@ -17,6 +17,13 @@ export async function POST(req: NextRequest) {
});
}
if (!user.Village?.isActive) {
return Response.json({
success: false,
message: "Akun anda tidak aktif, silahkan hubungi admin",
});
}
return Response.json({
success: true,
message: "Sukses",

View File

@@ -0,0 +1,59 @@
import { prisma } from "@/module/_global";
import { ILogin } from "@/types";
import { NextRequest } from "next/server";
export async function POST(req: NextRequest) {
try {
const { phone }: ILogin = await req.json();
const user = await prisma.user.findUnique({
where: { phone, isActive: true },
select: { id: true, phone: true, isWithoutOTP: true },
});
if (!user) {
return Response.json({
success: false,
message: "Nomor telepon tidak terdaftar",
});
}
// Generate OTP
const code = Math.floor(1000 + Math.random() * 9000);
const message = `Desa+\nMasukkan kode ini ${code} pada web app Desa+ anda. Jangan berikan pada siapapun.`;
// Send WhatsApp
try {
const resWa = await fetch(`${process.env.URL_OTP}/api/wa/send-text`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.WA_SERVER_TOKEN}`,
},
body: JSON.stringify({
number: user.phone,
text: message,
}),
});
if (!resWa.ok) {
console.error("WhatsApp API Error:", resWa.status);
}
} catch (error) {
console.error("WhatsApp Fetch Error:", error);
}
return Response.json({
success: true,
message: "Sukses",
phone: user.phone,
isWithoutOTP: user.isWithoutOTP,
id: user.id,
otp: code, // Return OTP for client-side verification (as per existing logic)
});
} catch (error) {
console.error(error);
return Response.json({ message: "Internal Server Error (error: 500)", success: false });
}
}

View File

@@ -2,6 +2,7 @@ import { prisma } from "@/module/_global";
import { funGetUserById } from "@/module/auth";
import _, { ceil } from "lodash";
import { NextResponse } from "next/server";
import moment from "moment";
export async function GET(request: Request) {
try {
@@ -38,10 +39,10 @@ export async function GET(request: Request) {
DivisionProjectTask: {
some: {
dateStart: {
gte: new Date(String(date))
gte: moment(String(date)).startOf('day').toDate()
},
dateEnd: {
lte: new Date(String(dateAkhir))
lte: moment(String(dateAkhir)).endOf('day').toDate()
}
}
}
@@ -54,10 +55,10 @@ export async function GET(request: Request) {
DivisionProjectTask: {
some: {
dateStart: {
gte: new Date(String(date))
gte: moment(String(date)).startOf('day').toDate()
},
dateEnd: {
lte: new Date(String(dateAkhir))
lte: moment(String(dateAkhir)).endOf('day').toDate()
}
}
}
@@ -102,10 +103,10 @@ export async function GET(request: Request) {
DivisionProjectTask: {
some: {
dateStart: {
gte: new Date(String(date))
gte: moment(String(date)).startOf('day').toDate()
},
dateEnd: {
lte: new Date(String(dateAkhir))
lte: moment(String(dateAkhir)).endOf('day').toDate()
}
}
}
@@ -117,10 +118,10 @@ export async function GET(request: Request) {
DivisionProjectTask: {
some: {
dateStart: {
gte: new Date(String(date))
gte: moment(String(date)).startOf('day').toDate()
},
dateEnd: {
lte: new Date(String(dateAkhir))
lte: moment(String(dateAkhir)).endOf('day').toDate()
}
}
}
@@ -171,8 +172,8 @@ export async function GET(request: Request) {
idGroup: String(grup)
},
createdAt: {
gte: new Date(String(date)),
lte: new Date(String(dateAkhir))
gte: moment(String(date)).startOf('day').toDate(),
lte: moment(String(dateAkhir)).endOf('day').toDate()
},
}
} else {
@@ -181,8 +182,8 @@ export async function GET(request: Request) {
category: 'FILE',
idDivision: String(division),
createdAt: {
gte: new Date(String(date)),
lte: new Date(String(dateAkhir))
gte: moment(String(date)).startOf('day').toDate(),
lte: moment(String(dateAkhir)).endOf('day').toDate()
},
}
}
@@ -252,8 +253,8 @@ export async function GET(request: Request) {
DivisionCalendarReminder: {
some: {
dateStart: {
gte: new Date(String(date)),
lte: new Date()
gte: moment(String(date)).startOf('day').toDate(),
lte: moment().toDate()
}
}
}
@@ -267,8 +268,8 @@ export async function GET(request: Request) {
DivisionCalendarReminder: {
some: {
dateStart: {
gt: new Date(),
lte: new Date(String(dateAkhir))
gt: moment().toDate(),
lte: moment(String(dateAkhir)).endOf('day').toDate()
}
}
}
@@ -293,8 +294,8 @@ export async function GET(request: Request) {
DivisionCalendarReminder: {
some: {
dateStart: {
gte: new Date(String(date)),
lte: new Date()
gte: moment(String(date)).startOf('day').toDate(),
lte: moment().toDate()
}
}
}
@@ -306,8 +307,8 @@ export async function GET(request: Request) {
DivisionCalendarReminder: {
some: {
dateStart: {
gt: new Date(),
lte: new Date(String(dateAkhir))
gt: moment().toDate(),
lte: moment(String(dateAkhir)).endOf('day').toDate()
}
}
}

View File

@@ -44,7 +44,8 @@ export async function GET(request: Request, context: { params: { id: string } })
},
Village:{
select:{
name:true
name:true,
isActive:true,
}
}
},
@@ -57,8 +58,9 @@ export async function GET(request: Request, context: { params: { id: string } })
const phone = users?.phone.substr(2)
const role = users?.UserRole.name
const village = users?.Village.name
const villageIsActive = users?.Village.isActive
const result = { ...userData, group, position, idUserRole, phone, role, village };
const result = { ...userData, group, position, idUserRole, phone, role, village, villageIsActive };
const omitData = _.omit(result, ["Group", "Position", "UserRole", "Village"]);

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
export async function GET(request: Request) {
try {
return NextResponse.json({ success: true, version: "2.1.7", tahap: "beta", update: "-api untuk dashboard noc" }, { status: 200 });
return NextResponse.json({ success: true, version: "2.1.10", tahap: "beta", update: "-perbaikan grafik divisi" }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, version: "Gagal mendapatkan version, coba lagi nanti (error: 500)", reason: (error as Error).message, }, { status: 500 });

11
src/lib/formatDateTime.ts Normal file
View File

@@ -0,0 +1,11 @@
function formatDateTime(date: Date) {
return new Intl.DateTimeFormat('id-ID', {
hour: '2-digit',
minute: '2-digit',
day: '2-digit',
month: 'short',
year: 'numeric',
}).format(date);
}
export default formatDateTime

38
src/lib/timeAgo.ts Normal file
View File

@@ -0,0 +1,38 @@
function timeAgo(date: Date) {
const now = new Date();
const d = new Date(date);
const diffMs = now.getTime() - d.getTime();
const seconds = Math.floor(diffMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
// 🔥 cek apakah masih hari yang sama
const isToday =
now.getDate() === d.getDate() &&
now.getMonth() === d.getMonth() &&
now.getFullYear() === d.getFullYear();
if (isToday) {
if (seconds < 60) return `${seconds} detik lalu`;
if (minutes < 60) return `${minutes} menit lalu`;
return `${hours} jam lalu`;
}
// 🔥 kalau bukan hari ini → tampil tanggal + jam
const time = d.toLocaleTimeString("id-ID", {
hour: "2-digit",
minute: "2-digit",
});
const datePart = d.toLocaleDateString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
});
return `${time} ${datePart}`;
}
export default timeAgo

View File

@@ -5,7 +5,6 @@ import { useFocusTrap } from "@mantine/hooks";
import { useState } from "react";
import toast from "react-hot-toast";
import ViewVerification from "../../varification/view/view_verification";
function ViewLogin() {
const focusTrapRef = useFocusTrap()
const textInfo = "Kami akan mengirimkan kode verifikasi melalui WhatsApp untuk mengonfirmasi nomor Anda.";
@@ -34,23 +33,24 @@ function ViewLogin() {
})
const cekLogin = await cek.json()
if (cekLogin.success) {
const code = Math.floor(1000 + Math.random() * 9000)
try {
const res = await fetch(`https://wa.wibudev.com/code?nom=${cekLogin.phone}&text=*DARMASABA*%0A%0A
JANGAN BERIKAN KODE RAHASIA ini kepada siapa pun TERMASUK PIHAK DARMASABA. Masukkan otentikasi: *${encodeURIComponent(code)}*`).then(
async (res) => {
if (res.status == 200) {
setValPhone(cekLogin.phone)
setOTP(code)
setUser(cekLogin.id)
setVerif(true)
toast.success('Kode verifikasi telah dikirim')
} else {
console.error(res.status)
toast.error('Internal Server Error')
}
}
)
const res = await fetch('/api/auth/otp', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ phone: isPhone })
})
const data = await res.json()
if (data.success) {
setValPhone(data.phone)
setOTP(data.otp)
setUser(data.id)
setVerif(true)
toast.success('Kode verifikasi telah dikirim')
} else {
toast.error(data.message || 'Gagal mengirim kode verifikasi')
}
} catch (error) {
console.error(error)
toast.error('Internal Server Error')

View File

@@ -15,19 +15,20 @@ export default function ViewVerification({ phone, otp, user }: IVerification) {
async function onResend() {
try {
const code = Math.floor(1000 + Math.random() * 9000)
const res = await fetch(`https://wa.wibudev.com/code?nom=${phone}&text=*DARMASABA*%0A%0A
JANGAN BERIKAN KODE RAHASIA ini kepada siapa pun TERMASUK PIHAK DARMASABA. Masukkan otentikasi: *${encodeURIComponent(code)}*`)
.then(
async (res) => {
if (res.status == 200) {
toast.success('Kode verifikasi telah dikirim')
setOTP(code)
} else {
toast.error('Internal Server Error')
}
}
);
const res = await fetch('/api/auth/otp', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ phone })
})
const data = await res.json()
if (data.success) {
toast.success('Kode verifikasi telah dikirim')
setOTP(data.otp)
} else {
toast.error(data.message || 'Gagal mengirim ulang kode')
}
} catch (error) {
console.error(error)
toast.error('Internal Server Error')