This commit is contained in:
2026-05-16 18:57:42 +07:00
commit 01c00093d5
128 changed files with 23193 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
import * as cookie from "cookie";
import { z } from "zod";
import { Session } from "@contracts/constants";
import { getSessionCookieOptions } from "./lib/cookies";
import { createRouter, authedQuery, publicQuery } from "./middleware";
import { loginWithCredentials, setSessionCookie } from "./auth";
export const authRouter = createRouter({
me: authedQuery.query((opts) => opts.ctx.user),
login: publicQuery
.input(
z.object({
identifier: z.string().min(1),
password: z.string().min(1),
}),
)
.mutation(async ({ input, ctx }) => {
const { user, token } = await loginWithCredentials(
input.identifier,
input.password,
);
setSessionCookie(ctx.resHeaders, token);
return { user };
}),
logout: authedQuery.mutation(async ({ ctx }) => {
const opts = getSessionCookieOptions(ctx.req.headers);
ctx.resHeaders.append(
"set-cookie",
cookie.serialize(Session.cookieName, "", {
httpOnly: opts.httpOnly,
path: opts.path,
sameSite: opts.sameSite?.toLowerCase() as "lax" | "none",
secure: opts.secure,
maxAge: 0,
}),
);
return { success: true };
}),
});
+142
View File
@@ -0,0 +1,142 @@
import crypto from "node:crypto";
import * as cookie from "cookie";
import { sql } from "drizzle-orm";
import { Errors } from "@contracts/errors";
import { LocalAuth, Session } from "@contracts/constants";
import { getSessionCookieOptions } from "./lib/cookies";
import { signSessionToken, verifySessionToken } from "./session";
import { findUserByLoginIdentifier, findUserByUnionId, touchUserLastSignIn } from "./queries/users";
import { getDb } from "./queries/connection";
import { users } from "@db/schema";
const SCRYPT_KEY_LENGTH = 64;
export function hashPassword(password: string) {
const salt = crypto.randomBytes(16).toString("hex");
const derived = crypto.scryptSync(password, salt, SCRYPT_KEY_LENGTH) as Buffer;
return `${salt}:${derived.toString("hex")}`;
}
export function verifyPassword(password: string, storedHash: string) {
const [salt, hash] = storedHash.split(":");
if (!salt || !hash) {
return false;
}
const derived = crypto.scryptSync(password, salt, SCRYPT_KEY_LENGTH) as Buffer;
const stored = Buffer.from(hash, "hex");
if (stored.length !== derived.length) {
return false;
}
return crypto.timingSafeEqual(stored, derived);
}
export async function authenticateRequest(headers: Headers) {
const cookies = cookie.parse(headers.get("cookie") || "");
const token = cookies[Session.cookieName];
if (!token) {
console.warn("[auth] No session cookie found in request.");
throw Errors.forbidden("Invalid authentication token.");
}
const claim = await verifySessionToken(token);
if (!claim) {
throw Errors.forbidden("Invalid authentication token.");
}
const user = await findUserByUnionId(claim.unionId);
if (!user) {
throw Errors.forbidden("User not found. Please re-login.");
}
return user;
}
export async function loginWithCredentials(
identifier: string,
password: string,
) {
const user = await findUserByLoginIdentifier(identifier);
if (!user || !user.passwordHash) {
throw Errors.forbidden("Invalid username or password.");
}
if (!verifyPassword(password, user.passwordHash)) {
throw Errors.forbidden("Invalid username or password.");
}
await touchUserLastSignIn(user.unionId);
const token = await signSessionToken({
unionId: user.unionId,
clientId: "local",
});
return { user, token };
}
export function setSessionCookie(headers: Headers, token: string) {
const cookieOpts = getSessionCookieOptions(headers);
headers.append(
"set-cookie",
cookie.serialize(Session.cookieName, token, {
httpOnly: cookieOpts.httpOnly,
path: cookieOpts.path,
sameSite: cookieOpts.sameSite?.toLowerCase() as "lax" | "none",
secure: cookieOpts.secure,
maxAge: Session.maxAgeMs / 1000,
}),
);
}
export async function seedLocalUserIfMissing() {
const db = getDb();
// Ensure `password_hash` column exists (older DBs may lack it)
try {
const [colCheck] = await db.execute(sql`
SELECT COUNT(*) as cnt FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'users' AND column_name = 'password_hash'
`);
// colCheck can be an array of rows depending on driver
let count = 0;
if (Array.isArray(colCheck) && colCheck.length > 0) {
const first = colCheck[0];
count = Number(Object.values(first)[0]);
}
if (count === 0) {
await db.execute(sql`ALTER TABLE users ADD COLUMN password_hash TEXT`);
}
} catch (err) {
// If checking or altering fails, log and continue — seed will still try insert without password if necessary
console.warn('[seed] failed to ensure password_hash column exists', err);
}
// 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;
} 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.
try {
await db.insert(users).values({
unionId: LocalAuth.defaultAdminEmail,
name: "Admin Lokal",
email: LocalAuth.defaultAdminEmail,
passwordHash: hashPassword(LocalAuth.defaultAdminPassword),
role: "admin",
lastSignInAt: new Date(),
});
} catch (err) {
console.warn('[seed] typed insert failed, attempting fallback insert without password_hash', err);
try {
await db.execute(sql`INSERT 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);
}
}
}
+36
View File
@@ -0,0 +1,36 @@
import { Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
import type { HttpBindings } from "@hono/node-server";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "./router";
import { createContext } from "./context";
import { env } from "./lib/env";
import { seedLocalUserIfMissing } from "./auth";
const app = new Hono<{ Bindings: HttpBindings }>();
await seedLocalUserIfMissing();
app.use(bodyLimit({ maxSize: 50 * 1024 * 1024 }));
app.use("/api/trpc/*", async (c) => {
return fetchRequestHandler({
endpoint: "/api/trpc",
req: c.req.raw,
router: appRouter,
createContext,
});
});
app.all("/api/*", (c) => c.json({ error: "Not Found" }, 404));
export default app;
if (env.isProduction) {
const { serve } = await import("@hono/node-server");
const { serveStaticFiles } = await import("./lib/vite");
serveStaticFiles(app);
const port = parseInt(process.env.PORT || "3000");
serve({ fetch: app.fetch, port }, () => {
console.log(`Server running on http://localhost:${port}/`);
});
}
+21
View File
@@ -0,0 +1,21 @@
import type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
import type { User } from "@db/schema";
import { authenticateRequest } from "./auth";
export type TrpcContext = {
req: Request;
resHeaders: Headers;
user?: User;
};
export async function createContext(
opts: FetchCreateContextFnOptions,
): Promise<TrpcContext> {
const ctx: TrpcContext = { req: opts.req, resHeaders: opts.resHeaders };
try {
ctx.user = await authenticateRequest(opts.req.headers);
} catch {
// Authentication is optional here
}
return ctx;
}
+16
View File
@@ -0,0 +1,16 @@
import { createRouter, publicQuery } from "./middleware";
import { getDashboardStats } from "./queries/dashboard";
import { findRecentActivities } from "./queries/activityLogs";
import { z } from "zod";
export const dashboardRouter = createRouter({
stats: publicQuery.query(async () => {
return getDashboardStats();
}),
activities: publicQuery
.input(z.object({ limit: z.number().min(1).max(100).default(20) }).optional())
.query(async ({ input }) => {
return findRecentActivities(input?.limit ?? 20);
}),
});
+49
View File
@@ -0,0 +1,49 @@
import { z } from "zod";
import { createRouter, publicQuery } from "./middleware";
import {
findAllDistributions,
createDistribution,
deleteDistribution,
} from "./queries/distributions";
export const distributionRouter = createRouter({
list: publicQuery
.input(
z.object({
placeOfWorshipId: z.number().optional(),
recipientId: z.number().optional(),
}).optional()
)
.query(async ({ input }) => {
return findAllDistributions(input?.placeOfWorshipId, input?.recipientId);
}),
create: publicQuery
.input(
z.object({
placeOfWorshipId: z.number().min(1),
recipientId: z.number().min(1),
distributedBy: z.number().optional(),
aidType: z.string().min(1),
amount: z.number().min(0).default(0),
quantity: z.number().min(1).default(1),
unit: z.string().default("package"),
notes: z.string().optional(),
})
)
.mutation(async ({ input }) => {
return createDistribution({
...input,
amount: input.amount.toString(),
distributedBy: input.distributedBy ?? null,
notes: input.notes ?? null,
});
}),
delete: publicQuery
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
await deleteDistribution(input.id);
return { success: true };
}),
});
+17
View File
@@ -0,0 +1,17 @@
import type { CookieOptions } from "hono/utils/cookie";
function isLocalhost(headers: Headers): boolean {
const host = headers.get("host") || "";
return host.startsWith("localhost:") || host.startsWith("127.0.0.1:");
}
export function getSessionCookieOptions(headers: Headers): CookieOptions {
const localhost = isLocalhost(headers);
return {
httpOnly: true,
path: "/",
sameSite: localhost ? "Lax" : "None",
secure: !localhost,
};
}
+16
View File
@@ -0,0 +1,16 @@
import "dotenv/config";
function required(name: string): string {
const value = process.env[name];
if (!value && process.env.NODE_ENV === "production") {
throw new Error(`Missing required environment variable: ${name}`);
}
return value ?? "";
}
export const env = {
appSecret: required("APP_SECRET"),
isProduction: process.env.NODE_ENV === "production",
databaseUrl: required("DATABASE_URL"),
ownerUnionId: process.env.OWNER_UNION_ID ?? "",
};
+77
View File
@@ -0,0 +1,77 @@
interface RequestConfig extends RequestInit {
baseUrl?: string;
params?: Record<string, string | number>;
timeout?: number;
}
export class HttpClient {
private baseUrl: string;
private defaultHeaders: Record<string, string>;
constructor(baseURL: string, opts?: { headers?: Record<string, string> }) {
this.baseUrl = baseURL;
this.defaultHeaders = {
"Content-Type": "application/json",
...opts?.headers,
};
}
async request<T>(endpoint: string, config: RequestConfig = {}): Promise<T> {
const {
method = "GET",
params,
body,
headers,
timeout = 30000,
...rest
} = config;
const url = new URL(`${this.baseUrl}${endpoint}`);
if (params) {
Object.entries(params).forEach(([key, value]) =>
url.searchParams.append(key, value.toString()),
);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url.toString(), {
...rest,
method,
headers: { ...this.defaultHeaders, ...headers },
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorData = (await response
.json()
.catch(() => ({}))) as Record<string, string>;
throw new Error(errorData.message || `HTTP Error: ${response.status}`);
}
return (await response.json()) as T;
} catch (error: any) {
if (error.name === "AbortError") {
throw new Error("Request timeout");
}
throw error;
}
}
get<T>(
url: string,
params?: RequestConfig["params"],
config?: RequestConfig,
) {
return this.request<T>(url, { ...config, method: "GET", params });
}
post<T>(url: string, body?: any, config?: RequestConfig) {
return this.request<T>(url, { ...config, method: "POST", body });
}
}
+23
View File
@@ -0,0 +1,23 @@
import type { Hono } from "hono";
import type { HttpBindings } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import fs from "fs";
import path from "path";
type App = Hono<{ Bindings: HttpBindings }>;
export function serveStaticFiles(app: App) {
const distPath = path.resolve(import.meta.dirname, "../dist/public");
app.use("*", serveStatic({ root: "./dist/public" }));
app.notFound((c) => {
const accept = c.req.header("accept") ?? "";
if (!accept.includes("text/html")) {
return c.json({ error: "Not Found" }, 404);
}
const indexPath = path.resolve(distPath, "index.html");
const content = fs.readFileSync(indexPath, "utf-8");
return c.html(content);
});
}
+42
View File
@@ -0,0 +1,42 @@
import { ErrorMessages } from "@contracts/constants";
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import type { TrpcContext } from "./context";
const t = initTRPC.context<TrpcContext>().create({
transformer: superjson,
});
export const createRouter = t.router;
export const publicQuery = t.procedure;
const requireAuth = t.middleware(async (opts) => {
const { ctx, next } = opts;
if (!ctx.user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: ErrorMessages.unauthenticated,
});
}
return next({ ctx: { ...ctx, user: ctx.user } });
});
function requireRole(role: string) {
return t.middleware(async (opts) => {
const { ctx, next } = opts;
if (!ctx.user || ctx.user.role !== role) {
throw new TRPCError({
code: "FORBIDDEN",
message: ErrorMessages.insufficientRole,
});
}
return next({ ctx: { ...ctx, user: ctx.user } });
});
}
export const authedQuery = t.procedure.use(requireAuth);
export const adminQuery = authedQuery.use(requireRole("admin"));
+86
View File
@@ -0,0 +1,86 @@
import { z } from "zod";
import { createRouter, publicQuery } from "./middleware";
import {
findAllPlaces,
findPlaceById,
createPlace,
updatePlace,
deletePlace,
} from "./queries/placesOfWorship";
export const placeOfWorshipRouter = createRouter({
list: publicQuery
.input(
z.object({
search: z.string().optional(),
type: z.string().optional(),
active: z.string().optional(),
}).optional()
)
.query(async ({ input }) => {
return findAllPlaces(input?.search, input?.type, input?.active);
}),
byId: publicQuery
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
return findPlaceById(input.id);
}),
create: publicQuery
.input(
z.object({
name: z.string().min(1),
type: z.enum(["mosque", "church", "temple", "vihara", "other"]),
address: z.string().optional(),
latitude: z.number().min(-90).max(90),
longitude: z.number().min(-180).max(180),
radius: z.number().min(100).max(10000).default(1000),
capacity: z.number().min(1).max(10000).default(100),
contactName: z.string().optional(),
contactPhone: z.string().optional(),
managerId: z.number().optional(),
})
)
.mutation(async ({ input }) => {
return createPlace({
...input,
latitude: input.latitude.toString(),
longitude: input.longitude.toString(),
managerId: input.managerId ?? null,
});
}),
update: publicQuery
.input(
z.object({
id: z.number(),
name: z.string().min(1).optional(),
type: z.enum(["mosque", "church", "temple", "vihara", "other"]).optional(),
address: z.string().optional(),
latitude: z.number().min(-90).max(90).optional(),
longitude: z.number().min(-180).max(180).optional(),
radius: z.number().min(100).max(10000).optional(),
capacity: z.number().min(1).max(10000).optional(),
contactName: z.string().optional(),
contactPhone: z.string().optional(),
managerId: z.number().optional(),
isActive: z.enum(["yes", "no"]).optional(),
})
)
.mutation(async ({ input }) => {
const { id, ...data } = input;
const updateData: Record<string, unknown> = { ...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);
}),
delete: publicQuery
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
await deletePlace(input.id);
return { success: true };
}),
});
+22
View File
@@ -0,0 +1,22 @@
import { desc, count } from "drizzle-orm";
import { getDb } from "./connection";
import { activityLogs } from "@db/schema";
import type { InsertActivityLog } from "@db/schema";
export async function findRecentActivities(limit: number = 20) {
const db = getDb();
return db.select().from(activityLogs)
.orderBy(desc(activityLogs.createdAt))
.limit(limit);
}
export async function createActivityLog(data: InsertActivityLog) {
const db = getDb();
await db.insert(activityLogs).values(data);
}
export async function countActivities() {
const db = getDb();
const result = await db.select({ count: count() }).from(activityLogs);
return result[0]?.count ?? 0;
}
+18
View File
@@ -0,0 +1,18 @@
import { drizzle } from "drizzle-orm/mysql2";
import { env } from "../lib/env";
import * as schema from "@db/schema";
import * as relations from "@db/relations";
const fullSchema = { ...schema, ...relations };
let instance: ReturnType<typeof drizzle<typeof fullSchema>>;
export function getDb() {
if (!instance) {
instance = drizzle(env.databaseUrl, {
mode: "planetscale",
schema: fullSchema,
});
}
return instance;
}
+54
View File
@@ -0,0 +1,54 @@
import { sql, eq } from "drizzle-orm";
import { getDb } from "./connection";
import { recipients, placesOfWorship, distributions } from "@db/schema";
export async function getDashboardStats() {
const db = getDb();
const totalRecipients = await db.select({ count: sql<number>`COUNT(*)` }).from(recipients);
const activeRecipients = await db.select({ count: sql<number>`COUNT(*)` })
.from(recipients)
.where(eq(recipients.status, "active"));
const pendingRecipients = await db.select({ count: sql<number>`COUNT(*)` })
.from(recipients)
.where(eq(recipients.status, "pending"));
const totalPlaces = await db.select({ count: sql<number>`COUNT(*)` }).from(placesOfWorship);
const activePlaces = await db.select({ count: sql<number>`COUNT(*)` })
.from(placesOfWorship)
.where(eq(placesOfWorship.isActive, "yes"));
const totalDistributions = await db.select({ count: sql<number>`COUNT(*)` }).from(distributions);
const totalAidAmount = await db.select({ total: sql<number>`COALESCE(SUM(amount), 0)` })
.from(distributions);
const byType = await db.select({
type: placesOfWorship.type,
count: sql<number>`COUNT(*)`,
}).from(placesOfWorship).groupBy(placesOfWorship.type);
const byStatus = await db.select({
status: recipients.status,
count: sql<number>`COUNT(*)`,
}).from(recipients).groupBy(recipients.status);
const monthlyDist = await db.select({
month: sql<string>`DATE_FORMAT(distribution_date, '%Y-%m')`,
count: sql<number>`COUNT(*)`,
amount: sql<number>`COALESCE(SUM(amount), 0)`,
}).from(distributions)
.groupBy(sql`DATE_FORMAT(distribution_date, '%Y-%m')`)
.orderBy(sql`DATE_FORMAT(distribution_date, '%Y-%m')`)
.limit(12);
return {
totalRecipients: totalRecipients[0]?.count ?? 0,
activeRecipients: activeRecipients[0]?.count ?? 0,
pendingRecipients: pendingRecipients[0]?.count ?? 0,
totalPlaces: totalPlaces[0]?.count ?? 0,
activePlaces: activePlaces[0]?.count ?? 0,
totalDistributions: totalDistributions[0]?.count ?? 0,
totalAidAmount: Number(totalAidAmount[0]?.total ?? 0),
byType,
byStatus,
monthlyDist,
};
}
+43
View File
@@ -0,0 +1,43 @@
import { eq, and, desc, count, sql } 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) {
const db = getDb();
const conditions = [];
if (placeOfWorshipId) conditions.push(eq(distributions.placeOfWorshipId, placeOfWorshipId));
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));
}
export async function findDistributionById(id: number) {
const db = getDb();
const rows = await db.select().from(distributions).where(eq(distributions.id, id)).limit(1);
return rows.at(0) ?? null;
}
export async function createDistribution(data: InsertDistribution) {
const db = getDb();
const [{ id }] = await db.insert(distributions).values(data).$returningId();
return findDistributionById(id);
}
export async function deleteDistribution(id: number) {
const db = getDb();
await db.delete(distributions).where(eq(distributions.id, id));
}
export async function countDistributions() {
const db = getDb();
const result = await db.select({ count: count() }).from(distributions);
return result[0]?.count ?? 0;
}
export async function sumDistributionAmount() {
const db = getDb();
const result = await db.select({ total: sql<number>`COALESCE(SUM(${distributions.amount}), 0)` })
.from(distributions);
return result[0]?.total ?? 0;
}
+57
View File
@@ -0,0 +1,57 @@
import { eq, like, and, count, desc } from "drizzle-orm";
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) {
const db = getDb();
const conditions = [];
if (search) {
conditions.push(like(placesOfWorship.name, `%${search}%`));
}
if (type && type !== "all") {
conditions.push(eq(placesOfWorship.type, type as "mosque" | "church" | "temple" | "vihara" | "other"));
}
if (active && active !== "all") {
conditions.push(eq(placesOfWorship.isActive, active as "yes" | "no"));
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
return db.select().from(placesOfWorship).where(where).orderBy(desc(placesOfWorship.createdAt));
}
export async function findPlaceById(id: number) {
const db = getDb();
const rows = await db.select().from(placesOfWorship).where(eq(placesOfWorship.id, id)).limit(1);
return rows.at(0) ?? null;
}
export async function createPlace(data: InsertPlaceOfWorship) {
const db = getDb();
const [{ id }] = await db.insert(placesOfWorship).values(data).$returningId();
return findPlaceById(id);
}
export async function updatePlace(id: number, data: Partial<InsertPlaceOfWorship>) {
const db = getDb();
await db.update(placesOfWorship).set(data).where(eq(placesOfWorship.id, id));
return findPlaceById(id);
}
export async function deletePlace(id: number) {
const db = getDb();
await db.delete(placesOfWorship).where(eq(placesOfWorship.id, id));
}
export async function countPlaces() {
const db = getDb();
const result = await db.select({ count: count() }).from(placesOfWorship);
return result[0]?.count ?? 0;
}
export async function countActivePlaces() {
const db = getDb();
const result = await db.select({ count: count() })
.from(placesOfWorship)
.where(eq(placesOfWorship.isActive, "yes"));
return result[0]?.count ?? 0;
}
+90
View File
@@ -0,0 +1,90 @@
import { eq, like, and, count, desc } from "drizzle-orm";
import { getDb } from "./connection";
import { recipients } from "@db/schema";
import type { InsertRecipient } from "@db/schema";
export async function findAllRecipients(
search?: string,
status?: string,
placeOfWorshipId?: number
) {
const db = getDb();
const conditions = [];
if (search) {
conditions.push(like(recipients.name, `%${search}%`));
}
if (status && status !== "all") {
conditions.push(eq(recipients.status, status as "pending" | "active" | "rejected" | "suspended"));
}
if (placeOfWorshipId) {
conditions.push(eq(recipients.placeOfWorshipId, placeOfWorshipId));
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
return db.select().from(recipients).where(where).orderBy(desc(recipients.createdAt));
}
export async function findRecipientById(id: number) {
const db = getDb();
const rows = await db.select().from(recipients).where(eq(recipients.id, id)).limit(1);
return rows.at(0) ?? null;
}
export async function findPendingRecipients(placeOfWorshipId?: number) {
const db = getDb();
const conditions = [eq(recipients.status, "pending")];
if (placeOfWorshipId) {
conditions.push(eq(recipients.placeOfWorshipId, placeOfWorshipId));
}
return db.select().from(recipients)
.where(and(...conditions))
.orderBy(desc(recipients.createdAt));
}
export async function createRecipient(data: InsertRecipient) {
const db = getDb();
const [{ id }] = await db.insert(recipients).values(data).$returningId();
return findRecipientById(id);
}
export async function updateRecipient(id: number, data: Partial<InsertRecipient>) {
const db = getDb();
await db.update(recipients).set(data).where(eq(recipients.id, id));
return findRecipientById(id);
}
export async function verifyRecipient(
id: number,
status: "active" | "rejected",
verifiedBy: number,
rejectionReason?: string
) {
return updateRecipient(id, {
status,
verifiedBy,
verifiedAt: new Date(),
rejectionReason: status === "rejected" ? rejectionReason : null,
});
}
export async function deleteRecipient(id: number) {
const db = getDb();
await db.delete(recipients).where(eq(recipients.id, id));
}
export async function countRecipients(status?: string) {
const db = getDb();
const query = db.select({ count: count() }).from(recipients);
if (status) {
return query.where(eq(recipients.status, status as "pending" | "active" | "rejected" | "suspended"));
}
const result = await query;
return result[0]?.count ?? 0;
}
export async function countActiveRecipients() {
return countRecipients("active");
}
export async function countPendingRecipients() {
return countRecipients("pending");
}
+52
View File
@@ -0,0 +1,52 @@
import { eq, or } from "drizzle-orm";
import * as schema from "@db/schema";
import type { InsertUser } from "@db/schema";
import { getDb } from "./connection";
import { env } from "../lib/env";
export async function findUserByUnionId(unionId: string) {
const rows = await getDb()
.select()
.from(schema.users)
.where(eq(schema.users.unionId, unionId))
.limit(1);
return rows.at(0);
}
export async function findUserByLoginIdentifier(identifier: string) {
const rows = await getDb()
.select()
.from(schema.users)
.where(or(eq(schema.users.unionId, identifier), eq(schema.users.email, identifier)))
.limit(1);
return rows.at(0);
}
export async function touchUserLastSignIn(unionId: string) {
await getDb()
.update(schema.users)
.set({ lastSignInAt: new Date() })
.where(eq(schema.users.unionId, unionId));
}
export async function upsertUser(data: InsertUser) {
const values = { ...data };
const updateSet: Partial<InsertUser> = {
lastSignInAt: new Date(),
...data,
};
if (
values.role === undefined &&
values.unionId &&
values.unionId === env.ownerUnionId
) {
values.role = "admin";
updateSet.role = "admin";
}
await getDb()
.insert(schema.users)
.values(values)
.onDuplicateKeyUpdate({ set: updateSet });
}
+116
View File
@@ -0,0 +1,116 @@
import { z } from "zod";
import { createRouter, publicQuery } from "./middleware";
import {
findAllRecipients,
findRecipientById,
findPendingRecipients,
createRecipient,
updateRecipient,
verifyRecipient,
deleteRecipient,
} from "./queries/recipients";
export const recipientRouter = createRouter({
list: publicQuery
.input(
z.object({
search: z.string().optional(),
status: z.string().optional(),
placeOfWorshipId: z.number().optional(),
}).optional()
)
.query(async ({ input }) => {
return findAllRecipients(input?.search, input?.status, input?.placeOfWorshipId);
}),
pending: publicQuery
.input(z.object({ placeOfWorshipId: z.number().optional() }).optional())
.query(async ({ input }) => {
return findPendingRecipients(input?.placeOfWorshipId);
}),
byId: publicQuery
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
return findRecipientById(input.id);
}),
create: publicQuery
.input(
z.object({
nik: z.string().length(16),
name: z.string().min(1),
birthDate: z.string().optional(),
gender: z.enum(["male", "female"]).optional(),
address: z.string().optional(),
phone: z.string().optional(),
latitude: z.number().min(-90).max(90).optional(),
longitude: z.number().min(-180).max(180).optional(),
familyMembers: z.number().min(1).default(1),
incomePerMonth: z.number().min(0).default(0),
placeOfWorshipId: z.number().min(1),
registeredBy: z.number().optional(),
notes: z.string().optional(),
})
)
.mutation(async ({ input }) => {
return createRecipient({
...input,
latitude: input.latitude?.toString() ?? null,
longitude: input.longitude?.toString() ?? null,
birthDate: input.birthDate ?? null,
gender: input.gender ?? null,
address: input.address ?? null,
phone: input.phone ?? null,
registeredBy: input.registeredBy ?? null,
notes: input.notes ?? null,
status: "pending",
});
}),
update: publicQuery
.input(
z.object({
id: z.number(),
nik: z.string().length(16).optional(),
name: z.string().min(1).optional(),
birthDate: z.string().optional(),
gender: z.enum(["male", "female"]).optional(),
address: z.string().optional(),
phone: z.string().optional(),
latitude: z.number().min(-90).max(90).optional(),
longitude: z.number().min(-180).max(180).optional(),
familyMembers: z.number().min(1).optional(),
incomePerMonth: z.number().min(0).optional(),
placeOfWorshipId: z.number().optional(),
notes: z.string().optional(),
})
)
.mutation(async ({ input }) => {
const { id, ...data } = input;
const updateData: Record<string, unknown> = { ...data };
if (data.latitude !== undefined) updateData.latitude = data.latitude.toString();
if (data.longitude !== undefined) updateData.longitude = data.longitude.toString();
return updateRecipient(id, updateData);
}),
verify: publicQuery
.input(
z.object({
id: z.number(),
status: z.enum(["active", "rejected"]),
verifiedBy: z.number(),
rejectionReason: z.string().optional(),
})
)
.mutation(async ({ input }) => {
return verifyRecipient(input.id, input.status, input.verifiedBy, input.rejectionReason);
}),
delete: publicQuery
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
await deleteRecipient(input.id);
return { success: true };
}),
});
+19
View File
@@ -0,0 +1,19 @@
import { authRouter } from "./auth-router";
import { placeOfWorshipRouter } from "./placeOfWorshipRouter";
import { recipientRouter } from "./recipientRouter";
import { distributionRouter } from "./distributionRouter";
import { dashboardRouter } from "./dashboardRouter";
import { seedRouter } from "./seedRouter";
import { createRouter, publicQuery } from "./middleware";
export const appRouter = createRouter({
ping: publicQuery.query(() => ({ ok: true, ts: Date.now() })),
auth: authRouter,
placeOfWorship: placeOfWorshipRouter,
recipient: recipientRouter,
distribution: distributionRouter,
dashboard: dashboardRouter,
seed: seedRouter,
});
export type AppRouter = typeof appRouter;
+69
View File
@@ -0,0 +1,69 @@
import { createRouter, publicQuery } from "./middleware";
import { getDb } from "./queries/connection";
import { placesOfWorship, recipients, distributions, activityLogs } from "@db/schema";
import { seedLocalUserIfMissing } from "./auth";
export const seedRouter = createRouter({
run: publicQuery.mutation(async () => {
const db = getDb();
await seedLocalUserIfMissing();
// Check if data already exists
const existing = await db.select({ count: placesOfWorship.id }).from(placesOfWorship).limit(1);
if (existing.length > 0) {
return { message: "Local admin ensured; data already seeded" };
}
// Seed Places of Worship
await db.insert(placesOfWorship).values([
{ name: "Masjid Al-Ikhlas", type: "mosque", address: "Jl. Pahlawan No. 12, Kelurahan Cempaka", latitude: "-6.1754", longitude: "106.8650", radius: 1500, capacity: 200, contactName: "Bapak Ahmad Surya", contactPhone: "081234567890", isActive: "yes" },
{ name: "Gereja Kristen Jawa", type: "church", address: "Jl. Diponegoro No. 45, Kelurahan Menteng", latitude: "-6.2008", longitude: "106.8320", radius: 1200, capacity: 150, contactName: "Ibu Maria Tan", contactPhone: "081987654321", isActive: "yes" },
{ name: "Pura Agung Jagatnatha", type: "temple", address: "Jl. Nusantara No. 78, Denpasar Timur", latitude: "-8.6580", longitude: "115.2200", radius: 2000, capacity: 100, contactName: "I Made Wirata", contactPhone: "082233445566", isActive: "yes" },
{ name: "Vihara Dharma Bhakti", type: "vihara", address: "Jl. Gajah Mada No. 88, Glodok, Jakarta Barat", latitude: "-6.1477", longitude: "106.8133", radius: 1000, capacity: 80, contactName: "Bapak Lim Swie Hian", contactPhone: "083344556677", isActive: "yes" },
{ name: "Masjid Nurul Huda", type: "mosque", address: "Jl. Sudirman No. 25, Kuningan, Jakarta Selatan", latitude: "-6.2088", longitude: "106.8456", radius: 1800, capacity: 300, contactName: "Bapak Hadi Pranoto", contactPhone: "084455667788", isActive: "yes" },
{ name: "Gereja Katolik Santo Antonius", type: "church", address: "Jl. Kartini No. 15, Yogyakarta Kota", latitude: "-7.8014", longitude: "110.3648", radius: 1300, capacity: 120, contactName: "Romo Yulianto", contactPhone: "085566778899", isActive: "yes" },
]);
// Seed Recipients
await db.insert(recipients).values([
{ nik: "3175012345678901", name: "Budi Santoso", birthDate: "1975-05-15", gender: "male", address: "Jl. Melati No. 3, Cempaka Putih", phone: "081111111111", latitude: "-6.1760", longitude: "106.8645", familyMembers: 4, incomePerMonth: 800000, status: "active", placeOfWorshipId: 1, notes: "Pekerja serabutan" },
{ nik: "3175012345678902", name: "Siti Aminah", birthDate: "1980-12-20", gender: "female", address: "Jl. Anggrek No. 7, Cempaka Putih", phone: "081222222222", latitude: "-6.1748", longitude: "106.8660", familyMembers: 3, incomePerMonth: 600000, status: "active", placeOfWorshipId: 1, notes: "Janda dengan 2 anak" },
{ nik: "3175012345678903", name: "Ahmad Fauzi", birthDate: "1968-03-10", gender: "male", address: "Jl. Mawar No. 12, Menteng", phone: "081333333333", latitude: "-6.2012", longitude: "106.8315", familyMembers: 5, incomePerMonth: 1000000, status: "active", placeOfWorshipId: 2, notes: "Penjual nasi keliling" },
{ nik: "3175012345678904", name: "Dewi Kusuma", birthDate: "1990-08-25", gender: "female", address: "Jl. Cemara No. 9, Menteng", phone: "081444444444", latitude: "-6.2002", longitude: "106.8330", familyMembers: 2, incomePerMonth: 700000, status: "pending", placeOfWorshipId: 2, notes: "Pekerja cleaning service" },
{ nik: "5175012345678905", name: "I Wayan Suardana", birthDate: "1972-11-05", gender: "male", address: "Jl. Raya Sanur No. 21, Denpasar", phone: "082555555555", latitude: "-8.6575", longitude: "115.2195", familyMembers: 6, incomePerMonth: 1200000, status: "active", placeOfWorshipId: 3, notes: "Petani subsisten" },
{ nik: "3175012345678906", name: "Rini Sulastri", birthDate: "1985-04-18", gender: "female", address: "Jl. Pekojan No. 14, Glodok", phone: "083666666666", latitude: "-6.1472", longitude: "106.8140", familyMembers: 3, incomePerMonth: 500000, status: "pending", placeOfWorshipId: 4, notes: "Penjual kue tradisional" },
{ nik: "3175012345678907", name: "Hendra Wijaya", birthDate: "1978-07-30", gender: "male", address: "Jl. Karet No. 8, Kuningan", phone: "084777777777", latitude: "-6.2095", longitude: "106.8450", familyMembers: 4, incomePerMonth: 900000, status: "active", placeOfWorshipId: 5, notes: "Sopir angkot" },
{ nik: "3475012345678908", name: "Yuliani", birthDate: "1982-09-12", gender: "female", address: "Jl. Malioboro No. 33, Yogyakarta", phone: "085888888888", latitude: "-7.8020", longitude: "110.3655", familyMembers: 2, incomePerMonth: 650000, status: "active", placeOfWorshipId: 6, notes: "Penjual oleh-oleh" },
{ nik: "3175012345678909", name: "Agus Priyanto", birthDate: "1965-01-22", gender: "male", address: "Jl. Kenanga No. 5, Cempaka Putih", phone: "086999999999", latitude: "-6.1750", longitude: "106.8655", familyMembers: 3, incomePerMonth: 750000, status: "rejected", placeOfWorshipId: 1, rejectionReason: "Memiliki usaha warung yang cukup stabil", notes: "Pemilik warung kecil" },
{ nik: "3175012345678910", name: "Maya Indah", birthDate: "1992-06-08", gender: "female", address: "Jl. Flamboyan No. 11, Kuningan", phone: "087000000001", latitude: "-6.2078", longitude: "106.8465", familyMembers: 1, incomePerMonth: 850000, status: "pending", placeOfWorshipId: 5, notes: "Mahasiswa tinggal sendiri" },
]);
// Seed Distributions
await db.insert(distributions).values([
{ placeOfWorshipId: 1, recipientId: 1, aidType: "Sembako", amount: "350000", quantity: 2, unit: "paket", notes: "Paket beras, minyak, gula, teh" },
{ placeOfWorshipId: 1, recipientId: 2, aidType: "Sembako", amount: "350000", quantity: 1, unit: "paket", notes: "Paket beras, minyak, gula" },
{ placeOfWorshipId: 2, recipientId: 3, aidType: "Bantuan Tunai", amount: "500000", quantity: 1, unit: "kali", notes: "BLT" },
{ placeOfWorshipId: 3, recipientId: 5, aidType: "Sembako", amount: "400000", quantity: 2, unit: "paket", notes: "Paket besar keluarga 6 orang" },
{ placeOfWorshipId: 4, recipientId: 6, aidType: "Modal Usaha", amount: "1000000", quantity: 1, unit: "kali", notes: "Modal usaha kue" },
{ placeOfWorshipId: 5, recipientId: 7, aidType: "Sembako", amount: "350000", quantity: 1, unit: "paket" },
{ placeOfWorshipId: 6, recipientId: 8, aidType: "Bantuan Pendidikan", amount: "750000", quantity: 1, unit: "kali", notes: "Biaya sekolah" },
{ placeOfWorshipId: 1, recipientId: 1, aidType: "Bantuan Tunai", amount: "600000", quantity: 1, unit: "kali", notes: "Bulanan kedua" },
]);
// Seed Activity Logs
await db.insert(activityLogs).values([
{ action: "CREATE", entityType: "recipient", entityId: 1, details: "Mendaftarkan Budi Santoso" },
{ action: "CREATE", entityType: "recipient", entityId: 2, details: "Mendaftarkan Siti Aminah" },
{ action: "VERIFY", entityType: "recipient", entityId: 1, details: "Memverifikasi Budi Santoso - Diterima" },
{ action: "VERIFY", entityType: "recipient", entityId: 2, details: "Memverifikasi Siti Aminah - Diterima" },
{ action: "CREATE", entityType: "distribution", entityId: 1, details: "Distribusi sembako ke Budi Santoso" },
{ action: "CREATE", entityType: "recipient", entityId: 4, details: "Mendaftarkan Dewi Kusuma" },
{ action: "CREATE", entityType: "recipient", entityId: 9, details: "Mendaftarkan Agus Priyanto" },
{ action: "VERIFY", entityType: "recipient", entityId: 9, details: "Memverifikasi Agus Priyanto - Ditolak" },
{ action: "CREATE", entityType: "place_of_worship", entityId: 5, details: "Menambahkan Masjid Nurul Huda" },
{ action: "CREATE", entityType: "recipient", entityId: 10, details: "Mendaftarkan Maya Indah" },
]);
return { message: "Database seeded successfully" };
}),
});
+45
View File
@@ -0,0 +1,45 @@
import * as jose from "jose";
import { env } from "./lib/env";
export type SessionPayload = {
unionId: string;
clientId: string;
};
const JWT_ALG = "HS256";
export async function signSessionToken(
payload: SessionPayload,
): Promise<string> {
const secret = new TextEncoder().encode(env.appSecret);
return new jose.SignJWT(payload)
.setProtectedHeader({ alg: JWT_ALG })
.setIssuedAt()
.setExpirationTime("1 year")
.sign(secret);
}
export async function verifySessionToken(
token: string,
): Promise<SessionPayload | null> {
if (!token) {
console.warn("[session] No token provided for verification.");
return null;
}
try {
const secret = new TextEncoder().encode(env.appSecret);
const { payload } = await jose.jwtVerify(token, secret, {
algorithms: [JWT_ALG],
});
const { unionId, clientId } = payload;
if (!unionId || !clientId) {
console.warn("[session] JWT payload missing required fields.");
return null;
}
return { unionId, clientId } as SessionPayload;
} catch (error) {
console.warn("[session] JWT verification failed:", error);
return null;
}
}