task-p.6 migrate

This commit is contained in:
phaichayon
2026-06-30 11:50:50 +07:00
parent b0081c7031
commit 9ab254ef0d
27 changed files with 3170 additions and 32 deletions

View File

@@ -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;

View File

@@ -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
}
]
}

357
plans/task-p.6.md Normal file
View File

@@ -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.

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 (
<PageContainer
pageTitle='Document Library'
pageDescription='Reusable static PDF documents for future quotation assembly workflows.'
access={canRead}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to the document library.
</div>
}
>
{canRead ? (
<HydrationBoundary state={dehydrate(queryClient)}>
<DocumentLibrarySettings />
</HydrationBoundary>
) : null}
</PageContainer>
);
}

View File

@@ -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",
},

View File

@@ -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',
{

View File

@@ -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<DocumentLibraryListItem>): 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<LibraryFormState>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-2xl'>
<DialogHeader>
<DialogTitle>{library ? 'Edit Library Item' : 'Create Library Item'}</DialogTitle>
<DialogDescription>
Manage reusable static PDF documents before later assembly flows.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className='grid gap-4'>
<div className='grid gap-4 md:grid-cols-2'>
<Input
placeholder='Code'
value={state.code}
onChange={(event) =>
setState((current) => ({ ...current, code: event.target.value }))
}
/>
<Input
placeholder='Name'
value={state.name}
onChange={(event) =>
setState((current) => ({ ...current, name: event.target.value }))
}
/>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<Select
value={state.documentType}
onValueChange={(value: LibraryFormState['documentType']) =>
setState((current) => ({ ...current, documentType: value }))
}
>
<SelectTrigger>
<SelectValue placeholder='Document type' />
</SelectTrigger>
<SelectContent>
{DOCUMENT_LIBRARY_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={state.brand}
onValueChange={(value: LibraryFormState['brand']) =>
setState((current) => ({ ...current, brand: value }))
}
>
<SelectTrigger>
<SelectValue placeholder='Brand' />
</SelectTrigger>
<SelectContent>
{DOCUMENT_LIBRARY_BRANDS.map((brand) => (
<SelectItem key={brand} value={brand}>
{brand}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<Select
value={state.language}
onValueChange={(value: LibraryFormState['language']) =>
setState((current) => ({ ...current, language: value }))
}
>
<SelectTrigger>
<SelectValue placeholder='Language' />
</SelectTrigger>
<SelectContent>
{DOCUMENT_LIBRARY_LANGUAGES.map((language) => (
<SelectItem key={language} value={language}>
{language}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={state.productType}
onValueChange={(value: LibraryFormState['productType']) =>
setState((current) => ({ ...current, productType: value }))
}
>
<SelectTrigger>
<SelectValue placeholder='Product type' />
</SelectTrigger>
<SelectContent>
{DOCUMENT_LIBRARY_PRODUCT_TYPES.map((productType) => (
<SelectItem key={productType} value={productType}>
{productType}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Textarea
placeholder='Description'
value={state.description}
onChange={(event) =>
setState((current) => ({ ...current, description: event.target.value }))
}
/>
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
<div>
<div className='font-medium'>Active Library</div>
<div className='text-muted-foreground text-sm'>
Inactive libraries remain visible for audit and historical traceability.
</div>
</div>
<Switch
checked={state.isActive}
onCheckedChange={(checked) =>
setState((current) => ({ ...current, isActive: checked }))
}
/>
</div>
<DialogFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type='submit' isLoading={isPending}>
{library ? 'Save Changes' : 'Create Library'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function VersionUploadDialog({
open,
onOpenChange,
libraryId
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
libraryId: string;
}) {
const [state, setState] = React.useState<VersionUploadState>({ version: '', file: null });
const uploadMutation = useMutation({
...uploadDocumentLibraryVersionMutation,
onSuccess: () => {
toast.success('Document version uploaded');
onOpenChange(false);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : 'Upload failed');
}
});
React.useEffect(() => {
if (open) {
setState({ version: '', file: null });
}
}, [open]);
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
if (!state.file) {
toast.error('Please select a PDF file');
return;
}
await uploadMutation.mutateAsync({
libraryId,
values: {
version: state.version,
file: state.file
}
});
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Upload Version</DialogTitle>
<DialogDescription>
Draft versions accept PDF uploads only. Publish and activate are separate actions.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className='grid gap-4'>
<Input
placeholder='Version'
value={state.version}
onChange={(event) =>
setState((current) => ({ ...current, version: event.target.value }))
}
/>
<Input
type='file'
accept='application/pdf,.pdf'
onChange={(event) =>
setState((current) => ({
...current,
file: event.target.files?.[0] ?? null
}))
}
/>
<DialogFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type='submit' isLoading={uploadMutation.isPending}>
Upload Version
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function VersionActions({
libraryId,
version
}: {
libraryId: string;
version: DocumentLibraryVersionRecord;
}) {
const publishMutation = useMutation({
...publishDocumentLibraryVersionMutation,
onSuccess: () => toast.success('Version published'),
onError: (error) => toast.error(error instanceof Error ? error.message : 'Publish failed')
});
const activateMutation = useMutation({
...activateDocumentLibraryVersionMutation,
onSuccess: () => toast.success('Version activated'),
onError: (error) => toast.error(error instanceof Error ? error.message : 'Activate failed')
});
const archiveMutation = useMutation({
...archiveDocumentLibraryVersionMutation,
onSuccess: () => toast.success('Version archived'),
onError: (error) => toast.error(error instanceof Error ? error.message : 'Archive failed')
});
const deleteMutation = useMutation({
...deleteDraftDocumentLibraryVersionMutation,
onSuccess: () => toast.success('Draft version deleted'),
onError: (error) => toast.error(error instanceof Error ? error.message : 'Delete failed')
});
return (
<div className='flex flex-wrap gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => openFile(getDocumentLibraryPreviewUrl(version.id))}
>
Preview
</Button>
<Button
variant='outline'
size='sm'
onClick={() => openFile(getDocumentLibraryDownloadUrl(version.id))}
>
Download
</Button>
{version.status === 'draft' ? (
<Button
size='sm'
variant='outline'
isLoading={publishMutation.isPending}
onClick={() => publishMutation.mutate({ libraryId, versionId: version.id })}
>
Publish
</Button>
) : null}
{version.status === 'published' ? (
<Button
size='sm'
isLoading={activateMutation.isPending}
onClick={() => activateMutation.mutate({ libraryId, versionId: version.id })}
>
Activate
</Button>
) : null}
{(version.status === 'draft' || version.status === 'published') && !version.isActive ? (
<Button
size='sm'
variant='outline'
isLoading={archiveMutation.isPending}
onClick={() => archiveMutation.mutate({ libraryId, versionId: version.id })}
>
Archive
</Button>
) : null}
{version.status === 'draft' ? (
<Button
size='sm'
variant='outline'
isLoading={deleteMutation.isPending}
onClick={() => deleteMutation.mutate({ libraryId, versionId: version.id })}
>
Delete Draft
</Button>
) : null}
</div>
);
}
function DocumentLibraryDetailSheet({
libraryId,
open,
onOpenChange
}: {
libraryId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { data } = useSuspenseQuery(documentLibraryByIdOptions(libraryId));
const [uploadDialogOpen, setUploadDialogOpen] = React.useState(false);
const library = data.library;
return (
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side='right' className='w-full overflow-y-auto sm:max-w-5xl'>
<SheetHeader>
<SheetTitle>{library.name}</SheetTitle>
<SheetDescription>
{library.code} {library.documentType} {library.brand} {library.language}
</SheetDescription>
</SheetHeader>
<div className='space-y-6 pb-6'>
<div className='grid gap-4 md:grid-cols-4'>
<div className='rounded-lg border p-4'>
<div className='text-muted-foreground text-xs'>Product Type</div>
<div className='mt-1 text-sm font-medium'>{library.productType}</div>
</div>
<div className='rounded-lg border p-4'>
<div className='text-muted-foreground text-xs'>Versions</div>
<div className='mt-1 text-sm font-medium'>{library.versions.length}</div>
</div>
<div className='rounded-lg border p-4'>
<div className='text-muted-foreground text-xs'>Updated At</div>
<div className='mt-1 text-sm font-medium'>{formatDateTime(library.updatedAt)}</div>
</div>
<div className='rounded-lg border p-4'>
<div className='text-muted-foreground text-xs'>Description</div>
<div className='mt-1 text-sm font-medium'>
{library.description || 'No description provided'}
</div>
</div>
</div>
<div className='flex flex-wrap gap-2'>
<Button onClick={() => setUploadDialogOpen(true)}>
<Icons.add className='mr-2 h-4 w-4' />
Upload Version
</Button>
<Button
variant='outline'
onClick={() => openFile(getActiveDocumentLibraryPreviewUrl(library.id))}
>
Preview Active
</Button>
<Button
variant='outline'
onClick={() => openFile(getActiveDocumentLibraryDownloadUrl(library.id))}
>
Download Active
</Button>
</div>
{library.versions.length === 0 ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
No document versions uploaded yet.
</div>
) : (
<div className='space-y-4'>
{library.versions.map((version) => (
<div key={version.id} className='rounded-xl border p-4'>
<div className='flex flex-wrap items-start justify-between gap-4'>
<div className='space-y-2'>
<div className='flex flex-wrap items-center gap-2'>
<div className='text-lg font-semibold'>{version.version}</div>
<Badge variant={version.isActive ? 'secondary' : 'outline'}>
{version.status}
</Badge>
</div>
<div className='text-muted-foreground text-sm'>
{version.fileName} {version.pageCount} page(s) {version.fileSize} bytes
</div>
<div className='text-muted-foreground text-xs'>
Created {formatDateTime(version.createdAt)}
{version.publishedAt
? ` • Published ${formatDateTime(version.publishedAt)}`
: ''}
{version.archivedAt
? ` • Archived ${formatDateTime(version.archivedAt)}`
: ''}
</div>
</div>
<VersionActions libraryId={library.id} version={version} />
</div>
</div>
))}
</div>
)}
</div>
</SheetContent>
</Sheet>
<VersionUploadDialog
open={uploadDialogOpen}
onOpenChange={setUploadDialogOpen}
libraryId={library.id}
/>
</>
);
}
export function DocumentLibrarySettings() {
const { data } = useSuspenseQuery(documentLibrariesQueryOptions());
const [dialogState, setDialogState] = React.useState<{
open: boolean;
library?: DocumentLibraryListItem;
}>({ open: false });
const [detailState, setDetailState] = React.useState<{
open: boolean;
libraryId?: string;
}>({ open: false });
const [filters, setFilters] = React.useState({
documentType: '__all__',
brand: '__all__',
language: '__all__',
productType: '__all__'
});
const filteredItems = React.useMemo(() => {
return data.items.filter((item) => {
if (filters.documentType !== '__all__' && item.documentType !== filters.documentType) {
return false;
}
if (filters.brand !== '__all__' && item.brand !== filters.brand) {
return false;
}
if (filters.language !== '__all__' && item.language !== filters.language) {
return false;
}
if (filters.productType !== '__all__' && item.productType !== filters.productType) {
return false;
}
return true;
});
}, [data.items, filters]);
return (
<div className='space-y-6'>
<div className='flex flex-col gap-4 rounded-lg border p-4'>
<div className='flex flex-wrap items-center justify-between gap-4'>
<div>
<div className='font-medium'>Document Library Foundation</div>
<div className='text-muted-foreground text-sm'>
Manage reusable PDF documents such as SLA, warranty, datasheet, and profile files.
</div>
</div>
<Button onClick={() => setDialogState({ open: true })}>
<Icons.add className='mr-2 h-4 w-4' />
Create Library Item
</Button>
</div>
<div className='grid gap-4 md:grid-cols-4'>
<Select
value={filters.documentType}
onValueChange={(value) =>
setFilters((current) => ({ ...current, documentType: value }))
}
>
<SelectTrigger>
<SelectValue placeholder='All types' />
</SelectTrigger>
<SelectContent>
<SelectItem value='__all__'>All types</SelectItem>
{DOCUMENT_LIBRARY_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filters.brand}
onValueChange={(value) => setFilters((current) => ({ ...current, brand: value }))}
>
<SelectTrigger>
<SelectValue placeholder='All brands' />
</SelectTrigger>
<SelectContent>
<SelectItem value='__all__'>All brands</SelectItem>
{DOCUMENT_LIBRARY_BRANDS.map((brand) => (
<SelectItem key={brand} value={brand}>
{brand}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filters.language}
onValueChange={(value) =>
setFilters((current) => ({ ...current, language: value }))
}
>
<SelectTrigger>
<SelectValue placeholder='All languages' />
</SelectTrigger>
<SelectContent>
<SelectItem value='__all__'>All languages</SelectItem>
{DOCUMENT_LIBRARY_LANGUAGES.map((language) => (
<SelectItem key={language} value={language}>
{language}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filters.productType}
onValueChange={(value) =>
setFilters((current) => ({ ...current, productType: value }))
}
>
<SelectTrigger>
<SelectValue placeholder='All products' />
</SelectTrigger>
<SelectContent>
<SelectItem value='__all__'>All products</SelectItem>
{DOCUMENT_LIBRARY_PRODUCT_TYPES.map((productType) => (
<SelectItem key={productType} value={productType}>
{productType}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{filteredItems.length === 0 ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
No document library items match the current filters.
</div>
) : (
filteredItems.map((item) => (
<div key={item.id} className='rounded-xl border p-5'>
<div className='flex flex-wrap items-start justify-between gap-4'>
<div className='space-y-1'>
<div className='text-xl font-semibold'>{item.name}</div>
<div className='text-muted-foreground text-sm'>
{item.code} {item.documentType} {item.brand} {item.language} {' '}
{item.productType}
</div>
<div className='text-muted-foreground text-sm'>
{item.description || 'No description provided.'}
</div>
</div>
<div className='flex flex-wrap gap-2'>
<Badge variant={item.isActive ? 'secondary' : 'outline'}>{item.status}</Badge>
<Badge variant='outline'>Active {item.activeVersion ?? '-'}</Badge>
<Badge variant='outline'>{item.versionCount} version(s)</Badge>
</div>
</div>
<div className='mt-4 flex flex-wrap gap-2'>
<Button
variant='outline'
onClick={() => setDialogState({ open: true, library: item })}
>
Edit Metadata
</Button>
<Button
onClick={() => setDetailState({ open: true, libraryId: item.id })}
>
Manage Versions
</Button>
</div>
</div>
))
)}
<LibraryDialog
open={dialogState.open}
onOpenChange={(open) => setDialogState((current) => ({ ...current, open }))}
library={dialogState.library}
/>
{detailState.open && detailState.libraryId ? (
<React.Suspense fallback={null}>
<DocumentLibraryDetailSheet
libraryId={detailState.libraryId}
open={detailState.open}
onOpenChange={(open) => setDetailState((current) => ({ ...current, open }))}
/>
</React.Suspense>
) : null}
</div>
);
}

View File

@@ -0,0 +1,101 @@
import { mutationOptions } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import {
activateDocumentLibraryVersion,
archiveDocumentLibraryVersion,
createDocumentLibrary,
deleteDraftDocumentLibraryVersion,
publishDocumentLibraryVersion,
updateDocumentLibrary,
uploadDocumentLibraryVersion
} from './service';
import { documentLibraryKeys } from './queries';
import type {
DocumentLibraryMutationPayload,
DocumentLibraryVersionUploadPayload
} from './types';
async function invalidateLibraryLists() {
await getQueryClient().invalidateQueries({ queryKey: documentLibraryKeys.lists() });
}
async function invalidateLibraryDetail(id: string) {
await getQueryClient().invalidateQueries({ queryKey: documentLibraryKeys.detail(id) });
}
export const createDocumentLibraryMutation = mutationOptions({
mutationFn: (values: DocumentLibraryMutationPayload) => createDocumentLibrary(values),
onSettled: async (_data, error) => {
if (!error) {
await invalidateLibraryLists();
}
}
});
export const updateDocumentLibraryMutation = mutationOptions({
mutationFn: ({
id,
values
}: {
id: string;
values: Partial<DocumentLibraryMutationPayload>;
}) => updateDocumentLibrary(id, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([invalidateLibraryLists(), invalidateLibraryDetail(variables.id)]);
}
}
});
export const uploadDocumentLibraryVersionMutation = mutationOptions({
mutationFn: ({
libraryId,
values
}: {
libraryId: string;
values: DocumentLibraryVersionUploadPayload;
}) => uploadDocumentLibraryVersion(libraryId, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateLibraryLists(),
invalidateLibraryDetail(variables.libraryId)
]);
}
}
});
function makeVersionLifecycleMutation(
action: (versionId: string) => Promise<unknown>
) {
return mutationOptions({
mutationFn: ({
libraryId,
versionId
}: {
libraryId: string;
versionId: string;
}) => action(versionId),
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateLibraryLists(),
invalidateLibraryDetail(variables.libraryId)
]);
}
}
});
}
export const publishDocumentLibraryVersionMutation = makeVersionLifecycleMutation(
publishDocumentLibraryVersion
);
export const activateDocumentLibraryVersionMutation = makeVersionLifecycleMutation(
activateDocumentLibraryVersion
);
export const archiveDocumentLibraryVersionMutation = makeVersionLifecycleMutation(
archiveDocumentLibraryVersion
);
export const deleteDraftDocumentLibraryVersionMutation = makeVersionLifecycleMutation(
deleteDraftDocumentLibraryVersion
);

View File

@@ -0,0 +1,25 @@
import { queryOptions } from '@tanstack/react-query';
import { getDocumentLibraries, getDocumentLibraryById } from './service';
import type { DocumentLibraryFilters } from './types';
export const documentLibraryKeys = {
all: ['crm-document-library'] as const,
lists: () => [...documentLibraryKeys.all, 'list'] as const,
list: (filters: DocumentLibraryFilters) => [...documentLibraryKeys.lists(), filters] as const,
details: () => [...documentLibraryKeys.all, 'detail'] as const,
detail: (id: string) => [...documentLibraryKeys.details(), id] as const
};
export const documentLibrariesQueryOptions = (
filters: DocumentLibraryFilters = {}
) =>
queryOptions({
queryKey: documentLibraryKeys.list(filters),
queryFn: () => getDocumentLibraries(filters)
});
export const documentLibraryByIdOptions = (id: string) =>
queryOptions({
queryKey: documentLibraryKeys.detail(id),
queryFn: () => getDocumentLibraryById(id)
});

View File

@@ -0,0 +1,726 @@
import { and, asc, desc, eq, inArray, isNull } from 'drizzle-orm';
import {
crmDocumentLibraries,
crmDocumentLibraryVersions,
users
} from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import { auditAction } from '@/features/foundation/audit-log/service';
import {
buildOrganizationStorageKey,
getStorageProvider
} from '@/features/foundation/storage/service';
import type {
DocumentLibraryDetail,
DocumentLibraryFilters,
DocumentLibraryListItem,
DocumentLibraryMutationPayload,
DocumentLibraryRecord,
DocumentLibraryVersionRecord,
DocumentLibraryVersionStatus
} from '../types';
import {
assertCanDeleteDraftVersion,
assertLifecycleTransition,
assertSupportedBrand,
assertSupportedDocumentType,
assertSupportedLanguage,
assertSupportedProductType,
normalizeDocumentLibraryCode,
parsePdfUpload
} from './validation';
function sanitizeFileName(fileName: string) {
return fileName.replace(/[^a-zA-Z0-9._-]+/g, '-');
}
function mapLibrary(row: typeof crmDocumentLibraries.$inferSelect): DocumentLibraryRecord {
return {
id: row.id,
organizationId: row.organizationId,
code: row.code,
name: row.name,
documentType: row.documentType as DocumentLibraryRecord['documentType'],
description: row.description,
brand: row.brand as DocumentLibraryRecord['brand'],
language: row.language as DocumentLibraryRecord['language'],
productType: row.productType as DocumentLibraryRecord['productType'],
status: row.status as DocumentLibraryRecord['status'],
isActive: row.isActive,
createdBy: row.createdBy,
updatedBy: row.updatedBy,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null
};
}
function mapVersion(
row: typeof crmDocumentLibraryVersions.$inferSelect
): DocumentLibraryVersionRecord {
return {
id: row.id,
organizationId: row.organizationId,
libraryId: row.libraryId,
version: row.version,
fileName: row.fileName,
storageProvider: row.storageProvider,
storageKey: row.storageKey,
mimeType: row.mimeType,
fileSize: row.fileSize,
checksum: row.checksum,
pageCount: row.pageCount,
status: row.status as DocumentLibraryVersionStatus,
isActive: row.isActive,
publishedAt: row.publishedAt?.toISOString() ?? null,
publishedBy: row.publishedBy,
archivedAt: row.archivedAt?.toISOString() ?? null,
archivedBy: row.archivedBy,
createdBy: row.createdBy,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null
};
}
async function getLibraryRow(id: string, organizationId: string) {
const [library] = await db
.select()
.from(crmDocumentLibraries)
.where(
and(
eq(crmDocumentLibraries.id, id),
eq(crmDocumentLibraries.organizationId, organizationId),
isNull(crmDocumentLibraries.deletedAt)
)
)
.limit(1);
if (!library) {
throw new AuthError('Document library item not found', 404);
}
return library;
}
async function getVersionRow(versionId: string, organizationId: string) {
const [version] = await db
.select()
.from(crmDocumentLibraryVersions)
.where(
and(
eq(crmDocumentLibraryVersions.id, versionId),
eq(crmDocumentLibraryVersions.organizationId, organizationId),
isNull(crmDocumentLibraryVersions.deletedAt)
)
)
.limit(1);
if (!version) {
throw new AuthError('Document library version not found', 404);
}
return version;
}
async function ensureUniqueCode(
organizationId: string,
code: string,
excludeId?: string
) {
const rows = await db
.select({ id: crmDocumentLibraries.id })
.from(crmDocumentLibraries)
.where(
and(
eq(crmDocumentLibraries.organizationId, organizationId),
eq(crmDocumentLibraries.code, code),
isNull(crmDocumentLibraries.deletedAt)
)
);
if (rows.some((row) => row.id !== excludeId)) {
throw new Error(`Document library code already exists: ${code}`);
}
}
export async function listDocumentLibraries(
organizationId: string,
filters: DocumentLibraryFilters = {}
): Promise<DocumentLibraryListItem[]> {
const libraries = await db
.select()
.from(crmDocumentLibraries)
.where(
and(
eq(crmDocumentLibraries.organizationId, organizationId),
isNull(crmDocumentLibraries.deletedAt),
...(filters.documentType
? [eq(crmDocumentLibraries.documentType, filters.documentType)]
: []),
...(filters.brand ? [eq(crmDocumentLibraries.brand, filters.brand)] : []),
...(filters.language ? [eq(crmDocumentLibraries.language, filters.language)] : []),
...(filters.productType
? [eq(crmDocumentLibraries.productType, filters.productType)]
: []),
...(filters.isActive === 'true' ? [eq(crmDocumentLibraries.isActive, true)] : []),
...(filters.isActive === 'false' ? [eq(crmDocumentLibraries.isActive, false)] : [])
)
)
.orderBy(asc(crmDocumentLibraries.name));
if (!libraries.length) {
return [];
}
const versions = await db
.select()
.from(crmDocumentLibraryVersions)
.where(
and(
inArray(
crmDocumentLibraryVersions.libraryId,
libraries.map((library) => library.id)
),
isNull(crmDocumentLibraryVersions.deletedAt)
)
)
.orderBy(desc(crmDocumentLibraryVersions.createdAt));
return libraries.map((library) => {
const libraryVersions = versions.filter((version) => version.libraryId === library.id);
const activeVersion =
libraryVersions.find((version) => version.isActive)?.version ?? null;
return {
...mapLibrary(library),
activeVersion,
versionCount: libraryVersions.length
};
});
}
export async function getDocumentLibraryById(
id: string,
organizationId: string
): Promise<DocumentLibraryDetail> {
const library = await getLibraryRow(id, organizationId);
const versions = await db
.select()
.from(crmDocumentLibraryVersions)
.where(
and(
eq(crmDocumentLibraryVersions.libraryId, id),
eq(crmDocumentLibraryVersions.organizationId, organizationId),
isNull(crmDocumentLibraryVersions.deletedAt)
)
)
.orderBy(desc(crmDocumentLibraryVersions.createdAt));
return {
...mapLibrary(library),
versions: versions.map(mapVersion)
};
}
export async function createDocumentLibrary(input: {
organizationId: string;
userId: string;
values: DocumentLibraryMutationPayload;
}) {
assertSupportedDocumentType(input.values.documentType);
assertSupportedBrand(input.values.brand);
assertSupportedLanguage(input.values.language);
assertSupportedProductType(input.values.productType);
const code = normalizeDocumentLibraryCode(input.values.code);
await ensureUniqueCode(input.organizationId, code);
const [created] = await db
.insert(crmDocumentLibraries)
.values({
id: crypto.randomUUID(),
organizationId: input.organizationId,
code,
name: input.values.name.trim(),
documentType: input.values.documentType,
description: input.values.description?.trim() || null,
brand: input.values.brand,
language: input.values.language,
productType: input.values.productType,
status: input.values.isActive === false ? 'inactive' : 'active',
isActive: input.values.isActive ?? true,
createdBy: input.userId,
updatedBy: input.userId
})
.returning();
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'crm_document_library',
entityId: created.id,
action: 'create',
afterData: created
});
return mapLibrary(created);
}
export async function updateDocumentLibrary(input: {
id: string;
organizationId: string;
userId: string;
values: Partial<DocumentLibraryMutationPayload>;
}) {
const current = await getLibraryRow(input.id, input.organizationId);
if (input.values.documentType) {
assertSupportedDocumentType(input.values.documentType);
}
if (input.values.brand) {
assertSupportedBrand(input.values.brand);
}
if (input.values.language) {
assertSupportedLanguage(input.values.language);
}
if (input.values.productType) {
assertSupportedProductType(input.values.productType);
}
const code =
typeof input.values.code === 'string'
? normalizeDocumentLibraryCode(input.values.code)
: current.code;
await ensureUniqueCode(input.organizationId, code, current.id);
const [updated] = await db
.update(crmDocumentLibraries)
.set({
code,
name: input.values.name?.trim() ?? current.name,
documentType: input.values.documentType ?? current.documentType,
description:
input.values.description !== undefined
? input.values.description?.trim() || null
: current.description,
brand: input.values.brand ?? current.brand,
language: input.values.language ?? current.language,
productType: input.values.productType ?? current.productType,
status:
input.values.isActive !== undefined
? input.values.isActive
? 'active'
: 'inactive'
: (current.status as 'active' | 'inactive'),
isActive: input.values.isActive ?? current.isActive,
updatedBy: input.userId,
updatedAt: new Date()
})
.where(eq(crmDocumentLibraries.id, current.id))
.returning();
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'crm_document_library',
entityId: updated.id,
action: 'update',
beforeData: current,
afterData: updated
});
return mapLibrary(updated);
}
export async function uploadDocumentLibraryVersion(input: {
libraryId: string;
organizationId: string;
userId: string;
version: string;
file: {
name: string;
type: string;
size: number;
buffer: Buffer;
};
}) {
const library = await getLibraryRow(input.libraryId, input.organizationId);
const normalizedVersion = input.version.trim();
if (!normalizedVersion) {
throw new Error('Version is required.');
}
const existing = await db
.select({ id: crmDocumentLibraryVersions.id })
.from(crmDocumentLibraryVersions)
.where(
and(
eq(crmDocumentLibraryVersions.libraryId, library.id),
eq(crmDocumentLibraryVersions.version, normalizedVersion),
isNull(crmDocumentLibraryVersions.deletedAt)
)
);
if (existing.length > 0) {
throw new Error(`Version already exists: ${normalizedVersion}`);
}
const parsedFile = await parsePdfUpload({
fileName: input.file.name,
mimeType: input.file.type,
size: input.file.size,
buffer: input.file.buffer
});
const versionId = crypto.randomUUID();
const storageKey = buildOrganizationStorageKey(input.organizationId, [
'document-library',
library.id,
versionId,
sanitizeFileName(parsedFile.fileName)
]);
const stored = await getStorageProvider().putObject({
key: storageKey,
body: input.file.buffer,
contentType: parsedFile.contentType,
fileName: parsedFile.fileName
});
const [created] = await db
.insert(crmDocumentLibraryVersions)
.values({
id: versionId,
organizationId: input.organizationId,
libraryId: library.id,
version: normalizedVersion,
fileName: parsedFile.fileName,
storageProvider: stored.provider,
storageKey: stored.key,
mimeType: parsedFile.contentType,
fileSize: parsedFile.fileSize,
checksum: parsedFile.checksum,
pageCount: parsedFile.pageCount,
status: 'draft',
isActive: false,
createdBy: input.userId
})
.returning();
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'crm_document_library_version',
entityId: created.id,
action: 'upload',
afterData: created
});
return mapVersion(created);
}
async function setVersionStatus(input: {
versionId: string;
organizationId: string;
userId: string;
nextStatus: DocumentLibraryVersionStatus;
}) {
const current = await getVersionRow(input.versionId, input.organizationId);
if (input.nextStatus === 'archived' && current.isActive) {
throw new Error('Active versions must be replaced before archiving.');
}
assertLifecycleTransition(
current.status as DocumentLibraryVersionStatus,
input.nextStatus
);
const [updated] = await db
.update(crmDocumentLibraryVersions)
.set({
status: input.nextStatus,
isActive: input.nextStatus === 'active',
publishedAt:
input.nextStatus === 'published' || input.nextStatus === 'active'
? current.publishedAt ?? new Date()
: current.publishedAt,
publishedBy:
input.nextStatus === 'published' || input.nextStatus === 'active'
? current.publishedBy ?? input.userId
: current.publishedBy,
archivedAt: input.nextStatus === 'archived' ? new Date() : null,
archivedBy: input.nextStatus === 'archived' ? input.userId : null,
updatedAt: new Date()
})
.where(eq(crmDocumentLibraryVersions.id, current.id))
.returning();
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'crm_document_library_version',
entityId: updated.id,
action: input.nextStatus,
beforeData: current,
afterData: updated
});
return mapVersion(updated);
}
export async function publishDocumentLibraryVersion(input: {
versionId: string;
organizationId: string;
userId: string;
}) {
return setVersionStatus({ ...input, nextStatus: 'published' });
}
export async function activateDocumentLibraryVersion(input: {
versionId: string;
organizationId: string;
userId: string;
}) {
const current = await getVersionRow(input.versionId, input.organizationId);
assertLifecycleTransition(current.status as DocumentLibraryVersionStatus, 'active');
const [updated] = await db.transaction(async (tx) => {
await tx
.update(crmDocumentLibraryVersions)
.set({
isActive: false,
updatedAt: new Date()
})
.where(
and(
eq(crmDocumentLibraryVersions.libraryId, current.libraryId),
eq(crmDocumentLibraryVersions.organizationId, input.organizationId),
isNull(crmDocumentLibraryVersions.deletedAt)
)
);
const [next] = await tx
.update(crmDocumentLibraryVersions)
.set({
status: 'active',
isActive: true,
publishedAt: current.publishedAt ?? new Date(),
publishedBy: current.publishedBy ?? input.userId,
updatedAt: new Date()
})
.where(eq(crmDocumentLibraryVersions.id, current.id))
.returning();
await tx
.update(crmDocumentLibraries)
.set({
isActive: true,
status: 'active',
updatedAt: new Date(),
updatedBy: input.userId
})
.where(eq(crmDocumentLibraries.id, current.libraryId));
return [next] as const;
});
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'crm_document_library_version',
entityId: updated.id,
action: 'activate',
beforeData: current,
afterData: updated
});
return mapVersion(updated);
}
export async function archiveDocumentLibraryVersion(input: {
versionId: string;
organizationId: string;
userId: string;
}) {
return setVersionStatus({ ...input, nextStatus: 'archived' });
}
export async function deleteDraftDocumentLibraryVersion(input: {
versionId: string;
organizationId: string;
userId: string;
}) {
const current = await getVersionRow(input.versionId, input.organizationId);
assertCanDeleteDraftVersion(
current.status as DocumentLibraryVersionStatus,
current.isActive
);
await getStorageProvider().deleteObject({ key: current.storageKey });
const [updated] = await db
.update(crmDocumentLibraryVersions)
.set({
deletedAt: new Date(),
updatedAt: new Date()
})
.where(eq(crmDocumentLibraryVersions.id, current.id))
.returning();
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'crm_document_library_version',
entityId: current.id,
action: 'delete',
beforeData: current,
afterData: updated
});
return mapVersion(updated);
}
async function streamVersionFile(input: {
versionId: string;
organizationId: string;
userId: string;
action: 'preview' | 'download';
}) {
const version = await getVersionRow(input.versionId, input.organizationId);
const object = await getStorageProvider().getObject({ key: version.storageKey });
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'crm_document_library_version',
entityId: version.id,
action: input.action,
afterData: {
storageKey: version.storageKey,
fileName: version.fileName
}
});
return {
version: mapVersion(version),
object: {
...object,
fileName: version.fileName,
contentType: version.mimeType,
size: version.fileSize,
checksum: version.checksum
}
};
}
export async function previewDocumentLibraryVersion(input: {
versionId: string;
organizationId: string;
userId: string;
}) {
return streamVersionFile({ ...input, action: 'preview' });
}
export async function downloadDocumentLibraryVersion(input: {
versionId: string;
organizationId: string;
userId: string;
}) {
return streamVersionFile({ ...input, action: 'download' });
}
export async function downloadActiveDocumentLibraryVersion(input: {
libraryId: string;
organizationId: string;
userId: string;
}) {
const [activeVersion] = await db
.select()
.from(crmDocumentLibraryVersions)
.where(
and(
eq(crmDocumentLibraryVersions.libraryId, input.libraryId),
eq(crmDocumentLibraryVersions.organizationId, input.organizationId),
eq(crmDocumentLibraryVersions.isActive, true),
isNull(crmDocumentLibraryVersions.deletedAt)
)
)
.limit(1);
if (!activeVersion) {
throw new AuthError('No active document version found', 404);
}
return downloadDocumentLibraryVersion({
versionId: activeVersion.id,
organizationId: input.organizationId,
userId: input.userId
});
}
export async function previewActiveDocumentLibraryVersion(input: {
libraryId: string;
organizationId: string;
userId: string;
}) {
const [activeVersion] = await db
.select()
.from(crmDocumentLibraryVersions)
.where(
and(
eq(crmDocumentLibraryVersions.libraryId, input.libraryId),
eq(crmDocumentLibraryVersions.organizationId, input.organizationId),
eq(crmDocumentLibraryVersions.isActive, true),
isNull(crmDocumentLibraryVersions.deletedAt)
)
)
.limit(1);
if (!activeVersion) {
throw new AuthError('No active document version found', 404);
}
return previewDocumentLibraryVersion({
versionId: activeVersion.id,
organizationId: input.organizationId,
userId: input.userId
});
}
export async function listDocumentLibraryPublishers(
versionIds: string[]
): Promise<Record<string, string | null>> {
if (!versionIds.length) {
return {};
}
const rows = await db
.select({
id: users.id,
name: users.name
})
.from(users)
.where(
inArray(
users.id,
(
await db
.select({ publishedBy: crmDocumentLibraryVersions.publishedBy })
.from(crmDocumentLibraryVersions)
.where(inArray(crmDocumentLibraryVersions.id, versionIds))
)
.map((row) => row.publishedBy)
.filter((value): value is string => typeof value === 'string')
)
);
return Object.fromEntries(rows.map((row) => [row.id, row.name]));
}

