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 ( +

+

{t("listEmpty")}

+
+ ); + } + + 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 ( + + +
+
+ + + {file ? ( +
+ {file.name} + +
+ ) : ( +
+ +
+ )} +
+ +
+ + setName(e.target.value)} + disabled={isUploading || !file} + /> +
+ + +
+
+
+ ); +} 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 ( +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + className="space-y-6" + > + + {(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 */} + !open && setEditCategory(null)} + > + + + {formT("titleUpdate")} + + {editCategory && ( + setEditCategory(null)} + loading={actionLoading} + /> + )} + + + + {/* 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")}

+
+ + + + + {t("addCategory")} + + } + /> + + + {formT("titleCreate")} + + setOpen(false)} + loading={loading} + /> + + +
+ + +
+ ); +} 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 ( + + + + {commonT("addPeriod")} + + } + /> + + + {t("title")} + {t("description")} + +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ + + + +
+
+ ); +} 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" + /> + +
+
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + className="space-y-6" + > + + + {/* Basic Info Section */} +
+
+
+

{t("sectionBasic")}

+
+ +
+ + {(field) => ( + + )} + + + + {(field) => ( + + )} + +
+ +
+ state.values.type}> + {(currentType) => { + const categoryOptions = categories + .filter( + (c: TransactionCategory) => c.type === currentType, + ) + .map((c: TransactionCategory) => ({ + label: c.name, + value: c.id, + })); + + return ( + + {(field) => ( + + )} + + ); + }} + + + + {(field) => ( + + )} + +
+ + + {(field) => ( + + )} + +
+ + {/* Evidence Section */} +
+
+
+

+ {t("sectionEvidence")} +

+
+ + + {(field) => ( + + )} + +
+ + + +
+ + +
+ +
+
+ ); +} 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 ( +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + className="flex flex-col gap-6" + > + {actionError && ( +
+ {actionError} +
+ )} + +
+ {/* Name Field */} + + {(field) => ( + + )} + + +
+ {/* Start Date Field */} + + {(field) => ( + + )} + + + {/* End Date Field */} + + {(field) => ( + + )} + +
+ +
+ {/* SK Number Field */} + + {(field) => ( + + )} + + + {/* SK Date Field */} + + {(field) => ( + + )} + +
+ + {/* Description Field */} + + {(field) => ( + + )} + +
+ + + } + > + {t("btnCancel")} + + + [state.canSubmit, state.isSubmitting]} + > + {(state) => { + const [canSubmit, isSubmittingState] = state; + const loading = isSubmitting || isSubmittingState; + return ( + } + > + {loading + ? t("btnSubmitting") || "Saving..." + : isUpdate + ? t("btnSave") + : t("btnAdd")} + + ); + }} + + +
+ ); +} 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 */} + + +
+ + {editingTerm + ? t("editDialogTitle") || "Ubah Masa Jabatan" + : t("addDialogTitle") || "Tambah Masa Jabatan"} + + + {editingTerm + ? t("editDialogDesc") || "Sesuaikan detail masa jabatan." + : t("addDialogDesc") || "Masukkan periode kepengurusan baru."} + +
+
+ updateTermAction(editingTerm.id, data) + : createTermAction + } + onSuccess={() => { + setIsDialogOpen(false); + toast.success( + editingTerm + ? t("updateSuccess") || "Berhasil diperbarui" + : t("createSuccess") || "Berhasil ditambahkan", + ); + }} + onCancel={() => setIsDialogOpen(false)} + /> +
+
+
+
+ ); +} 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 ? ( - {user.name} ) : ( 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/") ? ( + Receipt + ) : ( +
+ + + 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