v1.0
This commit is contained in:
@@ -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
@@ -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
@@ -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}/`);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 ?? "",
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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"));
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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" };
|
||||
}),
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user