diff --git a/.env.example b/.env.example index 750be2c4..931436a3 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,14 @@ SUPER_ADMIN_NAME=Administrator # Cron CRON_SECRET= + +# Axiom — Log Storage (https://axiom.co/docs/guides/pino) +AXIOM_TOKEN=AXIOM_TOKEN +AXIOM_DATASET=DATA_SET + +# Cloudflare R2 — Object Storage +R2_ACCOUNT_ID= +R2_ACCESS_KEY_ID= +R2_SECRET_ACCESS_KEY= +R2_BUCKET_NAME= +R2_PUBLIC_URL= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffc36ae8..c0315684 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,28 @@ env: ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }} - + GDRIVE_SERVICE_ACCOUNT_KEY: ${{secrets.GDRIVE_SERVICE_ACCOUNT_KEY}} + R2_ACCOUNT_ID: ${{secrets.R2_ACCOUNT_ID}} + R2_ACCESS_KEY_ID: ${{secrets.R2_ACCESS_KEY_ID}} + R2_SECRET_ACCESS_KEY: ${{secrets.R2_SECRET_ACCESS_KEY}} + R2_BUCKET_NAME: ${{secrets.R2_BUCKET_NAME}} + R2_PUBLIC_URL: ${{secrets.R2_PUBLIC_URL}} + AXIOM_TOKEN: ${{secrets.AXIOM_TOKEN}} + AXIOM_DATASET: ${{secrets.AXIOM_DATASET}} + CRON_SECRET: ${{secrets.CRON_SECRET}} + BETTER_AUTH_SECRET: ${{secrets.BETTER_AUTH_SECRET}} + BETTER_AUTH_URL: http://localhost:3000 + BETTER_AUTH_COOKIE_PREFIX: domus_auth_test + GOOGLE_CLIENT_ID: ${{secrets.GOOGLE_CLIENT_ID}} + GOOGLE_CLIENT_SECRET: ${{secrets.GOOGLE_CLIENT_SECRET}} + SUPER_ADMIN_EMAIL: superadmin@test.com + SUPER_ADMIN_PASSWORD: testing123 + SUPER_ADMIN_NAME: Super Admin + NEXT_PUBLIC_DOMUS_VERSION: 0.0.0-dev + CF_API_TOKEN: ${{secrets.CF_API_TOKEN}} + CF_ACCOUNT_ID: ${{secrets.CF_ACCOUNT_ID}} + WHATSAPP_TOKEN: ${{secrets.WHATSAPP_TOKEN}} + jobs: check: name: Lint & Type Check diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a8aebafe..0ec5e82a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -16,8 +16,8 @@ env: TURBO_TEAM: ${{ secrets.TURBO_TEAM }} jobs: - deploy: - name: Vercel Deploy + dash: + name: Dashboard runs-on: ubuntu-latest env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} @@ -55,4 +55,4 @@ jobs: NEXT_PUBLIC_DOMUS_VERSION: ${{ github.ref_name }} - name: Deploy Project Artifacts to Vercel - run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }} \ No newline at end of file + run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index 071880d7..3ab70306 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -307,4 +307,4 @@ See → [docs/tdd.md — Deployment & Infrastructure](docs/tdd.md#9-deployment-- ## License -MIT — see → [docs/tdd.md — License](docs/tdd.md#11-license) +MIT — see → [docs/tdd.md — License](docs/tdd.md#12-license) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 861637fb..d2c7d644 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,7 +58,7 @@ pnpm dev ``` domus/ ├── apps/ -│ ├── dash/ # Next.js 15 — main application (dash.pkrbt.id) +│ ├── dash/ # Next.js 15 — main application (pkrbt.id) │ └── cron/ # Cloudflare Workers — scheduled tasks ├── packages/ │ ├── core/ # Framework-agnostic business logic (Clean Architecture) diff --git a/apps/cron/.gitignore b/apps/cron/.gitignore new file mode 100644 index 00000000..7310e736 --- /dev/null +++ b/apps/cron/.gitignore @@ -0,0 +1 @@ +.wrangler \ No newline at end of file diff --git a/apps/cron/package.json b/apps/cron/package.json index edfe127a..de9a0ce6 100644 --- a/apps/cron/package.json +++ b/apps/cron/package.json @@ -2,9 +2,11 @@ "name": "@domus/cron", "version": "0.0.1", "private": true, + "type": "module", "scripts": { - "dev": "wrangler dev", - "deploy": "wrangler deploy", + "build": "tsdown", + "dev": "tsdown --watch", + "deploy": "pnpm run build && wrangler deploy dist/index.mjs", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -12,7 +14,8 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4.20250224.0", + "tsdown": "^0.21.7", "typescript": "^5.7.3", - "wrangler": "^3.111.0" + "wrangler": "^4.79.0" } } diff --git a/apps/cron/tsdown.config.ts b/apps/cron/tsdown.config.ts new file mode 100644 index 00000000..e1c1a278 --- /dev/null +++ b/apps/cron/tsdown.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + target: 'node20', + outDir: 'dist', + clean: true, + // Equivalent to previous bundle: true + unbundle: false, + deps: { + // Equivalent to previous external: [/^cloudflare:/] + neverBundle: [/^cloudflare:/], + }, + minify: true, +}); diff --git a/apps/cron/wrangler.toml b/apps/cron/wrangler.toml index cfae0a50..d584ff1e 100644 --- a/apps/cron/wrangler.toml +++ b/apps/cron/wrangler.toml @@ -1,11 +1,11 @@ name = "domus-cron" -main = "src/index.ts" +main = "dist/index.mjs" compatibility_date = "2026-04-01" [triggers] -crons = ["0 0 * * 0"] +crons = ["0 0 * * 7"] [vars] -DASH_API_URL = "https://dash.pkrbt.id" +DASH_API_URL = "https://pkrbt.id" # CRON_SECRET must be set via: wrangler secret put CRON_SECRET diff --git a/apps/dash/app/api/cron/rotate-join-id/route.ts b/apps/dash/app/api/cron/rotate-join-id/route.ts index e4a11c66..c32b4d0f 100644 --- a/apps/dash/app/api/cron/rotate-join-id/route.ts +++ b/apps/dash/app/api/cron/rotate-join-id/route.ts @@ -1,2 +1,2 @@ // Thin export only — no logic here. -export { POST } from "@/app/api-routes/cron/rotate-join-id"; +export { POST } from "@/src/api-routes/cron/rotate-join-id"; diff --git a/apps/dash/app/api/upload/route.ts b/apps/dash/app/api/upload/route.ts new file mode 100644 index 00000000..60018556 --- /dev/null +++ b/apps/dash/app/api/upload/route.ts @@ -0,0 +1,40 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { logger } from "@/shared/core/logger"; + +/** + * Mock upload API for E2E testing and development. + * This should be replaced with a real storage implementation (e.g. R2, GDrive) in production. + */ +export async function POST(req: NextRequest) { + try { + const formData = await req.formData(); + const file = formData.get("file") as File; + const type = formData.get("type") as string; + const id = formData.get("id") as string; + + logger.info("Mock Upload API received file", { + name: file?.name, + size: file?.size, + type, + id, + }); + + // Dummy delay to simulate network latency for the test progress bar + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Return a dummy URL (pointing back to a placeholder or something valid) + // In a real implementation, this would be the URL from the storage provider. + const dummyUrl = `https://images.unsplash.com/photo-1518173946687-a4c8a9833d8e?q=80&w=1000&auto=format&fit=crop`; + + return NextResponse.json({ + success: true, + url: dummyUrl, + }); + } catch (error) { + logger.error("Mock Upload API error", { error }); + return NextResponse.json( + { success: false, message: "Upload failed" }, + { status: 500 }, + ); + } +} diff --git a/apps/dash/app/globals.css b/apps/dash/app/globals.css index 40a18533..3ef0492c 100644 --- a/apps/dash/app/globals.css +++ b/apps/dash/app/globals.css @@ -206,6 +206,17 @@ --font-heading: "Plus Jakarta Sans", sans-serif; --font-body: "Inter", sans-serif; --font-sans: "Inter", sans-serif; + + /* Animations */ + @keyframes shimmer { + from { + background-position: 200% 0; + } + to { + background-position: -200% 0; + } + } + --animate-shimmer: shimmer 2s infinite linear; } /* ------------------------------------------------------------- diff --git a/apps/dash/e2e/features/org/join.spec.ts b/apps/dash/e2e/features/org/join.spec.ts index b9f9f65c..63a864d9 100644 --- a/apps/dash/e2e/features/org/join.spec.ts +++ b/apps/dash/e2e/features/org/join.spec.ts @@ -51,7 +51,9 @@ test.describe("Organization Join Page", () => { await expect(page).toHaveURL(new RegExp(`/join/${TEST_JOIN_ID}/success`), { timeout: 10000, }); - await expect(page.getByText(/Pendaftaran Berhasil!/i)).toBeVisible(); + await expect( + page.getByRole("heading", { name: /Pendaftaran Berhasil!/i }), + ).toBeVisible(); }); test("should show already registered message when joining twice", async ({ @@ -76,7 +78,12 @@ test.describe("Organization Join Page", () => { await page.getByRole("button", { name: /Bergabung Sekarang/i }).click(); // Verify first one is "Success" - await expect(page.getByText(/Pendaftaran Berhasil!/i)).toBeVisible(); + await expect(page).toHaveURL(new RegExp(`/join/${TEST_JOIN_ID}/success`), { + timeout: 20000, + }); + await expect( + page.getByRole("heading", { name: /Pendaftaran Berhasil!/i }), + ).toBeVisible({ timeout: 10000 }); // 3. Perform second registration (same user, same org) await page.goto(`/join/${TEST_JOIN_ID}`); @@ -91,7 +98,9 @@ test.describe("Organization Join Page", () => { new RegExp(`/join/${TEST_JOIN_ID}/success\\?exists=true`), { timeout: 10000 }, ); - await expect(page.getByText(/Pendaftaran Sudah Ada/i)).toBeVisible(); + await expect( + page.getByRole("heading", { name: /Pendaftaran Sudah Ada/i }), + ).toBeVisible({ timeout: 15000 }); await expect(page.getByText(/Anda sudah terdaftar/i)).toBeVisible(); }); @@ -102,6 +111,8 @@ test.describe("Organization Join Page", () => { accountStatus: AccountStatus.Pending, }); await page.goto("/join/invalid-id-999"); - await expect(page.getByText(/Link Tidak Valid/i)).toBeVisible(); + await expect( + page.getByRole("heading", { name: /Link Tidak Valid/i }), + ).toBeVisible(); }); }); diff --git a/apps/dash/e2e/features/org/manage.spec.ts b/apps/dash/e2e/features/org/manage.spec.ts index 0041dafd..d0369ee3 100644 --- a/apps/dash/e2e/features/org/manage.spec.ts +++ b/apps/dash/e2e/features/org/manage.spec.ts @@ -51,8 +51,10 @@ test.describe("Organization Update & Remove Page", () => { test("should soft remove an existing organization", async ({ page }) => { // 1. Create a dummy organization to test removal + const randomSuffix = Math.random().toString(36).substring(7); + const orgName = `Organisasi Untuk Dihapus ${randomSuffix}`; await page.goto("/org/new"); - await page.locator("#name").fill("Organisasi Untuk Dihapus"); + await page.locator("#name").fill(orgName); await page.getByRole("button", { name: /Simpan/i }).click(); // 2. Wait for redirect to details page @@ -74,7 +76,87 @@ test.describe("Organization Update & Remove Page", () => { page .getByTestId("org-grid") .locator("> div") - .filter({ hasText: "Organisasi Untuk Dihapus" }), + .filter({ hasText: orgName }), ).toHaveCount(0); }); + + test("should inline edit organization name, description, cover, and logo", async ({ + page, + }) => { + // 1. Navigate to /org and click on the first organization to edit + await page.goto("/org"); + const firstCardLink = page.getByTestId("org-grid").locator("a").first(); + const orgUrlPath = await firstCardLink.getAttribute("href"); + if (!orgUrlPath) throw new Error("No URL found"); + + await page.goto(orgUrlPath); + + // 2. Edit Name + const nameGroup = page.locator(".group\\/name"); + await nameGroup.hover(); + await page.getByRole("button", { name: "Ubah Nama" }).click(); + + const nameInput = page.locator("input.font-heading"); + await expect(nameInput).toBeVisible(); + await nameInput.fill("Nama Inline Baru"); + await page.keyboard.press("Enter"); + + await expect( + page.getByRole("heading", { name: "Nama Inline Baru" }), + ).toBeVisible(); + + // 3. Edit Description + const descGroup = page.locator(".group\\/desc"); + await descGroup.hover(); + await page.getByRole("button", { name: "Ubah Deskripsi" }).click(); + + const descTextarea = page.locator("textarea.font-body"); + await expect(descTextarea).toBeVisible(); + await descTextarea.fill("Deskripsi Inline Baru"); + // Click the check button + await page.locator("textarea.font-body + div > button:first-child").click(); + + await expect(page.getByText("Deskripsi Inline Baru")).toBeVisible(); + + // 4. Edit Cover + const headerGroup = page.locator(".group\\/header"); + await headerGroup.hover(); + const ubahCoverBtn = page.getByRole("button", { name: "Ubah Cover" }); + await expect(ubahCoverBtn).toBeVisible(); + + // In Playwright, we can handle the file chooser before clicking + const fileChooserPromise = page.waitForEvent("filechooser"); + await ubahCoverBtn.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles({ + name: "cover.jpg", + mimeType: "image/jpeg", + buffer: Buffer.from("fake image content"), + }); + + // We expect the progress overlay to appear and then disappear (upload completes) + await expect(page.getByText(/Uploading/i)).toBeVisible(); + await expect(page.getByText(/Uploading/i)).toBeHidden(); + + // 5. Edit Logo + const logoGroup = page + .locator("div.w-24.h-24.group, div.w-28.h-28.group") + .first(); + await logoGroup.hover(); + const ubahLogoBtn = page.getByRole("button", { name: "Ubah Logo" }); + await expect(ubahLogoBtn).toBeVisible(); + + const logoChooserPromise = page.waitForEvent("filechooser"); + await ubahLogoBtn.click(); + const logoChooser = await logoChooserPromise; + await logoChooser.setFiles({ + name: "logo.jpg", + mimeType: "image/jpeg", + buffer: Buffer.from("fake image content"), + }); + + // Wait for upload progress to finish + await expect(page.getByText(/Uploading/i)).toBeVisible(); + await expect(page.getByText(/Uploading/i)).toBeHidden(); + }); }); diff --git a/apps/dash/next.config.ts b/apps/dash/next.config.ts index 84963ca8..05f4de90 100644 --- a/apps/dash/next.config.ts +++ b/apps/dash/next.config.ts @@ -19,8 +19,18 @@ const nextConfig: NextConfig = { protocol: "https", hostname: "placehold.co", }, + { + protocol: "https", + hostname: "**.pkrbt.id", + }, ], }, + serverExternalPackages: [ + "@axiomhq/pino", + "pino", + "thread-stream", + "pino-pretty", + ], }; export default withNextIntl(nextConfig); diff --git a/apps/dash/package.json b/apps/dash/package.json index c743442e..822fbd80 100644 --- a/apps/dash/package.json +++ b/apps/dash/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build", + "build": "next build --webpack", "start": "next start", "lint": "biome check", "format": "biome format --write", @@ -16,14 +16,17 @@ "e2e": "playwright test", "e2e:ui": "playwright test --ui", "auth:gen": "npx auth@latest generate --config src/shared/auth/server.ts --output=../../packages/db/src/schema/better-auth.ts", + "auth:patch": "tsx ./seed/patch-schema.ts", "db:seed": "tsx ./seed/index.ts" }, "dependencies": { + "@axiomhq/pino": "^1.6.0", "@base-ui/react": "^1.3.0", "@domus/auth": "workspace:*", "@domus/config": "workspace:*", "@domus/core": "workspace:*", "@domus/db": "workspace:*", + "@domus/storage": "workspace:*", "@fontsource/inter": "^5.2.8", "@fontsource/plus-jakarta-sans": "^5.2.8", "@hello-pangea/dnd": "^18.0.1", @@ -31,6 +34,7 @@ "@tanstack/react-form": "^1.28.6", "@types/uuid": "^11.0.0", "better-auth": "1.5.6", + "browser-image-compression": "^2.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", @@ -40,6 +44,7 @@ "next": "16.2.1", "next-intl": "^4.8.3", "next-themes": "^0.4.6", + "pino": "^10.3.1", "react": "19.2.4", "react-day-picker": "^9.14.0", "react-dom": "19.2.4", @@ -56,9 +61,11 @@ "@playwright/test": "^1.58.2", "@tailwindcss/postcss": "^4", "@types/node": "^24.0.0", + "@types/pino": "^7.0.5", "@types/react": "^19", "@types/react-dom": "^19", "@vitest/coverage-istanbul": "^4.1.0", + "pino-pretty": "^13.1.3", "tailwindcss": "^4", "typescript": "^5", "vitest": "^4.1.0", diff --git a/apps/dash/proxy.ts b/apps/dash/proxy.ts index 9cdd95e7..709f3c3f 100644 --- a/apps/dash/proxy.ts +++ b/apps/dash/proxy.ts @@ -2,6 +2,7 @@ import c from "@domus/config"; import { headers } from "next/headers"; import { type NextRequest, NextResponse } from "next/server"; import auth from "@/shared/auth/server"; +import { logger } from "@/shared/core/logger"; import { routing } from "@/shared/i18n/routing"; /** @@ -12,70 +13,94 @@ import { routing } from "@/shared/i18n/routing"; */ export default async function proxy(request: NextRequest) { const { pathname } = request.nextUrl; - - // 1. Get session - const session: typeof auth.$Infer.Session | null = await auth.api.getSession({ - headers: await headers(), + logger.info("proxy: start", { + method: request.method, + pathname, }); - // 2. Auth Redirection Logic - const isPublicPage = pathname.startsWith("/login"); - const isStatusPage = - pathname.startsWith("/pending") || pathname.startsWith("/rejected"); + try { + // 1. Get session + const session: typeof auth.$Infer.Session | null = + await auth.api.getSession({ + headers: await headers(), + }); - if (!session) { - // Unauthenticated user - if (!isPublicPage && !isStatusPage) { - const url = new URL("/login", request.url); - url.searchParams.set("callbackUrl", pathname); - return NextResponse.redirect(url); - } - } else { - if (!pathname.startsWith("/join")) { - // Authenticated user - const status = session.context.accountStatus; + // 2. Auth Redirection Logic + const isPublicPage = pathname.startsWith("/login"); + const isStatusPage = + pathname.startsWith("/pending") || pathname.startsWith("/rejected"); - if (status === "pending" && pathname !== "/pending") { - return NextResponse.redirect(new URL("/pending", request.url)); + if (!session) { + // Unauthenticated user + if (!isPublicPage && !isStatusPage) { + const url = new URL("/login", request.url); + url.searchParams.set("callbackUrl", pathname); + logger.warn("proxy: unauthenticated_redirect", { pathname }); + return NextResponse.redirect(url); } + } else { + logger.info("proxy: session_found", { + userId: session.user.id, + accountStatus: session.context.accountStatus, + }); - if (status === "rejected" && pathname !== "/rejected") { - return NextResponse.redirect(new URL("/rejected", request.url)); - } + if (!pathname.startsWith("/join")) { + // Authenticated user + const status = session.context.accountStatus; - if (status === "approved" && (isPublicPage || isStatusPage)) { - return NextResponse.redirect(new URL("/", request.url)); + if (status === "pending" && pathname !== "/pending") { + logger.warn("proxy: status_redirect", { status, pathname }); + return NextResponse.redirect(new URL("/pending", request.url)); + } + + if (status === "rejected" && pathname !== "/rejected") { + logger.warn("proxy: status_redirect", { status, pathname }); + return NextResponse.redirect(new URL("/rejected", request.url)); + } + + if (status === "approved" && (isPublicPage || isStatusPage)) { + logger.info("proxy: status_redirect", { status, pathname }); + return NextResponse.redirect(new URL("/", request.url)); + } } } - } - // 3. i18n Locale Handling (from original proxy.ts) foobar - const cookieName = c.app.locale.cookieName; - const cookieLocale = request.cookies.get(cookieName)?.value; + // 3. i18n Locale Handling (from original proxy.ts) foobar + const cookieName = c.app.locale.cookieName; + const cookieLocale = request.cookies.get(cookieName)?.value; - // Use cookie if available, otherwise default - const finalLocale = cookieLocale || routing.defaultLocale; + // Use cookie if available, otherwise default + const finalLocale = cookieLocale || routing.defaultLocale; - // 4. Create the response and set local headers - const requestHeaders = new Headers(request.headers); - requestHeaders.set("X-NEXT-INTL-LOCALE", finalLocale); + // 4. Create the response and set local headers + const requestHeaders = new Headers(request.headers); + requestHeaders.set("X-NEXT-INTL-LOCALE", finalLocale); - const response = NextResponse.next({ - request: { - headers: requestHeaders, - }, - }); - - // 5. Persist the locale in our custom cookie if it's missing or changed - if (cookieLocale !== finalLocale) { - response.cookies.set(cookieName, finalLocale, { - path: "/", - maxAge: 60 * 60 * 24 * 365, // 1 year - sameSite: "lax", + const response = NextResponse.next({ + request: { + headers: requestHeaders, + }, }); - } - return response; + // 5. Persist the locale in our custom cookie if it's missing or changed + if (cookieLocale !== finalLocale) { + logger.info("proxy: locale_persistence", { + old: cookieLocale, + final: finalLocale, + }); + response.cookies.set(cookieName, finalLocale, { + path: "/", + maxAge: 60 * 60 * 24 * 365, // 1 year + sameSite: "lax", + }); + } + + return response; + } catch (error) { + const message = error instanceof Error ? error.message : "Internal Error"; + logger.error("proxy: error", { message, pathname }); + throw error; + } } export const config = { diff --git a/apps/dash/seed/index.ts b/apps/dash/seed/index.ts index 45ebb605..950ee175 100644 --- a/apps/dash/seed/index.ts +++ b/apps/dash/seed/index.ts @@ -1,5 +1,5 @@ import c from "@domus/config"; -import { UserRole } from "@domus/core"; +import { AccountStatus, UserRole } from "@domus/core"; import { ensureUser } from "./helper/user"; import { seedOrganization } from "./org"; @@ -12,6 +12,7 @@ async function initSuperAdmin(_isDryRun: boolean) { email: c.initial.adminEmail, password: c.initial.adminPassword, role: [UserRole.SuperAdmin], + accountStatus: AccountStatus.Approved, }); } diff --git a/apps/dash/seed/patch-schema.ts b/apps/dash/seed/patch-schema.ts new file mode 100644 index 00000000..f9114210 --- /dev/null +++ b/apps/dash/seed/patch-schema.ts @@ -0,0 +1,136 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Patch script to convert all schema IDs in packages/db/src/schema from text to uuid. + * + * Usage: tsx apps/dash/seed/patch-schema.ts + */ + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const SCHEMA_DIR = path.resolve(__dirname, "../../../packages/db/src/schema"); + +function patchFile(filePath: string) { + let content = fs.readFileSync(filePath, "utf8"); + let modified = false; + + // 1. Patch primary keys and remove $defaultFn + // Pattern 1: uuid('id').primaryKey().$defaultFn(() => uuidv7()) + const pkUuidDefaultRegex = + /id:\s*uuid\(['"]id['"]\)\s*\.primaryKey\(\)\s*\.\$defaultFn\([^)]*\)/g; + if (pkUuidDefaultRegex.test(content)) { + content = content.replace( + pkUuidDefaultRegex, + 'id: uuid("id").primaryKey()', + ); + modified = true; + } + + // Pattern 2: text('id').primaryKey() + const pkTextRegex = /id:\s*text\(['"]id['"]\)\.primaryKey\(\)/g; + if (pkTextRegex.test(content)) { + content = content.replace(pkTextRegex, 'id: uuid("id").primaryKey()'); + modified = true; + } + + // 2. Patch foreign keys and related columns + const fkPatterns = [ + { + regex: /userId:\s*text\(['"]user_id['"]\)/g, + replacement: 'userId: uuid("user_id")', + }, + { + regex: /organizationId:\s*text\(['"]organization_id['"]\)/g, + replacement: 'organizationId: uuid("organization_id")', + }, + { + regex: /inviterId:\s*text\(['"]inviter_id['"]\)/g, + replacement: 'inviterId: uuid("inviter_id")', + }, + { + regex: /parentId:\s*text\(['"]parent_id['"]\)/g, + replacement: 'parentId: uuid("parent_id")', + }, + { + regex: /impersonatedBy:\s*text\(['"]impersonated_by['"]\)/g, + replacement: 'impersonatedBy: uuid("impersonated_by")', + }, + { + regex: /activeOrganizationId:\s*text\(['"]active_organization_id['"]\)/g, + replacement: 'activeOrganizationId: uuid("active_organization_id")', + }, + { + regex: /createdBy:\s*text\(['"]created_by['"]\)/g, + replacement: 'createdBy: uuid("created_by")', + }, + { + regex: /updatedBy:\s*text\(['"]updated_by['"]\)/g, + replacement: 'updatedBy: uuid("updated_by")', + }, + { + regex: /verifiedBy:\s*text\(['"]verified_by['"]\)/g, + replacement: 'verifiedBy: uuid("verified_by")', + }, + { + regex: /lockedBy:\s*text\(['"]locked_by['"]\)/g, + replacement: 'lockedBy: uuid("locked_by")', + }, + ]; + + for (const pattern of fkPatterns) { + if (pattern.regex.test(content)) { + content = content.replace(pattern.regex, pattern.replacement); + modified = true; + } + } + + if (modified) { + // 3. Update imports + // Ensure uuid is imported from drizzle-orm/pg-core + const pgCoreImportRegex = + /import\s*{\s*([^}]*)\s*}\s*from\s*['"]drizzle-orm\/pg-core['"];/; + const match = content.match(pgCoreImportRegex); + + if (match) { + const imports = match[1]; + const importList = imports + .split(",") + .map((i) => i.trim()) + .filter(Boolean); + + if (!importList.includes("uuid")) { + importList.push("uuid"); + importList.sort(); + const newImports = `import {\n ${importList.join(",\n ")},\n} from "drizzle-orm/pg-core";`; + content = content.replace(pgCoreImportRegex, newImports); + } + } + + // 4. Remove unused uuid package imports + content = content.replace( + /import\s*{\s*v7\s*as\s*uuidv7\s*}\s*from\s*['"]uuid['"];?\n?/g, + "", + ); + content = content.replace( + /import\s*{\s*v7\s*}\s*from\s*['"]uuid['"];?\n?/g, + "", + ); + + fs.writeFileSync(filePath, content, "utf8"); + console.log(`Patched: ${filePath}`); + } +} + +function run() { + const betterAuthSchema = path.join(SCHEMA_DIR, "better-auth.ts"); + if (fs.existsSync(betterAuthSchema)) { + patchFile(betterAuthSchema); + } else { + console.error(`Could not find better-auth schema at: ${betterAuthSchema}`); + } +} + +run(); diff --git a/apps/dash/src/app/api-routes/cron/rotate-join-id.ts b/apps/dash/src/api-routes/cron/rotate-join-id.ts similarity index 100% rename from apps/dash/src/app/api-routes/cron/rotate-join-id.ts rename to apps/dash/src/api-routes/cron/rotate-join-id.ts diff --git a/apps/dash/src/pages/auth/ui/LoginPage.tsx b/apps/dash/src/pages/auth/ui/LoginPage.tsx index 7210bd5f..8bece980 100644 --- a/apps/dash/src/pages/auth/ui/LoginPage.tsx +++ b/apps/dash/src/pages/auth/ui/LoginPage.tsx @@ -2,6 +2,7 @@ import c, { Environment } from "@domus/config"; import type { Metadata } from "next"; import { useTranslations } from "next-intl"; import { Card, CardContent } from "@/shared/ui/shadcn/card"; +import { Brand } from "@/widgets/layout/ui/components/Brand"; import { LoginForm } from "./components/LoginForm"; export const LoginMetadata: Metadata = { @@ -11,7 +12,7 @@ export const LoginMetadata: Metadata = { /** * Login page UI component. * - * Renders the "Sacred Dusk" branded login page template. + * Renders a warm, casual login page with glassmorphism effects. * Delegating authentication form logic to the LoginForm feature. * * @returns The LoginPage component. @@ -21,29 +22,33 @@ export function LoginPage() { const emailPasswordForm = c.app.env !== Environment.Production; return ( - <> - {/* LoginPage Start */} -
- - - {/* Branding Header */} -
-
-

