From 9ab254ef0d2674b2ff3536df8952b2fc931c1f45 Mon Sep 17 00:00:00 2001 From: phaichayon Date: Tue, 30 Jun 2026 11:50:50 +0700 Subject: [PATCH] task-p.6 migrate --- drizzle/0007_document_library_foundation.sql | 50 ++ drizzle/meta/_journal.json | 9 +- plans/task-p.6.md | 357 ++++++++ .../document-library/[id]/download/route.ts | 37 + .../document-library/[id]/preview/route.ts | 37 + .../settings/document-library/[id]/route.ts | 92 +++ .../document-library/[id]/versions/route.ts | 59 ++ .../crm/settings/document-library/route.ts | 102 +++ .../versions/[id]/activate/route.ts | 39 + .../versions/[id]/archive/route.ts | 39 + .../versions/[id]/download/route.ts | 36 + .../versions/[id]/preview/route.ts | 36 + .../versions/[id]/publish/route.ts | 39 + .../document-library/versions/[id]/route.ts | 39 + .../crm/settings/document-library/page.tsx | 41 + src/config/nav-config.ts | 32 +- src/db/schema.ts | 61 ++ .../components/document-library-settings.tsx | 764 ++++++++++++++++++ .../foundation/document-library/mutations.ts | 101 +++ .../foundation/document-library/queries.ts | 25 + .../document-library/server/service.ts | 726 +++++++++++++++++ .../server/validation.test.ts | 38 + .../document-library/server/validation.ts | 110 +++ .../foundation/document-library/service.ts | 119 +++ .../foundation/document-library/types.ts | 130 +++ .../components/template-settings.tsx | 68 +- src/lib/auth/rbac.ts | 16 + 27 files changed, 3170 insertions(+), 32 deletions(-) create mode 100644 drizzle/0007_document_library_foundation.sql create mode 100644 plans/task-p.6.md create mode 100644 src/app/api/crm/settings/document-library/[id]/download/route.ts create mode 100644 src/app/api/crm/settings/document-library/[id]/preview/route.ts create mode 100644 src/app/api/crm/settings/document-library/[id]/route.ts create mode 100644 src/app/api/crm/settings/document-library/[id]/versions/route.ts create mode 100644 src/app/api/crm/settings/document-library/route.ts create mode 100644 src/app/api/crm/settings/document-library/versions/[id]/activate/route.ts create mode 100644 src/app/api/crm/settings/document-library/versions/[id]/archive/route.ts create mode 100644 src/app/api/crm/settings/document-library/versions/[id]/download/route.ts create mode 100644 src/app/api/crm/settings/document-library/versions/[id]/preview/route.ts create mode 100644 src/app/api/crm/settings/document-library/versions/[id]/publish/route.ts create mode 100644 src/app/api/crm/settings/document-library/versions/[id]/route.ts create mode 100644 src/app/dashboard/crm/settings/document-library/page.tsx create mode 100644 src/features/foundation/document-library/components/document-library-settings.tsx create mode 100644 src/features/foundation/document-library/mutations.ts create mode 100644 src/features/foundation/document-library/queries.ts create mode 100644 src/features/foundation/document-library/server/service.ts create mode 100644 src/features/foundation/document-library/server/validation.test.ts create mode 100644 src/features/foundation/document-library/server/validation.ts create mode 100644 src/features/foundation/document-library/service.ts create mode 100644 src/features/foundation/document-library/types.ts diff --git a/drizzle/0007_document_library_foundation.sql b/drizzle/0007_document_library_foundation.sql new file mode 100644 index 0000000..42990df --- /dev/null +++ b/drizzle/0007_document_library_foundation.sql @@ -0,0 +1,50 @@ +CREATE TABLE "crm_document_libraries" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "code" text NOT NULL, + "name" text NOT NULL, + "document_type" text NOT NULL, + "description" text, + "brand" text NOT NULL, + "language" text NOT NULL, + "product_type" text NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "deleted_at" timestamp with time zone, + "created_by" text NOT NULL, + "updated_by" text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "crm_document_libraries_org_code_idx" ON "crm_document_libraries" USING btree ("organization_id","code"); +--> statement-breakpoint +CREATE TABLE "crm_document_library_versions" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "library_id" text NOT NULL, + "version" text NOT NULL, + "file_name" text NOT NULL, + "storage_provider" text NOT NULL, + "storage_key" text NOT NULL, + "mime_type" text NOT NULL, + "file_size" integer NOT NULL, + "checksum" text NOT NULL, + "page_count" integer NOT NULL, + "status" text DEFAULT 'draft' NOT NULL, + "is_active" boolean DEFAULT false NOT NULL, + "published_at" timestamp with time zone, + "published_by" text, + "archived_at" timestamp with time zone, + "archived_by" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "deleted_at" timestamp with time zone, + "created_by" text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "crm_document_library_versions_library_version_idx" ON "crm_document_library_versions" USING btree ("library_id","version"); +--> statement-breakpoint +CREATE UNIQUE INDEX "crm_document_library_versions_active_library_idx" + ON "crm_document_library_versions" USING btree ("library_id") + WHERE "is_active" = true AND "deleted_at" IS NULL; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index b6c40ff..bd76f2d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1782749787685, "tag": "0006_lazy_shiver_man", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1782910000000, + "tag": "0007_document_library_foundation", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/plans/task-p.6.md b/plans/task-p.6.md new file mode 100644 index 0000000..6081853 --- /dev/null +++ b/plans/task-p.6.md @@ -0,0 +1,357 @@ +# Task P.6 - Document Library Foundation + +## Objective + +Build the Document Library foundation for managing reusable PDF documents that can later be assembled with CRM-generated documents. + +This phase prepares the system for future PDF merge workflows such as: + +* Quotation + SLA +* Quotation + Warranty +* Quotation + Datasheet +* Quotation + Drawing +* Quotation + Company Profile + +This task does **not** perform PDF merge yet. + +--- + +# Background + +Completed phases: + +* P.4 PDF Runtime +* P.5 Document Template Management +* P.5.1 Production Hardening + +The next required foundation is a centralized library for external/static documents before implementing Document Assembly. + +--- + +# Scope + +Included: + +* Document Library data model +* Document category/type model +* Version management +* Organization and brand scope +* Language support +* Product type applicability +* File upload/storage metadata +* Active/default version handling +* Preview/download APIs +* Basic management UI +* RBAC permissions +* Audit log integration + +Excluded: + +* PDF merge +* SLA assembly into quotation +* Render configuration +* User-selectable append documents +* Approval package generation + +--- + +# Document Types + +Support document types such as: + +* SLA +* Warranty +* Terms and Conditions +* Company Profile +* Datasheet +* Drawing +* Certificate +* Appendix +* Safety Document +* Installation Manual + +--- + +# Data Model Requirements + +Design tables for: + +## Document Library + +Represents a logical document family. + +Example: + +```text +SLA - Crane - TH +Warranty - Dock Door - EN +Company Profile - ALLA - TH +``` + +Fields should include: + +* id +* organizationId +* code +* name +* documentType +* description +* brand +* language +* productType +* status +* isActive +* createdBy +* createdAt +* updatedAt + +## Document Library Versions + +Represents uploaded PDF versions. + +Fields should include: + +* id +* libraryId +* version +* fileName +* filePath / storageKey +* mimeType +* fileSize +* checksum +* pageCount +* status +* isActive +* publishedAt +* publishedBy +* archivedAt +* archivedBy +* createdAt +* updatedAt + +--- + +# Version Lifecycle + +Support: + +```text +Draft +↓ +Published +↓ +Active +↓ +Archived +``` + +Only one active version is allowed per library item. + +--- + +# Storage Requirements + +Reuse the existing storage abstraction from prior PDF artifact work. + +Do not store PDF binary directly in database. + +Store only: + +* storage key +* filename +* MIME type +* checksum +* size +* metadata + +--- + +# Upload Requirements + +Allow uploading PDF files only in this phase. + +Validate: + +* MIME type = application/pdf +* file size limit +* checksum +* readable PDF +* page count extraction if available + +Reject invalid uploads. + +--- + +# Preview and Download + +Provide: + +* preview document version +* download document version +* download active version + +Preview should not mutate lifecycle state. + +--- + +# Applicability Rules + +Each document may apply to: + +* organization +* brand +* document type +* product type +* language + +Examples: + +```text +SLA + ALLA + Crane + TH +Warranty + ONVALLA + Dock Door + EN +Company Profile + ALLA + All Product Types + TH +``` + +--- + +# API Requirements + +Create APIs for: + +* list document library items +* create library item +* update library item +* create/upload version +* publish version +* activate version +* archive version +* preview version +* download version +* delete draft version if safe + +--- + +# UI Requirements + +Add management UI under CRM settings or document settings. + +Suggested route: + +```text +/dashboard/crm/settings/document-library +``` + +UI should include: + +* library list +* filters by type, brand, language, product type +* version history +* upload new version +* publish +* activate +* archive +* preview +* download + +--- + +# Permissions + +Add permissions: + +* documentLibraryRead +* documentLibraryCreate +* documentLibraryUpdate +* documentLibraryUpload +* documentLibraryPublish +* documentLibraryActivate +* documentLibraryArchive +* documentLibraryDownload + +Activation should require elevated permission. + +--- + +# Audit Log + +Record audit logs for: + +* create library item +* update metadata +* upload version +* publish version +* activate version +* archive version +* download version + +--- + +# Validation + +Validate: + +* duplicate code per organization +* duplicate active version +* invalid PDF +* missing file +* unsupported document type +* unsupported language +* invalid lifecycle transition + +--- + +# Testing Requirements + +Run: + +```bash +npm run typecheck +npm run build +``` + +Add tests for: + +* version lifecycle +* active version uniqueness +* PDF upload validation +* checksum generation +* archive protection +* permission guards where practical + +--- + +# Acceptance Criteria + +* Document Library module exists. +* PDF files can be registered as document versions. +* Only valid PDFs are accepted. +* Versions support Draft, Published, Active, Archived lifecycle. +* Only one active version exists per library item. +* Active document can be previewed and downloaded. +* Library items can be filtered by type, brand, language, and product type. +* Storage abstraction is reused. +* No PDF merge is implemented in this phase. +* System is ready for P.7 Document Assembly. + +--- + +# Out of Scope + +## P.7 + +* Merge quotation PDF with SLA/Warranty/Datasheet +* Document package generation +* Page ordering rules +* Final merged PDF artifact + +## P.8 + +* Render configuration +* User-selectable append documents +* Default append policy per product type + +--- + +# Final Success Condition + +Task P.6 is complete when ALLA OS has a reusable Document Library that can store, version, activate, preview, and download static PDF documents such as SLA, Warranty, Datasheet, Drawing, and Company Profile, without yet merging them into generated CRM PDFs. diff --git a/src/app/api/crm/settings/document-library/[id]/download/route.ts b/src/app/api/crm/settings/document-library/[id]/download/route.ts new file mode 100644 index 0000000..77c33fc --- /dev/null +++ b/src/app/api/crm/settings/document-library/[id]/download/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { downloadActiveDocumentLibraryVersion } from '@/features/foundation/document-library/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { params: Promise<{ id: string }> }; + +export async function GET(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmDocumentLibraryDownload + }); + + const { object } = await downloadActiveDocumentLibraryVersion({ + libraryId: id, + organizationId: organization.id, + userId: session.user.id + }); + + return new NextResponse(object.body, { + headers: { + 'Content-Type': object.contentType, + 'Content-Disposition': `attachment; filename="${object.fileName}"` + } + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + return NextResponse.json( + { message: 'Unable to download active document version' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/crm/settings/document-library/[id]/preview/route.ts b/src/app/api/crm/settings/document-library/[id]/preview/route.ts new file mode 100644 index 0000000..a8ee4ee --- /dev/null +++ b/src/app/api/crm/settings/document-library/[id]/preview/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { previewActiveDocumentLibraryVersion } from '@/features/foundation/document-library/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { params: Promise<{ id: string }> }; + +export async function GET(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmDocumentLibraryRead + }); + + const { object } = await previewActiveDocumentLibraryVersion({ + libraryId: id, + organizationId: organization.id, + userId: session.user.id + }); + + return new NextResponse(object.body, { + headers: { + 'Content-Type': object.contentType, + 'Content-Disposition': `inline; filename="${object.fileName}"` + } + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + return NextResponse.json( + { message: 'Unable to preview active document version' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/crm/settings/document-library/[id]/route.ts b/src/app/api/crm/settings/document-library/[id]/route.ts new file mode 100644 index 0000000..2b3c756 --- /dev/null +++ b/src/app/api/crm/settings/document-library/[id]/route.ts @@ -0,0 +1,92 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { + getDocumentLibraryById, + updateDocumentLibrary +} from '@/features/foundation/document-library/server/service'; +import { + DOCUMENT_LIBRARY_BRANDS, + DOCUMENT_LIBRARY_LANGUAGES, + DOCUMENT_LIBRARY_PRODUCT_TYPES, + DOCUMENT_LIBRARY_TYPES +} from '@/features/foundation/document-library/types'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { params: Promise<{ id: string }> }; + +const updateDocumentLibrarySchema = z.object({ + code: z.string().min(1).optional(), + name: z.string().min(1).optional(), + documentType: z.enum(DOCUMENT_LIBRARY_TYPES).optional(), + description: z.string().optional().nullable(), + brand: z.enum(DOCUMENT_LIBRARY_BRANDS).optional(), + language: z.enum(DOCUMENT_LIBRARY_LANGUAGES).optional(), + productType: z.enum(DOCUMENT_LIBRARY_PRODUCT_TYPES).optional(), + isActive: z.boolean().optional() +}); + +export async function GET(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmDocumentLibraryRead + }); + const library = await getDocumentLibraryById(id, organization.id); + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'Document library item loaded successfully', + library + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json( + { message: 'Unable to load document library item' }, + { status: 500 } + ); + } +} + +export async function PATCH(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmDocumentLibraryUpdate + }); + const payload = updateDocumentLibrarySchema.parse(await request.json()); + const library = await updateDocumentLibrary({ + id, + organizationId: organization.id, + userId: session.user.id, + values: payload + }); + + return NextResponse.json({ + success: true, + message: 'Document library item updated successfully', + library + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json( + { message: 'Unable to update document library item' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/crm/settings/document-library/[id]/versions/route.ts b/src/app/api/crm/settings/document-library/[id]/versions/route.ts new file mode 100644 index 0000000..ab49c36 --- /dev/null +++ b/src/app/api/crm/settings/document-library/[id]/versions/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { uploadDocumentLibraryVersion } from '@/features/foundation/document-library/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { params: Promise<{ id: string }> }; + +export async function POST(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmDocumentLibraryUpload + }); + + const formData = await request.formData(); + const version = formData.get('version'); + const file = formData.get('file'); + + if (typeof version !== 'string' || !version.trim()) { + return NextResponse.json({ message: 'Version is required' }, { status: 400 }); + } + + if (!(file instanceof File)) { + return NextResponse.json({ message: 'PDF file is required' }, { status: 400 }); + } + + const created = await uploadDocumentLibraryVersion({ + libraryId: id, + organizationId: organization.id, + userId: session.user.id, + version, + file: { + name: file.name, + type: file.type, + size: file.size, + buffer: Buffer.from(await file.arrayBuffer()) + } + }); + + return NextResponse.json({ + success: true, + message: 'Document library version uploaded successfully', + version: created + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json( + { message: 'Unable to upload document library version' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/crm/settings/document-library/route.ts b/src/app/api/crm/settings/document-library/route.ts new file mode 100644 index 0000000..ca7c334 --- /dev/null +++ b/src/app/api/crm/settings/document-library/route.ts @@ -0,0 +1,102 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { + createDocumentLibrary, + listDocumentLibraries +} from '@/features/foundation/document-library/server/service'; +import { + DOCUMENT_LIBRARY_BRANDS, + DOCUMENT_LIBRARY_LANGUAGES, + DOCUMENT_LIBRARY_PRODUCT_TYPES, + DOCUMENT_LIBRARY_TYPES +} from '@/features/foundation/document-library/types'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +const documentLibrarySchema = z.object({ + code: z.string().min(1), + name: z.string().min(1), + documentType: z.enum(DOCUMENT_LIBRARY_TYPES), + description: z.string().optional().nullable(), + brand: z.enum(DOCUMENT_LIBRARY_BRANDS), + language: z.enum(DOCUMENT_LIBRARY_LANGUAGES), + productType: z.enum(DOCUMENT_LIBRARY_PRODUCT_TYPES), + isActive: z.boolean().optional() +}); + +export async function GET(request: NextRequest) { + try { + const { organization } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmDocumentLibraryRead + }); + + const { searchParams } = request.nextUrl; + const isActiveParam = searchParams.get('isActive'); + const items = await listDocumentLibraries(organization.id, { + documentType: + (searchParams.get('documentType') as (typeof DOCUMENT_LIBRARY_TYPES)[number] | null) ?? + undefined, + brand: + (searchParams.get('brand') as (typeof DOCUMENT_LIBRARY_BRANDS)[number] | null) ?? + undefined, + language: + (searchParams.get('language') as (typeof DOCUMENT_LIBRARY_LANGUAGES)[number] | null) ?? + undefined, + productType: + (searchParams.get('productType') as + | (typeof DOCUMENT_LIBRARY_PRODUCT_TYPES)[number] + | null) ?? undefined, + isActive: isActiveParam === 'true' || isActiveParam === 'false' ? isActiveParam : undefined + }); + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'Document library loaded successfully', + items + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to load document library' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmDocumentLibraryCreate + }); + const payload = documentLibrarySchema.parse(await request.json()); + const library = await createDocumentLibrary({ + organizationId: organization.id, + userId: session.user.id, + values: payload + }); + + return NextResponse.json({ + success: true, + message: 'Document library item created successfully', + library + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json( + { message: 'Unable to create document library item' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/crm/settings/document-library/versions/[id]/activate/route.ts b/src/app/api/crm/settings/document-library/versions/[id]/activate/route.ts new file mode 100644 index 0000000..3b3dc40 --- /dev/null +++ b/src/app/api/crm/settings/document-library/versions/[id]/activate/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { activateDocumentLibraryVersion } from '@/features/foundation/document-library/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { params: Promise<{ id: string }> }; + +export async function POST(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmDocumentLibraryActivate + }); + const version = await activateDocumentLibraryVersion({ + versionId: id, + organizationId: organization.id, + userId: session.user.id + }); + + return NextResponse.json({ + success: true, + message: 'Document library version activated successfully', + version + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json( + { message: 'Unable to activate document library version' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/crm/settings/document-library/versions/[id]/archive/route.ts b/src/app/api/crm/settings/document-library/versions/[id]/archive/route.ts new file mode 100644 index 0000000..8c874a6 --- /dev/null +++ b/src/app/api/crm/settings/document-library/versions/[id]/archive/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { archiveDocumentLibraryVersion } from '@/features/foundation/document-library/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { params: Promise<{ id: string }> }; + +export async function POST(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmDocumentLibraryArchive + }); + const version = await archiveDocumentLibraryVersion({ + versionId: id, + organizationId: organization.id, + userId: session.user.id + }); + + return NextResponse.json({ + success: true, + message: 'Document library version archived successfully', + version + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json( + { message: 'Unable to archive document library version' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/crm/settings/document-library/versions/[id]/download/route.ts b/src/app/api/crm/settings/document-library/versions/[id]/download/route.ts new file mode 100644 index 0000000..9bb8b32 --- /dev/null +++ b/src/app/api/crm/settings/document-library/versions/[id]/download/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { downloadDocumentLibraryVersion } from '@/features/foundation/document-library/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { params: Promise<{ id: string }> }; + +export async function GET(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmDocumentLibraryDownload + }); + const { object } = await downloadDocumentLibraryVersion({ + versionId: id, + organizationId: organization.id, + userId: session.user.id + }); + + return new NextResponse(object.body, { + headers: { + 'Content-Type': object.contentType, + 'Content-Disposition': `attachment; filename="${object.fileName}"` + } + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + return NextResponse.json( + { message: 'Unable to download document library version' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/crm/settings/document-library/versions/[id]/preview/route.ts b/src/app/api/crm/settings/document-library/versions/[id]/preview/route.ts new file mode 100644 index 0000000..5f171b2 --- /dev/null +++ b/src/app/api/crm/settings/document-library/versions/[id]/preview/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { previewDocumentLibraryVersion } from '@/features/foundation/document-library/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { params: Promise<{ id: string }> }; + +export async function GET(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmDocumentLibraryRead + }); + const { object } = await previewDocumentLibraryVersion({ + versionId: id, + organizationId: organization.id, + userId: session.user.id + }); + + return new NextResponse(object.body, { + headers: { + 'Content-Type': object.contentType, + 'Content-Disposition': `inline; filename="${object.fileName}"` + } + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + return NextResponse.json( + { message: 'Unable to preview document library version' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/crm/settings/document-library/versions/[id]/publish/route.ts b/src/app/api/crm/settings/document-library/versions/[id]/publish/route.ts new file mode 100644 index 0000000..204c6df --- /dev/null +++ b/src/app/api/crm/settings/document-library/versions/[id]/publish/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { publishDocumentLibraryVersion } from '@/features/foundation/document-library/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { params: Promise<{ id: string }> }; + +export async function POST(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmDocumentLibraryPublish + }); + const version = await publishDocumentLibraryVersion({ + versionId: id, + organizationId: organization.id, + userId: session.user.id + }); + + return NextResponse.json({ + success: true, + message: 'Document library version published successfully', + version + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json( + { message: 'Unable to publish document library version' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/crm/settings/document-library/versions/[id]/route.ts b/src/app/api/crm/settings/document-library/versions/[id]/route.ts new file mode 100644 index 0000000..d83256e --- /dev/null +++ b/src/app/api/crm/settings/document-library/versions/[id]/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { deleteDraftDocumentLibraryVersion } from '@/features/foundation/document-library/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { params: Promise<{ id: string }> }; + +export async function DELETE(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmDocumentLibraryUpload + }); + const version = await deleteDraftDocumentLibraryVersion({ + versionId: id, + organizationId: organization.id, + userId: session.user.id + }); + + return NextResponse.json({ + success: true, + message: 'Draft document library version deleted successfully', + version + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json( + { message: 'Unable to delete draft document library version' }, + { status: 500 } + ); + } +} diff --git a/src/app/dashboard/crm/settings/document-library/page.tsx b/src/app/dashboard/crm/settings/document-library/page.tsx new file mode 100644 index 0000000..291ffb4 --- /dev/null +++ b/src/app/dashboard/crm/settings/document-library/page.tsx @@ -0,0 +1,41 @@ +import { auth } from '@/auth'; +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import PageContainer from '@/components/layout/page-container'; +import { DocumentLibrarySettings } from '@/features/foundation/document-library/components/document-library-settings'; +import { documentLibrariesQueryOptions } from '@/features/foundation/document-library/queries'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { getQueryClient } from '@/lib/query-client'; + +export default async function DocumentLibraryRoute() { + const session = await auth(); + const canRead = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmDocumentLibraryRead))); + + const queryClient = getQueryClient(); + + if (canRead) { + void queryClient.prefetchQuery(documentLibrariesQueryOptions()); + } + + return ( + + You do not have access to the document library. + + } + > + {canRead ? ( + + + + ) : null} + + ); +} diff --git a/src/config/nav-config.ts b/src/config/nav-config.ts index ab1e0bd..078ee96 100644 --- a/src/config/nav-config.ts +++ b/src/config/nav-config.ts @@ -107,18 +107,26 @@ import { NavGroup } from "@/types"; permission: "crm.master_option.read", }, }, - { - title: "Document Sequences", - url: "/dashboard/crm/settings/document-sequences", - access: { - requireOrg: true, - permission: "crm.document_sequence.read", - }, - }, - { - title: "Approval Workflows", - url: "/dashboard/crm/settings/approval-workflows", - access: { + { + title: "Document Sequences", + url: "/dashboard/crm/settings/document-sequences", + access: { + requireOrg: true, + permission: "crm.document_sequence.read", + }, + }, + { + title: "Document Library", + url: "/dashboard/crm/settings/document-library", + access: { + requireOrg: true, + permission: "crm.document_library.read", + }, + }, + { + title: "Approval Workflows", + url: "/dashboard/crm/settings/approval-workflows", + access: { requireOrg: true, permission: "crm.approval.workflow.read", }, diff --git a/src/db/schema.ts b/src/db/schema.ts index 0f8494d..e7b1db8 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -931,6 +931,67 @@ export const crmDocumentTemplateVersions = pgTable( }) ); +export const crmDocumentLibraries = pgTable( + 'crm_document_libraries', + { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + code: text('code').notNull(), + name: text('name').notNull(), + documentType: text('document_type').notNull(), + description: text('description'), + brand: text('brand').notNull(), + language: text('language').notNull(), + productType: text('product_type').notNull(), + status: text('status').default('active').notNull(), + isActive: boolean('is_active').default(true).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true }), + createdBy: text('created_by').notNull(), + updatedBy: text('updated_by').notNull() + }, + (table) => ({ + organizationCodeIdx: uniqueIndex('crm_document_libraries_org_code_idx').on( + table.organizationId, + table.code + ) + }) +); + +export const crmDocumentLibraryVersions = pgTable( + 'crm_document_library_versions', + { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + libraryId: text('library_id').notNull(), + version: text('version').notNull(), + fileName: text('file_name').notNull(), + storageProvider: text('storage_provider').notNull(), + storageKey: text('storage_key').notNull(), + mimeType: text('mime_type').notNull(), + fileSize: integer('file_size').notNull(), + checksum: text('checksum').notNull(), + pageCount: integer('page_count').notNull(), + status: text('status').default('draft').notNull(), + isActive: boolean('is_active').default(false).notNull(), + publishedAt: timestamp('published_at', { withTimezone: true }), + publishedBy: text('published_by'), + archivedAt: timestamp('archived_at', { withTimezone: true }), + archivedBy: text('archived_by'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true }), + createdBy: text('created_by').notNull() + }, + (table) => ({ + libraryVersionIdx: uniqueIndex('crm_document_library_versions_library_version_idx').on( + table.libraryId, + table.version + ) + }) +); + export const crmDocumentTemplateMappings = pgTable( 'crm_document_template_mappings', { diff --git a/src/features/foundation/document-library/components/document-library-settings.tsx b/src/features/foundation/document-library/components/document-library-settings.tsx new file mode 100644 index 0000000..e02acb8 --- /dev/null +++ b/src/features/foundation/document-library/components/document-library-settings.tsx @@ -0,0 +1,764 @@ +'use client'; + +import * as React from 'react'; +import { useMutation, useSuspenseQuery } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { Icons } from '@/components/icons'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle +} from '@/components/ui/sheet'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { formatDateTime } from '@/lib/date-format'; +import { + activateDocumentLibraryVersionMutation, + archiveDocumentLibraryVersionMutation, + createDocumentLibraryMutation, + deleteDraftDocumentLibraryVersionMutation, + publishDocumentLibraryVersionMutation, + updateDocumentLibraryMutation, + uploadDocumentLibraryVersionMutation +} from '../mutations'; +import { + documentLibrariesQueryOptions, + documentLibraryByIdOptions +} from '../queries'; +import { + getActiveDocumentLibraryDownloadUrl, + getActiveDocumentLibraryPreviewUrl, + getDocumentLibraryDownloadUrl, + getDocumentLibraryPreviewUrl +} from '../service'; +import { + DOCUMENT_LIBRARY_BRANDS, + DOCUMENT_LIBRARY_LANGUAGES, + DOCUMENT_LIBRARY_PRODUCT_TYPES, + DOCUMENT_LIBRARY_TYPES, + type DocumentLibraryDetail, + type DocumentLibraryListItem, + type DocumentLibraryMutationPayload, + type DocumentLibraryVersionRecord +} from '../types'; + +type LibraryFormState = { + code: string; + name: string; + documentType: DocumentLibraryMutationPayload['documentType']; + description: string; + brand: DocumentLibraryMutationPayload['brand']; + language: DocumentLibraryMutationPayload['language']; + productType: DocumentLibraryMutationPayload['productType']; + isActive: boolean; +}; + +type VersionUploadState = { + version: string; + file: File | null; +}; + +function toLibraryState(library?: Partial): LibraryFormState { + return { + code: library?.code ?? '', + name: library?.name ?? '', + documentType: (library?.documentType as LibraryFormState['documentType']) ?? 'sla', + description: library?.description ?? '', + brand: (library?.brand as LibraryFormState['brand']) ?? 'alla', + language: (library?.language as LibraryFormState['language']) ?? 'th', + productType: (library?.productType as LibraryFormState['productType']) ?? 'all', + isActive: library?.isActive ?? true + }; +} + +function buildLibraryPayload(state: LibraryFormState): DocumentLibraryMutationPayload { + return { + code: state.code, + name: state.name, + documentType: state.documentType, + description: state.description || null, + brand: state.brand, + language: state.language, + productType: state.productType, + isActive: state.isActive + }; +} + +function openFile(url: string) { + window.open(url, '_blank', 'noopener,noreferrer'); +} + +function LibraryDialog({ + open, + onOpenChange, + library +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + library?: DocumentLibraryListItem; +}) { + const [state, setState] = React.useState(toLibraryState(library)); + const createMutation = useMutation({ + ...createDocumentLibraryMutation, + onSuccess: () => { + toast.success('Document library item created'); + onOpenChange(false); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : 'Create failed'); + } + }); + const updateMutation = useMutation({ + ...updateDocumentLibraryMutation, + onSuccess: () => { + toast.success('Document library item updated'); + onOpenChange(false); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : 'Update failed'); + } + }); + + React.useEffect(() => { + if (open) { + setState(toLibraryState(library)); + } + }, [open, library]); + + async function onSubmit(event: React.FormEvent) { + event.preventDefault(); + const payload = buildLibraryPayload(state); + + if (library) { + await updateMutation.mutateAsync({ id: library.id, values: payload }); + return; + } + + await createMutation.mutateAsync(payload); + } + + const isPending = createMutation.isPending || updateMutation.isPending; + + return ( + + + + {library ? 'Edit Library Item' : 'Create Library Item'} + + Manage reusable static PDF documents before later assembly flows. + + +
+
+ + setState((current) => ({ ...current, code: event.target.value })) + } + /> + + setState((current) => ({ ...current, name: event.target.value })) + } + /> +
+
+ + +
+
+ + +
+