task-p.6 migrate
This commit is contained in:
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
92
src/app/api/crm/settings/document-library/[id]/route.ts
Normal file
92
src/app/api/crm/settings/document-library/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
102
src/app/api/crm/settings/document-library/route.ts
Normal file
102
src/app/api/crm/settings/document-library/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
41
src/app/dashboard/crm/settings/document-library/page.tsx
Normal file
41
src/app/dashboard/crm/settings/document-library/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
101
src/features/foundation/document-library/mutations.ts
Normal file
101
src/features/foundation/document-library/mutations.ts
Normal 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
|
||||
);
|
||||
25
src/features/foundation/document-library/queries.ts
Normal file
25
src/features/foundation/document-library/queries.ts
Normal 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)
|
||||
});
|
||||
726
src/features/foundation/document-library/server/service.ts
Normal file
726
src/features/foundation/document-library/server/service.ts
Normal 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]));
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
110
src/features/foundation/document-library/server/validation.ts
Normal file
110
src/features/foundation/document-library/server/validation.ts
Normal 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
|
||||
};
|
||||
}
|
||||
119
src/features/foundation/document-library/service.ts
Normal file
119
src/features/foundation/document-library/service.ts
Normal 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`;
|
||||
}
|
||||
130
src/features/foundation/document-library/types.ts
Normal file
130
src/features/foundation/document-library/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user