diff --git a/GEMINI.md b/GEMINI.md
new file mode 100644
index 00000000..8dfc6802
--- /dev/null
+++ b/GEMINI.md
@@ -0,0 +1 @@
+READ GUIDE at AGENTS.md
\ No newline at end of file
diff --git a/apps/dash/app/(dash)/event/[id]/page.tsx b/apps/dash/app/(dash)/event/[id]/page.tsx
index 9414d072..d1e8883b 100644
--- a/apps/dash/app/(dash)/event/[id]/page.tsx
+++ b/apps/dash/app/(dash)/event/[id]/page.tsx
@@ -1,7 +1,9 @@
+import { AttachmentReferenceType } from "@domus/core/entity/enums";
import { notFound, redirect } from "next/navigation";
import { EventDetailPage } from "@/pages/event";
import { getAuthContext } from "@/shared/auth/server";
import {
+ attachment as attachmentService,
attendance as attendanceService,
event as eventService,
rsvp as rsvpService,
@@ -69,12 +71,21 @@ export default async function Page({
rsvpSummary = summary || null;
}
+ // Fetch Attachments
+ const [attachmentsData, _attError] = await attachmentService.findByReference(
+ auth,
+ id,
+ AttachmentReferenceType.Event,
+ );
+ const attachments = attachmentsData || [];
+
return (
);
diff --git a/apps/dash/app/(dash)/finance/period/[periodId]/transaction/create/page.tsx b/apps/dash/app/(dash)/finance/period/[periodId]/transaction/create/page.tsx
new file mode 100644
index 00000000..cd307110
--- /dev/null
+++ b/apps/dash/app/(dash)/finance/period/[periodId]/transaction/create/page.tsx
@@ -0,0 +1,11 @@
+import { TransactionCreatePage } from "@/pages/finance";
+
+export default async function Page({
+ params,
+}: {
+ params: Promise<{ periodId: string }>;
+}) {
+ const { periodId } = await params;
+
+ return ;
+}
diff --git a/apps/dash/app/(dash)/setting/categories/page.tsx b/apps/dash/app/(dash)/setting/categories/page.tsx
new file mode 100644
index 00000000..9340facc
--- /dev/null
+++ b/apps/dash/app/(dash)/setting/categories/page.tsx
@@ -0,0 +1 @@
+export { CategorySettingsPage as default } from "@/pages/finance/ui/CategorySettingsPage";
diff --git a/apps/dash/e2e/assets/dummy.pdf b/apps/dash/e2e/assets/dummy.pdf
new file mode 100644
index 00000000..eaf5f751
Binary files /dev/null and b/apps/dash/e2e/assets/dummy.pdf differ
diff --git a/apps/dash/e2e/features/finance/transaction-recording.spec.ts b/apps/dash/e2e/features/finance/transaction-recording.spec.ts
new file mode 100644
index 00000000..a3bbb6a7
--- /dev/null
+++ b/apps/dash/e2e/features/finance/transaction-recording.spec.ts
@@ -0,0 +1,79 @@
+import path from "node:path";
+import { TransactionType } from "@domus/core";
+import { expect, test } from "@playwright/test";
+import { iHaveLoggedInAsTreasurer } from "../../helper/auth";
+import {
+ iHaveOpenFinancialPeriod,
+ iHaveTransactionCategory,
+} from "../../helper/finance";
+import { FinancePage } from "../../pages/finance/FinancePage";
+
+test.describe("Finance - Transaction Recording", () => {
+ // Run serially to avoid race conditions in shared DB test data setup
+ test.describe.configure({ mode: "serial" });
+
+ let financePage: FinancePage;
+
+ test.beforeEach(async ({ context, page }) => {
+ // 1. Ensure test data exists
+ const now = new Date();
+ await iHaveOpenFinancialPeriod(now.getMonth() + 1, now.getFullYear());
+ await iHaveTransactionCategory("Kolekte", TransactionType.Income);
+ await iHaveTransactionCategory("Listrik", TransactionType.Expense);
+
+ // 2. Login as Treasurer
+ await iHaveLoggedInAsTreasurer(context);
+
+ financePage = new FinancePage(page);
+ });
+
+ test("should successfully record an income transaction with receipt", async ({
+ page,
+ }) => {
+ await financePage.goto();
+ await financePage.switchToPeriodsTab();
+
+ // 1. Click record button for the open period
+ await financePage.btnRecordTransaction.first().click();
+ await expect(page).toHaveURL(/\/transaction\/create/);
+
+ // 2. Fill the form
+ const receiptPath = path.resolve(__dirname, "../../assets/dummy-id.png");
+
+ await financePage.fillTransactionForm({
+ type: TransactionType.Income,
+ date: new Date().toISOString().split("T")[0],
+ category: "Kolekte",
+ amount: "500000",
+ description: "Kolekte Misa Minggu Ke-3",
+ receiptPath,
+ });
+
+ // 3. Submit
+ await financePage.submitTransaction();
+
+ // 4. Verify success
+ await financePage.expectSuccessToast();
+ await expect(page).toHaveURL("/finance");
+ });
+
+ test("should show validation errors for missing mandatory fields", async ({
+ page,
+ }) => {
+ await financePage.goto();
+ await financePage.switchToPeriodsTab();
+ await financePage.btnRecordTransaction.first().click();
+
+ // Touch fields to trigger onChange validation, then submit
+ await financePage.inputAmount.click();
+ await financePage.inputAmount.fill("0");
+ await financePage.inputDescription.click();
+ await financePage.inputDescription.fill("");
+
+ // Submit empty/invalid form
+ await financePage.submitTransaction();
+
+ // Should stay on the transaction create page (not navigate back to /finance)
+ await expect(page).toHaveURL(/\/transaction\/create/);
+ });
+});
diff --git a/apps/dash/e2e/features/notifications/dropdown.spec.ts b/apps/dash/e2e/features/notifications/dropdown.spec.ts
new file mode 100644
index 00000000..43f2c962
--- /dev/null
+++ b/apps/dash/e2e/features/notifications/dropdown.spec.ts
@@ -0,0 +1,128 @@
+import { expect, test } from "@playwright/test";
+import { v7 as uuidv7 } from "uuid";
+import {
+ deleteNotificationsForUser,
+ iHaveLoggedInAsSuperAdmin,
+ iHaveNotification,
+} from "../../helper";
+import { NotificationPage } from "../../pages/NotificationPage";
+
+// We run in serial mode to prevent database conflicts between tests using the same user.
+test.describe.configure({ mode: "serial" });
+
+test.describe("In-App Notification System", () => {
+ let notificationPage: NotificationPage;
+ let user: { id: string; name: string; email: string };
+
+ test.beforeEach(async ({ page, context }) => {
+ notificationPage = new NotificationPage(page);
+ user = await iHaveLoggedInAsSuperAdmin(context);
+ await deleteNotificationsForUser(user.id);
+
+ await page.goto("/");
+ await expect(page).toHaveURL("/", { timeout: 15000 });
+ await page.waitForLoadState("networkidle");
+ });
+
+ test("should show empty state when no notifications exist", async () => {
+ await notificationPage.openDropdown();
+ await expect(notificationPage.emptyState).toBeVisible();
+ await expect(notificationPage.notificationItems).toHaveCount(0);
+ });
+
+ test("should show unread badge when a new notification arrives", async ({
+ page,
+ }) => {
+ const title = `New Notification ${Date.now()}`;
+ await iHaveNotification({
+ userId: user.id,
+ title: title,
+ referenceType: "Event",
+ referenceId: uuidv7(),
+ });
+
+ await page.goto("/");
+ await page.waitForLoadState("networkidle");
+
+ // Poll for the badge count to match DB state
+ await expect(async () => {
+ const count = await notificationPage.getUnreadCount();
+ expect(count).toBeGreaterThan(0);
+ }).toPass({ timeout: 15000 });
+
+ await expect(notificationPage.unreadBadge).toBeVisible();
+ });
+
+ test("should open dropdown and show notification items", async ({ page }) => {
+ const title = `Dashboard Update ${Date.now()}`;
+ await iHaveNotification({
+ userId: user.id,
+ title: title,
+ });
+
+ await page.goto("/");
+ await page.waitForLoadState("networkidle");
+ await notificationPage.openDropdown();
+
+ await expect(page.getByText(title)).toBeVisible();
+ });
+
+ test("should mark notification as read when clicked", async ({ page }) => {
+ const title = `Click Me ${Date.now()}`;
+ await iHaveNotification({
+ userId: user.id,
+ title: title,
+ referenceType: "Event",
+ referenceId: uuidv7(),
+ });
+
+ await page.goto("/");
+ await page.waitForLoadState("networkidle");
+ await notificationPage.openDropdown();
+
+ // Verify unread indicator exists initially
+ const item = notificationPage.notificationItems.filter({ hasText: title });
+ const indicator = item.locator('[data-slot="notif-unread-indicator"]');
+ await expect(indicator).toBeVisible();
+
+ // Click item
+ await notificationPage.clickNotification(title);
+
+ // Wait for navigation
+ await page.waitForURL("**/event/**", { timeout: 10000 });
+ await page.waitForLoadState("networkidle");
+
+ // Go back and check read state
+ await page.goto("/");
+ await page.waitForLoadState("networkidle");
+ await notificationPage.openDropdown();
+
+ const refreshedItem = notificationPage.notificationItems.filter({
+ hasText: title,
+ });
+ const refreshedIndicator = refreshedItem.locator(
+ '[data-slot="notif-unread-indicator"]',
+ );
+
+ // Indicator should be gone if read
+ await expect(refreshedIndicator).toBeHidden();
+ });
+
+ test("should mark all as read", async ({ page }) => {
+ await iHaveNotification({ userId: user.id, title: "Notif 1" });
+ await iHaveNotification({ userId: user.id, title: "Notif 2" });
+
+ await page.goto("/");
+ await page.waitForLoadState("networkidle");
+ await notificationPage.openDropdown();
+
+ await notificationPage.markAllAsRead();
+
+ // The badge should disappear from the main trigger
+ await expect(notificationPage.unreadBadge).toBeHidden();
+
+ // Indicators inside the items should also be gone
+ const indicators = page.locator('[data-slot="notif-unread-indicator"]');
+ await expect(indicators).toHaveCount(0);
+ });
+});
diff --git a/apps/dash/e2e/features/org/term-management.spec.ts b/apps/dash/e2e/features/org/term-management.spec.ts
new file mode 100644
index 00000000..21b504c7
--- /dev/null
+++ b/apps/dash/e2e/features/org/term-management.spec.ts
@@ -0,0 +1,83 @@
+import path from "node:path";
+import { expect, test } from "@playwright/test";
+import { iHaveLoggedInAsSuperAdmin } from "../../helper/auth";
+import { OrgCreatePage } from "../../pages/org/OrgCreatePage";
+import { OrgDetailPage } from "../../pages/org/OrgDetailPage";
+
+test.describe("Organizational Term Management", () => {
+ test("should manage terms in an organization", async ({ page, context }) => {
+ // Extend timeout: org create + term CRUD + Google Drive upload can take > 30s
+ test.setTimeout(90_000);
+
+ // 0. Setup authentication and page objects
+ await iHaveLoggedInAsSuperAdmin(context);
+ const detailPage = new OrgDetailPage(page);
+ const createPage = new OrgCreatePage(page);
+
+ // 1. Create a dedicated organization for this test to ensure isolation
+ const randomSuffix = Math.random().toString(36).substring(7);
+ const orgName = `Org E2E Term ${randomSuffix}`;
+ await createPage.goto();
+ await createPage.fillName(orgName);
+ await createPage.submit();
+
+ // 2. Wait for redirect to details page and get the URL
+ await expect(page).toHaveURL(/\/org\/(?!new$)[^/]+$/);
+
+ const termData = {
+ name: "Pengurus Periode 2025-2030",
+ startDate: "2025-01-01",
+ endDate: "2030-12-31",
+ skNumber: "SK/2025/001",
+ skDate: "2025-01-01",
+ description: "Masa jabatan pengurus periode baru.",
+ };
+
+ // 3. Create term
+ await detailPage.addTerm(termData);
+
+ // 2. Verify in list
+ await expect(page.getByText(termData.name)).toBeVisible();
+
+ // 3. Update term
+ const updatedName = "Pengurus Periode 2025-2030 (Updated)";
+ await detailPage.updateTermAt(0, { name: updatedName });
+
+ // 4. Verify update
+ await expect(page.getByText(updatedName)).toBeVisible();
+
+ // 5. Expand term
+ await detailPage.toggleTermExpandAt(0);
+
+ // 6. Upload attachment
+ const attachmentName = `SK Pelantikan ${randomSuffix}`;
+ const dummyFilePath = path.join(process.cwd(), "e2e/assets/dummy.pdf");
+ await detailPage.uploadTermAttachment(0, attachmentName, dummyFilePath);
+
+ // 7. Verify attachment is in list
+ // Hard reload ensures fresh server data and clean React state.
+ // (router.refresh() can preserve stale expandedTerms causing false-hidden elements)
+ await page.reload();
+ await page.waitForLoadState("networkidle");
+ await detailPage.ensureTermExpandedAt(0);
+
+ const attachmentItem = page
+ .getByTestId("attachment-item-name")
+ .filter({ hasText: attachmentName });
+
+ // Use toBeAttached — the
has class="truncate" (overflow:hidden) which can
+ // cause Playwright to report it as "hidden" even when the term section is visible.
+ await expect(attachmentItem).toBeAttached({ timeout: 10000 });
+
+ console.log(
+ "Term Management E2E: Successfully verified update and attachment",
+ );
+
+ // 8. Delete term
+ await detailPage.deleteTermAt(0);
+
+ // 9. Verify removed
+ await expect(page.getByText(updatedName)).toHaveCount(0);
+ await expect(page.getByText("Belum ada masa jabatan.")).toBeVisible();
+ });
+});
diff --git a/apps/dash/e2e/features/setting/transaction-category.spec.ts b/apps/dash/e2e/features/setting/transaction-category.spec.ts
new file mode 100644
index 00000000..4af9d0b5
--- /dev/null
+++ b/apps/dash/e2e/features/setting/transaction-category.spec.ts
@@ -0,0 +1,50 @@
+import { test } from "@playwright/test";
+import {
+ iHaveLoggedInAsApprovedParishioner,
+ iHaveLoggedInAsTreasurer,
+} from "../../helper/auth";
+import { CategorySettingsPage } from "../../pages/setting/CategorySettingsPage";
+
+test.describe("Transaction Category Management", () => {
+ test("Treasurer can manage categories (CRUD)", async ({ page, context }) => {
+ await iHaveLoggedInAsTreasurer(context);
+ const categoryPage = new CategorySettingsPage(page);
+
+ await categoryPage.goto();
+
+ // 1. Create
+ await categoryPage.addCategory({
+ name: "Iuran Wajib",
+ type: "Pemasukan",
+ });
+ await categoryPage.expectCategoryVisible("Iuran Wajib");
+
+ // 2. Create another one
+ await categoryPage.addCategory({
+ name: "Listrik Gereja",
+ type: "Pengeluaran",
+ });
+ await categoryPage.expectCategoryVisible("Listrik Gereja");
+
+ // 3. Delete
+ await categoryPage.deleteCategory("Listrik Gereja");
+ await categoryPage.expectCategoryHidden("Listrik Gereja");
+ });
+
+ test("Ordinary parishioner cannot access category settings", async ({
+ page,
+ context,
+ }) => {
+ await iHaveLoggedInAsApprovedParishioner(context);
+
+ // Attempt to navigate
+ await page.goto("/setting/categories");
+
+ // Should be redirected to dashboard or see unauthorized
+ // In Domus, usually redirects to / or /pending if not allowed
+ // But since it's a treasurer/admin role required, the proxy might handle it
+ // Let's check for base dashboard element or breadcrumb that indicates we are NOT in categories
+ // For now, checking if URL is NOT categories or contains redirected status
+ await page.waitForURL(/\/(?!(setting\/categories))/);
+ });
+});
diff --git a/apps/dash/e2e/features/setting/user-management.spec.ts b/apps/dash/e2e/features/setting/user-management.spec.ts
index 61877a65..461d6758 100644
--- a/apps/dash/e2e/features/setting/user-management.spec.ts
+++ b/apps/dash/e2e/features/setting/user-management.spec.ts
@@ -1,4 +1,4 @@
-import { AccountStatus, UserRole } from "@domus/core";
+import { AccountStatus, type User, UserRole } from "@domus/core";
import { expect, test } from "@playwright/test";
import { iHaveLoggedInAsSuperAdmin, iHaveUser } from "../../helper";
import { UserManagementPage } from "../../pages/setting/UserManagementPage";
@@ -6,8 +6,8 @@ import { UserManagementPage } from "../../pages/setting/UserManagementPage";
test.describe("User Management Feature", () => {
let userPage: UserManagementPage;
- let budi: any;
- let ani: any;
+ let budi: User;
+ let ani: User;
test.beforeEach(async ({ page }) => {
// 1. Login as Super Admin to access the page
diff --git a/apps/dash/e2e/helper/auth.ts b/apps/dash/e2e/helper/auth.ts
index 829cdc57..367e9763 100644
--- a/apps/dash/e2e/helper/auth.ts
+++ b/apps/dash/e2e/helper/auth.ts
@@ -42,6 +42,25 @@ export async function iHaveLoggedInAsApprovedParishioner(
return iHaveLoggedInAs(context, withDefaults);
}
+/**
+ * Orchestrates a complete login flow for a Treasurer.
+ */
+export async function iHaveLoggedInAsTreasurer(
+ context: BrowserContext,
+ payload?: Partial,
+) {
+ const withDefaults: UserPayload = {
+ email: "treasurer@example.com",
+ name: "Parish Treasurer",
+ password: "testing123",
+ accountStatus: AccountStatus.Approved,
+ role: [UserRole.Treasurer],
+ ...payload,
+ };
+
+ return iHaveLoggedInAs(context, withDefaults);
+}
+
/**
* Orchestrates an E2E login flow using the UI.
*/
diff --git a/apps/dash/e2e/helper/finance.ts b/apps/dash/e2e/helper/finance.ts
new file mode 100644
index 00000000..c406bde1
--- /dev/null
+++ b/apps/dash/e2e/helper/finance.ts
@@ -0,0 +1,74 @@
+import { PeriodStatus, type TransactionType, UserRole } from "@domus/core";
+import type { FinancialPeriod } from "@domus/core/entity/financial-period";
+import * as core from "@/shared/core/service";
+
+/**
+ * Ensures a financial period exists and is open for the given month/year.
+ */
+export async function iHaveOpenFinancialPeriod(
+ month: number,
+ year: number,
+): Promise {
+ // Try find existing
+ const [periods] = await core.financialPeriod.findAll();
+ const existing = periods?.find(
+ (p) =>
+ p.month === month && p.year === year && p.status === PeriodStatus.Open,
+ );
+
+ if (existing) return existing;
+
+ // Create new
+ const [created, error] = await core.financialPeriod.create(
+ {
+ month,
+ year,
+ status: PeriodStatus.Open,
+ },
+ {
+ userId: "system-test", // Mock system user
+ roles: [UserRole.Treasurer],
+ accountStatus: "approved",
+ orgRoles: {},
+ parishId: "mock-parish-id",
+ },
+ );
+
+ if (error) throw error;
+ if (!created) throw new Error("Failed to create financial period");
+
+ return created;
+}
+
+/**
+ * Ensures a transaction category exists.
+ */
+export async function iHaveTransactionCategory(
+ name: string,
+ type: TransactionType,
+) {
+ const [categories] = await core.transactionCategory.getCategories();
+ const existing = categories?.find((c) => c.name === name && c.type === type);
+
+ if (existing) return existing;
+
+ const [created, error] = await core.transactionCategory.createCategory(
+ {
+ userId: "system-test",
+ roles: [UserRole.SuperAdmin],
+ accountStatus: "approved",
+ orgRoles: {},
+ parishId: "mock-parish-id",
+ },
+ {
+ name,
+ type,
+ },
+ );
+
+ if (error) {
+ console.error("FINANCE CATEGORY CREATE ERROR:", error);
+ throw error;
+ }
+ return created;
+}
diff --git a/apps/dash/e2e/helper/index.ts b/apps/dash/e2e/helper/index.ts
index 1a6191bf..f7fe5c9a 100644
--- a/apps/dash/e2e/helper/index.ts
+++ b/apps/dash/e2e/helper/index.ts
@@ -1,5 +1,6 @@
export * from "./auth";
export * from "./diocese";
+export * from "./notification";
export * from "./org";
export * from "./parish";
export * from "./user";
diff --git a/apps/dash/e2e/helper/notification.ts b/apps/dash/e2e/helper/notification.ts
new file mode 100644
index 00000000..d5183e2a
--- /dev/null
+++ b/apps/dash/e2e/helper/notification.ts
@@ -0,0 +1,44 @@
+import { NotificationChannel, NotificationStatus } from "@domus/core";
+import { db, notifications } from "@domus/db";
+import { eq } from "drizzle-orm";
+import { v7 as uuidv7 } from "uuid";
+
+interface CreateNotificationPayload {
+ userId: string;
+ title: string;
+ message?: string;
+ referenceType?: string;
+ referenceId?: string;
+}
+
+/**
+ * Programmatically creates a notification in the database for E2E testing.
+ */
+export async function iHaveNotification(payload: CreateNotificationPayload) {
+ const [newNotification] = await db
+ .insert(notifications)
+ .values({
+ id: uuidv7(),
+ userId: payload.userId,
+ title: payload.title,
+ body: payload.message || "Test Notification Message",
+ type: "test",
+ channel: NotificationChannel.InApp,
+ status: NotificationStatus.Sent,
+ isRead: false,
+ referenceType: payload.referenceType,
+ referenceId: payload.referenceId || null,
+ createdAt: new Date(),
+ })
+ .returning();
+
+ return newNotification;
+}
+
+/**
+ * Deletes all notifications for a specific user.
+ * Useful for ensuring a clean state in E2E tests.
+ */
+export async function deleteNotificationsForUser(userId: string) {
+ await db.delete(notifications).where(eq(notifications.userId, userId));
+}
diff --git a/apps/dash/e2e/pages/AttendancePage.ts b/apps/dash/e2e/pages/AttendancePage.ts
index 6007e965..edb013a7 100644
--- a/apps/dash/e2e/pages/AttendancePage.ts
+++ b/apps/dash/e2e/pages/AttendancePage.ts
@@ -87,7 +87,7 @@ export class AttendancePage {
await expect(
this.page.getByText(/kehadiran berhasil dicatat/i),
- ).toBeVisible();
+ ).toBeVisible({ timeout: 10000 });
// Close dialog by clicking outside or escape (Dialog is usually persistent until closed)
await this.page.keyboard.press("Escape");
diff --git a/apps/dash/e2e/pages/NotificationPage.ts b/apps/dash/e2e/pages/NotificationPage.ts
new file mode 100644
index 00000000..b7f6f626
--- /dev/null
+++ b/apps/dash/e2e/pages/NotificationPage.ts
@@ -0,0 +1,97 @@
+import { expect, type Locator, type Page } from "@playwright/test";
+
+/**
+ * Page Object Model for the Notification components.
+ * Encapsulates selectors and actions for the notification dropdown and list.
+ */
+export class NotificationPage {
+ readonly page: Page;
+ readonly trigger: Locator;
+ readonly dropdown: Locator;
+ readonly unreadBadge: Locator;
+ readonly markAllReadButton: Locator;
+ readonly viewAllButton: Locator;
+ readonly notificationItems: Locator;
+ readonly emptyState: Locator;
+ readonly loadingState: Locator;
+
+ constructor(page: Page) {
+ this.page = page;
+ this.trigger = page.locator('[data-slot="notification-trigger"]');
+ this.dropdown = page.locator('[data-slot="popover-content"]');
+ this.unreadBadge = this.trigger.locator("span");
+ this.markAllReadButton = page.locator(
+ '[data-slot="notif-mark-all-as-read"]',
+ );
+ this.viewAllButton = page.getByRole("link", {
+ name: /Lihat semua|View all/i,
+ });
+ this.notificationItems = page.locator('[data-slot="notification-item"]');
+ this.emptyState = page.locator('[data-slot="notif-empty-state"]');
+ this.loadingState = page.getByText(
+ /Memuat notifikasi|Loading notifications/i,
+ );
+ }
+
+ /**
+ * Opens the notification dropdown and waits for loading to finish.
+ */
+ async openDropdown() {
+ // Ensure trigger is attached and visible before clicking
+ await this.trigger.waitFor({ state: "visible" });
+
+ // Sometimes the first click is swallowed if the page is still hydrating
+ // We retry up to 3 times if the dropdown doesn't show up
+ await expect(async () => {
+ await this.trigger.click();
+ await expect(this.dropdown).toBeVisible({ timeout: 2000 });
+ }).toPass({
+ intervals: [1000, 2000],
+ timeout: 10000,
+ });
+
+ // Wait for either notification items or empty state to be visible
+ // Match the data-slot used in NotificationDropdown.tsx
+ const content = this.page.locator(
+ '[data-slot="notification-item"], [data-slot="notif-empty-state"]',
+ );
+ await content.first().waitFor({ state: "visible" });
+ }
+
+ /**
+ * Closes the notification dropdown by clicking outside or pressing Escape.
+ */
+ async closeDropdown() {
+ await this.page.keyboard.press("Escape");
+ await this.dropdown.waitFor({ state: "hidden" });
+ }
+
+ /**
+ * Marks all notifications as read.
+ */
+ async markAllAsRead() {
+ await this.markAllReadButton.click();
+ }
+
+ /**
+ * Gets the unread count from the badge.
+ */
+ async getUnreadCount() {
+ if (!(await this.unreadBadge.isVisible())) {
+ return 0;
+ }
+ const text = await this.unreadBadge.innerText();
+ return text === "99+" ? 100 : Number.parseInt(text || "0", 10);
+ }
+
+ /**
+ * Clicks on a specific notification item by partial text.
+ *
+ * @param text - Text content to match.
+ */
+ async clickNotification(text: string | RegExp) {
+ const item = this.notificationItems.filter({ hasText: text });
+ await item.waitFor({ state: "visible" });
+ await item.click();
+ }
+}
diff --git a/apps/dash/e2e/pages/finance/FinancePage.ts b/apps/dash/e2e/pages/finance/FinancePage.ts
new file mode 100644
index 00000000..6d7f4a2d
--- /dev/null
+++ b/apps/dash/e2e/pages/finance/FinancePage.ts
@@ -0,0 +1,135 @@
+import { TransactionType } from "@domus/core";
+import type { Page } from "@playwright/test";
+import { expect } from "@playwright/test";
+
+/**
+ * Page Object Model for Finance management.
+ */
+export class FinancePage {
+ constructor(private readonly page: Page) {}
+
+ /**
+ * Navigation
+ */
+ async goto() {
+ await this.page.goto("/finance");
+ }
+
+ /**
+ * locators for Period List
+ */
+ get btnRecordTransaction() {
+ return this.page.getByTestId("btn-record-transaction");
+ }
+
+ get tabPeriods() {
+ return this.page.getByRole("tab", { name: /periode|periods/i });
+ }
+
+ async switchToPeriodsTab() {
+ await this.tabPeriods.click();
+ }
+
+ /**
+ * Transaction Form Locators
+ */
+ get radioTypeIncome() {
+ return this.page
+ .getByTestId("radio-type")
+ .getByRole("radio", { name: /pemasukan/i });
+ }
+
+ get radioTypeExpense() {
+ return this.page
+ .getByTestId("radio-type")
+ .getByRole("radio", { name: /pengeluaran/i });
+ }
+
+ get inputDate() {
+ return this.page.getByTestId("input-date");
+ }
+
+ get selectCategory() {
+ return this.page.getByTestId("select-category");
+ }
+
+ get inputAmount() {
+ return this.page.getByTestId("input-amount");
+ }
+
+ get inputDescription() {
+ return this.page.getByTestId("input-description");
+ }
+
+ get inputReceipt() {
+ return this.page.getByTestId("input-receipt");
+ }
+
+ get btnSubmit() {
+ return this.page.getByRole("button", { name: /simpan/i });
+ }
+
+ /**
+ * Actions
+ */
+ async fillTransactionForm(data: {
+ type: TransactionType;
+ date: string;
+ category: string;
+ amount: string;
+ description: string;
+ receiptPath?: string;
+ }) {
+ if (data.type === TransactionType.Income) {
+ await this.radioTypeIncome.click();
+ } else {
+ await this.radioTypeExpense.click();
+ }
+
+ // Date field usually uses a custom DatePicker,
+ // but TextField for date might just be a string or handled by Select
+ // Assuming it's a fillable input based on TextField standard
+ await this.inputDate.fill(data.date);
+
+ // Select category (use .first() to handle potential parallel-worker duplicates in test DB)
+ await this.selectCategory.click();
+ await this.page
+ .getByRole("option", { name: data.category })
+ .first()
+ .click();
+
+ await this.inputAmount.fill(data.amount);
+ await this.inputDescription.fill(data.description);
+
+ if (data.receiptPath) {
+ const fileChooserPromise = this.page.waitForEvent("filechooser");
+ await this.page.getByText(/klik untuk unggah/i).click();
+ const fileChooser = await fileChooserPromise;
+ await fileChooser.setFiles(data.receiptPath);
+
+ // Wait for the empty state to disappear
+ await expect(this.page.getByText(/klik untuk unggah/i)).toBeHidden({
+ timeout: 15000,
+ });
+
+ // Wait for upload success indicator
+ await expect(
+ this.page.getByText(/berhasil diunggah/i).first(),
+ ).toBeVisible({
+ timeout: 15000,
+ });
+ }
+ }
+
+ async submitTransaction() {
+ await this.btnSubmit.click();
+ }
+
+ async expectSuccessToast() {
+ await expect(
+ this.page.getByText(/transaksi berhasil dicatat/i),
+ ).toBeVisible({
+ timeout: 10000,
+ });
+ }
+}
diff --git a/apps/dash/e2e/pages/org/OrgDetailPage.ts b/apps/dash/e2e/pages/org/OrgDetailPage.ts
index 5853b92f..f215f83f 100644
--- a/apps/dash/e2e/pages/org/OrgDetailPage.ts
+++ b/apps/dash/e2e/pages/org/OrgDetailPage.ts
@@ -1,4 +1,4 @@
-import type { Locator, Page } from "@playwright/test";
+import { expect, type Locator, type Page } from "@playwright/test";
/**
* Page Object Model for the Organization Detail page.
@@ -20,6 +20,17 @@ export class OrgDetailPage {
readonly unitNameInput: Locator;
readonly unitDescriptionInput: Locator;
readonly unitSubmitButton: Locator;
+ // Term management
+ readonly addTermButton: Locator;
+ readonly termForm: Locator;
+ readonly termNameInput: Locator;
+ readonly termStartDateInput: Locator;
+ readonly termEndDateInput: Locator;
+ readonly termSkNumberInput: Locator;
+ readonly termSkDateInput: Locator;
+ readonly termDescriptionInput: Locator;
+ readonly termSubmitButton: Locator;
+ readonly termList: Locator;
constructor(page: Page) {
this.page = page;
@@ -40,6 +51,17 @@ export class OrgDetailPage {
this.unitNameInput = page.getByTestId("unit-name-input");
this.unitDescriptionInput = page.getByTestId("unit-description-input");
this.unitSubmitButton = page.getByTestId("unit-submit-button");
+ // Term management
+ this.addTermButton = page.getByTestId("add-term-button");
+ this.termForm = page.getByTestId("term-form");
+ this.termNameInput = page.getByTestId("term-name-input");
+ this.termStartDateInput = page.getByTestId("term-start-date-input");
+ this.termEndDateInput = page.getByTestId("term-end-date-input");
+ this.termSkNumberInput = page.getByTestId("term-sk-number-input");
+ this.termSkDateInput = page.getByTestId("term-sk-date-input");
+ this.termDescriptionInput = page.getByTestId("term-description-input");
+ this.termSubmitButton = page.getByTestId("term-submit-button");
+ this.termList = page.getByTestId("term-list");
}
/**
@@ -207,7 +229,211 @@ export class OrgDetailPage {
async getUnitNames(): Promise {
const unitHeaders = this.page.locator('[data-testid="unit-list"] h4');
// Wait for at least one unit to be visible
- await unitHeaders.first().waitFor({ state: "visible" });
+ if ((await unitHeaders.count()) > 0) {
+ await unitHeaders.first().waitFor({ state: "visible" });
+ }
return await unitHeaders.allTextContents();
}
+
+ /**
+ * Adds a new organizational term (Masa Jabatan).
+ *
+ * @param data - Term details.
+ */
+ async addTerm(data: {
+ name: string;
+ startDate: string;
+ endDate: string;
+ skNumber?: string;
+ skDate?: string;
+ description?: string;
+ }) {
+ await this.addTermButton.scrollIntoViewIfNeeded();
+ await this.addTermButton.waitFor({ state: "visible", timeout: 10000 });
+ await this.addTermButton.click();
+ await this.termForm.waitFor({ state: "visible", timeout: 5000 });
+
+ await this.termNameInput.fill(data.name);
+ await this.termStartDateInput.fill(data.startDate);
+ await this.termEndDateInput.fill(data.endDate);
+
+ if (data.skNumber) await this.termSkNumberInput.fill(data.skNumber);
+ if (data.skDate) await this.termSkDateInput.fill(data.skDate);
+ if (data.description)
+ await this.termDescriptionInput.fill(data.description);
+
+ await this.termSubmitButton.click();
+ await this.termForm.waitFor({ state: "hidden" });
+ }
+
+ /**
+ * Toggles expansion of a term in the list.
+ *
+ * @param index - The 0-based index of the term.
+ */
+ async toggleTermExpandAt(index: number) {
+ const termItem = this.page
+ .locator('[data-testid^="term-item-"]')
+ .nth(index);
+ await termItem.getByTestId("term-expand-button").click();
+ // Wait for animation
+ await this.page.waitForTimeout(500);
+ }
+
+ /**
+ * Ensures a term at the specified index is expanded.
+ * If already expanded, does nothing. If collapsed, expands it.
+ *
+ * @param index - The 0-based index of the term.
+ */
+ async ensureTermExpandedAt(index: number) {
+ const termItem = this.page
+ .locator('[data-testid^="term-item-"]')
+ .nth(index);
+ await termItem.waitFor({ state: "visible" });
+ const state = await termItem
+ .getByTestId("term-expand-button")
+ .getAttribute("data-state");
+ if (state !== "open") {
+ await termItem.getByTestId("term-expand-button").click();
+ }
+ // Wait for expanded content to be visible (confirms animation is complete)
+ await termItem
+ .getByTestId("term-expanded-content")
+ .waitFor({ state: "visible" });
+ }
+
+ /**
+ * Checks if a term at the specified index is expanded.
+ *
+ * @param index - The 0-based index of the term.
+ * @returns True if expanded, false otherwise.
+ */
+ async isTermExpandedAt(index: number): Promise {
+ const termItem = this.page
+ .locator('[data-testid^="term-item-"]')
+ .nth(index);
+ const state = await termItem
+ .getByTestId("term-expand-button")
+ .getAttribute("data-state");
+ return state === "open";
+ }
+
+ /**
+ * Deletes a term at the specified index.
+ *
+ * @param index - The 0-based index of the term.
+ */
+ async deleteTermAt(index: number) {
+ this.page.once("dialog", (dialog) => dialog.accept());
+ const termItem = this.page
+ .locator('[data-testid^="term-item-"]')
+ .nth(index);
+ await termItem.getByTestId("term-delete-button").click();
+ }
+
+ /**
+ * Updates a term at the specified index.
+ *
+ * @param index - The 0-based index of the term.
+ * @param data - The update data.
+ */
+ async updateTermAt(
+ index: number,
+ data: {
+ name?: string;
+ startDate?: string;
+ endDate?: string;
+ skNumber?: string;
+ skDate?: string;
+ description?: string;
+ },
+ ) {
+ const termItem = this.page
+ .locator('[data-testid^="term-item-"]')
+ .nth(index);
+ await termItem.scrollIntoViewIfNeeded();
+ await termItem.getByTestId("term-edit-button").click();
+ await this.termForm.waitFor({ state: "visible", timeout: 5000 });
+
+ if (data.name) await this.termNameInput.fill(data.name);
+ if (data.startDate) await this.termStartDateInput.fill(data.startDate);
+ if (data.endDate) await this.termEndDateInput.fill(data.endDate);
+ if (data.skNumber) await this.termSkNumberInput.fill(data.skNumber);
+ if (data.skDate) await this.termSkDateInput.fill(data.skDate);
+ if (data.description)
+ await this.termDescriptionInput.fill(data.description);
+
+ await this.termSubmitButton.click();
+ await this.termForm.waitFor({ state: "hidden" });
+ }
+
+ /**
+ * Uploads an attachment to a term.
+ *
+ * @param index - The 0-based index of the term.
+ * @param fileName - The name of the attachment.
+ * @param filePath - The path to the file.
+ */
+ async uploadTermAttachment(
+ index: number,
+ fileName: string,
+ filePath: string,
+ ) {
+ const termItem = this.page
+ .locator('[data-testid^="term-item-"]')
+ .nth(index);
+
+ // Expand if not already - check for expansion button state or content visibility
+ // If not visible, click expand
+ // Expand if not already
+ if (!(await this.isTermExpandedAt(index))) {
+ await this.toggleTermExpandAt(index);
+ }
+
+ const fileInput = termItem.getByTestId("attachment-file-input");
+ const nameInput = termItem.getByTestId("attachment-name-input");
+ const uploadBtn = termItem.getByTestId("attachment-submit-button");
+
+ await fileInput.setInputFiles(filePath);
+ await nameInput.fill(fileName);
+ await uploadBtn.click();
+
+ // Wait for success toast to appear (upload done)
+ const successToast = this.page
+ .locator("li[data-sonner-toast]")
+ .filter({ hasText: "Lampiran berhasil diunggah." });
+ await expect(successToast).toBeVisible({ timeout: 15000 });
+
+ // Wait for router.refresh() to complete: network goes idle and term card is stable
+ await this.page.waitForLoadState("networkidle", { timeout: 15000 });
+ // Wait for the term card to re-appear (confirms React re-render is done)
+ await this.page
+ .locator('[data-testid^="term-item-"]')
+ .nth(index)
+ .waitFor({ state: "visible" });
+ }
+
+ /**
+ * Gets all term names from the list.
+ *
+ * @returns An array of term names.
+ */
+ async getTermNames(): Promise {
+ const termHeaders = this.termList.locator("h4");
+ // Wait for the list to be populated if needed
+ await this.page.waitForTimeout(500);
+ return await termHeaders.allTextContents();
+ }
+
+ /**
+ * Gets a specific term item locator by its name.
+ *
+ * @param name - The name of the term.
+ */
+ getTermItem(name: string) {
+ return this.termList.locator('[data-testid^="term-item-"]').filter({
+ hasText: name,
+ });
+ }
}
diff --git a/apps/dash/e2e/pages/setting/CategorySettingsPage.ts b/apps/dash/e2e/pages/setting/CategorySettingsPage.ts
new file mode 100644
index 00000000..0569ae22
--- /dev/null
+++ b/apps/dash/e2e/pages/setting/CategorySettingsPage.ts
@@ -0,0 +1,81 @@
+import { expect, type Locator, type Page } from "@playwright/test";
+
+/**
+ * Page Object Model for Categoriy Settings Page.
+ */
+export class CategorySettingsPage {
+ readonly page: Page;
+ readonly addButton: Locator;
+ readonly dialogTitle: Locator;
+ readonly nameInput: Locator;
+ readonly typeSelect: Locator;
+ readonly submitButton: Locator;
+ readonly deleteButton: Locator;
+ readonly confirmDeleteButton: Locator;
+
+ constructor(page: Page) {
+ this.page = page;
+ this.addButton = page.getByRole("button", {
+ name: /Tambah Kategori|Add Category/i,
+ });
+ this.dialogTitle = page.getByText(/Formulir Kategori|Category Form/i);
+ this.nameInput = page.getByLabel(/Nama Kategori|Category Name/i);
+ this.typeSelect = page.getByRole("combobox");
+ this.submitButton = page.getByRole("button", { name: /Simpan|Save/i });
+ this.deleteButton = page.getByRole("button", { name: /Hapus|Delete/i });
+ this.confirmDeleteButton = page.getByRole("button", {
+ name: /Ya, Hapus|Yes, Delete/i,
+ });
+ }
+
+ /**
+ * Navigates to the category settings page.
+ */
+ async goto() {
+ await this.page.goto("/setting/categories");
+ await expect(this.page).toHaveURL(/\/setting\/categories/);
+ }
+
+ /**
+ * Adds a new category.
+ */
+ async addCategory(payload: { name: string; type: string }) {
+ await this.addButton.click();
+ await expect(this.dialogTitle).toBeVisible();
+
+ await this.nameInput.fill(payload.name);
+
+ // Base UI Select handling matches what's used in other tests
+ await this.typeSelect.click();
+ await this.page
+ .getByRole("option", { name: new RegExp(payload.type, "i") })
+ .click();
+
+ await this.submitButton.click();
+ await expect(this.page.getByRole("dialog")).toBeHidden();
+ }
+
+ /**
+ * Deletes a category by name.
+ */
+ async deleteCategory(name: string) {
+ const row = this.page.locator("tr", { hasText: name });
+ await row.getByRole("button", { name: /Hapus|Delete/i }).click();
+ await this.confirmDeleteButton.click();
+ await expect(this.page.getByRole("alertdialog")).toBeHidden();
+ }
+
+ /**
+ * Checks if category exists in the list.
+ */
+ async expectCategoryVisible(name: string) {
+ await expect(this.page.locator("tr", { hasText: name })).toBeVisible();
+ }
+
+ /**
+ * Checks if category does not exist in the list.
+ */
+ async expectCategoryHidden(name: string) {
+ await expect(this.page.locator("tr", { hasText: name })).toBeHidden();
+ }
+}
diff --git a/apps/dash/next.config.ts b/apps/dash/next.config.ts
index 2f5a39e8..9cf20dc6 100644
--- a/apps/dash/next.config.ts
+++ b/apps/dash/next.config.ts
@@ -33,6 +33,10 @@ const nextConfig: NextConfig = {
protocol: "https",
hostname: "**.pkrbt.id",
},
+ {
+ protocol: "https",
+ hostname: "drive.google.com",
+ },
],
},
};
diff --git a/apps/dash/package.json b/apps/dash/package.json
index df9c7755..75461b4c 100644
--- a/apps/dash/package.json
+++ b/apps/dash/package.json
@@ -28,6 +28,7 @@
"@domus/config": "workspace:*",
"@domus/core": "workspace:*",
"@domus/db": "workspace:*",
+ "@domus/mailer": "workspace:*",
"@domus/storage": "workspace:*",
"@fontsource/inter": "^5.2.8",
"@fontsource/plus-jakarta-sans": "^5.2.8",
diff --git a/apps/dash/public/assets/finance-hero-bg.png b/apps/dash/public/assets/finance-hero-bg.png
new file mode 100644
index 00000000..702d0a86
Binary files /dev/null and b/apps/dash/public/assets/finance-hero-bg.png differ
diff --git a/apps/dash/src/app/[locale]/finance/[year]/[month]/page.tsx b/apps/dash/src/app/[locale]/finance/[year]/[month]/page.tsx
new file mode 100644
index 00000000..19051c1c
--- /dev/null
+++ b/apps/dash/src/app/[locale]/finance/[year]/[month]/page.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { use } from "react";
+import { MonthlyReportPage } from "@/pages/finance/ui/MonthlyReportPage";
+
+interface PageProps {
+ params: Promise<{
+ year: string;
+ month: string;
+ }>;
+}
+
+/**
+ * Monthly Report Page (Thin Export).
+ */
+export default function Page({ params }: PageProps) {
+ const { year, month } = use(params);
+
+ return (
+
+ );
+}
diff --git a/apps/dash/src/features/attachment/actions/attachment.ts b/apps/dash/src/features/attachment/actions/attachment.ts
new file mode 100644
index 00000000..fb97d4a9
--- /dev/null
+++ b/apps/dash/src/features/attachment/actions/attachment.ts
@@ -0,0 +1,85 @@
+"use server";
+
+import {
+ ForbiddenError,
+ fail,
+ type Result,
+ ValidationError,
+} from "@domus/core";
+import type { Attachment } from "@domus/core/entity/attachment";
+import type { AttachmentReferenceType } from "@domus/core/entity/enums";
+import { revalidatePath } from "next/cache";
+import { getAuthSession } from "@/shared/auth/server";
+import { attachment as attachmentService } from "@/shared/core";
+
+export async function getAttachmentsByReferenceAction(
+ referenceId: string,
+ referenceType: AttachmentReferenceType,
+): Promise> {
+ const [session, sessionError] = await getAuthSession();
+ if (sessionError || !session)
+ return fail(sessionError || new ForbiddenError("Unauthorized"));
+
+ return attachmentService.findByReference(
+ session.context,
+ referenceId,
+ referenceType,
+ );
+}
+
+export async function uploadAttachmentAction(
+ formData: FormData,
+): Promise> {
+ const [session, sessionError] = await getAuthSession();
+ if (sessionError || !session)
+ return fail(sessionError || new ForbiddenError("Unauthorized"));
+
+ const referenceId = formData.get("referenceId") as string;
+ const referenceType = formData.get(
+ "referenceType",
+ ) as AttachmentReferenceType;
+ const name = formData.get("name") as string;
+ const file = formData.get("file") as File;
+
+ if (!referenceId || !referenceType || !name || !file) {
+ return fail(new ValidationError("Missing required fields"));
+ }
+
+ const fileData = {
+ name,
+ type: file.type,
+ size: file.size,
+ arrayBuffer: () => file.arrayBuffer(),
+ };
+
+ const res = await attachmentService.upload(
+ session.context,
+ fileData,
+ referenceId,
+ referenceType,
+ );
+
+ const path = formData.get("revalidatePath") as string;
+ if (res[0] && path) {
+ revalidatePath(path);
+ }
+
+ return res;
+}
+
+export async function deleteAttachmentAction(
+ id: string,
+ revalidatePathStr?: string,
+): Promise> {
+ const [session, sessionError] = await getAuthSession();
+ if (sessionError || !session)
+ return fail(sessionError || new ForbiddenError("Unauthorized"));
+
+ const res = await attachmentService.delete(session.context, id);
+
+ if (!res[1] && revalidatePathStr) {
+ revalidatePath(revalidatePathStr);
+ }
+
+ return res;
+}
diff --git a/apps/dash/src/features/attachment/index.ts b/apps/dash/src/features/attachment/index.ts
new file mode 100644
index 00000000..af49fb2b
--- /dev/null
+++ b/apps/dash/src/features/attachment/index.ts
@@ -0,0 +1,3 @@
+export * from "./actions/attachment";
+export * from "./ui/AttachmentList";
+export * from "./ui/AttachmentUpload";
diff --git a/apps/dash/src/features/attachment/ui/AttachmentList.tsx b/apps/dash/src/features/attachment/ui/AttachmentList.tsx
new file mode 100644
index 00000000..84b717ad
--- /dev/null
+++ b/apps/dash/src/features/attachment/ui/AttachmentList.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import type { Attachment } from "@domus/core/entity/attachment";
+import {
+ ExternalLink,
+ File,
+ FileArchive,
+ FileImage,
+ FileText,
+ Trash,
+} from "lucide-react";
+import { useTranslations } from "next-intl";
+import { cn } from "@/shared/ui/common/utils";
+import { Button, buttonVariants } from "@/shared/ui/shadcn/button";
+import { Card } from "@/shared/ui/shadcn/card";
+import { formatBytes } from "@/shared/utils/format";
+
+type AttachmentListProps = {
+ attachments: Attachment[];
+ onDelete?: (id: string) => void;
+ isDeleting?: string | null;
+ canManage?: boolean;
+};
+
+function getFileIcon(mimeType: string | null | undefined) {
+ if (!mimeType) return ;
+ if (mimeType.startsWith("image/"))
+ return ;
+ if (
+ mimeType.includes("pdf") ||
+ mimeType.includes("document") ||
+ mimeType.startsWith("text/")
+ ) {
+ return ;
+ }
+ if (
+ mimeType.includes("zip") ||
+ mimeType.includes("tar") ||
+ mimeType.includes("rar")
+ ) {
+ return ;
+ }
+ return ;
+}
+
+export function AttachmentList({
+ attachments,
+ onDelete,
+ isDeleting,
+ canManage = false,
+}: AttachmentListProps) {
+ const t = useTranslations("AttachmentWidget");
+
+ if (attachments.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {attachments.map((attachment) => (
+
+
+
+ {getFileIcon(attachment.mimeType)}
+
+
+
+
+ {attachment.name}
+
+
+
+ {attachment.size
+ ? formatBytes(attachment.size)
+ : "Unknown size"}
+
+
+
+
+
+
+
+ {t("viewDocument")}
+
+
+ {canManage && onDelete && (
+
+ )}
+
+
+
+ ))}
+
+ );
+}
diff --git a/apps/dash/src/features/attachment/ui/AttachmentUpload.tsx b/apps/dash/src/features/attachment/ui/AttachmentUpload.tsx
new file mode 100644
index 00000000..c1db489e
--- /dev/null
+++ b/apps/dash/src/features/attachment/ui/AttachmentUpload.tsx
@@ -0,0 +1,171 @@
+"use client";
+
+import type { AttachmentReferenceType } from "@domus/core/entity/enums";
+import { UploadCloud, X } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useState } from "react";
+import { toast } from "sonner";
+import { Button } from "@/shared/ui/shadcn/button";
+import { Card, CardContent } from "@/shared/ui/shadcn/card";
+import { Input } from "@/shared/ui/shadcn/input";
+import { Label } from "@/shared/ui/shadcn/label";
+
+type AttachmentUploadProps = {
+ referenceId: string;
+ referenceType: AttachmentReferenceType;
+ onSuccess?: () => void;
+ onError?: (err: Error) => void;
+ onUpload: (formData: FormData) => Promise;
+};
+
+export function AttachmentUpload({
+ referenceId,
+ referenceType,
+ onSuccess,
+ onError,
+ onUpload,
+}: AttachmentUploadProps) {
+ const t = useTranslations("AttachmentWidget");
+ const [file, setFile] = useState(null);
+ const [name, setName] = useState("");
+ const [isUploading, setIsUploading] = useState(false);
+
+ const onSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!file) {
+ toast.error(t("uploadError"));
+ return;
+ }
+
+ if (!name.trim()) {
+ toast.error("Name is required");
+ return;
+ }
+
+ setIsUploading(true);
+
+ try {
+ const formData = new FormData();
+ formData.append("referenceId", referenceId);
+ formData.append("referenceType", referenceType);
+ formData.append("name", name);
+ formData.append("file", file);
+ formData.append("revalidatePath", window.location.pathname);
+
+ const [attachment, err] = await onUpload(formData);
+
+ if (err || !attachment) {
+ if (onError) {
+ onError(err as Error);
+ } else {
+ toast.error(t("uploadError"));
+ }
+ } else {
+ toast.success(t("uploadSuccess"));
+ setName("");
+ setFile(null);
+ onSuccess?.();
+ }
+ } catch (e) {
+ if (onError) {
+ onError(e as Error);
+ } else {
+ toast.error(t("uploadError"));
+ }
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+ const handleFileChange = (e: React.ChangeEvent) => {
+ if (e.target.files?.[0]) {
+ const selectedFile = e.target.files[0];
+ setFile(selectedFile);
+ // Auto-fill name if empty string
+ if (!name) {
+ const fileName = selectedFile.name.split(".").slice(0, -1).join(".");
+ setName(fileName);
+ }
+ }
+ };
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/apps/dash/src/pages/event/ui/EventDetailPage.tsx b/apps/dash/src/pages/event/ui/EventDetailPage.tsx
index d6d33e06..025830ee 100644
--- a/apps/dash/src/pages/event/ui/EventDetailPage.tsx
+++ b/apps/dash/src/pages/event/ui/EventDetailPage.tsx
@@ -1,6 +1,7 @@
"use client";
import {
+ type Attachment,
type Attendance,
AttendanceStatus,
type Event,
@@ -10,6 +11,7 @@ import {
RsvpStatus,
type RsvpSummary,
} from "@domus/core";
+import { AttachmentReferenceType } from "@domus/core/entity/enums";
import { format } from "date-fns";
import { id as idLocale } from "date-fns/locale";
import {
@@ -28,6 +30,12 @@ import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect, useState, useTransition } from "react";
import { toast } from "sonner";
+import {
+ AttachmentList,
+ AttachmentUpload,
+ deleteAttachmentAction,
+ uploadAttachmentAction,
+} from "@/features/attachment";
import { Badge } from "@/shared/ui/shadcn/badge";
import { Button } from "@/shared/ui/shadcn/button";
import {
@@ -50,6 +58,7 @@ interface EventDetailPageProps {
attendance: Attendance | null;
rsvp: Rsvp | null;
rsvpSummary: RsvpSummary | null;
+ attachments: Attachment[];
isAdmin: boolean;
}
@@ -62,6 +71,7 @@ export function EventDetailPage({
attendance: initialAttendance,
rsvp: initialRsvp,
rsvpSummary,
+ attachments,
isAdmin,
}: EventDetailPageProps) {
const t = useTranslations("EventDetailPage");
@@ -399,6 +409,55 @@ export function EventDetailPage({
)}
+ {/* Attachments Section */}
+
+
+
+ {t("attachmentsTitle", { fallback: "Dokumen & Lampiran" })}
+
+
+
+
{
+ const [res, error] = await deleteAttachmentAction(id);
+ if (error) throw new Error(error.message);
+ return res;
+ }
+ : undefined
+ }
+ />
+
+ {isAdmin && (
+
+
+ {t("uploadNewAttachment", {
+ fallback: "Unggah Lampiran Baru",
+ })}
+
+
{
+ toast.success(
+ t("attachmentUploaded", {
+ fallback: "Lampiran berhasil diunggah",
+ }),
+ );
+ router.refresh();
+ }}
+ onError={(err) => {
+ toast.error(err.message);
+ }}
+ onUpload={uploadAttachmentAction}
+ />
+
+ )}
+
+
+
diff --git a/apps/dash/src/pages/event/ui/RecordAttendanceDialog.tsx b/apps/dash/src/pages/event/ui/RecordAttendanceDialog.tsx
index 67d756bf..d264c795 100644
--- a/apps/dash/src/pages/event/ui/RecordAttendanceDialog.tsx
+++ b/apps/dash/src/pages/event/ui/RecordAttendanceDialog.tsx
@@ -67,7 +67,7 @@ export function RecordAttendanceDialog({
search();
}, [debouncedQuery]);
- const handleRecord = async (parishionerId: string) => {
+ const handleRecord = async (parishionerId: string, name: string) => {
// Prevent double-click or multiple simultaneous records
if (isRecording) return;
@@ -82,7 +82,7 @@ export function RecordAttendanceDialog({
}
setLastRecordedId(parishionerId);
- toast.success(t("recordSuccess"));
+ toast.success(t("recordSuccess", { name }));
onSuccess?.();
// Clear last recorded highlight after a while
@@ -174,7 +174,7 @@ export function RecordAttendanceDialog({
handleRecord(p.id)}
+ onClick={() => handleRecord(p.id, p.fullName)}
disabled={isRecording}
icon={
isRecording && lastRecordedId === null ? (
diff --git a/apps/dash/src/pages/finance/actions/financial-period.ts b/apps/dash/src/pages/finance/actions/financial-period.ts
new file mode 100644
index 00000000..6aa6cf09
--- /dev/null
+++ b/apps/dash/src/pages/finance/actions/financial-period.ts
@@ -0,0 +1,176 @@
+"use server";
+
+import {
+ type CreateFinancialPeriod,
+ type FinancialPeriod,
+ fail,
+ ok,
+ type Result,
+} from "@domus/core";
+import { revalidatePath } from "next/cache";
+import { getAuthContext } from "@/shared/auth/server";
+import { financialPeriod } from "@/shared/core";
+import { logger } from "@/shared/core/logger";
+import { ActionError } from "@/shared/error/ActionError";
+
+/**
+ * Retrieves all financial periods.
+ *
+ * @returns Result with the list of financial periods.
+ */
+export async function getFinancialPeriodsAction(): Promise<
+ Result
+> {
+ const [_auth, authError] = await getAuthContext();
+ if (authError) return fail(authError);
+
+ try {
+ const [res, error] = await financialPeriod.findAll();
+ if (error) return fail(error);
+ return ok(res);
+ } catch (e) {
+ return fail(ActionError.from(e));
+ }
+}
+
+/**
+ * Creates a new financial period.
+ *
+ * @param data - The data for the new financial period.
+ * @returns Result with the created financial period.
+ */
+export async function createFinancialPeriodAction(
+ data: CreateFinancialPeriod,
+): Promise> {
+ const [auth, authError] = await getAuthContext();
+ if (authError) return fail(authError);
+
+ logger.info("createFinancialPeriodAction", {
+ month: data.month,
+ year: data.year,
+ userId: auth.userId,
+ });
+
+ try {
+ const [res, error] = await financialPeriod.create(data, auth);
+ if (error) {
+ logger.error("createFinancialPeriodAction: failed", {
+ month: data.month,
+ year: data.year,
+ code: error.code,
+ message: error.message,
+ });
+ return fail(error);
+ }
+
+ revalidatePath("/finance", "page");
+ return ok(res);
+ } catch (e) {
+ return fail(ActionError.from(e));
+ }
+}
+
+/**
+ * Locks a financial period.
+ *
+ * @param id - The ID of the financial period to lock.
+ * @returns Result with void.
+ */
+export async function lockFinancialPeriodAction(
+ id: string,
+): Promise> {
+ const [auth, authError] = await getAuthContext();
+ if (authError) return fail(authError);
+
+ logger.info("lockFinancialPeriodAction", {
+ periodId: id,
+ userId: auth.userId,
+ });
+
+ try {
+ const [res, error] = await financialPeriod.lock(id, auth);
+ if (error) {
+ logger.error("lockFinancialPeriodAction: failed", {
+ periodId: id,
+ code: error.code,
+ message: error.message,
+ });
+ return fail(error);
+ }
+
+ revalidatePath("/finance", "page");
+ return ok(res);
+ } catch (e) {
+ return fail(ActionError.from(e));
+ }
+}
+
+/**
+ * Unlocks a financial period.
+ *
+ * @param id - The ID of the financial period to unlock.
+ * @returns Result with void.
+ */
+export async function unlockFinancialPeriodAction(
+ id: string,
+): Promise> {
+ const [auth, authError] = await getAuthContext();
+ if (authError) return fail(authError);
+
+ logger.info("unlockFinancialPeriodAction", {
+ periodId: id,
+ userId: auth.userId,
+ });
+
+ try {
+ const [res, error] = await financialPeriod.unlock(id, auth);
+ if (error) {
+ logger.error("unlockFinancialPeriodAction: failed", {
+ periodId: id,
+ code: error.code,
+ message: error.message,
+ });
+ return fail(error);
+ }
+
+ revalidatePath("/finance", "page");
+ return ok(res);
+ } catch (e) {
+ return fail(ActionError.from(e));
+ }
+}
+
+/**
+ * Deletes a financial period.
+ *
+ * @param id - The ID of the financial period to delete.
+ * @returns Result with void.
+ */
+export async function deleteFinancialPeriodAction(
+ id: string,
+): Promise> {
+ const [auth, authError] = await getAuthContext();
+ if (authError) return fail(authError);
+
+ logger.info("deleteFinancialPeriodAction", {
+ periodId: id,
+ userId: auth.userId,
+ });
+
+ try {
+ const [, error] = await financialPeriod.delete(id, auth);
+ if (error) {
+ logger.error("deleteFinancialPeriodAction: failed", {
+ periodId: id,
+ code: error.code,
+ message: error.message,
+ });
+ return fail(error);
+ }
+
+ revalidatePath("/finance", "page");
+ return ok(undefined);
+ } catch (e) {
+ return fail(ActionError.from(e));
+ }
+}
diff --git a/apps/dash/src/pages/finance/actions/report.ts b/apps/dash/src/pages/finance/actions/report.ts
new file mode 100644
index 00000000..99d7153c
--- /dev/null
+++ b/apps/dash/src/pages/finance/actions/report.ts
@@ -0,0 +1,100 @@
+"use server";
+
+import {
+ fail,
+ type MonthlyReport,
+ ok,
+ type Result,
+ type YearlyReport,
+} from "@domus/core";
+import { getAuthContext } from "@/shared/auth/server";
+import { transaction } from "@/shared/core";
+import { logger } from "@/shared/core/logger";
+import { ActionError } from "@/shared/error/ActionError";
+
+/**
+ * Retrieves a yearly financial report.
+ *
+ * @param year - The year to retrieve the report for.
+ * @returns Result with the yearly report.
+ */
+export async function getYearlyReportAction(
+ year: number,
+): Promise> {
+ const [auth, authError] = await getAuthContext();
+ if (authError) return fail(authError);
+
+ logger.info("getYearlyReportAction", {
+ userId: auth.userId,
+ year,
+ });
+
+ try {
+ const [res, error] = await transaction.getYearlyReport(year, auth);
+
+ if (error) {
+ logger.error("getYearlyReportAction: failed", {
+ year,
+ code: error.code,
+ message: error.message,
+ });
+ return fail(error);
+ }
+
+ return ok(res);
+ } catch (e) {
+ return fail(ActionError.from(e as Error));
+ }
+}
+
+/**
+ * Retrieves a monthly financial report.
+ *
+ * @param year - The year of the report.
+ * @param month - The month of the report.
+ * @returns Result with the monthly report.
+ */
+export async function getMonthlyReportAction(
+ year: number,
+ month: number,
+): Promise> {
+ const [auth, authError] = await getAuthContext();
+ if (authError) return fail(authError);
+
+ logger.info("getMonthlyReportAction", {
+ userId: auth.userId,
+ year,
+ month,
+ });
+
+ try {
+ // We need to find the periodId for this year/month
+ const [periods, periodError] = await transaction.getYearlyReport(
+ year,
+ auth,
+ );
+ if (periodError) return fail(periodError);
+
+ const period = periods.stats.find((p) => p.month === month);
+ if (!period)
+ return fail(new ActionError("Financial period not found for this month"));
+
+ const [res, error] = await transaction.getMonthlyReport(
+ period.periodId,
+ auth,
+ );
+
+ if (error) {
+ logger.error("getMonthlyReportAction: failed", {
+ periodId: period.periodId,
+ code: error.code,
+ message: error.message,
+ });
+ return fail(error);
+ }
+
+ return ok(res);
+ } catch (e) {
+ return fail(ActionError.from(e as Error));
+ }
+}
diff --git a/apps/dash/src/pages/finance/actions/transaction-category.ts b/apps/dash/src/pages/finance/actions/transaction-category.ts
new file mode 100644
index 00000000..550de904
--- /dev/null
+++ b/apps/dash/src/pages/finance/actions/transaction-category.ts
@@ -0,0 +1,148 @@
+"use server";
+
+import {
+ type CreateTransactionCategory,
+ fail,
+ ok,
+ type Result,
+ type TransactionCategory,
+ type UpdateTransactionCategory,
+} from "@domus/core";
+import { revalidatePath } from "next/cache";
+import { getAuthContext } from "@/shared/auth/server";
+import { transactionCategory } from "@/shared/core";
+import { logger } from "@/shared/core/logger";
+import { ActionError } from "@/shared/error/ActionError";
+
+/**
+ * Retrieves all transaction categories.
+ *
+ * @returns Result with the list of transaction categories.
+ */
+export async function getTransactionCategoriesAction(): Promise<
+ Result
+> {
+ const [, authError] = await getAuthContext();
+ if (authError) return fail(authError);
+
+ try {
+ const [res, error] = await transactionCategory.getCategories();
+ if (error) return fail(error);
+ return ok(res);
+ } catch (e) {
+ return fail(ActionError.from(e));
+ }
+}
+
+/**
+ * Creates a new transaction category.
+ *
+ * @param data - The data for the new transaction category.
+ * @returns Result with the created transaction category.
+ */
+export async function createTransactionCategoryAction(
+ data: CreateTransactionCategory,
+): Promise> {
+ const [auth, authError] = await getAuthContext();
+ if (authError) return fail(authError);
+
+ logger.info("createTransactionCategoryAction", {
+ name: data.name,
+ type: data.type,
+ userId: auth.userId,
+ });
+
+ try {
+ const [res, error] = await transactionCategory.createCategory(auth, data);
+ if (error) {
+ logger.error("createTransactionCategoryAction: failed", {
+ name: data.name,
+ type: data.type,
+ code: error.code,
+ message: error.message,
+ });
+ return fail(error);
+ }
+
+ revalidatePath("/setting/categories", "page");
+ return ok(res);
+ } catch (e) {
+ return fail(ActionError.from(e));
+ }
+}
+
+/**
+ * Updates an existing transaction category.
+ *
+ * @param id - The ID of the transaction category to update.
+ * @param data - The update data.
+ * @returns Result with the updated transaction category.
+ */
+export async function updateTransactionCategoryAction(
+ id: string,
+ data: UpdateTransactionCategory,
+): Promise> {
+ const [auth, authError] = await getAuthContext();
+ if (authError) return fail(authError);
+
+ logger.info("updateTransactionCategoryAction", {
+ categoryId: id,
+ userId: auth.userId,
+ });
+
+ try {
+ const [res, error] = await transactionCategory.updateCategory(
+ auth,
+ id,
+ data,
+ );
+ if (error) {
+ logger.error("updateTransactionCategoryAction: failed", {
+ categoryId: id,
+ code: error.code,
+ message: error.message,
+ });
+ return fail(error);
+ }
+
+ revalidatePath("/setting/categories", "page");
+ return ok(res);
+ } catch (e) {
+ return fail(ActionError.from(e));
+ }
+}
+
+/**
+ * Deletes a transaction category.
+ *
+ * @param id - The ID of the transaction category to delete.
+ * @returns Result with void.
+ */
+export async function deleteTransactionCategoryAction(
+ id: string,
+): Promise> {
+ const [auth, authError] = await getAuthContext();
+ if (authError) return fail(authError);
+
+ logger.info("deleteTransactionCategoryAction", {
+ categoryId: id,
+ userId: auth.userId,
+ });
+
+ try {
+ const [, error] = await transactionCategory.deleteCategory(auth, id);
+ if (error) {
+ logger.error("deleteTransactionCategoryAction: failed", {
+ categoryId: id,
+ code: error.code,
+ message: error.message,
+ });
+ return fail(error);
+ }
+
+ revalidatePath("/setting/categories", "page");
+ return ok(undefined);
+ } catch (e) {
+ return fail(ActionError.from(e));
+ }
+}
diff --git a/apps/dash/src/pages/finance/actions/transaction.ts b/apps/dash/src/pages/finance/actions/transaction.ts
new file mode 100644
index 00000000..d8efdb6a
--- /dev/null
+++ b/apps/dash/src/pages/finance/actions/transaction.ts
@@ -0,0 +1,133 @@
+"use server";
+
+import {
+ type CreateTransaction,
+ CreateTransactionSchema,
+ fail,
+ ok,
+ type Result,
+ type Transaction,
+ type UpdateTransaction,
+} from "@domus/core";
+import { revalidatePath } from "next/cache";
+import { getAuthContext } from "@/shared/auth/server";
+import { transaction } from "@/shared/core";
+import { logger } from "@/shared/core/logger";
+import { ActionError } from "@/shared/error/ActionError";
+
+/**
+ * Creates a new financial transaction.
+ *
+ * @param data - The transaction data to create.
+ * @returns Result with the created transaction.
+ */
+export async function createTransactionAction(
+ rawData: CreateTransaction,
+): Promise> {
+ const [auth, authError] = await getAuthContext();
+ if (authError) return fail(authError);
+
+ const parsed = CreateTransactionSchema.safeParse(rawData);
+ if (!parsed.success) {
+ return fail(new ActionError("Data tidak valid", "VALIDATION_ERROR", 400));
+ }
+ const data = parsed.data;
+
+ logger.info("createTransactionAction", {
+ userId: auth.userId,
+ periodId: data.periodId,
+ type: data.type,
+ amount: data.amount,
+ });
+
+ try {
+ const [res, error] = await transaction.create(data, auth);
+
+ if (error) {
+ logger.error("createTransactionAction: failed", {
+ code: error.code,
+ message: error.message,
+ });
+ return fail(error);
+ }
+
+ revalidatePath("/finance", "layout");
+ return ok(res);
+ } catch (e) {
+ return fail(ActionError.from(e));
+ }
+}
+
+/**
+ * Updates an existing financial transaction.
+ *
+ * @param id - The transaction ID.
+ * @param data - The data to update.
+ * @returns Result with the updated transaction.
+ */
+export async function updateTransactionAction(
+ id: string,
+ data: UpdateTransaction,
+): Promise> {
+ const [auth, authError] = await getAuthContext();
+ if (authError) return fail(authError);
+
+ logger.info("updateTransactionAction", {
+ transactionId: id,
+ userId: auth.userId,
+ });
+
+ try {
+ const [res, error] = await transaction.update(id, data, auth);
+
+ if (error) {
+ logger.error("updateTransactionAction: failed", {
+ transactionId: id,
+ code: error.code,
+ message: error.message,
+ });
+ return fail(error);
+ }
+
+ revalidatePath("/finance", "layout");
+ return ok(res);
+ } catch (e) {
+ return fail(ActionError.from(e));
+ }
+}
+
+/**
+ * Deletes a financial transaction.
+ *
+ * @param id - The transaction ID to delete.
+ * @returns Result with void.
+ */
+export async function deleteTransactionAction(
+ id: string,
+): Promise> {
+ const [auth, authError] = await getAuthContext();
+ if (authError) return fail(authError);
+
+ logger.info("deleteTransactionAction", {
+ transactionId: id,
+ userId: auth.userId,
+ });
+
+ try {
+ const [, error] = await transaction.delete(id, auth);
+
+ if (error) {
+ logger.error("deleteTransactionAction: failed", {
+ transactionId: id,
+ code: error.code,
+ message: error.message,
+ });
+ return fail(error);
+ }
+
+ revalidatePath("/finance", "layout");
+ return ok(undefined);
+ } catch (e) {
+ return fail(ActionError.from(e));
+ }
+}
diff --git a/apps/dash/src/pages/finance/actions/upload-receipt.ts b/apps/dash/src/pages/finance/actions/upload-receipt.ts
new file mode 100644
index 00000000..c97a689e
--- /dev/null
+++ b/apps/dash/src/pages/finance/actions/upload-receipt.ts
@@ -0,0 +1,60 @@
+"use server";
+
+import type { Attachment } from "@domus/core";
+import { AttachmentReferenceType, fail, ok, type Result } from "@domus/core";
+import { getAuthContext } from "@/shared/auth/server";
+import { attachment } from "@/shared/core";
+import { logger } from "@/shared/core/logger";
+import { ActionError } from "@/shared/error/ActionError";
+
+/**
+ * Uploads a transaction receipt to private storage.
+ *
+ * @param transactionId - The ID of the transaction this receipt belongs to.
+ * @param formData - The FormData containing the `file`.
+ * @returns `Promise>`
+ */
+export async function uploadReceiptAction(
+ transactionId: string,
+ formData: FormData,
+): Promise> {
+ const [auth, authError] = await getAuthContext();
+ if (authError) return fail(authError);
+
+ logger.info("uploadReceiptAction: start", {
+ userId: auth.userId,
+ transactionId,
+ });
+
+ const file = formData.get("file") as File | null;
+ if (!file) {
+ return fail(new ActionError("File tidak ditemukan"));
+ }
+
+ // Basic validation (can be extended)
+ if (file.size > 10 * 1024 * 1024) {
+ return fail(new ActionError("Ukuran file terlalu besar (maksimal 10MB)"));
+ }
+
+ try {
+ const result = await attachment.upload(
+ auth,
+ file,
+ transactionId,
+ AttachmentReferenceType.Transaction,
+ );
+
+ if (result[1]) {
+ logger.error("uploadReceiptAction: failed", { error: result[1] });
+ return fail(result[1]);
+ }
+
+ logger.info("uploadReceiptAction: succeeded", {
+ attachmentId: result[0].id,
+ });
+ return ok(result[0]);
+ } catch (error: unknown) {
+ logger.error("uploadReceiptAction: error", { error });
+ return fail(ActionError.from(error));
+ }
+}
diff --git a/apps/dash/src/pages/finance/index.ts b/apps/dash/src/pages/finance/index.ts
index aaf7337d..c857246a 100644
--- a/apps/dash/src/pages/finance/index.ts
+++ b/apps/dash/src/pages/finance/index.ts
@@ -1 +1,2 @@
export { FinancePage } from "./ui/FinancePage";
+export { TransactionCreatePage } from "./ui/TransactionCreatePage";
diff --git a/apps/dash/src/pages/finance/ui/Accordion.tsx b/apps/dash/src/pages/finance/ui/Accordion.tsx
new file mode 100644
index 00000000..9fb75293
--- /dev/null
+++ b/apps/dash/src/pages/finance/ui/Accordion.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion";
+import { ChevronDown } from "lucide-react";
+import * as React from "react";
+import { cn } from "@/shared/ui/common/utils";
+
+const Accordion = React.forwardRef<
+ HTMLDivElement,
+ AccordionPrimitive.Root.Props
+>((props, ref) => );
+Accordion.displayName = "Accordion";
+
+const AccordionItem = React.forwardRef<
+ HTMLDivElement,
+ AccordionPrimitive.Item.Props
+>(({ className, ...props }, ref) => (
+
+));
+AccordionItem.displayName = "AccordionItem";
+
+const AccordionTrigger = React.forwardRef<
+ HTMLButtonElement,
+ AccordionPrimitive.Trigger.Props
+>(({ className, children, ...props }, ref) => (
+
+ svg]:rotate-180",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+));
+AccordionTrigger.displayName = "AccordionTrigger";
+
+const AccordionContent = React.forwardRef<
+ HTMLDivElement,
+ AccordionPrimitive.Panel.Props
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+));
+AccordionContent.displayName = "AccordionContent";
+
+export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
diff --git a/apps/dash/src/pages/finance/ui/CategoryForm.tsx b/apps/dash/src/pages/finance/ui/CategoryForm.tsx
new file mode 100644
index 00000000..0b47768b
--- /dev/null
+++ b/apps/dash/src/pages/finance/ui/CategoryForm.tsx
@@ -0,0 +1,110 @@
+"use client";
+
+import type {
+ CreateTransactionCategory,
+ TransactionCategory,
+} from "@domus/core";
+import { TransactionType } from "@domus/core";
+import { Check, Tag, X } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { PremiumAction, PremiumFooter } from "@/shared/ui/components";
+import { useDomusForm } from "@/shared/ui/fields/context";
+
+/**
+ * Prop types for CategoryForm component.
+ */
+interface CategoryFormProps {
+ /** Initial data for editing existing category */
+ initialData?: TransactionCategory;
+ /** Function to handle form submission */
+ onSubmit: (data: CreateTransactionCategory) => Promise;
+ /** Function to handle cancellation */
+ onCancel: () => void;
+ /** Flag to indicate if the form is submitting */
+ loading?: boolean;
+}
+
+/**
+ * Form component for creating or updating transaction categories using Domus Form System.
+ */
+export function CategoryForm({
+ initialData,
+ onSubmit,
+ onCancel,
+ loading,
+}: CategoryFormProps) {
+ const t = useTranslations("CategoryForm");
+ const enumsT = useTranslations("Enums.TransactionType");
+
+ const form = useDomusForm({
+ defaultValues: {
+ name: initialData?.name ?? "",
+ type: initialData?.type ?? TransactionType.Income,
+ } as CreateTransactionCategory,
+ onSubmit: async ({ value }) => {
+ await onSubmit(value);
+ },
+ });
+
+ return (
+
+ {(field) => (
+ }
+ dataTestId="field-name"
+ />
+ )}
+
+
+
+ {(field) => (
+
+ )}
+
+
+
+ }
+ onClick={onCancel}
+ disabled={loading}
+ type="button"
+ >
+ {t("btnCancel")}
+
+
+ ⌛
+ ) : (
+
+ )
+ }
+ disabled={!form.state.canSubmit || loading}
+ >
+ {loading ? "..." : t("btnSave")}
+
+
+
+ );
+}
diff --git a/apps/dash/src/pages/finance/ui/CategoryList.tsx b/apps/dash/src/pages/finance/ui/CategoryList.tsx
new file mode 100644
index 00000000..7409276b
--- /dev/null
+++ b/apps/dash/src/pages/finance/ui/CategoryList.tsx
@@ -0,0 +1,238 @@
+"use client";
+
+import { type TransactionCategory, TransactionType } from "@domus/core";
+import { Edit, ShieldAlert, Trash2 } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useCallback, useEffect, useState } from "react";
+import { toast } from "sonner";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/shared/ui/shadcn/alert-dialog";
+import { Badge } from "@/shared/ui/shadcn/badge";
+import { Button } from "@/shared/ui/shadcn/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/shared/ui/shadcn/dialog";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/shared/ui/shadcn/table";
+import {
+ deleteTransactionCategoryAction,
+ getTransactionCategoriesAction,
+ updateTransactionCategoryAction,
+} from "../actions/transaction-category";
+import { CategoryForm } from "./CategoryForm";
+
+/**
+ * List component for displaying and managing transaction categories.
+ */
+export function CategoryList() {
+ const t = useTranslations("CategoryPage");
+ const formT = useTranslations("CategoryForm");
+ const enumsT = useTranslations("Enums.TransactionType");
+ const [categories, setCategories] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ // Dialog states
+ const [deleteId, setDeleteId] = useState(null);
+ const [editCategory, setEditCategory] = useState(
+ null,
+ );
+ const [actionLoading, setActionLoading] = useState(false);
+
+ const fetchCategories = useCallback(async () => {
+ setLoading(true);
+ const [res, error] = await getTransactionCategoriesAction();
+ if (!error && res) {
+ setCategories(res);
+ } else {
+ toast.error(t("errorMessage"));
+ }
+ setLoading(false);
+ }, [t]);
+
+ useEffect(() => {
+ fetchCategories();
+ }, [fetchCategories]);
+
+ const handleDelete = async () => {
+ if (!deleteId) return;
+
+ setActionLoading(true);
+ const [, error] = await deleteTransactionCategoryAction(deleteId);
+ setActionLoading(false);
+ setDeleteId(null);
+
+ if (error) {
+ toast.error(t("deleteError"));
+ return;
+ }
+
+ toast.success(t("deleteSuccess"), {
+ id: "delete-category-success",
+ });
+ setDeleteId(null);
+ };
+
+ const handleUpdate = async (data: Partial) => {
+ if (!editCategory) return;
+
+ setActionLoading(true);
+ const [, error] = await updateTransactionCategoryAction(
+ editCategory.id,
+ data,
+ );
+ setActionLoading(false);
+
+ if (error) {
+ toast.error(t("updateError"));
+ return;
+ }
+
+ toast.success(t("updateSuccess"), {
+ id: "update-category-success",
+ });
+ setEditCategory(null);
+ fetchCategories();
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (categories.length === 0) {
+ return (
+
+
+
{t("emptyState")}
+
+ );
+ }
+
+ const selectedDeleteCategory = categories.find((c) => c.id === deleteId);
+
+ return (
+
+
+
+
+ {t("colName")}
+ {t("colType")}
+ {t("colActions")}
+
+
+
+ {categories.map((category) => (
+
+ {category.name}
+
+
+ {enumsT(category.type)}
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+ {/* Edit Dialog */}
+
+
+ {/* Delete Confirmation */}
+
!open && setDeleteId(null)}
+ >
+
+
+ {t("confirmDeleteTitle")}
+
+ {t("confirmDeleteDesc", {
+ name: selectedDeleteCategory?.name ?? "",
+ })}
+
+
+
+
+ {formT("btnCancel")}
+
+ {
+ e.preventDefault();
+ handleDelete();
+ }}
+ disabled={actionLoading}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {actionLoading ? "..." : t("confirmDeleteAction")}
+
+
+
+
+
+ );
+}
diff --git a/apps/dash/src/pages/finance/ui/CategorySettingsPage.tsx b/apps/dash/src/pages/finance/ui/CategorySettingsPage.tsx
new file mode 100644
index 00000000..176c157a
--- /dev/null
+++ b/apps/dash/src/pages/finance/ui/CategorySettingsPage.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import type { CreateTransactionCategory } from "@domus/core";
+import { Plus } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useState } from "react";
+import { toast } from "sonner";
+import { Button } from "@/shared/ui/shadcn/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/shared/ui/shadcn/dialog";
+import { createTransactionCategoryAction } from "../actions/transaction-category";
+import { CategoryForm } from "./CategoryForm";
+import { CategoryList } from "./CategoryList";
+
+/**
+ * Page component for managing transaction categories.
+ * Provides a layout with a title, description, and an "Add Category" button
+ * that opens a dialog for creating a new category.
+ */
+export function CategorySettingsPage() {
+ const t = useTranslations("CategoryPage");
+ const formT = useTranslations("CategoryForm");
+ const [open, setOpen] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [refreshKey, setRefreshKey] = useState(0);
+
+ const handleCreate = async (data: CreateTransactionCategory) => {
+ setLoading(true);
+ const [, error] = await createTransactionCategoryAction(data);
+ setLoading(false);
+
+ if (error) {
+ toast.error(t("createError"));
+ return;
+ }
+
+ toast.success(t("createSuccess"), {
+ id: "create-category-success",
+ });
+ setOpen(false);
+ // Force refresh CategoryList
+ setRefreshKey((prev) => prev + 1);
+ };
+
+ return (
+
+
+
+
{t("title")}
+
{t("description")}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/dash/src/pages/finance/ui/CreateFinancialPeriodDialog.tsx b/apps/dash/src/pages/finance/ui/CreateFinancialPeriodDialog.tsx
new file mode 100644
index 00000000..56878d09
--- /dev/null
+++ b/apps/dash/src/pages/finance/ui/CreateFinancialPeriodDialog.tsx
@@ -0,0 +1,159 @@
+"use client";
+
+import { UserRole } from "@domus/core";
+import { Plus } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useState } from "react";
+import { toast } from "sonner";
+import { useAuthContext } from "@/shared/auth/context";
+import { Button } from "@/shared/ui/shadcn/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/shared/ui/shadcn/dialog";
+import { Label } from "@/shared/ui/shadcn/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/shared/ui/shadcn/select";
+import { createFinancialPeriodAction } from "../actions/financial-period";
+
+const MONTHS = [
+ "Januari",
+ "Februari",
+ "Maret",
+ "April",
+ "Mei",
+ "Juni",
+ "Juli",
+ "Agustus",
+ "September",
+ "Oktober",
+ "November",
+ "Desember",
+];
+
+/**
+ * Dialog component for creating a new financial period.
+ * Automatically suggests the current month and year.
+ * Restricted to the Treasurer role.
+ */
+export function CreateFinancialPeriodDialog() {
+ const t = useTranslations("FinancialPeriodForm");
+ const commonT = useTranslations("FinancePage");
+ const { roles } = useAuthContext();
+ const [open, setOpen] = useState(false);
+ const [loading, setLoading] = useState(false);
+
+ const now = new Date();
+ const [month, setMonth] = useState((now.getMonth() + 1).toString());
+ const [year, setYear] = useState(now.getFullYear().toString());
+
+ // Only Treasurer can create periods
+ const isTreasurer = roles.includes(UserRole.Treasurer);
+
+ if (!isTreasurer) return null;
+
+ const handleSubmit = async () => {
+ setLoading(true);
+ const [, error] = await createFinancialPeriodAction({
+ month: Number.parseInt(month, 10),
+ year: Number.parseInt(year, 10),
+ });
+
+ setLoading(false);
+ if (error) {
+ toast.error(commonT("createError"));
+ return;
+ }
+
+ toast.success(commonT("createSuccess"));
+ setOpen(false);
+ };
+
+ const years = Array.from({ length: 5 }, (_, i) =>
+ (now.getFullYear() - 2 + i).toString(),
+ );
+
+ return (
+
+ );
+}
diff --git a/apps/dash/src/pages/finance/ui/FinancePage.tsx b/apps/dash/src/pages/finance/ui/FinancePage.tsx
index d767c705..694cfb37 100644
--- a/apps/dash/src/pages/finance/ui/FinancePage.tsx
+++ b/apps/dash/src/pages/finance/ui/FinancePage.tsx
@@ -1,9 +1,19 @@
+import { ListOrdered, Wallet } from "lucide-react";
import { useTranslations } from "next-intl";
-import { UnderConstruction } from "@/shared/ui/components/UnderConstruction";
+import { PremiumHero } from "@/shared/ui/components/PremiumHero";
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@/shared/ui/shadcn/tabs";
+import { CreateFinancialPeriodDialog } from "./CreateFinancialPeriodDialog";
+import { FinancialPeriodList } from "./FinancialPeriodList";
+import { FinancialReportTable } from "./FinancialReportTable";
/**
* Finance Page component.
- * Displays an under construction notice for the finance section.
+ * Displays the financial period management dashboard and reports.
*
* @returns The FinancePage component.
*/
@@ -11,6 +21,50 @@ export function FinancePage() {
const t = useTranslations("FinancePage");
return (
-
+
+
}
+ orgName="Finance Department"
+ actions={
}
+ />
+
+
+
+
+
+ {t("dashboardTitle")}
+
+
+
+
+ {t("tabReports")}
+
+
+
+ {t("tabPeriods")}
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("periodListTitle")}
+
+
+
+
+
+
+
+
);
}
diff --git a/apps/dash/src/pages/finance/ui/FinancialPeriodList.tsx b/apps/dash/src/pages/finance/ui/FinancialPeriodList.tsx
new file mode 100644
index 00000000..4926c15c
--- /dev/null
+++ b/apps/dash/src/pages/finance/ui/FinancialPeriodList.tsx
@@ -0,0 +1,276 @@
+"use client";
+
+import type { FinancialPeriod } from "@domus/core";
+import { PeriodStatus, UserRole } from "@domus/core";
+import { Lock, PlusCircle, ShieldAlert, Trash2, Unlock } from "lucide-react";
+import Link from "next/link";
+import { useTranslations } from "next-intl";
+import { useCallback, useEffect, useState } from "react";
+import { toast } from "sonner";
+import { useAuthContext } from "@/shared/auth/context";
+import { cn } from "@/shared/ui/common/utils";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/shared/ui/shadcn/alert-dialog";
+import { Badge } from "@/shared/ui/shadcn/badge";
+import { Button, buttonVariants } from "@/shared/ui/shadcn/button";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/shared/ui/shadcn/table";
+import {
+ deleteFinancialPeriodAction,
+ getFinancialPeriodsAction,
+ lockFinancialPeriodAction,
+ unlockFinancialPeriodAction,
+} from "../actions/financial-period";
+
+/**
+ * List component for displaying and managing financial periods.
+ * Shows a table of periods with status, lock/unlock and delete actions.
+ */
+export function FinancialPeriodList() {
+ const t = useTranslations("FinancePage");
+ const enumsT = useTranslations("Enums.PeriodStatus");
+ const { roles } = useAuthContext();
+ const [periods, setPeriods] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ // Dialog states
+ const [confirmOpen, setConfirmOpen] = useState(false);
+ const [confirmType, setConfirmType] = useState<
+ "lock" | "unlock" | "delete" | null
+ >(null);
+ const [selectedId, setSelectedId] = useState(null);
+ const [actionLoading, setActionLoading] = useState(false);
+
+ const isTreasurer = roles.includes(UserRole.Treasurer);
+
+ const fetchPeriods = useCallback(async () => {
+ setLoading(true);
+ const [res, error] = await getFinancialPeriodsAction();
+ if (!error && res) {
+ setPeriods(res);
+ } else {
+ toast.error(t("errorMessage"));
+ }
+ setLoading(false);
+ }, [t]);
+
+ useEffect(() => {
+ fetchPeriods();
+ }, [fetchPeriods]);
+
+ const handleAction = async () => {
+ if (!selectedId || !confirmType) return;
+
+ setActionLoading(true);
+ let error: unknown;
+
+ if (confirmType === "lock") {
+ [, error] = await lockFinancialPeriodAction(selectedId);
+ } else if (confirmType === "unlock") {
+ [, error] = await unlockFinancialPeriodAction(selectedId);
+ } else if (confirmType === "delete") {
+ [, error] = await deleteFinancialPeriodAction(selectedId);
+ }
+
+ setActionLoading(false);
+ setConfirmOpen(false);
+
+ if (error) {
+ toast.error(t(`${confirmType}Error`));
+ return;
+ }
+
+ toast.success(t(`${confirmType}Success`));
+ fetchPeriods();
+ };
+
+ const openConfirm = (id: string, type: "lock" | "unlock" | "delete") => {
+ setSelectedId(id);
+ setConfirmType(type);
+ setConfirmOpen(true);
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (periods.length === 0) {
+ return (
+
+
+
{t("emptyState")}
+
+ );
+ }
+
+ const selectedPeriod = periods.find((p) => p.id === selectedId);
+ const periodLabel = selectedPeriod
+ ? `${new Date(0, selectedPeriod.month - 1).toLocaleString("id-ID", {
+ month: "long",
+ })} ${selectedPeriod.year}`
+ : "";
+
+ return (
+
+
+
+
+ {t("colPeriod")}
+ {t("colStatus")}
+ {t("colLockedBy")}
+ {t("colLockedAt")}
+ {isTreasurer && (
+ {t("colActions")}
+ )}
+
+
+
+ {periods.map((period) => (
+
+
+ {new Date(0, period.month - 1).toLocaleString("id-ID", {
+ month: "long",
+ })}{" "}
+ {period.year}
+
+
+
+ {enumsT(period.status)}
+
+
+ {period.lockedBy || "-"}
+
+ {period.lockedAt
+ ? new Date(period.lockedAt).toLocaleDateString("id-ID")
+ : "-"}
+
+ {isTreasurer && (
+
+
+ {period.status === PeriodStatus.Open && (
+
+
+ {t("btnRecord")}
+
+ )}
+ {period.status === PeriodStatus.Open ? (
+
+ ) : (
+
+ )}
+
+
+
+ )}
+
+ ))}
+
+
+
+
+
+
+
+ {confirmType === "delete"
+ ? t("confirmDeleteTitle")
+ : t(
+ `confirm${confirmType
+ ?.charAt(0)
+ .toUpperCase()}${confirmType?.slice(1)}Title`,
+ )}
+
+
+ {confirmType === "delete"
+ ? t("confirmDeleteDesc", { period: periodLabel })
+ : t(
+ `confirm${confirmType
+ ?.charAt(0)
+ .toUpperCase()}${confirmType?.slice(1)}Desc`,
+ )}
+
+
+
+
+ {t("btnCancel")}
+
+ {
+ e.preventDefault();
+ handleAction();
+ }}
+ disabled={actionLoading}
+ className={
+ confirmType === "delete"
+ ? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ : ""
+ }
+ >
+ {actionLoading
+ ? "..."
+ : confirmType === "delete"
+ ? t("btnDelete")
+ : confirmType
+ ? t(
+ `btn${confirmType
+ .charAt(0)
+ .toUpperCase()}${confirmType.slice(1)}`,
+ )
+ : ""}
+
+
+
+
+
+ );
+}
diff --git a/apps/dash/src/pages/finance/ui/FinancialReportTable.tsx b/apps/dash/src/pages/finance/ui/FinancialReportTable.tsx
new file mode 100644
index 00000000..e719bbce
--- /dev/null
+++ b/apps/dash/src/pages/finance/ui/FinancialReportTable.tsx
@@ -0,0 +1,221 @@
+"use client";
+
+import type { YearlyReport } from "@domus/core";
+import {
+ ArrowRight,
+ ChevronLeft,
+ ChevronRight,
+ FileText,
+ TrendingDown,
+ TrendingUp,
+} from "lucide-react";
+import Link from "next/link";
+import { useTranslations } from "next-intl";
+import { useCallback, useEffect, useState } from "react";
+import { toast } from "sonner";
+import { Button, buttonVariants } from "@/shared/ui/shadcn/button";
+import { Card, CardContent } from "@/shared/ui/shadcn/card";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/shared/ui/shadcn/table";
+import { getYearlyReportAction } from "../actions/report";
+
+/**
+ * Component for displaying the yearly financial report table.
+ */
+export function FinancialReportTable() {
+ const t = useTranslations("FinancePage");
+ const [report, setReport] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [year, setYear] = useState(new Date().getFullYear());
+
+ const fetchReport = useCallback(
+ async (targetYear: number) => {
+ setLoading(true);
+ const [res, error] = await getYearlyReportAction(targetYear);
+ if (!error && res) {
+ setReport(res);
+ } else if (error) {
+ toast.error(t("errorMessage"));
+ }
+ setLoading(false);
+ },
+ [t],
+ );
+
+ useEffect(() => {
+ fetchReport(year);
+ }, [year, fetchReport]);
+
+ const formatCurrency = (amount: number) => {
+ return new Intl.NumberFormat("id-ID", {
+ style: "currency",
+ currency: "IDR",
+ minimumFractionDigits: 0,
+ }).format(amount);
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
{year}
+
+
+
+
+
+
+
+
+
+
+
+ {t("income")}
+
+
+ {formatCurrency(report?.totalIncome ?? 0)}
+
+
+
+
+
+
+
+
+
+
+
+ {t("expense")}
+
+
+ {formatCurrency(report?.totalExpense ?? 0)}
+
+
+
+
+
+
+
+
+
+
+
+ {t("month")}
+ {t("income")}
+ {t("expense")}
+ {t("balance")}
+ {t("actions")}
+
+
+
+ {report?.stats.length === 0 ? (
+
+
+ {t("emptyState")}
+
+
+ ) : (
+ report?.stats.map((stat) => (
+
+
+ {new Date(0, stat.month - 1).toLocaleString("id-ID", {
+ month: "long",
+ })}
+
+
+ {formatCurrency(stat.income)}
+
+
+ {formatCurrency(stat.expense)}
+
+ = 0
+ ? "text-slate-900 dark:text-slate-100"
+ : "text-red-700 dark:text-red-300",
+ )}
+ >
+ {formatCurrency(stat.balance)}
+
+
+
+
+ {t("viewDetail")}
+
+
+
+
+ ))
+ )}
+
+ {report && report.stats.length > 0 && (
+
+
+ {t("total")}
+
+ {formatCurrency(report.totalIncome)}
+
+
+ {formatCurrency(report.totalExpense)}
+
+ = 0 ? "text-primary" : "text-red-700",
+ )}
+ >
+ {formatCurrency(report.netBalance)}
+
+
+
+
+ )}
+
+
+
+ );
+}
+
+function cn(...classes: (string | boolean | undefined)[]) {
+ return classes.filter(Boolean).join(" ");
+}
diff --git a/apps/dash/src/pages/finance/ui/MonthlyReportCollapsible.tsx b/apps/dash/src/pages/finance/ui/MonthlyReportCollapsible.tsx
new file mode 100644
index 00000000..50ae5b7a
--- /dev/null
+++ b/apps/dash/src/pages/finance/ui/MonthlyReportCollapsible.tsx
@@ -0,0 +1,266 @@
+"use client";
+
+import type { MonthlyReport } from "@domus/core";
+import { TrendingDown, TrendingUp, Wallet } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useCallback, useEffect, useState } from "react";
+import { toast } from "sonner";
+import { Badge } from "@/shared/ui/shadcn/badge";
+import { Card, CardContent } from "@/shared/ui/shadcn/card";
+import { Separator } from "@/shared/ui/shadcn/separator";
+import { getMonthlyReportAction } from "../actions/report";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "./Accordion";
+
+interface MonthlyReportCollapsibleProps {
+ year: number;
+ month: number;
+}
+
+/**
+ * Component for displaying the monthly financial report with collapsible categories.
+ */
+export function MonthlyReportCollapsible({
+ year,
+ month,
+}: MonthlyReportCollapsibleProps) {
+ const t = useTranslations("FinancePage");
+ const [report, setReport] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ const fetchReport = useCallback(async () => {
+ setLoading(true);
+ const [res, error] = await getMonthlyReportAction(year, month);
+ if (!error && res) {
+ setReport(res);
+ } else if (error) {
+ toast.error(t("errorMessage"));
+ }
+ setLoading(false);
+ }, [year, month, t]);
+
+ useEffect(() => {
+ fetchReport();
+ }, [fetchReport]);
+
+ const formatCurrency = (amount: number) => {
+ return new Intl.NumberFormat("id-ID", {
+ style: "currency",
+ currency: "IDR",
+ minimumFractionDigits: 0,
+ }).format(amount);
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!report) return null;
+
+ const _monthName = new Date(year, month - 1).toLocaleString("id-ID", {
+ month: "long",
+ });
+
+ return (
+
+ {/* Summary Cards */}
+
+
+
+
+
+
+
+
+ {t("income")}
+
+
+ {formatCurrency(report.totalIncome)}
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("expense")}
+
+
+ {formatCurrency(report.totalExpense)}
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("netBalance")}
+
+
+ {formatCurrency(report.balance)}
+
+
+
+
+
+
+
+ {/* Income Categories */}
+
+
+
+
+
+ {t("incomeByCategory")}
+
+
+
+
+ {report.incomeCategories.map((cat) => (
+
+
+
+
+ {cat.categoryName}
+
+
+ {formatCurrency(cat.amount)}
+
+
+
+
+
+
+
+ {cat.transactions.map((tx) => (
+
+
+
+ {tx.description}
+
+
+ {new Date(tx.date).toLocaleDateString("id-ID", {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ })}
+
+
+
+ {formatCurrency(tx.amount)}
+
+
+ ))}
+
+
+
+
+ ))}
+ {report.incomeCategories.length === 0 && (
+
+ {t("noIncome")}
+
+ )}
+
+
+
+ {/* Expense Categories */}
+
+
+
+
+
+ {t("expenseByCategory")}
+
+
+
+
+ {report.expenseCategories.map((cat) => (
+
+
+
+
+ {cat.categoryName}
+
+
+ {formatCurrency(cat.amount)}
+
+
+
+
+
+
+
+ {cat.transactions.map((tx) => (
+
+
+
+ {tx.description}
+
+
+ {new Date(tx.date).toLocaleDateString("id-ID", {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ })}
+
+
+
+ {formatCurrency(tx.amount)}
+
+
+ ))}
+
+
+
+
+ ))}
+ {report.expenseCategories.length === 0 && (
+
+ {t("noExpense")}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/dash/src/pages/finance/ui/MonthlyReportPage.tsx b/apps/dash/src/pages/finance/ui/MonthlyReportPage.tsx
new file mode 100644
index 00000000..6bfce06e
--- /dev/null
+++ b/apps/dash/src/pages/finance/ui/MonthlyReportPage.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { ChevronLeft, FileText } from "lucide-react";
+import Link from "next/link";
+import { useTranslations } from "next-intl";
+import { cn } from "@/shared/ui/common/utils";
+import { PremiumHero } from "@/shared/ui/components/PremiumHero";
+import { buttonVariants } from "@/shared/ui/shadcn/button";
+import { MonthlyReportCollapsible } from "./MonthlyReportCollapsible";
+
+interface MonthlyReportPageProps {
+ year: number;
+ month: number;
+}
+
+/**
+ * Monthly Report Page component.
+ * Displays detailed category-based report for a specific month.
+ */
+export function MonthlyReportPage({ year, month }: MonthlyReportPageProps) {
+ const t = useTranslations("FinancePage");
+
+ const monthName = new Date(year, month - 1).toLocaleString("id-ID", {
+ month: "long",
+ });
+
+ return (
+
+
}
+ orgName="Finance Department"
+ actions={
+
+
+ {t("btnBackToYearly")}
+
+ }
+ />
+
+
+
+
+
+ );
+}
diff --git a/apps/dash/src/pages/finance/ui/TransactionCreatePage.tsx b/apps/dash/src/pages/finance/ui/TransactionCreatePage.tsx
new file mode 100644
index 00000000..c7a40e1b
--- /dev/null
+++ b/apps/dash/src/pages/finance/ui/TransactionCreatePage.tsx
@@ -0,0 +1,235 @@
+"use client";
+
+import type { TransactionCategory } from "@domus/core";
+import { CreateTransactionSchema, TransactionType } from "@domus/core";
+import { Wallet } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
+import { v7 as uuidv7 } from "uuid";
+import { PremiumHero } from "@/shared/ui/components/PremiumHero";
+import { useDomusForm } from "@/shared/ui/fields";
+import { Button } from "@/shared/ui/shadcn/button";
+import { Card, CardContent } from "@/shared/ui/shadcn/card";
+import { createTransactionAction } from "../actions/transaction";
+import { getTransactionCategoriesAction } from "../actions/transaction-category";
+
+interface TransactionCreatePageProps {
+ /**
+ * Financial period ID where this transaction belongs.
+ */
+ periodId: string;
+}
+
+/**
+ * Page for recording a new financial transaction.
+ * Integrated with receipt mapping and automatic category selection.
+ */
+export function TransactionCreatePage({
+ periodId,
+}: TransactionCreatePageProps) {
+ const t = useTranslations("TransactionForm");
+ const pt = useTranslations("TransactionCreatePage");
+ const router = useRouter();
+ const [categories, setCategories] = useState([]);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ // Pre-generate transaction ID for receipt association
+ const transactionId = useMemo(() => uuidv7(), []);
+
+ useEffect(() => {
+ async function loadCategories() {
+ const [res, error] = await getTransactionCategoriesAction();
+ if (!error && res) {
+ setCategories(res);
+ }
+ }
+ loadCategories();
+ }, []);
+
+ const form = useDomusForm({
+ defaultValues: {
+ id: transactionId,
+ periodId,
+ categoryId: "",
+ type: TransactionType.Expense as TransactionType,
+ amount: 0,
+ description: "",
+ date: new Date(),
+ receiptPhoto: null as string | null,
+ },
+ validators: {
+ onChange: ({ value }) => {
+ const result = CreateTransactionSchema.safeParse(value);
+ if (!result.success) {
+ return result.error.flatten().fieldErrors;
+ }
+ return undefined;
+ },
+ },
+ onSubmit: async ({ value }) => {
+ setIsSubmitting(true);
+ const [_res, error] = await createTransactionAction(value);
+ setIsSubmitting(false);
+
+ if (error) {
+ toast.error(pt("errorToast"));
+ return;
+ }
+
+ toast.success(pt("successToast"));
+ router.push("/finance");
+ router.refresh();
+ },
+ });
+
+ return (
+
+
}
+ orgName="Finance Department"
+ />
+
+
+
+ );
+}
diff --git a/apps/dash/src/pages/notifications/actions/notification-actions.ts b/apps/dash/src/pages/notifications/actions/notification-actions.ts
new file mode 100644
index 00000000..dd0919df
--- /dev/null
+++ b/apps/dash/src/pages/notifications/actions/notification-actions.ts
@@ -0,0 +1,103 @@
+"use server";
+
+import type { Notification } from "@domus/core";
+import { fail, ok, type Result } from "@domus/core";
+import { revalidatePath } from "next/cache";
+import { getAuthContext } from "@/shared/auth/server";
+import { notification } from "@/shared/core/service";
+import { ActionError } from "@/shared/error/ActionError";
+
+/**
+ * Retrieves recent notifications for the current user.
+ *
+ * @param limit - The maximum number of notifications to retrieve.
+ * @returns Result with notifications array.
+ */
+export async function getNotificationsAction(
+ limit = 10,
+): Promise> {
+ const [ctx, authError] = await getAuthContext();
+ if (authError) return fail(authError);
+
+ try {
+ const [data, fetchError] = await notification.findByUserId(
+ ctx.userId,
+ ctx,
+ {
+ limit,
+ },
+ );
+ if (fetchError) return fail(fetchError);
+
+ return ok(data);
+ } catch (e) {
+ return fail(ActionError.from(e));
+ }
+}
+
+/**
+ * Retrieves the count of unread notifications for the current user.
+ *
+ * @returns Result with the unread count.
+ */
+export async function getUnreadCountAction(): Promise> {
+ const [ctx, authError] = await getAuthContext();
+ if (authError) return fail(authError);
+
+ try {
+ const [unreadCount, fetchError] = await notification.countUnreadByUserId(
+ ctx.userId,
+ ctx,
+ );
+ if (fetchError) return fail(fetchError);
+
+ return ok(unreadCount);
+ } catch (e) {
+ return fail(ActionError.from(e));
+ }
+}
+
+/**
+ * Marks a specific notification as read.
+ *
+ * @param id - The ID of the notification to mark as read.
+ * @returns Result with void.
+ */
+export async function markNotificationAsReadAction(
+ id: string,
+): Promise> {
+ const [ctx, authError] = await getAuthContext();
+ if (authError) return fail(authError);
+
+ try {
+ const [, markError] = await notification.markAsRead(id, ctx);
+ if (markError) return fail(markError);
+
+ revalidatePath("/", "layout");
+ return ok(undefined);
+ } catch (e) {
+ return fail(ActionError.from(e));
+ }
+}
+
+/**
+ * Marks all unread notifications for the current user as read.
+ *
+ * @returns Result with void.
+ */
+export async function markAllNotificationsAsReadAction(): Promise<
+ Result
+> {
+ const [ctx, authError] = await getAuthContext();
+ if (authError) return fail(authError);
+
+ try {
+ const [, markError] = await notification.markAllAsRead(ctx.userId, ctx);
+ if (markError) return fail(markError);
+
+ revalidatePath("/", "layout");
+ return ok(undefined);
+ } catch (e) {
+ return fail(ActionError.from(e));
+ }
+}
diff --git a/apps/dash/src/pages/org/actions/term.ts b/apps/dash/src/pages/org/actions/term.ts
new file mode 100644
index 00000000..31500a44
--- /dev/null
+++ b/apps/dash/src/pages/org/actions/term.ts
@@ -0,0 +1,182 @@
+"use server";
+
+import type { Attachment } from "@domus/core";
+import {
+ type CreateTerm,
+ fail,
+ ok,
+ type Result,
+ type Term,
+ type UpdateTerm,
+} from "@domus/core";
+import { AttachmentReferenceType } from "@domus/core/entity/enums";
+import { revalidatePath } from "next/cache";
+import { getAuthContext } from "@/shared/auth/server";
+import {
+ attachment as attachmentService,
+ term as termService,
+} from "@/shared/core";
+import { logger } from "@/shared/core/logger";
+import { ActionError } from "@/shared/error/ActionError";
+
+/**
+ * Creates a new organizational term.
+ *
+ * @param data - The term data to create.
+ * @returns `Promise>` on success.
+ */
+export async function createTermAction(
+ data: CreateTerm,
+): Promise> {
+ const [auth, authError] = await getAuthContext();
+ if (authError) return fail(authError);
+
+ logger.info("createTermAction: start", {
+ organizationId: data.organizationId,
+ userId: auth.userId,
+ });
+
+ try {
+ const [res, error] = await termService.create(data, auth);
+
+ if (error) {
+ logger.error("createTermAction: failed", {
+ organizationId: data.organizationId,
+ code: error.code,
+ message: error.message,
+ });
+ return fail(error);
+ }
+
+ logger.info("createTermAction: succeeded", { id: res.id });
+ revalidatePath(`/org/${data.organizationId}`);
+ return ok(res);
+ } catch (e) {
+ return fail(ActionError.from(e));
+ }
+}
+
+/**
+ * Updates an existing organizational term.
+ *
+ * @param id - The term ID.
+ * @param data - The update data.
+ * @returns `Promise>` on success.
+ */
+export async function updateTermAction(
+ id: string,
+ data: UpdateTerm,
+): Promise> {
+ const [auth, authError] = await getAuthContext();
+ if (authError) return fail(authError);
+
+ logger.info("updateTermAction: start", { id, userId: auth.userId });
+
+ try {
+ const [res, error] = await termService.update(id, data, auth);
+
+ if (error) {
+ logger.error("updateTermAction: failed", {
+ id,
+ code: error.code,
+ message: error.message,
+ });
+ return fail(error);
+ }
+
+ logger.info("updateTermAction: succeeded", { id });
+ revalidatePath(`/org/${res.organizationId}`);
+ return ok(res);
+ } catch (e) {
+ return fail(ActionError.from(e));
+ }
+}
+
+/**
+ * Deletes an organizational term.
+ *
+ * @param id - The term ID.
+ * @returns `Promise>` on success.
+ */
+export async function deleteTermAction(id: string): Promise> {
+ const [auth, authError] = await getAuthContext();
+ if (authError) return fail(authError);
+
+ logger.info("deleteTermAction: start", { id, userId: auth.userId });
+
+ try {
+ const [term, findError] = await termService.findById(id, auth);
+ if (findError) return fail(findError);
+
+ const [_, error] = await termService.delete(id, auth);
+
+ if (error) {
+ logger.error("deleteTermAction: failed", {
+ id,
+ code: error.code,
+ message: error.message,
+ });
+ return fail(error);
+ }
+
+ logger.info("deleteTermAction: succeeded", { id });
+ revalidatePath(`/org/${term.organizationId}`);
+ return ok(undefined);
+ } catch (e) {
+ return fail(ActionError.from(e));
+ }
+}
+
+/**
+ * Retrieves all terms for a specific organization, including their attachments.
+ *
+ * @param organizationId - The organization ID.
+ * @returns `Promise>` on success.
+ */
+export async function getTermsAction(
+ organizationId: string,
+): Promise> {
+ const [auth, authError] = await getAuthContext();
+ if (authError) return fail(authError);
+
+ try {
+ const [terms, error] = await termService.findByOrganizationId(
+ organizationId,
+ auth,
+ );
+ if (error) return fail(error);
+
+ // Ensure unique terms by ID to prevent UI duplication issues
+ const uniqueTerms = terms.filter(
+ (term, index, self) => index === self.findIndex((t) => t.id === term.id),
+ );
+
+ // Fetch attachments for each term
+ const termsWithAttachments = await Promise.all(
+ uniqueTerms.map(async (term) => {
+ const [attachments, attachError] =
+ await attachmentService.findByReference(
+ auth,
+ term.id,
+ AttachmentReferenceType.Term,
+ );
+
+ // Deduplicate attachments to prevent UI duplication issues
+ const uniqueAttachments = attachError
+ ? []
+ : attachments.filter(
+ (a, i, self) => i === self.findIndex((at) => at.id === a.id),
+ );
+
+ return {
+ ...term,
+ attachments: uniqueAttachments,
+ };
+ }),
+ );
+
+ return ok(termsWithAttachments);
+ } catch (e) {
+ return fail(ActionError.from(e));
+ }
+}
diff --git a/apps/dash/src/pages/org/ui/OrgDetailPage.tsx b/apps/dash/src/pages/org/ui/OrgDetailPage.tsx
index 359f267b..c9ceef52 100644
--- a/apps/dash/src/pages/org/ui/OrgDetailPage.tsx
+++ b/apps/dash/src/pages/org/ui/OrgDetailPage.tsx
@@ -1,5 +1,6 @@
"use server";
import { OrgRole, UserRole } from "@domus/core";
+import { AttachmentReferenceType } from "@domus/core/entity/enums";
import { UserPlus, Users } from "lucide-react";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
@@ -7,18 +8,27 @@ import { getTranslations } from "next-intl/server";
import { z } from "zod";
import { getAuthSession } from "@/shared/auth/server";
import {
+ attachment as attachmentService,
organization as organizationService,
unit as unitService,
} from "@/shared/core";
import { DomusCard, DomusCardContent } from "@/shared/ui/components/DomusCard";
import { Badge } from "@/shared/ui/shadcn/badge";
import { Button } from "@/shared/ui/shadcn/button";
+import {
+ createTermAction,
+ deleteTermAction as deleteTermActionSrv,
+ getTermsAction,
+ updateTermAction,
+} from "../actions/term";
import { createUnitAction } from "../actions/unit-create";
import { deleteUnitAction } from "../actions/unit-delete";
import { reorderUnitsAction } from "../actions/unit-reorder";
import { updateUnitAction } from "../actions/unit-update";
import { DeleteOrgButton } from "./components/DeleteOrgButton";
+import { OrgAttachmentSection } from "./components/OrgAttachmentSection";
import { OrgHeader } from "./components/OrgHeader";
+import { TermList } from "./components/TermList";
import { UnitList } from "./components/UnitList";
interface OrgDetailPageProps {
@@ -45,12 +55,26 @@ export async function OrgDetailPage({ params }: OrgDetailPageProps) {
redirect("/auth/login");
}
- // Parallel fetch org and units
- const [[org, orgError], [units, _unitsError]] = await Promise.all([
+ // Parallel fetch org, units, attachments, and terms
+ const [
+ [org, orgError],
+ [units, _unitsError],
+ [attachmentsData, _attError],
+ [termsData, _termsError],
+ ] = await Promise.all([
organizationService.findById(id),
unitService.findByOrganizationId(id),
+ attachmentService.findByReference(
+ session.context,
+ id,
+ AttachmentReferenceType.Organization,
+ ),
+ getTermsAction(id),
]);
+ const attachments = attachmentsData || [];
+ const terms = termsData || [];
+
if (orgError || !org) {
notFound();
}
@@ -96,6 +120,28 @@ export async function OrgDetailPage({ params }: OrgDetailPageProps) {
/>
+
+
+
+
+
+
+
+
{/* Sidebar: Details & Stats */}
diff --git a/apps/dash/src/pages/org/ui/components/OrgAttachmentSection.tsx b/apps/dash/src/pages/org/ui/components/OrgAttachmentSection.tsx
new file mode 100644
index 00000000..95611036
--- /dev/null
+++ b/apps/dash/src/pages/org/ui/components/OrgAttachmentSection.tsx
@@ -0,0 +1,91 @@
+"use client";
+
+import { type Attachment, AttachmentReferenceType } from "@domus/core";
+import { FileEdit } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { useTransition } from "react";
+import { toast } from "sonner";
+import {
+ AttachmentList,
+ AttachmentUpload,
+ deleteAttachmentAction,
+ uploadAttachmentAction,
+} from "@/features/attachment";
+import { DomusCard, DomusCardContent } from "@/shared/ui/components/DomusCard";
+
+interface OrgAttachmentSectionProps {
+ organizationId: string;
+ attachments: Attachment[];
+ canReview: boolean;
+}
+
+export function OrgAttachmentSection({
+ organizationId,
+ attachments,
+ canReview,
+}: OrgAttachmentSectionProps) {
+ const t = useTranslations("OrgDetailPage");
+ const router = useRouter();
+ const [_isPending, startTransition] = useTransition();
+
+ return (
+
+
+
+
+ {t("attachmentsTitle", { fallback: "Dokumen & Lampiran" })}
+
+
+
{
+ const [res, error] = await deleteAttachmentAction(
+ id,
+ window.location.pathname,
+ );
+ if (error) throw new Error(error.message);
+ startTransition(() => {
+ router.refresh();
+ });
+ return res;
+ }
+ : undefined
+ }
+ />
+
+ {canReview && (
+
+
+ {t("uploadNewAttachment", { fallback: "Unggah Lampiran Baru" })}
+
+
{
+ toast.success(
+ t("attachmentUploaded", {
+ fallback: "Lampiran berhasil diunggah",
+ }),
+ );
+ startTransition(() => {
+ router.refresh();
+ });
+ }}
+ onError={(err) => {
+ toast.error(err.message);
+ }}
+ onUpload={uploadAttachmentAction}
+ />
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/dash/src/pages/org/ui/components/TermForm.tsx b/apps/dash/src/pages/org/ui/components/TermForm.tsx
new file mode 100644
index 00000000..f468ed79
--- /dev/null
+++ b/apps/dash/src/pages/org/ui/components/TermForm.tsx
@@ -0,0 +1,249 @@
+"use client";
+
+import {
+ type CreateTerm,
+ CreateTermSchema,
+ type Result,
+ type Term,
+ UpdateTermSchema,
+} from "@domus/core";
+import { Check, X } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useState } from "react";
+import { PremiumAction, PremiumFooter } from "@/shared/ui/components";
+import { useDomusForm } from "@/shared/ui/fields/context";
+
+/**
+ * Props for the TermForm component.
+ */
+type TermFormProps = {
+ /**
+ * The organization ID this term belongs to.
+ */
+ organizationId: string;
+ /**
+ * Action function to handle submission.
+ */
+ action: (data: CreateTerm) => Promise>;
+ /**
+ * Default values for edit mode.
+ */
+ defaultValues?: Partial;
+ /**
+ * Whether this is an update form.
+ */
+ isUpdate?: boolean;
+ /**
+ * Callback on successful submission.
+ */
+ onSuccess?: () => void;
+ /**
+ * Callback on cancel/close.
+ */
+ onCancel?: () => void;
+};
+
+/**
+ * Formats a Date object to YYYY-MM-DD string for HTML5 date input.
+ */
+const formatDate = (date: Date | null | undefined): string => {
+ if (!date) return "";
+ const d = new Date(date);
+ return d.toISOString().split("T")[0];
+};
+
+/**
+ * Values for the term form, where dates are handled as strings for the input field.
+ */
+type TermFormValues = Omit & {
+ startDate: string;
+ endDate: string;
+ skDate: string;
+};
+
+/**
+ * TermForm component for managing organizational terms (Masa Jabatan).
+ * Uses standardized useDomusForm with premium field components.
+ *
+ * @param props - Component properties.
+ */
+export function TermForm({
+ organizationId,
+ action,
+ defaultValues,
+ isUpdate,
+ onSuccess,
+ onCancel,
+}: TermFormProps) {
+ const t = useTranslations("TermForm");
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [actionError, setActionError] = useState(null);
+
+ const form = useDomusForm({
+ defaultValues: {
+ organizationId,
+ name: defaultValues?.name ?? "",
+ startDate: formatDate(defaultValues?.startDate),
+ endDate: formatDate(defaultValues?.endDate),
+ skNumber: defaultValues?.skNumber ?? "",
+ skDate: formatDate(defaultValues?.skDate),
+ description: defaultValues?.description ?? "",
+ } as TermFormValues,
+ validators: {
+ // biome-ignore lint/suspicious/noExplicitAny: Complex Zod/TanStack Form type mismatch with dates/strings
+ onChange: (isUpdate ? UpdateTermSchema : CreateTermSchema) as any,
+ },
+ onSubmit: async ({ value }) => {
+ setIsSubmitting(true);
+ setActionError(null);
+
+ // Transform strings back to Dates for the action
+ const data: CreateTerm = {
+ ...value,
+ startDate: value.startDate ? new Date(value.startDate) : null,
+ endDate: value.endDate ? new Date(value.endDate) : null,
+ skDate: value.skDate ? new Date(value.skDate) : null,
+ };
+
+ const [_, error] = await action(data);
+
+ setIsSubmitting(false);
+
+ if (!error) {
+ onSuccess?.();
+ } else {
+ setActionError(error.message);
+ }
+ },
+ });
+
+ return (
+
+ );
+}
diff --git a/apps/dash/src/pages/org/ui/components/TermList.tsx b/apps/dash/src/pages/org/ui/components/TermList.tsx
new file mode 100644
index 00000000..33bd8c86
--- /dev/null
+++ b/apps/dash/src/pages/org/ui/components/TermList.tsx
@@ -0,0 +1,355 @@
+"use client";
+
+import type {
+ Attachment,
+ CreateTerm,
+ Result,
+ Term,
+ UpdateTerm,
+} from "@domus/core";
+import { AttachmentReferenceType } from "@domus/core/entity/enums";
+import {
+ Calendar,
+ ChevronDown,
+ ChevronUp,
+ FileEdit,
+ Pencil,
+ Plus,
+ Trash2,
+} from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { useState, useTransition } from "react";
+import { toast } from "sonner";
+
+import {
+ AttachmentList,
+ AttachmentUpload,
+ deleteAttachmentAction,
+ uploadAttachmentAction,
+} from "@/features/attachment";
+import { Button } from "@/shared/ui/shadcn/button";
+import { Card, CardContent } from "@/shared/ui/shadcn/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogTitle,
+} from "@/shared/ui/shadcn/dialog";
+import { TermForm } from "./TermForm";
+
+type TermWithAttachments = Term & {
+ attachments?: Attachment[];
+};
+
+type TermListProps = {
+ /**
+ * Initial list of terms for the organization.
+ */
+ terms: TermWithAttachments[];
+ /**
+ * The organization ID.
+ */
+ organizationId: string;
+ /**
+ * Whether the user can manage terms.
+ */
+ canManage: boolean;
+ /**
+ * Action to create a term.
+ */
+ createTermAction: (data: CreateTerm) => Promise>;
+ /**
+ * Action to update a term.
+ */
+ updateTermAction: (id: string, data: UpdateTerm) => Promise>;
+ /**
+ * Action to delete a term.
+ */
+ deleteTermAction: (id: string) => Promise>;
+};
+
+/**
+ * TermList component for managing organizational terms (Masa Jabatan).
+ */
+export function TermList({
+ terms: initialTerms,
+ organizationId,
+ canManage,
+ createTermAction,
+ updateTermAction,
+ deleteTermAction,
+}: TermListProps) {
+ const t = useTranslations("TermList");
+ const router = useRouter();
+ const [_isPending, startTransition] = useTransition();
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+ const [editingTerm, setEditingTerm] = useState(null);
+ const [expandedTerms, setExpandedTerms] = useState>(
+ {},
+ );
+
+ const terms = initialTerms;
+
+ const handleCreate = () => {
+ setEditingTerm(null);
+ setIsDialogOpen(true);
+ };
+
+ const handleEdit = (term: Term) => {
+ setEditingTerm(term);
+ setIsDialogOpen(true);
+ };
+
+ const handleDelete = async (id: string) => {
+ if (
+ !confirm(
+ t("confirmDelete") || "Are you sure you want to delete this term?",
+ )
+ )
+ return;
+
+ const [_, error] = await deleteTermAction(id);
+ if (!error) {
+ toast.success(t("deleteSuccess") || "Term deleted successfully");
+ } else {
+ toast.error(error.message);
+ }
+ };
+
+ const toggleExpand = (id: string) => {
+ setExpandedTerms((prev) => ({ ...prev, [id]: !prev[id] }));
+ };
+
+ const formatDate = (date: Date | null) => {
+ if (!date) return "-";
+ return new Date(date).toLocaleDateString("id-ID", {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ });
+ };
+
+ return (
+
+
+
+
+ {t("title") || "Masa Jabatan"}
+
+
+ {t("subtitle") || "Kelola periode kepengurusan dan SK organisasi."}
+
+
+ {canManage && (
+
+ )}
+
+
+
+ {terms.length === 0 ? (
+
+
+ {t("emptyState") || "Belum ada masa jabatan."}
+
+ {canManage && (
+
+ )}
+
+ ) : (
+ terms.map((term) => (
+
+
+
+
+
+
+
+
+ {term.name}
+
+
+ {formatDate(term.startDate)}
+ •
+ {formatDate(term.endDate)}
+
+
+
+ {canManage && (
+ <>
+
+
+ >
+ )}
+
+
+
+
+ {expandedTerms[term.id] && (
+
+
+ {/* Term Info */}
+
+
+
+ {t("skInfo") || "Informasi SK"}
+
+
+ {term.skNumber || "-"}
+
+
+ {term.skDate
+ ? formatDate(term.skDate)
+ : t("noSkDate") || "Tanggal SK tidak tersedia"}
+
+
+ {term.description && (
+
+
+ {t("description") || "Keterangan"}
+
+
+ {term.description}
+
+
+ )}
+
+
+ {/* Attachments Section */}
+
+
+
+ {t("attachments") || "Lampiran (SK/Dokumen)"}
+
+
+
{
+ const [_, err] = await deleteAttachmentAction(
+ id,
+ window.location.pathname,
+ );
+ if (err) throw err;
+ startTransition(() => {
+ router.refresh();
+ });
+ }}
+ />
+
+ {canManage && (
+
+
{
+ startTransition(() => {
+ router.refresh();
+ });
+ }}
+ />
+
+ )}
+
+
+
+ )}
+
+
+ ))
+ )}
+
+
+ {/* Create/Edit Dialog */}
+
+
+ );
+}
diff --git a/apps/dash/src/pages/setting/ui/components/UserList.tsx b/apps/dash/src/pages/setting/ui/components/UserList.tsx
index ca6235f4..dcfbd952 100644
--- a/apps/dash/src/pages/setting/ui/components/UserList.tsx
+++ b/apps/dash/src/pages/setting/ui/components/UserList.tsx
@@ -1,12 +1,13 @@
"use client";
-import type { Result, User } from "@domus/core";
+import type { Result, User, UserRole } from "@domus/core";
import {
AlertCircle,
ShieldCheck,
ShieldPlus,
User as UserIcon,
} from "lucide-react";
+import Image from "next/image";
import { useTranslations } from "next-intl";
import { use, useTransition } from "react";
import { toast } from "sonner";
@@ -84,7 +85,10 @@ export function UserList({ promise, canManage = false }: UserListProps) {
role: string,
) => {
const confirmed = window.confirm(
- t("confirmAssignDesc", { name: userName, role: tEnums(role as any) }),
+ t("confirmAssignDesc", {
+ name: userName,
+ role: tEnums(role as UserRole),
+ }),
);
if (!confirmed) return;
@@ -102,7 +106,10 @@ export function UserList({ promise, canManage = false }: UserListProps) {
toast.error(t("errorAssign"), { description: error.message });
} else {
toast.success(
- t("successAssign", { name: userName, role: tEnums(role as any) }),
+ t("successAssign", {
+ name: userName,
+ role: tEnums(role as UserRole),
+ }),
);
}
});
@@ -151,9 +158,11 @@ export function UserList({ promise, canManage = false }: UserListProps) {
{user.image ? (
-

) : (
diff --git a/apps/dash/src/shared/core/index.ts b/apps/dash/src/shared/core/index.ts
index 3444eced..d8d7a8fb 100644
--- a/apps/dash/src/shared/core/index.ts
+++ b/apps/dash/src/shared/core/index.ts
@@ -14,10 +14,12 @@ export const service = {
parishioner: s.parishioner,
rsvp: s.rsvp,
transaction: s.transaction,
+ transactionCategory: s.transactionCategory,
user: s.user,
diocese: s.diocese,
parish: s.parish,
vicariate: s.vicariate,
+ attachment: s.attachment,
};
export default service;
diff --git a/apps/dash/src/shared/core/listeners.ts b/apps/dash/src/shared/core/listeners.ts
index e6266c40..c9953a9d 100644
--- a/apps/dash/src/shared/core/listeners.ts
+++ b/apps/dash/src/shared/core/listeners.ts
@@ -91,10 +91,14 @@ export function registerListeners(): void {
});
// Send email notification
- await notification.create({
+ const [notifEmail, _errorEmail] = await notification.create({
...payload,
channel: NotificationChannel.Email,
});
+
+ if (notifEmail) {
+ await notification.send(notifEmail.id);
+ }
});
emitter.on(EnrollmentRejected, async (event) => {
@@ -104,7 +108,7 @@ export function registerListeners(): void {
});
// Send email notification only for rejection
- await notification.create({
+ const [notifEmail, _errorEmail] = await notification.create({
userId: event.userId,
title: "Pendaftaran Ditolak",
body: `Halo ${event.parishionerName}, mohon maaf pendaftaran Anda belum dapat disetujui.`,
@@ -113,6 +117,10 @@ export function registerListeners(): void {
referenceType: "Enrollment",
channel: NotificationChannel.Email,
});
+
+ if (notifEmail) {
+ await notification.send(notifEmail.id);
+ }
});
// ---------------------------------------------------------------------------
diff --git a/apps/dash/src/shared/core/service.ts b/apps/dash/src/shared/core/service.ts
index f140d4f6..2b16c9d8 100644
--- a/apps/dash/src/shared/core/service.ts
+++ b/apps/dash/src/shared/core/service.ts
@@ -1,6 +1,9 @@
export const runtime = "nodejs";
+import config from "@domus/config";
import {
+ AttachmentReferenceType,
+ AttachmentService,
AttendanceService,
DioceseService,
EnrollmentService,
@@ -14,19 +17,23 @@ import {
PlacementService,
RsvpService,
TermService,
+ TransactionCategoryService,
TransactionService,
UnitService,
UserService,
VicariateService,
} from "@domus/core";
import { createRepositories, db } from "@domus/db";
+import { NodemailerMailer } from "@domus/mailer";
import { emitter } from "./emitter";
import { logger } from "./logger";
import { privateStorage, publicStorage } from "./storage";
-// Repositories (Batched via factory)
const repo = createRepositories(db, logger);
+// Mailer
+const mailer = new NodemailerMailer(config.email);
+
// Services
export const attendance = new AttendanceService(
repo.attendance,
@@ -55,8 +62,10 @@ export const member = new MemberService(
);
export const notification = new NotificationService(
repo.notification,
+ repo.user,
publicStorage,
privateStorage,
+ mailer,
logger,
);
export const enrollment = new EnrollmentService(
@@ -76,12 +85,7 @@ export const unit = new UnitService(
privateStorage,
logger,
);
-export const term = new TermService(
- repo.term,
- publicStorage,
- privateStorage,
- logger,
-);
+export const term = new TermService(repo.term, logger);
export const placement = new PlacementService(
repo.placement,
publicStorage,
@@ -110,10 +114,15 @@ export const rsvp = new RsvpService(
export const transaction = new TransactionService(
repo.transaction,
repo.financialPeriod,
+ repo.transactionCategory,
publicStorage,
privateStorage,
logger,
);
+export const transactionCategory = new TransactionCategoryService(
+ repo.transactionCategory,
+ logger,
+);
export const user = new UserService(
repo.user,
publicStorage,
@@ -123,3 +132,18 @@ export const user = new UserService(
export const diocese = new DioceseService(repo.diocese, logger);
export const vicariate = new VicariateService(repo.vicariate, logger);
export const parish = new ParishService(repo.parish, logger);
+export const attachment = new AttachmentService(
+ repo.attachment,
+ privateStorage,
+ logger,
+ {
+ folderIds: {
+ [AttachmentReferenceType.Event]: process.env.GDRIVE_FOLDER_EVENT ?? "",
+ [AttachmentReferenceType.Organization]:
+ process.env.GDRIVE_FOLDER_ORG_DOCS ?? "",
+ [AttachmentReferenceType.Term]: process.env.GDRIVE_FOLDER_SK_DOC ?? "",
+ [AttachmentReferenceType.Transaction]:
+ process.env.GDRIVE_FOLDER_RECEIPT ?? "",
+ },
+ },
+);
diff --git a/apps/dash/src/shared/i18n/messages/en.json b/apps/dash/src/shared/i18n/messages/en.json
index 282a0e39..a79e83b9 100644
--- a/apps/dash/src/shared/i18n/messages/en.json
+++ b/apps/dash/src/shared/i18n/messages/en.json
@@ -62,6 +62,20 @@
"privacyPolicy": "Privacy Policy",
"termsConditions": "Terms & Conditions"
},
+ "NotificationDropdown": {
+ "title": "Notifications",
+ "unreadCount": "{count} unread",
+ "markAllAsRead": "Mark All as Read",
+ "emptyTitle": "No notifications yet",
+ "emptyDesc": "All the latest updates about your activities will appear here.",
+ "viewAll": "View All",
+ "type": {
+ "Event": "Event",
+ "Organization": "Organization",
+ "Transaction": "Finance",
+ "Enrollment": "Enrollment"
+ }
+ },
"ProfilePage": {
"title": "User Profile Under Development",
"description": "The page for managing your profile and service history will be available soon."
@@ -452,8 +466,51 @@
"description": "Making it easy for you to confirm attendance for various upcoming parish activities."
},
"FinancePage": {
- "title": "Finance Report Coming Soon",
- "description": "We are preparing a real-time and accurate parish finance transparency system for you."
+ "title": "Financial Period Management",
+ "description": "Manage monthly periods for parish transaction recording.",
+ "addPeriod": "Add Period",
+ "colPeriod": "Period",
+ "colStatus": "Status",
+ "colLockedBy": "Locked By",
+ "colLockedAt": "Locked At",
+ "colActions": "Actions",
+ "btnLock": "Lock",
+ "btnUnlock": "Unlock",
+ "btnDelete": "Delete",
+ "btnRecord": "Record Transaction",
+ "emptyState": "No financial periods yet.",
+ "errorTitle": "An Error Occurred",
+ "errorMessage": "Failed to load period list.",
+ "createSuccess": "Period successfully created.",
+ "createError": "Failed to create period.",
+ "lockSuccess": "Period successfully locked.",
+ "lockError": "Failed to lock period.",
+ "unlockSuccess": "Period successfully unlocked.",
+ "unlockError": "Failed to unlock period.",
+ "deleteSuccess": "Period successfully deleted.",
+ "deleteError": "Failed to delete period.",
+ "confirmDeleteTitle": "Delete Period?",
+ "confirmDeleteDesc": "Are you sure you want to delete period {period}? Deleted data cannot be recovered.",
+ "confirmUnlockDesc": "Unlocking allows adding or changing transactions in this period.",
+ "dashboardTitle": "Financial Dashboard",
+ "tabReports": "Reports",
+ "tabPeriods": "Periods",
+ "periodListTitle": "Financial Period List",
+ "yearlyReportTitle": "Yearly Financial Report",
+ "monthlyDetailTitle": "Financial Report Details",
+ "income": "Income",
+ "expense": "Expense",
+ "balance": "Balance",
+ "netBalance": "Net Balance",
+ "total": "Total",
+ "month": "Month",
+ "actions": "Actions",
+ "viewDetail": "View Detail",
+ "category": "Category",
+ "amount": "Amount",
+ "transactions": "Transaction List",
+ "noTransactions": "No transactions.",
+ "backToYearly": "Back to Yearly Report"
},
"EventPage": {
"title": "Event Schedule in Progress",
@@ -662,5 +719,87 @@
"noPending": "All attendance requests reviewed",
"noAttendances": "No attendance data yet",
"emptyState": "No attendance data yet"
+ },
+ "EventAttendance": {
+ "btnRecordTitle": "Input Attendance",
+ "recordModalTitle": "Manual Attendance Input",
+ "recordModalSubtitle": "Record parishioner attendance directly",
+ "searchPlaceholder": "Search name or email...",
+ "btnRecord": "Record Attendance",
+ "btnHadir": "Present",
+ "recordInstruction": "Click 'Present' to record attendance.",
+ "noResults": "No parishioner found.",
+ "recordSuccess": "Attendance successfully recorded for {name}."
+ },
+ "CategoryPage": {
+ "title": "Transaction Categories",
+ "description": "Manage income and expense categories for financial bookkeeping.",
+ "addCategory": "Add Category",
+ "colName": "Category Name",
+ "colType": "Type",
+ "colActions": "Actions",
+ "emptyState": "No transaction categories yet.",
+ "errorTitle": "Error Occurred",
+ "errorMessage": "Failed to load category list.",
+ "createSuccess": "Category successfully created.",
+ "createError": "Failed to create category.",
+ "updateSuccess": "Category successfully updated.",
+ "updateError": "Failed to update category.",
+ "deleteSuccess": "Category successfully deleted.",
+ "deleteError": "Failed to delete category.",
+ "confirmDeleteTitle": "Delete Category?",
+ "confirmDeleteDesc": "Are you sure you want to delete the category {name}? Deleted categories cannot be used for new transactions.",
+ "btnEdit": "Edit",
+ "btnDelete": "Delete"
+ },
+ "CategoryForm": {
+ "titleCreate": "Add New Category",
+ "titleUpdate": "Edit Category",
+ "nameLabel": "Category Name",
+ "namePlaceholder": "Example: Collection 1, Electricity, etc.",
+ "typeLabel": "Transaction Type",
+ "btnSave": "Save",
+ "btnCancel": "Cancel"
+ },
+ "TransactionCreatePage": {
+ "title": "Record New Transaction",
+ "description": "Record income or expense details for this period.",
+ "successToast": "Transaction successfully recorded.",
+ "errorToast": "Failed to record transaction."
+ },
+ "TransactionDetailPage": {
+ "title": "Transaction Details",
+ "description": "View or update financial transaction details.",
+ "successToast": "Transaction successfully updated.",
+ "errorToast": "Failed to update transaction.",
+ "deleteSuccess": "Transaction successfully deleted.",
+ "deleteError": "Failed to delete transaction.",
+ "confirmDeleteTitle": "Delete Transaction?",
+ "confirmDeleteDesc": "Are you sure you want to delete this {amount} transaction? This action cannot be undone."
+ },
+ "TransactionForm": {
+ "sectionBasic": "Transaction Information",
+ "sectionEvidence": "Transaction Evidence",
+ "dateLabel": "Transaction Date",
+ "amountLabel": "Amount (IDR)",
+ "amountPlaceholder": "Example: 50000",
+ "categoryLabel": "Category",
+ "categoryPlaceholder": "Select category...",
+ "noteLabel": "Note / Description",
+ "notePlaceholder": "Describe the transaction details...",
+ "receiptLabel": "Receipt / Invoice Photo (Optional)",
+ "receiptDescription": "Upload transaction evidence for digital archive. Max 1.5MB.",
+ "btnSave": "Save Transaction",
+ "btnCancel": "Cancel"
+ },
+ "ReceiptUploadField": {
+ "clickToUpload": "Click to upload receipt",
+ "formats": "JPG, PNG, WebP, or PDF (Max 1.5MB)",
+ "uploading": "Uploading...",
+ "uploaded": "Successfully Uploaded",
+ "errorInvalidType": "File format not supported!",
+ "errorUploadFailed": "Failed to upload to server.",
+ "errorUploadInternal": "Internal error occurred during upload.",
+ "successUpload": "Receipt attached successfully!"
}
}
diff --git a/apps/dash/src/shared/i18n/messages/id.json b/apps/dash/src/shared/i18n/messages/id.json
index 11b506bb..c49c8002 100644
--- a/apps/dash/src/shared/i18n/messages/id.json
+++ b/apps/dash/src/shared/i18n/messages/id.json
@@ -62,6 +62,20 @@
"privacyPolicy": "Kebijakan Privasi",
"termsConditions": "Syarat & Ketentuan"
},
+ "NotificationDropdown": {
+ "title": "Notifikasi",
+ "unreadCount": "{count} belum dibaca",
+ "markAllAsRead": "Tandai Semua Dibaca",
+ "emptyTitle": "Belum ada notifikasi",
+ "emptyDesc": "Semua kabar terbaru tentang aktivitas Anda akan muncul di sini.",
+ "viewAll": "Lihat Semua",
+ "type": {
+ "Event": "Kegiatan",
+ "Organization": "Organisasi",
+ "Transaction": "Keuangan",
+ "Enrollment": "Pendaftaran"
+ }
+ },
"ProfilePage": {
"title": "Profil Pengguna Sedang Dikembangkan",
"description": "Halaman untuk mengelola data diri dan riwayat pelayanan Anda akan segera tersedia."
@@ -247,7 +261,10 @@
"confirmDeleteCancel": "Batal",
"confirmDeleteAction": "Hapus",
"deleteSuccess": "Organisasi berhasil dihapus.",
- "deleteError": "Gagal menghapus organisasi."
+ "deleteError": "Gagal menghapus organisasi.",
+ "attachmentsTitle": "Dokumen & Lampiran",
+ "uploadNewAttachment": "Unggah Lampiran Baru",
+ "attachmentUploaded": "Lampiran berhasil diunggah"
},
"OrgPublicPage": {
"ctaJoin": "Daftar Sekarang",
@@ -485,8 +502,62 @@
"description": "Memudahkan Anda memberikan konfirmasi kehadiran untuk berbagai kegiatan paroki mendatang."
},
"FinancePage": {
- "title": "Laporan Keuangan Segera Hadir",
- "description": "Kami sedang menyiapkan sistem transparansi keuangan paroki yang real-time dan akurat untuk Anda."
+ "title": "Manajemen Periode Keuangan",
+ "description": "Kelola periode bulanan untuk pencatatan transaksi paroki.",
+ "addPeriod": "Tambah Periode",
+ "colPeriod": "Periode",
+ "colStatus": "Status",
+ "colLockedBy": "Dikunci Oleh",
+ "colLockedAt": "Waktu Kunci",
+ "colActions": "Aksi",
+ "btnLock": "Kunci",
+ "btnUnlock": "Buka Kunci",
+ "btnDelete": "Hapus",
+ "btnRecord": "Catat Transaksi",
+ "emptyState": "Belum ada periode keuangan.",
+ "errorTitle": "Terjadi Kesalahan",
+ "errorMessage": "Gagal memuat daftar periode.",
+ "createSuccess": "Periode berhasil dibuat.",
+ "createError": "Gagal membuat periode.",
+ "lockSuccess": "Periode berhasil dikunci.",
+ "lockError": "Gagal mengunci periode.",
+ "unlockSuccess": "Periode berhasil dibuka kuncinya.",
+ "unlockError": "Gagal membuka kunci periode.",
+ "deleteSuccess": "Periode berhasil dihapus.",
+ "deleteError": "Gagal menghapus periode.",
+ "confirmDeleteTitle": "Hapus Periode?",
+ "confirmDeleteDesc": "Apakah Anda yakin ingin menghapus periode {period}? Data yang dihapus tidak bisa dikembalikan.",
+ "confirmLockTitle": "Kunci Periode?",
+ "confirmLockDesc": "Setelah dikunci, tidak ada transaksi baru yang bisa ditambahkan ke periode ini.",
+ "confirmUnlockTitle": "Buka Kunci Periode?",
+ "confirmUnlockDesc": "Membuka kunci memungkinkan penambahan atau perubahan transaksi di periode ini.",
+ "dashboardTitle": "Dasbor Keuangan",
+ "tabReports": "Laporan",
+ "tabPeriods": "Periode",
+ "periodListTitle": "Daftar Periode Keuangan",
+ "yearlyReportTitle": "Laporan Keuangan Tahunan",
+ "monthlyDetailTitle": "Detail Laporan Keuangan",
+ "income": "Pemasukan",
+ "expense": "Pengeluaran",
+ "balance": "Saldo",
+ "netBalance": "Saldo Bersih",
+ "total": "Total",
+ "month": "Bulan",
+ "actions": "Aksi",
+ "viewDetail": "Lihat Detail",
+ "category": "Kategori",
+ "amount": "Jumlah",
+ "transactions": "Daftar Transaksi",
+ "noTransactions": "Tidak ada transaksi.",
+ "backToYearly": "Kembali ke Laporan Tahunan"
+ },
+ "FinancialPeriodForm": {
+ "title": "Tambah Periode Keuangan",
+ "description": "Pilih bulan dan tahun untuk periode baru.",
+ "monthLabel": "Bulan",
+ "yearLabel": "Tahun",
+ "btnSave": "Simpan Periode",
+ "btnCancel": "Batal"
},
"EventPage": {
"title": "Kegiatan & Agenda",
@@ -760,13 +831,137 @@
"emptyState": "Belum ada data presensi"
},
"EventAttendance": {
- "btnRecordTitle": "Input Presensi",
- "recordModalTitle": "Input Presensi Manual",
+ "btnRecordTitle": "Input Kehadiran",
+ "recordModalTitle": "Input Kehadiran Manual",
"recordModalSubtitle": "Catat kehadiran umat secara langsung",
"searchPlaceholder": "Cari nama atau email umat...",
- "noResults": "Umat tidak ditemukan",
+ "btnRecord": "Catat Kehadiran",
"btnHadir": "Hadir",
- "recordInstruction": "Klik tombol 'Hadir' untuk mencatat kehadiran secara instan.",
- "recordSuccess": "Kehadiran berhasil dicatat"
+ "recordInstruction": "Klik tombol 'Hadir' untuk mencatat kehadiran.",
+ "noResults": "Umat tidak ditemukan.",
+ "recordSuccess": "Kehadiran berhasil dicatat untuk {name}."
+ },
+ "CategoryPage": {
+ "title": "Kategori Transaksi",
+ "description": "Kelola kategori pemasukan dan pengeluaran untuk pembukuan keuangan.",
+ "addCategory": "Tambah Kategori",
+ "colName": "Nama Kategori",
+ "colType": "Tipe",
+ "colActions": "Aksi",
+ "emptyState": "Belum ada kategori transaksi.",
+ "errorTitle": "Terjadi Kesalahan",
+ "errorMessage": "Gagal memuat daftar kategori.",
+ "createSuccess": "Kategori berhasil dibuat.",
+ "createError": "Gagal membuat kategori.",
+ "updateSuccess": "Kategori berhasil diperbarui.",
+ "updateError": "Gagal memperbarui kategori.",
+ "deleteSuccess": "Kategori berhasil dihapus.",
+ "deleteError": "Gagal menghapus kategori.",
+ "confirmDeleteTitle": "Hapus Kategori?",
+ "confirmDeleteDesc": "Apakah Anda yakin ingin menghapus kategori {name}? Kategori yang dihapus tidak dapat digunakan untuk transaksi baru.",
+ "confirmDeleteAction": "Ya, Hapus",
+ "btnEdit": "Edit",
+ "btnDelete": "Hapus"
+ },
+ "CategoryForm": {
+ "titleCreate": "Formulir Kategori",
+ "titleUpdate": "Edit Kategori",
+ "nameLabel": "Nama Kategori",
+ "namePlaceholder": "Contoh: Kolekte 1, Listrik, dll.",
+ "typeLabel": "Tipe Kategori",
+ "btnSave": "Simpan",
+ "btnCancel": "Batal"
+ },
+ "TransactionCreatePage": {
+ "title": "Catat Transaksi Baru",
+ "description": "Rekam detail pemasukan atau pengeluaran untuk periode ini.",
+ "successToast": "Transaksi berhasil dicatat.",
+ "errorToast": "Gagal mencatat transaksi."
+ },
+ "TransactionDetailPage": {
+ "title": "Detail Transaksi",
+ "description": "Lihat atau ubah detail transaksi keuangan.",
+ "successToast": "Transaksi berhasil diperbarui.",
+ "errorToast": "Gagal memperbarui transaksi.",
+ "deleteSuccess": "Transaksi berhasil dihapus.",
+ "deleteError": "Gagal menghapus transaksi.",
+ "confirmDeleteTitle": "Hapus Transaksi?",
+ "confirmDeleteDesc": "Yakin mau hapus transaksi senilai {amount} ini? Data gak bisa dikembalikan lho."
+ },
+ "TransactionForm": {
+ "sectionBasic": "Informasi Transaksi",
+ "sectionEvidence": "Bukti Transaksi",
+ "dateLabel": "Tanggal Transaksi",
+ "amountLabel": "Nominal (Rp)",
+ "amountPlaceholder": "Contoh: 50000",
+ "categoryLabel": "Kategori",
+ "categoryPlaceholder": "Pilih kategori...",
+ "noteLabel": "Catatan / Keterangan",
+ "notePlaceholder": "Ceritain detail transaksinya bray...",
+ "receiptLabel": "Foto Struk / Nota (Opsional)",
+ "receiptDescription": "Upload foto bukti transaksi buat arsip digital. Maks 1.5MB.",
+ "btnSave": "Simpan Transaksi",
+ "btnCancel": "Batal"
+ },
+ "ReceiptUploadField": {
+ "clickToUpload": "Klik untuk unggah struk",
+ "formats": "JPG, PNG, WebP, atau PDF (Maks 1.5MB)",
+ "uploading": "Sedang Mengunggah",
+ "uploaded": "Berhasil Diunggah",
+ "errorInvalidType": "Format file gak didukung bray!",
+ "errorUploadFailed": "Gagal upload ke server.",
+ "errorUploadInternal": "Terjadi kesalahan internal pas upload.",
+ "successUpload": "Bukti transaksi berhasil diunggah"
+ },
+ "AttachmentWidget": {
+ "listTitle": "Lampiran Dokumen",
+ "listEmpty": "Belum ada dokumen terlampir.",
+ "uploadTitle": "Unggah Lampiran",
+ "uploadNameLabel": "Nama Dokumen",
+ "uploadNamePlaceholder": "Contoh: Surat Keputusan...",
+ "uploadFileLabel": "Pilih Dokumen",
+ "uploadSuccess": "Lampiran berhasil diunggah.",
+ "uploadError": "Gagal mengunggah lampiran.",
+ "deleteSuccess": "Lampiran berhasil dihapus.",
+ "deleteError": "Gagal menghapus lampiran.",
+ "deleteConfirmTitle": "Hapus Lampiran?",
+ "deleteConfirmDesc": "Apakah Anda yakin ingin menghapus {name}? Dokumen akan dihapus secara permanen.",
+ "viewDocument": "Lihat Dokumen",
+ "btnUpload": "Unggah Dokumen",
+ "btnSubmitting": "Mengunggah..."
+ },
+ "TermList": {
+ "title": "Masa Jabatan",
+ "subtitle": "Kelola periode kepengurusan dan SK organisasi.",
+ "addTerm": "Tambah Masa Jabatan",
+ "emptyState": "Belum ada masa jabatan.",
+ "addFirstTerm": "Tambah masa jabatan pertama",
+ "confirmDelete": "Apakah Anda yakin ingin menghapus masa jabatan ini?",
+ "deleteSuccess": "Masa jabatan berhasil dihapus.",
+ "skInfo": "Informasi SK",
+ "noSkDate": "Tanggal SK tidak tersedia",
+ "description": "Keterangan",
+ "attachments": "Lampiran (SK/Dokumen)",
+ "editDialogTitle": "Ubah Masa Jabatan",
+ "addDialogTitle": "Tambah Masa Jabatan",
+ "editDialogDesc": "Sesuaikan detail masa jabatan.",
+ "addDialogDesc": "Masukkan periode kepengurusan baru.",
+ "updateSuccess": "Masa jabatan berhasil diperbarui.",
+ "createSuccess": "Masa jabatan berhasil ditambahkan."
+ },
+ "TermForm": {
+ "nameLabel": "Nama Periode / Jabatan",
+ "namePlaceholder": "Contoh: Pengurus Inti 2024-2027",
+ "startDateLabel": "Tanggal Mulai",
+ "endDateLabel": "Tanggal Berakhir",
+ "skNumberLabel": "Nomor SK",
+ "skNumberPlaceholder": "Contoh: 001/SK/PAROKI/2024",
+ "skDateLabel": "Tanggal SK",
+ "descLabel": "Keterangan",
+ "descPlaceholder": "Ceritain detail masa jabatannya bray...",
+ "btnCancel": "Batal",
+ "btnSubmitting": "Menyimpan...",
+ "btnSave": "Simpan Perubahan",
+ "btnAdd": "Tambah Masa Jabatan"
}
}
diff --git a/apps/dash/src/shared/ui/components/PremiumNotificationItem.tsx b/apps/dash/src/shared/ui/components/PremiumNotificationItem.tsx
new file mode 100644
index 00000000..fe0ae762
--- /dev/null
+++ b/apps/dash/src/shared/ui/components/PremiumNotificationItem.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+import type { Notification } from "@domus/core";
+import { formatDistanceToNow } from "date-fns";
+import { id } from "date-fns/locale";
+import {
+ Calendar,
+ CheckCircle2,
+ CreditCard,
+ Info,
+ Landmark,
+ Users,
+} from "lucide-react";
+import { motion } from "motion/react";
+import { cn } from "@/shared/ui/common/utils";
+
+interface PremiumNotificationItemProps {
+ notification: Notification;
+ onClick?: (id: string) => void;
+ index?: number;
+}
+
+/**
+ * PremiumNotificationItem is a high-fidelity list item for notifications.
+ * Features staggered entry animations, glassmorphism-ready styles, and intuitive icons.
+ */
+export function PremiumNotificationItem({
+ notification,
+ onClick,
+ index = 0,
+}: PremiumNotificationItemProps) {
+ const getIcon = (type?: string) => {
+ switch (type) {
+ case "Event":
+ return
;
+ case "Organization":
+ return
;
+ case "Transaction":
+ return
;
+ case "Enrollment":
+ return
;
+ case "Term":
+ return
;
+ default:
+ return
;
+ }
+ };
+
+ return (
+
onClick?.(notification.id)}
+ data-slot="notification-item"
+ >
+
+
+ {getIcon(notification.referenceType ?? undefined)}
+
+
+
+
+
+
+ {notification.title}
+
+
+ {formatDistanceToNow(new Date(notification.createdAt), {
+ addSuffix: true,
+ locale: id,
+ })}
+
+
+
+ {notification.body}
+
+
+
+ {!notification.isRead && (
+
+ )}
+
+ );
+}
diff --git a/apps/dash/src/shared/ui/components/index.ts b/apps/dash/src/shared/ui/components/index.ts
index 4ee7e14b..6e96ddfa 100644
--- a/apps/dash/src/shared/ui/components/index.ts
+++ b/apps/dash/src/shared/ui/components/index.ts
@@ -3,6 +3,7 @@ export * from "./LegalDialog";
export * from "./PremiumAction";
export * from "./PremiumFooter";
export * from "./PremiumHero";
+export * from "./PremiumNotificationItem";
export * from "./PremiumSearch";
export * from "./PrivacyView";
export * from "./Providers";
diff --git a/apps/dash/src/shared/ui/fields/DateField.tsx b/apps/dash/src/shared/ui/fields/DateField.tsx
new file mode 100644
index 00000000..da911176
--- /dev/null
+++ b/apps/dash/src/shared/ui/fields/DateField.tsx
@@ -0,0 +1,99 @@
+"use client";
+
+import type * as React from "react";
+import { cn } from "@/shared/ui/common/utils";
+import { Input } from "@/shared/ui/shadcn/input";
+import { BaseField } from "./BaseField";
+import { useFieldContext } from "./context";
+
+/**
+ * Props for the pre-bound DateField component.
+ */
+export interface DateFieldProps
+ extends Omit<
+ React.ComponentProps<"input">,
+ "value" | "onChange" | "onBlur" | "id" | "type"
+ > {
+ /**
+ * Label for the field.
+ */
+ label?: string;
+ /**
+ * Optional helper text.
+ */
+ description?: string;
+ /**
+ * Optional icon to display inside the input.
+ */
+ icon?: React.ReactNode;
+ /**
+ * Animation delay for entrance stagger.
+ */
+ delay?: number;
+ /**
+ * Optional test ID for E2E testing.
+ */
+ dataTestId?: string;
+}
+
+/**
+ * A pre-bound premium date input field.
+ * Consumes field state from TanStack Form context.
+ * Uses standard HTML5 date input but with Domus premium styling.
+ */
+export function DateField({
+ label,
+ description,
+ className,
+ delay,
+ icon,
+ dataTestId,
+ ...props
+}: DateFieldProps) {
+ const field = useFieldContext
();
+ const activeErrors = field.state.meta.errors;
+ const hasError = activeErrors.length > 0;
+ const isValidated =
+ field.state.meta.isTouched && !hasError && !!field.state.value;
+
+ return (
+
+
+ {icon && (
+
+ {icon}
+
+ )}
+
field.handleChange(e.target.value)}
+ onBlur={field.handleBlur}
+ className={cn(
+ "h-8 bg-[var(--glass-background)] backdrop-blur-md border-[var(--glass-border)] ring-offset-background",
+ "transition-all duration-500 ease-[0.23,1,0.32,1]",
+ "focus-visible:bg-background/40 focus-visible:ring-primary/10 focus-visible:border-primary/40 focus-visible:shadow-[0_0_20px_-5px_rgba(var(--primary),0.2)]",
+ icon && "pl-9",
+ hasError &&
+ "border-destructive/30 focus-visible:ring-destructive/10 focus-visible:border-destructive/50 focus-visible:shadow-[0_0_20px_-5px_rgba(239,68,68,0.2)]",
+ isValidated &&
+ "border-success/30 focus-visible:ring-success/10 focus-visible:border-success/50 focus-visible:shadow-[0_0_20px_-5px_rgba(var(--success),0.2)]",
+ className,
+ )}
+ {...props}
+ />
+
+
+ );
+}
diff --git a/apps/dash/src/shared/ui/fields/RadioGroupField.tsx b/apps/dash/src/shared/ui/fields/RadioGroupField.tsx
index a88bd7a8..e91457bb 100644
--- a/apps/dash/src/shared/ui/fields/RadioGroupField.tsx
+++ b/apps/dash/src/shared/ui/fields/RadioGroupField.tsx
@@ -36,6 +36,8 @@ export interface RadioGroupFieldProps
className?: string;
/** Animation delay for entrance stagger. */
delay?: number;
+ /** Test ID for E2E testing. */
+ dataTestId?: string;
}
/**
@@ -48,6 +50,7 @@ export function RadioGroupField({
options,
className,
delay,
+ dataTestId,
...props
}: RadioGroupFieldProps) {
const field = useFieldContext();
@@ -72,6 +75,7 @@ export function RadioGroupField({
onBlur={field.handleBlur}
name={field.name}
className="flex flex-col gap-2 pt-2"
+ data-testid={dataTestId}
{...props}
>
{options.map((option) => (
diff --git a/apps/dash/src/shared/ui/fields/ReceiptUploadField.tsx b/apps/dash/src/shared/ui/fields/ReceiptUploadField.tsx
new file mode 100644
index 00000000..3de9dcea
--- /dev/null
+++ b/apps/dash/src/shared/ui/fields/ReceiptUploadField.tsx
@@ -0,0 +1,257 @@
+"use client";
+
+import type { Attachment } from "@domus/core";
+import { Check, FileText, FileUp, X } from "lucide-react";
+import { AnimatePresence, motion } from "motion/react";
+import Image from "next/image";
+import { useTranslations } from "next-intl";
+import { useId, useRef, useState } from "react";
+import { toast } from "sonner";
+import { uploadReceiptAction } from "@/pages/finance/actions/upload-receipt";
+import { cn } from "@/shared/ui/common/utils";
+import { Button } from "@/shared/ui/shadcn/button";
+import { compressImage } from "@/shared/utils/image-compression";
+import { useFieldContext } from "./context";
+
+export interface ReceiptUploadFieldProps {
+ /**
+ * Label for the field.
+ */
+ label?: string;
+ /**
+ * Optional helper text.
+ */
+ description?: string;
+ /**
+ * The ID of the transaction to link the receipt to.
+ */
+ transactionId: string;
+ /**
+ * Animation delay.
+ */
+ delay?: number;
+ /**
+ * Optional test ID.
+ */
+ dataTestId?: string;
+}
+
+/**
+ * A premium receipt upload field for the Domus form system.
+ * Handles client-side compression, progress simulation, and private storage upload.
+ */
+export function ReceiptUploadField({
+ label,
+ description,
+ transactionId,
+ delay = 0,
+ dataTestId,
+}: ReceiptUploadFieldProps) {
+ const id = useId();
+ const field = useFieldContext();
+ const [isUploading, setIsUploading] = useState(false);
+ const [progress, setProgress] = useState(0);
+ const [preview, setPreview] = useState(null);
+ const fileInputRef = useRef(null);
+ const t = useTranslations("ReceiptUploadField");
+
+ const simulateProgress = () => {
+ setProgress(0);
+ const interval = setInterval(() => {
+ setProgress((v) => {
+ if (v >= 95) {
+ clearInterval(interval);
+ return 95;
+ }
+ return v + (100 - v) * 0.1;
+ });
+ }, 150);
+ return () => clearInterval(interval);
+ };
+
+ const handleFileChange = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ // Validate type
+ const allowedTypes = [
+ "image/jpeg",
+ "image/png",
+ "image/webp",
+ "application/pdf",
+ ];
+ if (!allowedTypes.includes(file.type)) {
+ toast.error(t("errorInvalidType"));
+ return;
+ }
+
+ setIsUploading(true);
+ const stopSimulation = simulateProgress();
+
+ try {
+ let fileToUpload = file;
+ if (file.type.startsWith("image/")) {
+ fileToUpload = await compressImage(file, { preset: "receipt" });
+ }
+
+ const formData = new FormData();
+ formData.append("file", fileToUpload);
+
+ const [data, error] = await uploadReceiptAction(transactionId, formData);
+
+ if (error) {
+ toast.error(error.message || t("errorUploadFailed"));
+ return;
+ }
+
+ if (data) {
+ setPreview(data);
+ field.handleChange(data.id);
+ toast.success(t("successUpload"));
+ }
+ } catch (err) {
+ console.error("[ReceiptUploadField] Upload error:", err);
+ toast.error(t("errorUploadInternal"));
+ } finally {
+ stopSimulation();
+ setProgress(100);
+ setTimeout(() => {
+ setIsUploading(false);
+ setProgress(0);
+ }, 400);
+ }
+ };
+
+ const removeFile = () => {
+ setPreview(null);
+ field.handleChange(null);
+ if (fileInputRef.current) fileInputRef.current.value = "";
+ };
+
+ return (
+
+ {label && (
+
+ )}
+
+
+
+ {!preview && !isUploading && (
+ fileInputRef.current?.click()}
+ >
+
+
+
+
+ {t("clickToUpload")}
+
+
+ {t("formats")}
+
+
+ )}
+
+ {isUploading && (
+
+
+
+
+
+ {t("uploading")} {Math.round(progress)}%
+
+
+ )}
+
+ {preview && (
+
+
+ {preview.mimeType?.startsWith("image/") ? (
+
+ ) : (
+
+
+
+ PDF
+
+
+ )}
+
+
+
+ {preview.name}
+
+
+
+
+ {t("uploaded")}
+
+
+
+
+
+ )}
+
+
+
+
+
+ {description && (
+
+ {description}
+
+ )}
+
+ );
+}
diff --git a/apps/dash/src/shared/ui/fields/context.ts b/apps/dash/src/shared/ui/fields/context.ts
index 534d725a..efedff6c 100644
--- a/apps/dash/src/shared/ui/fields/context.ts
+++ b/apps/dash/src/shared/ui/fields/context.ts
@@ -1,6 +1,8 @@
import { createFormHook, createFormHookContexts } from "@tanstack/react-form";
import { CheckboxField } from "./CheckboxField";
+import { DateField } from "./DateField";
import { RadioGroupField } from "./RadioGroupField";
+import { ReceiptUploadField } from "./ReceiptUploadField";
import { SelectField } from "./SelectField";
import { TextAreaField } from "./TextAreaField";
import { TextField } from "./TextField";
@@ -37,6 +39,8 @@ export const { useAppForm: useDomusForm, withForm } = createFormHook({
TextAreaField,
CheckboxField,
RadioGroupField,
+ DateField,
+ ReceiptUploadField,
},
formComponents: {},
});
diff --git a/apps/dash/src/shared/ui/fields/index.ts b/apps/dash/src/shared/ui/fields/index.ts
index 5c38eeb8..fa2a8363 100644
--- a/apps/dash/src/shared/ui/fields/index.ts
+++ b/apps/dash/src/shared/ui/fields/index.ts
@@ -1,7 +1,9 @@
export * from "./BaseField";
export * from "./CheckboxField";
export * from "./context";
+export * from "./DateField";
export * from "./RadioGroupField";
+export * from "./ReceiptUploadField";
export * from "./SelectField";
export * from "./TextAreaField";
export * from "./TextField";
diff --git a/apps/dash/src/shared/utils/format.ts b/apps/dash/src/shared/utils/format.ts
new file mode 100644
index 00000000..c297e03e
--- /dev/null
+++ b/apps/dash/src/shared/utils/format.ts
@@ -0,0 +1,11 @@
+export function formatBytes(bytes: number, decimals = 2): string {
+ if (!+bytes) return "0 Bytes";
+
+ const k = 1024;
+ const dm = decimals < 0 ? 0 : decimals;
+ const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
+
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
+}
diff --git a/apps/dash/src/shared/utils/image-compression.ts b/apps/dash/src/shared/utils/image-compression.ts
index c1c53db0..f13c587d 100644
--- a/apps/dash/src/shared/utils/image-compression.ts
+++ b/apps/dash/src/shared/utils/image-compression.ts
@@ -5,7 +5,7 @@ import imageCompression from "browser-image-compression";
*/
export type CompressImageOptions = {
/** Compression preset. Defaults to `'default'`. */
- preset?: "id-card" | "org-logo" | "org-cover" | "default";
+ preset?: "id-card" | "org-logo" | "org-cover" | "receipt" | "default";
};
type CompressionConfig = {
@@ -36,6 +36,12 @@ const PRESETS: Record<
maxWidthOrHeight: 1280,
useWebWorker: true,
},
+ /** Financial receipt — sharp enough for OCR/auditing. */
+ receipt: {
+ maxSizeMB: 1.5,
+ maxWidthOrHeight: 2048,
+ useWebWorker: true,
+ },
/** Generic fallback preset. */
default: {
maxSizeMB: 1,
diff --git a/apps/dash/src/widgets/layout/ui/components/NotificationDropdown.tsx b/apps/dash/src/widgets/layout/ui/components/NotificationDropdown.tsx
new file mode 100644
index 00000000..d9ef58c7
--- /dev/null
+++ b/apps/dash/src/widgets/layout/ui/components/NotificationDropdown.tsx
@@ -0,0 +1,258 @@
+"use client";
+
+import type { Notification } from "@domus/core";
+import {
+ Bell,
+ Check,
+ CreditCard,
+ Inbox,
+ Info,
+ Landmark,
+ Users,
+} from "lucide-react";
+import { AnimatePresence, motion } from "motion/react";
+import { useRouter } from "next/navigation";
+import { useLocale, useTranslations } from "next-intl";
+import { useCallback, useEffect, useState, useTransition } from "react";
+import { toast } from "sonner";
+import {
+ getNotificationsAction,
+ getUnreadCountAction,
+ markAllNotificationsAsReadAction,
+ markNotificationAsReadAction,
+} from "@/pages/notifications/actions/notification-actions";
+import { cn } from "@/shared/ui/common/utils";
+import { PremiumAction, PremiumNotificationItem } from "@/shared/ui/components";
+import {
+ Popover,
+ PopoverContent,
+ PopoverHeader,
+ PopoverTitle,
+ PopoverTrigger,
+} from "@/shared/ui/shadcn/popover";
+
+/**
+ * Premium notification dropdown component for the top navigation bar.
+ * Features glassmorphism, staggered animations, and asymmetric indicators.
+ */
+export function NotificationDropdown() {
+ const t = useTranslations("NotificationDropdown");
+ const _locale = useLocale();
+ const router = useRouter();
+ const [isPending, startTransition] = useTransition();
+
+ const [notifications, setNotifications] = useState([]);
+ const [unreadCount, setUnreadCount] = useState(0);
+ const [isOpen, setIsOpen] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const fetchStatus = useCallback(async () => {
+ const [count] = await getUnreadCountAction();
+ if (count !== null) setUnreadCount(count);
+ }, []);
+
+ const fetchAll = useCallback(async () => {
+ setIsLoading(true);
+ const [data] = await getNotificationsAction(15);
+ if (data !== null) setNotifications(data);
+ await fetchStatus();
+ setIsLoading(false);
+ }, [fetchStatus]);
+
+ // Poll for unread count every 30 seconds
+ useEffect(() => {
+ fetchStatus();
+ const interval = setInterval(fetchStatus, 30000);
+ return () => clearInterval(interval);
+ }, [fetchStatus]);
+
+ const handleOpenChange = (open: boolean) => {
+ setIsOpen(open);
+ if (open) {
+ fetchAll();
+ }
+ };
+
+ const handleMarkAllRead = () => {
+ startTransition(async () => {
+ const [_, error] = await markAllNotificationsAsReadAction();
+ if (error) {
+ toast.error(error.message);
+ return;
+ }
+ setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));
+ setUnreadCount(0);
+ });
+ };
+
+ const handleNotificationClick = (n: Notification) => {
+ if (!n.isRead) {
+ startTransition(async () => {
+ await markNotificationAsReadAction(n.id);
+ fetchStatus();
+ });
+ }
+
+ if (n.referenceId && n.referenceType) {
+ let route = "";
+ switch (n.referenceType) {
+ case "Event":
+ route = `/event/${n.referenceId}`;
+ break;
+ case "Organization":
+ route = `/org/${n.referenceId}`;
+ break;
+ case "Transaction":
+ route = "/finance";
+ break;
+ case "Enrollment":
+ route = `/enrollment/${n.referenceId}`;
+ break;
+ case "Term":
+ route = `/term/${n.referenceId}`;
+ break;
+ default:
+ route = "#";
+ }
+
+ if (route !== "#") {
+ router.push(route);
+ setIsOpen(false);
+ }
+ }
+ };
+
+ const _getIcon = (type: string) => {
+ switch (type) {
+ case "Event":
+ return ;
+ case "Organization":
+ return ;
+ case "Transaction":
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ return (
+
+
+
+
+ {unreadCount > 0 && (
+
+ {unreadCount > 9 ? "9+" : unreadCount}
+
+ )}
+
+
+
+
+
+
+
+ {t("title")}
+
+ {unreadCount > 0 && (
+
+ {t("unreadCount", { count: unreadCount })}
+
+ )}
+
+
+
+
+ {isLoading ? (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ ) : notifications.length > 0 ? (
+
+
+ {notifications.map((n, i) => (
+ handleNotificationClick(n)}
+ />
+ ))}
+
+
+ ) : (
+
+
+
+
+
+ {t("emptyTitle")}
+
+
+ {t("emptyDesc")}
+
+
+ )}
+
+
+ {notifications.length > 0 && (
+
+
}
+ onClick={handleMarkAllRead}
+ disabled={isPending || unreadCount === 0}
+ >
+ {t("markAllAsRead")}
+
+
{
+ router.push("/notifications");
+ setIsOpen(false);
+ }}
+ >
+ {t("viewAll")}
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/dash/src/widgets/layout/ui/components/TopNavbar.tsx b/apps/dash/src/widgets/layout/ui/components/TopNavbar.tsx
index 4928cb51..0eb86f02 100644
--- a/apps/dash/src/widgets/layout/ui/components/TopNavbar.tsx
+++ b/apps/dash/src/widgets/layout/ui/components/TopNavbar.tsx
@@ -1,12 +1,13 @@
"use client";
-import { Bell, ChevronDown, LogOut, Moon, Sun, User } from "lucide-react";
+import { ChevronDown, LogOut, Moon, Sun, User } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { authClient } from "@/shared/auth/client";
import { Brand } from "./Brand";
+import { NotificationDropdown } from "./NotificationDropdown";
/**
* Fixed top navigation bar with glassmorphism, theme toggle, notification bell, and user menu.
@@ -60,16 +61,7 @@ export function TopNavbar() {
{/* Notification bell */}
-
+
{/* User menu */}
diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts
index e626c530..0bae0a47 100644
--- a/packages/config/src/index.ts
+++ b/packages/config/src/index.ts
@@ -32,6 +32,11 @@ const envSchema = z.object({
SYNC_USERNAME: z.string().min(1),
SYNC_PASSWORD: z.string().min(1),
SYNC_COOKIE_TTL: z.coerce.number().default(15),
+ SMTP_HOST: z.string().min(1).optional(),
+ SMTP_PORT: z.coerce.number().default(587),
+ SMTP_USER: z.string().min(1).optional(),
+ SMTP_PASS: z.string().min(1).optional(),
+ SMTP_FROM: z.string().min(1).default('Domus '),
});
function getEnv() {
@@ -86,6 +91,13 @@ const configSchema = z.object({
password: z.string(),
cookieTtl: z.number(),
}),
+ email: z.object({
+ host: z.string().optional(),
+ port: z.number(),
+ user: z.string().optional(),
+ pass: z.string().optional(),
+ from: z.string(),
+ }),
});
/**
* The unified configuration object for the Domus application.
@@ -137,6 +149,13 @@ function getConfig() {
password: env.SYNC_PASSWORD,
cookieTtl: env.SYNC_COOKIE_TTL,
},
+ email: {
+ host: env.SMTP_HOST,
+ port: env.SMTP_PORT,
+ user: env.SMTP_USER,
+ pass: env.SMTP_PASS,
+ from: env.SMTP_FROM,
+ },
};
return configSchema.parse(config);
}
diff --git a/packages/core/src/contract/financial-period.ts b/packages/core/src/contract/financial-period.ts
index 1f0b8ec8..113b9547 100644
--- a/packages/core/src/contract/financial-period.ts
+++ b/packages/core/src/contract/financial-period.ts
@@ -21,6 +21,11 @@ export interface IFinancialPeriodRepository {
year: number,
): Promise;
+ /**
+ * Finds all financial periods for a specific year.
+ */
+ findByYear(year: number): Promise;
+
/**
* Retrieves all financial periods.
*/
diff --git a/packages/core/src/contract/index.ts b/packages/core/src/contract/index.ts
index e7fdc9d2..007bd72b 100644
--- a/packages/core/src/contract/index.ts
+++ b/packages/core/src/contract/index.ts
@@ -6,6 +6,7 @@ export * from './enrollment';
export * from './event';
export * from './financial-period';
export * from './logger';
+export * from './mailer';
export * from './member';
export * from './notification';
export * from './organization';
@@ -15,6 +16,7 @@ export * from './placement';
export * from './rsvp';
export * from './term';
export * from './transaction';
+export * from './transaction-category';
export * from './unit';
export * from './user';
export * from './vicariate';
diff --git a/packages/core/src/contract/mailer.ts b/packages/core/src/contract/mailer.ts
new file mode 100644
index 00000000..664cd129
--- /dev/null
+++ b/packages/core/src/contract/mailer.ts
@@ -0,0 +1,36 @@
+import type { Result } from '../utils/result';
+
+/**
+ * Payload for sending an email.
+ */
+export interface SendEmailPayload {
+ /**
+ * Recipient email address.
+ */
+ to: string;
+ /**
+ * Email subject line.
+ */
+ subject: string;
+ /**
+ * Plain text version of the email body.
+ */
+ text: string;
+ /**
+ * HTML version of the email body.
+ */
+ html?: string;
+}
+
+/**
+ * Contract for email dispatch service.
+ */
+export interface IMailer {
+ /**
+ * Sends an email with the provided payload.
+ *
+ * @param payload - The email content and recipient details.
+ * @returns `ok(undefined)` on success, `fail(Error)` on failure.
+ */
+ send(payload: SendEmailPayload): Promise>;
+}
diff --git a/packages/core/src/contract/notification.ts b/packages/core/src/contract/notification.ts
index 76779458..dffc604f 100644
--- a/packages/core/src/contract/notification.ts
+++ b/packages/core/src/contract/notification.ts
@@ -10,9 +10,17 @@ export interface INotificationRepository {
findById(id: string): Promise;
/**
- * Finds all notifications for a specific user.
+ * Finds notifications for a specific user with pagination.
*/
- findByUserId(userId: string): Promise;
+ findByUserId(
+ userId: string,
+ options?: { limit?: number; offset?: number },
+ ): Promise;
+
+ /**
+ * Counts unread notifications for a specific user.
+ */
+ countUnreadByUserId(userId: string): Promise;
/**
* Creates a new notification record.
diff --git a/packages/core/src/contract/transaction-category.ts b/packages/core/src/contract/transaction-category.ts
new file mode 100644
index 00000000..183ea5d6
--- /dev/null
+++ b/packages/core/src/contract/transaction-category.ts
@@ -0,0 +1,88 @@
+import type { AuthContext } from '../entity/auth-context';
+import type {
+ CreateTransactionCategory,
+ TransactionCategory,
+ UpdateTransactionCategory,
+} from '../entity/transaction-category';
+import type { Result } from '../utils';
+
+/**
+ * Repository contract for transaction category data access.
+ */
+export interface ITransactionCategoryRepository {
+ /**
+ * Finds a category by its ID.
+ */
+ findById(id: string): Promise;
+
+ /**
+ * Finds all categories that are not soft-deleted.
+ */
+ findAll(): Promise;
+
+ /**
+ * Finds a category by name and type to prevent duplicates.
+ */
+ findByNameAndType(
+ name: string,
+ type: string,
+ ): Promise;
+
+ /**
+ * Creates a new transaction category.
+ */
+ create(data: CreateTransactionCategory): Promise;
+
+ /**
+ * Updates an existing transaction category.
+ */
+ update(
+ id: string,
+ data: UpdateTransactionCategory,
+ ): Promise;
+
+ /**
+ * Soft deletes a transaction category.
+ */
+ delete(id: string): Promise;
+}
+
+/**
+ * Service contract for transaction category business logic.
+ */
+export interface ITransactionCategoryService {
+ /**
+ * Retrieves all transaction categories.
+ */
+ getCategories(): Promise>;
+
+ /**
+ * Retrieves a single category by ID.
+ */
+ getCategory(id: string): Promise>;
+
+ /**
+ * Creates a new transaction category.
+ * Requires Treasurer or SuperAdmin role.
+ */
+ createCategory(
+ ctx: AuthContext,
+ data: CreateTransactionCategory,
+ ): Promise>;
+
+ /**
+ * Updates an existing transaction category.
+ * Requires Treasurer or SuperAdmin role.
+ */
+ updateCategory(
+ ctx: AuthContext,
+ id: string,
+ data: UpdateTransactionCategory,
+ ): Promise>;
+
+ /**
+ * Soft deletes a transaction category.
+ * Requires Treasurer or SuperAdmin role.
+ */
+ deleteCategory(ctx: AuthContext, id: string): Promise>;
+}
diff --git a/packages/core/src/entity/enums.ts b/packages/core/src/entity/enums.ts
index c31a86b2..debad6ef 100644
--- a/packages/core/src/entity/enums.ts
+++ b/packages/core/src/entity/enums.ts
@@ -269,6 +269,7 @@ export const AttachmentReferenceType = {
Event: 'Event',
Organization: 'Organization',
Term: 'Term',
+ Transaction: 'Transaction',
} as const;
/**
diff --git a/packages/core/src/entity/financial-report.ts b/packages/core/src/entity/financial-report.ts
new file mode 100644
index 00000000..593d279d
--- /dev/null
+++ b/packages/core/src/entity/financial-report.ts
@@ -0,0 +1,56 @@
+import { z } from 'zod';
+import { TransactionEntity } from './transaction';
+
+/**
+ * Monthly statistics for a yearly overview.
+ */
+export const MonthlyStatsSchema = z.object({
+ periodId: z.string(),
+ month: z.number().min(1).max(12),
+ income: z.number(),
+ expense: z.number(),
+ balance: z.number(),
+});
+
+export type MonthlyStats = z.infer;
+
+/**
+ * Yearly report summary.
+ */
+export const YearlyReportSchema = z.object({
+ year: z.number(),
+ stats: z.array(MonthlyStatsSchema),
+ totalIncome: z.number(),
+ totalExpense: z.number(),
+ netBalance: z.number(),
+});
+
+export type YearlyReport = z.infer;
+
+/**
+ * Detailed category statistics for a month, including transactions.
+ */
+export const CategoryReportSchema = z.object({
+ categoryId: z.string(),
+ categoryName: z.string(),
+ amount: z.number(),
+ transactions: z.array(TransactionEntity),
+});
+
+export type CategoryReport = z.infer;
+
+/**
+ * Monthly detailed report.
+ */
+export const MonthlyReportSchema = z.object({
+ periodId: z.string(),
+ year: z.number(),
+ month: z.number(),
+ incomeCategories: z.array(CategoryReportSchema),
+ expenseCategories: z.array(CategoryReportSchema),
+ totalIncome: z.number(),
+ totalExpense: z.number(),
+ balance: z.number(),
+});
+
+export type MonthlyReport = z.infer;
diff --git a/packages/core/src/entity/index.ts b/packages/core/src/entity/index.ts
index 94b04f83..698fdddb 100644
--- a/packages/core/src/entity/index.ts
+++ b/packages/core/src/entity/index.ts
@@ -7,6 +7,7 @@ export * from './enrollment';
export * from './enums';
export * from './event';
export * from './financial-period';
+export * from './financial-report';
export * from './join';
export * from './notification';
export * from './organization';
diff --git a/packages/core/src/entity/term.ts b/packages/core/src/entity/term.ts
index 2ce5b173..8af9df35 100644
--- a/packages/core/src/entity/term.ts
+++ b/packages/core/src/entity/term.ts
@@ -22,3 +22,32 @@ export const TermEntity = z.object({
* Type representing a term entity.
*/
export type Term = z.infer;
+
+/**
+ * Zod schema for creating a term.
+ */
+export const CreateTermSchema = TermEntity.omit({
+ id: true,
+ createdAt: true,
+ updatedAt: true,
+ deletedAt: true,
+}).extend({
+ startDate: z.coerce.date().nullable(),
+ endDate: z.coerce.date().nullable(),
+ skDate: z.coerce.date().nullable(),
+});
+
+/**
+ * Zod schema for updating a term.
+ */
+export const UpdateTermSchema = CreateTermSchema.partial();
+
+/**
+ * Type representing data required to create a new term.
+ */
+export type CreateTerm = z.infer;
+
+/**
+ * Type representing data used to update an existing term.
+ */
+export type UpdateTerm = z.infer;
diff --git a/packages/core/src/entity/transaction-category.ts b/packages/core/src/entity/transaction-category.ts
index b872f26e..a715ab72 100644
--- a/packages/core/src/entity/transaction-category.ts
+++ b/packages/core/src/entity/transaction-category.ts
@@ -17,3 +17,31 @@ export const TransactionCategoryEntity = z.object({
* Inferred type for a financial transaction category entity.
*/
export type TransactionCategory = z.infer;
+
+/**
+ * Zod schema for creating a new transaction category.
+ */
+export const CreateTransactionCategorySchema = TransactionCategoryEntity.pick({
+ name: true,
+ type: true,
+});
+
+/**
+ * Inferred type for creating a new transaction category.
+ */
+export type CreateTransactionCategory = z.infer<
+ typeof CreateTransactionCategorySchema
+>;
+
+/**
+ * Zod schema for updating an existing transaction category.
+ */
+export const UpdateTransactionCategorySchema =
+ CreateTransactionCategorySchema.partial();
+
+/**
+ * Inferred type for updating an existing transaction category.
+ */
+export type UpdateTransactionCategory = z.infer<
+ typeof UpdateTransactionCategorySchema
+>;
diff --git a/packages/core/src/entity/transaction.ts b/packages/core/src/entity/transaction.ts
index 0c2a461a..d4bae13e 100644
--- a/packages/core/src/entity/transaction.ts
+++ b/packages/core/src/entity/transaction.ts
@@ -7,16 +7,16 @@ export { TransactionType };
* Zod schema for a financial transaction entity.
*/
export const TransactionEntity = z.object({
- id: z.uuidv7(),
- periodId: z.uuidv7(),
- categoryId: z.uuidv7(),
+ id: z.uuid(),
+ periodId: z.uuid(),
+ categoryId: z.uuid(),
type: z.enum(Object.values(TransactionType) as [string, ...string[]]),
- amount: z.number().positive(),
+ amount: z.coerce.number().positive(),
description: z.string().min(1),
- date: z.date(),
+ date: z.coerce.date(),
receiptPhoto: z.string().nullish(),
- createdBy: z.uuidv7(),
- updatedBy: z.uuidv7().nullish(),
+ createdBy: z.uuid(),
+ updatedBy: z.uuid().nullish(),
createdAt: z.date(),
updatedAt: z.date(),
deletedAt: z.date().nullable(),
@@ -37,8 +37,8 @@ export const CreateTransactionSchema = TransactionEntity.omit({
updatedAt: true,
deletedAt: true,
}).extend({
- id: z.uuidv7().optional(),
- createdBy: z.uuidv7().optional(),
+ id: z.uuid().optional(),
+ createdBy: z.uuid().optional(),
});
/**
diff --git a/packages/core/src/error/error.ts b/packages/core/src/error/error.ts
index e9494425..9a0ba4f8 100644
--- a/packages/core/src/error/error.ts
+++ b/packages/core/src/error/error.ts
@@ -99,3 +99,13 @@ export class LocationOutsideRadiusError extends CoreError {
);
}
}
+/**
+ * Error thrown when a requested resource already exists or conflicts.
+ */
+export class ConflictError extends CoreError {
+ public override readonly logLevel = 'info';
+
+ constructor(message: string) {
+ super('CONFLICT', 409, message);
+ }
+}
diff --git a/packages/core/src/service/attachment.spec.ts b/packages/core/src/service/attachment.spec.ts
index 31a9d542..2afd3bb7 100644
--- a/packages/core/src/service/attachment.spec.ts
+++ b/packages/core/src/service/attachment.spec.ts
@@ -23,6 +23,7 @@ describe('AttachmentService', () => {
[AttachmentReferenceType.Event]: 'folder-event',
[AttachmentReferenceType.Organization]: 'folder-org',
[AttachmentReferenceType.Term]: 'folder-term',
+ [AttachmentReferenceType.Transaction]: 'folder-transaction',
},
};
diff --git a/packages/core/src/service/financial-period.ts b/packages/core/src/service/financial-period.ts
index fd6a8ed8..9b87f61b 100644
--- a/packages/core/src/service/financial-period.ts
+++ b/packages/core/src/service/financial-period.ts
@@ -88,12 +88,8 @@ export class FinancialPeriodService {
ctx: AuthContext,
): Promise> {
try {
- // Permission check: Bendahara, superAdmin, or adminParoki
- if (
- !ctx.roles.includes(UserRole.Treasurer) &&
- !ctx.roles.includes(UserRole.SuperAdmin) &&
- !ctx.roles.includes(UserRole.ParishAdmin)
- ) {
+ // Permission check: Strictly Treasurer only
+ if (!ctx.roles.includes(UserRole.Treasurer)) {
this.logger.info('Unauthorized financial period creation attempt', {
userId: ctx.userId,
});
@@ -139,10 +135,8 @@ export class FinancialPeriodService {
return fail(new NotFoundError('Financial Period'));
}
- if (
- !ctx.roles.includes(UserRole.Treasurer) &&
- !ctx.roles.includes(UserRole.SuperAdmin)
- ) {
+ // Permission check: Strictly Treasurer only
+ if (!ctx.roles.includes(UserRole.Treasurer)) {
this.logger.info('Unauthorized financial period lock attempt', {
id,
userId: ctx.userId,
@@ -187,10 +181,8 @@ export class FinancialPeriodService {
return fail(new NotFoundError('Financial Period'));
}
- if (
- !ctx.roles.includes(UserRole.Treasurer) &&
- !ctx.roles.includes(UserRole.SuperAdmin)
- ) {
+ // Permission check: Strictly Treasurer only
+ if (!ctx.roles.includes(UserRole.Treasurer)) {
this.logger.info('Unauthorized financial period unlock attempt', {
id,
userId: ctx.userId,
@@ -227,17 +219,15 @@ export class FinancialPeriodService {
try {
const existing = await this.repo.findById(id);
if (!existing) {
- this.logger.info('Financial period not found for deletion', {
+ this.logger.info('Financial period not found for delete', {
id,
userId: ctx.userId,
});
return fail(new NotFoundError('Financial Period'));
}
- if (
- !ctx.roles.includes(UserRole.Treasurer) &&
- !ctx.roles.includes(UserRole.SuperAdmin)
- ) {
+ // Permission check: Strictly Treasurer only
+ if (!ctx.roles.includes(UserRole.Treasurer)) {
this.logger.info('Unauthorized financial period deletion attempt', {
id,
userId: ctx.userId,
diff --git a/packages/core/src/service/index.ts b/packages/core/src/service/index.ts
index a50d883a..50f076f7 100644
--- a/packages/core/src/service/index.ts
+++ b/packages/core/src/service/index.ts
@@ -14,6 +14,7 @@ export * from './placement';
export * from './rsvp';
export * from './term';
export * from './transaction';
+export * from './transaction-category';
export * from './unit';
export * from './user';
export * from './vicariate';
diff --git a/packages/core/src/service/notification.spec.ts b/packages/core/src/service/notification.spec.ts
index d2cc486a..e61593d8 100644
--- a/packages/core/src/service/notification.spec.ts
+++ b/packages/core/src/service/notification.spec.ts
@@ -2,9 +2,11 @@ import { v7 } from 'uuid';
import { describe, expect, it } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type { ILogger } from '../contract/logger';
+import type { IMailer } from '../contract/mailer';
import type { INotificationRepository } from '../contract/notification';
import type { IPrivateStorage } from '../contract/storage-private';
import type { IPublicStorage } from '../contract/storage-public';
+import type { IUserRepository } from '../contract/user';
import type { AuthContext } from '../entity/auth-context';
import {
AccountStatus,
@@ -13,18 +15,24 @@ import {
UserRole,
} from '../entity/enums';
import type { Notification } from '../entity/notification';
+import type { User } from '../entity/user';
import { ForbiddenError } from '../error';
+import type { Result } from '../utils';
import { NotificationService } from './notification';
describe('NotificationService', () => {
const repo = mock();
+ const userRepo = mock();
const publicStorage = mock();
const privateStorage = mock();
+ const mailer = mock();
const logger = mock();
const service = new NotificationService(
repo,
+ userRepo,
publicStorage,
privateStorage,
+ mailer,
logger,
);
@@ -73,6 +81,34 @@ describe('NotificationService', () => {
const [_result, error] = await service.findByUserId(userId, otherUserCtx);
expect(error).toBeInstanceOf(ForbiddenError);
});
+
+ it('should pass pagination options to repository', async () => {
+ repo.findByUserId.mockResolvedValue([]);
+ const options = { limit: 5, offset: 10 };
+ await service.findByUserId(userId, userCtx, options);
+ expect(repo.findByUserId).toHaveBeenCalledWith(userId, options);
+ });
+ });
+
+ describe('countUnreadByUserId', () => {
+ it('should allow user to count their own unread notifications', async () => {
+ repo.countUnreadByUserId.mockResolvedValue(5);
+ const [result, error] = await service.countUnreadByUserId(
+ userId,
+ userCtx,
+ );
+ expect(error).toBeNull();
+ expect(result).toBe(5);
+ expect(repo.countUnreadByUserId).toHaveBeenCalledWith(userId);
+ });
+
+ it('should forbid user from counting others unread notifications', async () => {
+ const [_result, error] = await service.countUnreadByUserId(
+ userId,
+ otherUserCtx,
+ );
+ expect(error).toBeInstanceOf(ForbiddenError);
+ });
});
describe('markAsRead', () => {
@@ -142,4 +178,53 @@ describe('NotificationService', () => {
expect(error).toBeInstanceOf(ForbiddenError);
});
});
+ describe('send', () => {
+ it('should send email and update status to sent', async () => {
+ const emailNotif = {
+ ...mockNotification,
+ channel: NotificationChannel.Email,
+ status: NotificationStatus.Pending,
+ };
+ repo.findById.mockResolvedValue(emailNotif as unknown as Notification);
+ userRepo.findById.mockResolvedValue({
+ email: 'test@example.com',
+ } as unknown as User);
+ mailer.send.mockResolvedValue([undefined, null] as Result);
+
+ const [_result, error] = await service.send(notifId);
+
+ expect(error).toBeNull();
+ expect(mailer.send).toHaveBeenCalledWith(
+ expect.objectContaining({ to: 'test@example.com' }),
+ );
+ expect(repo.update).toHaveBeenCalledWith(
+ notifId,
+ expect.objectContaining({ status: NotificationStatus.Sent }),
+ );
+ });
+
+ it('should update status to failed if mailer fails', async () => {
+ const emailNotif = {
+ ...mockNotification,
+ channel: NotificationChannel.Email,
+ status: NotificationStatus.Pending,
+ };
+ repo.findById.mockResolvedValue(emailNotif as unknown as Notification);
+ userRepo.findById.mockResolvedValue({
+ email: 'test@example.com',
+ } as unknown as User);
+ mailer.send.mockResolvedValue([
+ null,
+ new Error('SMTP Error'),
+ ] as unknown as Result);
+
+ const [_result, error] = await service.send(notifId);
+
+ expect(error).toBeInstanceOf(Error);
+ expect(repo.update).toHaveBeenCalledWith(
+ notifId,
+ expect.objectContaining({ status: NotificationStatus.Failed }),
+ );
+ });
+ });
});
diff --git a/packages/core/src/service/notification.ts b/packages/core/src/service/notification.ts
index 26c34efd..2f07df43 100644
--- a/packages/core/src/service/notification.ts
+++ b/packages/core/src/service/notification.ts
@@ -1,9 +1,15 @@
import type { ILogger } from '../contract/logger';
+import type { IMailer } from '../contract/mailer';
import type { INotificationRepository } from '../contract/notification';
import type { IPrivateStorage } from '../contract/storage-private';
import type { IPublicStorage } from '../contract/storage-public';
+import type { IUserRepository } from '../contract/user';
import type { AuthContext } from '../entity/auth-context';
-import { NotificationStatus, UserRole } from '../entity/enums';
+import {
+ NotificationChannel,
+ NotificationStatus,
+ UserRole,
+} from '../entity/enums';
import type { CreateNotification, Notification } from '../entity/notification';
import { ForbiddenError, InternalError, NotFoundError } from '../error';
import { fail, ok, type Result } from '../utils/result';
@@ -14,8 +20,10 @@ import { fail, ok, type Result } from '../utils/result';
export class NotificationService {
constructor(
private readonly repo: INotificationRepository,
+ private readonly userRepo: IUserRepository,
readonly _publicStorage: IPublicStorage,
readonly _privateStorage: IPrivateStorage,
+ private readonly mailer: IMailer,
private readonly logger: ILogger,
) {}
@@ -37,11 +45,17 @@ export class NotificationService {
}
/**
- * Retrieves all notifications for a specific user.
+ * Retrieves notifications for a specific user with pagination.
+ *
+ * @param userId - The ID of the user whose notifications to retrieve.
+ * @param ctx - The authentication context of the requester.
+ * @param options - Pagination options (limit and offset).
+ * @returns `ok(notifications)` if authorized, `fail(ForbiddenError)` if not, or `fail(InternalError)`.
*/
async findByUserId(
userId: string,
ctx: AuthContext,
+ options?: { limit?: number; offset?: number },
): Promise> {
try {
// Permission check: User can only see their own notifications, or adminParoki/superAdmin
@@ -57,7 +71,7 @@ export class NotificationService {
return fail(new ForbiddenError());
}
- const data = await this.repo.findByUserId(userId);
+ const data = await this.repo.findByUserId(userId, options);
return ok(data);
} catch (error) {
this.logger.error('Failed to find notifications by user', {
@@ -68,17 +82,54 @@ export class NotificationService {
}
}
+ /**
+ * Counts unread notifications for a specific user.
+ *
+ * @param userId - The ID of the user to count unread notifications for.
+ * @param ctx - The authentication context of the requester.
+ * @returns `ok(count)` if authorized, `fail(ForbiddenError)` if not, or `fail(InternalError)`.
+ */
+ async countUnreadByUserId(
+ userId: string,
+ ctx: AuthContext,
+ ): Promise> {
+ try {
+ if (
+ ctx.userId !== userId &&
+ !ctx.roles.includes(UserRole.ParishAdmin) &&
+ !ctx.roles.includes(UserRole.SuperAdmin)
+ ) {
+ this.logger.info('Unauthorized unread count retrieval attempt', {
+ targetUserId: userId,
+ userId: ctx.userId,
+ });
+ return fail(new ForbiddenError());
+ }
+
+ const count = await this.repo.countUnreadByUserId(userId);
+ return ok(count);
+ } catch (error) {
+ this.logger.error('Failed to count unread notifications', {
+ userId,
+ error,
+ });
+ return fail(new InternalError(error));
+ }
+ }
+
/**
* Creates a new notification.
*/
async create(data: CreateNotification): Promise> {
try {
// Usually called by other services, no ctx check here or internal check
+ const isEmail = data.channel === NotificationChannel.Email;
+
const notification = await this.repo.create({
...data,
isRead: false,
- status: NotificationStatus.Sent,
- sentAt: new Date(),
+ status: isEmail ? NotificationStatus.Pending : NotificationStatus.Sent,
+ sentAt: isEmail ? null : new Date(),
});
this.logger.info('Notification created', {
@@ -219,4 +270,68 @@ export class NotificationService {
return fail(new InternalError(error));
}
}
+
+ /**
+ * Sends a notification via its designated channel (currently supports Email).
+ *
+ * @param id - The ID of the notification to send.
+ * @returns `ok(void)` on success, `fail(NotFoundError | Error)` on failure.
+ */
+ async send(id: string): Promise> {
+ try {
+ const notification = await this.repo.findById(id);
+ if (!notification) {
+ return fail(new NotFoundError('Notification'));
+ }
+
+ if (notification.channel !== NotificationChannel.Email) {
+ // Only Email channel needs manual sending logic for now
+ return ok(undefined);
+ }
+
+ const user = await this.userRepo.findById(notification.userId);
+ if (!user?.email) {
+ this.logger.error('Target user or email not found for notification', {
+ id,
+ userId: notification.userId,
+ });
+ await this.repo.update(id, {
+ status: NotificationStatus.Failed,
+ });
+ return fail(new NotFoundError('User email'));
+ }
+
+ const [_, error] = await this.mailer.send({
+ to: user.email,
+ subject: notification.title,
+ text: notification.body,
+ // html: notification.data?.html as string | undefined, // Not in schema yet
+ });
+
+ if (error) {
+ this.logger.error('Failed to send email notification', {
+ id,
+ error,
+ });
+ await this.repo.update(id, {
+ status: NotificationStatus.Failed,
+ });
+ return fail(error);
+ }
+
+ await this.repo.update(id, {
+ status: NotificationStatus.Sent,
+ sentAt: new Date(),
+ });
+
+ this.logger.info('Email notification sent successfully', { id });
+ return ok(undefined);
+ } catch (error) {
+ this.logger.error('Unexpected error during notification dispatch', {
+ id,
+ error,
+ });
+ return fail(new InternalError(error));
+ }
+ }
}
diff --git a/packages/core/src/service/term.spec.ts b/packages/core/src/service/term.spec.ts
index 17323e98..e9b901df 100644
--- a/packages/core/src/service/term.spec.ts
+++ b/packages/core/src/service/term.spec.ts
@@ -1,46 +1,81 @@
import { v7 } from 'uuid';
-import { describe, expect, it } from 'vitest';
+import { beforeEach, describe, expect, it } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type { ILogger } from '../contract/logger';
-import type { IPrivateStorage } from '../contract/storage-private';
-import type { IPublicStorage } from '../contract/storage-public';
import type { ITermRepository } from '../contract/term';
+import type { AuthContext } from '../entity/auth-context';
+import { AccountStatus, OrgRole, UserRole } from '../entity/enums';
import type { Term } from '../entity/term';
-import { NotFoundError } from '../error';
+import { ForbiddenError, NotFoundError } from '../error';
import { TermService } from './term';
describe('TermService', () => {
const repo = mock();
- const publicStorage = mock();
- const privateStorage = mock();
const logger = mock();
- const service = new TermService(repo, publicStorage, privateStorage, logger);
+ const service = new TermService(repo, logger);
const termId = v7();
const organizationId = v7();
+ const userId = v7();
- const mockTerm = {
+ const mockTerm: Term = {
id: termId,
organizationId,
name: '2024-2027',
startDate: new Date(),
endDate: new Date(),
+ description: null,
+ skNumber: null,
+ skDate: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
};
+ const adminCtx: AuthContext = {
+ userId,
+ accountStatus: AccountStatus.Approved,
+ roles: [UserRole.ParishAdmin],
+ orgRoles: {},
+ };
+
+ const orgAdminCtx: AuthContext = {
+ userId,
+ accountStatus: AccountStatus.Approved,
+ roles: [UserRole.Parishioner],
+ orgRoles: {
+ [organizationId]: OrgRole.Admin,
+ },
+ };
+
+ const unauthorizedCtx: AuthContext = {
+ userId,
+ accountStatus: AccountStatus.Approved,
+ roles: [UserRole.Parishioner],
+ orgRoles: {
+ 'other-org': OrgRole.Admin,
+ },
+ };
+
+ beforeEach(() => {
+ repo.findById.mockReset();
+ repo.findByOrganizationId.mockReset();
+ repo.create.mockReset();
+ repo.update.mockReset();
+ repo.delete.mockReset();
+ });
+
describe('findById', () => {
it('should return term if found', async () => {
- repo.findById.mockResolvedValue(mockTerm as unknown as Term);
- const [result, error] = await service.findById(termId);
+ repo.findById.mockResolvedValue(mockTerm);
+ const [result, error] = await service.findById(termId, adminCtx);
expect(error).toBeNull();
expect(result).toEqual(mockTerm);
});
it('should return NotFoundError if not found', async () => {
repo.findById.mockResolvedValue(null);
- const [result, error] = await service.findById(termId);
+ const [result, error] = await service.findById(termId, adminCtx);
expect(result).toBeNull();
expect(error).toBeInstanceOf(NotFoundError);
});
@@ -48,53 +83,114 @@ describe('TermService', () => {
describe('findByOrganizationId', () => {
it('should return list of terms for an organization', async () => {
- repo.findByOrganizationId.mockResolvedValue([
- mockTerm as unknown as Term,
- ]);
- const [result, error] =
- await service.findByOrganizationId(organizationId);
+ repo.findByOrganizationId.mockResolvedValue([mockTerm]);
+ const [result, error] = await service.findByOrganizationId(
+ organizationId,
+ adminCtx,
+ );
expect(error).toBeNull();
expect(result).toHaveLength(1);
});
});
describe('create', () => {
- it('should create term', async () => {
- repo.create.mockResolvedValue(mockTerm as unknown as Term);
- const [result, error] = await service.create({
- organizationId,
- name: 'New Term',
- startDate: new Date(),
- endDate: new Date(),
- description: null,
- skNumber: null,
- skDate: null,
- });
+ it('should create term if authorized (ParishAdmin)', async () => {
+ repo.create.mockResolvedValue(mockTerm);
+ const [result, error] = await service.create(
+ {
+ organizationId,
+ name: 'New Term',
+ startDate: new Date(),
+ endDate: new Date(),
+ description: null,
+ skNumber: null,
+ skDate: null,
+ },
+ adminCtx,
+ );
expect(error).toBeNull();
expect(result).toEqual(mockTerm);
});
+
+ it('should create term if authorized (OrgAdmin)', async () => {
+ repo.create.mockResolvedValue(mockTerm);
+ const [result, error] = await service.create(
+ {
+ organizationId,
+ name: 'New Term',
+ startDate: new Date(),
+ endDate: new Date(),
+ description: null,
+ skNumber: null,
+ skDate: null,
+ },
+ orgAdminCtx,
+ );
+ expect(error).toBeNull();
+ expect(result).toEqual(mockTerm);
+ });
+
+ it('should return ForbiddenError if unauthorized', async () => {
+ const [_result, error] = await service.create(
+ {
+ organizationId,
+ name: 'New Term',
+ startDate: new Date(),
+ endDate: new Date(),
+ description: null,
+ skNumber: null,
+ skDate: null,
+ },
+ unauthorizedCtx,
+ );
+ expect(error).toBeInstanceOf(ForbiddenError);
+ });
});
describe('update', () => {
- it('should update term', async () => {
+ it('should update term if authorized', async () => {
+ repo.findById.mockResolvedValue(mockTerm);
repo.update.mockResolvedValue({
...mockTerm,
name: 'Updated',
- } as unknown as Term);
- const [result, error] = await service.update(termId, {
- name: 'Updated',
});
+ const [result, error] = await service.update(
+ termId,
+ {
+ name: 'Updated',
+ },
+ orgAdminCtx,
+ );
expect(error).toBeNull();
expect(result?.name).toBe('Updated');
});
+
+ it('should return ForbiddenError if unauthorized', async () => {
+ repo.findById.mockResolvedValue(mockTerm);
+ const [_result, error] = await service.update(
+ termId,
+ {
+ name: 'Updated',
+ },
+ unauthorizedCtx,
+ );
+ expect(error).toBeInstanceOf(ForbiddenError);
+ });
});
describe('delete', () => {
- it('should delete term', async () => {
+ it('should delete term if authorized', async () => {
+ repo.findById.mockResolvedValue(mockTerm);
repo.delete.mockResolvedValue(undefined);
- const [_result, error] = await service.delete(termId);
+ const [_result, error] = await service.delete(termId, adminCtx);
expect(error).toBeNull();
expect(repo.delete).toHaveBeenCalledWith(termId);
});
+
+ it('should return ForbiddenError if unauthorized', async () => {
+ repo.findById.mockResolvedValue(mockTerm);
+ const [_result, error] = await service.delete(termId, unauthorizedCtx);
+ expect(error).toBeInstanceOf(ForbiddenError);
+ });
});
});
diff --git a/packages/core/src/service/term.ts b/packages/core/src/service/term.ts
index 17866cbd..02e142fa 100644
--- a/packages/core/src/service/term.ts
+++ b/packages/core/src/service/term.ts
@@ -1,94 +1,207 @@
import type { ILogger } from '../contract/logger';
-import type { IPrivateStorage } from '../contract/storage-private';
-import type { IPublicStorage } from '../contract/storage-public';
import type { ITermRepository } from '../contract/term';
+import type { AuthContext } from '../entity/auth-context';
+import { OrgRole, UserRole } from '../entity/enums';
import type { Term } from '../entity/term';
-import { InternalError, NotFoundError } from '../error';
+import { ForbiddenError, InternalError, NotFoundError } from '../error';
import { fail, ok, type Result } from '../utils/result';
/**
- * Service for managing organizational terms.
+ * Service for managing organization term records (masa jabatan).
*/
export class TermService {
constructor(
private readonly repo: ITermRepository,
- readonly _publicStorage: IPublicStorage,
- readonly _privateStorage: IPrivateStorage,
private readonly logger: ILogger,
) {}
- async findById(id: string): Promise> {
+ /**
+ * Authorizes write operations on a term.
+ * Access is granted to Organization Admins/Owners, Parish Admins, and Super Admins.
+ *
+ * @param organizationId - The organization ID.
+ * @param ctx - The authentication context.
+ * @returns `ok(undefined)` if authorized, `fail(ForbiddenError)` if not.
+ */
+ private authorizeWrite(
+ organizationId: string,
+ ctx: AuthContext,
+ ): Result {
+ const isGlobalAdmin =
+ ctx.roles.includes(UserRole.ParishAdmin) ||
+ ctx.roles.includes(UserRole.SuperAdmin);
+
+ const isOrgAdmin =
+ ctx.orgRoles?.[organizationId] === OrgRole.Admin ||
+ ctx.orgRoles?.[organizationId] === OrgRole.Owner;
+
+ if (!isGlobalAdmin && !isOrgAdmin) {
+ this.logger.warn('Unauthorized term write attempt', {
+ organizationId,
+ userId: ctx.userId,
+ });
+ return fail(new ForbiddenError());
+ }
+
+ return ok(undefined);
+ }
+
+ /**
+ * Retrieves a term by its ID.
+ *
+ * @param id - The term ID.
+ * @param ctx - The auth context for logging.
+ * @returns `ok(term)` if found, `fail(NotFoundError)` if not.
+ */
+ async findById(id: string, ctx: AuthContext): Promise> {
try {
const term = await this.repo.findById(id);
if (!term) {
- this.logger.info('Term not found', { id });
return fail(new NotFoundError('Term'));
}
return ok(term);
} catch (error) {
- this.logger.error('Failed to find term by id', { id, error });
+ this.logger.error('Failed to find term', {
+ id,
+ userId: ctx.userId,
+ error,
+ });
return fail(new InternalError(error));
}
}
- async findByOrganizationId(organizationId: string): Promise> {
+ /**
+ * Retrieves all terms for a specific organization.
+ *
+ * @param organizationId - The organization ID.
+ * @param ctx - The auth context for logging.
+ * @returns `ok(terms)` on success.
+ */
+ async findByOrganizationId(
+ organizationId: string,
+ ctx: AuthContext,
+ ): Promise> {
try {
const terms = await this.repo.findByOrganizationId(organizationId);
return ok(terms);
} catch (error) {
- this.logger.error('Failed to find terms by organization', {
+ this.logger.error('Failed to find terms by organizationId', {
organizationId,
+ userId: ctx.userId,
error,
});
return fail(new InternalError(error));
}
}
+ /**
+ * Creates a new organization term.
+ * Requires Admin/Owner role for the organization or Global Admin role.
+ *
+ * @param data - The term data.
+ * @param ctx - The auth context.
+ * @returns `ok(term)` on success, `fail(ForbiddenError)` if not authorized.
+ */
async create(
- term: Omit,
+ data: Omit,
+ ctx: AuthContext,
): Promise> {
try {
- const result = await this.repo.create(term);
+ const authResult = this.authorizeWrite(data.organizationId, ctx);
+ if (authResult[1]) return fail(authResult[1]);
+
+ const term = await this.repo.create(data);
this.logger.info('Term created', {
- termId: result.id,
- organizationId: result.organizationId,
+ termId: term.id,
+ orgId: data.organizationId,
+ userId: ctx.userId,
});
- return ok(result);
+ return ok(term);
} catch (error) {
this.logger.error('Failed to create term', {
- organizationId: term.organizationId,
+ orgId: data.organizationId,
+ userId: ctx.userId,
error,
});
return fail(new InternalError(error));
}
}
+ /**
+ * Updates an existing term.
+ * Requires Admin/Owner role for the organization or Global Admin role.
+ *
+ * @param id - The term ID.
+ * @param data - The update data.
+ * @param ctx - The auth context.
+ * @returns `ok(term)` on success, `fail(ForbiddenError)` if not authorized.
+ */
async update(
id: string,
- term: Partial<
- Omit<
- Term,
- 'id' | 'organizationId' | 'createdAt' | 'updatedAt' | 'deletedAt'
- >
- >,
+ data: Partial>,
+ ctx: AuthContext,
): Promise> {
try {
- const result = await this.repo.update(id, term);
- this.logger.info('Term updated', { termId: id });
- return ok(result);
+ const existing = await this.repo.findById(id);
+ if (!existing) {
+ return fail(new NotFoundError('Term'));
+ }
+
+ const authResult = this.authorizeWrite(existing.organizationId, ctx);
+ if (authResult[1]) return fail(authResult[1]);
+
+ const updated = await this.repo.update(id, data);
+ if (!updated) {
+ return fail(new NotFoundError('Term'));
+ }
+
+ this.logger.info('Term updated', {
+ termId: id,
+ orgId: existing.organizationId,
+ userId: ctx.userId,
+ });
+ return ok(updated);
} catch (error) {
- this.logger.error('Failed to update term', { termId: id, error });
+ this.logger.error('Failed to update term', {
+ id,
+ userId: ctx.userId,
+ error,
+ });
return fail(new InternalError(error));
}
}
- async delete(id: string): Promise> {
+ /**
+ * Soft deletes a term.
+ * Requires Admin/Owner role for the organization or Global Admin role.
+ *
+ * @param id - The term ID to delete.
+ * @param ctx - The auth context.
+ * @returns `ok(undefined)` on success.
+ */
+ async delete(id: string, ctx: AuthContext): Promise> {
try {
+ const existing = await this.repo.findById(id);
+ if (!existing) {
+ return fail(new NotFoundError('Term'));
+ }
+
+ const authResult = this.authorizeWrite(existing.organizationId, ctx);
+ if (authResult[1]) return fail(authResult[1]);
+
await this.repo.delete(id);
- this.logger.info('Term deleted', { termId: id });
+ this.logger.info('Term deleted', {
+ termId: id,
+ orgId: existing.organizationId,
+ userId: ctx.userId,
+ });
return ok(undefined);
} catch (error) {
- this.logger.error('Failed to delete term', { termId: id, error });
+ this.logger.error('Failed to delete term', {
+ id,
+ userId: ctx.userId,
+ error,
+ });
return fail(new InternalError(error));
}
}
diff --git a/packages/core/src/service/transaction-category.spec.ts b/packages/core/src/service/transaction-category.spec.ts
new file mode 100644
index 00000000..b5775884
--- /dev/null
+++ b/packages/core/src/service/transaction-category.spec.ts
@@ -0,0 +1,179 @@
+import { v7 } from 'uuid';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { mock } from 'vitest-mock-extended';
+import type { ILogger } from '../contract/logger';
+import type { ITransactionCategoryRepository } from '../contract/transaction-category';
+import type { AuthContext } from '../entity/auth-context';
+import { AccountStatus, TransactionType, UserRole } from '../entity/enums';
+import {
+ type TransactionCategory,
+ TransactionCategoryEntity,
+} from '../entity/transaction-category';
+import { ConflictError, ForbiddenError, NotFoundError } from '../error';
+import { createTransactionCategoryService } from './transaction-category';
+
+describe('TransactionCategoryService', () => {
+ const repo = mock();
+ const logger = mock();
+ const service = createTransactionCategoryService(repo, logger);
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ const mockCategory: TransactionCategory = TransactionCategoryEntity.parse({
+ id: v7(),
+ name: 'Kolekte 1',
+ type: TransactionType.Income,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ deletedAt: null,
+ });
+
+ const treasurerCtx: AuthContext = {
+ userId: v7(),
+ roles: [UserRole.Treasurer],
+ accountStatus: AccountStatus.Approved,
+ };
+
+ const parishionerCtx: AuthContext = {
+ userId: v7(),
+ roles: [UserRole.Parishioner],
+ accountStatus: AccountStatus.Approved,
+ };
+
+ describe('getCategories', () => {
+ it('should return all categories', async () => {
+ repo.findAll.mockResolvedValue([mockCategory]);
+ const [result, error] = await service.getCategories();
+ expect(error).toBeNull();
+ expect(result).toEqual([mockCategory]);
+ });
+ });
+
+ describe('getCategory', () => {
+ it('should return category when found', async () => {
+ repo.findById.mockResolvedValue(mockCategory);
+ const [result, error] = await service.getCategory(mockCategory.id);
+ expect(error).toBeNull();
+ expect(result).toEqual(mockCategory);
+ });
+
+ it('should return NotFoundError when not found', async () => {
+ repo.findById.mockResolvedValue(null);
+ const [result, error] = await service.getCategory('999');
+ expect(result).toBeNull();
+ expect(error).toBeInstanceOf(NotFoundError);
+ });
+ });
+
+ describe('createCategory', () => {
+ it('should allow treasurer to create', async () => {
+ repo.findByNameAndType.mockResolvedValue(null);
+ repo.create.mockResolvedValue(mockCategory);
+
+ const [result, error] = await service.createCategory(treasurerCtx, {
+ name: 'Kolekte 1',
+ type: TransactionType.Income,
+ });
+
+ expect(error).toBeNull();
+ expect(result).toEqual(mockCategory);
+ });
+
+ it('should forbid parishioner to create', async () => {
+ const [result, error] = await service.createCategory(parishionerCtx, {
+ name: 'Kolekte 1',
+ type: TransactionType.Income,
+ });
+
+ expect(result).toBeNull();
+ expect(error).toBeInstanceOf(ForbiddenError);
+ });
+
+ it('should return ConflictError if name+type already exists', async () => {
+ repo.findByNameAndType.mockResolvedValue(mockCategory);
+
+ const [result, error] = await service.createCategory(treasurerCtx, {
+ name: 'Kolekte 1',
+ type: TransactionType.Income,
+ });
+
+ expect(result).toBeNull();
+ expect(error).toBeInstanceOf(ConflictError);
+ });
+ });
+
+ describe('updateCategory', () => {
+ it('should allow treasurer to update', async () => {
+ repo.findById.mockResolvedValue(mockCategory);
+ repo.findByNameAndType.mockResolvedValue(null);
+ repo.update.mockResolvedValue({
+ ...mockCategory,
+ name: 'Kolekte Update',
+ });
+
+ const [result, error] = await service.updateCategory(
+ treasurerCtx,
+ mockCategory.id,
+ { name: 'Kolekte Update' },
+ );
+
+ expect(error).toBeNull();
+ expect(result?.name).toBe('Kolekte Update');
+ });
+
+ it('should return NotFoundError if category does not exist', async () => {
+ repo.findById.mockResolvedValue(null);
+
+ const [result, error] = await service.updateCategory(treasurerCtx, v7(), {
+ name: 'Update',
+ });
+
+ expect(result).toBeNull();
+ expect(error).toBeInstanceOf(NotFoundError);
+ });
+
+ it('should return ConflictError if update causes duplicate name+type', async () => {
+ const otherCategory = { ...mockCategory, id: v7(), name: 'Other' };
+ repo.findById.mockResolvedValue(mockCategory);
+ repo.findByNameAndType.mockResolvedValue(otherCategory);
+
+ const [result, error] = await service.updateCategory(
+ treasurerCtx,
+ mockCategory.id,
+ { name: 'Other' },
+ );
+
+ expect(result).toBeNull();
+ expect(error).toBeInstanceOf(ConflictError);
+ });
+ });
+
+ describe('deleteCategory', () => {
+ it('should allow treasurer to delete', async () => {
+ repo.findById.mockResolvedValue(mockCategory);
+ repo.delete.mockResolvedValue(undefined);
+
+ const [_result, error] = await service.deleteCategory(
+ treasurerCtx,
+ mockCategory.id,
+ );
+
+ expect(error).toBeNull();
+ expect(repo.delete).toHaveBeenCalledWith(mockCategory.id);
+ });
+
+ it('should forbid parishioner to delete', async () => {
+ repo.findById.mockResolvedValue(mockCategory);
+
+ const [result, error] = await service.deleteCategory(
+ parishionerCtx,
+ mockCategory.id,
+ );
+
+ expect(result).toBeNull();
+ expect(error).toBeInstanceOf(ForbiddenError);
+ });
+ });
+});
diff --git a/packages/core/src/service/transaction-category.ts b/packages/core/src/service/transaction-category.ts
new file mode 100644
index 00000000..7f7798cd
--- /dev/null
+++ b/packages/core/src/service/transaction-category.ts
@@ -0,0 +1,220 @@
+import type { ILogger } from '../contract/logger';
+import type {
+ ITransactionCategoryRepository,
+ ITransactionCategoryService,
+} from '../contract/transaction-category';
+import type { AuthContext } from '../entity/auth-context';
+import { UserRole } from '../entity/enums';
+import type {
+ CreateTransactionCategory,
+ TransactionCategory,
+ UpdateTransactionCategory,
+} from '../entity/transaction-category';
+import {
+ ConflictError,
+ ForbiddenError,
+ InternalError,
+ NotFoundError,
+} from '../error';
+import { fail, ok, type Result } from '../utils/result';
+
+/**
+ * Service for managing transaction categories.
+ */
+export class TransactionCategoryService implements ITransactionCategoryService {
+ constructor(
+ private readonly repo: ITransactionCategoryRepository,
+ private readonly logger: ILogger,
+ ) {}
+
+ /**
+ * Retrieves all transaction categories.
+ */
+ async getCategories(): Promise> {
+ try {
+ const data = await this.repo.findAll();
+ return ok(data);
+ } catch (error) {
+ this.logger.error('Failed to get transaction categories', { error });
+ return fail(new InternalError(error));
+ }
+ }
+
+ /**
+ * Retrieves a single category by ID.
+ */
+ async getCategory(id: string): Promise> {
+ try {
+ const data = await this.repo.findById(id);
+ if (!data) {
+ return fail(new NotFoundError('TransactionCategory'));
+ }
+ return ok(data);
+ } catch (error) {
+ this.logger.error('Failed to get transaction category', { id, error });
+ return fail(new InternalError(error));
+ }
+ }
+
+ /**
+ * Creates a new transaction category.
+ * Requires Treasurer or SuperAdmin role.
+ */
+ async createCategory(
+ ctx: AuthContext,
+ data: CreateTransactionCategory,
+ ): Promise> {
+ try {
+ // Permission check: Treasurer or SuperAdmin
+ const isAuthorized =
+ ctx.roles.includes(UserRole.Treasurer) ||
+ ctx.roles.includes(UserRole.SuperAdmin);
+
+ if (!isAuthorized) {
+ this.logger.info('Unauthorized transaction category creation attempt', {
+ userId: ctx.userId,
+ });
+ return fail(new ForbiddenError());
+ }
+
+ // Check for duplicate name + type
+ const existing = await this.repo.findByNameAndType(data.name, data.type);
+ if (existing) {
+ return fail(
+ new ConflictError('Kategori dengan nama dan tipe tersebut sudah ada'),
+ );
+ }
+
+ const category = await this.repo.create(data);
+
+ this.logger.info('Transaction category created', {
+ id: category.id,
+ name: category.name,
+ type: category.type,
+ userId: ctx.userId,
+ });
+
+ return ok(category);
+ } catch (error) {
+ this.logger.error('Failed to create transaction category', {
+ userId: ctx.userId,
+ error,
+ });
+ return fail(new InternalError(error));
+ }
+ }
+
+ /**
+ * Updates an existing transaction category.
+ * Requires Treasurer or SuperAdmin role.
+ */
+ async updateCategory(
+ ctx: AuthContext,
+ id: string,
+ data: UpdateTransactionCategory,
+ ): Promise> {
+ try {
+ const isAuthorized =
+ ctx.roles.includes(UserRole.Treasurer) ||
+ ctx.roles.includes(UserRole.SuperAdmin);
+
+ if (!isAuthorized) {
+ this.logger.info('Unauthorized transaction category update attempt', {
+ id,
+ userId: ctx.userId,
+ });
+ return fail(new ForbiddenError());
+ }
+
+ const existing = await this.repo.findById(id);
+ if (!existing) {
+ return fail(new NotFoundError('TransactionCategory'));
+ }
+
+ // If name or type is being updated, check for duplicates
+ if (
+ (data.name && data.name !== existing.name) ||
+ (data.type && data.type !== existing.type)
+ ) {
+ const duplicate = await this.repo.findByNameAndType(
+ data.name ?? existing.name,
+ data.type ?? existing.type,
+ );
+ if (duplicate && duplicate.id !== id) {
+ return fail(
+ new ConflictError(
+ 'Kategori dengan nama dan tipe tersebut sudah ada',
+ ),
+ );
+ }
+ }
+
+ const updated = await this.repo.update(id, data);
+
+ this.logger.info('Transaction category updated', {
+ id,
+ userId: ctx.userId,
+ });
+
+ return ok(updated);
+ } catch (error) {
+ this.logger.error('Failed to update transaction category', {
+ id,
+ userId: ctx.userId,
+ error,
+ });
+ return fail(new InternalError(error));
+ }
+ }
+
+ /**
+ * Soft deletes a transaction category.
+ * Requires Treasurer or SuperAdmin role.
+ */
+ async deleteCategory(ctx: AuthContext, id: string): Promise> {
+ try {
+ const isAuthorized =
+ ctx.roles.includes(UserRole.Treasurer) ||
+ ctx.roles.includes(UserRole.SuperAdmin);
+
+ if (!isAuthorized) {
+ this.logger.info('Unauthorized transaction category deletion attempt', {
+ id,
+ userId: ctx.userId,
+ });
+ return fail(new ForbiddenError());
+ }
+
+ const existing = await this.repo.findById(id);
+ if (!existing) {
+ return fail(new NotFoundError('TransactionCategory'));
+ }
+
+ await this.repo.delete(id);
+
+ this.logger.info('Transaction category deleted', {
+ id,
+ userId: ctx.userId,
+ });
+
+ return ok(undefined);
+ } catch (error) {
+ this.logger.error('Failed to delete transaction category', {
+ id,
+ userId: ctx.userId,
+ error,
+ });
+ return fail(new InternalError(error));
+ }
+ }
+}
+
+/**
+ * Factory function to create a new instance of TransactionCategoryService.
+ */
+export function createTransactionCategoryService(
+ repo: ITransactionCategoryRepository,
+ logger: ILogger,
+): TransactionCategoryService {
+ return new TransactionCategoryService(repo, logger);
+}
diff --git a/packages/core/src/service/transaction.spec.ts b/packages/core/src/service/transaction.spec.ts
index a262c1ff..b0712977 100644
--- a/packages/core/src/service/transaction.spec.ts
+++ b/packages/core/src/service/transaction.spec.ts
@@ -6,6 +6,7 @@ import type { ILogger } from '../contract/logger';
import type { IPrivateStorage } from '../contract/storage-private';
import type { IPublicStorage } from '../contract/storage-public';
import type { ITransactionRepository } from '../contract/transaction';
+import type { ITransactionCategoryRepository } from '../contract/transaction-category';
import type { AuthContext } from '../entity/auth-context';
import {
AccountStatus,
@@ -15,18 +16,21 @@ import {
} from '../entity/enums';
import type { FinancialPeriod } from '../entity/financial-period';
import type { Transaction } from '../entity/transaction';
+import type { TransactionCategory } from '../entity/transaction-category';
import { ForbiddenError, ValidationError } from '../error';
import { TransactionService } from './transaction';
describe('TransactionService', () => {
const repo = mock();
const periodRepo = mock();
+ const categoryRepo = mock();
const publicStorage = mock();
const privateStorage = mock();
const logger = mock();
const service = new TransactionService(
repo,
periodRepo,
+ categoryRepo,
publicStorage,
privateStorage,
logger,
@@ -204,4 +208,53 @@ describe('TransactionService', () => {
expect(error?.message).toContain('locked');
});
});
+
+ describe('getYearlyReport', () => {
+ it('should return yearly report', async () => {
+ periodRepo.findByYear.mockResolvedValue([
+ mockPeriod as unknown as FinancialPeriod,
+ ]);
+ repo.findByPeriodId.mockResolvedValue([
+ mockTransaction as unknown as Transaction,
+ ]);
+
+ const [result, error] = await service.getYearlyReport(2026, bendaharaCtx);
+
+ expect(error).toBeNull();
+ expect(result?.year).toBe(2026);
+ expect(result?.stats).toHaveLength(1);
+ });
+
+ it('should forbid regular parishioners', async () => {
+ const [_result, error] = await service.getYearlyReport(2026, _userCtx);
+ expect(error).toBeInstanceOf(ForbiddenError);
+ });
+ });
+
+ describe('getMonthlyReport', () => {
+ it('should return monthly report with categories', async () => {
+ periodRepo.findById.mockResolvedValue(
+ mockPeriod as unknown as FinancialPeriod,
+ );
+ repo.findByPeriodId.mockResolvedValue([
+ mockTransaction as unknown as Transaction,
+ ]);
+ categoryRepo.findAll.mockResolvedValue([
+ {
+ id: catId,
+ name: 'Donasi',
+ type: TransactionType.Income,
+ } as unknown as TransactionCategory,
+ ]);
+
+ const [result, error] = await service.getMonthlyReport(
+ periodId,
+ bendaharaCtx,
+ );
+
+ expect(error).toBeNull();
+ expect(result?.incomeCategories).toHaveLength(1);
+ expect(result?.incomeCategories[0]?.categoryName).toBe('Donasi');
+ });
+ });
});
diff --git a/packages/core/src/service/transaction.ts b/packages/core/src/service/transaction.ts
index 5f4a6acc..ba78a531 100644
--- a/packages/core/src/service/transaction.ts
+++ b/packages/core/src/service/transaction.ts
@@ -3,8 +3,14 @@ import type { ILogger } from '../contract/logger';
import type { IPrivateStorage } from '../contract/storage-private';
import type { IPublicStorage } from '../contract/storage-public';
import type { ITransactionRepository } from '../contract/transaction';
+import type { ITransactionCategoryRepository } from '../contract/transaction-category';
import type { AuthContext } from '../entity/auth-context';
-import { PeriodStatus, UserRole } from '../entity/enums';
+import { PeriodStatus, TransactionType, UserRole } from '../entity/enums';
+import type {
+ MonthlyReport,
+ MonthlyStats,
+ YearlyReport,
+} from '../entity/financial-report';
import type {
CreateTransaction,
Transaction,
@@ -25,6 +31,7 @@ export class TransactionService {
constructor(
private readonly repo: ITransactionRepository,
private readonly periodRepo: IFinancialPeriodRepository,
+ private readonly categoryRepo: ITransactionCategoryRepository,
readonly _publicStorage: IPublicStorage,
readonly _privateStorage: IPrivateStorage,
private readonly logger: ILogger,
@@ -245,4 +252,164 @@ export class TransactionService {
return fail(new InternalError(error));
}
}
+
+ /**
+ * Generates a yearly financial report overview.
+ * Access restricted to Treasurer, Pastor, and Executive Board.
+ */
+ async getYearlyReport(
+ year: number,
+ ctx: AuthContext,
+ ): Promise> {
+ try {
+ if (
+ !ctx.roles.includes(UserRole.Treasurer) &&
+ !ctx.roles.includes(UserRole.Pastor) &&
+ !ctx.roles.includes(UserRole.ExecutiveBoard) &&
+ !ctx.roles.includes(UserRole.SuperAdmin)
+ ) {
+ return fail(new ForbiddenError());
+ }
+
+ const periods = await this.periodRepo.findByYear(year);
+ const stats: MonthlyStats[] = [];
+
+ for (const period of periods) {
+ const transactions = await this.repo.findByPeriodId(period.id);
+ let income = 0;
+ let expense = 0;
+
+ for (const tx of transactions) {
+ if (tx.type === TransactionType.Income) {
+ income += tx.amount;
+ } else {
+ expense += tx.amount;
+ }
+ }
+
+ stats.push({
+ periodId: period.id,
+ month: period.month,
+ income,
+ expense,
+ balance: income - expense,
+ });
+ }
+
+ // Sort by month
+ stats.sort((a, b) => a.month - b.month);
+
+ const totalIncome = stats.reduce((sum, s) => sum + s.income, 0);
+ const totalExpense = stats.reduce((sum, s) => sum + s.expense, 0);
+
+ return ok({
+ year,
+ stats,
+ totalIncome,
+ totalExpense,
+ netBalance: totalIncome - totalExpense,
+ });
+ } catch (error) {
+ this.logger.error('Failed to generate yearly report', { year, error });
+ return fail(new InternalError(error));
+ }
+ }
+
+ /**
+ * Generates a monthly financial report detail.
+ * Access restricted to Treasurer, Pastor, and Executive Board.
+ */
+ async getMonthlyReport(
+ periodId: string,
+ ctx: AuthContext,
+ ): Promise> {
+ try {
+ if (
+ !ctx.roles.includes(UserRole.Treasurer) &&
+ !ctx.roles.includes(UserRole.Pastor) &&
+ !ctx.roles.includes(UserRole.ExecutiveBoard) &&
+ !ctx.roles.includes(UserRole.SuperAdmin)
+ ) {
+ return fail(new ForbiddenError());
+ }
+
+ const period = await this.periodRepo.findById(periodId);
+ if (!period) {
+ return fail(new NotFoundError('Financial Period'));
+ }
+
+ const transactions = await this.repo.findByPeriodId(periodId);
+ const categories = await this.categoryRepo.findAll();
+
+ const incomeCategories: MonthlyReport['incomeCategories'] = [];
+ const expenseCategories: MonthlyReport['expenseCategories'] = [];
+
+ // Group by category
+ const grouped = transactions.reduce(
+ (acc, tx) => {
+ let item = acc[tx.categoryId];
+ if (!item) {
+ item = {
+ categoryId: tx.categoryId,
+ categoryName:
+ categories.find((c) => c.id === tx.categoryId)?.name ??
+ 'Unknown',
+ amount: 0,
+ transactions: [],
+ };
+ acc[tx.categoryId] = item;
+ }
+ item.amount += tx.amount;
+ item.transactions.push(tx);
+ return acc;
+ },
+ {} as Record<
+ string,
+ {
+ categoryId: string;
+ categoryName: string;
+ amount: number;
+ transactions: Transaction[];
+ }
+ >,
+ );
+
+ for (const catId in grouped) {
+ const item = grouped[catId];
+ if (!item) continue;
+ const category = categories.find((c) => c.id === catId);
+ if (category?.type === TransactionType.Income) {
+ incomeCategories.push(item);
+ } else {
+ expenseCategories.push(item);
+ }
+ }
+
+ const totalIncome = incomeCategories.reduce(
+ (sum, c) => sum + c.amount,
+ 0,
+ );
+ const totalExpense = expenseCategories.reduce(
+ (sum, c) => sum + c.amount,
+ 0,
+ );
+
+ return ok({
+ periodId,
+ year: period.year,
+ month: period.month,
+ incomeCategories,
+ expenseCategories,
+ totalIncome,
+ totalExpense,
+ balance: totalIncome - totalExpense,
+ });
+ } catch (error) {
+ this.logger.error('Failed to generate monthly report', {
+ periodId,
+ error,
+ });
+ return fail(new InternalError(error));
+ }
+ }
}
diff --git a/packages/db/migrations/0000_initial.sql b/packages/db/migrations/0000_sour_shockwave.sql
similarity index 99%
rename from packages/db/migrations/0000_initial.sql
rename to packages/db/migrations/0000_sour_shockwave.sql
index e4fd48c2..9364949f 100644
--- a/packages/db/migrations/0000_initial.sql
+++ b/packages/db/migrations/0000_sour_shockwave.sql
@@ -1,4 +1,4 @@
-CREATE TYPE "public"."attachment_reference_type" AS ENUM('Event', 'Organization', 'Term');--> statement-breakpoint
+CREATE TYPE "public"."attachment_reference_type" AS ENUM('Event', 'Organization', 'Term', 'Transaction');--> statement-breakpoint
CREATE TYPE "public"."attendance_method" AS ENUM('qr-code', 'gps', 'manual');--> statement-breakpoint
CREATE TYPE "public"."attendance_status" AS ENUM('present', 'pending', 'absent');--> statement-breakpoint
CREATE TYPE "public"."event_status" AS ENUM('published', 'completed');--> statement-breakpoint
@@ -159,7 +159,6 @@ CREATE TABLE "events" (
"radius_meters" integer DEFAULT 100 NOT NULL,
"start_date_time" timestamp NOT NULL,
"end_date_time" timestamp,
- "event_photo" text,
"qr_code" text NOT NULL,
"visibility" "event_visibility" NOT NULL,
"is_rsvp_enabled" boolean DEFAULT false NOT NULL,
@@ -356,7 +355,8 @@ CREATE TABLE "transaction_categories" (
"type" "transaction_type" NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
- "deleted_at" timestamp
+ "deleted_at" timestamp,
+ CONSTRAINT "transaction_categories_name_type_unique" UNIQUE("name","type")
);
--> statement-breakpoint
CREATE TABLE "transactions" (
diff --git a/packages/db/migrations/0001_reflective_gamma_corps.sql b/packages/db/migrations/0001_reflective_gamma_corps.sql
deleted file mode 100644
index 53368c51..00000000
--- a/packages/db/migrations/0001_reflective_gamma_corps.sql
+++ /dev/null
@@ -1 +0,0 @@
-ALTER TABLE "events" DROP COLUMN "event_photo";
\ No newline at end of file
diff --git a/packages/db/migrations/0002_greedy_triathlon.sql b/packages/db/migrations/0002_greedy_triathlon.sql
deleted file mode 100644
index f1004e1a..00000000
--- a/packages/db/migrations/0002_greedy_triathlon.sql
+++ /dev/null
@@ -1 +0,0 @@
-ALTER TABLE "org_terms" ADD COLUMN "sk_document" text;
\ No newline at end of file
diff --git a/packages/db/migrations/0003_motionless_jack_murdock.sql b/packages/db/migrations/0003_motionless_jack_murdock.sql
deleted file mode 100644
index 7ef41f51..00000000
--- a/packages/db/migrations/0003_motionless_jack_murdock.sql
+++ /dev/null
@@ -1 +0,0 @@
-ALTER TABLE "org_terms" DROP COLUMN "sk_document";
\ No newline at end of file
diff --git a/packages/db/migrations/meta/0000_snapshot.json b/packages/db/migrations/meta/0000_snapshot.json
index 792a29c5..95937f4b 100644
--- a/packages/db/migrations/meta/0000_snapshot.json
+++ b/packages/db/migrations/meta/0000_snapshot.json
@@ -1,5 +1,5 @@
{
- "id": "cf56cb7f-9ef9-4086-a05d-36373913c55e",
+ "id": "721b3a99-f0b0-4c1d-8d52-6c35a677e8e9",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
@@ -1093,12 +1093,6 @@
"primaryKey": false,
"notNull": false
},
- "event_photo": {
- "name": "event_photo",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
"qr_code": {
"name": "qr_code",
"type": "text",
@@ -2462,7 +2456,13 @@
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
- "uniqueConstraints": {},
+ "uniqueConstraints": {
+ "transaction_categories_name_type_unique": {
+ "name": "transaction_categories_name_type_unique",
+ "nullsNotDistinct": false,
+ "columns": ["name", "type"]
+ }
+ },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
@@ -2694,7 +2694,7 @@
"public.attachment_reference_type": {
"name": "attachment_reference_type",
"schema": "public",
- "values": ["Event", "Organization", "Term"]
+ "values": ["Event", "Organization", "Term", "Transaction"]
},
"public.attendance_method": {
"name": "attendance_method",
diff --git a/packages/db/migrations/meta/0001_snapshot.json b/packages/db/migrations/meta/0001_snapshot.json
deleted file mode 100644
index 590709bf..00000000
--- a/packages/db/migrations/meta/0001_snapshot.json
+++ /dev/null
@@ -1,2779 +0,0 @@
-{
- "id": "4037bbe0-0fd5-4ff4-9e2c-bc2c5cf3a95d",
- "prevId": "cf56cb7f-9ef9-4086-a05d-36373913c55e",
- "version": "7",
- "dialect": "postgresql",
- "tables": {
- "public.account": {
- "name": "account",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "account_id": {
- "name": "account_id",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "provider_id": {
- "name": "provider_id",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "user_id": {
- "name": "user_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "access_token": {
- "name": "access_token",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "refresh_token": {
- "name": "refresh_token",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "id_token": {
- "name": "id_token",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "access_token_expires_at": {
- "name": "access_token_expires_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "refresh_token_expires_at": {
- "name": "refresh_token_expires_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "scope": {
- "name": "scope",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "password": {
- "name": "password",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {
- "account_userId_idx": {
- "name": "account_userId_idx",
- "columns": [
- {
- "expression": "user_id",
- "isExpression": false,
- "asc": true,
- "nulls": "last"
- }
- ],
- "isUnique": false,
- "concurrently": false,
- "method": "btree",
- "with": {}
- }
- },
- "foreignKeys": {
- "account_user_id_user_id_fk": {
- "name": "account_user_id_user_id_fk",
- "tableFrom": "account",
- "tableTo": "user",
- "columnsFrom": ["user_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.invitation": {
- "name": "invitation",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "organization_id": {
- "name": "organization_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "email": {
- "name": "email",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "role": {
- "name": "role",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "status": {
- "name": "status",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "default": "'pending'"
- },
- "expires_at": {
- "name": "expires_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "inviter_id": {
- "name": "inviter_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {
- "invitation_organizationId_idx": {
- "name": "invitation_organizationId_idx",
- "columns": [
- {
- "expression": "organization_id",
- "isExpression": false,
- "asc": true,
- "nulls": "last"
- }
- ],
- "isUnique": false,
- "concurrently": false,
- "method": "btree",
- "with": {}
- },
- "invitation_email_idx": {
- "name": "invitation_email_idx",
- "columns": [
- {
- "expression": "email",
- "isExpression": false,
- "asc": true,
- "nulls": "last"
- }
- ],
- "isUnique": false,
- "concurrently": false,
- "method": "btree",
- "with": {}
- }
- },
- "foreignKeys": {
- "invitation_organization_id_organization_id_fk": {
- "name": "invitation_organization_id_organization_id_fk",
- "tableFrom": "invitation",
- "tableTo": "organization",
- "columnsFrom": ["organization_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "invitation_inviter_id_user_id_fk": {
- "name": "invitation_inviter_id_user_id_fk",
- "tableFrom": "invitation",
- "tableTo": "user",
- "columnsFrom": ["inviter_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.member": {
- "name": "member",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "organization_id": {
- "name": "organization_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "user_id": {
- "name": "user_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "role": {
- "name": "role",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "default": "'member'"
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {
- "member_organizationId_idx": {
- "name": "member_organizationId_idx",
- "columns": [
- {
- "expression": "organization_id",
- "isExpression": false,
- "asc": true,
- "nulls": "last"
- }
- ],
- "isUnique": false,
- "concurrently": false,
- "method": "btree",
- "with": {}
- },
- "member_userId_idx": {
- "name": "member_userId_idx",
- "columns": [
- {
- "expression": "user_id",
- "isExpression": false,
- "asc": true,
- "nulls": "last"
- }
- ],
- "isUnique": false,
- "concurrently": false,
- "method": "btree",
- "with": {}
- }
- },
- "foreignKeys": {
- "member_organization_id_organization_id_fk": {
- "name": "member_organization_id_organization_id_fk",
- "tableFrom": "member",
- "tableTo": "organization",
- "columnsFrom": ["organization_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "member_user_id_user_id_fk": {
- "name": "member_user_id_user_id_fk",
- "tableFrom": "member",
- "tableTo": "user",
- "columnsFrom": ["user_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.organization": {
- "name": "organization",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "parish_id": {
- "name": "parish_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "slug": {
- "name": "slug",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "logo": {
- "name": "logo",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "metadata": {
- "name": "metadata",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "type": {
- "name": "type",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "parent_id": {
- "name": "parent_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "cover": {
- "name": "cover",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "join_id": {
- "name": "join_id",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "description": {
- "name": "description",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "short_name": {
- "name": "short_name",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {
- "organization_slug_uidx": {
- "name": "organization_slug_uidx",
- "columns": [
- {
- "expression": "slug",
- "isExpression": false,
- "asc": true,
- "nulls": "last"
- }
- ],
- "isUnique": true,
- "concurrently": false,
- "method": "btree",
- "with": {}
- }
- },
- "foreignKeys": {
- "organization_parish_id_parishes_id_fk": {
- "name": "organization_parish_id_parishes_id_fk",
- "tableFrom": "organization",
- "tableTo": "parishes",
- "columnsFrom": ["parish_id"],
- "columnsTo": ["id"],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "organization_slug_unique": {
- "name": "organization_slug_unique",
- "nullsNotDistinct": false,
- "columns": ["slug"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.session": {
- "name": "session",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "expires_at": {
- "name": "expires_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "token": {
- "name": "token",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "ip_address": {
- "name": "ip_address",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "user_agent": {
- "name": "user_agent",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "user_id": {
- "name": "user_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "impersonated_by": {
- "name": "impersonated_by",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "active_organization_id": {
- "name": "active_organization_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {
- "session_userId_idx": {
- "name": "session_userId_idx",
- "columns": [
- {
- "expression": "user_id",
- "isExpression": false,
- "asc": true,
- "nulls": "last"
- }
- ],
- "isUnique": false,
- "concurrently": false,
- "method": "btree",
- "with": {}
- }
- },
- "foreignKeys": {
- "session_user_id_user_id_fk": {
- "name": "session_user_id_user_id_fk",
- "tableFrom": "session",
- "tableTo": "user",
- "columnsFrom": ["user_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "session_token_unique": {
- "name": "session_token_unique",
- "nullsNotDistinct": false,
- "columns": ["token"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.user": {
- "name": "user",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "email": {
- "name": "email",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "email_verified": {
- "name": "email_verified",
- "type": "boolean",
- "primaryKey": false,
- "notNull": true,
- "default": false
- },
- "image": {
- "name": "image",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "role": {
- "name": "role",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "banned": {
- "name": "banned",
- "type": "boolean",
- "primaryKey": false,
- "notNull": false,
- "default": false
- },
- "ban_reason": {
- "name": "ban_reason",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "ban_expires": {
- "name": "ban_expires",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "account_status": {
- "name": "account_status",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "default": "'pending'"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "parishioner_id": {
- "name": "parishioner_id",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "user_email_unique": {
- "name": "user_email_unique",
- "nullsNotDistinct": false,
- "columns": ["email"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.verification": {
- "name": "verification",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "identifier": {
- "name": "identifier",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "value": {
- "name": "value",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "expires_at": {
- "name": "expires_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- }
- },
- "indexes": {
- "verification_identifier_idx": {
- "name": "verification_identifier_idx",
- "columns": [
- {
- "expression": "identifier",
- "isExpression": false,
- "asc": true,
- "nulls": "last"
- }
- ],
- "isUnique": false,
- "concurrently": false,
- "method": "btree",
- "with": {}
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.attachments": {
- "name": "attachments",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "reference_id": {
- "name": "reference_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "reference_type": {
- "name": "reference_type",
- "type": "attachment_reference_type",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "file_id": {
- "name": "file_id",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "viewer_url": {
- "name": "viewer_url",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "mime_type": {
- "name": "mime_type",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "size": {
- "name": "size",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "uploaded_by": {
- "name": "uploaded_by",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "attachments_uploaded_by_user_id_fk": {
- "name": "attachments_uploaded_by_user_id_fk",
- "tableFrom": "attachments",
- "tableTo": "user",
- "columnsFrom": ["uploaded_by"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.attendances": {
- "name": "attendances",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "event_id": {
- "name": "event_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "parishioner_id": {
- "name": "parishioner_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "method": {
- "name": "method",
- "type": "attendance_method",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "status": {
- "name": "status",
- "type": "attendance_status",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "verified_by": {
- "name": "verified_by",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "verified_at": {
- "name": "verified_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "attendances_event_id_events_id_fk": {
- "name": "attendances_event_id_events_id_fk",
- "tableFrom": "attendances",
- "tableTo": "events",
- "columnsFrom": ["event_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "attendances_parishioner_id_parishioners_id_fk": {
- "name": "attendances_parishioner_id_parishioners_id_fk",
- "tableFrom": "attendances",
- "tableTo": "parishioners",
- "columnsFrom": ["parishioner_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "attendances_verified_by_user_id_fk": {
- "name": "attendances_verified_by_user_id_fk",
- "tableFrom": "attendances",
- "tableTo": "user",
- "columnsFrom": ["verified_by"],
- "columnsTo": ["id"],
- "onDelete": "set null",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "attendances_event_id_parishioner_id_unique": {
- "name": "attendances_event_id_parishioner_id_unique",
- "nullsNotDistinct": false,
- "columns": ["event_id", "parishioner_id"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.dioceses": {
- "name": "dioceses",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "address": {
- "name": "address",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "phone": {
- "name": "phone",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "email": {
- "name": "email",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "logo": {
- "name": "logo",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "website": {
- "name": "website",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "description": {
- "name": "description",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.events": {
- "name": "events",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "organization_id": {
- "name": "organization_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "description": {
- "name": "description",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "location": {
- "name": "location",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "latitude": {
- "name": "latitude",
- "type": "double precision",
- "primaryKey": false,
- "notNull": false
- },
- "longitude": {
- "name": "longitude",
- "type": "double precision",
- "primaryKey": false,
- "notNull": false
- },
- "radius_meters": {
- "name": "radius_meters",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "default": 100
- },
- "start_date_time": {
- "name": "start_date_time",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "end_date_time": {
- "name": "end_date_time",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "qr_code": {
- "name": "qr_code",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "visibility": {
- "name": "visibility",
- "type": "event_visibility",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "is_rsvp_enabled": {
- "name": "is_rsvp_enabled",
- "type": "boolean",
- "primaryKey": false,
- "notNull": true,
- "default": false
- },
- "status": {
- "name": "status",
- "type": "event_status",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "created_by": {
- "name": "created_by",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "events_organization_id_organization_id_fk": {
- "name": "events_organization_id_organization_id_fk",
- "tableFrom": "events",
- "tableTo": "organization",
- "columnsFrom": ["organization_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "events_created_by_user_id_fk": {
- "name": "events_created_by_user_id_fk",
- "tableFrom": "events",
- "tableTo": "user",
- "columnsFrom": ["created_by"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.financial_periods": {
- "name": "financial_periods",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "month": {
- "name": "month",
- "type": "integer",
- "primaryKey": false,
- "notNull": true
- },
- "year": {
- "name": "year",
- "type": "integer",
- "primaryKey": false,
- "notNull": true
- },
- "status": {
- "name": "status",
- "type": "period_status",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "locked_by": {
- "name": "locked_by",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "locked_at": {
- "name": "locked_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "financial_periods_locked_by_user_id_fk": {
- "name": "financial_periods_locked_by_user_id_fk",
- "tableFrom": "financial_periods",
- "tableTo": "user",
- "columnsFrom": ["locked_by"],
- "columnsTo": ["id"],
- "onDelete": "set null",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.notifications": {
- "name": "notifications",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "user_id": {
- "name": "user_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "title": {
- "name": "title",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "body": {
- "name": "body",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "type": {
- "name": "type",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "channel": {
- "name": "channel",
- "type": "notification_channel",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "is_read": {
- "name": "is_read",
- "type": "boolean",
- "primaryKey": false,
- "notNull": true,
- "default": false
- },
- "status": {
- "name": "status",
- "type": "notification_status",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "reference_id": {
- "name": "reference_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "reference_type": {
- "name": "reference_type",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "sent_at": {
- "name": "sent_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "notifications_user_id_user_id_fk": {
- "name": "notifications_user_id_user_id_fk",
- "tableFrom": "notifications",
- "tableTo": "user",
- "columnsFrom": ["user_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.org_enrollments": {
- "name": "org_enrollments",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "parishioner_id": {
- "name": "parishioner_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "organization_id": {
- "name": "organization_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "unit_id": {
- "name": "unit_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "status": {
- "name": "status",
- "type": "enrollment_status",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "joined_at": {
- "name": "joined_at",
- "type": "date",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "org_enrollments_parishioner_id_parishioners_id_fk": {
- "name": "org_enrollments_parishioner_id_parishioners_id_fk",
- "tableFrom": "org_enrollments",
- "tableTo": "parishioners",
- "columnsFrom": ["parishioner_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "org_enrollments_organization_id_organization_id_fk": {
- "name": "org_enrollments_organization_id_organization_id_fk",
- "tableFrom": "org_enrollments",
- "tableTo": "organization",
- "columnsFrom": ["organization_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "org_enrollments_unit_id_org_units_id_fk": {
- "name": "org_enrollments_unit_id_org_units_id_fk",
- "tableFrom": "org_enrollments",
- "tableTo": "org_units",
- "columnsFrom": ["unit_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "org_enrollments_parishioner_id_organization_id_unique": {
- "name": "org_enrollments_parishioner_id_organization_id_unique",
- "nullsNotDistinct": false,
- "columns": ["parishioner_id", "organization_id"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.org_placements": {
- "name": "org_placements",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "enrollment_id": {
- "name": "enrollment_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "term_id": {
- "name": "term_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "unit_id": {
- "name": "unit_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "position": {
- "name": "position",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "default": "'anggota'"
- },
- "order": {
- "name": "order",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "default": 0
- },
- "start_date": {
- "name": "start_date",
- "type": "date",
- "primaryKey": false,
- "notNull": false
- },
- "end_date": {
- "name": "end_date",
- "type": "date",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "org_placements_enrollment_id_org_enrollments_id_fk": {
- "name": "org_placements_enrollment_id_org_enrollments_id_fk",
- "tableFrom": "org_placements",
- "tableTo": "org_enrollments",
- "columnsFrom": ["enrollment_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "org_placements_term_id_org_terms_id_fk": {
- "name": "org_placements_term_id_org_terms_id_fk",
- "tableFrom": "org_placements",
- "tableTo": "org_terms",
- "columnsFrom": ["term_id"],
- "columnsTo": ["id"],
- "onDelete": "set null",
- "onUpdate": "no action"
- },
- "org_placements_unit_id_org_units_id_fk": {
- "name": "org_placements_unit_id_org_units_id_fk",
- "tableFrom": "org_placements",
- "tableTo": "org_units",
- "columnsFrom": ["unit_id"],
- "columnsTo": ["id"],
- "onDelete": "set null",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.org_terms": {
- "name": "org_terms",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "organization_id": {
- "name": "organization_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "start_date": {
- "name": "start_date",
- "type": "date",
- "primaryKey": false,
- "notNull": false
- },
- "end_date": {
- "name": "end_date",
- "type": "date",
- "primaryKey": false,
- "notNull": false
- },
- "sk_number": {
- "name": "sk_number",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "sk_date": {
- "name": "sk_date",
- "type": "date",
- "primaryKey": false,
- "notNull": false
- },
- "description": {
- "name": "description",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "org_terms_organization_id_organization_id_fk": {
- "name": "org_terms_organization_id_organization_id_fk",
- "tableFrom": "org_terms",
- "tableTo": "organization",
- "columnsFrom": ["organization_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.org_units": {
- "name": "org_units",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "organization_id": {
- "name": "organization_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "description": {
- "name": "description",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "position": {
- "name": "position",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "default": 0
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "org_units_organization_id_organization_id_fk": {
- "name": "org_units_organization_id_organization_id_fk",
- "tableFrom": "org_units",
- "tableTo": "organization",
- "columnsFrom": ["organization_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.parishes": {
- "name": "parishes",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "vicariate_id": {
- "name": "vicariate_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "sync_id": {
- "name": "sync_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "address": {
- "name": "address",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "phone": {
- "name": "phone",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "email": {
- "name": "email",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "logo": {
- "name": "logo",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "website": {
- "name": "website",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "description": {
- "name": "description",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "regency_id": {
- "name": "regency_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "district_id": {
- "name": "district_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "parishes_vicariate_id_vicariates_id_fk": {
- "name": "parishes_vicariate_id_vicariates_id_fk",
- "tableFrom": "parishes",
- "tableTo": "vicariates",
- "columnsFrom": ["vicariate_id"],
- "columnsTo": ["id"],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "parishes_sync_id_unique": {
- "name": "parishes_sync_id_unique",
- "nullsNotDistinct": false,
- "columns": ["sync_id"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.parishioners": {
- "name": "parishioners",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "user_id": {
- "name": "user_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "email": {
- "name": "email",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "avatar_url": {
- "name": "avatar_url",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "full_name": {
- "name": "full_name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "honorific": {
- "name": "honorific",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "suffix": {
- "name": "suffix",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "birth_place": {
- "name": "birth_place",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "birth_date": {
- "name": "birth_date",
- "type": "date",
- "primaryKey": false,
- "notNull": false
- },
- "gender": {
- "name": "gender",
- "type": "gender",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": false
- },
- "education_level": {
- "name": "education_level",
- "type": "education_level",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": false
- },
- "id_card_number": {
- "name": "id_card_number",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "id_card_photo": {
- "name": "id_card_photo",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "regency_id": {
- "name": "regency_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "district_id": {
- "name": "district_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "village_id": {
- "name": "village_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "sync_id": {
- "name": "sync_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "synced_at": {
- "name": "synced_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "baptism_name": {
- "name": "baptism_name",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "family_card_number": {
- "name": "family_card_number",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "blood_type": {
- "name": "blood_type",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "ethnicity": {
- "name": "ethnicity",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "religion": {
- "name": "religion",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "citizenship": {
- "name": "citizenship",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "family_relation": {
- "name": "family_relation",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "marital_status": {
- "name": "marital_status",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "marriage_date": {
- "name": "marriage_date",
- "type": "date",
- "primaryKey": false,
- "notNull": false
- },
- "address": {
- "name": "address",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "house_status": {
- "name": "house_status",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "phone": {
- "name": "phone",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "education_major": {
- "name": "education_major",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "occupation": {
- "name": "occupation",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "profession": {
- "name": "profession",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "skills": {
- "name": "skills",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "physical_condition": {
- "name": "physical_condition",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "economic_status": {
- "name": "economic_status",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "social_activity": {
- "name": "social_activity",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "parishioners_user_id_user_id_fk": {
- "name": "parishioners_user_id_user_id_fk",
- "tableFrom": "parishioners",
- "tableTo": "user",
- "columnsFrom": ["user_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "parishioners_regency_id_regencies_id_fk": {
- "name": "parishioners_regency_id_regencies_id_fk",
- "tableFrom": "parishioners",
- "tableTo": "regencies",
- "columnsFrom": ["regency_id"],
- "columnsTo": ["id"],
- "onDelete": "no action",
- "onUpdate": "no action"
- },
- "parishioners_district_id_districts_id_fk": {
- "name": "parishioners_district_id_districts_id_fk",
- "tableFrom": "parishioners",
- "tableTo": "districts",
- "columnsFrom": ["district_id"],
- "columnsTo": ["id"],
- "onDelete": "no action",
- "onUpdate": "no action"
- },
- "parishioners_village_id_villages_id_fk": {
- "name": "parishioners_village_id_villages_id_fk",
- "tableFrom": "parishioners",
- "tableTo": "villages",
- "columnsFrom": ["village_id"],
- "columnsTo": ["id"],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "parishioners_sync_id_unique": {
- "name": "parishioners_sync_id_unique",
- "nullsNotDistinct": false,
- "columns": ["sync_id"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.districts": {
- "name": "districts",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true
- },
- "regency_id": {
- "name": "regency_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true
- },
- "sync_id": {
- "name": "sync_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {},
- "foreignKeys": {
- "districts_regency_id_regencies_id_fk": {
- "name": "districts_regency_id_regencies_id_fk",
- "tableFrom": "districts",
- "tableTo": "regencies",
- "columnsFrom": ["regency_id"],
- "columnsTo": ["id"],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.provinces": {
- "name": "provinces",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {},
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.regencies": {
- "name": "regencies",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true
- },
- "province_id": {
- "name": "province_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true
- },
- "sync_id": {
- "name": "sync_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {},
- "foreignKeys": {
- "regencies_province_id_provinces_id_fk": {
- "name": "regencies_province_id_provinces_id_fk",
- "tableFrom": "regencies",
- "tableTo": "provinces",
- "columnsFrom": ["province_id"],
- "columnsTo": ["id"],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.villages": {
- "name": "villages",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "bigint",
- "primaryKey": true,
- "notNull": true
- },
- "district_id": {
- "name": "district_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true
- },
- "sync_id": {
- "name": "sync_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {},
- "foreignKeys": {
- "villages_district_id_districts_id_fk": {
- "name": "villages_district_id_districts_id_fk",
- "tableFrom": "villages",
- "tableTo": "districts",
- "columnsFrom": ["district_id"],
- "columnsTo": ["id"],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.rsvp": {
- "name": "rsvp",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "event_id": {
- "name": "event_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "parishioner_id": {
- "name": "parishioner_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "status": {
- "name": "status",
- "type": "rsvp_status",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "rsvp_event_id_events_id_fk": {
- "name": "rsvp_event_id_events_id_fk",
- "tableFrom": "rsvp",
- "tableTo": "events",
- "columnsFrom": ["event_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "rsvp_parishioner_id_parishioners_id_fk": {
- "name": "rsvp_parishioner_id_parishioners_id_fk",
- "tableFrom": "rsvp",
- "tableTo": "parishioners",
- "columnsFrom": ["parishioner_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "rsvp_event_id_parishioner_id_unique": {
- "name": "rsvp_event_id_parishioner_id_unique",
- "nullsNotDistinct": false,
- "columns": ["event_id", "parishioner_id"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.transaction_categories": {
- "name": "transaction_categories",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "type": {
- "name": "type",
- "type": "transaction_type",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.transactions": {
- "name": "transactions",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "period_id": {
- "name": "period_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "category_id": {
- "name": "category_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "type": {
- "name": "type",
- "type": "transaction_type",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "amount": {
- "name": "amount",
- "type": "double precision",
- "primaryKey": false,
- "notNull": true
- },
- "description": {
- "name": "description",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "date": {
- "name": "date",
- "type": "date",
- "primaryKey": false,
- "notNull": true
- },
- "receipt_photo": {
- "name": "receipt_photo",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "created_by": {
- "name": "created_by",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "updated_by": {
- "name": "updated_by",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "transactions_period_id_financial_periods_id_fk": {
- "name": "transactions_period_id_financial_periods_id_fk",
- "tableFrom": "transactions",
- "tableTo": "financial_periods",
- "columnsFrom": ["period_id"],
- "columnsTo": ["id"],
- "onDelete": "restrict",
- "onUpdate": "no action"
- },
- "transactions_category_id_transaction_categories_id_fk": {
- "name": "transactions_category_id_transaction_categories_id_fk",
- "tableFrom": "transactions",
- "tableTo": "transaction_categories",
- "columnsFrom": ["category_id"],
- "columnsTo": ["id"],
- "onDelete": "restrict",
- "onUpdate": "no action"
- },
- "transactions_created_by_user_id_fk": {
- "name": "transactions_created_by_user_id_fk",
- "tableFrom": "transactions",
- "tableTo": "user",
- "columnsFrom": ["created_by"],
- "columnsTo": ["id"],
- "onDelete": "restrict",
- "onUpdate": "no action"
- },
- "transactions_updated_by_user_id_fk": {
- "name": "transactions_updated_by_user_id_fk",
- "tableFrom": "transactions",
- "tableTo": "user",
- "columnsFrom": ["updated_by"],
- "columnsTo": ["id"],
- "onDelete": "set null",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.vicariates": {
- "name": "vicariates",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "diocese_id": {
- "name": "diocese_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "sync_id": {
- "name": "sync_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "address": {
- "name": "address",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "phone": {
- "name": "phone",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "email": {
- "name": "email",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "vicariates_diocese_id_dioceses_id_fk": {
- "name": "vicariates_diocese_id_dioceses_id_fk",
- "tableFrom": "vicariates",
- "tableTo": "dioceses",
- "columnsFrom": ["diocese_id"],
- "columnsTo": ["id"],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "vicariates_sync_id_unique": {
- "name": "vicariates_sync_id_unique",
- "nullsNotDistinct": false,
- "columns": ["sync_id"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- }
- },
- "enums": {
- "public.attachment_reference_type": {
- "name": "attachment_reference_type",
- "schema": "public",
- "values": ["Event", "Organization", "Term"]
- },
- "public.attendance_method": {
- "name": "attendance_method",
- "schema": "public",
- "values": ["qr-code", "gps", "manual"]
- },
- "public.attendance_status": {
- "name": "attendance_status",
- "schema": "public",
- "values": ["present", "pending", "absent"]
- },
- "public.event_status": {
- "name": "event_status",
- "schema": "public",
- "values": ["published", "completed"]
- },
- "public.event_visibility": {
- "name": "event_visibility",
- "schema": "public",
- "values": ["public", "private"]
- },
- "public.period_status": {
- "name": "period_status",
- "schema": "public",
- "values": ["open", "locked"]
- },
- "public.notification_channel": {
- "name": "notification_channel",
- "schema": "public",
- "values": ["in-app", "email", "push"]
- },
- "public.notification_status": {
- "name": "notification_status",
- "schema": "public",
- "values": ["pending", "sent", "failed"]
- },
- "public.enrollment_status": {
- "name": "enrollment_status",
- "schema": "public",
- "values": ["pending", "active", "inactive"]
- },
- "public.education_level": {
- "name": "education_level",
- "schema": "public",
- "values": [
- "kindergarten",
- "primary",
- "junior-high",
- "senior-high",
- "diploma-1",
- "diploma-2",
- "diploma-3",
- "diploma-4",
- "bachelor",
- "master",
- "doctorate",
- "special-needs",
- "non-formal",
- "other"
- ]
- },
- "public.gender": {
- "name": "gender",
- "schema": "public",
- "values": ["male", "female"]
- },
- "public.rsvp_status": {
- "name": "rsvp_status",
- "schema": "public",
- "values": ["attending", "not-attending", "maybe"]
- },
- "public.transaction_type": {
- "name": "transaction_type",
- "schema": "public",
- "values": ["income", "expense"]
- }
- },
- "schemas": {},
- "sequences": {},
- "roles": {},
- "policies": {},
- "views": {},
- "_meta": {
- "columns": {},
- "schemas": {},
- "tables": {}
- }
-}
diff --git a/packages/db/migrations/meta/0002_snapshot.json b/packages/db/migrations/meta/0002_snapshot.json
deleted file mode 100644
index 8c176bdf..00000000
--- a/packages/db/migrations/meta/0002_snapshot.json
+++ /dev/null
@@ -1,2785 +0,0 @@
-{
- "id": "0f199749-07bf-4432-844f-49665c04a7ca",
- "prevId": "4037bbe0-0fd5-4ff4-9e2c-bc2c5cf3a95d",
- "version": "7",
- "dialect": "postgresql",
- "tables": {
- "public.account": {
- "name": "account",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "account_id": {
- "name": "account_id",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "provider_id": {
- "name": "provider_id",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "user_id": {
- "name": "user_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "access_token": {
- "name": "access_token",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "refresh_token": {
- "name": "refresh_token",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "id_token": {
- "name": "id_token",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "access_token_expires_at": {
- "name": "access_token_expires_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "refresh_token_expires_at": {
- "name": "refresh_token_expires_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "scope": {
- "name": "scope",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "password": {
- "name": "password",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {
- "account_userId_idx": {
- "name": "account_userId_idx",
- "columns": [
- {
- "expression": "user_id",
- "isExpression": false,
- "asc": true,
- "nulls": "last"
- }
- ],
- "isUnique": false,
- "concurrently": false,
- "method": "btree",
- "with": {}
- }
- },
- "foreignKeys": {
- "account_user_id_user_id_fk": {
- "name": "account_user_id_user_id_fk",
- "tableFrom": "account",
- "tableTo": "user",
- "columnsFrom": ["user_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.invitation": {
- "name": "invitation",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "organization_id": {
- "name": "organization_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "email": {
- "name": "email",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "role": {
- "name": "role",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "status": {
- "name": "status",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "default": "'pending'"
- },
- "expires_at": {
- "name": "expires_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "inviter_id": {
- "name": "inviter_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {
- "invitation_organizationId_idx": {
- "name": "invitation_organizationId_idx",
- "columns": [
- {
- "expression": "organization_id",
- "isExpression": false,
- "asc": true,
- "nulls": "last"
- }
- ],
- "isUnique": false,
- "concurrently": false,
- "method": "btree",
- "with": {}
- },
- "invitation_email_idx": {
- "name": "invitation_email_idx",
- "columns": [
- {
- "expression": "email",
- "isExpression": false,
- "asc": true,
- "nulls": "last"
- }
- ],
- "isUnique": false,
- "concurrently": false,
- "method": "btree",
- "with": {}
- }
- },
- "foreignKeys": {
- "invitation_organization_id_organization_id_fk": {
- "name": "invitation_organization_id_organization_id_fk",
- "tableFrom": "invitation",
- "tableTo": "organization",
- "columnsFrom": ["organization_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "invitation_inviter_id_user_id_fk": {
- "name": "invitation_inviter_id_user_id_fk",
- "tableFrom": "invitation",
- "tableTo": "user",
- "columnsFrom": ["inviter_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.member": {
- "name": "member",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "organization_id": {
- "name": "organization_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "user_id": {
- "name": "user_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "role": {
- "name": "role",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "default": "'member'"
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {
- "member_organizationId_idx": {
- "name": "member_organizationId_idx",
- "columns": [
- {
- "expression": "organization_id",
- "isExpression": false,
- "asc": true,
- "nulls": "last"
- }
- ],
- "isUnique": false,
- "concurrently": false,
- "method": "btree",
- "with": {}
- },
- "member_userId_idx": {
- "name": "member_userId_idx",
- "columns": [
- {
- "expression": "user_id",
- "isExpression": false,
- "asc": true,
- "nulls": "last"
- }
- ],
- "isUnique": false,
- "concurrently": false,
- "method": "btree",
- "with": {}
- }
- },
- "foreignKeys": {
- "member_organization_id_organization_id_fk": {
- "name": "member_organization_id_organization_id_fk",
- "tableFrom": "member",
- "tableTo": "organization",
- "columnsFrom": ["organization_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "member_user_id_user_id_fk": {
- "name": "member_user_id_user_id_fk",
- "tableFrom": "member",
- "tableTo": "user",
- "columnsFrom": ["user_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.organization": {
- "name": "organization",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "parish_id": {
- "name": "parish_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "slug": {
- "name": "slug",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "logo": {
- "name": "logo",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "metadata": {
- "name": "metadata",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "type": {
- "name": "type",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "parent_id": {
- "name": "parent_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "cover": {
- "name": "cover",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "join_id": {
- "name": "join_id",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "description": {
- "name": "description",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "short_name": {
- "name": "short_name",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {
- "organization_slug_uidx": {
- "name": "organization_slug_uidx",
- "columns": [
- {
- "expression": "slug",
- "isExpression": false,
- "asc": true,
- "nulls": "last"
- }
- ],
- "isUnique": true,
- "concurrently": false,
- "method": "btree",
- "with": {}
- }
- },
- "foreignKeys": {
- "organization_parish_id_parishes_id_fk": {
- "name": "organization_parish_id_parishes_id_fk",
- "tableFrom": "organization",
- "tableTo": "parishes",
- "columnsFrom": ["parish_id"],
- "columnsTo": ["id"],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "organization_slug_unique": {
- "name": "organization_slug_unique",
- "nullsNotDistinct": false,
- "columns": ["slug"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.session": {
- "name": "session",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "expires_at": {
- "name": "expires_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "token": {
- "name": "token",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "ip_address": {
- "name": "ip_address",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "user_agent": {
- "name": "user_agent",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "user_id": {
- "name": "user_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "impersonated_by": {
- "name": "impersonated_by",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "active_organization_id": {
- "name": "active_organization_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {
- "session_userId_idx": {
- "name": "session_userId_idx",
- "columns": [
- {
- "expression": "user_id",
- "isExpression": false,
- "asc": true,
- "nulls": "last"
- }
- ],
- "isUnique": false,
- "concurrently": false,
- "method": "btree",
- "with": {}
- }
- },
- "foreignKeys": {
- "session_user_id_user_id_fk": {
- "name": "session_user_id_user_id_fk",
- "tableFrom": "session",
- "tableTo": "user",
- "columnsFrom": ["user_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "session_token_unique": {
- "name": "session_token_unique",
- "nullsNotDistinct": false,
- "columns": ["token"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.user": {
- "name": "user",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "email": {
- "name": "email",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "email_verified": {
- "name": "email_verified",
- "type": "boolean",
- "primaryKey": false,
- "notNull": true,
- "default": false
- },
- "image": {
- "name": "image",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "role": {
- "name": "role",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "banned": {
- "name": "banned",
- "type": "boolean",
- "primaryKey": false,
- "notNull": false,
- "default": false
- },
- "ban_reason": {
- "name": "ban_reason",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "ban_expires": {
- "name": "ban_expires",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "account_status": {
- "name": "account_status",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "default": "'pending'"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "parishioner_id": {
- "name": "parishioner_id",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "user_email_unique": {
- "name": "user_email_unique",
- "nullsNotDistinct": false,
- "columns": ["email"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.verification": {
- "name": "verification",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "identifier": {
- "name": "identifier",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "value": {
- "name": "value",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "expires_at": {
- "name": "expires_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- }
- },
- "indexes": {
- "verification_identifier_idx": {
- "name": "verification_identifier_idx",
- "columns": [
- {
- "expression": "identifier",
- "isExpression": false,
- "asc": true,
- "nulls": "last"
- }
- ],
- "isUnique": false,
- "concurrently": false,
- "method": "btree",
- "with": {}
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.attachments": {
- "name": "attachments",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "reference_id": {
- "name": "reference_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "reference_type": {
- "name": "reference_type",
- "type": "attachment_reference_type",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "file_id": {
- "name": "file_id",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "viewer_url": {
- "name": "viewer_url",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "mime_type": {
- "name": "mime_type",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "size": {
- "name": "size",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "uploaded_by": {
- "name": "uploaded_by",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "attachments_uploaded_by_user_id_fk": {
- "name": "attachments_uploaded_by_user_id_fk",
- "tableFrom": "attachments",
- "tableTo": "user",
- "columnsFrom": ["uploaded_by"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.attendances": {
- "name": "attendances",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "event_id": {
- "name": "event_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "parishioner_id": {
- "name": "parishioner_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "method": {
- "name": "method",
- "type": "attendance_method",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "status": {
- "name": "status",
- "type": "attendance_status",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "verified_by": {
- "name": "verified_by",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "verified_at": {
- "name": "verified_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "attendances_event_id_events_id_fk": {
- "name": "attendances_event_id_events_id_fk",
- "tableFrom": "attendances",
- "tableTo": "events",
- "columnsFrom": ["event_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "attendances_parishioner_id_parishioners_id_fk": {
- "name": "attendances_parishioner_id_parishioners_id_fk",
- "tableFrom": "attendances",
- "tableTo": "parishioners",
- "columnsFrom": ["parishioner_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "attendances_verified_by_user_id_fk": {
- "name": "attendances_verified_by_user_id_fk",
- "tableFrom": "attendances",
- "tableTo": "user",
- "columnsFrom": ["verified_by"],
- "columnsTo": ["id"],
- "onDelete": "set null",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "attendances_event_id_parishioner_id_unique": {
- "name": "attendances_event_id_parishioner_id_unique",
- "nullsNotDistinct": false,
- "columns": ["event_id", "parishioner_id"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.dioceses": {
- "name": "dioceses",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "address": {
- "name": "address",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "phone": {
- "name": "phone",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "email": {
- "name": "email",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "logo": {
- "name": "logo",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "website": {
- "name": "website",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "description": {
- "name": "description",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.events": {
- "name": "events",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "organization_id": {
- "name": "organization_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "description": {
- "name": "description",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "location": {
- "name": "location",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "latitude": {
- "name": "latitude",
- "type": "double precision",
- "primaryKey": false,
- "notNull": false
- },
- "longitude": {
- "name": "longitude",
- "type": "double precision",
- "primaryKey": false,
- "notNull": false
- },
- "radius_meters": {
- "name": "radius_meters",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "default": 100
- },
- "start_date_time": {
- "name": "start_date_time",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "end_date_time": {
- "name": "end_date_time",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "qr_code": {
- "name": "qr_code",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "visibility": {
- "name": "visibility",
- "type": "event_visibility",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "is_rsvp_enabled": {
- "name": "is_rsvp_enabled",
- "type": "boolean",
- "primaryKey": false,
- "notNull": true,
- "default": false
- },
- "status": {
- "name": "status",
- "type": "event_status",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "created_by": {
- "name": "created_by",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "events_organization_id_organization_id_fk": {
- "name": "events_organization_id_organization_id_fk",
- "tableFrom": "events",
- "tableTo": "organization",
- "columnsFrom": ["organization_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "events_created_by_user_id_fk": {
- "name": "events_created_by_user_id_fk",
- "tableFrom": "events",
- "tableTo": "user",
- "columnsFrom": ["created_by"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.financial_periods": {
- "name": "financial_periods",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "month": {
- "name": "month",
- "type": "integer",
- "primaryKey": false,
- "notNull": true
- },
- "year": {
- "name": "year",
- "type": "integer",
- "primaryKey": false,
- "notNull": true
- },
- "status": {
- "name": "status",
- "type": "period_status",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "locked_by": {
- "name": "locked_by",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "locked_at": {
- "name": "locked_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "financial_periods_locked_by_user_id_fk": {
- "name": "financial_periods_locked_by_user_id_fk",
- "tableFrom": "financial_periods",
- "tableTo": "user",
- "columnsFrom": ["locked_by"],
- "columnsTo": ["id"],
- "onDelete": "set null",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.notifications": {
- "name": "notifications",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "user_id": {
- "name": "user_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "title": {
- "name": "title",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "body": {
- "name": "body",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "type": {
- "name": "type",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "channel": {
- "name": "channel",
- "type": "notification_channel",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "is_read": {
- "name": "is_read",
- "type": "boolean",
- "primaryKey": false,
- "notNull": true,
- "default": false
- },
- "status": {
- "name": "status",
- "type": "notification_status",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "reference_id": {
- "name": "reference_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "reference_type": {
- "name": "reference_type",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "sent_at": {
- "name": "sent_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "notifications_user_id_user_id_fk": {
- "name": "notifications_user_id_user_id_fk",
- "tableFrom": "notifications",
- "tableTo": "user",
- "columnsFrom": ["user_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.org_enrollments": {
- "name": "org_enrollments",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "parishioner_id": {
- "name": "parishioner_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "organization_id": {
- "name": "organization_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "unit_id": {
- "name": "unit_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "status": {
- "name": "status",
- "type": "enrollment_status",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "joined_at": {
- "name": "joined_at",
- "type": "date",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "org_enrollments_parishioner_id_parishioners_id_fk": {
- "name": "org_enrollments_parishioner_id_parishioners_id_fk",
- "tableFrom": "org_enrollments",
- "tableTo": "parishioners",
- "columnsFrom": ["parishioner_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "org_enrollments_organization_id_organization_id_fk": {
- "name": "org_enrollments_organization_id_organization_id_fk",
- "tableFrom": "org_enrollments",
- "tableTo": "organization",
- "columnsFrom": ["organization_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "org_enrollments_unit_id_org_units_id_fk": {
- "name": "org_enrollments_unit_id_org_units_id_fk",
- "tableFrom": "org_enrollments",
- "tableTo": "org_units",
- "columnsFrom": ["unit_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "org_enrollments_parishioner_id_organization_id_unique": {
- "name": "org_enrollments_parishioner_id_organization_id_unique",
- "nullsNotDistinct": false,
- "columns": ["parishioner_id", "organization_id"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.org_placements": {
- "name": "org_placements",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "enrollment_id": {
- "name": "enrollment_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "term_id": {
- "name": "term_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "unit_id": {
- "name": "unit_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "position": {
- "name": "position",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "default": "'anggota'"
- },
- "order": {
- "name": "order",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "default": 0
- },
- "start_date": {
- "name": "start_date",
- "type": "date",
- "primaryKey": false,
- "notNull": false
- },
- "end_date": {
- "name": "end_date",
- "type": "date",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "org_placements_enrollment_id_org_enrollments_id_fk": {
- "name": "org_placements_enrollment_id_org_enrollments_id_fk",
- "tableFrom": "org_placements",
- "tableTo": "org_enrollments",
- "columnsFrom": ["enrollment_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "org_placements_term_id_org_terms_id_fk": {
- "name": "org_placements_term_id_org_terms_id_fk",
- "tableFrom": "org_placements",
- "tableTo": "org_terms",
- "columnsFrom": ["term_id"],
- "columnsTo": ["id"],
- "onDelete": "set null",
- "onUpdate": "no action"
- },
- "org_placements_unit_id_org_units_id_fk": {
- "name": "org_placements_unit_id_org_units_id_fk",
- "tableFrom": "org_placements",
- "tableTo": "org_units",
- "columnsFrom": ["unit_id"],
- "columnsTo": ["id"],
- "onDelete": "set null",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.org_terms": {
- "name": "org_terms",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "organization_id": {
- "name": "organization_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "start_date": {
- "name": "start_date",
- "type": "date",
- "primaryKey": false,
- "notNull": false
- },
- "end_date": {
- "name": "end_date",
- "type": "date",
- "primaryKey": false,
- "notNull": false
- },
- "sk_number": {
- "name": "sk_number",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "sk_date": {
- "name": "sk_date",
- "type": "date",
- "primaryKey": false,
- "notNull": false
- },
- "description": {
- "name": "description",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "sk_document": {
- "name": "sk_document",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "org_terms_organization_id_organization_id_fk": {
- "name": "org_terms_organization_id_organization_id_fk",
- "tableFrom": "org_terms",
- "tableTo": "organization",
- "columnsFrom": ["organization_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.org_units": {
- "name": "org_units",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "organization_id": {
- "name": "organization_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "description": {
- "name": "description",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "position": {
- "name": "position",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "default": 0
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "org_units_organization_id_organization_id_fk": {
- "name": "org_units_organization_id_organization_id_fk",
- "tableFrom": "org_units",
- "tableTo": "organization",
- "columnsFrom": ["organization_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.parishes": {
- "name": "parishes",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "vicariate_id": {
- "name": "vicariate_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "sync_id": {
- "name": "sync_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "address": {
- "name": "address",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "phone": {
- "name": "phone",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "email": {
- "name": "email",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "logo": {
- "name": "logo",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "website": {
- "name": "website",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "description": {
- "name": "description",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "regency_id": {
- "name": "regency_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "district_id": {
- "name": "district_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "parishes_vicariate_id_vicariates_id_fk": {
- "name": "parishes_vicariate_id_vicariates_id_fk",
- "tableFrom": "parishes",
- "tableTo": "vicariates",
- "columnsFrom": ["vicariate_id"],
- "columnsTo": ["id"],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "parishes_sync_id_unique": {
- "name": "parishes_sync_id_unique",
- "nullsNotDistinct": false,
- "columns": ["sync_id"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.parishioners": {
- "name": "parishioners",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "user_id": {
- "name": "user_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "email": {
- "name": "email",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "avatar_url": {
- "name": "avatar_url",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "full_name": {
- "name": "full_name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "honorific": {
- "name": "honorific",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "suffix": {
- "name": "suffix",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "birth_place": {
- "name": "birth_place",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "birth_date": {
- "name": "birth_date",
- "type": "date",
- "primaryKey": false,
- "notNull": false
- },
- "gender": {
- "name": "gender",
- "type": "gender",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": false
- },
- "education_level": {
- "name": "education_level",
- "type": "education_level",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": false
- },
- "id_card_number": {
- "name": "id_card_number",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "id_card_photo": {
- "name": "id_card_photo",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "regency_id": {
- "name": "regency_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "district_id": {
- "name": "district_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "village_id": {
- "name": "village_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "sync_id": {
- "name": "sync_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "synced_at": {
- "name": "synced_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "baptism_name": {
- "name": "baptism_name",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "family_card_number": {
- "name": "family_card_number",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "blood_type": {
- "name": "blood_type",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "ethnicity": {
- "name": "ethnicity",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "religion": {
- "name": "religion",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "citizenship": {
- "name": "citizenship",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "family_relation": {
- "name": "family_relation",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "marital_status": {
- "name": "marital_status",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "marriage_date": {
- "name": "marriage_date",
- "type": "date",
- "primaryKey": false,
- "notNull": false
- },
- "address": {
- "name": "address",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "house_status": {
- "name": "house_status",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "phone": {
- "name": "phone",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "education_major": {
- "name": "education_major",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "occupation": {
- "name": "occupation",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "profession": {
- "name": "profession",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "skills": {
- "name": "skills",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "physical_condition": {
- "name": "physical_condition",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "economic_status": {
- "name": "economic_status",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "social_activity": {
- "name": "social_activity",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "parishioners_user_id_user_id_fk": {
- "name": "parishioners_user_id_user_id_fk",
- "tableFrom": "parishioners",
- "tableTo": "user",
- "columnsFrom": ["user_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "parishioners_regency_id_regencies_id_fk": {
- "name": "parishioners_regency_id_regencies_id_fk",
- "tableFrom": "parishioners",
- "tableTo": "regencies",
- "columnsFrom": ["regency_id"],
- "columnsTo": ["id"],
- "onDelete": "no action",
- "onUpdate": "no action"
- },
- "parishioners_district_id_districts_id_fk": {
- "name": "parishioners_district_id_districts_id_fk",
- "tableFrom": "parishioners",
- "tableTo": "districts",
- "columnsFrom": ["district_id"],
- "columnsTo": ["id"],
- "onDelete": "no action",
- "onUpdate": "no action"
- },
- "parishioners_village_id_villages_id_fk": {
- "name": "parishioners_village_id_villages_id_fk",
- "tableFrom": "parishioners",
- "tableTo": "villages",
- "columnsFrom": ["village_id"],
- "columnsTo": ["id"],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "parishioners_sync_id_unique": {
- "name": "parishioners_sync_id_unique",
- "nullsNotDistinct": false,
- "columns": ["sync_id"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.districts": {
- "name": "districts",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true
- },
- "regency_id": {
- "name": "regency_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true
- },
- "sync_id": {
- "name": "sync_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {},
- "foreignKeys": {
- "districts_regency_id_regencies_id_fk": {
- "name": "districts_regency_id_regencies_id_fk",
- "tableFrom": "districts",
- "tableTo": "regencies",
- "columnsFrom": ["regency_id"],
- "columnsTo": ["id"],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.provinces": {
- "name": "provinces",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {},
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.regencies": {
- "name": "regencies",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true
- },
- "province_id": {
- "name": "province_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true
- },
- "sync_id": {
- "name": "sync_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {},
- "foreignKeys": {
- "regencies_province_id_provinces_id_fk": {
- "name": "regencies_province_id_provinces_id_fk",
- "tableFrom": "regencies",
- "tableTo": "provinces",
- "columnsFrom": ["province_id"],
- "columnsTo": ["id"],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.villages": {
- "name": "villages",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "bigint",
- "primaryKey": true,
- "notNull": true
- },
- "district_id": {
- "name": "district_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true
- },
- "sync_id": {
- "name": "sync_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {},
- "foreignKeys": {
- "villages_district_id_districts_id_fk": {
- "name": "villages_district_id_districts_id_fk",
- "tableFrom": "villages",
- "tableTo": "districts",
- "columnsFrom": ["district_id"],
- "columnsTo": ["id"],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.rsvp": {
- "name": "rsvp",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "event_id": {
- "name": "event_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "parishioner_id": {
- "name": "parishioner_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "status": {
- "name": "status",
- "type": "rsvp_status",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "rsvp_event_id_events_id_fk": {
- "name": "rsvp_event_id_events_id_fk",
- "tableFrom": "rsvp",
- "tableTo": "events",
- "columnsFrom": ["event_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "rsvp_parishioner_id_parishioners_id_fk": {
- "name": "rsvp_parishioner_id_parishioners_id_fk",
- "tableFrom": "rsvp",
- "tableTo": "parishioners",
- "columnsFrom": ["parishioner_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "rsvp_event_id_parishioner_id_unique": {
- "name": "rsvp_event_id_parishioner_id_unique",
- "nullsNotDistinct": false,
- "columns": ["event_id", "parishioner_id"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.transaction_categories": {
- "name": "transaction_categories",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "type": {
- "name": "type",
- "type": "transaction_type",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.transactions": {
- "name": "transactions",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "period_id": {
- "name": "period_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "category_id": {
- "name": "category_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "type": {
- "name": "type",
- "type": "transaction_type",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "amount": {
- "name": "amount",
- "type": "double precision",
- "primaryKey": false,
- "notNull": true
- },
- "description": {
- "name": "description",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "date": {
- "name": "date",
- "type": "date",
- "primaryKey": false,
- "notNull": true
- },
- "receipt_photo": {
- "name": "receipt_photo",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "created_by": {
- "name": "created_by",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "updated_by": {
- "name": "updated_by",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "transactions_period_id_financial_periods_id_fk": {
- "name": "transactions_period_id_financial_periods_id_fk",
- "tableFrom": "transactions",
- "tableTo": "financial_periods",
- "columnsFrom": ["period_id"],
- "columnsTo": ["id"],
- "onDelete": "restrict",
- "onUpdate": "no action"
- },
- "transactions_category_id_transaction_categories_id_fk": {
- "name": "transactions_category_id_transaction_categories_id_fk",
- "tableFrom": "transactions",
- "tableTo": "transaction_categories",
- "columnsFrom": ["category_id"],
- "columnsTo": ["id"],
- "onDelete": "restrict",
- "onUpdate": "no action"
- },
- "transactions_created_by_user_id_fk": {
- "name": "transactions_created_by_user_id_fk",
- "tableFrom": "transactions",
- "tableTo": "user",
- "columnsFrom": ["created_by"],
- "columnsTo": ["id"],
- "onDelete": "restrict",
- "onUpdate": "no action"
- },
- "transactions_updated_by_user_id_fk": {
- "name": "transactions_updated_by_user_id_fk",
- "tableFrom": "transactions",
- "tableTo": "user",
- "columnsFrom": ["updated_by"],
- "columnsTo": ["id"],
- "onDelete": "set null",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.vicariates": {
- "name": "vicariates",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "diocese_id": {
- "name": "diocese_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "sync_id": {
- "name": "sync_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "address": {
- "name": "address",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "phone": {
- "name": "phone",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "email": {
- "name": "email",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "vicariates_diocese_id_dioceses_id_fk": {
- "name": "vicariates_diocese_id_dioceses_id_fk",
- "tableFrom": "vicariates",
- "tableTo": "dioceses",
- "columnsFrom": ["diocese_id"],
- "columnsTo": ["id"],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "vicariates_sync_id_unique": {
- "name": "vicariates_sync_id_unique",
- "nullsNotDistinct": false,
- "columns": ["sync_id"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- }
- },
- "enums": {
- "public.attachment_reference_type": {
- "name": "attachment_reference_type",
- "schema": "public",
- "values": ["Event", "Organization", "Term"]
- },
- "public.attendance_method": {
- "name": "attendance_method",
- "schema": "public",
- "values": ["qr-code", "gps", "manual"]
- },
- "public.attendance_status": {
- "name": "attendance_status",
- "schema": "public",
- "values": ["present", "pending", "absent"]
- },
- "public.event_status": {
- "name": "event_status",
- "schema": "public",
- "values": ["published", "completed"]
- },
- "public.event_visibility": {
- "name": "event_visibility",
- "schema": "public",
- "values": ["public", "private"]
- },
- "public.period_status": {
- "name": "period_status",
- "schema": "public",
- "values": ["open", "locked"]
- },
- "public.notification_channel": {
- "name": "notification_channel",
- "schema": "public",
- "values": ["in-app", "email", "push"]
- },
- "public.notification_status": {
- "name": "notification_status",
- "schema": "public",
- "values": ["pending", "sent", "failed"]
- },
- "public.enrollment_status": {
- "name": "enrollment_status",
- "schema": "public",
- "values": ["pending", "active", "inactive"]
- },
- "public.education_level": {
- "name": "education_level",
- "schema": "public",
- "values": [
- "kindergarten",
- "primary",
- "junior-high",
- "senior-high",
- "diploma-1",
- "diploma-2",
- "diploma-3",
- "diploma-4",
- "bachelor",
- "master",
- "doctorate",
- "special-needs",
- "non-formal",
- "other"
- ]
- },
- "public.gender": {
- "name": "gender",
- "schema": "public",
- "values": ["male", "female"]
- },
- "public.rsvp_status": {
- "name": "rsvp_status",
- "schema": "public",
- "values": ["attending", "not-attending", "maybe"]
- },
- "public.transaction_type": {
- "name": "transaction_type",
- "schema": "public",
- "values": ["income", "expense"]
- }
- },
- "schemas": {},
- "sequences": {},
- "roles": {},
- "policies": {},
- "views": {},
- "_meta": {
- "columns": {},
- "schemas": {},
- "tables": {}
- }
-}
diff --git a/packages/db/migrations/meta/0003_snapshot.json b/packages/db/migrations/meta/0003_snapshot.json
deleted file mode 100644
index 611546e5..00000000
--- a/packages/db/migrations/meta/0003_snapshot.json
+++ /dev/null
@@ -1,2779 +0,0 @@
-{
- "id": "fe0f105c-f0fc-4435-b7c4-b9157a29d849",
- "prevId": "0f199749-07bf-4432-844f-49665c04a7ca",
- "version": "7",
- "dialect": "postgresql",
- "tables": {
- "public.account": {
- "name": "account",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "account_id": {
- "name": "account_id",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "provider_id": {
- "name": "provider_id",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "user_id": {
- "name": "user_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "access_token": {
- "name": "access_token",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "refresh_token": {
- "name": "refresh_token",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "id_token": {
- "name": "id_token",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "access_token_expires_at": {
- "name": "access_token_expires_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "refresh_token_expires_at": {
- "name": "refresh_token_expires_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "scope": {
- "name": "scope",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "password": {
- "name": "password",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {
- "account_userId_idx": {
- "name": "account_userId_idx",
- "columns": [
- {
- "expression": "user_id",
- "isExpression": false,
- "asc": true,
- "nulls": "last"
- }
- ],
- "isUnique": false,
- "concurrently": false,
- "method": "btree",
- "with": {}
- }
- },
- "foreignKeys": {
- "account_user_id_user_id_fk": {
- "name": "account_user_id_user_id_fk",
- "tableFrom": "account",
- "tableTo": "user",
- "columnsFrom": ["user_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.invitation": {
- "name": "invitation",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "organization_id": {
- "name": "organization_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "email": {
- "name": "email",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "role": {
- "name": "role",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "status": {
- "name": "status",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "default": "'pending'"
- },
- "expires_at": {
- "name": "expires_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "inviter_id": {
- "name": "inviter_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {
- "invitation_organizationId_idx": {
- "name": "invitation_organizationId_idx",
- "columns": [
- {
- "expression": "organization_id",
- "isExpression": false,
- "asc": true,
- "nulls": "last"
- }
- ],
- "isUnique": false,
- "concurrently": false,
- "method": "btree",
- "with": {}
- },
- "invitation_email_idx": {
- "name": "invitation_email_idx",
- "columns": [
- {
- "expression": "email",
- "isExpression": false,
- "asc": true,
- "nulls": "last"
- }
- ],
- "isUnique": false,
- "concurrently": false,
- "method": "btree",
- "with": {}
- }
- },
- "foreignKeys": {
- "invitation_organization_id_organization_id_fk": {
- "name": "invitation_organization_id_organization_id_fk",
- "tableFrom": "invitation",
- "tableTo": "organization",
- "columnsFrom": ["organization_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "invitation_inviter_id_user_id_fk": {
- "name": "invitation_inviter_id_user_id_fk",
- "tableFrom": "invitation",
- "tableTo": "user",
- "columnsFrom": ["inviter_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.member": {
- "name": "member",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "organization_id": {
- "name": "organization_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "user_id": {
- "name": "user_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "role": {
- "name": "role",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "default": "'member'"
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {
- "member_organizationId_idx": {
- "name": "member_organizationId_idx",
- "columns": [
- {
- "expression": "organization_id",
- "isExpression": false,
- "asc": true,
- "nulls": "last"
- }
- ],
- "isUnique": false,
- "concurrently": false,
- "method": "btree",
- "with": {}
- },
- "member_userId_idx": {
- "name": "member_userId_idx",
- "columns": [
- {
- "expression": "user_id",
- "isExpression": false,
- "asc": true,
- "nulls": "last"
- }
- ],
- "isUnique": false,
- "concurrently": false,
- "method": "btree",
- "with": {}
- }
- },
- "foreignKeys": {
- "member_organization_id_organization_id_fk": {
- "name": "member_organization_id_organization_id_fk",
- "tableFrom": "member",
- "tableTo": "organization",
- "columnsFrom": ["organization_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "member_user_id_user_id_fk": {
- "name": "member_user_id_user_id_fk",
- "tableFrom": "member",
- "tableTo": "user",
- "columnsFrom": ["user_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.organization": {
- "name": "organization",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "parish_id": {
- "name": "parish_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "slug": {
- "name": "slug",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "logo": {
- "name": "logo",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "metadata": {
- "name": "metadata",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "type": {
- "name": "type",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "parent_id": {
- "name": "parent_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "cover": {
- "name": "cover",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "join_id": {
- "name": "join_id",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "description": {
- "name": "description",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "short_name": {
- "name": "short_name",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {
- "organization_slug_uidx": {
- "name": "organization_slug_uidx",
- "columns": [
- {
- "expression": "slug",
- "isExpression": false,
- "asc": true,
- "nulls": "last"
- }
- ],
- "isUnique": true,
- "concurrently": false,
- "method": "btree",
- "with": {}
- }
- },
- "foreignKeys": {
- "organization_parish_id_parishes_id_fk": {
- "name": "organization_parish_id_parishes_id_fk",
- "tableFrom": "organization",
- "tableTo": "parishes",
- "columnsFrom": ["parish_id"],
- "columnsTo": ["id"],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "organization_slug_unique": {
- "name": "organization_slug_unique",
- "nullsNotDistinct": false,
- "columns": ["slug"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.session": {
- "name": "session",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "expires_at": {
- "name": "expires_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "token": {
- "name": "token",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "ip_address": {
- "name": "ip_address",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "user_agent": {
- "name": "user_agent",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "user_id": {
- "name": "user_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "impersonated_by": {
- "name": "impersonated_by",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "active_organization_id": {
- "name": "active_organization_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {
- "session_userId_idx": {
- "name": "session_userId_idx",
- "columns": [
- {
- "expression": "user_id",
- "isExpression": false,
- "asc": true,
- "nulls": "last"
- }
- ],
- "isUnique": false,
- "concurrently": false,
- "method": "btree",
- "with": {}
- }
- },
- "foreignKeys": {
- "session_user_id_user_id_fk": {
- "name": "session_user_id_user_id_fk",
- "tableFrom": "session",
- "tableTo": "user",
- "columnsFrom": ["user_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "session_token_unique": {
- "name": "session_token_unique",
- "nullsNotDistinct": false,
- "columns": ["token"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.user": {
- "name": "user",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "email": {
- "name": "email",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "email_verified": {
- "name": "email_verified",
- "type": "boolean",
- "primaryKey": false,
- "notNull": true,
- "default": false
- },
- "image": {
- "name": "image",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "role": {
- "name": "role",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "banned": {
- "name": "banned",
- "type": "boolean",
- "primaryKey": false,
- "notNull": false,
- "default": false
- },
- "ban_reason": {
- "name": "ban_reason",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "ban_expires": {
- "name": "ban_expires",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "account_status": {
- "name": "account_status",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "default": "'pending'"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "parishioner_id": {
- "name": "parishioner_id",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "user_email_unique": {
- "name": "user_email_unique",
- "nullsNotDistinct": false,
- "columns": ["email"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.verification": {
- "name": "verification",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "identifier": {
- "name": "identifier",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "value": {
- "name": "value",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "expires_at": {
- "name": "expires_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- }
- },
- "indexes": {
- "verification_identifier_idx": {
- "name": "verification_identifier_idx",
- "columns": [
- {
- "expression": "identifier",
- "isExpression": false,
- "asc": true,
- "nulls": "last"
- }
- ],
- "isUnique": false,
- "concurrently": false,
- "method": "btree",
- "with": {}
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.attachments": {
- "name": "attachments",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "reference_id": {
- "name": "reference_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "reference_type": {
- "name": "reference_type",
- "type": "attachment_reference_type",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "file_id": {
- "name": "file_id",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "viewer_url": {
- "name": "viewer_url",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "mime_type": {
- "name": "mime_type",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "size": {
- "name": "size",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "uploaded_by": {
- "name": "uploaded_by",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "attachments_uploaded_by_user_id_fk": {
- "name": "attachments_uploaded_by_user_id_fk",
- "tableFrom": "attachments",
- "tableTo": "user",
- "columnsFrom": ["uploaded_by"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.attendances": {
- "name": "attendances",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "event_id": {
- "name": "event_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "parishioner_id": {
- "name": "parishioner_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "method": {
- "name": "method",
- "type": "attendance_method",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "status": {
- "name": "status",
- "type": "attendance_status",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "verified_by": {
- "name": "verified_by",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "verified_at": {
- "name": "verified_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "attendances_event_id_events_id_fk": {
- "name": "attendances_event_id_events_id_fk",
- "tableFrom": "attendances",
- "tableTo": "events",
- "columnsFrom": ["event_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "attendances_parishioner_id_parishioners_id_fk": {
- "name": "attendances_parishioner_id_parishioners_id_fk",
- "tableFrom": "attendances",
- "tableTo": "parishioners",
- "columnsFrom": ["parishioner_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "attendances_verified_by_user_id_fk": {
- "name": "attendances_verified_by_user_id_fk",
- "tableFrom": "attendances",
- "tableTo": "user",
- "columnsFrom": ["verified_by"],
- "columnsTo": ["id"],
- "onDelete": "set null",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "attendances_event_id_parishioner_id_unique": {
- "name": "attendances_event_id_parishioner_id_unique",
- "nullsNotDistinct": false,
- "columns": ["event_id", "parishioner_id"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.dioceses": {
- "name": "dioceses",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "address": {
- "name": "address",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "phone": {
- "name": "phone",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "email": {
- "name": "email",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "logo": {
- "name": "logo",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "website": {
- "name": "website",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "description": {
- "name": "description",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.events": {
- "name": "events",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "organization_id": {
- "name": "organization_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "description": {
- "name": "description",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "location": {
- "name": "location",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "latitude": {
- "name": "latitude",
- "type": "double precision",
- "primaryKey": false,
- "notNull": false
- },
- "longitude": {
- "name": "longitude",
- "type": "double precision",
- "primaryKey": false,
- "notNull": false
- },
- "radius_meters": {
- "name": "radius_meters",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "default": 100
- },
- "start_date_time": {
- "name": "start_date_time",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "end_date_time": {
- "name": "end_date_time",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "qr_code": {
- "name": "qr_code",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "visibility": {
- "name": "visibility",
- "type": "event_visibility",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "is_rsvp_enabled": {
- "name": "is_rsvp_enabled",
- "type": "boolean",
- "primaryKey": false,
- "notNull": true,
- "default": false
- },
- "status": {
- "name": "status",
- "type": "event_status",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "created_by": {
- "name": "created_by",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "events_organization_id_organization_id_fk": {
- "name": "events_organization_id_organization_id_fk",
- "tableFrom": "events",
- "tableTo": "organization",
- "columnsFrom": ["organization_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "events_created_by_user_id_fk": {
- "name": "events_created_by_user_id_fk",
- "tableFrom": "events",
- "tableTo": "user",
- "columnsFrom": ["created_by"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.financial_periods": {
- "name": "financial_periods",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "month": {
- "name": "month",
- "type": "integer",
- "primaryKey": false,
- "notNull": true
- },
- "year": {
- "name": "year",
- "type": "integer",
- "primaryKey": false,
- "notNull": true
- },
- "status": {
- "name": "status",
- "type": "period_status",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "locked_by": {
- "name": "locked_by",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "locked_at": {
- "name": "locked_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "financial_periods_locked_by_user_id_fk": {
- "name": "financial_periods_locked_by_user_id_fk",
- "tableFrom": "financial_periods",
- "tableTo": "user",
- "columnsFrom": ["locked_by"],
- "columnsTo": ["id"],
- "onDelete": "set null",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.notifications": {
- "name": "notifications",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "user_id": {
- "name": "user_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "title": {
- "name": "title",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "body": {
- "name": "body",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "type": {
- "name": "type",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "channel": {
- "name": "channel",
- "type": "notification_channel",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "is_read": {
- "name": "is_read",
- "type": "boolean",
- "primaryKey": false,
- "notNull": true,
- "default": false
- },
- "status": {
- "name": "status",
- "type": "notification_status",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "reference_id": {
- "name": "reference_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "reference_type": {
- "name": "reference_type",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "sent_at": {
- "name": "sent_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "notifications_user_id_user_id_fk": {
- "name": "notifications_user_id_user_id_fk",
- "tableFrom": "notifications",
- "tableTo": "user",
- "columnsFrom": ["user_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.org_enrollments": {
- "name": "org_enrollments",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "parishioner_id": {
- "name": "parishioner_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "organization_id": {
- "name": "organization_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "unit_id": {
- "name": "unit_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "status": {
- "name": "status",
- "type": "enrollment_status",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "joined_at": {
- "name": "joined_at",
- "type": "date",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "org_enrollments_parishioner_id_parishioners_id_fk": {
- "name": "org_enrollments_parishioner_id_parishioners_id_fk",
- "tableFrom": "org_enrollments",
- "tableTo": "parishioners",
- "columnsFrom": ["parishioner_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "org_enrollments_organization_id_organization_id_fk": {
- "name": "org_enrollments_organization_id_organization_id_fk",
- "tableFrom": "org_enrollments",
- "tableTo": "organization",
- "columnsFrom": ["organization_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "org_enrollments_unit_id_org_units_id_fk": {
- "name": "org_enrollments_unit_id_org_units_id_fk",
- "tableFrom": "org_enrollments",
- "tableTo": "org_units",
- "columnsFrom": ["unit_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "org_enrollments_parishioner_id_organization_id_unique": {
- "name": "org_enrollments_parishioner_id_organization_id_unique",
- "nullsNotDistinct": false,
- "columns": ["parishioner_id", "organization_id"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.org_placements": {
- "name": "org_placements",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "enrollment_id": {
- "name": "enrollment_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "term_id": {
- "name": "term_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "unit_id": {
- "name": "unit_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "position": {
- "name": "position",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "default": "'anggota'"
- },
- "order": {
- "name": "order",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "default": 0
- },
- "start_date": {
- "name": "start_date",
- "type": "date",
- "primaryKey": false,
- "notNull": false
- },
- "end_date": {
- "name": "end_date",
- "type": "date",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "org_placements_enrollment_id_org_enrollments_id_fk": {
- "name": "org_placements_enrollment_id_org_enrollments_id_fk",
- "tableFrom": "org_placements",
- "tableTo": "org_enrollments",
- "columnsFrom": ["enrollment_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "org_placements_term_id_org_terms_id_fk": {
- "name": "org_placements_term_id_org_terms_id_fk",
- "tableFrom": "org_placements",
- "tableTo": "org_terms",
- "columnsFrom": ["term_id"],
- "columnsTo": ["id"],
- "onDelete": "set null",
- "onUpdate": "no action"
- },
- "org_placements_unit_id_org_units_id_fk": {
- "name": "org_placements_unit_id_org_units_id_fk",
- "tableFrom": "org_placements",
- "tableTo": "org_units",
- "columnsFrom": ["unit_id"],
- "columnsTo": ["id"],
- "onDelete": "set null",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.org_terms": {
- "name": "org_terms",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "organization_id": {
- "name": "organization_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "start_date": {
- "name": "start_date",
- "type": "date",
- "primaryKey": false,
- "notNull": false
- },
- "end_date": {
- "name": "end_date",
- "type": "date",
- "primaryKey": false,
- "notNull": false
- },
- "sk_number": {
- "name": "sk_number",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "sk_date": {
- "name": "sk_date",
- "type": "date",
- "primaryKey": false,
- "notNull": false
- },
- "description": {
- "name": "description",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "org_terms_organization_id_organization_id_fk": {
- "name": "org_terms_organization_id_organization_id_fk",
- "tableFrom": "org_terms",
- "tableTo": "organization",
- "columnsFrom": ["organization_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.org_units": {
- "name": "org_units",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "organization_id": {
- "name": "organization_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "description": {
- "name": "description",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "position": {
- "name": "position",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "default": 0
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "org_units_organization_id_organization_id_fk": {
- "name": "org_units_organization_id_organization_id_fk",
- "tableFrom": "org_units",
- "tableTo": "organization",
- "columnsFrom": ["organization_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.parishes": {
- "name": "parishes",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "vicariate_id": {
- "name": "vicariate_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "sync_id": {
- "name": "sync_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "address": {
- "name": "address",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "phone": {
- "name": "phone",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "email": {
- "name": "email",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "logo": {
- "name": "logo",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "website": {
- "name": "website",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "description": {
- "name": "description",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "regency_id": {
- "name": "regency_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "district_id": {
- "name": "district_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "parishes_vicariate_id_vicariates_id_fk": {
- "name": "parishes_vicariate_id_vicariates_id_fk",
- "tableFrom": "parishes",
- "tableTo": "vicariates",
- "columnsFrom": ["vicariate_id"],
- "columnsTo": ["id"],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "parishes_sync_id_unique": {
- "name": "parishes_sync_id_unique",
- "nullsNotDistinct": false,
- "columns": ["sync_id"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.parishioners": {
- "name": "parishioners",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "user_id": {
- "name": "user_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "email": {
- "name": "email",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "avatar_url": {
- "name": "avatar_url",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "full_name": {
- "name": "full_name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "honorific": {
- "name": "honorific",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "suffix": {
- "name": "suffix",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "birth_place": {
- "name": "birth_place",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "birth_date": {
- "name": "birth_date",
- "type": "date",
- "primaryKey": false,
- "notNull": false
- },
- "gender": {
- "name": "gender",
- "type": "gender",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": false
- },
- "education_level": {
- "name": "education_level",
- "type": "education_level",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": false
- },
- "id_card_number": {
- "name": "id_card_number",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "id_card_photo": {
- "name": "id_card_photo",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "regency_id": {
- "name": "regency_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "district_id": {
- "name": "district_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "village_id": {
- "name": "village_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "sync_id": {
- "name": "sync_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "synced_at": {
- "name": "synced_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "baptism_name": {
- "name": "baptism_name",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "family_card_number": {
- "name": "family_card_number",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "blood_type": {
- "name": "blood_type",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "ethnicity": {
- "name": "ethnicity",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "religion": {
- "name": "religion",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "citizenship": {
- "name": "citizenship",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "family_relation": {
- "name": "family_relation",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "marital_status": {
- "name": "marital_status",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "marriage_date": {
- "name": "marriage_date",
- "type": "date",
- "primaryKey": false,
- "notNull": false
- },
- "address": {
- "name": "address",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "house_status": {
- "name": "house_status",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "phone": {
- "name": "phone",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "education_major": {
- "name": "education_major",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "occupation": {
- "name": "occupation",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "profession": {
- "name": "profession",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "skills": {
- "name": "skills",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "physical_condition": {
- "name": "physical_condition",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "economic_status": {
- "name": "economic_status",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "social_activity": {
- "name": "social_activity",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "parishioners_user_id_user_id_fk": {
- "name": "parishioners_user_id_user_id_fk",
- "tableFrom": "parishioners",
- "tableTo": "user",
- "columnsFrom": ["user_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "parishioners_regency_id_regencies_id_fk": {
- "name": "parishioners_regency_id_regencies_id_fk",
- "tableFrom": "parishioners",
- "tableTo": "regencies",
- "columnsFrom": ["regency_id"],
- "columnsTo": ["id"],
- "onDelete": "no action",
- "onUpdate": "no action"
- },
- "parishioners_district_id_districts_id_fk": {
- "name": "parishioners_district_id_districts_id_fk",
- "tableFrom": "parishioners",
- "tableTo": "districts",
- "columnsFrom": ["district_id"],
- "columnsTo": ["id"],
- "onDelete": "no action",
- "onUpdate": "no action"
- },
- "parishioners_village_id_villages_id_fk": {
- "name": "parishioners_village_id_villages_id_fk",
- "tableFrom": "parishioners",
- "tableTo": "villages",
- "columnsFrom": ["village_id"],
- "columnsTo": ["id"],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "parishioners_sync_id_unique": {
- "name": "parishioners_sync_id_unique",
- "nullsNotDistinct": false,
- "columns": ["sync_id"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.districts": {
- "name": "districts",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true
- },
- "regency_id": {
- "name": "regency_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true
- },
- "sync_id": {
- "name": "sync_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {},
- "foreignKeys": {
- "districts_regency_id_regencies_id_fk": {
- "name": "districts_regency_id_regencies_id_fk",
- "tableFrom": "districts",
- "tableTo": "regencies",
- "columnsFrom": ["regency_id"],
- "columnsTo": ["id"],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.provinces": {
- "name": "provinces",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {},
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.regencies": {
- "name": "regencies",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true
- },
- "province_id": {
- "name": "province_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true
- },
- "sync_id": {
- "name": "sync_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {},
- "foreignKeys": {
- "regencies_province_id_provinces_id_fk": {
- "name": "regencies_province_id_provinces_id_fk",
- "tableFrom": "regencies",
- "tableTo": "provinces",
- "columnsFrom": ["province_id"],
- "columnsTo": ["id"],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.villages": {
- "name": "villages",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "bigint",
- "primaryKey": true,
- "notNull": true
- },
- "district_id": {
- "name": "district_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true
- },
- "sync_id": {
- "name": "sync_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {},
- "foreignKeys": {
- "villages_district_id_districts_id_fk": {
- "name": "villages_district_id_districts_id_fk",
- "tableFrom": "villages",
- "tableTo": "districts",
- "columnsFrom": ["district_id"],
- "columnsTo": ["id"],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.rsvp": {
- "name": "rsvp",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "event_id": {
- "name": "event_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "parishioner_id": {
- "name": "parishioner_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "status": {
- "name": "status",
- "type": "rsvp_status",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "rsvp_event_id_events_id_fk": {
- "name": "rsvp_event_id_events_id_fk",
- "tableFrom": "rsvp",
- "tableTo": "events",
- "columnsFrom": ["event_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "rsvp_parishioner_id_parishioners_id_fk": {
- "name": "rsvp_parishioner_id_parishioners_id_fk",
- "tableFrom": "rsvp",
- "tableTo": "parishioners",
- "columnsFrom": ["parishioner_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "rsvp_event_id_parishioner_id_unique": {
- "name": "rsvp_event_id_parishioner_id_unique",
- "nullsNotDistinct": false,
- "columns": ["event_id", "parishioner_id"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.transaction_categories": {
- "name": "transaction_categories",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "type": {
- "name": "type",
- "type": "transaction_type",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.transactions": {
- "name": "transactions",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "period_id": {
- "name": "period_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "category_id": {
- "name": "category_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "type": {
- "name": "type",
- "type": "transaction_type",
- "typeSchema": "public",
- "primaryKey": false,
- "notNull": true
- },
- "amount": {
- "name": "amount",
- "type": "double precision",
- "primaryKey": false,
- "notNull": true
- },
- "description": {
- "name": "description",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "date": {
- "name": "date",
- "type": "date",
- "primaryKey": false,
- "notNull": true
- },
- "receipt_photo": {
- "name": "receipt_photo",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "created_by": {
- "name": "created_by",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "updated_by": {
- "name": "updated_by",
- "type": "uuid",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "transactions_period_id_financial_periods_id_fk": {
- "name": "transactions_period_id_financial_periods_id_fk",
- "tableFrom": "transactions",
- "tableTo": "financial_periods",
- "columnsFrom": ["period_id"],
- "columnsTo": ["id"],
- "onDelete": "restrict",
- "onUpdate": "no action"
- },
- "transactions_category_id_transaction_categories_id_fk": {
- "name": "transactions_category_id_transaction_categories_id_fk",
- "tableFrom": "transactions",
- "tableTo": "transaction_categories",
- "columnsFrom": ["category_id"],
- "columnsTo": ["id"],
- "onDelete": "restrict",
- "onUpdate": "no action"
- },
- "transactions_created_by_user_id_fk": {
- "name": "transactions_created_by_user_id_fk",
- "tableFrom": "transactions",
- "tableTo": "user",
- "columnsFrom": ["created_by"],
- "columnsTo": ["id"],
- "onDelete": "restrict",
- "onUpdate": "no action"
- },
- "transactions_updated_by_user_id_fk": {
- "name": "transactions_updated_by_user_id_fk",
- "tableFrom": "transactions",
- "tableTo": "user",
- "columnsFrom": ["updated_by"],
- "columnsTo": ["id"],
- "onDelete": "set null",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.vicariates": {
- "name": "vicariates",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true
- },
- "diocese_id": {
- "name": "diocese_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
- "sync_id": {
- "name": "sync_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "address": {
- "name": "address",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "phone": {
- "name": "phone",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "email": {
- "name": "email",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "deleted_at": {
- "name": "deleted_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "vicariates_diocese_id_dioceses_id_fk": {
- "name": "vicariates_diocese_id_dioceses_id_fk",
- "tableFrom": "vicariates",
- "tableTo": "dioceses",
- "columnsFrom": ["diocese_id"],
- "columnsTo": ["id"],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "vicariates_sync_id_unique": {
- "name": "vicariates_sync_id_unique",
- "nullsNotDistinct": false,
- "columns": ["sync_id"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- }
- },
- "enums": {
- "public.attachment_reference_type": {
- "name": "attachment_reference_type",
- "schema": "public",
- "values": ["Event", "Organization", "Term"]
- },
- "public.attendance_method": {
- "name": "attendance_method",
- "schema": "public",
- "values": ["qr-code", "gps", "manual"]
- },
- "public.attendance_status": {
- "name": "attendance_status",
- "schema": "public",
- "values": ["present", "pending", "absent"]
- },
- "public.event_status": {
- "name": "event_status",
- "schema": "public",
- "values": ["published", "completed"]
- },
- "public.event_visibility": {
- "name": "event_visibility",
- "schema": "public",
- "values": ["public", "private"]
- },
- "public.period_status": {
- "name": "period_status",
- "schema": "public",
- "values": ["open", "locked"]
- },
- "public.notification_channel": {
- "name": "notification_channel",
- "schema": "public",
- "values": ["in-app", "email", "push"]
- },
- "public.notification_status": {
- "name": "notification_status",
- "schema": "public",
- "values": ["pending", "sent", "failed"]
- },
- "public.enrollment_status": {
- "name": "enrollment_status",
- "schema": "public",
- "values": ["pending", "active", "inactive"]
- },
- "public.education_level": {
- "name": "education_level",
- "schema": "public",
- "values": [
- "kindergarten",
- "primary",
- "junior-high",
- "senior-high",
- "diploma-1",
- "diploma-2",
- "diploma-3",
- "diploma-4",
- "bachelor",
- "master",
- "doctorate",
- "special-needs",
- "non-formal",
- "other"
- ]
- },
- "public.gender": {
- "name": "gender",
- "schema": "public",
- "values": ["male", "female"]
- },
- "public.rsvp_status": {
- "name": "rsvp_status",
- "schema": "public",
- "values": ["attending", "not-attending", "maybe"]
- },
- "public.transaction_type": {
- "name": "transaction_type",
- "schema": "public",
- "values": ["income", "expense"]
- }
- },
- "schemas": {},
- "sequences": {},
- "roles": {},
- "policies": {},
- "views": {},
- "_meta": {
- "columns": {},
- "schemas": {},
- "tables": {}
- }
-}
diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json
index 9648d112..7a799733 100644
--- a/packages/db/migrations/meta/_journal.json
+++ b/packages/db/migrations/meta/_journal.json
@@ -5,29 +5,8 @@
{
"idx": 0,
"version": "7",
- "when": 1776321809820,
- "tag": "0000_initial",
- "breakpoints": true
- },
- {
- "idx": 1,
- "version": "7",
- "when": 1776322746094,
- "tag": "0001_reflective_gamma_corps",
- "breakpoints": true
- },
- {
- "idx": 2,
- "version": "7",
- "when": 1776324697266,
- "tag": "0002_greedy_triathlon",
- "breakpoints": true
- },
- {
- "idx": 3,
- "version": "7",
- "when": 1776324714458,
- "tag": "0003_motionless_jack_murdock",
+ "when": 1776529327967,
+ "tag": "0000_sour_shockwave",
"breakpoints": true
}
]
diff --git a/packages/db/package.json b/packages/db/package.json
index 067b4197..0033884a 100644
--- a/packages/db/package.json
+++ b/packages/db/package.json
@@ -35,6 +35,7 @@
"drizzle-orm": "^0.45.1",
"pg": "^8.20.0",
"tsx": "^4.21.0",
- "uuid": "^13.0.0"
+ "uuid": "^13.0.0",
+ "zod": "^4.3.6"
}
}
diff --git a/packages/db/src/repository/financial-period.ts b/packages/db/src/repository/financial-period.ts
index 22d41d2c..a386931e 100644
--- a/packages/db/src/repository/financial-period.ts
+++ b/packages/db/src/repository/financial-period.ts
@@ -74,6 +74,24 @@ export class FinancialPeriodRepository implements IFinancialPeriodRepository {
return row ? FinancialPeriodEntity.parse(row) : null;
}
+ /**
+ * Finds all financial periods for a specific year.
+ */
+ async findByYear(year: number): Promise {
+ this.logger.info('FinancialPeriodRepository.findByYear', { year });
+ const rows = await this.db
+ .select()
+ .from(financialPeriods)
+ .where(
+ and(
+ eq(financialPeriods.year, year),
+ isNull(financialPeriods.deletedAt),
+ ),
+ );
+
+ return rows.map((row) => FinancialPeriodEntity.parse(row));
+ }
+
/**
* Retrieves all financial periods.
*/
diff --git a/packages/db/src/repository/index.ts b/packages/db/src/repository/index.ts
index 4690b625..330bf94f 100644
--- a/packages/db/src/repository/index.ts
+++ b/packages/db/src/repository/index.ts
@@ -16,6 +16,7 @@ import { PlacementRepository } from './placement';
import { RsvpRepository } from './rsvp';
import { TermRepository } from './term';
import { TransactionRepository } from './transaction';
+import { TransactionCategoryRepository } from './transaction-category';
import { UnitRepository } from './unit';
import { UserRepository } from './user';
import { VicariateRepository } from './vicariate';
@@ -36,6 +37,7 @@ export * from './placement';
export * from './rsvp';
export * from './term';
export * from './transaction';
+export * from './transaction-category';
export * from './unit';
export * from './user';
export * from './vicariate';
@@ -63,6 +65,7 @@ export function createRepositories(db: DrizzleClient, logger: ILogger) {
rsvp: new RsvpRepository(db, logger),
term: new TermRepository(db, logger),
transaction: new TransactionRepository(db, logger),
+ transactionCategory: new TransactionCategoryRepository(db, logger),
unit: new UnitRepository(db, logger),
user: new UserRepository(db, logger),
diocese: new DioceseRepository(db, logger),
diff --git a/packages/db/src/repository/notification.spec.ts b/packages/db/src/repository/notification.spec.ts
index 75e75fe8..f475a476 100644
--- a/packages/db/src/repository/notification.spec.ts
+++ b/packages/db/src/repository/notification.spec.ts
@@ -60,7 +60,9 @@ describe('NotificationRepository', () => {
// @ts-expect-error
dbMock.select.mockReturnValue({
from: vi.fn().mockReturnValue({
- where: vi.fn().mockResolvedValue([mockNotifRow]),
+ where: vi.fn().mockReturnValue({
+ orderBy: vi.fn().mockResolvedValue([mockNotifRow]),
+ }),
}),
});
diff --git a/packages/db/src/repository/notification.ts b/packages/db/src/repository/notification.ts
index d28b8589..9307f7a5 100644
--- a/packages/db/src/repository/notification.ts
+++ b/packages/db/src/repository/notification.ts
@@ -5,7 +5,7 @@ import {
NotificationEntity,
type NotificationRecord,
} from '@domus/core';
-import { and, eq, isNull } from 'drizzle-orm';
+import { and, count, desc, eq, isNull } from 'drizzle-orm';
import type { DrizzleClient } from '../index';
import { notifications } from '../schema/notifications';
@@ -37,20 +37,56 @@ export class NotificationRepository implements INotificationRepository {
}
/**
- * Finds all notifications for a specific user.
+ * Finds notifications for a specific user with pagination.
*/
- async findByUserId(userId: string): Promise {
- this.logger.info('NotificationRepository.findByUserId', { userId });
- const rows = await this.db
+ async findByUserId(
+ userId: string,
+ options?: { limit?: number; offset?: number },
+ ): Promise {
+ this.logger.info('NotificationRepository.findByUserId', {
+ userId,
+ ...options,
+ });
+ const query = this.db
.select()
.from(notifications)
.where(
and(eq(notifications.userId, userId), isNull(notifications.deletedAt)),
- );
+ )
+ .orderBy(desc(notifications.createdAt));
+
+ if (options?.limit !== undefined) {
+ query.limit(options.limit);
+ }
+
+ if (options?.offset !== undefined) {
+ query.offset(options.offset);
+ }
+
+ const rows = await query;
return rows.map((row) => NotificationEntity.parse(row));
}
+ /**
+ * Counts unread notifications for a specific user.
+ */
+ async countUnreadByUserId(userId: string): Promise {
+ this.logger.info('NotificationRepository.countUnreadByUserId', { userId });
+ const [row] = await this.db
+ .select({ value: count() })
+ .from(notifications)
+ .where(
+ and(
+ eq(notifications.userId, userId),
+ eq(notifications.isRead, false),
+ isNull(notifications.deletedAt),
+ ),
+ );
+
+ return row?.value ?? 0;
+ }
+
/**
* Creates a new notification record.
*/
diff --git a/packages/db/src/repository/transaction-category.spec.ts b/packages/db/src/repository/transaction-category.spec.ts
new file mode 100644
index 00000000..32ea7e2f
--- /dev/null
+++ b/packages/db/src/repository/transaction-category.spec.ts
@@ -0,0 +1,125 @@
+import {
+ type ILogger,
+ TransactionCategoryEntity,
+ TransactionType,
+} from '@domus/core';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { mockDeep, mockReset } from 'vitest-mock-extended';
+import type { TestDrizzleClient } from '../../test/helper';
+import { transactionCategories } from '../schema/transaction-categories';
+import { TransactionCategoryRepository } from './transaction-category';
+
+describe('TransactionCategoryRepository', () => {
+ const dbMock = mockDeep();
+ const loggerMock = mockDeep();
+ const repo = new TransactionCategoryRepository(dbMock, loggerMock);
+
+ const VALID_ID = '0194b150-0001-7000-8000-000000000001';
+
+ beforeEach(() => {
+ mockReset(dbMock);
+ mockReset(loggerMock);
+ });
+
+ const mockCategoryRow = {
+ id: VALID_ID,
+ name: 'Kolekte 1',
+ type: TransactionType.Income,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ deletedAt: null,
+ };
+
+ const mockCategoryEntity = TransactionCategoryEntity.parse(mockCategoryRow);
+
+ describe('findById', () => {
+ it('should return category when found', async () => {
+ // @ts-expect-error
+ dbMock.select.mockReturnValue({
+ from: vi.fn().mockReturnValue({
+ where: vi.fn().mockResolvedValue([mockCategoryRow]),
+ }),
+ });
+
+ const result = await repo.findById(VALID_ID);
+ expect(result).toEqual(mockCategoryEntity);
+ });
+ });
+
+ describe('findAll', () => {
+ it('should return all categories', async () => {
+ // @ts-expect-error
+ dbMock.select.mockReturnValue({
+ from: vi.fn().mockReturnValue({
+ where: vi.fn().mockResolvedValue([mockCategoryRow]),
+ }),
+ });
+
+ const result = await repo.findAll();
+ expect(result).toEqual([mockCategoryEntity]);
+ });
+ });
+
+ describe('findByNameAndType', () => {
+ it('should return category when found', async () => {
+ // @ts-expect-error
+ dbMock.select.mockReturnValue({
+ from: vi.fn().mockReturnValue({
+ where: vi.fn().mockResolvedValue([mockCategoryRow]),
+ }),
+ });
+
+ const result = await repo.findByNameAndType(
+ 'Kolekte 1',
+ TransactionType.Income,
+ );
+ expect(result).toEqual(mockCategoryEntity);
+ });
+ });
+
+ describe('create', () => {
+ it('should create and return category', async () => {
+ // @ts-expect-error
+ dbMock.insert.mockReturnValue({
+ values: vi.fn().mockReturnValue({
+ returning: vi.fn().mockResolvedValue([mockCategoryRow]),
+ }),
+ });
+
+ const result = await repo.create(mockCategoryEntity);
+ expect(result).toEqual(mockCategoryEntity);
+ });
+ });
+
+ describe('update', () => {
+ it('should update and return category', async () => {
+ // @ts-expect-error
+ dbMock.update.mockReturnValue({
+ set: vi.fn().mockReturnValue({
+ where: vi.fn().mockReturnValue({
+ returning: vi.fn().mockResolvedValue([mockCategoryRow]),
+ }),
+ }),
+ });
+
+ const result = await repo.update(VALID_ID, {
+ name: 'Updated Name',
+ });
+ expect(result).toEqual(mockCategoryEntity);
+ });
+ });
+
+ describe('delete', () => {
+ it('should soft delete category', async () => {
+ // @ts-expect-error
+ dbMock.update.mockReturnValue({
+ set: vi.fn().mockReturnValue({
+ where: vi.fn().mockResolvedValue([{ id: VALID_ID }]),
+ }),
+ });
+
+ await repo.delete(VALID_ID);
+ expect(dbMock.update).toHaveBeenCalledWith(transactionCategories);
+ });
+ });
+});
diff --git a/packages/db/src/repository/transaction-category.ts b/packages/db/src/repository/transaction-category.ts
new file mode 100644
index 00000000..349a781b
--- /dev/null
+++ b/packages/db/src/repository/transaction-category.ts
@@ -0,0 +1,157 @@
+import {
+ type CreateTransactionCategory,
+ type ILogger,
+ type ITransactionCategoryRepository,
+ type TransactionCategory,
+ TransactionCategoryEntity,
+ type TransactionType,
+ type UpdateTransactionCategory,
+} from '@domus/core';
+import { and, eq, type InferInsertModel, isNull } from 'drizzle-orm';
+import { v7 as uuidv7 } from 'uuid';
+import type { DrizzleClient } from '../index';
+import { transactionCategories } from '../schema/transaction-categories';
+
+/**
+ * Repository implementation for transaction categories.
+ */
+export class TransactionCategoryRepository
+ implements ITransactionCategoryRepository
+{
+ constructor(
+ private readonly db: DrizzleClient,
+ private readonly logger: ILogger,
+ ) {}
+
+ /**
+ * Finds a transaction category by its unique ID.
+ */
+ async findById(id: string): Promise {
+ this.logger.info('TransactionCategoryRepository.findById', {
+ categoryId: id,
+ });
+ const [row] = await this.db
+ .select()
+ .from(transactionCategories)
+ .where(
+ and(
+ eq(transactionCategories.id, id),
+ isNull(transactionCategories.deletedAt),
+ ),
+ );
+
+ return row ? TransactionCategoryEntity.parse(row) : null;
+ }
+
+ /**
+ * Retrieves all transaction categories that are not soft-deleted.
+ */
+ async findAll(): Promise {
+ this.logger.info('TransactionCategoryRepository.findAll');
+ const rows = await this.db
+ .select()
+ .from(transactionCategories)
+ .where(isNull(transactionCategories.deletedAt));
+
+ return rows.map((row) => TransactionCategoryEntity.parse(row));
+ }
+
+ /**
+ * Finds a category by name and type to prevent duplicates.
+ */
+ async findByNameAndType(
+ name: string,
+ type: string,
+ ): Promise {
+ this.logger.info('TransactionCategoryRepository.findByNameAndType', {
+ name,
+ type,
+ });
+ const [row] = await this.db
+ .select()
+ .from(transactionCategories)
+ .where(
+ and(
+ eq(transactionCategories.name, name),
+ eq(transactionCategories.type, type as TransactionType),
+ isNull(transactionCategories.deletedAt),
+ ),
+ );
+
+ return row ? TransactionCategoryEntity.parse(row) : null;
+ }
+
+ /**
+ * Creates a new transaction category record.
+ */
+ async create(data: CreateTransactionCategory): Promise {
+ this.logger.info('TransactionCategoryRepository.create', {
+ name: data.name,
+ type: data.type,
+ });
+ const id = uuidv7();
+ const values = {
+ id,
+ name: data.name,
+ type: data.type as TransactionType,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ } satisfies InferInsertModel;
+
+ const [row] = await this.db
+ .insert(transactionCategories)
+ .values(values)
+ .returning();
+
+ if (!row) {
+ throw new Error('Failed to create transaction category');
+ }
+
+ return TransactionCategoryEntity.parse(row);
+ }
+
+ /**
+ * Updates a transaction category record.
+ */
+ async update(
+ id: string,
+ data: UpdateTransactionCategory,
+ ): Promise {
+ this.logger.info('TransactionCategoryRepository.update', {
+ categoryId: id,
+ });
+ const [row] = await this.db
+ .update(transactionCategories)
+ .set({
+ ...data,
+ type: data.type as TransactionType,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(transactionCategories.id, id),
+ isNull(transactionCategories.deletedAt),
+ ),
+ )
+ .returning();
+
+ if (!row) {
+ throw new Error(`Transaction category with ID ${id} not found`);
+ }
+
+ return TransactionCategoryEntity.parse(row);
+ }
+
+ /**
+ * Soft deletes a transaction category record.
+ */
+ async delete(id: string): Promise {
+ this.logger.info('TransactionCategoryRepository.delete', {
+ categoryId: id,
+ });
+ await this.db
+ .update(transactionCategories)
+ .set({ deletedAt: new Date() })
+ .where(eq(transactionCategories.id, id));
+ }
+}
diff --git a/packages/db/src/repository/user.ts b/packages/db/src/repository/user.ts
index dafc8c39..2662b6f6 100644
--- a/packages/db/src/repository/user.ts
+++ b/packages/db/src/repository/user.ts
@@ -140,12 +140,13 @@ export class UserRepository implements IUserRepository {
const conditions = [isNull(user.deletedAt)];
if (query?.search) {
- conditions.push(
- or(
- ilike(user.name, `%${query.search}%`),
- ilike(user.email, `%${query.search}%`),
- )!,
+ const searchCondition = or(
+ ilike(user.name, `%${query.search}%`),
+ ilike(user.email, `%${query.search}%`),
);
+ if (searchCondition) {
+ conditions.push(searchCondition);
+ }
}
if (query?.status && query.status.length > 0) {
diff --git a/packages/db/src/schema/financial-periods.ts b/packages/db/src/schema/financial-periods.ts
index e996d70b..e31f99af 100644
--- a/packages/db/src/schema/financial-periods.ts
+++ b/packages/db/src/schema/financial-periods.ts
@@ -1,5 +1,12 @@
import { PeriodStatus } from '@domus/core';
-import { integer, pgEnum, pgTable, timestamp, uuid } from 'drizzle-orm/pg-core';
+import {
+ integer,
+ pgEnum,
+ pgTable,
+ timestamp,
+ uniqueIndex,
+ uuid,
+} from 'drizzle-orm/pg-core';
import { v7 as uuidv7 } from 'uuid';
@@ -10,23 +17,36 @@ export const periodStatusEnum = pgEnum(
Object.values(PeriodStatus) as [string, ...string[]],
);
-export const financialPeriods = pgTable('financial_periods', {
- id: uuid('id')
- .primaryKey()
- .$defaultFn(() => uuidv7()),
+export const financialPeriods = pgTable(
+ 'financial_periods',
+ {
+ id: uuid('id')
+ .primaryKey()
+ .$defaultFn(() => uuidv7()),
- month: integer('month').notNull(),
- year: integer('year').notNull(),
- status: periodStatusEnum('status').notNull(),
- lockedBy: uuid('locked_by').references(() => user.id, {
- onDelete: 'set null',
- }),
+ month: integer('month').notNull(),
+ year: integer('year').notNull(),
+ status: periodStatusEnum('status').notNull(),
+ lockedBy: uuid('locked_by').references(() => user.id, {
+ onDelete: 'set null',
+ }),
- lockedAt: timestamp('locked_at'),
- createdAt: timestamp('created_at').defaultNow().notNull(),
- updatedAt: timestamp('updated_at')
- .defaultNow()
- .$onUpdate(() => new Date())
- .notNull(),
- deletedAt: timestamp('deleted_at'),
-});
+ lockedAt: timestamp('locked_at'),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ updatedAt: timestamp('updated_at')
+ .defaultNow()
+ .$onUpdate(() => new Date())
+ .notNull(),
+ deletedAt: timestamp('deleted_at'),
+ },
+ (table) => {
+ return [
+ {
+ uniqueMonthYear: uniqueIndex('financial_periods_month_year_unique').on(
+ table.month,
+ table.year,
+ ),
+ },
+ ];
+ },
+);
diff --git a/packages/db/src/schema/transaction-categories.ts b/packages/db/src/schema/transaction-categories.ts
index 39a6ad40..65033a53 100644
--- a/packages/db/src/schema/transaction-categories.ts
+++ b/packages/db/src/schema/transaction-categories.ts
@@ -1,5 +1,12 @@
import { TransactionType } from '@domus/core';
-import { pgEnum, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
+import {
+ pgEnum,
+ pgTable,
+ text,
+ timestamp,
+ unique,
+ uuid,
+} from 'drizzle-orm/pg-core';
import { v7 as uuidv7 } from 'uuid';
@@ -8,17 +15,26 @@ export const transactionTypeEnum = pgEnum(
Object.values(TransactionType) as [string, ...string[]],
);
-export const transactionCategories = pgTable('transaction_categories', {
- id: uuid('id')
- .primaryKey()
- .$defaultFn(() => uuidv7()),
+export const transactionCategories = pgTable(
+ 'transaction_categories',
+ {
+ id: uuid('id')
+ .primaryKey()
+ .$defaultFn(() => uuidv7()),
- name: text('name').notNull(),
- type: transactionTypeEnum('type').notNull(),
- createdAt: timestamp('created_at').defaultNow().notNull(),
- updatedAt: timestamp('updated_at')
- .defaultNow()
- .$onUpdate(() => new Date())
- .notNull(),
- deletedAt: timestamp('deleted_at'),
-});
+ name: text('name').notNull(),
+ type: transactionTypeEnum('type').notNull(),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ updatedAt: timestamp('updated_at')
+ .defaultNow()
+ .$onUpdate(() => new Date())
+ .notNull(),
+ deletedAt: timestamp('deleted_at'),
+ },
+ (table) => [
+ unique('transaction_categories_name_type_unique').on(
+ table.name,
+ table.type,
+ ),
+ ],
+);
diff --git a/packages/mailer/package.json b/packages/mailer/package.json
new file mode 100644
index 00000000..4a5498f2
--- /dev/null
+++ b/packages/mailer/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "@domus/mailer",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "scripts": {
+ "typecheck": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "@domus/tsconfig": "workspace:*",
+ "@types/node": "~24.12.2",
+ "@types/nodemailer": "^6.4.17",
+ "vitest": "^3.0.0"
+ },
+ "dependencies": {
+ "@domus/core": "workspace:*",
+ "nodemailer": "^6.10.0"
+ }
+}
diff --git a/packages/mailer/src/index.ts b/packages/mailer/src/index.ts
new file mode 100644
index 00000000..cbc0552f
--- /dev/null
+++ b/packages/mailer/src/index.ts
@@ -0,0 +1 @@
+export * from './nodemailer-mailer';
diff --git a/packages/mailer/src/nodemailer-mailer.ts b/packages/mailer/src/nodemailer-mailer.ts
new file mode 100644
index 00000000..e0124d71
--- /dev/null
+++ b/packages/mailer/src/nodemailer-mailer.ts
@@ -0,0 +1,77 @@
+import type { IMailer, Result, SendEmailPayload } from '@domus/core';
+import { fail, InternalError, ok } from '@domus/core';
+import nodemailer from 'nodemailer';
+
+/**
+ * Configuration for Nodemailer transport.
+ */
+export interface NodemailerConfig {
+ /**
+ * SMTP server hostname.
+ */
+ host?: string;
+ /**
+ * SMTP server port.
+ */
+ port: number;
+ /**
+ * SMTP username.
+ */
+ user?: string;
+ /**
+ * SMTP password.
+ */
+ pass?: string;
+ /**
+ * Default sender address.
+ */
+ from: string;
+}
+
+/**
+ * Implementation of IMailer using Nodemailer.
+ */
+export class NodemailerMailer implements IMailer {
+ private transport: nodemailer.Transporter;
+
+ constructor(private config: NodemailerConfig) {
+ this.transport = nodemailer.createTransport({
+ host: config.host,
+ port: config.port,
+ secure: config.port === 465,
+ auth:
+ config.user && config.pass
+ ? {
+ user: config.user,
+ pass: config.pass,
+ }
+ : undefined,
+ });
+ }
+
+ /**
+ * Sends an email using Nodemailer.
+ *
+ * @param payload - The email content and recipient details.
+ * @returns `ok(undefined)` on success, `fail(Error)` on failure.
+ */
+ async send(payload: SendEmailPayload): Promise> {
+ try {
+ await this.transport.sendMail({
+ from: this.config.from,
+ to: payload.to,
+ subject: payload.subject,
+ text: payload.text,
+ html: payload.html,
+ });
+
+ return ok(undefined);
+ } catch (error) {
+ return fail(
+ new InternalError(
+ error instanceof Error ? error : new Error(String(error)),
+ ),
+ );
+ }
+ }
+}
diff --git a/packages/mailer/tsconfig.json b/packages/mailer/tsconfig.json
new file mode 100644
index 00000000..576bf094
--- /dev/null
+++ b/packages/mailer/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "@domus/tsconfig/base.json",
+ "compilerOptions": {
+ "rootDir": "src"
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 76b69385..daa59ebb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -109,6 +109,9 @@ importers:
'@domus/db':
specifier: workspace:*
version: link:../../packages/db
+ '@domus/mailer':
+ specifier: workspace:*
+ version: link:../../packages/mailer
'@domus/storage':
specifier: workspace:*
version: link:../../packages/storage
@@ -365,6 +368,9 @@ importers:
uuid:
specifier: ^13.0.0
version: 13.0.0
+ zod:
+ specifier: ^4.3.6
+ version: 4.3.6
devDependencies:
'@domus/tsconfig':
specifier: workspace:*
@@ -385,6 +391,28 @@ importers:
specifier: ^3.1.0
version: 3.1.1(typescript@6.0.2)(vitest@4.1.4)
+ packages/mailer:
+ dependencies:
+ '@domus/core':
+ specifier: workspace:*
+ version: link:../core
+ nodemailer:
+ specifier: ^6.10.0
+ version: 6.10.1
+ devDependencies:
+ '@domus/tsconfig':
+ specifier: workspace:*
+ version: link:../tsconfig
+ '@types/node':
+ specifier: ~24.12.2
+ version: 24.12.2
+ '@types/nodemailer':
+ specifier: ^6.4.17
+ version: 6.4.23
+ vitest:
+ specifier: ^3.0.0
+ version: 3.2.4(@types/debug@4.1.13)(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(msw@2.13.2(@types/node@24.12.2)(typescript@6.0.2))(tsx@4.21.0)(yaml@2.8.3)
+
packages/storage:
dependencies:
'@aws-sdk/client-s3':
@@ -2586,6 +2614,144 @@ packages:
'@rolldown/pluginutils@1.0.0-rc.15':
resolution: {integrity: sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==}
+ '@rollup/rollup-android-arm-eabi@4.60.2':
+ resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.60.2':
+ resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.60.2':
+ resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.60.2':
+ resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.60.2':
+ resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.60.2':
+ resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.60.2':
+ resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==}
+ cpu: [arm]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.60.2':
+ resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==}
+ cpu: [arm]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-arm64-gnu@4.60.2':
+ resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm64-musl@4.60.2':
+ resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-loong64-gnu@4.60.2':
+ resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-loong64-musl@4.60.2':
+ resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.60.2':
+ resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-ppc64-musl@4.60.2':
+ resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.60.2':
+ resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-riscv64-musl@4.60.2':
+ resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-s390x-gnu@4.60.2':
+ resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-gnu@4.60.2':
+ resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-musl@4.60.2':
+ resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-openbsd-x64@4.60.2':
+ resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@rollup/rollup-openharmony-arm64@4.60.2':
+ resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.60.2':
+ resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.60.2':
+ resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-gnu@4.60.2':
+ resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.60.2':
+ resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==}
+ cpu: [x64]
+ os: [win32]
+
'@schummar/icu-type-parser@1.21.5':
resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==}
@@ -3240,6 +3406,9 @@ packages:
'@types/node@24.12.2':
resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==}
+ '@types/nodemailer@6.4.23':
+ resolution: {integrity: sha512-aFV3/NsYFLSx9mbb5gtirBSXJnAlrusoKNuPbxsASWc7vrKLmIrTQRpdcxNcSFL3VW2A2XpeLEavwb2qMi6nlQ==}
+
'@types/pg@8.20.0':
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
@@ -3283,9 +3452,23 @@ packages:
'@vitest/browser':
optional: true
+ '@vitest/expect@3.2.4':
+ resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
+
'@vitest/expect@4.1.4':
resolution: {integrity: sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==}
+ '@vitest/mocker@3.2.4':
+ resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==}
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+
'@vitest/mocker@4.1.4':
resolution: {integrity: sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==}
peerDependencies:
@@ -3297,18 +3480,33 @@ packages:
vite:
optional: true
+ '@vitest/pretty-format@3.2.4':
+ resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==}
+
'@vitest/pretty-format@4.1.4':
resolution: {integrity: sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==}
+ '@vitest/runner@3.2.4':
+ resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==}
+
'@vitest/runner@4.1.4':
resolution: {integrity: sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==}
+ '@vitest/snapshot@3.2.4':
+ resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==}
+
'@vitest/snapshot@4.1.4':
resolution: {integrity: sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==}
+ '@vitest/spy@3.2.4':
+ resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==}
+
'@vitest/spy@4.1.4':
resolution: {integrity: sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==}
+ '@vitest/utils@3.2.4':
+ resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
+
'@vitest/utils@4.1.4':
resolution: {integrity: sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==}
@@ -3535,6 +3733,10 @@ packages:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
+ cac@6.7.14:
+ resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
+ engines: {node: '>=8'}
+
cac@7.0.0:
resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==}
engines: {node: '>=20.19.0'}
@@ -3557,6 +3759,10 @@ packages:
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
+ chai@5.3.3:
+ resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
+ engines: {node: '>=18'}
+
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
@@ -3577,6 +3783,10 @@ packages:
character-reference-invalid@2.0.1:
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
+ check-error@2.1.3:
+ resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
+ engines: {node: '>= 16'}
+
cheerio-select@2.1.0:
resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
@@ -3754,6 +3964,10 @@ packages:
babel-plugin-macros:
optional: true
+ deep-eql@5.0.2:
+ resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
+ engines: {node: '>=6'}
+
deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
@@ -3996,6 +4210,9 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
+ es-module-lexer@1.7.0:
+ resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
+
es-module-lexer@2.0.0:
resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==}
@@ -4535,6 +4752,9 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+ js-tokens@9.0.1:
+ resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
+
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
@@ -4703,6 +4923,9 @@ packages:
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
+ loupe@3.2.1:
+ resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
+
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@@ -5063,6 +5286,10 @@ packages:
node-releases@2.0.37:
resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==}
+ nodemailer@6.10.1:
+ resolution: {integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==}
+ engines: {node: '>=6.0.0'}
+
npm-run-path@4.0.1:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'}
@@ -5174,6 +5401,10 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+ pathval@2.0.1:
+ resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
+ engines: {node: '>= 14.16'}
+
pg-cloudflare@1.3.0:
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
@@ -5438,6 +5669,11 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
+ rollup@4.60.2:
+ resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
rou3@0.7.12:
resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==}
@@ -5585,6 +5821,9 @@ packages:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
+ std-env@3.10.0:
+ resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
+
std-env@4.0.0:
resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==}
@@ -5642,6 +5881,9 @@ packages:
resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
engines: {node: '>=18'}
+ strip-literal@3.1.0:
+ resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
+
strnum@2.2.3:
resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==}
@@ -5695,6 +5937,9 @@ packages:
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
+ tinyexec@0.3.2:
+ resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
+
tinyexec@1.1.1:
resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==}
engines: {node: '>=18'}
@@ -5703,10 +5948,22 @@ packages:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
+ tinypool@1.1.1:
+ resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+
+ tinyrainbow@2.0.0:
+ resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
+ engines: {node: '>=14.0.0'}
+
tinyrainbow@3.1.0:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
+ tinyspy@4.0.4:
+ resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
+ engines: {node: '>=14.0.0'}
+
tldts-core@7.0.28:
resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==}
@@ -5935,6 +6192,51 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
+ vite-node@3.2.4:
+ resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ hasBin: true
+
+ vite@7.3.2:
+ resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^20.19.0 || >=22.12.0
+ jiti: '>=1.21.0'
+ less: ^4.0.0
+ lightningcss: ^1.21.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: '>=0.54.8'
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
vite@8.0.8:
resolution: {integrity: sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -5984,6 +6286,34 @@ packages:
typescript: 3.x || 4.x || 5.x || 6.x
vitest: '>=3.0.0'
+ vitest@3.2.4:
+ resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ hasBin: true
+ peerDependencies:
+ '@edge-runtime/vm': '*'
+ '@types/debug': ^4.1.12
+ '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
+ '@vitest/browser': 3.2.4
+ '@vitest/ui': 3.2.4
+ happy-dom: '*'
+ jsdom: '*'
+ peerDependenciesMeta:
+ '@edge-runtime/vm':
+ optional: true
+ '@types/debug':
+ optional: true
+ '@types/node':
+ optional: true
+ '@vitest/browser':
+ optional: true
+ '@vitest/ui':
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+
vitest@4.1.4:
resolution: {integrity: sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -8086,6 +8416,81 @@ snapshots:
'@rolldown/pluginutils@1.0.0-rc.15': {}
+ '@rollup/rollup-android-arm-eabi@4.60.2':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.60.2':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.60.2':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.60.2':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.60.2':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-gnu@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-musl@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-musl@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.60.2':
+ optional: true
+
+ '@rollup/rollup-openbsd-x64@4.60.2':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.60.2':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.60.2':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.60.2':
+ optional: true
+
+ '@rollup/rollup-win32-x64-gnu@4.60.2':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.60.2':
+ optional: true
+
'@schummar/icu-type-parser@1.21.5': {}
'@sec-ant/readable-stream@0.4.1': {}
@@ -8767,6 +9172,10 @@ snapshots:
dependencies:
undici-types: 7.16.0
+ '@types/nodemailer@6.4.23':
+ dependencies:
+ '@types/node': 24.12.2
+
'@types/pg@8.20.0':
dependencies:
'@types/node': 24.12.2
@@ -8823,6 +9232,14 @@ snapshots:
tinyrainbow: 3.1.0
vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-istanbul@4.1.4)(@vitest/coverage-v8@4.1.4)(msw@2.13.2(@types/node@24.12.2)(typescript@6.0.2))(vite@8.0.8(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))
+ '@vitest/expect@3.2.4':
+ dependencies:
+ '@types/chai': 5.2.3
+ '@vitest/spy': 3.2.4
+ '@vitest/utils': 3.2.4
+ chai: 5.3.3
+ tinyrainbow: 2.0.0
+
'@vitest/expect@4.1.4':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -8832,6 +9249,15 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
+ '@vitest/mocker@3.2.4(msw@2.13.2(@types/node@24.12.2)(typescript@6.0.2))(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))':
+ dependencies:
+ '@vitest/spy': 3.2.4
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ msw: 2.13.2(@types/node@24.12.2)(typescript@6.0.2)
+ vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)
+
'@vitest/mocker@4.1.4(msw@2.13.2(@types/node@24.12.2)(typescript@6.0.2))(vite@8.0.8(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@vitest/spy': 4.1.4
@@ -8841,15 +9267,31 @@ snapshots:
msw: 2.13.2(@types/node@24.12.2)(typescript@6.0.2)
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)
+ '@vitest/pretty-format@3.2.4':
+ dependencies:
+ tinyrainbow: 2.0.0
+
'@vitest/pretty-format@4.1.4':
dependencies:
tinyrainbow: 3.1.0
+ '@vitest/runner@3.2.4':
+ dependencies:
+ '@vitest/utils': 3.2.4
+ pathe: 2.0.3
+ strip-literal: 3.1.0
+
'@vitest/runner@4.1.4':
dependencies:
'@vitest/utils': 4.1.4
pathe: 2.0.3
+ '@vitest/snapshot@3.2.4':
+ dependencies:
+ '@vitest/pretty-format': 3.2.4
+ magic-string: 0.30.21
+ pathe: 2.0.3
+
'@vitest/snapshot@4.1.4':
dependencies:
'@vitest/pretty-format': 4.1.4
@@ -8857,8 +9299,18 @@ snapshots:
magic-string: 0.30.21
pathe: 2.0.3
+ '@vitest/spy@3.2.4':
+ dependencies:
+ tinyspy: 4.0.4
+
'@vitest/spy@4.1.4': {}
+ '@vitest/utils@3.2.4':
+ dependencies:
+ '@vitest/pretty-format': 3.2.4
+ loupe: 3.2.1
+ tinyrainbow: 2.0.0
+
'@vitest/utils@4.1.4':
dependencies:
'@vitest/pretty-format': 4.1.4
@@ -9045,6 +9497,8 @@ snapshots:
bytes@3.1.2: {}
+ cac@6.7.14: {}
+
cac@7.0.0: {}
call-bind-apply-helpers@1.0.2:
@@ -9063,6 +9517,14 @@ snapshots:
ccount@2.0.1: {}
+ chai@5.3.3:
+ dependencies:
+ assertion-error: 2.0.1
+ check-error: 2.1.3
+ deep-eql: 5.0.2
+ loupe: 3.2.1
+ pathval: 2.0.1
+
chai@6.2.2: {}
chalk@5.6.2: {}
@@ -9075,6 +9537,8 @@ snapshots:
character-reference-invalid@2.0.1: {}
+ check-error@2.1.3: {}
+
cheerio-select@2.1.0:
dependencies:
boolbase: 1.0.0
@@ -9232,6 +9696,8 @@ snapshots:
dedent@1.7.2: {}
+ deep-eql@5.0.2: {}
+
deepmerge@4.3.1: {}
default-browser-id@5.0.1: {}
@@ -9362,6 +9828,8 @@ snapshots:
es-errors@1.3.0: {}
+ es-module-lexer@1.7.0: {}
+
es-module-lexer@2.0.0: {}
es-object-atoms@1.1.1:
@@ -10073,6 +10541,8 @@ snapshots:
js-tokens@4.0.0: {}
+ js-tokens@9.0.1: {}
+
js-yaml@4.1.1:
dependencies:
argparse: 2.0.1
@@ -10214,6 +10684,8 @@ snapshots:
longest-streak@3.1.0: {}
+ loupe@3.2.1: {}
+
lru-cache@10.4.3: {}
lru-cache@5.1.1:
@@ -10847,6 +11319,8 @@ snapshots:
node-releases@2.0.37: {}
+ nodemailer@6.10.1: {}
+
npm-run-path@4.0.1:
dependencies:
path-key: 3.1.1
@@ -10966,6 +11440,8 @@ snapshots:
pathe@2.0.3: {}
+ pathval@2.0.1: {}
+
pg-cloudflare@1.3.0:
optional: true
@@ -11287,6 +11763,37 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.15
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.15
+ rollup@4.60.2:
+ dependencies:
+ '@types/estree': 1.0.8
+ optionalDependencies:
+ '@rollup/rollup-android-arm-eabi': 4.60.2
+ '@rollup/rollup-android-arm64': 4.60.2
+ '@rollup/rollup-darwin-arm64': 4.60.2
+ '@rollup/rollup-darwin-x64': 4.60.2
+ '@rollup/rollup-freebsd-arm64': 4.60.2
+ '@rollup/rollup-freebsd-x64': 4.60.2
+ '@rollup/rollup-linux-arm-gnueabihf': 4.60.2
+ '@rollup/rollup-linux-arm-musleabihf': 4.60.2
+ '@rollup/rollup-linux-arm64-gnu': 4.60.2
+ '@rollup/rollup-linux-arm64-musl': 4.60.2
+ '@rollup/rollup-linux-loong64-gnu': 4.60.2
+ '@rollup/rollup-linux-loong64-musl': 4.60.2
+ '@rollup/rollup-linux-ppc64-gnu': 4.60.2
+ '@rollup/rollup-linux-ppc64-musl': 4.60.2
+ '@rollup/rollup-linux-riscv64-gnu': 4.60.2
+ '@rollup/rollup-linux-riscv64-musl': 4.60.2
+ '@rollup/rollup-linux-s390x-gnu': 4.60.2
+ '@rollup/rollup-linux-x64-gnu': 4.60.2
+ '@rollup/rollup-linux-x64-musl': 4.60.2
+ '@rollup/rollup-openbsd-x64': 4.60.2
+ '@rollup/rollup-openharmony-arm64': 4.60.2
+ '@rollup/rollup-win32-arm64-msvc': 4.60.2
+ '@rollup/rollup-win32-ia32-msvc': 4.60.2
+ '@rollup/rollup-win32-x64-gnu': 4.60.2
+ '@rollup/rollup-win32-x64-msvc': 4.60.2
+ fsevents: 2.3.3
+
rou3@0.7.12: {}
router@2.2.0:
@@ -11516,6 +12023,8 @@ snapshots:
statuses@2.0.2: {}
+ std-env@3.10.0: {}
+
std-env@4.0.0: {}
stdin-discarder@0.2.2: {}
@@ -11572,6 +12081,10 @@ snapshots:
strip-final-newline@4.0.0: {}
+ strip-literal@3.1.0:
+ dependencies:
+ js-tokens: 9.0.1
+
strnum@2.2.3: {}
style-to-js@1.1.21:
@@ -11609,6 +12122,8 @@ snapshots:
tinybench@2.9.0: {}
+ tinyexec@0.3.2: {}
+
tinyexec@1.1.1: {}
tinyglobby@0.2.16:
@@ -11616,8 +12131,14 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
+ tinypool@1.1.1: {}
+
+ tinyrainbow@2.0.0: {}
+
tinyrainbow@3.1.0: {}
+ tinyspy@4.0.4: {}
+
tldts-core@7.0.28: {}
tldts@7.0.28:
@@ -11853,6 +12374,43 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
+ vite-node@3.2.4(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3):
+ dependencies:
+ cac: 6.7.14
+ debug: 4.4.3
+ es-module-lexer: 1.7.0
+ pathe: 2.0.3
+ vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)
+ transitivePeerDependencies:
+ - '@types/node'
+ - jiti
+ - less
+ - lightningcss
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+ - tsx
+ - yaml
+
+ vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3):
+ dependencies:
+ esbuild: 0.27.7
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
+ postcss: 8.5.10
+ rollup: 4.60.2
+ tinyglobby: 0.2.16
+ optionalDependencies:
+ '@types/node': 24.12.2
+ fsevents: 2.3.3
+ jiti: 2.6.1
+ lightningcss: 1.32.0
+ tsx: 4.21.0
+ yaml: 2.8.3
+
vite@8.0.8(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
lightningcss: 1.32.0
@@ -11874,6 +12432,48 @@ snapshots:
typescript: 6.0.2
vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-istanbul@4.1.4)(@vitest/coverage-v8@4.1.4)(msw@2.13.2(@types/node@24.12.2)(typescript@6.0.2))(vite@8.0.8(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))
+ vitest@3.2.4(@types/debug@4.1.13)(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(msw@2.13.2(@types/node@24.12.2)(typescript@6.0.2))(tsx@4.21.0)(yaml@2.8.3):
+ dependencies:
+ '@types/chai': 5.2.3
+ '@vitest/expect': 3.2.4
+ '@vitest/mocker': 3.2.4(msw@2.13.2(@types/node@24.12.2)(typescript@6.0.2))(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))
+ '@vitest/pretty-format': 3.2.4
+ '@vitest/runner': 3.2.4
+ '@vitest/snapshot': 3.2.4
+ '@vitest/spy': 3.2.4
+ '@vitest/utils': 3.2.4
+ chai: 5.3.3
+ debug: 4.4.3
+ expect-type: 1.3.0
+ magic-string: 0.30.21
+ pathe: 2.0.3
+ picomatch: 4.0.4
+ std-env: 3.10.0
+ tinybench: 2.9.0
+ tinyexec: 0.3.2
+ tinyglobby: 0.2.16
+ tinypool: 1.1.1
+ tinyrainbow: 2.0.0
+ vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)
+ vite-node: 3.2.4(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/debug': 4.1.13
+ '@types/node': 24.12.2
+ transitivePeerDependencies:
+ - jiti
+ - less
+ - lightningcss
+ - msw
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+ - tsx
+ - yaml
+
vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-istanbul@4.1.4)(@vitest/coverage-v8@4.1.4)(msw@2.13.2(@types/node@24.12.2)(typescript@6.0.2))(vite@8.0.8(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.4