View File

@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PDFDocument } from '@pdfme/pdf-lib';
import {
assertCanDeleteDraftVersion,
assertLifecycleTransition,
parsePdfUpload
} from './validation';
test('assertLifecycleTransition rejects invalid active archive transition', () => {
assert.throws(
() => assertLifecycleTransition('active', 'archived'),
/Invalid lifecycle transition/
);
});
test('assertCanDeleteDraftVersion rejects non-draft versions', () => {
assert.throws(
() => assertCanDeleteDraftVersion('published', false),
/Only inactive draft versions can be deleted/
);
});
test('parsePdfUpload accepts readable pdf and extracts page count', async () => {
const pdf = await PDFDocument.create();
pdf.addPage([200, 200]);
const bytes = await pdf.save();
const parsed = await parsePdfUpload({
fileName: 'sample.pdf',
mimeType: 'application/pdf',
size: bytes.length,
buffer: Buffer.from(bytes)
});
assert.equal(parsed.pageCount, 1);
assert.equal(parsed.contentType, 'application/pdf');
assert.equal(parsed.fileName, 'sample.pdf');
});

View File

@@ -0,0 +1,110 @@
import { PDFDocument } from '@pdfme/pdf-lib';
import { sha256 } from '@/features/foundation/storage/server/checksum';
import {
DOCUMENT_LIBRARY_BRANDS,
DOCUMENT_LIBRARY_LANGUAGES,
DOCUMENT_LIBRARY_PRODUCT_TYPES,
DOCUMENT_LIBRARY_TYPES,
type DocumentLibraryBrand,
type DocumentLibraryLanguage,
type DocumentLibraryProductType,
type DocumentLibraryType,
type DocumentLibraryVersionStatus
} from '../types';
export const MAX_DOCUMENT_LIBRARY_FILE_SIZE = 20 * 1024 * 1024;
const ALLOWED_STATUS_TRANSITIONS: Record<
DocumentLibraryVersionStatus,
DocumentLibraryVersionStatus[]
> = {
draft: ['published', 'archived'],
published: ['active', 'archived'],
active: [],
archived: []
};
export function normalizeDocumentLibraryCode(value: string) {
return value.trim().toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '');
}
export function assertSupportedDocumentType(value: string): asserts value is DocumentLibraryType {
if (!DOCUMENT_LIBRARY_TYPES.includes(value as DocumentLibraryType)) {
throw new Error(`Unsupported document type: ${value}`);
}
}
export function assertSupportedBrand(value: string): asserts value is DocumentLibraryBrand {
if (!DOCUMENT_LIBRARY_BRANDS.includes(value as DocumentLibraryBrand)) {
throw new Error(`Unsupported brand: ${value}`);
}
}
export function assertSupportedLanguage(
value: string
): asserts value is DocumentLibraryLanguage {
if (!DOCUMENT_LIBRARY_LANGUAGES.includes(value as DocumentLibraryLanguage)) {
throw new Error(`Unsupported language: ${value}`);
}
}
export function assertSupportedProductType(
value: string
): asserts value is DocumentLibraryProductType {
if (!DOCUMENT_LIBRARY_PRODUCT_TYPES.includes(value as DocumentLibraryProductType)) {
throw new Error(`Unsupported product type: ${value}`);
}
}
export function assertLifecycleTransition(
currentStatus: DocumentLibraryVersionStatus,
nextStatus: DocumentLibraryVersionStatus
) {
if (currentStatus === nextStatus) {
return;
}
if (!ALLOWED_STATUS_TRANSITIONS[currentStatus].includes(nextStatus)) {
throw new Error(`Invalid lifecycle transition: ${currentStatus} -> ${nextStatus}`);
}
}
export function assertCanDeleteDraftVersion(status: DocumentLibraryVersionStatus, isActive: boolean) {
if (status !== 'draft' || isActive) {
throw new Error('Only inactive draft versions can be deleted.');
}
}
export async function parsePdfUpload(args: {
fileName: string;
mimeType: string;
size: number;
buffer: Buffer;
}) {
if (args.mimeType !== 'application/pdf') {
throw new Error('Only PDF uploads are supported.');
}
if (args.size <= 0) {
throw new Error('Uploaded file is empty.');
}
if (args.size > MAX_DOCUMENT_LIBRARY_FILE_SIZE) {
throw new Error('Uploaded PDF exceeds the size limit.');
}
const pdf = await PDFDocument.load(args.buffer);
const pageCount = pdf.getPageCount();
if (pageCount <= 0) {
throw new Error('Uploaded PDF must contain at least one page.');
}
return {
contentType: 'application/pdf' as const,
checksum: sha256(args.buffer),
fileSize: args.size,
fileName: args.fileName,
pageCount
};
}

