diff --git a/api/auth.ts b/api/auth.ts index dba9aff..efcce2f 100644 --- a/api/auth.ts +++ b/api/auth.ts @@ -112,31 +112,62 @@ export async function seedLocalUserIfMissing() { } // Check existence without selecting `password_hash` to avoid failures on older schemas try { - const [rows] = await db.execute(sql`SELECT id FROM users WHERE unionId = ${LocalAuth.defaultAdminEmail} LIMIT 1`); - if (Array.isArray(rows) && rows.length > 0) return; + await db.execute(sql`SELECT id FROM users WHERE unionId = ${LocalAuth.defaultAdminEmail} LIMIT 1`); } catch (err) { console.warn('[seed] existence check failed, will attempt to insert anyway', err); } - // Try to insert using the typed Drizzle insert (preferred). If that fails because the - // `password_hash` column is missing, fall back to a raw INSERT that omits the column. + // 1. Ensure admin account try { - await db.insert(users).values({ - unionId: LocalAuth.defaultAdminEmail, - name: "Admin Lokal", - email: LocalAuth.defaultAdminEmail, - passwordHash: hashPassword(LocalAuth.defaultAdminPassword), - role: "admin", - lastSignInAt: new Date(), - }); + const [existingAdmin] = await db.execute(sql`SELECT id FROM users WHERE unionId = ${LocalAuth.defaultAdminEmail} LIMIT 1`); + if (!existingAdmin || (Array.isArray(existingAdmin) && existingAdmin.length === 0)) { + await db.insert(users).values({ + unionId: LocalAuth.defaultAdminEmail, + name: "Admin Lokal", + email: LocalAuth.defaultAdminEmail, + passwordHash: hashPassword(LocalAuth.defaultAdminPassword), + role: "admin", + lastSignInAt: new Date(), + }); + console.log(`[seed] Created admin account: ${LocalAuth.defaultAdminEmail}`); + } } catch (err) { - console.warn('[seed] typed insert failed, attempting fallback insert without password_hash', err); + console.warn('[seed] Admin seeding failed, attempting fallback', err); try { - await db.execute(sql`INSERT INTO users (unionId, name, email, role, lastSignInAt) VALUES ( + await db.execute(sql`INSERT IGNORE INTO users (unionId, name, email, role, lastSignInAt) VALUES ( ${LocalAuth.defaultAdminEmail}, ${'Admin Lokal'}, ${LocalAuth.defaultAdminEmail}, ${'admin'}, ${new Date()} )`); } catch (err2) { - console.error('[seed] fallback insert also failed', err2); + console.error('[seed] Admin fallback failed', err2); + } + } + + // 2. Seed default roles for testing + const defaultAccounts = [ + { email: "officer@example.com", name: "Petugas Lapangan", role: "officer" as const }, + { email: "manager@example.com", name: "Pengelola Wilayah", role: "manager" as const }, + ]; + + for (const acc of defaultAccounts) { + try { + const res = await db.execute(sql`SELECT id FROM users WHERE unionId = ${acc.email} LIMIT 1`); + const [existing] = res as any; + + if (existing && (!Array.isArray(existing) || existing.length > 0)) { + continue; + } + + await db.insert(users).values({ + unionId: acc.email, + name: acc.name, + email: acc.email, + passwordHash: hashPassword("password123"), + role: acc.role, + lastSignInAt: new Date(), + }); + console.log(`[seed] Created ${acc.role} account: ${acc.email}`); + } catch (err) { + console.warn(`[seed] Failed to create ${acc.role} (${acc.email})`, err); } } } \ No newline at end of file diff --git a/api/boot.ts b/api/boot.ts index 76b59b4..d748031 100644 --- a/api/boot.ts +++ b/api/boot.ts @@ -24,7 +24,7 @@ app.all("/api/*", (c) => c.json({ error: "Not Found" }, 404)); export default app; -if (env.isProduction) { +if (env.isProduction && typeof process !== "undefined" && process.versions?.node) { const { serve } = await import("@hono/node-server"); const { serveStaticFiles } = await import("./lib/vite"); serveStaticFiles(app); diff --git a/api/distributionRouter.ts b/api/distributionRouter.ts index 7eb2e6a..885dce0 100644 --- a/api/distributionRouter.ts +++ b/api/distributionRouter.ts @@ -1,24 +1,54 @@ import { z } from "zod"; -import { createRouter, publicQuery } from "./middleware"; +import { createRouter, authedQuery } from "./middleware"; import { findAllDistributions, + findDistributionById, createDistribution, deleteDistribution, } from "./queries/distributions"; +import { findRecipientById } from "./queries/recipients"; +import { createActivityLog } from "./queries/activityLogs"; +import { findPlaceIdsByManagerId } from "./queries/placesOfWorship"; +import { TRPCError } from "@trpc/server"; + +async function logActivitySafe(payload: Parameters[0]) { + try { + await createActivityLog(payload); + } catch (error) { + console.warn("[activity] failed to write distribution activity", error); + } +} export const distributionRouter = createRouter({ - list: publicQuery + list: authedQuery .input( z.object({ placeOfWorshipId: z.number().optional(), recipientId: z.number().optional(), }).optional() ) - .query(async ({ input }) => { - return findAllDistributions(input?.placeOfWorshipId, input?.recipientId); + .query(async ({ input, ctx }) => { + if (ctx.user.role === "manager") { + const placeIds = await findPlaceIdsByManagerId(ctx.user.id); + if (placeIds.length === 0) return []; + if (input?.placeOfWorshipId) { + if (!placeIds.includes(input.placeOfWorshipId)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Anda tidak berwenang mengakses data distribusi ini.", + }); + } + return findAllDistributions([input.placeOfWorshipId], input?.recipientId); + } + return findAllDistributions(placeIds, input?.recipientId); + } + return findAllDistributions( + input?.placeOfWorshipId ? [input.placeOfWorshipId] : undefined, + input?.recipientId + ); }), - create: publicQuery + create: authedQuery .input( z.object({ placeOfWorshipId: z.number().min(1), @@ -29,21 +59,74 @@ export const distributionRouter = createRouter({ quantity: z.number().min(1).default(1), unit: z.string().default("package"), notes: z.string().optional(), + handoverPhoto: z.string().min(1), }) ) - .mutation(async ({ input }) => { - return createDistribution({ + .mutation(async ({ input, ctx }) => { + if (ctx.user.role === "manager") { + const placeIds = await findPlaceIdsByManagerId(ctx.user.id); + if (!placeIds.includes(input.placeOfWorshipId)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Anda hanya berwenang mencatat distribusi bantuan untuk rumah ibadah yang Anda kelola.", + }); + } + } + + const created = await createDistribution({ ...input, amount: input.amount.toString(), distributedBy: input.distributedBy ?? null, notes: input.notes ?? null, + handoverPhoto: input.handoverPhoto, }); + + if (created) { + const recipient = await findRecipientById(created.recipientId); + await logActivitySafe({ + userId: ctx.user.id, + action: "CREATE", + entityType: "distribution", + entityId: created.id, + details: recipient + ? `Distribusi ${created.aidType} ke ${recipient.name}` + : `Distribusi ${created.aidType}`, + }); + } + + return created; }), - delete: publicQuery + delete: authedQuery .input(z.object({ id: z.number() })) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { + const target = await findDistributionById(input.id); + if (!target) { + throw new TRPCError({ code: "NOT_FOUND", message: "Data tidak ditemukan." }); + } + + if (ctx.user.role === "manager") { + const placeIds = await findPlaceIdsByManagerId(ctx.user.id); + if (!placeIds.includes(target.placeOfWorshipId)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Anda tidak berwenang menghapus data distribusi ini.", + }); + } + } + await deleteDistribution(input.id); + + if (target) { + await logActivitySafe({ + userId: ctx.user.id, + action: "DELETE", + entityType: "distribution", + entityId: target.id, + details: `Menghapus distribusi ${target.aidType}`, + }); + } + return { success: true }; }), }); diff --git a/api/placeOfWorshipRouter.ts b/api/placeOfWorshipRouter.ts index 36d547c..edd7c61 100644 --- a/api/placeOfWorshipRouter.ts +++ b/api/placeOfWorshipRouter.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { createRouter, publicQuery } from "./middleware"; +import { createRouter, publicQuery, adminQuery } from "./middleware"; import { findAllPlaces, findPlaceById, @@ -7,6 +7,15 @@ import { updatePlace, deletePlace, } from "./queries/placesOfWorship"; +import { createActivityLog } from "./queries/activityLogs"; + +async function logActivitySafe(payload: Parameters[0]) { + try { + await createActivityLog(payload); + } catch (error) { + console.warn("[activity] failed to write place activity", error); + } +} export const placeOfWorshipRouter = createRouter({ list: publicQuery @@ -17,8 +26,9 @@ export const placeOfWorshipRouter = createRouter({ active: z.string().optional(), }).optional() ) - .query(async ({ input }) => { - return findAllPlaces(input?.search, input?.type, input?.active); + .query(async ({ input, ctx }) => { + const managerId = ctx.user?.role === "manager" ? ctx.user.id : undefined; + return findAllPlaces(input?.search, input?.type, input?.active, managerId); }), byId: publicQuery @@ -27,7 +37,7 @@ export const placeOfWorshipRouter = createRouter({ return findPlaceById(input.id); }), - create: publicQuery + create: adminQuery .input( z.object({ name: z.string().min(1), @@ -43,15 +53,27 @@ export const placeOfWorshipRouter = createRouter({ }) ) .mutation(async ({ input }) => { - return createPlace({ + const created = await createPlace({ ...input, latitude: input.latitude.toString(), longitude: input.longitude.toString(), managerId: input.managerId ?? null, }); + + if (created) { + await logActivitySafe({ + userId: input.managerId ?? null, + action: "CREATE", + entityType: "place_of_worship", + entityId: created.id, + details: `Menambahkan ${created.name}`, + }); + } + + return created; }), - update: publicQuery + update: adminQuery .input( z.object({ id: z.number(), @@ -70,17 +92,42 @@ export const placeOfWorshipRouter = createRouter({ ) .mutation(async ({ input }) => { const { id, ...data } = input; + const beforeUpdate = await findPlaceById(id); const updateData: Record = { ...data }; if (data.latitude !== undefined) updateData.latitude = data.latitude.toString(); if (data.longitude !== undefined) updateData.longitude = data.longitude.toString(); if (data.managerId === undefined) delete updateData.managerId; - return updatePlace(id, updateData); + const updated = await updatePlace(id, updateData); + + if (updated) { + await logActivitySafe({ + userId: data.managerId ?? beforeUpdate?.managerId ?? null, + action: "UPDATE", + entityType: "place_of_worship", + entityId: updated.id, + details: `Memperbarui rumah ibadah ${updated.name}`, + }); + } + + return updated; }), - delete: publicQuery + delete: adminQuery .input(z.object({ id: z.number() })) .mutation(async ({ input }) => { + const target = await findPlaceById(input.id); await deletePlace(input.id); + + if (target) { + await logActivitySafe({ + userId: target.managerId ?? null, + action: "DELETE", + entityType: "place_of_worship", + entityId: target.id, + details: `Menghapus rumah ibadah ${target.name}`, + }); + } + return { success: true }; }), }); diff --git a/api/queries/distributions.ts b/api/queries/distributions.ts index dd61635..00519f4 100644 --- a/api/queries/distributions.ts +++ b/api/queries/distributions.ts @@ -1,12 +1,22 @@ -import { eq, and, desc, count, sql } from "drizzle-orm"; +import { eq, and, desc, count, sql, inArray } from "drizzle-orm"; import { getDb } from "./connection"; import { distributions } from "@db/schema"; import type { InsertDistribution } from "@db/schema"; -export async function findAllDistributions(placeOfWorshipId?: number, recipientId?: number) { +export async function findAllDistributions(placeOfWorshipIds?: number | number[], recipientId?: number) { const db = getDb(); const conditions = []; - if (placeOfWorshipId) conditions.push(eq(distributions.placeOfWorshipId, placeOfWorshipId)); + if (placeOfWorshipIds !== undefined) { + if (Array.isArray(placeOfWorshipIds)) { + if (placeOfWorshipIds.length > 0) { + conditions.push(inArray(distributions.placeOfWorshipId, placeOfWorshipIds)); + } else { + conditions.push(sql`1 = 0`); + } + } else { + conditions.push(eq(distributions.placeOfWorshipId, placeOfWorshipIds)); + } + } if (recipientId) conditions.push(eq(distributions.recipientId, recipientId)); const where = conditions.length > 0 ? and(...conditions) : undefined; return db.select().from(distributions).where(where).orderBy(desc(distributions.distributionDate)); diff --git a/api/queries/placesOfWorship.ts b/api/queries/placesOfWorship.ts index 8beae60..96495c2 100644 --- a/api/queries/placesOfWorship.ts +++ b/api/queries/placesOfWorship.ts @@ -3,7 +3,7 @@ import { getDb } from "./connection"; import { placesOfWorship } from "@db/schema"; import type { InsertPlaceOfWorship } from "@db/schema"; -export async function findAllPlaces(search?: string, type?: string, active?: string) { +export async function findAllPlaces(search?: string, type?: string, active?: string, managerId?: number) { const db = getDb(); const conditions = []; if (search) { @@ -15,6 +15,9 @@ export async function findAllPlaces(search?: string, type?: string, active?: str if (active && active !== "all") { conditions.push(eq(placesOfWorship.isActive, active as "yes" | "no")); } + if (managerId !== undefined) { + conditions.push(eq(placesOfWorship.managerId, managerId)); + } const where = conditions.length > 0 ? and(...conditions) : undefined; return db.select().from(placesOfWorship).where(where).orderBy(desc(placesOfWorship.createdAt)); } @@ -25,6 +28,21 @@ export async function findPlaceById(id: number) { return rows.at(0) ?? null; } +export async function findPlaceByManagerId(managerId: number) { + const db = getDb(); + const rows = await db.select().from(placesOfWorship).where(eq(placesOfWorship.managerId, managerId)).limit(1); + return rows.at(0) ?? null; +} + +export async function findPlaceIdsByManagerId(managerId: number) { + const db = getDb(); + const rows = await db + .select({ id: placesOfWorship.id }) + .from(placesOfWorship) + .where(eq(placesOfWorship.managerId, managerId)); + return rows.map((r) => r.id); +} + export async function createPlace(data: InsertPlaceOfWorship) { const db = getDb(); const [{ id }] = await db.insert(placesOfWorship).values(data).$returningId(); diff --git a/api/queries/recipients.ts b/api/queries/recipients.ts index 29ecba0..b592bae 100644 --- a/api/queries/recipients.ts +++ b/api/queries/recipients.ts @@ -1,4 +1,4 @@ -import { eq, like, and, count, desc } from "drizzle-orm"; +import { eq, like, and, count, desc, inArray, sql } from "drizzle-orm"; import { getDb } from "./connection"; import { recipients } from "@db/schema"; import type { InsertRecipient } from "@db/schema"; @@ -6,7 +6,7 @@ import type { InsertRecipient } from "@db/schema"; export async function findAllRecipients( search?: string, status?: string, - placeOfWorshipId?: number + placeOfWorshipIds?: number | number[] ) { const db = getDb(); const conditions = []; @@ -16,8 +16,16 @@ export async function findAllRecipients( if (status && status !== "all") { conditions.push(eq(recipients.status, status as "pending" | "active" | "rejected" | "suspended")); } - if (placeOfWorshipId) { - conditions.push(eq(recipients.placeOfWorshipId, placeOfWorshipId)); + if (placeOfWorshipIds !== undefined) { + if (Array.isArray(placeOfWorshipIds)) { + if (placeOfWorshipIds.length > 0) { + conditions.push(inArray(recipients.placeOfWorshipId, placeOfWorshipIds)); + } else { + conditions.push(sql`1 = 0`); + } + } else { + conditions.push(eq(recipients.placeOfWorshipId, placeOfWorshipIds)); + } } const where = conditions.length > 0 ? and(...conditions) : undefined; return db.select().from(recipients).where(where).orderBy(desc(recipients.createdAt)); @@ -29,11 +37,19 @@ export async function findRecipientById(id: number) { return rows.at(0) ?? null; } -export async function findPendingRecipients(placeOfWorshipId?: number) { +export async function findPendingRecipients(placeOfWorshipIds?: number | number[]) { const db = getDb(); const conditions = [eq(recipients.status, "pending")]; - if (placeOfWorshipId) { - conditions.push(eq(recipients.placeOfWorshipId, placeOfWorshipId)); + if (placeOfWorshipIds !== undefined) { + if (Array.isArray(placeOfWorshipIds)) { + if (placeOfWorshipIds.length > 0) { + conditions.push(inArray(recipients.placeOfWorshipId, placeOfWorshipIds)); + } else { + conditions.push(sql`1 = 0`); + } + } else { + conditions.push(eq(recipients.placeOfWorshipId, placeOfWorshipIds)); + } } return db.select().from(recipients) .where(and(...conditions)) diff --git a/api/queries/users.ts b/api/queries/users.ts index 9b73fb1..a8f8bf7 100644 --- a/api/queries/users.ts +++ b/api/queries/users.ts @@ -50,3 +50,39 @@ export async function upsertUser(data: InsertUser) { .values(values) .onDuplicateKeyUpdate({ set: updateSet }); } + +export async function findAllUsers() { + return getDb() + .select({ + id: schema.users.id, + unionId: schema.users.unionId, + name: schema.users.name, + email: schema.users.email, + role: schema.users.role, + avatar: schema.users.avatar, + createdAt: schema.users.createdAt, + updatedAt: schema.users.updatedAt, + lastSignInAt: schema.users.lastSignInAt, + }) + .from(schema.users) + .orderBy(schema.users.createdAt); +} + +export async function createUser(data: InsertUser) { + const [result] = await getDb().insert(schema.users).values(data); + return result; +} + +export async function updateUser(id: number, data: Partial) { + const result = await getDb() + .update(schema.users) + .set(data) + .where(eq(schema.users.id, id)); + return result; +} + +export async function deleteUser(id: number) { + await getDb() + .delete(schema.users) + .where(eq(schema.users.id, id)); +} diff --git a/api/recipientRouter.ts b/api/recipientRouter.ts index 30377b0..c84d9a7 100644 --- a/api/recipientRouter.ts +++ b/api/recipientRouter.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { createRouter, publicQuery } from "./middleware"; +import { createRouter, authedQuery } from "./middleware"; import { findAllRecipients, findRecipientById, @@ -9,9 +9,20 @@ import { verifyRecipient, deleteRecipient, } from "./queries/recipients"; +import { createActivityLog } from "./queries/activityLogs"; +import { findPlaceIdsByManagerId } from "./queries/placesOfWorship"; +import { TRPCError } from "@trpc/server"; + +async function logActivitySafe(payload: Parameters[0]) { + try { + await createActivityLog(payload); + } catch (error) { + console.warn("[activity] failed to write recipient activity", error); + } +} export const recipientRouter = createRouter({ - list: publicQuery + list: authedQuery .input( z.object({ search: z.string().optional(), @@ -19,23 +30,66 @@ export const recipientRouter = createRouter({ placeOfWorshipId: z.number().optional(), }).optional() ) - .query(async ({ input }) => { - return findAllRecipients(input?.search, input?.status, input?.placeOfWorshipId); + .query(async ({ input, ctx }) => { + if (ctx.user.role === "manager") { + const placeIds = await findPlaceIdsByManagerId(ctx.user.id); + if (placeIds.length === 0) return []; + if (input?.placeOfWorshipId) { + if (!placeIds.includes(input.placeOfWorshipId)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Anda tidak berwenang mengakses data rumah ibadah ini.", + }); + } + return findAllRecipients(input?.search, input?.status, [input.placeOfWorshipId]); + } + return findAllRecipients(input?.search, input?.status, placeIds); + } + return findAllRecipients( + input?.search, + input?.status, + input?.placeOfWorshipId ? [input.placeOfWorshipId] : undefined + ); }), - pending: publicQuery + pending: authedQuery .input(z.object({ placeOfWorshipId: z.number().optional() }).optional()) - .query(async ({ input }) => { - return findPendingRecipients(input?.placeOfWorshipId); + .query(async ({ input, ctx }) => { + if (ctx.user.role === "manager") { + const placeIds = await findPlaceIdsByManagerId(ctx.user.id); + if (placeIds.length === 0) return []; + if (input?.placeOfWorshipId) { + if (!placeIds.includes(input.placeOfWorshipId)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Anda tidak berwenang mengakses data rumah ibadah ini.", + }); + } + return findPendingRecipients([input.placeOfWorshipId]); + } + return findPendingRecipients(placeIds); + } + return findPendingRecipients(input?.placeOfWorshipId ? [input.placeOfWorshipId] : undefined); }), - byId: publicQuery + byId: authedQuery .input(z.object({ id: z.number() })) - .query(async ({ input }) => { - return findRecipientById(input.id); + .query(async ({ input, ctx }) => { + const recipient = await findRecipientById(input.id); + if (!recipient) return null; + if (ctx.user.role === "manager") { + const placeIds = await findPlaceIdsByManagerId(ctx.user.id); + if (!placeIds.includes(recipient.placeOfWorshipId)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Anda tidak berwenang mengakses data penerima ini.", + }); + } + } + return recipient; }), - create: publicQuery + create: authedQuery .input( z.object({ nik: z.string().length(16), @@ -51,10 +105,23 @@ export const recipientRouter = createRouter({ placeOfWorshipId: z.number().min(1), registeredBy: z.number().optional(), notes: z.string().optional(), + ktpDocument: z.string().min(1), + kkDocument: z.string().min(1), + sktmDocument: z.string().optional(), }) ) - .mutation(async ({ input }) => { - return createRecipient({ + .mutation(async ({ input, ctx }) => { + if (ctx.user.role === "manager") { + const placeIds = await findPlaceIdsByManagerId(ctx.user.id); + if (!placeIds.includes(input.placeOfWorshipId)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Anda hanya berwenang mendaftarkan penerima untuk rumah ibadah yang Anda kelola.", + }); + } + } + + const created = await createRecipient({ ...input, latitude: input.latitude?.toString() ?? null, longitude: input.longitude?.toString() ?? null, @@ -64,11 +131,26 @@ export const recipientRouter = createRouter({ phone: input.phone ?? null, registeredBy: input.registeredBy ?? null, notes: input.notes ?? null, + ktpDocument: input.ktpDocument, + kkDocument: input.kkDocument, + sktmDocument: input.sktmDocument ?? null, status: "pending", }); + + if (created) { + await logActivitySafe({ + userId: ctx.user.id, + action: "CREATE", + entityType: "recipient", + entityId: created.id, + details: `Mendaftarkan ${created.name}`, + }); + } + + return created; }), - update: publicQuery + update: authedQuery .input( z.object({ id: z.number(), @@ -84,17 +166,50 @@ export const recipientRouter = createRouter({ incomePerMonth: z.number().min(0).optional(), placeOfWorshipId: z.number().optional(), notes: z.string().optional(), + ktpDocument: z.string().optional(), + kkDocument: z.string().optional(), + sktmDocument: z.string().optional(), }) ) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { const { id, ...data } = input; + const beforeUpdate = await findRecipientById(id); + if (!beforeUpdate) { + throw new TRPCError({ code: "NOT_FOUND", message: "Penerima tidak ditemukan." }); + } + + if (ctx.user.role === "manager") { + const placeIds = await findPlaceIdsByManagerId(ctx.user.id); + if ( + !placeIds.includes(beforeUpdate.placeOfWorshipId) || + (input.placeOfWorshipId && !placeIds.includes(input.placeOfWorshipId)) + ) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Anda tidak berwenang mengubah data penerima ini.", + }); + } + } + const updateData: Record = { ...data }; if (data.latitude !== undefined) updateData.latitude = data.latitude.toString(); if (data.longitude !== undefined) updateData.longitude = data.longitude.toString(); - return updateRecipient(id, updateData); + const updated = await updateRecipient(id, updateData); + + if (updated) { + await logActivitySafe({ + userId: ctx.user.id, + action: "UPDATE", + entityType: "recipient", + entityId: updated.id, + details: `Memperbarui data ${updated.name}`, + }); + } + + return updated; }), - verify: publicQuery + verify: authedQuery .input( z.object({ id: z.number(), @@ -103,14 +218,56 @@ export const recipientRouter = createRouter({ rejectionReason: z.string().optional(), }) ) - .mutation(async ({ input }) => { - return verifyRecipient(input.id, input.status, input.verifiedBy, input.rejectionReason); + .mutation(async ({ input, ctx }) => { + // Verify is only allowed for Admin and Officer + if (ctx.user.role !== "admin" && ctx.user.role !== "officer") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Hanya petugas verifikasi atau administrator yang dapat memverifikasi kelayakan.", + }); + } + + const verified = await verifyRecipient(input.id, input.status, input.verifiedBy, input.rejectionReason); + if (verified) { + const statusText = input.status === "active" ? "Diterima" : "Ditolak"; + await logActivitySafe({ + userId: input.verifiedBy, + action: "VERIFY", + entityType: "recipient", + entityId: verified.id, + details: `Memverifikasi ${verified.name} - ${statusText}`, + }); + } + return verified; }), - delete: publicQuery + delete: authedQuery .input(z.object({ id: z.number() })) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { + const target = await findRecipientById(input.id); + if (!target) { + throw new TRPCError({ code: "NOT_FOUND", message: "Penerima tidak ditemukan." }); + } + + if (ctx.user.role === "manager") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Pengelola rumah ibadah tidak berwenang menghapus data penerima.", + }); + } + await deleteRecipient(input.id); + + if (target) { + await logActivitySafe({ + userId: ctx.user.id, + action: "DELETE", + entityType: "recipient", + entityId: target.id, + details: `Menghapus penerima ${target.name}`, + }); + } + return { success: true }; }), }); diff --git a/api/router.ts b/api/router.ts index 112a24d..bbb33e4 100644 --- a/api/router.ts +++ b/api/router.ts @@ -4,6 +4,7 @@ import { recipientRouter } from "./recipientRouter"; import { distributionRouter } from "./distributionRouter"; import { dashboardRouter } from "./dashboardRouter"; import { seedRouter } from "./seedRouter"; +import { userRouter } from "./userRouter"; import { createRouter, publicQuery } from "./middleware"; export const appRouter = createRouter({ @@ -14,6 +15,7 @@ export const appRouter = createRouter({ distribution: distributionRouter, dashboard: dashboardRouter, seed: seedRouter, + user: userRouter, }); export type AppRouter = typeof appRouter; diff --git a/api/userRouter.ts b/api/userRouter.ts new file mode 100644 index 0000000..b679c21 --- /dev/null +++ b/api/userRouter.ts @@ -0,0 +1,178 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { createRouter, adminQuery } from "./middleware"; +import { + findAllUsers, + createUser, + updateUser, + deleteUser, + findUserByLoginIdentifier, +} from "./queries/users"; +import { hashPassword } from "./auth"; +import { createActivityLog } from "./queries/activityLogs"; +import { getDb } from "./queries/connection"; +import * as schema from "@db/schema"; +import { inArray, eq } from "drizzle-orm"; + +async function logActivitySafe(payload: Parameters[0]) { + try { + await createActivityLog(payload); + } catch (error) { + console.warn("[activity] failed to write user activity", error); + } +} + +export const userRouter = createRouter({ + list: adminQuery.query(async () => { + return findAllUsers(); + }), + + create: adminQuery + .input( + z.object({ + name: z.string().min(1), + email: z.string().email(), + password: z.string().min(6), + role: z.enum(["admin", "officer", "manager"]), + placeIds: z.array(z.number()).optional(), + }) + ) + .mutation(async ({ input, ctx }) => { + const existing = await findUserByLoginIdentifier(input.email); + if (existing) { + throw new TRPCError({ + code: "CONFLICT", + message: "Email sudah terdaftar di sistem.", + }); + } + + const result = await createUser({ + unionId: input.email, + name: input.name, + email: input.email, + passwordHash: hashPassword(input.password), + role: input.role, + lastSignInAt: new Date(), + }); + + const insertedId = result.insertId; + + // Hubungkan rumah ibadah yang dipilih jika perannya adalah manager + if (input.role === "manager" && input.placeIds && input.placeIds.length > 0) { + const db = getDb(); + await db + .update(schema.placesOfWorship) + .set({ managerId: insertedId }) + .where(inArray(schema.placesOfWorship.id, input.placeIds)); + } + + await logActivitySafe({ + userId: ctx.user.id, + action: "CREATE", + entityType: "user", + entityId: insertedId, + details: `Membuat akun baru: ${input.name} (${input.role})`, + }); + + return { success: true, id: insertedId }; + }), + + update: adminQuery + .input( + z.object({ + id: z.number(), + name: z.string().min(1).optional(), + email: z.string().email().optional(), + password: z.string().min(6).optional(), + role: z.enum(["admin", "officer", "manager"]).optional(), + placeIds: z.array(z.number()).optional(), + }) + ) + .mutation(async ({ input, ctx }) => { + const updatedFields: Record = {}; + + if (input.name !== undefined) updatedFields.name = input.name; + if (input.email !== undefined) { + const existing = await findUserByLoginIdentifier(input.email); + if (existing && existing.id !== input.id) { + throw new TRPCError({ + code: "CONFLICT", + message: "Email sudah terdaftar untuk pengguna lain.", + }); + } + updatedFields.email = input.email; + updatedFields.unionId = input.email; + } + if (input.password !== undefined) { + updatedFields.passwordHash = hashPassword(input.password); + } + if (input.role !== undefined) updatedFields.role = input.role; + + await updateUser(input.id, updatedFields); + + // Kelola asosiasi Rumah Ibadah + const db = getDb(); + // Cari role saat ini untuk mengecek apakah bertipe manager + const userRec = await db + .select({ role: schema.users.role }) + .from(schema.users) + .where(eq(schema.users.id, input.id)) + .limit(1); + const userRole = userRec[0]?.role; + + if (userRole === "manager") { + // Hapus penugasan manager dari semua rumah ibadah yang sebelumnya dikelola user ini + await db + .update(schema.placesOfWorship) + .set({ managerId: null }) + .where(eq(schema.placesOfWorship.managerId, input.id)); + + // Hubungkan ke kumpulan rumah ibadah baru jika ada + if (input.placeIds && input.placeIds.length > 0) { + await db + .update(schema.placesOfWorship) + .set({ managerId: input.id }) + .where(inArray(schema.placesOfWorship.id, input.placeIds)); + } + } else { + // Jika peran berubah dari manager ke peran lain, hapus semua relasi kelolanya + await db + .update(schema.placesOfWorship) + .set({ managerId: null }) + .where(eq(schema.placesOfWorship.managerId, input.id)); + } + + await logActivitySafe({ + userId: ctx.user.id, + action: "UPDATE", + entityType: "user", + entityId: input.id, + details: `Memperbarui akun pengguna ID ${input.id}`, + }); + + return { success: true }; + }), + + delete: adminQuery + .input(z.object({ id: z.number() })) + .mutation(async ({ input, ctx }) => { + if (input.id === ctx.user.id) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Anda tidak dapat menghapus akun Anda sendiri.", + }); + } + + await deleteUser(input.id); + + await logActivitySafe({ + userId: ctx.user.id, + action: "DELETE", + entityType: "user", + entityId: input.id, + details: `Menghapus akun pengguna ID ${input.id}`, + }); + + return { success: true }; + }), +}); diff --git a/db/schema.ts b/db/schema.ts index b38c39b..ac68888 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -4,6 +4,7 @@ import { serial, varchar, text, + longtext, timestamp, bigint, int, @@ -78,6 +79,9 @@ export const recipients = mysqlTable( verifiedBy: bigint("verified_by", { mode: "number", unsigned: true }), verifiedAt: timestamp("verified_at"), notes: text("notes"), + ktpDocument: longtext("ktp_document"), + kkDocument: longtext("kk_document"), + sktmDocument: longtext("sktm_document"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull().$onUpdate(() => new Date()), }, @@ -104,6 +108,7 @@ export const distributions = mysqlTable( quantity: int("quantity").default(1), unit: varchar("unit", { length: 50 }).default("package"), distributionDate: timestamp("distribution_date").defaultNow().notNull(), + handoverPhoto: longtext("handover_photo"), notes: text("notes"), createdAt: timestamp("created_at").defaultNow().notNull(), }, diff --git a/grep.exe.stackdump b/grep.exe.stackdump new file mode 100644 index 0000000..c5447d9 --- /dev/null +++ b/grep.exe.stackdump @@ -0,0 +1,15 @@ +Stack trace: +Frame Function Args +000FFFFBCE0 00210062F57 (00000000002, 00000000002, 00000000000, 000FFFFDE50) +00000000000 00210065045 (000FFFFC690, 00000000000, 000000001F4, 00000000000) +000FFFFC3F0 0021013A968 (00000000000, 00000000000, 00000000000, 00000000000) +00000000041 00210135F9B (00000000000, 00000000000, 00000000000, 00000000000) +000FFFFC810 002101363A5 (00000000000, 00800000000, 000000000B5, 00100437000) +000FFFFC810 0021021D538 (00000000000, 00800000030, 00000000000, 00000000000) +000FFFFC810 00100429F25 (000FFFFCC7B, 000FFFFC848, 00000000007, 00000000001) +000FFFFC880 00100404B11 (00000000009, 002101FEA90, 000FFFFCB80, 00000000000) +000FFFFCA70 001004294B0 (00000000006, 000FFFFCD30, 000FFFFCC50, 000FFFFCC50) +000FFFFCD30 00210049B91 (00000000000, 00000000000, 00000000000, 00000000000) +000FFFFFFF0 00210047716 (00000000000, 00000000000, 00000000000, 00000000000) +000FFFFFFF0 002100477C4 (00000000000, 00000000000, 00000000000, 00000000000) +End of stack trace diff --git a/index.html b/index.html index f59dff6..6eea4ea 100644 --- a/index.html +++ b/index.html @@ -1,13 +1,17 @@ - - - - WebGIS Poverty Mapping - - - -
- - - + + + + + WebGIS Poverty Mapping + + + + +
+ + + + \ No newline at end of file diff --git a/scratch/alter_db.ts b/scratch/alter_db.ts new file mode 100644 index 0000000..8f30d19 --- /dev/null +++ b/scratch/alter_db.ts @@ -0,0 +1,18 @@ +import { getDb } from "../api/queries/connection"; +import { sql } from "drizzle-orm"; + +async function main() { + const db = getDb(); + try { + console.log("Altering recipients table columns to LONGTEXT..."); + await db.execute(sql`ALTER TABLE recipients MODIFY COLUMN ktp_document LONGTEXT`); + await db.execute(sql`ALTER TABLE recipients MODIFY COLUMN kk_document LONGTEXT`); + await db.execute(sql`ALTER TABLE recipients MODIFY COLUMN sktm_document LONGTEXT`); + console.log("Database altered successfully!"); + } catch (err) { + console.error("ERROR ALTERING TABLE:", err); + } + process.exit(0); +} + +main().catch(console.error); diff --git a/scratch/check_users.ts b/scratch/check_users.ts new file mode 100644 index 0000000..c7b2ccf --- /dev/null +++ b/scratch/check_users.ts @@ -0,0 +1,16 @@ +import { sql } from "drizzle-orm"; +import { getDb } from "../api/queries/connection"; +import { users } from "../db/schema"; + +async function check() { + const db = getDb(); + const res = await db.execute(sql`SELECT id FROM users WHERE unionId = 'officer@example.com' LIMIT 1`); + console.log("Raw query result for officer:", res); + + const allUsers = await db.select().from(users); + console.log("Current Users in DB:"); + console.table(allUsers.map(u => ({ id: u.id, email: u.email, role: u.role }))); + process.exit(0); +} + +check().catch(console.error); diff --git a/scratch/force_seed.ts b/scratch/force_seed.ts new file mode 100644 index 0000000..f608604 --- /dev/null +++ b/scratch/force_seed.ts @@ -0,0 +1,10 @@ +import { seedLocalUserIfMissing } from "../api/auth"; + +async function forceSeed() { + console.log("Starting force seed..."); + await seedLocalUserIfMissing(); + console.log("Force seed completed."); + process.exit(0); +} + +forceSeed().catch(console.error); diff --git a/scratch/test_insert.ts b/scratch/test_insert.ts new file mode 100644 index 0000000..e70599f --- /dev/null +++ b/scratch/test_insert.ts @@ -0,0 +1,15 @@ +import { getDb } from "../api/queries/connection"; +import { sql } from "drizzle-orm"; + +async function main() { + const db = getDb(); + try { + const [result] = await db.execute(sql`DESCRIBE recipients`); + console.log("RECIPIENTS TABLE STRUCTURE:", result); + } catch (err) { + console.error("ERROR DESCRIBING TABLE:", err); + } + process.exit(0); +} + +main().catch(console.error); diff --git a/src/App.tsx b/src/App.tsx index d58724f..528a0ca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,7 +11,13 @@ import VerificationPage from "./pages/VerificationPage"; import UsersPage from "./pages/UsersPage"; import SettingsPage from "./pages/SettingsPage"; -function ProtectedRoute({ children, adminOnly }: { children: React.ReactNode; adminOnly?: boolean }) { +function ProtectedRoute({ + children, + allowedRoles, +}: { + children: React.ReactNode; + allowedRoles?: string[]; +}) { const { user, isLoading } = useAuth(); if (isLoading) { @@ -26,7 +32,7 @@ function ProtectedRoute({ children, adminOnly }: { children: React.ReactNode; ad return ; } - if (adminOnly && user.role !== "admin") { + if (allowedRoles && !allowedRoles.includes(user.role)) { return ; } @@ -72,7 +78,7 @@ export default function App() { + } @@ -80,7 +86,7 @@ export default function App() { + } @@ -88,7 +94,7 @@ export default function App() { + } diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 9fca0ee..9f460ca 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -33,9 +33,14 @@ const menuItems = [ { icon: Map, label: "Peta GIS", path: "/map" }, { icon: Users, label: "Penerima Bantuan", path: "/recipients" }, { icon: MapPinHouse, label: "Rumah Ibadah", path: "/places" }, - { icon: ClipboardCheck, label: "Verifikasi", path: "/verification" }, - { icon: Shield, label: "Pengguna", path: "/users", adminOnly: true }, - { icon: Settings, label: "Pengaturan", path: "/settings" }, + { + icon: ClipboardCheck, + label: "Verifikasi", + path: "/verification", + roles: ["admin", "officer"], + }, + { icon: Shield, label: "Pengguna", path: "/users", roles: ["admin"] }, + { icon: Settings, label: "Pengaturan", path: "/settings", roles: ["admin"] }, ]; export default function AppLayout({ children }: { children: ReactNode }) { @@ -46,11 +51,10 @@ export default function AppLayout({ children }: { children: ReactNode }) { const [collapsed, setCollapsed] = useState(false); const [mobileOpen, setMobileOpen] = useState(false); - const isAdmin = user?.role === "admin"; const activeItem = menuItems.find((item) => item.path === location.pathname); const filteredItems = menuItems.filter( - (item) => !item.adminOnly || isAdmin + (item) => !item.roles || (user?.role && item.roles.includes(user.role)) ); const sidebarWidth = collapsed ? 72 : 260; @@ -95,11 +99,6 @@ export default function AppLayout({ children }: { children: ReactNode }) { > {!collapsed && {item.label}} - {!collapsed && item.adminOnly && ( - - Admin - - )} ); })} diff --git a/src/components/DocumentUploadField.tsx b/src/components/DocumentUploadField.tsx new file mode 100644 index 0000000..a6d624a --- /dev/null +++ b/src/components/DocumentUploadField.tsx @@ -0,0 +1,140 @@ +import { useId, useMemo, type ChangeEvent } from "react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { FileUp, FileText, Image as ImageIcon, X } from "lucide-react"; +import { toast } from "sonner"; + +type DocumentUploadFieldProps = { + label: string; + value?: string | null; + onChange?: (value: string | null) => void; + required?: boolean; + helperText?: string; + accept?: string; + className?: string; +}; + +function readFileAsDataUrl(file: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result ?? "")); + reader.onerror = () => reject(reader.error ?? new Error("Gagal membaca file")); + reader.readAsDataURL(file); + }); +} + +function getPreviewKind(value?: string | null) { + if (!value) return "none"; + if (value.startsWith("data:application/pdf")) return "pdf"; + if (value.startsWith("data:image/")) return "image"; + return "file"; +} + +export function DocumentUploadField({ + label, + value, + onChange, + required = false, + helperText, + accept = "image/*,application/pdf", + className = "", +}: DocumentUploadFieldProps) { + const inputId = useId(); + const previewKind = useMemo(() => getPreviewKind(value), [value]); + + const handleSelectFile = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file || !onChange) return; + + const MAX_SIZE = 2 * 1024 * 1024; // 2MB + if (file.size > MAX_SIZE) { + toast.error("Pastikan gambar tidak lebih besar dari 2mb"); + return; + } + + const dataUrl = await readFileAsDataUrl(file); + onChange(dataUrl); + }; + + const canEdit = Boolean(onChange); + + return ( +
+
+ + {canEdit && value ? ( + + ) : null} +
+ {helperText ?

{helperText}

: null} + +