Added comprehensive QC reports and fix summaries for: - Desa (Berita, Potensi, Profil, Layanan, Penghargaan, Pengumuman) - Kesehatan (Posyandu) - Landing Page (APBDes, SDGS, Anti-Korupsi, Profil, Prestasi) - PPID (Daftar Informasi, Dasar Hukum, IKM, Permohonan, Struktur, Visi Misi)
1123 lines
28 KiB
Markdown
1123 lines
28 KiB
Markdown
# Quality Control Report - Gallery Desa Admin
|
|
|
|
**Lokasi:** `/src/app/admin/(dashboard)/desa/gallery/`
|
|
**Tanggal QC:** 25 Februari 2026
|
|
**Status:** ⚠️ **Needs Improvement** (ada issue critical data loss risk)
|
|
|
|
---
|
|
|
|
## 📋 Ringkasan Eksekutif
|
|
|
|
Halaman Gallery Desa (Foto & Video) memiliki implementasi yang **cukup baik** dengan CRUD lengkap, upload gambar, YouTube embed, dan state management terstruktur. Namun ditemukan **18 issue** dengan rincian:
|
|
|
|
- 🔴 **High Priority:** 5 issue
|
|
- 🟡 **Medium Priority:** 8 issue
|
|
- 🟢 **Low Priority:** 5 issue
|
|
|
|
**Overall Score: 6/10** - Needs Improvement
|
|
|
|
---
|
|
|
|
## 📁 Struktur File yang Diperiksa
|
|
|
|
```
|
|
/src/app/admin/(dashboard)/desa/gallery/
|
|
├── layout.tsx
|
|
├── lib/
|
|
│ ├── layoutTabs.tsx # Tab navigation Foto/Video
|
|
│ ├── youtube-utils.ts # YouTube URL conversion utilities
|
|
│ └── youtubeEmbed.tsx # Reusable embed component (UNUSED)
|
|
├── foto/
|
|
│ ├── page.tsx # List foto dengan search & pagination
|
|
│ ├── create/
|
|
│ │ └── page.tsx # Upload foto dengan dropzone
|
|
│ └── [id]/
|
|
│ ├── page.tsx # Detail foto
|
|
│ └── edit/
|
|
│ └── page.tsx # Edit foto (replace image)
|
|
└── video/
|
|
├── page.tsx # List video dengan search & pagination
|
|
├── create/
|
|
│ └── page.tsx # Add video YouTube dengan embed preview
|
|
└── [id]/
|
|
├── page.tsx # Detail video
|
|
└── edit/
|
|
└── page.tsx # Edit video
|
|
```
|
|
|
|
**File Terkait:**
|
|
- State: `/src/app/admin/(dashboard)/_state/desa/gallery.ts`
|
|
- API: `/src/app/api/[[...slugs]]/_lib/desa/gallery/foto/` (7 files)
|
|
- API: `/src/app/api/[[...slugs]]/_lib/desa/gallery/video/` (6 files)
|
|
- Schema: `/prisma/schema.prisma` (Model `GalleryFoto` & `GalleryVideo`)
|
|
|
|
---
|
|
|
|
## 🔴 HIGH PRIORITY ISSUES
|
|
|
|
### 1. Schema - `deletedAt @default(now())` (CRITICAL BUG)
|
|
|
|
**File:** `prisma/schema.prisma`
|
|
|
|
```prisma
|
|
model GalleryFoto {
|
|
id String @id @default(cuid())
|
|
name String
|
|
deletedAt DateTime @default(now()) // ❌ CRITICAL BUG
|
|
isActive Boolean @default(true)
|
|
}
|
|
|
|
model GalleryVideo {
|
|
id String @id @default(cuid())
|
|
name String
|
|
deletedAt DateTime @default(now()) // ❌ CRITICAL BUG
|
|
isActive Boolean @default(true)
|
|
}
|
|
```
|
|
|
|
**Dampak:**
|
|
- **Setiap record baru langsung ter-mark sebagai deleted** saat dibuat
|
|
- Query dengan filter `deletedAt: null` tidak akan dapat data baru
|
|
- Soft delete logic tidak bekerja sama sekali
|
|
- Data inconsistency antara `deletedAt` (set) dan `isActive` (true)
|
|
|
|
**Severity:** 🔴 **CRITICAL** - Ini adalah bug yang sama seperti di Profil Desa dan Pengumuman
|
|
|
|
**Solusi:**
|
|
```prisma
|
|
model GalleryFoto {
|
|
id String @id @default(cuid())
|
|
name String
|
|
deletedAt DateTime? // ✅ Nullable, tanpa default
|
|
isActive Boolean @default(true)
|
|
}
|
|
|
|
model GalleryVideo {
|
|
id String @id @default(cuid())
|
|
name String
|
|
deletedAt DateTime? // ✅ Nullable, tanpa default
|
|
isActive Boolean @default(true)
|
|
}
|
|
```
|
|
|
|
**Migration Required:**
|
|
```bash
|
|
bunx prisma migrate dev --name fix_deleted_at_default
|
|
# atau
|
|
bunx prisma db push
|
|
|
|
# Data cleanup
|
|
UPDATE "GalleryFoto" SET "deletedAt" = NULL WHERE "isActive" = true;
|
|
UPDATE "GalleryVideo" SET "deletedAt" = NULL WHERE "isActive" = true;
|
|
```
|
|
|
|
---
|
|
|
|
### 2. API - File Orphaning Saat Create Gagal
|
|
|
|
**File:** `src/app/admin/(dashboard)/desa/gallery/foto/create/page.tsx`
|
|
|
|
```typescript
|
|
// Line 78-88
|
|
const res = await ApiFetch.api.fileStorage.create.post({
|
|
file,
|
|
name: file.name,
|
|
});
|
|
const uploaded = res.data?.data;
|
|
if (!uploaded?.id) {
|
|
return toast.error('Gagal mengunggah gambar, silakan coba lagi');
|
|
}
|
|
FotoState.create.form.imagesId = uploaded.id;
|
|
await FotoState.create.create(); // ❌ Jika ini gagal, file sudah ter-upload
|
|
```
|
|
|
|
**Dampak:**
|
|
- File ter-upload ke server tapi gallery tidak terbuat
|
|
- **Orphaned files** menumpuk di database dan filesystem
|
|
- Storage waste, tidak ada cleanup mechanism
|
|
|
|
**Severity:** 🔴 **HIGH** - Data integrity issue
|
|
|
|
**Solusi:**
|
|
|
|
**Option A - Transaction di API:**
|
|
```typescript
|
|
// Di API create.ts
|
|
try {
|
|
// Validate fileStorage exists first
|
|
const fileStorage = await prisma.fileStorage.findUnique({
|
|
where: { id: body.imagesId }
|
|
});
|
|
|
|
if (!fileStorage) {
|
|
return Response.json({
|
|
success: false,
|
|
message: "File tidak ditemukan"
|
|
}, { status: 404 });
|
|
}
|
|
|
|
const gallery = await prisma.galleryFoto.create({
|
|
data: {
|
|
name: body.name,
|
|
deskripsi: body.deskripsi,
|
|
imagesId: body.imagesId,
|
|
}
|
|
});
|
|
|
|
return { success: true, data: gallery };
|
|
} catch (error) {
|
|
// Rollback file jika create gagal
|
|
if (body.imagesId) {
|
|
await prisma.fileStorage.delete({ where: { id: body.imagesId } }).catch(() => {});
|
|
}
|
|
throw error;
|
|
}
|
|
```
|
|
|
|
**Option B - Cleanup di Frontend:**
|
|
```typescript
|
|
try {
|
|
const uploaded = await uploadFile(file);
|
|
const result = await createGallery({ ...imagesId: uploaded.id });
|
|
|
|
if (!result.success) {
|
|
// Cleanup orphaned file
|
|
await deleteFile(uploaded.id);
|
|
throw new Error('Create gallery failed');
|
|
}
|
|
} catch (error) {
|
|
toast.error('Gagal membuat gallery');
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 3. API - Old File Dihapus Sebelum Update Confirmed
|
|
|
|
**File:** `src/app/api/[[...slugs]]/_lib/desa/gallery/foto/updt.ts`
|
|
|
|
```typescript
|
|
// Line 47-58
|
|
if (existing.imagesId && existing.imagesId !== body.imagesId) {
|
|
const oldImage = existing.imageGalleryFoto;
|
|
if (oldImage) {
|
|
const filePath = path.join(oldImage.path, oldImage.name);
|
|
await fs.unlink(filePath); // ❌ File dihapus DULU
|
|
await prisma.fileStorage.delete({
|
|
where: { id: oldImage.id },
|
|
});
|
|
}
|
|
}
|
|
|
|
// Baru update data
|
|
const updated = await prisma.galleryFoto.update({
|
|
where: { id },
|
|
data: { ... }
|
|
});
|
|
```
|
|
|
|
**Dampak:**
|
|
- Jika `prisma.galleryFoto.update()` gagal, **old file sudah terhapus**
|
|
- **DATA LOSS** - Gallery tidak punya image sama sekali
|
|
- Tidak ada rollback mechanism
|
|
|
|
**Severity:** 🔴 **HIGH** - Data loss risk
|
|
|
|
**Solusi:**
|
|
```typescript
|
|
// Update data DULU, baru hapus old file
|
|
const updated = await prisma.galleryFoto.update({
|
|
where: { id },
|
|
data: {
|
|
name: body.name,
|
|
deskripsi: body.deskripsi,
|
|
imagesId: body.imagesId,
|
|
},
|
|
include: { imageGalleryFoto: true }
|
|
});
|
|
|
|
// Hapus old file SETELAH update berhasil
|
|
if (existing.imagesId && existing.imagesId !== body.imagesId) {
|
|
const oldImage = existing.imageGalleryFoto;
|
|
if (oldImage) {
|
|
try {
|
|
const filePath = path.join(oldImage.path, oldImage.name);
|
|
await fs.unlink(filePath);
|
|
await prisma.fileStorage.delete({
|
|
where: { id: oldImage.id },
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to delete old file:', error);
|
|
// Log error tapi tidak rollback karena update sudah berhasil
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 4. API - Tidak Ada Authentication/Authorization
|
|
|
|
**File:** Semua API endpoints di `/src/app/api/[[...slugs]]/_lib/desa/gallery/`
|
|
|
|
```typescript
|
|
export default async function fotoCreate(context: Context) {
|
|
// ❌ Tidak ada validasi session/user
|
|
const body = await context.body;
|
|
|
|
// Langsung proses create
|
|
await prisma.galleryFoto.create({ ... });
|
|
}
|
|
```
|
|
|
|
**Dampak:**
|
|
- **Siapa saja bisa upload/delete foto/video** jika tahu endpoint
|
|
- Tidak ada audit trail siapa yang upload/delete
|
|
- Security risk untuk production
|
|
|
|
**Severity:** 🔴 **HIGH** - Security vulnerability
|
|
|
|
**Solusi:**
|
|
```typescript
|
|
import { getSession } from '@/lib/auth';
|
|
|
|
export default async function fotoCreate(context: Context) {
|
|
const session = await getSession();
|
|
|
|
if (!session || !session.user) {
|
|
return Response.json({
|
|
success: false,
|
|
message: "Unauthorized"
|
|
}, { status: 401 });
|
|
}
|
|
|
|
// Check role/permission jika perlu
|
|
if (!session.user.menuIds?.includes('gallery')) {
|
|
return Response.json({
|
|
success: false,
|
|
message: "Forbidden"
|
|
}, { status: 403 });
|
|
}
|
|
|
|
const body = await context.body;
|
|
// ... lanjut proses
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 5. API - Tidak Ada Input Validation
|
|
|
|
**File:** `src/app/api/[[...slugs]]/_lib/desa/gallery/foto/create.ts`
|
|
|
|
```typescript
|
|
// Line 13-23
|
|
await prisma.galleryFoto.create({
|
|
data: {
|
|
name: body.name, // ❌ Tidak ada validasi length
|
|
deskripsi: body.deskripsi, // ❌ Tidak ada sanitasi XSS
|
|
imagesId: body.imagesId, // ❌ Tidak cek apakah FileStorage ada
|
|
},
|
|
});
|
|
```
|
|
|
|
**Dampak:**
|
|
- User bisa input name sangat panjang (bisa break UI/database)
|
|
- XSS attack via `deskripsi` field (rich text editor)
|
|
- Bisa create gallery dengan `imagesId` yang tidak valid
|
|
|
|
**Severity:** 🔴 **HIGH** - Security & data integrity
|
|
|
|
**Solusi:**
|
|
```typescript
|
|
// Validasi input
|
|
const { name, deskripsi, imagesId } = await context.body;
|
|
|
|
// Check length
|
|
if (!name || name.length > 255) {
|
|
return Response.json({
|
|
success: false,
|
|
message: "Name maksimal 255 karakter"
|
|
}, { status: 400 });
|
|
}
|
|
|
|
// Check duplikasi
|
|
const existing = await prisma.galleryFoto.findFirst({
|
|
where: { name, isActive: true }
|
|
});
|
|
|
|
if (existing) {
|
|
return Response.json({
|
|
success: false,
|
|
message: "Name sudah digunakan"
|
|
}, { status: 400 });
|
|
}
|
|
|
|
// Check fileStorage exists
|
|
const fileStorage = await prisma.fileStorage.findUnique({
|
|
where: { id: imagesId }
|
|
});
|
|
|
|
if (!fileStorage) {
|
|
return Response.json({
|
|
success: false,
|
|
message: "File tidak ditemukan"
|
|
}, { status: 404 });
|
|
}
|
|
|
|
// Sanitize HTML (gunakan library seperti DOMPurify di server)
|
|
const sanitizedDeskripsi = sanitizeHtml(deskripsi);
|
|
|
|
// Create
|
|
const gallery = await prisma.galleryFoto.create({
|
|
data: {
|
|
name,
|
|
deskripsi: sanitizedDeskripsi,
|
|
imagesId,
|
|
}
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 🟡 MEDIUM PRIORITY ISSUES
|
|
|
|
### 6. UI - Dead Code (youtubeEmbed.tsx Tidak Digunakan)
|
|
|
|
**File:** `src/app/admin/(dashboard)/desa/gallery/lib/youtubeEmbed.tsx`
|
|
|
|
```typescript
|
|
// Component ini TIDAK digunakan di mana pun
|
|
export function YoutubeEmbed({ url, ... }: Props) {
|
|
// ... component code
|
|
}
|
|
```
|
|
|
|
**Dampak:**
|
|
- Dead code menumpuk (120+ baris tidak digunakan)
|
|
- Confusing untuk developer baru
|
|
- Maintenance overhead
|
|
|
|
**Severity:** 🟡 **MEDIUM** - Code quality issue
|
|
|
|
**Solusi:**
|
|
- **Option A:** Hapus file ini jika memang tidak diperlukan
|
|
- **Option B:** Gunakan component ini di semua halaman (create, edit, detail) untuk konsistensi
|
|
|
|
**Recommendation:** Hapus, karena setiap halaman sudah implementasi iframe manual dengan cara berbeda.
|
|
|
|
---
|
|
|
|
### 7. UI - Inconsistent Styling Foto vs Video
|
|
|
|
**File:** `foto/page.tsx` vs `video/page.tsx`
|
|
|
|
```typescript
|
|
// foto/page.tsx - Line 58
|
|
<Box p={{ base: 'md', md: 'lg' }}> // ✅ Responsive padding
|
|
|
|
// video/page.tsx - Line 60
|
|
<Box py={20}> // ❌ Hardcoded padding
|
|
```
|
|
|
|
**Dampak:** Inconsistent spacing antara foto dan video pages.
|
|
|
|
**Severity:** 🟡 **MEDIUM** - UX inconsistency
|
|
|
|
**Solusi:**
|
|
```typescript
|
|
// video/page.tsx
|
|
<Box p={{ base: 'md', md: 'lg' }}> // ✅ Konsisten dengan foto
|
|
```
|
|
|
|
---
|
|
|
|
### 8. UI - Memory Leak Potential (createObjectURL)
|
|
|
|
**File:** `src/app/admin/(dashboard)/desa/gallery/foto/create/page.tsx`
|
|
|
|
```typescript
|
|
// Line 59-62
|
|
useEffect(() => {
|
|
if (file) {
|
|
const url = URL.createObjectURL(file);
|
|
setPreviewImage(url);
|
|
}
|
|
}, [file]);
|
|
|
|
// Line 47-52
|
|
const resetForm = () => {
|
|
FotoState.create.form = { name: '', deskripsi: '', imagesId: '' };
|
|
setPreviewImage(null);
|
|
setFile(null);
|
|
// ❌ URL.revokeObjectURL() tidak dipanggil
|
|
};
|
|
```
|
|
|
|
**Dampak:**
|
|
- Memory leak jika user upload banyak gambar tanpa refresh
|
|
- Browser bisa crash setelah banyak createObjectURL tidak di-cleanup
|
|
|
|
**Severity:** 🟡 **MEDIUM** - Performance issue
|
|
|
|
**Solusi:**
|
|
```typescript
|
|
// Cleanup saat unmount atau file berubah
|
|
useEffect(() => {
|
|
if (file) {
|
|
const url = URL.createObjectURL(file);
|
|
setPreviewImage(url);
|
|
|
|
return () => {
|
|
URL.revokeObjectURL(url); // ✅ Cleanup
|
|
};
|
|
}
|
|
}, [file]);
|
|
|
|
// Cleanup saat reset
|
|
const resetForm = () => {
|
|
if (previewImage) {
|
|
URL.revokeObjectURL(previewImage); // ✅ Cleanup
|
|
}
|
|
FotoState.create.form = { name: '', deskripsi: '', imagesId: '' };
|
|
setPreviewImage(null);
|
|
setFile(null);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
### 9. State - Error Handling Tidak Konsisten
|
|
|
|
**File:** `src/app/admin/(dashboard)/_state/desa/gallery.ts`
|
|
|
|
```typescript
|
|
// Line 39-53 (foto.create)
|
|
async create() {
|
|
try {
|
|
const res = await ApiFetch.api.desa.gallery.foto["create"].post(foto.create.form);
|
|
if (res.status === 200) {
|
|
foto.findMany.load();
|
|
return toast.success("Foto berhasil disimpan!");
|
|
}
|
|
return toast.error("Gagal menyimpan foto");
|
|
} catch (error) {
|
|
console.log((error as Error).message);
|
|
// ❌ Error di-catch tapi tidak ada toast error notification
|
|
}
|
|
}
|
|
|
|
// Line 91-107 (foto.findUnique)
|
|
async load(id: string) {
|
|
try {
|
|
const res = await fetch(`/api/desa/gallery/foto/${id}`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
foto.findUnique.data = data.data ?? null;
|
|
}
|
|
} catch (error) {
|
|
console.error("Error fetching foto:", error);
|
|
// ❌ Tidak ada error toast notification
|
|
}
|
|
}
|
|
|
|
// Line 205-227 (foto.findRecent)
|
|
async load() {
|
|
try {
|
|
// ...
|
|
} catch (error) {
|
|
console.error("Gagal fetch foto recent:", error);
|
|
// ❌ Tidak ada error toast notification
|
|
}
|
|
}
|
|
```
|
|
|
|
**Dampak:**
|
|
- User tidak tahu ada error (silent failure)
|
|
- UX buruk (loading forever tanpa feedback)
|
|
- Sulit debug production issues
|
|
|
|
**Severity:** 🟡 **MEDIUM** - UX issue
|
|
|
|
**Solusi:**
|
|
```typescript
|
|
async create() {
|
|
try {
|
|
const res = await ApiFetch.api.desa.gallery.foto["create"].post(foto.create.form);
|
|
if (res.status === 200) {
|
|
foto.findMany.load();
|
|
return toast.success("Foto berhasil disimpan!");
|
|
}
|
|
return toast.error("Gagal menyimpan foto");
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
console.error('Create foto failed:', errorMessage);
|
|
toast.error(`Gagal menyimpan foto: ${errorMessage}`); // ✅ Show error
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 10. State - `findMany.load()` Dipanggil Tanpa Parameter
|
|
|
|
**File:** `src/app/admin/(dashboard)/_state/desa/gallery.ts`
|
|
|
|
```typescript
|
|
// Line 47 (foto.create)
|
|
async create() {
|
|
// ...
|
|
if (res.status === 200) {
|
|
foto.findMany.load(); // ❌ Tanpa parameter (default page=1, limit=10)
|
|
return toast.success("Foto berhasil disimpan!");
|
|
}
|
|
}
|
|
|
|
// Line 119 (foto.delete)
|
|
async byId(id: string) {
|
|
// ...
|
|
if (response.ok) {
|
|
toast.success(result.message || "Foto berhasil dihapus");
|
|
await foto.findMany.load(); // ❌ Tanpa parameter
|
|
}
|
|
}
|
|
```
|
|
|
|
**Dampak:**
|
|
- Jika user di page 5, setelah create/delete refresh ke page 1
|
|
- User bingung kenapa data hilang (padahal masih ada, cuma page berubah)
|
|
|
|
**Severity:** 🟡 **MEDIUM** - UX issue
|
|
|
|
**Solusi:**
|
|
```typescript
|
|
// Simpan current pagination state
|
|
let currentPage = 1;
|
|
let currentLimit = 10;
|
|
let currentSearch = '';
|
|
|
|
// Set parameter saat load
|
|
async load(page = 1, limit = 10, search = '') {
|
|
currentPage = page;
|
|
currentLimit = limit;
|
|
currentSearch = search;
|
|
// ... load data
|
|
}
|
|
|
|
// Gunakan current state saat refresh
|
|
async create() {
|
|
// ...
|
|
if (res.status === 200) {
|
|
await foto.findMany.load(currentPage, currentLimit, currentSearch); // ✅ Pass current params
|
|
toast.success("Foto berhasil disimpan!");
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 11. API - Video Search Tidak Include `deskripsi`
|
|
|
|
**File:** `src/app/api/[[...slugs]]/_lib/desa/gallery/video/find-many.ts`
|
|
|
|
```typescript
|
|
// Line 18-21
|
|
if (search) {
|
|
where.OR = [
|
|
{ name: { contains: search, mode: 'insensitive' } }
|
|
// ❌ deskripsi tidak di-include
|
|
];
|
|
}
|
|
```
|
|
|
|
**Bandingkan dengan Foto:**
|
|
```typescript
|
|
// foto/find-many.ts - Line 20-26
|
|
if (search) {
|
|
where.OR = [
|
|
{ name: { contains: search, mode: 'insensitive' } },
|
|
{ deskripsi: { contains: search, mode: 'insensitive' } } // ✅ Include deskripsi
|
|
];
|
|
}
|
|
```
|
|
|
|
**Dampak:**
|
|
- User tidak bisa search video berdasarkan deskripsi
|
|
- Inconsistent behavior antara foto dan video
|
|
|
|
**Severity:** 🟡 **MEDIUM** - Feature inconsistency
|
|
|
|
**Solusi:**
|
|
```typescript
|
|
if (search) {
|
|
where.OR = [
|
|
{ name: { contains: search, mode: 'insensitive' } },
|
|
{ deskripsi: { contains: search, mode: 'insensitive' } } // ✅ Add deskripsi
|
|
];
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 12. UI - Skeleton Height Terlalu Besar
|
|
|
|
**File:** `src/app/admin/(dashboard)/desa/gallery/video/page.tsx`
|
|
|
|
```typescript
|
|
// Line 73-77
|
|
if (loading || !data) {
|
|
return (
|
|
<Stack py={20}>
|
|
<Skeleton height={600} radius="md" /> // ❌ Terlalu besar
|
|
</Stack>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Dampak:** Skeleton mengambil hampir seluruh layar, UX buruk.
|
|
|
|
**Severity:** 🟡 **MEDIUM** - UX issue
|
|
|
|
**Solusi:**
|
|
```typescript
|
|
<Skeleton height={200} radius="md" /> // ✅ Lebih reasonable
|
|
```
|
|
|
|
---
|
|
|
|
### 13. UI - Duplicate `convertToEmbedUrl` Function
|
|
|
|
**File:** `src/app/admin/(dashboard)/desa/gallery/video/[id]/page.tsx`
|
|
|
|
```typescript
|
|
// Line 106-118
|
|
function convertToEmbedUrl(youtubeUrl: string): string {
|
|
try {
|
|
const url = new URL(youtubeUrl);
|
|
const videoId = url.searchParams.get("v");
|
|
if (!videoId) return youtubeUrl;
|
|
return `https://www.youtube.com/embed/${videoId}`;
|
|
} catch (err) {
|
|
return youtubeUrl;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Padahal sudah ada di:** `lib/youtube-utils.ts`
|
|
```typescript
|
|
export function convertYoutubeUrlToEmbed(url: string) {
|
|
const videoIdMatch = url.match(
|
|
/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/
|
|
);
|
|
return videoIdMatch ? `https://www.youtube.com/embed/${videoIdMatch[1]}` : null;
|
|
}
|
|
```
|
|
|
|
**Dampak:**
|
|
- Duplicate code (violation DRY principle)
|
|
- Logic berbeda (page.tsx hanya support watch URL, utils.ts support multiple formats)
|
|
- Maintenance overhead
|
|
|
|
**Severity:** 🟡 **MEDIUM** - Code quality issue
|
|
|
|
**Solusi:**
|
|
```typescript
|
|
// Import dari utils
|
|
import { convertYoutubeUrlToEmbed } from '../../lib/youtube-utils';
|
|
|
|
// Gunakan function yang sudah ada
|
|
const embedLink = convertYoutubeUrlToEmbed(data.linkVideo);
|
|
```
|
|
|
|
---
|
|
|
|
### 14. Utils - YouTube Shorts URL Tidak Disupport
|
|
|
|
**File:** `src/app/admin/(dashboard)/desa/gallery/lib/youtube-utils.ts`
|
|
|
|
```typescript
|
|
export function convertYoutubeUrlToEmbed(url: string) {
|
|
const videoIdMatch = url.match(
|
|
/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/
|
|
);
|
|
// ❌ Regex tidak support youtube.com/shorts/VIDEO_ID
|
|
return videoIdMatch ? `https://www.youtube.com/embed/${videoIdMatch[1]}` : null;
|
|
}
|
|
```
|
|
|
|
**Dampak:** User tidak bisa input YouTube Shorts URL (format populer).
|
|
|
|
**Severity:** 🟡 **MEDIUM** - Feature gap
|
|
|
|
**Solusi:**
|
|
```typescript
|
|
export function convertYoutubeUrlToEmbed(url: string) {
|
|
const videoIdMatch = url.match(
|
|
/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/
|
|
);
|
|
// ✅ Added shorts\/ support
|
|
return videoIdMatch ? `https://www.youtube.com/embed/${videoIdMatch[1]}` : null;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 🟢 LOW PRIORITY ISSUES
|
|
|
|
### 15. UI - Redundant Variable (`filteredData`)
|
|
|
|
**File:** `src/app/admin/(dashboard)/desa/gallery/foto/page.tsx`
|
|
|
|
```typescript
|
|
// Line 78-79
|
|
const filteredData = data || [];
|
|
// ❌ Variable ini redundant, data sudah difilter di backend
|
|
```
|
|
|
|
**Dampak:** Minor code clutter.
|
|
|
|
**Severity:** 🟢 **LOW** - Code cleanliness
|
|
|
|
**Solusi:** Hapus variable, gunakan langsung `data || []`.
|
|
|
|
---
|
|
|
|
### 16. UI - useEffect Redundant di layoutTabs.tsx
|
|
|
|
**File:** `src/app/admin/(dashboard)/desa/gallery/lib/layoutTabs.tsx`
|
|
|
|
```typescript
|
|
// Line 35-40
|
|
useEffect(() => {
|
|
const match = tabs.find(tab => tab.href === pathname)
|
|
if (match) {
|
|
setActiveTab(match.value)
|
|
}
|
|
}, [pathname])
|
|
// ❌ Redundant karena sudah ada logic serupa di handleTabChange
|
|
```
|
|
|
|
**Dampak:** Minor performance overhead.
|
|
|
|
**Severity:** 🟢 **LOW** - Code quality
|
|
|
|
**Solusi:** Hapus useEffect jika tidak diperlukan.
|
|
|
|
---
|
|
|
|
### 17. State - `findRecent` Tidak Digunakan di UI
|
|
|
|
**File:** `src/app/admin/(dashboard)/_state/desa/gallery.ts`
|
|
|
|
```typescript
|
|
// Line 205-227
|
|
foto: {
|
|
findRecent: {
|
|
loading: false,
|
|
data: [] as any[],
|
|
async load() {
|
|
// ... fetch recent photos
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Dampak:** Dead code di state management.
|
|
|
|
**Severity:** 🟢 **LOW** - Code cleanliness
|
|
|
|
**Solusi:**
|
|
- Option A: Hapus jika memang tidak diperlukan
|
|
- Option B: Implementasi di UI (misal: widget "Recent Photos" di dashboard)
|
|
|
|
---
|
|
|
|
### 18. State - Mix State Mutation dan Return Value
|
|
|
|
**File:** `src/app/admin/(dashboard)/_state/desa/gallery.ts`
|
|
|
|
```typescript
|
|
// Line 138-203 (foto.update)
|
|
async load(id: string) {
|
|
// ... fetch GET
|
|
if (result?.success) {
|
|
const data = result.data;
|
|
this.id = data.id;
|
|
this.form = { ... };
|
|
return data; // ❌ Mix mutation + return value (confusing API)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Dampak:** Confusing API, tidak jelas apakah caller harus gunakan return value atau akses state langsung.
|
|
|
|
**Severity:** 🟢 **LOW** - Code quality
|
|
|
|
**Solusi:**
|
|
```typescript
|
|
// Option A: Hanya mutation (recommended)
|
|
async load(id: string) {
|
|
// ... fetch GET
|
|
if (result?.success) {
|
|
const data = result.data;
|
|
this.id = data.id;
|
|
this.form = { ... };
|
|
// No return value
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
await foto.update.load(id);
|
|
const formData = foto.update.form; // Akses dari state
|
|
|
|
// Option B: Hanya return value
|
|
async load(id: string) {
|
|
// ... fetch GET
|
|
if (result?.success) {
|
|
return result.data;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Usage
|
|
const data = await foto.update.load(id);
|
|
```
|
|
|
|
---
|
|
|
|
## ✅ YANG SUDAH BAIK
|
|
|
|
### **Schema:**
|
|
- ✅ Relasi GalleryFoto ke FileStorage sudah benar
|
|
- ✅ Kedua model memiliki soft delete fields (`deletedAt`, `isActive`)
|
|
- ✅ Audit trail dengan `createdAt` dan `updatedAt`
|
|
|
|
### **API:**
|
|
- ✅ CRUD lengkap untuk Foto dan Video
|
|
- ✅ Pagination support dengan `page`, `limit`
|
|
- ✅ Search functionality (foto: name + deskripsi, video: name only)
|
|
- ✅ Soft delete di-support via `isActive` flag di find-many
|
|
- ✅ File cleanup saat delete foto (hapus filesystem + database)
|
|
- ✅ Error handling ada di semua endpoints
|
|
- ✅ Response format konsisten: `{ success, message, data }`
|
|
|
|
### **UI/UX:**
|
|
- ✅ Responsive design (desktop table + mobile cards)
|
|
- ✅ Loading states dan skeleton
|
|
- ✅ Toast notifications untuk feedback
|
|
- ✅ Form validation (name, deskripsi, image required)
|
|
- ✅ Dropzone untuk upload gambar dengan preview
|
|
- ✅ File size limit (5MB) dan format validation
|
|
- ✅ Rich text editor untuk deskripsi
|
|
- ✅ YouTube URL conversion dengan embed preview
|
|
- ✅ Search dengan debounce (1000ms)
|
|
- ✅ Modal konfirmasi hapus
|
|
- ✅ Empty state message
|
|
- ✅ Reset form functionality
|
|
|
|
### **State Management:**
|
|
- ✅ Valtio proxy untuk global state
|
|
- ✅ Separate state untuk foto dan video
|
|
- ✅ CRUD operations lengkap
|
|
- ✅ Form validation dengan Zod
|
|
- ✅ Pagination state management
|
|
- ✅ Loading states
|
|
|
|
### **Utilities:**
|
|
- ✅ YouTube URL conversion support multiple formats (watch, embed, youtu.be)
|
|
- ✅ Reusable component pattern (youtubeEmbed.tsx - meski tidak digunakan)
|
|
|
|
---
|
|
|
|
## 📊 Metrics
|
|
|
|
| Aspek | Score | Keterangan |
|
|
|-------|-------|------------|
|
|
| **Schema Design** | 6/10 | Good structure, tapi ada critical bug di deletedAt |
|
|
| **API Design** | 6/10 | RESTful, tapi tidak ada auth & validation |
|
|
| **API Security** | 4/10 | Tidak ada authentication, XSS risk |
|
|
| **UI/UX** | 7.5/10 | Responsive, comprehensive features |
|
|
| **State Management** | 6.5/10 | Valtio works well, inconsistency di error handling |
|
|
| **Code Quality** | 6/10 | Dead code, duplicate code, memory leak potential |
|
|
|
|
**Overall Score: 6/10** - **Needs Improvement**
|
|
|
|
---
|
|
|
|
## 🎯 Action Plan
|
|
|
|
### Week 1 (Critical Fixes) 🔴
|
|
|
|
- [ ] **URGENT:** Fix `deletedAt @default(now())` di schema
|
|
- [ ] **URGENT:** Fix file orphaning saat create gagal
|
|
- [ ] **URGENT:** Fix old file delete sebelum update confirmed
|
|
- [ ] **URGENT:** Tambahkan authentication di semua API endpoints
|
|
- [ ] **URGENT:** Tambahkan input validation di API
|
|
|
|
### Week 2 (High Priority) 🟡
|
|
|
|
- [ ] Tambahkan rollback mechanism untuk operasi file
|
|
- [ ] Fix error handling konsisten (semua catch show toast)
|
|
- [ ] Fix `findMany.load()` pass current pagination params
|
|
- [ ] Tambahkan video search include deskripsi
|
|
- [ ] Fix memory leak (createObjectURL cleanup)
|
|
|
|
### Week 3 (Polish) 🟢
|
|
|
|
- [ ] Hapus dead code (youtubeEmbed.tsx, findRecent)
|
|
- [ ] Konsistensi styling foto vs video pages
|
|
- [ ] Hapus duplicate convertToEmbedUrl function
|
|
- [ ] Tambahkan support YouTube Shorts URL
|
|
- [ ] Fix skeleton height
|
|
- [ ] Fix redundant useEffect di layoutTabs
|
|
|
|
---
|
|
|
|
## 📝 Technical Notes
|
|
|
|
### **Database Migration:**
|
|
|
|
Fix deletedAt default:
|
|
```bash
|
|
# Generate migration
|
|
bunx prisma migrate dev --name fix_gallery_deleted_at
|
|
|
|
# Atau jika tidak pakai migrate
|
|
bunx prisma db push
|
|
|
|
# Data cleanup
|
|
UPDATE "GalleryFoto" SET "deletedAt" = NULL WHERE "isActive" = true;
|
|
UPDATE "GalleryVideo" SET "deletedAt" = NULL WHERE "isActive" = true;
|
|
```
|
|
|
|
### **File Orphan Prevention:**
|
|
|
|
Implementasi transaction pattern di API:
|
|
```typescript
|
|
// Di API create.ts
|
|
export default async function fotoCreate(context: Context) {
|
|
try {
|
|
// Validate fileStorage exists
|
|
const fileStorage = await prisma.fileStorage.findUnique({
|
|
where: { id: body.imagesId }
|
|
});
|
|
|
|
if (!fileStorage) {
|
|
return Response.json({
|
|
success: false,
|
|
message: "File tidak ditemukan"
|
|
}, { status: 404 });
|
|
}
|
|
|
|
// Create gallery dengan transaction
|
|
const gallery = await prisma.galleryFoto.create({
|
|
data: {
|
|
name: body.name,
|
|
deskripsi: body.deskripsi,
|
|
imagesId: body.imagesId,
|
|
}
|
|
});
|
|
|
|
return { success: true, data: gallery };
|
|
} catch (error) {
|
|
// Rollback file jika create gagal
|
|
if (body.imagesId) {
|
|
await prisma.fileStorage.delete({
|
|
where: { id: body.imagesId }
|
|
}).catch(() => {});
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
```
|
|
|
|
### **Memory Leak Prevention:**
|
|
|
|
```typescript
|
|
// Cleanup createObjectURL
|
|
useEffect(() => {
|
|
if (file) {
|
|
const url = URL.createObjectURL(file);
|
|
setPreviewImage(url);
|
|
|
|
return () => {
|
|
URL.revokeObjectURL(url); // ✅ Cleanup
|
|
};
|
|
}
|
|
}, [file]);
|
|
|
|
// Cleanup saat reset
|
|
const resetForm = () => {
|
|
if (previewImage) {
|
|
URL.revokeObjectURL(previewImage);
|
|
}
|
|
// ...
|
|
};
|
|
```
|
|
|
|
### **YouTube Shorts Support:**
|
|
|
|
```typescript
|
|
// youtube-utils.ts
|
|
export function convertYoutubeUrlToEmbed(url: string) {
|
|
const videoIdMatch = url.match(
|
|
/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/
|
|
);
|
|
return videoIdMatch
|
|
? `https://www.youtube.com/embed/${videoIdMatch[1]}`
|
|
: null;
|
|
}
|
|
|
|
// Test cases
|
|
convertYoutubeUrlToEmbed('https://youtube.com/watch?v=VIDEO_ID'); // ✅
|
|
convertYoutubeUrlToEmbed('https://youtu.be/VIDEO_ID'); // ✅
|
|
convertYoutubeUrlToEmbed('https://youtube.com/embed/VIDEO_ID'); // ✅
|
|
convertYoutubeUrlToEmbed('https://youtube.com/shorts/VIDEO_ID'); // ✅ NEW
|
|
```
|
|
|
|
---
|
|
|
|
## 📚 References
|
|
|
|
- [Prisma Transactions](https://www.prisma.io/docs/concepts/components/prisma-client/transactions)
|
|
- [Prisma Soft Delete Pattern](https://www.prisma.io/docs/concepts/components/prisma-client/soft-delete)
|
|
- [URL.createObjectURL() Memory Management](https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL#memory_management)
|
|
- [Mantine Skeleton Documentation](https://mantine.dev/core/skeleton/)
|
|
- [React Toastify Documentation](https://fkhadra.github.io/react-toastify/)
|
|
|
|
---
|
|
|
|
## 📈 Comparison dengan QC Sebelumnya
|
|
|
|
| Aspek | Profil Desa | Potensi Desa | Berita Desa | Pengumuman | **Gallery** |
|
|
|-------|-------------|--------------|-------------|------------|-------------|
|
|
| Schema | 6/10 | 7/10 | 8/10 | 7/10 | **6/10** |
|
|
| API Security | 4/10 | 6/10 | 6/10 | 6/10 | **4/10** |
|
|
| API Design | 7/10 | 8/10 | 7.5/10 | 7/10 | **6/10** |
|
|
| UI/UX | 8/10 | 8.5/10 | 8/10 | 7.5/10 | **7.5/10** |
|
|
| State Mgmt | 7/10 | 8/10 | 8/10 | 7/10 | **6.5/10** |
|
|
| Code Quality | 7/10 | 7.5/10 | 7/10 | 6.5/10 | **6/10** |
|
|
| **Overall** | **6.5/10** | **7.5/10** | **7/10** | **6.5/10** | **6/10** |
|
|
|
|
**Gallery** memiliki score **terendah** karena:
|
|
- ❌ Critical bug `deletedAt @default(now())` (sama seperti Profil & Pengumuman)
|
|
- ❌ File orphaning issue (data integrity)
|
|
- ❌ Old file dihapus sebelum update confirmed (data loss risk)
|
|
- ❌ Tidak ada authentication di API
|
|
- ❌ Dead code (youtubeEmbed.tsx tidak digunakan)
|
|
- ❌ Memory leak potential
|
|
- ❌ Duplicate code (convertToEmbedUrl)
|
|
|
|
Tapi Gallery punya **UI/UX yang bagus** (7.5/10) dengan:
|
|
- ✅ Upload gambar dengan dropzone & preview
|
|
- ✅ YouTube embed conversion
|
|
- ✅ Rich text editor
|
|
- ✅ Responsive design
|
|
- ✅ Comprehensive validation
|
|
|
|
---
|
|
|
|
**Dibuat oleh:** QC Automation
|
|
**Review Status:** ⏳ Menunggu Review Developer
|
|
**Next Review:** Setelah implementasi fixes
|