View File

@@ -0,0 +1,119 @@
import { apiClient } from '@/lib/api-client';
import type {
DocumentLibraryDetailResponse,
DocumentLibraryFilters,
DocumentLibraryListResponse,
DocumentLibraryMutationPayload,
DocumentLibraryVersionActionResponse,
DocumentLibraryVersionUploadPayload,
MutationSuccessResponse
} from './types';
const BASE_PATH = '/crm/settings/document-library';
async function uploadFormData<T>(endpoint: string, formData: FormData): Promise<T> {
const response = await fetch(`/api${endpoint}`, {
method: 'POST',
body: formData
});
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as
| { message?: string }
| null;
throw new Error(payload?.message || `API error: ${response.status}`);
}
return response.json() as Promise<T>;
}
export async function getDocumentLibraries(filters: DocumentLibraryFilters = {}) {
const searchParams = new URLSearchParams();
if (filters.documentType) searchParams.set('documentType', filters.documentType);
if (filters.brand) searchParams.set('brand', filters.brand);
if (filters.language) searchParams.set('language', filters.language);
if (filters.productType) searchParams.set('productType', filters.productType);
if (filters.isActive) searchParams.set('isActive', filters.isActive);
const query = searchParams.toString();
return apiClient<DocumentLibraryListResponse>(`${BASE_PATH}${query ? `?${query}` : ''}`);
}
export async function getDocumentLibraryById(id: string) {
return apiClient<DocumentLibraryDetailResponse>(`${BASE_PATH}/${id}`);
}
export async function createDocumentLibrary(values: DocumentLibraryMutationPayload) {
return apiClient<MutationSuccessResponse>(BASE_PATH, {
method: 'POST',
body: JSON.stringify(values)
});
}
export async function updateDocumentLibrary(
id: string,
values: Partial<DocumentLibraryMutationPayload>
) {
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/${id}`, {
method: 'PATCH',
body: JSON.stringify(values)
});
}
export async function uploadDocumentLibraryVersion(
libraryId: string,
payload: DocumentLibraryVersionUploadPayload
) {
const formData = new FormData();
formData.set('version', payload.version);
formData.set('file', payload.file);
return uploadFormData<DocumentLibraryVersionActionResponse>(
`${BASE_PATH}/${libraryId}/versions`,
formData
);
}
export async function publishDocumentLibraryVersion(versionId: string) {
return apiClient<DocumentLibraryVersionActionResponse>(
`${BASE_PATH}/versions/${versionId}/publish`,
{ method: 'POST', body: JSON.stringify({}) }
);
}
export async function activateDocumentLibraryVersion(versionId: string) {
return apiClient<DocumentLibraryVersionActionResponse>(
`${BASE_PATH}/versions/${versionId}/activate`,
{ method: 'POST', body: JSON.stringify({}) }
);
}
export async function archiveDocumentLibraryVersion(versionId: string) {
return apiClient<DocumentLibraryVersionActionResponse>(
`${BASE_PATH}/versions/${versionId}/archive`,
{ method: 'POST', body: JSON.stringify({}) }
);
}
export async function deleteDraftDocumentLibraryVersion(versionId: string) {
return apiClient<DocumentLibraryVersionActionResponse>(
`${BASE_PATH}/versions/${versionId}`,
{ method: 'DELETE' }
);
}
export function getDocumentLibraryPreviewUrl(versionId: string) {
return `/api${BASE_PATH}/versions/${versionId}/preview`;
}
export function getDocumentLibraryDownloadUrl(versionId: string) {
return `/api${BASE_PATH}/versions/${versionId}/download`;
}
export function getActiveDocumentLibraryPreviewUrl(libraryId: string) {
return `/api${BASE_PATH}/${libraryId}/preview`;
}
export function getActiveDocumentLibraryDownloadUrl(libraryId: string) {
return `/api${BASE_PATH}/${libraryId}/download`;
}

View File

@@ -0,0 +1,130 @@
export const DOCUMENT_LIBRARY_TYPES = [
'sla',
'warranty',
'terms_conditions',
'company_profile',
'datasheet',
'drawing',
'certificate',
'appendix',
'safety_document',
'installation_manual'
] as const;
export const DOCUMENT_LIBRARY_BRANDS = ['alla', 'onvalla', 'generic'] as const;
export const DOCUMENT_LIBRARY_LANGUAGES = ['th', 'en'] as const;
export const DOCUMENT_LIBRARY_PRODUCT_TYPES = [
'all',
'crane',
'dock_door',
'solar',
'service'
] as const;
export const DOCUMENT_LIBRARY_VERSION_STATUSES = [
'draft',
'published',
'active',
'archived'
] as const;
export type DocumentLibraryType = (typeof DOCUMENT_LIBRARY_TYPES)[number];
export type DocumentLibraryBrand = (typeof DOCUMENT_LIBRARY_BRANDS)[number];
export type DocumentLibraryLanguage = (typeof DOCUMENT_LIBRARY_LANGUAGES)[number];
export type DocumentLibraryProductType = (typeof DOCUMENT_LIBRARY_PRODUCT_TYPES)[number];
export type DocumentLibraryVersionStatus =
(typeof DOCUMENT_LIBRARY_VERSION_STATUSES)[number];
export interface DocumentLibraryRecord {
id: string;
organizationId: string;
code: string;
name: string;
documentType: DocumentLibraryType;
description: string | null;
brand: DocumentLibraryBrand;
language: DocumentLibraryLanguage;
productType: DocumentLibraryProductType;
status: 'active' | 'inactive';
isActive: boolean;
createdBy: string;
updatedBy: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export interface DocumentLibraryVersionRecord {
id: string;
organizationId: string;
libraryId: string;
version: string;
fileName: string;
storageProvider: string;
storageKey: string;
mimeType: string;
fileSize: number;
checksum: string;
pageCount: number;
status: DocumentLibraryVersionStatus;
isActive: boolean;
publishedAt: string | null;
publishedBy: string | null;
archivedAt: string | null;
archivedBy: string | null;
createdBy: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export interface DocumentLibraryListItem extends DocumentLibraryRecord {
activeVersion: string | null;
versionCount: number;
}
export interface DocumentLibraryDetail extends DocumentLibraryRecord {
versions: DocumentLibraryVersionRecord[];
}
export interface DocumentLibraryFilters {
documentType?: DocumentLibraryType;
brand?: DocumentLibraryBrand;
language?: DocumentLibraryLanguage;
productType?: DocumentLibraryProductType;
isActive?: 'true' | 'false';
}
export interface DocumentLibraryMutationPayload {
code: string;
name: string;
documentType: DocumentLibraryType;
description?: string | null;
brand: DocumentLibraryBrand;
language: DocumentLibraryLanguage;
productType: DocumentLibraryProductType;
isActive?: boolean;
}
export interface DocumentLibraryVersionUploadPayload {
version: string;
file: File;
}
export interface MutationSuccessResponse {
success: boolean;
message: string;
}
export interface DocumentLibraryListResponse extends MutationSuccessResponse {
time: string;
items: DocumentLibraryListItem[];
}
export interface DocumentLibraryDetailResponse extends MutationSuccessResponse {
time: string;
library: DocumentLibraryDetail;
}
export interface DocumentLibraryVersionActionResponse extends MutationSuccessResponse {
version: DocumentLibraryVersionRecord;
}

View File

@@ -15,6 +15,14 @@ import {
DialogTitle
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle
} from '@/components/ui/sheet';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
@@ -290,7 +298,7 @@ function TemplateDialog({
);
}
function VersionDialog({
function VersionSheet({
open,
onOpenChange,
templateId,
@@ -340,45 +348,67 @@ function VersionDialog({
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-3xl'>
<DialogHeader>
<DialogTitle>{version ? 'Edit Version' : 'Create Version'}</DialogTitle>
<DialogDescription>Schema JSON is accepted as raw JSON in this admin release.</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className='grid gap-4'>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side='right' className='w-full sm:max-w-3xl'>
<SheetHeader>
<SheetTitle>{version ? 'Edit Version' : 'Create Version'}</SheetTitle>
<SheetDescription>
Schema JSON is accepted as raw JSON in this admin release.
</SheetDescription>
</SheetHeader>
<form onSubmit={onSubmit} className='flex h-full flex-col gap-4 overflow-y-auto pb-6'>
<div className='grid gap-4 md:grid-cols-2'>
<Field label='Version'>
<Input value={state.version} onChange={(e) => setState((current) => ({ ...current, version: e.target.value }))} />
<Input
value={state.version}
onChange={(e) => setState((current) => ({ ...current, version: e.target.value }))}
/>
</Field>
<Field label='File Path'>
<Input value={state.filePath} onChange={(e) => setState((current) => ({ ...current, filePath: e.target.value }))} />
<Input
value={state.filePath}
onChange={(e) => setState((current) => ({ ...current, filePath: e.target.value }))}
/>
</Field>
</div>
<Field label='Preview Image URL'>
<Input value={state.previewImageUrl} onChange={(e) => setState((current) => ({ ...current, previewImageUrl: e.target.value }))} />
<Input
value={state.previewImageUrl}
onChange={(e) =>
setState((current) => ({ ...current, previewImageUrl: e.target.value }))
}
/>
</Field>
<Field label='Schema JSON'>
<Textarea className='min-h-64 font-mono text-xs' value={state.schemaJson} onChange={(e) => setState((current) => ({ ...current, schemaJson: e.target.value }))} />
<Textarea
className='min-h-64 font-mono text-xs'
value={state.schemaJson}
onChange={(e) => setState((current) => ({ ...current, schemaJson: e.target.value }))}
/>
</Field>
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
<div>
<div className='font-medium'>Active Version</div>
<div className='text-muted-foreground text-sm'>Inactive versions remain stored for traceability.</div>
<div className='text-muted-foreground text-sm'>
Inactive versions remain stored for traceability.
</div>
</div>
<Switch checked={state.isActive} onCheckedChange={(checked) => setState((current) => ({ ...current, isActive: checked }))} />
<Switch
checked={state.isActive}
onCheckedChange={(checked) => setState((current) => ({ ...current, isActive: checked }))}
/>
</div>
<DialogFooter>
<SheetFooter className='mt-auto sm:flex-row sm:justify-end'>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type='submit' isLoading={isPending}>
{version ? 'Save Version' : 'Create Version'}
</Button>
</DialogFooter>
</SheetFooter>
</form>
</DialogContent>
</Dialog>
</SheetContent>
</Sheet>
);
}
@@ -715,7 +745,7 @@ function TemplateDetailCard({ templateId }: { templateId: string }) {
))}
</Tabs>
<VersionDialog
<VersionSheet
open={versionDialogState.open}
onOpenChange={(open) =>
setVersionDialogState((current) => ({ ...current, open }))

View File

@@ -112,6 +112,14 @@ export const PERMISSIONS = {
crmDocumentTemplateActivate: 'crm.document_template.activate',
crmDocumentTemplateRollback: 'crm.document_template.rollback',
crmDocumentTemplateArchive: 'crm.document_template.archive',
crmDocumentLibraryRead: 'crm.document_library.read',
crmDocumentLibraryCreate: 'crm.document_library.create',
crmDocumentLibraryUpdate: 'crm.document_library.update',
crmDocumentLibraryUpload: 'crm.document_library.upload',
crmDocumentLibraryPublish: 'crm.document_library.publish',
crmDocumentLibraryActivate: 'crm.document_library.activate',
crmDocumentLibraryArchive: 'crm.document_library.archive',
crmDocumentLibraryDownload: 'crm.document_library.download',
crmDocumentSequenceRead: 'crm.document_sequence.read',
crmDocumentSequenceCreate: 'crm.document_sequence.create',
crmDocumentSequenceUpdate: 'crm.document_sequence.update',
@@ -661,6 +669,14 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
{ key: PERMISSIONS.crmDocumentTemplateActivate, label: 'Activate document templates' },
{ key: PERMISSIONS.crmDocumentTemplateRollback, label: 'Rollback document templates' },
{ key: PERMISSIONS.crmDocumentTemplateArchive, label: 'Archive document templates' },
{ key: PERMISSIONS.crmDocumentLibraryRead, label: 'Read document library' },
{ key: PERMISSIONS.crmDocumentLibraryCreate, label: 'Create document library items' },
{ key: PERMISSIONS.crmDocumentLibraryUpdate, label: 'Update document library items' },
{ key: PERMISSIONS.crmDocumentLibraryUpload, label: 'Upload document library versions' },
{ key: PERMISSIONS.crmDocumentLibraryPublish, label: 'Publish document library versions' },
{ key: PERMISSIONS.crmDocumentLibraryActivate, label: 'Activate document library versions' },
{ key: PERMISSIONS.crmDocumentLibraryArchive, label: 'Archive document library versions' },
{ key: PERMISSIONS.crmDocumentLibraryDownload, label: 'Download document library versions' },
{ key: PERMISSIONS.crmDocumentSequenceRead, label: 'Read document sequences' },
{ key: PERMISSIONS.crmDocumentSequenceCreate, label: 'Create document sequences' },
{ key: PERMISSIONS.crmDocumentSequenceUpdate, label: 'Update document sequences' },