- {t("heading")} -

-

- {t("subheading")} -

-
+
+ {/* Warm Soft Background Gradient */} +
+
+
+
+ + {/* Glassmorphism Card */} + + + {/* Branding Header */} +
+ +
+

+ {t("heading")} +

+

+ {t("subheading")} +

+
- {/* Main Form Area */} - -
-
-
- {/* LoginPage End */} - + {/* Main Form Area */} + + + +
); } diff --git a/apps/dash/src/pages/auth/ui/components/LoginForm.tsx b/apps/dash/src/pages/auth/ui/components/LoginForm.tsx index ad20e79d..69d8911e 100644 --- a/apps/dash/src/pages/auth/ui/components/LoginForm.tsx +++ b/apps/dash/src/pages/auth/ui/components/LoginForm.tsx @@ -76,10 +76,10 @@ export function LoginForm({ }; return ( -
+
{/* Error Message */} {error && ( -
+
{error}
)} @@ -91,12 +91,12 @@ export function LoginForm({ disabled={isLoading} suppressHydrationWarning className={cn( - "w-full flex items-center justify-center gap-3 py-6 px-4 bg-white border border-[#D4D8E0] rounded-lg hover:bg-surface-container-low transition-all duration-200 group active:scale-[0.98]", - "cursor-pointer shadow-sm", + "w-full flex items-center justify-center gap-2.5 py-5 bg-white/60 dark:bg-card/20 border-white/20 rounded-xl hover:bg-white/80 transition-all duration-300 group active:scale-[0.98]", + "cursor-pointer shadow-sm backdrop-blur-sm", )} > - + {t("googleButton")} @@ -105,19 +105,19 @@ export function LoginForm({ {emailPasswordForm && ( <> {/* Separator */} -
+
- +
- + {t("orSeparator")}
-
-
+ +
@@ -129,21 +129,21 @@ export function LoginForm({ required disabled={isLoading} suppressHydrationWarning - className="w-full px-4 py-2.5 bg-surface-container-low border-0 rounded-lg text-sm text-on-surface focus-visible:ring-1 focus-visible:ring-[#a33c29]/30 focus-visible:bg-surface-container-lowest transition-all duration-200 outline-none placeholder:text-outline/50" + className="w-full px-3.5 py-2 bg-white/20 dark:bg-card/20 border-transparent rounded-xl text-[13px] text-foreground focus-visible:ring-1 focus-visible:ring-accent/40 focus-visible:bg-white/30 transition-all duration-300 outline-none placeholder:text-muted-foreground/30" />
-
-
+
+
{t("forgotPassword")} @@ -156,7 +156,7 @@ export function LoginForm({ required disabled={isLoading} suppressHydrationWarning - className="w-full px-4 py-2.5 bg-surface-container-low border-0 rounded-lg text-sm text-on-surface focus-visible:ring-1 focus-visible:ring-[#a33c29]/30 focus-visible:bg-surface-container-lowest transition-all duration-200 outline-none placeholder:text-outline/50" + className="w-full px-3.5 py-2 bg-white/20 dark:bg-card/20 border-transparent rounded-xl text-[13px] text-foreground focus-visible:ring-1 focus-visible:ring-accent/40 focus-visible:bg-white/30 transition-all duration-300 outline-none placeholder:text-muted-foreground/30" />
@@ -164,7 +164,7 @@ export function LoginForm({ type="submit" disabled={isLoading} suppressHydrationWarning - className="w-full mt-4 py-6 bg-[#233345] hover:bg-[#233345]/90 text-white text-sm font-bold rounded-lg shadow-md active:scale-[0.98] transition-all duration-200" + className="w-full mt-1.5 py-5 bg-accent hover:bg-accent/90 text-accent-foreground text-[13px] font-black rounded-xl shadow-lg active:scale-[0.98] transition-all duration-300 uppercase tracking-widest" > {isLoading ? t("loading") : t("submitButton")} diff --git a/apps/dash/src/pages/home/actions/home.ts b/apps/dash/src/pages/home/actions/home.ts index 9bf18e49..4b037fd1 100644 --- a/apps/dash/src/pages/home/actions/home.ts +++ b/apps/dash/src/pages/home/actions/home.ts @@ -3,6 +3,7 @@ import type { RsvpStatus as CoreRsvpStatus, EventStatus, } from "@domus/core"; +import { logger } from "@/shared/core/logger"; import { event as eventService, parishioner as parishionerService, @@ -36,7 +37,6 @@ export interface ParishionerRequest { } import type { LucideIcon } from "lucide-react"; -import { DASHBOARD_ACTIONS } from "@/shared/config/actions"; /** A parish service shortcut card. */ export interface LayananItem { @@ -62,7 +62,9 @@ export async function getEvents(ctx?: AuthContext): Promise<{ success: boolean; data: AgendaItem[]; }> { + logger.info("getEvents: start", { userId: ctx?.userId }); if (!ctx) { + logger.warn("getEvents: unauthorized"); return { success: false, data: [] }; } @@ -77,6 +79,10 @@ export async function getEvents(ctx?: AuthContext): Promise<{ ); if (error) { + logger.error("getEvents: failed", { + code: error.code, + message: error.message, + }); return { success: false, data: [] }; } @@ -94,6 +100,7 @@ export async function getEvents(ctx?: AuthContext): Promise<{ eventPhoto: e.eventPhoto ?? FALLBACK_EVENT_PHOTO, })); + logger.info("getEvents: succeeded", { count: data.length }); return { success: true, data }; } @@ -102,7 +109,9 @@ export async function getPendingParishioners(ctx?: AuthContext): Promise<{ success: boolean; data: ParishionerRequest[]; }> { + logger.info("getPendingParishioners: start", { userId: ctx?.userId }); if (!ctx) { + logger.warn("getPendingParishioners: unauthorized"); return { success: true, data: [] }; } @@ -110,6 +119,10 @@ export async function getPendingParishioners(ctx?: AuthContext): Promise<{ await parishionerService.getPendingVerificationRequests(ctx, 10); if (error) { + logger.error("getPendingParishioners: failed", { + code: error.code, + message: error.message, + }); return { success: false, data: [] }; } @@ -121,5 +134,6 @@ export async function getPendingParishioners(ctx?: AuthContext): Promise<{ avatarUrl: r.avatarUrl ?? FALLBACK_AVATAR, })); + logger.info("getPendingParishioners: succeeded", { count: data.length }); return { success: true, data }; } diff --git a/apps/dash/src/pages/home/ui/components/HomeContent.tsx b/apps/dash/src/pages/home/ui/components/HomeContent.tsx index ed131c54..b4dd7bef 100644 --- a/apps/dash/src/pages/home/ui/components/HomeContent.tsx +++ b/apps/dash/src/pages/home/ui/components/HomeContent.tsx @@ -23,7 +23,7 @@ export function HomeContent({ parishionerRequests, }: HomeContentProps): ReactNode { const t = useTranslations("HomePage"); - const [activeTab, setActiveTab] = useState("agenda"); + const [activeTab, setActiveTab] = useState("layanan"); const HOME_NAV_ITEMS = [ { id: "agenda" as TabId, label: t("agendaTab"), icon: Calendar, badge: 10 }, @@ -42,7 +42,7 @@ export function HomeContent({ {activeTab === "agenda" && } {activeTab === "tugas" && } {activeTab === "layanan" && ( - + )}
diff --git a/apps/dash/src/pages/org/actions/create.ts b/apps/dash/src/pages/org/actions/create.ts index 5374bf3c..86c8f3e6 100644 --- a/apps/dash/src/pages/org/actions/create.ts +++ b/apps/dash/src/pages/org/actions/create.ts @@ -4,6 +4,7 @@ import { OrgType, UserRole } from "@domus/core"; import { headers } from "next/headers"; import auth, { getSession } from "@/shared/auth/server"; import { organization as organizationService } from "@/shared/core"; +import { logger } from "@/shared/core/logger"; import type { ActionResult } from "@/shared/types"; import { actionFail, actionOk } from "@/shared/types"; @@ -18,9 +19,14 @@ export async function createAction( data: CreateOrganization, ): Promise> { const session = await getSession(await headers()); + logger.info("createAction: start", { + name: data.name, + type: data.type, + userId: session?.user?.id, + }); if (!session?.context) { - console.error("[createOrganizationAction] No session context found"); + logger.warn("createAction: unauthorized"); return actionFail( "Anda harus login untuk melakukan aksi ini.", "UNAUTHORIZED", @@ -33,6 +39,7 @@ export async function createAction( !userRoles.includes(UserRole.ParishAdmin) && !userRoles.includes(UserRole.SuperAdmin) ) { + logger.warn("createAction: forbidden", { userId: session.user.id }); return actionFail( "Anda tidak memiliki izin untuk membuat organisasi.", "FORBIDDEN", @@ -44,6 +51,10 @@ export async function createAction( const [_parentValid, parentError] = await organizationService.validateParent(data.type, data.parentId); if (parentError) { + logger.warn("createAction: validation_failed", { + reason: "parent_invalid", + parentId: data.parentId, + }); return actionFail( parentError.message || "Validasi parent gagal.", "BAD_REQUEST", @@ -54,6 +65,10 @@ export async function createAction( const [_depthValid, depthError] = await organizationService.validateCategoricalDepth(data.parentId); if (depthError) { + logger.warn("createAction: validation_failed", { + reason: "depth_exceeded", + parentId: data.parentId, + }); return actionFail( depthError.message || "Batas kedalaman Kategorial terlampaui.", "BAD_REQUEST", @@ -77,31 +92,27 @@ export async function createAction( "", )}-${Math.random().toString(36).substring(2, 7)}`, logo: data.logo || undefined, - // Additional fields sanitized for Zod validation type: data.type, parentId: data.parentId ?? undefined, cover: data.cover ?? undefined, description: data.description ?? undefined, }, }); - - console.log( - "[createOrganizationAction] Better Auth response:", - JSON.stringify(org, null, 2), - ); - if (!org) { + logger.error("createAction: failed", { + message: "Better Auth returned null", + }); return actionFail( "Gagal membuat organisasi melalui Better Auth.", "INTERNAL_ERROR", ); } - + logger.info("createAction: succeeded", { id: org.id }); return actionOk(org as unknown as Organization); } catch (error: unknown) { - console.error("[createOrganizationAction] Error:", error); const message = error instanceof Error ? error.message : "Terjadi kesalahan."; + logger.error("createAction: error", { message }); return actionFail(message, "ERROR"); } } diff --git a/apps/dash/src/pages/org/actions/delete.ts b/apps/dash/src/pages/org/actions/delete.ts index 2dea68a8..e25d2724 100644 --- a/apps/dash/src/pages/org/actions/delete.ts +++ b/apps/dash/src/pages/org/actions/delete.ts @@ -3,6 +3,7 @@ import { revalidatePath } from "next/cache"; import { headers } from "next/headers"; import { getSession } from "@/shared/auth/server"; import { organization as organizationService } from "@/shared/core"; +import { logger } from "@/shared/core/logger"; import type { ActionResult } from "@/shared/types"; import { actionFail, actionOk } from "@/shared/types"; @@ -15,9 +16,10 @@ import { actionFail, actionOk } from "@/shared/types"; */ export async function removeAction(id: string): Promise> { const session = await getSession(await headers()); + logger.info("removeAction: start", { id, userId: session?.user?.id }); if (!session?.context) { - console.error("[deleteOrganizationAction] No session context found"); + logger.warn("removeAction: unauthorized"); return actionFail( "Anda harus login untuk melakukan aksi ini.", "UNAUTHORIZED", @@ -28,7 +30,11 @@ export async function removeAction(id: string): Promise> { const [, error] = await organizationService.delete(id, session.context); if (error) { - console.error("[deleteOrganizationAction] Service Error:", error); + logger.error("removeAction: failed", { + id, + code: error.code, + message: error.message, + }); return actionFail( error.message || "Anda tidak memiliki izin untuk menghapus organisasi ini.", @@ -37,12 +43,14 @@ export async function removeAction(id: string): Promise> { } revalidatePath("/org"); + revalidatePath("/", "layout"); + logger.info("removeAction: succeeded", { id }); return actionOk(undefined); } catch (error: unknown) { - console.error("[deleteOrganizationAction] Error:", error); const message = error instanceof Error ? error.message : "Terjadi kesalahan."; + logger.error("removeAction: error", { id, message }); return actionFail(message, "ERROR"); } } diff --git a/apps/dash/src/pages/org/actions/join-data.ts b/apps/dash/src/pages/org/actions/join-data.ts index a2964018..3159c064 100644 --- a/apps/dash/src/pages/org/actions/join-data.ts +++ b/apps/dash/src/pages/org/actions/join-data.ts @@ -2,6 +2,7 @@ import type { Organization, Result, Unit } from "@domus/core"; import { CoreError, fail, NotFoundError, ok } from "@domus/core"; +import { logger } from "@/shared/core/logger"; import { organization as orgService, unit as unitService, @@ -16,13 +17,19 @@ export async function getJoinDataAction(joinId: string): Promise< units: Unit[]; }> > { + logger.info("getJoinDataAction: start", { joinId }); try { // 1. Find organization by joinId const [orgData, orgError] = await orgService.findByJoinId(joinId); if (orgError) { if (orgError instanceof NotFoundError) { + logger.warn("getJoinDataAction: organization_not_found", { joinId }); return fail(new NotFoundError("Organisasi")); } + logger.error("getJoinDataAction: org_error", { + joinId, + code: orgError.code, + }); return fail(orgError); } @@ -31,15 +38,21 @@ export async function getJoinDataAction(joinId: string): Promise< orgData.id, ); if (unitError) { + logger.error("getJoinDataAction: unit_error", { + orgId: orgData.id, + code: unitError.code, + }); return fail(unitError); } + logger.info("getJoinDataAction: succeeded", { orgId: orgData.id }); return ok({ organization: orgData, units: units || [], }); } catch (error) { - console.error("[getJoinDataAction]", error); + const message = error instanceof Error ? error.message : "Internal Error"; + logger.error("getJoinDataAction: error", { message }); return fail( new CoreError( "INTERNAL_ERROR", diff --git a/apps/dash/src/pages/org/actions/join-mock.ts b/apps/dash/src/pages/org/actions/join-mock.ts index 5debc424..d55d291f 100644 --- a/apps/dash/src/pages/org/actions/join-mock.ts +++ b/apps/dash/src/pages/org/actions/join-mock.ts @@ -1,6 +1,7 @@ "use server"; import type { JoinForm } from "@domus/core"; +import { logger } from "@/shared/core/logger"; import type { ActionResult } from "@/shared/types"; import { actionOk } from "@/shared/types"; @@ -14,15 +15,13 @@ import { actionOk } from "@/shared/types"; */ export async function joinMockAction( joinId: string, - data: JoinForm, + _data: JoinForm, ): Promise> { + logger.info("joinMockAction: start", { joinId }); // Simulate network delay await new Promise((resolve) => setTimeout(resolve, 1200)); - console.log( - `[Mock Action] Join request received for joinId: ${joinId}`, - data, - ); + logger.info("joinMockAction: succeeded", { joinId }); // Return success result return actionOk(undefined); diff --git a/apps/dash/src/pages/org/actions/join.ts b/apps/dash/src/pages/org/actions/join.ts index b4c25837..136f0236 100644 --- a/apps/dash/src/pages/org/actions/join.ts +++ b/apps/dash/src/pages/org/actions/join.ts @@ -4,6 +4,7 @@ import type { JoinForm } from "@domus/core"; import { headers } from "next/headers"; import { getSession } from "@/shared/auth/server"; import { enrollment } from "@/shared/core"; +import { logger } from "@/shared/core/logger"; import { type ActionResult, actionFail, actionOk } from "@/shared/types"; /** @@ -18,8 +19,10 @@ export async function joinAction( data: JoinForm, ): Promise> { const session = await getSession(await headers()); + logger.info("joinAction: start", { joinId, userId: session?.user?.id }); if (!session) { + logger.warn("joinAction: unauthorized"); return actionFail("Anda harus masuk terlebih dahulu.", "UNAUTHORIZED"); } @@ -30,8 +33,14 @@ export async function joinAction( ); if (error) { + logger.error("joinAction: failed", { + joinId, + code: error.code, + message: error.message, + }); return actionFail(error.message, error.code); } + logger.info("joinAction: succeeded", { joinId, isNew: result?.isNew }); return actionOk({ isNew: result?.isNew }); } diff --git a/apps/dash/src/pages/org/actions/list-parent.ts b/apps/dash/src/pages/org/actions/list-parent.ts index a72b1870..0c259f99 100644 --- a/apps/dash/src/pages/org/actions/list-parent.ts +++ b/apps/dash/src/pages/org/actions/list-parent.ts @@ -2,6 +2,7 @@ import type { Organization, OrgType } from "@domus/core"; import { organization as organizationService } from "@/shared/core"; +import { logger } from "@/shared/core/logger"; import type { ActionResult } from "@/shared/types"; /** @@ -12,6 +13,7 @@ export async function listParentAction( type: OrgType, currentId?: string | null, ): Promise> { + logger.info("listParentAction: start", { type, currentId }); try { const [result, error] = await organizationService.listForType( type, @@ -19,6 +21,10 @@ export async function listParentAction( ); if (error) { + logger.error("listParentAction: failed", { + type, + message: error.message, + }); return { success: false, code: "INTERNAL_SERVER_ERROR", @@ -26,16 +32,19 @@ export async function listParentAction( }; } + logger.info("listParentAction: succeeded", { count: result?.length ?? 0 }); return { success: true, data: result ?? [], }; } catch (error) { - console.error("Failed to get parent organizations:", error); + const message = + error instanceof Error ? error.message : "Internal Server Error"; + logger.error("listParentAction: error", { type, message }); return { success: false, code: "INTERNAL_SERVER_ERROR", - message: error instanceof Error ? error.message : "Internal Server Error", + message, }; } } diff --git a/apps/dash/src/pages/org/actions/list.ts b/apps/dash/src/pages/org/actions/list.ts index 73b6f5c2..431a4df3 100644 --- a/apps/dash/src/pages/org/actions/list.ts +++ b/apps/dash/src/pages/org/actions/list.ts @@ -1,5 +1,6 @@ import type { AuthContext, Organization } from "@domus/core"; import { organization as organizationService } from "@/shared/core"; +import { logger } from "@/shared/core/logger"; import type { ActionResult } from "@/shared/types"; /** Organization with virtual member count and location for the UI. */ @@ -16,8 +17,10 @@ export interface OrganizationWithCount extends Organization { export async function listAction( ctx: AuthContext, ): Promise> { + logger.info("listAction: start", { userId: ctx?.userId }); try { if (!ctx) { + logger.warn("listAction: unauthorized"); return { success: false, code: "UNAUTHORIZED", @@ -28,6 +31,10 @@ export async function listAction( const [result, error] = await organizationService.getUserOrganizations(ctx); if (error) { + logger.error("listAction: failed", { + code: "INTERNAL_SERVER_ERROR", + message: error.message, + }); return { success: false, code: "INTERNAL_SERVER_ERROR", @@ -35,16 +42,19 @@ export async function listAction( }; } + logger.info("listAction: succeeded", { count: result?.length ?? 0 }); return { success: true, data: (result ?? []) as OrganizationWithCount[], }; } catch (error) { - console.error("Failed to get organizations:", error); + const message = + error instanceof Error ? error.message : "Internal Server Error"; + logger.error("listAction: error", { message }); return { success: false, code: "INTERNAL_SERVER_ERROR", - message: error instanceof Error ? error.message : "Internal Server Error", + message, }; } } diff --git a/apps/dash/src/pages/org/actions/unit-create.ts b/apps/dash/src/pages/org/actions/unit-create.ts index e4e65668..4f7071d6 100644 --- a/apps/dash/src/pages/org/actions/unit-create.ts +++ b/apps/dash/src/pages/org/actions/unit-create.ts @@ -4,6 +4,7 @@ import type { CreateUnit, Unit } from "@domus/core"; import { headers } from "next/headers"; import { getSession } from "@/shared/auth/server"; import { unit as unitService } from "@/shared/core"; +import { logger } from "@/shared/core/logger"; import type { ActionResult } from "@/shared/types"; import { actionFail, actionOk } from "@/shared/types"; @@ -17,8 +18,14 @@ export async function createUnitAction( data: CreateUnit, ): Promise> { const session = await getSession(await headers()); + logger.info("createUnitAction: start", { + organizationId: data.organizationId, + name: data.name, + userId: session?.user?.id, + }); if (!session?.context) { + logger.warn("createUnitAction: unauthorized"); return actionFail( "Anda harus login untuk melakukan aksi ini.", "UNAUTHORIZED", @@ -28,11 +35,17 @@ export async function createUnitAction( const [res, error] = await unitService.create(data, session.context); if (error) { + logger.error("createUnitAction: failed", { + organizationId: data.organizationId, + code: error.code, + message: error.message, + }); return actionFail( error.message || "Gagal membuat unit.", error.code || "ERROR", ); } + logger.info("createUnitAction: succeeded", { id: res.id }); return actionOk(res); } diff --git a/apps/dash/src/pages/org/actions/unit-delete.ts b/apps/dash/src/pages/org/actions/unit-delete.ts index 46672ce9..5b6c3314 100644 --- a/apps/dash/src/pages/org/actions/unit-delete.ts +++ b/apps/dash/src/pages/org/actions/unit-delete.ts @@ -3,6 +3,7 @@ import { headers } from "next/headers"; import { getSession } from "@/shared/auth/server"; import { unit as unitService } from "@/shared/core"; +import { logger } from "@/shared/core/logger"; import type { ActionResult } from "@/shared/types"; import { actionFail, actionOk } from "@/shared/types"; @@ -16,8 +17,10 @@ export async function deleteUnitAction( id: string, ): Promise> { const session = await getSession(await headers()); + logger.info("deleteUnitAction: start", { id, userId: session?.user?.id }); if (!session?.context) { + logger.warn("deleteUnitAction: unauthorized"); return actionFail( "Anda harus login untuk melakukan aksi ini.", "UNAUTHORIZED", @@ -27,11 +30,17 @@ export async function deleteUnitAction( const [_, error] = await unitService.delete(id, session.context); if (error) { + logger.error("deleteUnitAction: failed", { + id, + code: error.code, + message: error.message, + }); return actionFail( error.message || "Gagal menghapus unit.", error.code || "ERROR", ); } + logger.info("deleteUnitAction: succeeded", { id }); return actionOk(undefined); } diff --git a/apps/dash/src/pages/org/actions/unit-reorder.ts b/apps/dash/src/pages/org/actions/unit-reorder.ts index b993ff6c..9a477e7b 100644 --- a/apps/dash/src/pages/org/actions/unit-reorder.ts +++ b/apps/dash/src/pages/org/actions/unit-reorder.ts @@ -4,6 +4,7 @@ import type { ReorderUnits } from "@domus/core"; import { headers } from "next/headers"; import { getSession } from "@/shared/auth/server"; import { unit as unitService } from "@/shared/core"; +import { logger } from "@/shared/core/logger"; import type { ActionResult } from "@/shared/types"; import { actionFail, actionOk } from "@/shared/types"; @@ -19,8 +20,13 @@ export async function reorderUnitsAction( data: ReorderUnits, ): Promise> { const session = await getSession(await headers()); + logger.info("reorderUnitsAction: start", { + organizationId, + userId: session?.user?.id, + }); if (!session?.context) { + logger.warn("reorderUnitsAction: unauthorized"); return actionFail( "Anda harus login untuk melakukan aksi ini.", "UNAUTHORIZED", @@ -34,11 +40,17 @@ export async function reorderUnitsAction( ); if (error) { + logger.error("reorderUnitsAction: failed", { + organizationId, + code: error.code, + message: error.message, + }); return actionFail( error.message || "Gagal mengatur ulang urutan unit.", error.code || "ERROR", ); } + logger.info("reorderUnitsAction: succeeded", { organizationId }); return actionOk(undefined); } diff --git a/apps/dash/src/pages/org/actions/unit-update.ts b/apps/dash/src/pages/org/actions/unit-update.ts index bf6390d1..62443193 100644 --- a/apps/dash/src/pages/org/actions/unit-update.ts +++ b/apps/dash/src/pages/org/actions/unit-update.ts @@ -4,6 +4,7 @@ import type { Unit, UpdateUnit } from "@domus/core"; import { headers } from "next/headers"; import { getSession } from "@/shared/auth/server"; import { unit as unitService } from "@/shared/core"; +import { logger } from "@/shared/core/logger"; import type { ActionResult } from "@/shared/types"; import { actionFail, actionOk } from "@/shared/types"; @@ -19,8 +20,10 @@ export async function updateUnitAction( data: UpdateUnit, ): Promise> { const session = await getSession(await headers()); + logger.info("updateUnitAction: start", { id, userId: session?.user?.id }); if (!session?.context) { + logger.warn("updateUnitAction: unauthorized"); return actionFail( "Anda harus login untuk melakukan aksi ini.", "UNAUTHORIZED", @@ -30,11 +33,17 @@ export async function updateUnitAction( const [res, error] = await unitService.update(id, data, session.context); if (error) { + logger.error("updateUnitAction: failed", { + id, + code: error.code, + message: error.message, + }); return actionFail( error.message || "Gagal memperbarui unit.", error.code || "ERROR", ); } + logger.info("updateUnitAction: succeeded", { id }); return actionOk(res); } diff --git a/apps/dash/src/pages/org/actions/update-organization.ts b/apps/dash/src/pages/org/actions/update-organization.ts index ddd66b4d..ade0554a 100644 --- a/apps/dash/src/pages/org/actions/update-organization.ts +++ b/apps/dash/src/pages/org/actions/update-organization.ts @@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache"; import { headers } from "next/headers"; import { getSession } from "@/shared/auth/server"; import { organization as organizationService } from "@/shared/core"; +import { logger } from "@/shared/core/logger"; import type { ActionResult } from "@/shared/types"; import { actionFail, actionOk } from "@/shared/types"; @@ -20,9 +21,13 @@ export async function updateOrganizationAction( data: UpdateOrganization, ): Promise> { const session = await getSession(await headers()); + logger.info("updateOrganizationAction: start", { + id, + userId: session?.user?.id, + }); if (!session?.context) { - console.error("[updateOrganizationAction] No session context found"); + logger.warn("updateOrganizationAction: unauthorized"); return actionFail( "Anda harus login untuk melakukan aksi ini.", "UNAUTHORIZED", @@ -37,7 +42,11 @@ export async function updateOrganizationAction( ); if (error) { - console.error("[updateOrganizationAction] Service Error:", error); + logger.error("updateOrganizationAction: failed", { + id, + code: error.name === "ValidationError" ? "BAD_REQUEST" : "FORBIDDEN", + message: error.message, + }); return actionFail( error.message || "Gagal mengupdate organisasi.", error.name === "ValidationError" ? "BAD_REQUEST" : "FORBIDDEN", @@ -45,6 +54,10 @@ export async function updateOrganizationAction( } if (!org) { + logger.error("updateOrganizationAction: failed", { + id, + message: "Service returned null", + }); return actionFail("Gagal mengupdate organisasi.", "INTERNAL_ERROR"); } @@ -53,11 +66,12 @@ export async function updateOrganizationAction( revalidatePath(`/org/${id}/update`); revalidatePath("/org"); + logger.info("updateOrganizationAction: succeeded", { id }); return actionOk(org); } catch (error: unknown) { - console.error("[updateOrganizationAction] Error:", error); const message = error instanceof Error ? error.message : "Terjadi kesalahan."; + logger.error("updateOrganizationAction: error", { id, message }); return actionFail(message, "ERROR"); } } diff --git a/apps/dash/src/pages/org/actions/upload-org-image.ts b/apps/dash/src/pages/org/actions/upload-org-image.ts new file mode 100644 index 00000000..8a2165c0 --- /dev/null +++ b/apps/dash/src/pages/org/actions/upload-org-image.ts @@ -0,0 +1,78 @@ +"use server"; + +import { headers } from "next/headers"; +import { getSession } from "@/shared/auth/server"; +import { logger } from "@/shared/core/logger"; +import { publicStorage } from "@/shared/core/storage"; +import type { ActionResult } from "@/shared/types"; +import { actionFail, actionOk } from "@/shared/types"; + +/** + * Uploads an organization image (logo or cover) to public storage. + * Only authenticated users can perform this. + * + * @param orgId - The ID of the organization. + * @param type - Whether this is a "cover" or "logo". + * @param formData - The FormData containing the `file`. + * @returns `ActionResult` with the public URL. + */ +export async function uploadOrgImageAction( + orgId: string, + type: "cover" | "logo", + formData: FormData, +): Promise> { + const session = await getSession(await headers()); + logger.info("uploadOrgImageAction: start", { + userId: session?.user?.id, + orgId, + type, + }); + + if (!session?.context) { + logger.warn("uploadOrgImageAction: unauthorized"); + return actionFail( + "Anda harus login untuk melakukan aksi ini.", + "UNAUTHORIZED", + ); + } + + const file = formData.get("file") as File | null; + if (!file) { + return actionFail("File tidak ditemukan", "BAD_REQUEST"); + } + + if (file.size > 5 * 1024 * 1024) { + return actionFail( + "Ukuran file terlalu besar (maksimal 5MB)", + "BAD_REQUEST", + ); + } + + const allowedTypes = ["image/jpeg", "image/png", "image/webp"]; + if (!allowedTypes.includes(file.type)) { + return actionFail( + "Format file tidak didukung (hanya JPG, PNG, WEBP)", + "BAD_REQUEST", + ); + } + + try { + const buffer = Buffer.from(await file.arrayBuffer()); + const ext = file.name.split(".").pop()?.toLowerCase() || "jpg"; + const fileName = `${type}.${ext}`; + const path = `organizations/${orgId}/${fileName}`; + + let url = await publicStorage.upload(buffer, path); + + // Add cache buster to force browser refresh if replacing existing image + url = `${url}?v=${Date.now()}`; + + logger.info("uploadOrgImageAction: succeeded", { url }); + return actionOk(url); + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "Terjadi kesalahan"; + logger.error("uploadOrgImageAction: error", { message }); + return actionFail("Gagal mengunggah gambar", "INTERNAL_ERROR"); + } +} diff --git a/apps/dash/src/pages/org/ui/OrgDetailPage.tsx b/apps/dash/src/pages/org/ui/OrgDetailPage.tsx index 580a3f04..89d02af6 100644 --- a/apps/dash/src/pages/org/ui/OrgDetailPage.tsx +++ b/apps/dash/src/pages/org/ui/OrgDetailPage.tsx @@ -1,12 +1,15 @@ -import { Settings } from "lucide-react"; +import { UserRole } from "@domus/core"; +import { headers } from "next/headers"; import Link from "next/link"; import { notFound, redirect } from "next/navigation"; +import { getSession } from "@/shared/auth/server"; import { organization as organizationService, unit as unitService, } from "@/shared/core"; import { Button } from "@/shared/ui/shadcn/button"; import { Card, CardContent } from "@/shared/ui/shadcn/card"; +import { OrgHeader } from "./components/OrgHeader"; import { UnitList } from "./components/UnitList"; interface OrgDetailPageProps { @@ -32,6 +35,11 @@ export async function OrgDetailPage({ params }: OrgDetailPageProps) { notFound(); } + const session = await getSession(await headers()); + const canRemove = + session?.context?.roles.includes(UserRole.SuperAdmin) || + session?.context?.roles.includes(UserRole.ParishAdmin); + // Server action to remove the organization const handleRemove = async () => { "use server"; @@ -56,30 +64,7 @@ export async function OrgDetailPage({ params }: OrgDetailPageProps) { {org.name}
- {/* Header Section */} -
-
-

- {org.name} -

-

- {org.description || "Tidak ada deskripsi untuk organisasi ini."} -

-
- -
- - - -
-
+
{/* Main Content: Unit Management */} @@ -133,22 +118,24 @@ export async function OrgDetailPage({ params }: OrgDetailPageProps) {
-
-

- Tindakan Berbahaya -

- - - -
+ {canRemove && ( +
+

+ Tindakan Berbahaya +

+
+ +
+
+ )}
diff --git a/apps/dash/src/pages/org/ui/components/JoinForm.tsx b/apps/dash/src/pages/org/ui/components/JoinForm.tsx index 69714334..76ba09fa 100644 --- a/apps/dash/src/pages/org/ui/components/JoinForm.tsx +++ b/apps/dash/src/pages/org/ui/components/JoinForm.tsx @@ -49,13 +49,13 @@ import { * Labels for Indonesian UI translation. */ const EducationLevelLabels: Record = { - sd: "SD / Sederajat", - smp: "SMP / Sederajat", - sma: "SMA / SMK / Sederajat", - d3: "Diploma (D3)", - s1: "Sarjana (S1)", - s2: "Magister (S2)", - s3: "Doktor (S3)", + primary: "SD / Sederajat", + junior: "SMP / Sederajat", + senior: "SMA / SMK / Sederajat", + diploma: "Diploma (D3)", + bachelor: "Sarjana (S1)", + master: "Magister (S2)", + doctorate: "Doktor (S3)", }; const GenderLabels: Record = { @@ -66,7 +66,7 @@ const GenderLabels: Record = { /** * Honorific suggestions for registration. */ -const HONORIFIC_SUGGESTIONS = ["Dr.", "Prof.", "dr.", "Sr.", "RD", "RP"]; +const HONORIFIC_SUGGESTIONS = ["Dr", "Prof", "dr", "Sr", "RD", "RP"]; interface JoinFormProps { /** diff --git a/apps/dash/src/pages/org/ui/components/OrgHeader.tsx b/apps/dash/src/pages/org/ui/components/OrgHeader.tsx new file mode 100644 index 00000000..c305ebf7 --- /dev/null +++ b/apps/dash/src/pages/org/ui/components/OrgHeader.tsx @@ -0,0 +1,536 @@ +"use client"; + +import type { Organization } from "@domus/core"; +import { Check, Edit2, ImageIcon, Settings, X } from "lucide-react"; +import Image from "next/image"; +import Link from "next/link"; +import { useRef, useState } from "react"; +import { toast } from "sonner"; +import { useHasOrgPermission } from "@/shared/auth/client"; +import { Button } from "@/shared/ui/shadcn/button"; +import { Input } from "@/shared/ui/shadcn/input"; +import { Textarea } from "@/shared/ui/shadcn/textarea"; +import { compressImage } from "@/shared/utils/image-compression"; +import { updateOrganizationAction } from "../../actions/update-organization"; +import { uploadOrgImageAction } from "../../actions/upload-org-image"; + +export function OrgHeader({ org }: { org: Organization }) { + const [isEditingName, setIsEditingName] = useState(false); + const [name, setName] = useState(org.name); + const [isSavingName, setIsSavingName] = useState(false); + + const [isEditingDesc, setIsEditingDesc] = useState(false); + const [desc, setDesc] = useState(org.description || ""); + const [isSavingDesc, setIsSavingDesc] = useState(false); + + const [_isEditingCover, setIsEditingCover] = useState(false); + const [cover, setCover] = useState(org.cover || ""); + const [isSavingCover, setIsSavingCover] = useState(false); + + const [_isEditingLogo, setIsEditingLogo] = useState(false); + const [logo, setLogo] = useState(org.logo || ""); + const [isSavingLogo, setIsSavingLogo] = useState(false); + + const canEdit = useHasOrgPermission("organization", "update"); + + // Upload progress states + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadType, setUploadType] = useState<"cover" | "logo" | null>(null); + + const coverInputRef = useRef(null); + const logoInputRef = useRef(null); + + /** + * Simulates a realistic upload progress. + * Faster at first, then slows down as it approaches 90%. + */ + const simulateProgress = (onFinish: () => void) => { + setUploadProgress(0); + setIsUploading(true); + + const interval = setInterval(() => { + setUploadProgress((prev) => { + if (prev >= 90) { + clearInterval(interval); + return 90; + } + // Diminishing returns: the higher the progress, the smaller the jump + const jump = Math.max(0.5, (95 - prev) / 10); + return prev + jump; + }); + }, 200); + + return () => { + clearInterval(interval); + setUploadProgress(100); + setTimeout(() => { + setIsUploading(false); + setUploadProgress(0); + onFinish(); + }, 400); // Wait for transition + }; + }; + + const handleSaveName = async () => { + if (name.trim() === org.name) { + setIsEditingName(false); + return; + } + setIsSavingName(true); + const result = await updateOrganizationAction(org.id, { + name: name.trim(), + }); + setIsSavingName(false); + if (result.success) { + toast.success("Nama organisasi diperbarui"); + setIsEditingName(false); + } else { + toast.error(result.message || "Gagal memperbarui"); + setName(org.name); + } + }; + + const handleSaveDesc = async () => { + const trimmed = desc.trim(); + if (trimmed === (org.description || "")) { + setIsEditingDesc(false); + return; + } + setIsSavingDesc(true); + const result = await updateOrganizationAction(org.id, { + description: trimmed || null, + }); + setIsSavingDesc(false); + if (result.success) { + toast.success("Deskripsi organisasi diperbarui"); + setIsEditingDesc(false); + } else { + toast.error(result.message || "Gagal memperbarui"); + setDesc(org.description || ""); + } + }; + + const _handleSaveCover = async () => { + const trimmed = cover.trim(); + if (trimmed === (org.cover || "")) { + setIsEditingCover(false); + return; + } + setIsSavingCover(true); + const result = await updateOrganizationAction(org.id, { + cover: trimmed || null, + }); + setIsSavingCover(false); + if (result.success) { + toast.success("Cover organisasi diperbarui"); + setIsEditingCover(false); + } else { + toast.error(result.message || "Gagal memperbarui"); + setCover(org.cover || ""); + } + }; + + const _handleSaveLogo = async () => { + const trimmed = logo.trim(); + if (trimmed === (org.logo || "")) { + setIsEditingLogo(false); + return; + } + setIsSavingLogo(true); + const result = await updateOrganizationAction(org.id, { + logo: trimmed || null, + }); + setIsSavingLogo(false); + if (result.success) { + toast.success("Logo organisasi diperbarui"); + setIsEditingLogo(false); + } else { + toast.error(result.message || "Gagal memperbarui"); + setLogo(org.logo || ""); + } + }; + + const handleUploadCover = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setIsSavingCover(true); + setUploadType("cover"); + const finishProgress = simulateProgress(() => { + setIsSavingCover(false); + setUploadType(null); + }); + + try { + // Compress image using shared utility + const compressedFile = await compressImage(file, { preset: "org-cover" }); + + const formData = new FormData(); + formData.append("file", compressedFile); + + const uploadResult = await uploadOrgImageAction( + org.id, + "cover", + formData, + ); + if (!uploadResult.success) { + toast.error(uploadResult.message || "Gagal mengunggah cover"); + finishProgress(); + return; + } + + const newUrl = uploadResult.data; + if (!newUrl) { + finishProgress(); + return; + } + + const updateResult = await updateOrganizationAction(org.id, { + cover: newUrl, + }); + finishProgress(); + if (updateResult.success) { + toast.success("Cover organisasi diperbarui"); + setCover(newUrl); + setIsEditingCover(false); + } else { + toast.error(updateResult.message || "Gagal menyimpan cover"); + } + } catch (error) { + console.error("Compression error:", error); + toast.error("Gagal mengompres gambar"); + finishProgress(); + } + }; + + const handleUploadLogo = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setIsSavingLogo(true); + setUploadType("logo"); + const finishProgress = simulateProgress(() => { + setIsSavingLogo(false); + setUploadType(null); + }); + + try { + // Compress image using shared utility + const compressedFile = await compressImage(file, { preset: "org-logo" }); + + const formData = new FormData(); + formData.append("file", compressedFile); + + const uploadResult = await uploadOrgImageAction(org.id, "logo", formData); + if (!uploadResult.success) { + toast.error(uploadResult.message || "Gagal mengunggah logo"); + finishProgress(); + return; + } + + const newUrl = uploadResult.data; + if (!newUrl) { + finishProgress(); + return; + } + + const updateResult = await updateOrganizationAction(org.id, { + logo: newUrl, + }); + finishProgress(); + if (updateResult.success) { + toast.success("Logo organisasi diperbarui"); + setLogo(newUrl); + setIsEditingLogo(false); + } else { + toast.error(updateResult.message || "Gagal menyimpan logo"); + } + } catch (error) { + console.error("Compression error:", error); + toast.error("Gagal mengompres logo"); + finishProgress(); + } + }; + + return ( +
+ {/* Cover Background */} +
+ {org.cover ? ( + Cover + ) : ( +
+ )} +
+
+ + {/* Cover Progress Bar */} + {isUploading && uploadType === "cover" && ( +
+
+
+
+
+
+ + Uploading Cover {Math.round(uploadProgress)}% + +
+
+ )} + + {/* Edit Cover Overlay */} + + {canEdit && ( + + )} + +
+
+ {/* Logo */} +
+ {org.logo ? ( + Logo + ) : ( +
+ + + Tanpa Logo + +
+ )} + {canEdit && ( + + )} + + + {/* Logo Progress Overlay */} + {isUploading && uploadType === "logo" && ( +
+
+ + Upload progress + + + + + {Math.round(uploadProgress)}% + +
+ + Uploading + +
+ )} +
+ +
+ {/* Inline edit Name */} + {isEditingName ? ( +
+ setName(e.target.value)} + className="font-heading text-2xl sm:text-4xl font-black h-auto py-2 px-3 bg-white/10 text-white border-white/20 focus-visible:ring-2 focus-visible:ring-emerald-400/50 shadow-inner rounded-xl w-full" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") handleSaveName(); + if (e.key === "Escape") { + setName(org.name); + setIsEditingName(false); + } + }} + disabled={isSavingName} + /> + + +
+ ) : ( +
+

+ {org.name} +

+ {canEdit && ( + + )} +
+ )} + + {/* Inline edit Description */} + {isEditingDesc ? ( +
+