feat: comprehensive project structure improvements and Cloud for Sovereignty landing zone
- Add Cloud for Sovereignty landing zone architecture and deployment - Implement complete legal document management system - Reorganize documentation with improved navigation - Add infrastructure improvements (Dockerfiles, K8s, monitoring) - Add operational improvements (graceful shutdown, rate limiting, caching) - Create comprehensive project structure documentation - Add Azure deployment automation scripts - Improve repository navigation and organization
This commit is contained in:
359
packages/database/src/clause-library.ts
Normal file
359
packages/database/src/clause-library.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* Clause Library Management
|
||||
* Handles reusable clause library for document assembly
|
||||
*/
|
||||
|
||||
import { query } from './client';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ClauseLibrarySchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
title: z.string().optional(),
|
||||
clause_text: z.string(),
|
||||
category: z.string().optional(),
|
||||
subcategory: z.string().optional(),
|
||||
jurisdiction: z.string().optional(),
|
||||
practice_area: z.string().optional(),
|
||||
variables: z.record(z.unknown()).optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
version: z.number().int().positive(),
|
||||
is_active: z.boolean(),
|
||||
is_public: z.boolean(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
created_by: z.string().uuid().optional(),
|
||||
created_at: z.date(),
|
||||
updated_at: z.date(),
|
||||
});
|
||||
|
||||
export type ClauseLibrary = z.infer<typeof ClauseLibrarySchema>;
|
||||
|
||||
export interface CreateClauseInput {
|
||||
name: string;
|
||||
title?: string;
|
||||
clause_text: string;
|
||||
category?: string;
|
||||
subcategory?: string;
|
||||
jurisdiction?: string;
|
||||
practice_area?: string;
|
||||
variables?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
version?: number;
|
||||
is_active?: boolean;
|
||||
is_public?: boolean;
|
||||
tags?: string[];
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a clause
|
||||
*/
|
||||
export async function createClause(input: CreateClauseInput): Promise<ClauseLibrary> {
|
||||
const result = await query<ClauseLibrary>(
|
||||
`INSERT INTO clause_library
|
||||
(name, title, clause_text, category, subcategory, jurisdiction, practice_area,
|
||||
variables, metadata, version, is_active, is_public, tags, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.name,
|
||||
input.title || null,
|
||||
input.clause_text,
|
||||
input.category || null,
|
||||
input.subcategory || null,
|
||||
input.jurisdiction || null,
|
||||
input.practice_area || null,
|
||||
input.variables ? JSON.stringify(input.variables) : null,
|
||||
input.metadata ? JSON.stringify(input.metadata) : null,
|
||||
input.version || 1,
|
||||
input.is_active !== false,
|
||||
input.is_public || false,
|
||||
input.tags || [],
|
||||
input.created_by || null,
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clause by ID
|
||||
*/
|
||||
export async function getClause(id: string): Promise<ClauseLibrary | null> {
|
||||
const result = await query<ClauseLibrary>(
|
||||
`SELECT * FROM clause_library WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clause by name and version
|
||||
*/
|
||||
export async function getClauseByName(
|
||||
name: string,
|
||||
version?: number
|
||||
): Promise<ClauseLibrary | null> {
|
||||
if (version) {
|
||||
const result = await query<ClauseLibrary>(
|
||||
`SELECT * FROM clause_library
|
||||
WHERE name = $1 AND version = $2`,
|
||||
[name, version]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
} else {
|
||||
const result = await query<ClauseLibrary>(
|
||||
`SELECT * FROM clause_library
|
||||
WHERE name = $1 AND is_active = TRUE
|
||||
ORDER BY version DESC
|
||||
LIMIT 1`,
|
||||
[name]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List clauses with filters
|
||||
*/
|
||||
export interface ClauseFilters {
|
||||
category?: string;
|
||||
subcategory?: string;
|
||||
jurisdiction?: string;
|
||||
practice_area?: string;
|
||||
is_active?: boolean;
|
||||
is_public?: boolean;
|
||||
tags?: string[];
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export async function listClauses(
|
||||
filters: ClauseFilters = {},
|
||||
limit = 100,
|
||||
offset = 0
|
||||
): Promise<ClauseLibrary[]> {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (filters.category) {
|
||||
conditions.push(`category = $${paramIndex++}`);
|
||||
params.push(filters.category);
|
||||
}
|
||||
|
||||
if (filters.subcategory) {
|
||||
conditions.push(`subcategory = $${paramIndex++}`);
|
||||
params.push(filters.subcategory);
|
||||
}
|
||||
|
||||
if (filters.jurisdiction) {
|
||||
conditions.push(`jurisdiction = $${paramIndex++}`);
|
||||
params.push(filters.jurisdiction);
|
||||
}
|
||||
|
||||
if (filters.practice_area) {
|
||||
conditions.push(`practice_area = $${paramIndex++}`);
|
||||
params.push(filters.practice_area);
|
||||
}
|
||||
|
||||
if (filters.is_active !== undefined) {
|
||||
conditions.push(`is_active = $${paramIndex++}`);
|
||||
params.push(filters.is_active);
|
||||
}
|
||||
|
||||
if (filters.is_public !== undefined) {
|
||||
conditions.push(`is_public = $${paramIndex++}`);
|
||||
params.push(filters.is_public);
|
||||
}
|
||||
|
||||
if (filters.tags && filters.tags.length > 0) {
|
||||
conditions.push(`tags && $${paramIndex++}`);
|
||||
params.push(filters.tags);
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
conditions.push(
|
||||
`(name ILIKE $${paramIndex} OR title ILIKE $${paramIndex} OR clause_text ILIKE $${paramIndex})`
|
||||
);
|
||||
params.push(`%${filters.search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const result = await query<ClauseLibrary>(
|
||||
`SELECT * FROM clause_library
|
||||
${whereClause}
|
||||
ORDER BY name, version DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
||||
[...params, limit, offset]
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update clause
|
||||
*/
|
||||
export async function updateClause(
|
||||
id: string,
|
||||
updates: Partial<
|
||||
Pick<
|
||||
ClauseLibrary,
|
||||
| 'title'
|
||||
| 'clause_text'
|
||||
| 'category'
|
||||
| 'subcategory'
|
||||
| 'jurisdiction'
|
||||
| 'practice_area'
|
||||
| 'variables'
|
||||
| 'metadata'
|
||||
| 'is_active'
|
||||
| 'tags'
|
||||
>
|
||||
>
|
||||
): Promise<ClauseLibrary | null> {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
const fieldMap: Record<string, string> = {
|
||||
title: 'title',
|
||||
clause_text: 'clause_text',
|
||||
category: 'category',
|
||||
subcategory: 'subcategory',
|
||||
jurisdiction: 'jurisdiction',
|
||||
practice_area: 'practice_area',
|
||||
variables: 'variables',
|
||||
metadata: 'metadata',
|
||||
is_active: 'is_active',
|
||||
tags: 'tags',
|
||||
};
|
||||
|
||||
for (const [key, dbField] of Object.entries(fieldMap)) {
|
||||
if (key in updates && updates[key as keyof typeof updates] !== undefined) {
|
||||
const value = updates[key as keyof typeof updates];
|
||||
if (key === 'variables' || key === 'metadata') {
|
||||
fields.push(`${dbField} = $${paramIndex++}`);
|
||||
values.push(JSON.stringify(value));
|
||||
} else {
|
||||
fields.push(`${dbField} = $${paramIndex++}`);
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return getClause(id);
|
||||
}
|
||||
|
||||
fields.push(`updated_at = NOW()`);
|
||||
values.push(id);
|
||||
|
||||
const result = await query<ClauseLibrary>(
|
||||
`UPDATE clause_library
|
||||
SET ${fields.join(', ')}
|
||||
WHERE id = $${paramIndex}
|
||||
RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new clause version
|
||||
*/
|
||||
export async function createClauseVersion(
|
||||
clauseId: string,
|
||||
updates: Partial<Pick<ClauseLibrary, 'clause_text' | 'title' | 'variables' | 'metadata'>>
|
||||
): Promise<ClauseLibrary> {
|
||||
const original = await getClause(clauseId);
|
||||
if (!original) {
|
||||
throw new Error(`Clause ${clauseId} not found`);
|
||||
}
|
||||
|
||||
const versionResult = await query<{ max_version: number }>(
|
||||
`SELECT MAX(version) as max_version FROM clause_library WHERE name = $1`,
|
||||
[original.name]
|
||||
);
|
||||
const nextVersion = (versionResult.rows[0]?.max_version || 0) + 1;
|
||||
|
||||
return createClause({
|
||||
name: original.name,
|
||||
title: updates.title || original.title,
|
||||
clause_text: updates.clause_text || original.clause_text,
|
||||
category: original.category,
|
||||
subcategory: original.subcategory,
|
||||
jurisdiction: original.jurisdiction,
|
||||
practice_area: original.practice_area,
|
||||
variables: updates.variables || original.variables,
|
||||
metadata: updates.metadata || original.metadata,
|
||||
version: nextVersion,
|
||||
is_active: true,
|
||||
is_public: original.is_public,
|
||||
tags: original.tags,
|
||||
created_by: original.created_by,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render clause with variables
|
||||
*/
|
||||
export function renderClause(
|
||||
clause: ClauseLibrary,
|
||||
variables: Record<string, unknown>
|
||||
): string {
|
||||
let rendered = clause.clause_text;
|
||||
|
||||
// Replace {{variable}} patterns
|
||||
rendered = rendered.replace(/\{\{(\w+)\}\}/g, (match, varName) => {
|
||||
const value = variables[varName];
|
||||
return value !== undefined && value !== null ? String(value) : match;
|
||||
});
|
||||
|
||||
// Support nested variables {{object.property}}
|
||||
rendered = rendered.replace(/\{\{(\w+(?:\.\w+)+)\}\}/g, (match, path) => {
|
||||
const parts = path.split('.');
|
||||
let value: unknown = variables;
|
||||
for (const part of parts) {
|
||||
if (value && typeof value === 'object' && part in value) {
|
||||
value = (value as Record<string, unknown>)[part];
|
||||
} else {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return value !== undefined && value !== null ? String(value) : match;
|
||||
});
|
||||
|
||||
return rendered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clause usage statistics
|
||||
*/
|
||||
export interface ClauseUsageStats {
|
||||
clause_id: string;
|
||||
clause_name: string;
|
||||
usage_count: number;
|
||||
last_used?: Date;
|
||||
}
|
||||
|
||||
export async function getClauseUsageStats(
|
||||
clause_id: string
|
||||
): Promise<ClauseUsageStats | null> {
|
||||
// This would query a usage tracking table (to be created)
|
||||
// For now, return basic info
|
||||
const clause = await getClause(clause_id);
|
||||
if (!clause) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
clause_id: clause.id,
|
||||
clause_name: clause.name,
|
||||
usage_count: 0, // TODO: Implement usage tracking
|
||||
last_used: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
357
packages/database/src/court-filings.ts
Normal file
357
packages/database/src/court-filings.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Court Filing Management
|
||||
* Handles e-filing, court submissions, and filing tracking
|
||||
*/
|
||||
|
||||
import { query } from './client';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CourtFilingSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
matter_id: z.string().uuid(),
|
||||
document_id: z.string().uuid(),
|
||||
filing_type: z.string(),
|
||||
court_name: z.string(),
|
||||
court_system: z.string().optional(),
|
||||
case_number: z.string().optional(),
|
||||
docket_number: z.string().optional(),
|
||||
filing_date: z.date().optional(),
|
||||
filing_deadline: z.date().optional(),
|
||||
status: z.enum(['draft', 'submitted', 'accepted', 'rejected', 'filed']),
|
||||
filing_reference: z.string().optional(),
|
||||
filing_confirmation: z.string().optional(),
|
||||
submitted_by: z.string().uuid().optional(),
|
||||
submitted_at: z.date().optional(),
|
||||
accepted_at: z.date().optional(),
|
||||
rejection_reason: z.string().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
created_at: z.date(),
|
||||
updated_at: z.date(),
|
||||
});
|
||||
|
||||
export type CourtFiling = z.infer<typeof CourtFilingSchema>;
|
||||
|
||||
export type FilingType =
|
||||
| 'pleading'
|
||||
| 'motion'
|
||||
| 'brief'
|
||||
| 'exhibit'
|
||||
| 'affidavit'
|
||||
| 'order'
|
||||
| 'judgment'
|
||||
| 'notice'
|
||||
| 'response'
|
||||
| 'reply'
|
||||
| 'other';
|
||||
|
||||
export type CourtSystem = 'federal' | 'state' | 'municipal' | 'administrative' | 'other';
|
||||
|
||||
export interface CreateCourtFilingInput {
|
||||
matter_id: string;
|
||||
document_id: string;
|
||||
filing_type: FilingType;
|
||||
court_name: string;
|
||||
court_system?: CourtSystem;
|
||||
case_number?: string;
|
||||
docket_number?: string;
|
||||
filing_date?: Date | string;
|
||||
filing_deadline?: Date | string;
|
||||
status?: 'draft' | 'submitted' | 'accepted' | 'rejected' | 'filed';
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a court filing record
|
||||
*/
|
||||
export async function createCourtFiling(input: CreateCourtFilingInput): Promise<CourtFiling> {
|
||||
const result = await query<CourtFiling>(
|
||||
`INSERT INTO court_filings
|
||||
(matter_id, document_id, filing_type, court_name, court_system,
|
||||
case_number, docket_number, filing_date, filing_deadline, status, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.matter_id,
|
||||
input.document_id,
|
||||
input.filing_type,
|
||||
input.court_name,
|
||||
input.court_system || null,
|
||||
input.case_number || null,
|
||||
input.docket_number || null,
|
||||
input.filing_date ? new Date(input.filing_date) : null,
|
||||
input.filing_deadline ? new Date(input.filing_deadline) : null,
|
||||
input.status || 'draft',
|
||||
input.metadata ? JSON.stringify(input.metadata) : null,
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filing by ID
|
||||
*/
|
||||
export async function getCourtFiling(id: string): Promise<CourtFiling | null> {
|
||||
const result = await query<CourtFiling>(
|
||||
`SELECT * FROM court_filings WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filings for a matter
|
||||
*/
|
||||
export async function getMatterFilings(
|
||||
matter_id: string,
|
||||
status?: string
|
||||
): Promise<CourtFiling[]> {
|
||||
const conditions = ['matter_id = $1'];
|
||||
const params: unknown[] = [matter_id];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (status) {
|
||||
conditions.push(`status = $${paramIndex++}`);
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
const result = await query<CourtFiling>(
|
||||
`SELECT * FROM court_filings
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY filing_date DESC NULLS LAST, created_at DESC`,
|
||||
params
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filings for a document
|
||||
*/
|
||||
export async function getDocumentFilings(document_id: string): Promise<CourtFiling[]> {
|
||||
const result = await query<CourtFiling>(
|
||||
`SELECT * FROM court_filings
|
||||
WHERE document_id = $1
|
||||
ORDER BY filing_date DESC NULLS LAST`,
|
||||
[document_id]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update filing status
|
||||
*/
|
||||
export async function updateFilingStatus(
|
||||
id: string,
|
||||
status: 'draft' | 'submitted' | 'accepted' | 'rejected' | 'filed',
|
||||
updates?: {
|
||||
filing_reference?: string;
|
||||
filing_confirmation?: string;
|
||||
submitted_by?: string;
|
||||
submitted_at?: Date;
|
||||
accepted_at?: Date;
|
||||
rejection_reason?: string;
|
||||
}
|
||||
): Promise<CourtFiling | null> {
|
||||
const fields: string[] = [`status = $1`];
|
||||
const values: unknown[] = [status];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (updates) {
|
||||
if (updates.filing_reference !== undefined) {
|
||||
fields.push(`filing_reference = $${paramIndex++}`);
|
||||
values.push(updates.filing_reference);
|
||||
}
|
||||
|
||||
if (updates.filing_confirmation !== undefined) {
|
||||
fields.push(`filing_confirmation = $${paramIndex++}`);
|
||||
values.push(updates.filing_confirmation);
|
||||
}
|
||||
|
||||
if (updates.submitted_by !== undefined) {
|
||||
fields.push(`submitted_by = $${paramIndex++}`);
|
||||
values.push(updates.submitted_by);
|
||||
}
|
||||
|
||||
if (updates.submitted_at !== undefined) {
|
||||
fields.push(`submitted_at = $${paramIndex++}`);
|
||||
values.push(updates.submitted_at);
|
||||
} else if (status === 'submitted') {
|
||||
fields.push(`submitted_at = NOW()`);
|
||||
}
|
||||
|
||||
if (updates.accepted_at !== undefined) {
|
||||
fields.push(`accepted_at = $${paramIndex++}`);
|
||||
values.push(updates.accepted_at);
|
||||
} else if (status === 'accepted' || status === 'filed') {
|
||||
fields.push(`accepted_at = NOW()`);
|
||||
}
|
||||
|
||||
if (updates.rejection_reason !== undefined) {
|
||||
fields.push(`rejection_reason = $${paramIndex++}`);
|
||||
values.push(updates.rejection_reason);
|
||||
}
|
||||
}
|
||||
|
||||
fields.push(`updated_at = NOW()`);
|
||||
values.push(id);
|
||||
|
||||
const result = await query<CourtFiling>(
|
||||
`UPDATE court_filings
|
||||
SET ${fields.join(', ')}
|
||||
WHERE id = $${paramIndex}
|
||||
RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get upcoming filing deadlines
|
||||
*/
|
||||
export async function getUpcomingFilingDeadlines(
|
||||
days_ahead = 30,
|
||||
matter_id?: string
|
||||
): Promise<CourtFiling[]> {
|
||||
const conditions = [
|
||||
`filing_deadline IS NOT NULL`,
|
||||
`filing_deadline >= CURRENT_DATE`,
|
||||
`filing_deadline <= CURRENT_DATE + INTERVAL '${days_ahead} days'`,
|
||||
`status IN ('draft', 'submitted')`,
|
||||
];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (matter_id) {
|
||||
conditions.push(`matter_id = $${paramIndex++}`);
|
||||
params.push(matter_id);
|
||||
}
|
||||
|
||||
const result = await query<CourtFiling>(
|
||||
`SELECT * FROM court_filings
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY filing_deadline ASC`,
|
||||
params
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filings by court
|
||||
*/
|
||||
export async function getFilingsByCourt(
|
||||
court_name: string,
|
||||
case_number?: string
|
||||
): Promise<CourtFiling[]> {
|
||||
const conditions = ['court_name = $1'];
|
||||
const params: unknown[] = [court_name];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (case_number) {
|
||||
conditions.push(`case_number = $${paramIndex++}`);
|
||||
params.push(case_number);
|
||||
}
|
||||
|
||||
const result = await query<CourtFiling>(
|
||||
`SELECT * FROM court_filings
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY filing_date DESC NULLS LAST`,
|
||||
params
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filing statistics
|
||||
*/
|
||||
export interface FilingStatistics {
|
||||
total: number;
|
||||
by_status: Record<string, number>;
|
||||
by_type: Record<string, number>;
|
||||
upcoming_deadlines: number;
|
||||
overdue: number;
|
||||
}
|
||||
|
||||
export async function getFilingStatistics(
|
||||
matter_id?: string
|
||||
): Promise<FilingStatistics> {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (matter_id) {
|
||||
conditions.push(`matter_id = $${paramIndex++}`);
|
||||
params.push(matter_id);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// Get total and by status
|
||||
const statusResult = await query<{ status: string; count: string }>(
|
||||
`SELECT status, COUNT(*) as count
|
||||
FROM court_filings
|
||||
${whereClause}
|
||||
GROUP BY status`,
|
||||
params
|
||||
);
|
||||
|
||||
// Get by type
|
||||
const typeResult = await query<{ filing_type: string; count: string }>(
|
||||
`SELECT filing_type, COUNT(*) as count
|
||||
FROM court_filings
|
||||
${whereClause}
|
||||
GROUP BY filing_type`,
|
||||
params
|
||||
);
|
||||
|
||||
// Get upcoming deadlines
|
||||
const upcomingResult = await query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM court_filings
|
||||
${whereClause}
|
||||
AND filing_deadline IS NOT NULL
|
||||
AND filing_deadline >= CURRENT_DATE
|
||||
AND filing_deadline <= CURRENT_DATE + INTERVAL '30 days'
|
||||
AND status IN ('draft', 'submitted')`,
|
||||
params
|
||||
);
|
||||
|
||||
// Get overdue
|
||||
const overdueResult = await query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM court_filings
|
||||
${whereClause}
|
||||
AND filing_deadline IS NOT NULL
|
||||
AND filing_deadline < CURRENT_DATE
|
||||
AND status IN ('draft', 'submitted')`,
|
||||
params
|
||||
);
|
||||
|
||||
const stats: FilingStatistics = {
|
||||
total: 0,
|
||||
by_status: {},
|
||||
by_type: {},
|
||||
upcoming_deadlines: parseInt(upcomingResult.rows[0]?.count || '0', 10),
|
||||
overdue: parseInt(overdueResult.rows[0]?.count || '0', 10),
|
||||
};
|
||||
|
||||
for (const row of statusResult.rows) {
|
||||
const count = parseInt(row.count, 10);
|
||||
stats.total += count;
|
||||
stats.by_status[row.status] = count;
|
||||
}
|
||||
|
||||
for (const row of typeResult.rows) {
|
||||
stats.by_type[row.filing_type] = parseInt(row.count, 10);
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
// Aliases for route compatibility
|
||||
export const listCourtFilings = getMatterFilings;
|
||||
export const getFilingDeadlines = getUpcomingFilingDeadlines;
|
||||
|
||||
336
packages/database/src/document-audit.ts
Normal file
336
packages/database/src/document-audit.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* Document Audit Trail
|
||||
* Comprehensive audit logging for all document actions
|
||||
*/
|
||||
|
||||
import { query } from './client';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const DocumentAuditLogSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
document_id: z.string().uuid().optional(),
|
||||
version_id: z.string().uuid().optional(),
|
||||
matter_id: z.string().uuid().optional(),
|
||||
action: z.string(),
|
||||
performed_by: z.string().uuid().optional(),
|
||||
performed_at: z.date(),
|
||||
ip_address: z.string().optional(),
|
||||
user_agent: z.string().optional(),
|
||||
details: z.record(z.unknown()).optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export type DocumentAuditLog = z.infer<typeof DocumentAuditLogSchema>;
|
||||
|
||||
export type DocumentAction =
|
||||
| 'created'
|
||||
| 'viewed'
|
||||
| 'downloaded'
|
||||
| 'modified'
|
||||
| 'version_created'
|
||||
| 'version_restored'
|
||||
| 'deleted'
|
||||
| 'shared'
|
||||
| 'filed'
|
||||
| 'approved'
|
||||
| 'rejected'
|
||||
| 'commented'
|
||||
| 'checked_out'
|
||||
| 'checked_in'
|
||||
| 'access_denied'
|
||||
| 'exported'
|
||||
| 'printed'
|
||||
| 'watermarked'
|
||||
| 'encrypted'
|
||||
| 'decrypted';
|
||||
|
||||
export interface CreateAuditLogInput {
|
||||
document_id?: string;
|
||||
version_id?: string;
|
||||
matter_id?: string;
|
||||
action: DocumentAction;
|
||||
performed_by?: string;
|
||||
ip_address?: string;
|
||||
user_agent?: string;
|
||||
details?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create audit log entry
|
||||
*/
|
||||
export async function createDocumentAuditLog(
|
||||
input: CreateAuditLogInput
|
||||
): Promise<DocumentAuditLog> {
|
||||
const result = await query<DocumentAuditLog>(
|
||||
`INSERT INTO document_audit_log
|
||||
(document_id, version_id, matter_id, action, performed_by,
|
||||
ip_address, user_agent, details, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.document_id || null,
|
||||
input.version_id || null,
|
||||
input.matter_id || null,
|
||||
input.action,
|
||||
input.performed_by || null,
|
||||
input.ip_address || null,
|
||||
input.user_agent || null,
|
||||
input.details ? JSON.stringify(input.details) : null,
|
||||
input.metadata ? JSON.stringify(input.metadata) : null,
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search audit logs
|
||||
*/
|
||||
export interface AuditLogFilters {
|
||||
document_id?: string;
|
||||
version_id?: string;
|
||||
matter_id?: string;
|
||||
action?: DocumentAction | DocumentAction[];
|
||||
performed_by?: string;
|
||||
start_date?: Date;
|
||||
end_date?: Date;
|
||||
ip_address?: string;
|
||||
}
|
||||
|
||||
export interface AuditLogSearchResult {
|
||||
logs: DocumentAuditLog[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export async function searchDocumentAuditLogs(
|
||||
filters: AuditLogFilters = {},
|
||||
page = 1,
|
||||
page_size = 50
|
||||
): Promise<AuditLogSearchResult> {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (filters.document_id) {
|
||||
conditions.push(`document_id = $${paramIndex++}`);
|
||||
params.push(filters.document_id);
|
||||
}
|
||||
|
||||
if (filters.version_id) {
|
||||
conditions.push(`version_id = $${paramIndex++}`);
|
||||
params.push(filters.version_id);
|
||||
}
|
||||
|
||||
if (filters.matter_id) {
|
||||
conditions.push(`matter_id = $${paramIndex++}`);
|
||||
params.push(filters.matter_id);
|
||||
}
|
||||
|
||||
if (filters.action) {
|
||||
if (Array.isArray(filters.action)) {
|
||||
conditions.push(`action = ANY($${paramIndex++})`);
|
||||
params.push(filters.action);
|
||||
} else {
|
||||
conditions.push(`action = $${paramIndex++}`);
|
||||
params.push(filters.action);
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.performed_by) {
|
||||
conditions.push(`performed_by = $${paramIndex++}`);
|
||||
params.push(filters.performed_by);
|
||||
}
|
||||
|
||||
if (filters.start_date) {
|
||||
conditions.push(`performed_at >= $${paramIndex++}`);
|
||||
params.push(filters.start_date);
|
||||
}
|
||||
|
||||
if (filters.end_date) {
|
||||
conditions.push(`performed_at <= $${paramIndex++}`);
|
||||
params.push(filters.end_date);
|
||||
}
|
||||
|
||||
if (filters.ip_address) {
|
||||
conditions.push(`ip_address = $${paramIndex++}`);
|
||||
params.push(filters.ip_address);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// Get total count
|
||||
const countResult = await query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM document_audit_log ${whereClause}`,
|
||||
params
|
||||
);
|
||||
const total = parseInt(countResult.rows[0]?.count || '0', 10);
|
||||
|
||||
// Get paginated results
|
||||
const offset = (page - 1) * page_size;
|
||||
const result = await query<DocumentAuditLog>(
|
||||
`SELECT * FROM document_audit_log
|
||||
${whereClause}
|
||||
ORDER BY performed_at DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
||||
[...params, page_size, offset]
|
||||
);
|
||||
|
||||
return {
|
||||
logs: result.rows,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit log for a document
|
||||
*/
|
||||
export async function getDocumentAuditLog(
|
||||
document_id: string,
|
||||
limit = 100
|
||||
): Promise<DocumentAuditLog[]> {
|
||||
const result = await query<DocumentAuditLog>(
|
||||
`SELECT * FROM document_audit_log
|
||||
WHERE document_id = $1
|
||||
ORDER BY performed_at DESC
|
||||
LIMIT $2`,
|
||||
[document_id, limit]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit log for a matter
|
||||
*/
|
||||
export async function getMatterAuditLog(
|
||||
matter_id: string,
|
||||
limit = 100
|
||||
): Promise<DocumentAuditLog[]> {
|
||||
const result = await query<DocumentAuditLog>(
|
||||
`SELECT * FROM document_audit_log
|
||||
WHERE matter_id = $1
|
||||
ORDER BY performed_at DESC
|
||||
LIMIT $2`,
|
||||
[matter_id, limit]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user activity
|
||||
*/
|
||||
export async function getUserDocumentActivity(
|
||||
user_id: string,
|
||||
limit = 100
|
||||
): Promise<DocumentAuditLog[]> {
|
||||
const result = await query<DocumentAuditLog>(
|
||||
`SELECT * FROM document_audit_log
|
||||
WHERE performed_by = $1
|
||||
ORDER BY performed_at DESC
|
||||
LIMIT $2`,
|
||||
[user_id, limit]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get access log for a document (who accessed it)
|
||||
*/
|
||||
export async function getDocumentAccessLog(
|
||||
document_id: string
|
||||
): Promise<DocumentAuditLog[]> {
|
||||
const result = await query<DocumentAuditLog>(
|
||||
`SELECT * FROM document_audit_log
|
||||
WHERE document_id = $1
|
||||
AND action IN ('viewed', 'downloaded', 'exported', 'printed')
|
||||
ORDER BY performed_at DESC`,
|
||||
[document_id]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
// Aliases for route compatibility
|
||||
export const getDocumentAuditLogs = getDocumentAuditLog;
|
||||
|
||||
/**
|
||||
* Get audit statistics
|
||||
*/
|
||||
export interface AuditStatistics {
|
||||
total_actions: number;
|
||||
by_action: Record<string, number>;
|
||||
by_user: Record<string, number>;
|
||||
recent_activity: number; // Last 24 hours
|
||||
}
|
||||
|
||||
export async function getAuditStatistics(
|
||||
document_id?: string,
|
||||
matter_id?: string
|
||||
): Promise<AuditStatistics> {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (document_id) {
|
||||
conditions.push(`document_id = $${paramIndex++}`);
|
||||
params.push(document_id);
|
||||
}
|
||||
|
||||
if (matter_id) {
|
||||
conditions.push(`matter_id = $${paramIndex++}`);
|
||||
params.push(matter_id);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// Get total and by action
|
||||
const actionResult = await query<{ action: string; count: string }>(
|
||||
`SELECT action, COUNT(*) as count
|
||||
FROM document_audit_log
|
||||
${whereClause}
|
||||
GROUP BY action`,
|
||||
params
|
||||
);
|
||||
|
||||
// Get by user
|
||||
const userResult = await query<{ performed_by: string; count: string }>(
|
||||
`SELECT performed_by, COUNT(*) as count
|
||||
FROM document_audit_log
|
||||
${whereClause}
|
||||
AND performed_by IS NOT NULL
|
||||
GROUP BY performed_by`,
|
||||
params
|
||||
);
|
||||
|
||||
// Get recent activity (last 24 hours)
|
||||
const recentResult = await query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM document_audit_log
|
||||
${whereClause}
|
||||
AND performed_at >= NOW() - INTERVAL '24 hours'`,
|
||||
params
|
||||
);
|
||||
|
||||
const stats: AuditStatistics = {
|
||||
total_actions: 0,
|
||||
by_action: {},
|
||||
by_user: {},
|
||||
recent_activity: parseInt(recentResult.rows[0]?.count || '0', 10),
|
||||
};
|
||||
|
||||
for (const row of actionResult.rows) {
|
||||
const count = parseInt(row.count, 10);
|
||||
stats.total_actions += count;
|
||||
stats.by_action[row.action] = count;
|
||||
}
|
||||
|
||||
for (const row of userResult.rows) {
|
||||
stats.by_user[row.performed_by] = parseInt(row.count, 10);
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
218
packages/database/src/document-checkout.ts
Normal file
218
packages/database/src/document-checkout.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Document Checkout/Lock Management
|
||||
* Prevents concurrent edits and manages document locks
|
||||
*/
|
||||
|
||||
import { query } from './client';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const DocumentCheckoutSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
document_id: z.string().uuid(),
|
||||
checked_out_by: z.string().uuid(),
|
||||
checked_out_at: z.date(),
|
||||
expires_at: z.date(),
|
||||
lock_type: z.enum(['exclusive', 'shared_read']),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
export type DocumentCheckout = z.infer<typeof DocumentCheckoutSchema>;
|
||||
|
||||
export interface CreateCheckoutInput {
|
||||
document_id: string;
|
||||
checked_out_by: string;
|
||||
duration_hours?: number;
|
||||
lock_type?: 'exclusive' | 'shared_read';
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check out a document (lock it for editing)
|
||||
*/
|
||||
export async function checkoutDocument(
|
||||
input: CreateCheckoutInput
|
||||
): Promise<DocumentCheckout> {
|
||||
// Check if document is already checked out
|
||||
const existing = await getDocumentCheckout(input.document_id);
|
||||
if (existing) {
|
||||
if (existing.checked_out_by !== input.checked_out_by) {
|
||||
throw new Error('Document is already checked out by another user');
|
||||
}
|
||||
// Same user - extend checkout
|
||||
return extendCheckout(input.document_id, input.duration_hours || 24);
|
||||
}
|
||||
|
||||
const duration_hours = input.duration_hours || 24;
|
||||
const expires_at = new Date();
|
||||
expires_at.setHours(expires_at.getHours() + duration_hours);
|
||||
|
||||
const result = await query<DocumentCheckout>(
|
||||
`INSERT INTO document_checkouts
|
||||
(document_id, checked_out_by, expires_at, lock_type, notes)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.document_id,
|
||||
input.checked_out_by,
|
||||
expires_at,
|
||||
input.lock_type || 'exclusive',
|
||||
input.notes || null,
|
||||
]
|
||||
);
|
||||
|
||||
// Update document table
|
||||
await query(
|
||||
`UPDATE documents
|
||||
SET is_checked_out = TRUE, checked_out_by = $1, checked_out_at = NOW()
|
||||
WHERE id = $2`,
|
||||
[input.checked_out_by, input.document_id]
|
||||
);
|
||||
|
||||
return result.rows[0]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check in a document (release the lock)
|
||||
*/
|
||||
export async function checkinDocument(
|
||||
document_id: string,
|
||||
checked_out_by: string
|
||||
): Promise<boolean> {
|
||||
const checkout = await getDocumentCheckout(document_id);
|
||||
if (!checkout) {
|
||||
return false; // Not checked out
|
||||
}
|
||||
|
||||
if (checkout.checked_out_by !== checked_out_by) {
|
||||
throw new Error('Document is checked out by another user');
|
||||
}
|
||||
|
||||
await query(
|
||||
`DELETE FROM document_checkouts WHERE document_id = $1`,
|
||||
[document_id]
|
||||
);
|
||||
|
||||
// Update document table
|
||||
await query(
|
||||
`UPDATE documents
|
||||
SET is_checked_out = FALSE, checked_out_by = NULL, checked_out_at = NULL
|
||||
WHERE id = $1`,
|
||||
[document_id]
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get checkout status for a document
|
||||
*/
|
||||
export async function getDocumentCheckout(
|
||||
document_id: string
|
||||
): Promise<DocumentCheckout | null> {
|
||||
// Clean up expired checkouts first
|
||||
await cleanupExpiredCheckouts();
|
||||
|
||||
const result = await query<DocumentCheckout>(
|
||||
`SELECT * FROM document_checkouts
|
||||
WHERE document_id = $1 AND expires_at > NOW()`,
|
||||
[document_id]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend checkout duration
|
||||
*/
|
||||
export async function extendCheckout(
|
||||
document_id: string,
|
||||
additional_hours: number
|
||||
): Promise<DocumentCheckout> {
|
||||
const result = await query<DocumentCheckout>(
|
||||
`UPDATE document_checkouts
|
||||
SET expires_at = expires_at + INTERVAL '${additional_hours} hours'
|
||||
WHERE document_id = $1
|
||||
RETURNING *`,
|
||||
[document_id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Document is not checked out');
|
||||
}
|
||||
|
||||
return result.rows[0]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force release checkout (admin function)
|
||||
*/
|
||||
export async function forceReleaseCheckout(document_id: string): Promise<boolean> {
|
||||
await query(`DELETE FROM document_checkouts WHERE document_id = $1`, [document_id]);
|
||||
|
||||
await query(
|
||||
`UPDATE documents
|
||||
SET is_checked_out = FALSE, checked_out_by = NULL, checked_out_at = NULL
|
||||
WHERE id = $1`,
|
||||
[document_id]
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired checkouts
|
||||
*/
|
||||
export async function cleanupExpiredCheckouts(): Promise<number> {
|
||||
const result = await query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM document_checkouts
|
||||
WHERE expires_at <= NOW()`
|
||||
);
|
||||
const expiredCount = parseInt(result.rows[0]?.count || '0', 10);
|
||||
|
||||
if (expiredCount > 0) {
|
||||
await query(
|
||||
`DELETE FROM document_checkouts WHERE expires_at <= NOW()`
|
||||
);
|
||||
|
||||
// Update documents table
|
||||
await query(
|
||||
`UPDATE documents
|
||||
SET is_checked_out = FALSE, checked_out_by = NULL, checked_out_at = NULL
|
||||
WHERE id IN (
|
||||
SELECT document_id FROM document_checkouts WHERE expires_at <= NOW()
|
||||
)`
|
||||
);
|
||||
}
|
||||
|
||||
return expiredCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all checkouts for a user
|
||||
*/
|
||||
export async function getUserCheckouts(user_id: string): Promise<DocumentCheckout[]> {
|
||||
await cleanupExpiredCheckouts();
|
||||
|
||||
const result = await query<DocumentCheckout>(
|
||||
`SELECT * FROM document_checkouts
|
||||
WHERE checked_out_by = $1 AND expires_at > NOW()
|
||||
ORDER BY expires_at ASC`,
|
||||
[user_id]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active checkouts
|
||||
*/
|
||||
export async function getAllActiveCheckouts(): Promise<DocumentCheckout[]> {
|
||||
await cleanupExpiredCheckouts();
|
||||
|
||||
const result = await query<DocumentCheckout>(
|
||||
`SELECT * FROM document_checkouts
|
||||
WHERE expires_at > NOW()
|
||||
ORDER BY expires_at ASC`
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
265
packages/database/src/document-comments.ts
Normal file
265
packages/database/src/document-comments.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Document Comments and Annotations
|
||||
* Handles comments, annotations, and collaborative review features
|
||||
*/
|
||||
|
||||
import { query } from './client';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const DocumentCommentSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
document_id: z.string().uuid(),
|
||||
version_id: z.string().uuid().optional(),
|
||||
parent_comment_id: z.string().uuid().optional(),
|
||||
comment_text: z.string(),
|
||||
comment_type: z.enum(['comment', 'suggestion', 'question', 'resolution']),
|
||||
status: z.enum(['open', 'resolved', 'dismissed']),
|
||||
page_number: z.number().int().optional(),
|
||||
x_position: z.number().optional(),
|
||||
y_position: z.number().optional(),
|
||||
highlight_text: z.string().optional(),
|
||||
author_id: z.string().uuid(),
|
||||
resolved_by: z.string().uuid().optional(),
|
||||
resolved_at: z.date().optional(),
|
||||
created_at: z.date(),
|
||||
updated_at: z.date(),
|
||||
});
|
||||
|
||||
export type DocumentComment = z.infer<typeof DocumentCommentSchema>;
|
||||
|
||||
export interface CreateDocumentCommentInput {
|
||||
document_id: string;
|
||||
version_id?: string;
|
||||
parent_comment_id?: string;
|
||||
comment_text: string;
|
||||
comment_type?: 'comment' | 'suggestion' | 'question' | 'resolution';
|
||||
status?: 'open' | 'resolved' | 'dismissed';
|
||||
page_number?: number;
|
||||
x_position?: number;
|
||||
y_position?: number;
|
||||
highlight_text?: string;
|
||||
author_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a document comment
|
||||
*/
|
||||
export async function createDocumentComment(
|
||||
input: CreateDocumentCommentInput
|
||||
): Promise<DocumentComment> {
|
||||
const result = await query<DocumentComment>(
|
||||
`INSERT INTO document_comments
|
||||
(document_id, version_id, parent_comment_id, comment_text, comment_type,
|
||||
status, page_number, x_position, y_position, highlight_text, author_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.document_id,
|
||||
input.version_id || null,
|
||||
input.parent_comment_id || null,
|
||||
input.comment_text,
|
||||
input.comment_type || 'comment',
|
||||
input.status || 'open',
|
||||
input.page_number || null,
|
||||
input.x_position || null,
|
||||
input.y_position || null,
|
||||
input.highlight_text || null,
|
||||
input.author_id,
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comment by ID
|
||||
*/
|
||||
export async function getDocumentComment(id: string): Promise<DocumentComment | null> {
|
||||
const result = await query<DocumentComment>(
|
||||
`SELECT * FROM document_comments WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all comments for a document
|
||||
*/
|
||||
export async function getDocumentComments(
|
||||
document_id: string,
|
||||
version_id?: string,
|
||||
include_resolved = false
|
||||
): Promise<DocumentComment[]> {
|
||||
const conditions = ['document_id = $1'];
|
||||
const params: unknown[] = [document_id];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (version_id) {
|
||||
conditions.push(`version_id = $${paramIndex++}`);
|
||||
params.push(version_id);
|
||||
}
|
||||
|
||||
if (!include_resolved) {
|
||||
conditions.push(`status != 'resolved'`);
|
||||
}
|
||||
|
||||
const result = await query<DocumentComment>(
|
||||
`SELECT * FROM document_comments
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY created_at ASC`,
|
||||
params
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get threaded comments (with replies)
|
||||
*/
|
||||
export interface ThreadedComment extends DocumentComment {
|
||||
replies?: ThreadedComment[];
|
||||
}
|
||||
|
||||
export async function getThreadedDocumentComments(
|
||||
document_id: string,
|
||||
version_id?: string
|
||||
): Promise<ThreadedComment[]> {
|
||||
const allComments = await getDocumentComments(document_id, version_id, true);
|
||||
|
||||
// Build tree structure
|
||||
const commentMap = new Map<string, ThreadedComment>();
|
||||
const rootComments: ThreadedComment[] = [];
|
||||
|
||||
// First pass: create map
|
||||
for (const comment of allComments) {
|
||||
commentMap.set(comment.id, { ...comment, replies: [] });
|
||||
}
|
||||
|
||||
// Second pass: build tree
|
||||
for (const comment of allComments) {
|
||||
const threaded = commentMap.get(comment.id)!;
|
||||
if (comment.parent_comment_id) {
|
||||
const parent = commentMap.get(comment.parent_comment_id);
|
||||
if (parent) {
|
||||
parent.replies!.push(threaded);
|
||||
}
|
||||
} else {
|
||||
rootComments.push(threaded);
|
||||
}
|
||||
}
|
||||
|
||||
return rootComments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update comment
|
||||
*/
|
||||
export async function updateDocumentComment(
|
||||
id: string,
|
||||
updates: Partial<Pick<DocumentComment, 'comment_text' | 'status'>>
|
||||
): Promise<DocumentComment | null> {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (updates.comment_text !== undefined) {
|
||||
fields.push(`comment_text = $${paramIndex++}`);
|
||||
values.push(updates.comment_text);
|
||||
}
|
||||
|
||||
if (updates.status !== undefined) {
|
||||
fields.push(`status = $${paramIndex++}`);
|
||||
values.push(updates.status);
|
||||
|
||||
if (updates.status === 'resolved') {
|
||||
// Get current user from context - for now, we'll need to pass it
|
||||
// This should be handled by the service layer
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return getDocumentComment(id);
|
||||
}
|
||||
|
||||
fields.push(`updated_at = NOW()`);
|
||||
values.push(id);
|
||||
|
||||
const result = await query<DocumentComment>(
|
||||
`UPDATE document_comments
|
||||
SET ${fields.join(', ')}
|
||||
WHERE id = $${paramIndex}
|
||||
RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve comment
|
||||
*/
|
||||
export async function resolveDocumentComment(
|
||||
id: string,
|
||||
resolved_by: string
|
||||
): Promise<DocumentComment | null> {
|
||||
const result = await query<DocumentComment>(
|
||||
`UPDATE document_comments
|
||||
SET status = 'resolved', resolved_by = $1, resolved_at = NOW(), updated_at = NOW()
|
||||
WHERE id = $2
|
||||
RETURNING *`,
|
||||
[resolved_by, id]
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comment statistics for a document
|
||||
*/
|
||||
export interface CommentStatistics {
|
||||
total: number;
|
||||
open: number;
|
||||
resolved: number;
|
||||
dismissed: number;
|
||||
by_type: Record<string, number>;
|
||||
}
|
||||
|
||||
export async function getDocumentCommentStatistics(
|
||||
document_id: string
|
||||
): Promise<CommentStatistics> {
|
||||
const result = await query<{
|
||||
total: string;
|
||||
open: string;
|
||||
resolved: string;
|
||||
dismissed: string;
|
||||
comment_type: string;
|
||||
count: string;
|
||||
}>(
|
||||
`SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE status = 'open') as open,
|
||||
COUNT(*) FILTER (WHERE status = 'resolved') as resolved,
|
||||
COUNT(*) FILTER (WHERE status = 'dismissed') as dismissed,
|
||||
comment_type,
|
||||
COUNT(*) as count
|
||||
FROM document_comments
|
||||
WHERE document_id = $1
|
||||
GROUP BY comment_type`,
|
||||
[document_id]
|
||||
);
|
||||
|
||||
const stats: CommentStatistics = {
|
||||
total: parseInt(result.rows[0]?.total || '0', 10),
|
||||
open: parseInt(result.rows[0]?.open || '0', 10),
|
||||
resolved: parseInt(result.rows[0]?.resolved || '0', 10),
|
||||
dismissed: parseInt(result.rows[0]?.dismissed || '0', 10),
|
||||
by_type: {},
|
||||
};
|
||||
|
||||
for (const row of result.rows) {
|
||||
stats.by_type[row.comment_type] = parseInt(row.count, 10);
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
303
packages/database/src/document-retention.ts
Normal file
303
packages/database/src/document-retention.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Document Retention Management
|
||||
* Handles retention policies and disposal workflows
|
||||
*/
|
||||
|
||||
import { query } from './client';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const RetentionPolicySchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
document_type: z.string().optional(),
|
||||
matter_type: z.string().optional(),
|
||||
retention_period_years: z.number().int().positive(),
|
||||
retention_trigger: z.enum(['creation', 'matter_close', 'last_access']),
|
||||
disposal_action: z.enum(['archive', 'delete', 'review']),
|
||||
is_active: z.boolean(),
|
||||
created_by: z.string().uuid().optional(),
|
||||
created_at: z.date(),
|
||||
updated_at: z.date(),
|
||||
});
|
||||
|
||||
export type RetentionPolicy = z.infer<typeof RetentionPolicySchema>;
|
||||
|
||||
export const RetentionRecordSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
document_id: z.string().uuid(),
|
||||
policy_id: z.string().uuid(),
|
||||
retention_start_date: z.date(),
|
||||
retention_end_date: z.date(),
|
||||
status: z.enum(['active', 'expired', 'disposed', 'extended']),
|
||||
disposed_at: z.date().optional(),
|
||||
disposed_by: z.string().uuid().optional(),
|
||||
notes: z.string().optional(),
|
||||
created_at: z.date(),
|
||||
updated_at: z.date(),
|
||||
});
|
||||
|
||||
export type RetentionRecord = z.infer<typeof RetentionRecordSchema>;
|
||||
|
||||
export interface CreateRetentionPolicyInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
document_type?: string;
|
||||
matter_type?: string;
|
||||
retention_period_years: number;
|
||||
retention_trigger?: 'creation' | 'matter_close' | 'last_access';
|
||||
disposal_action?: 'archive' | 'delete' | 'review';
|
||||
is_active?: boolean;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create retention policy
|
||||
*/
|
||||
export async function createRetentionPolicy(
|
||||
input: CreateRetentionPolicyInput
|
||||
): Promise<RetentionPolicy> {
|
||||
const result = await query<RetentionPolicy>(
|
||||
`INSERT INTO document_retention_policies
|
||||
(name, description, document_type, matter_type, retention_period_years,
|
||||
retention_trigger, disposal_action, is_active, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.name,
|
||||
input.description || null,
|
||||
input.document_type || null,
|
||||
input.matter_type || null,
|
||||
input.retention_period_years,
|
||||
input.retention_trigger || 'creation',
|
||||
input.disposal_action || 'archive',
|
||||
input.is_active !== false,
|
||||
input.created_by || null,
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get policy by ID
|
||||
*/
|
||||
export async function getRetentionPolicy(id: string): Promise<RetentionPolicy | null> {
|
||||
const result = await query<RetentionPolicy>(
|
||||
`SELECT * FROM document_retention_policies WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List retention policies
|
||||
*/
|
||||
export async function listRetentionPolicies(
|
||||
active_only = true
|
||||
): Promise<RetentionPolicy[]> {
|
||||
const whereClause = active_only ? 'WHERE is_active = TRUE' : '';
|
||||
const result = await query<RetentionPolicy>(
|
||||
`SELECT * FROM document_retention_policies
|
||||
${whereClause}
|
||||
ORDER BY name`
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply retention policy to document
|
||||
*/
|
||||
export async function applyRetentionPolicy(
|
||||
document_id: string,
|
||||
policy_id: string
|
||||
): Promise<RetentionRecord> {
|
||||
const policy = await getRetentionPolicy(policy_id);
|
||||
if (!policy) {
|
||||
throw new Error(`Retention policy ${policy_id} not found`);
|
||||
}
|
||||
|
||||
// Calculate retention dates based on trigger
|
||||
let retention_start_date: Date;
|
||||
const retention_end_date = new Date();
|
||||
|
||||
if (policy.retention_trigger === 'creation') {
|
||||
// Get document creation date
|
||||
const docResult = await query<{ created_at: Date }>(
|
||||
`SELECT created_at FROM documents WHERE id = $1`,
|
||||
[document_id]
|
||||
);
|
||||
retention_start_date = docResult.rows[0]?.created_at || new Date();
|
||||
retention_end_date.setFullYear(
|
||||
retention_start_date.getFullYear() + policy.retention_period_years
|
||||
);
|
||||
} else if (policy.retention_trigger === 'matter_close') {
|
||||
// Get matter close date (would need to query matter)
|
||||
retention_start_date = new Date();
|
||||
retention_end_date.setFullYear(
|
||||
retention_start_date.getFullYear() + policy.retention_period_years
|
||||
);
|
||||
} else {
|
||||
// last_access - start from now
|
||||
retention_start_date = new Date();
|
||||
retention_end_date.setFullYear(
|
||||
retention_start_date.getFullYear() + policy.retention_period_years
|
||||
);
|
||||
}
|
||||
|
||||
const result = await query<RetentionRecord>(
|
||||
`INSERT INTO document_retention_records
|
||||
(document_id, policy_id, retention_start_date, retention_end_date, status)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (document_id)
|
||||
DO UPDATE SET policy_id = EXCLUDED.policy_id,
|
||||
retention_start_date = EXCLUDED.retention_start_date,
|
||||
retention_end_date = EXCLUDED.retention_end_date,
|
||||
status = 'active',
|
||||
updated_at = NOW()
|
||||
RETURNING *`,
|
||||
[document_id, policy_id, retention_start_date, retention_end_date, 'active']
|
||||
);
|
||||
|
||||
return result.rows[0]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get retention record for document
|
||||
*/
|
||||
export async function getDocumentRetentionRecord(
|
||||
document_id: string
|
||||
): Promise<RetentionRecord | null> {
|
||||
const result = await query<RetentionRecord>(
|
||||
`SELECT * FROM document_retention_records
|
||||
WHERE document_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`,
|
||||
[document_id]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get expired retention records
|
||||
*/
|
||||
export async function getExpiredRetentionRecords(): Promise<RetentionRecord[]> {
|
||||
const result = await query<RetentionRecord>(
|
||||
`SELECT * FROM document_retention_records
|
||||
WHERE status = 'active'
|
||||
AND retention_end_date <= CURRENT_DATE
|
||||
ORDER BY retention_end_date ASC`
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark retention record as disposed
|
||||
*/
|
||||
export async function disposeDocument(
|
||||
document_id: string,
|
||||
disposed_by: string,
|
||||
notes?: string
|
||||
): Promise<RetentionRecord | null> {
|
||||
const result = await query<RetentionRecord>(
|
||||
`UPDATE document_retention_records
|
||||
SET status = 'disposed', disposed_at = NOW(), disposed_by = $1, notes = $2, updated_at = NOW()
|
||||
WHERE document_id = $3
|
||||
RETURNING *`,
|
||||
[disposed_by, notes || null, document_id]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend retention period
|
||||
*/
|
||||
export async function extendRetention(
|
||||
document_id: string,
|
||||
additional_years: number
|
||||
): Promise<RetentionRecord | null> {
|
||||
const result = await query<RetentionRecord>(
|
||||
`UPDATE document_retention_records
|
||||
SET retention_end_date = retention_end_date + INTERVAL '${additional_years} years',
|
||||
status = 'extended',
|
||||
updated_at = NOW()
|
||||
WHERE document_id = $1
|
||||
RETURNING *`,
|
||||
[document_id]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Place document on legal hold (suspends retention)
|
||||
*/
|
||||
export async function placeOnLegalHold(
|
||||
document_id: string,
|
||||
notes?: string
|
||||
): Promise<RetentionRecord | null> {
|
||||
const result = await query<RetentionRecord>(
|
||||
`UPDATE document_retention_records
|
||||
SET status = 'extended', notes = COALESCE(notes || E'\n', '') || 'Legal Hold: ' || $1, updated_at = NOW()
|
||||
WHERE document_id = $2
|
||||
RETURNING *`,
|
||||
[notes || 'Legal hold placed', document_id]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get retention statistics
|
||||
*/
|
||||
export interface RetentionStatistics {
|
||||
total_documents: number;
|
||||
active_retention: number;
|
||||
expired_retention: number;
|
||||
disposed: number;
|
||||
on_hold: number;
|
||||
upcoming_expirations: number; // Next 30 days
|
||||
}
|
||||
|
||||
export async function getRetentionStatistics(): Promise<RetentionStatistics> {
|
||||
const statsResult = await query<{
|
||||
status: string;
|
||||
count: string;
|
||||
}>(
|
||||
`SELECT status, COUNT(*) as count
|
||||
FROM document_retention_records
|
||||
GROUP BY status`
|
||||
);
|
||||
|
||||
const upcomingResult = await query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM document_retention_records
|
||||
WHERE status = 'active'
|
||||
AND retention_end_date >= CURRENT_DATE
|
||||
AND retention_end_date <= CURRENT_DATE + INTERVAL '30 days'`
|
||||
);
|
||||
|
||||
const stats: RetentionStatistics = {
|
||||
total_documents: 0,
|
||||
active_retention: 0,
|
||||
expired_retention: 0,
|
||||
disposed: 0,
|
||||
on_hold: 0,
|
||||
upcoming_expirations: parseInt(upcomingResult.rows[0]?.count || '0', 10),
|
||||
};
|
||||
|
||||
for (const row of statsResult.rows) {
|
||||
const count = parseInt(row.count, 10);
|
||||
stats.total_documents += count;
|
||||
if (row.status === 'active') {
|
||||
stats.active_retention = count;
|
||||
} else if (row.status === 'expired') {
|
||||
stats.expired_retention = count;
|
||||
} else if (row.status === 'disposed') {
|
||||
stats.disposed = count;
|
||||
} else if (row.status === 'extended') {
|
||||
stats.on_hold += count;
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
121
packages/database/src/document-search.ts
Normal file
121
packages/database/src/document-search.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Document Search
|
||||
* Full-text search and document discovery
|
||||
*/
|
||||
|
||||
import { query } from './client';
|
||||
import { listDocuments, getDocumentById } from './schema';
|
||||
|
||||
export interface DocumentSearchResult {
|
||||
documents: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
content?: string;
|
||||
file_url?: string;
|
||||
created_at: Date;
|
||||
relevance_score?: number;
|
||||
}>;
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface SearchFilters {
|
||||
type?: string;
|
||||
matter_id?: string;
|
||||
created_after?: Date;
|
||||
created_before?: Date;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search documents
|
||||
*/
|
||||
export async function searchDocuments(
|
||||
filters: SearchFilters = {},
|
||||
page = 1,
|
||||
page_size = 50
|
||||
): Promise<DocumentSearchResult> {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (filters.type) {
|
||||
conditions.push(`type = $${paramIndex++}`);
|
||||
params.push(filters.type);
|
||||
}
|
||||
|
||||
if (filters.created_after) {
|
||||
conditions.push(`created_at >= $${paramIndex++}`);
|
||||
params.push(filters.created_after);
|
||||
}
|
||||
|
||||
if (filters.created_before) {
|
||||
conditions.push(`created_at <= $${paramIndex++}`);
|
||||
params.push(filters.created_before);
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
conditions.push(
|
||||
`(title ILIKE $${paramIndex} OR content ILIKE $${paramIndex} OR ocr_text ILIKE $${paramIndex})`
|
||||
);
|
||||
params.push(`%${filters.search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// Get total count
|
||||
const countResult = await query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM documents ${whereClause}`,
|
||||
params
|
||||
);
|
||||
const total = parseInt(countResult.rows[0]?.count || '0', 10);
|
||||
|
||||
// Get paginated results
|
||||
const offset = (page - 1) * page_size;
|
||||
const result = await query<{
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
content?: string;
|
||||
file_url?: string;
|
||||
created_at: Date;
|
||||
}>(
|
||||
`SELECT id, title, type, content, file_url, created_at
|
||||
FROM documents
|
||||
${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
||||
[...params, page_size, offset]
|
||||
);
|
||||
|
||||
return {
|
||||
documents: result.rows,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get search suggestions
|
||||
*/
|
||||
export async function getSearchSuggestions(query: string, limit = 10): Promise<string[]> {
|
||||
if (!query || query.length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = await query<{ title: string }>(
|
||||
`SELECT DISTINCT title
|
||||
FROM documents
|
||||
WHERE title ILIKE $1
|
||||
ORDER BY title
|
||||
LIMIT $2`,
|
||||
[`%${query}%`, limit]
|
||||
);
|
||||
|
||||
return result.rows.map((row) => row.title);
|
||||
}
|
||||
|
||||
328
packages/database/src/document-templates.ts
Normal file
328
packages/database/src/document-templates.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Document Template Management
|
||||
* Handles legal document templates with variable substitution
|
||||
*/
|
||||
|
||||
import { query } from './client';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const DocumentTemplateSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
subcategory: z.string().optional(),
|
||||
template_content: z.string(),
|
||||
variables: z.record(z.unknown()).optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
version: z.number().int().positive(),
|
||||
is_active: z.boolean(),
|
||||
is_public: z.boolean(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
created_by: z.string().uuid().optional(),
|
||||
created_at: z.date(),
|
||||
updated_at: z.date(),
|
||||
});
|
||||
|
||||
export type DocumentTemplate = z.infer<typeof DocumentTemplateSchema>;
|
||||
|
||||
export interface CreateDocumentTemplateInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
subcategory?: string;
|
||||
template_content: string;
|
||||
variables?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
version?: number;
|
||||
is_active?: boolean;
|
||||
is_public?: boolean;
|
||||
tags?: string[];
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a document template
|
||||
*/
|
||||
export async function createDocumentTemplate(
|
||||
input: CreateDocumentTemplateInput
|
||||
): Promise<DocumentTemplate> {
|
||||
const result = await query<DocumentTemplate>(
|
||||
`INSERT INTO document_templates
|
||||
(name, description, category, subcategory, template_content, variables,
|
||||
metadata, version, is_active, is_public, tags, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.name,
|
||||
input.description || null,
|
||||
input.category || null,
|
||||
input.subcategory || null,
|
||||
input.template_content,
|
||||
input.variables ? JSON.stringify(input.variables) : null,
|
||||
input.metadata ? JSON.stringify(input.metadata) : null,
|
||||
input.version || 1,
|
||||
input.is_active !== false,
|
||||
input.is_public || false,
|
||||
input.tags || [],
|
||||
input.created_by || null,
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get template by ID
|
||||
*/
|
||||
export async function getDocumentTemplate(id: string): Promise<DocumentTemplate | null> {
|
||||
const result = await query<DocumentTemplate>(
|
||||
`SELECT * FROM document_templates WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get template by name and version
|
||||
*/
|
||||
export async function getDocumentTemplateByName(
|
||||
name: string,
|
||||
version?: number
|
||||
): Promise<DocumentTemplate | null> {
|
||||
if (version) {
|
||||
const result = await query<DocumentTemplate>(
|
||||
`SELECT * FROM document_templates
|
||||
WHERE name = $1 AND version = $2`,
|
||||
[name, version]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
} else {
|
||||
// Get latest active version
|
||||
const result = await query<DocumentTemplate>(
|
||||
`SELECT * FROM document_templates
|
||||
WHERE name = $1 AND is_active = TRUE
|
||||
ORDER BY version DESC
|
||||
LIMIT 1`,
|
||||
[name]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List templates with filters
|
||||
*/
|
||||
export interface TemplateFilters {
|
||||
category?: string;
|
||||
subcategory?: string;
|
||||
is_active?: boolean;
|
||||
is_public?: boolean;
|
||||
tags?: string[];
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export async function listDocumentTemplates(
|
||||
filters: TemplateFilters = {},
|
||||
limit = 100,
|
||||
offset = 0
|
||||
): Promise<DocumentTemplate[]> {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (filters.category) {
|
||||
conditions.push(`category = $${paramIndex++}`);
|
||||
params.push(filters.category);
|
||||
}
|
||||
|
||||
if (filters.subcategory) {
|
||||
conditions.push(`subcategory = $${paramIndex++}`);
|
||||
params.push(filters.subcategory);
|
||||
}
|
||||
|
||||
if (filters.is_active !== undefined) {
|
||||
conditions.push(`is_active = $${paramIndex++}`);
|
||||
params.push(filters.is_active);
|
||||
}
|
||||
|
||||
if (filters.is_public !== undefined) {
|
||||
conditions.push(`is_public = $${paramIndex++}`);
|
||||
params.push(filters.is_public);
|
||||
}
|
||||
|
||||
if (filters.tags && filters.tags.length > 0) {
|
||||
conditions.push(`tags && $${paramIndex++}`);
|
||||
params.push(filters.tags);
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
conditions.push(
|
||||
`(name ILIKE $${paramIndex} OR description ILIKE $${paramIndex} OR template_content ILIKE $${paramIndex})`
|
||||
);
|
||||
params.push(`%${filters.search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const result = await query<DocumentTemplate>(
|
||||
`SELECT * FROM document_templates
|
||||
${whereClause}
|
||||
ORDER BY name, version DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
||||
[...params, limit, offset]
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update template
|
||||
*/
|
||||
export async function updateDocumentTemplate(
|
||||
id: string,
|
||||
updates: Partial<
|
||||
Pick<
|
||||
DocumentTemplate,
|
||||
'description' | 'template_content' | 'variables' | 'metadata' | 'is_active' | 'tags'
|
||||
>
|
||||
>
|
||||
): Promise<DocumentTemplate | null> {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (updates.description !== undefined) {
|
||||
fields.push(`description = $${paramIndex++}`);
|
||||
values.push(updates.description);
|
||||
}
|
||||
|
||||
if (updates.template_content !== undefined) {
|
||||
fields.push(`template_content = $${paramIndex++}`);
|
||||
values.push(updates.template_content);
|
||||
}
|
||||
|
||||
if (updates.variables !== undefined) {
|
||||
fields.push(`variables = $${paramIndex++}`);
|
||||
values.push(JSON.stringify(updates.variables));
|
||||
}
|
||||
|
||||
if (updates.metadata !== undefined) {
|
||||
fields.push(`metadata = $${paramIndex++}`);
|
||||
values.push(JSON.stringify(updates.metadata));
|
||||
}
|
||||
|
||||
if (updates.is_active !== undefined) {
|
||||
fields.push(`is_active = $${paramIndex++}`);
|
||||
values.push(updates.is_active);
|
||||
}
|
||||
|
||||
if (updates.tags !== undefined) {
|
||||
fields.push(`tags = $${paramIndex++}`);
|
||||
values.push(updates.tags);
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return getDocumentTemplate(id);
|
||||
}
|
||||
|
||||
fields.push(`updated_at = NOW()`);
|
||||
values.push(id);
|
||||
|
||||
const result = await query<DocumentTemplate>(
|
||||
`UPDATE document_templates
|
||||
SET ${fields.join(', ')}
|
||||
WHERE id = $${paramIndex}
|
||||
RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new template version
|
||||
*/
|
||||
export async function createTemplateVersion(
|
||||
templateId: string,
|
||||
updates: Partial<Pick<DocumentTemplate, 'template_content' | 'description' | 'variables' | 'metadata'>>
|
||||
): Promise<DocumentTemplate> {
|
||||
const original = await getDocumentTemplate(templateId);
|
||||
if (!original) {
|
||||
throw new Error(`Template ${templateId} not found`);
|
||||
}
|
||||
|
||||
// Get next version number
|
||||
const versionResult = await query<{ max_version: number }>(
|
||||
`SELECT MAX(version) as max_version FROM document_templates WHERE name = $1`,
|
||||
[original.name]
|
||||
);
|
||||
const nextVersion = (versionResult.rows[0]?.max_version || 0) + 1;
|
||||
|
||||
return createDocumentTemplate({
|
||||
name: original.name,
|
||||
description: updates.description || original.description,
|
||||
category: original.category,
|
||||
subcategory: original.subcategory,
|
||||
template_content: updates.template_content || original.template_content,
|
||||
variables: updates.variables || original.variables,
|
||||
metadata: updates.metadata || original.metadata,
|
||||
version: nextVersion,
|
||||
is_active: true,
|
||||
is_public: original.is_public,
|
||||
tags: original.tags,
|
||||
created_by: original.created_by,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render template with variables
|
||||
* Supports {{variable_name}} syntax
|
||||
*/
|
||||
export function renderDocumentTemplate(
|
||||
template: DocumentTemplate,
|
||||
variables: Record<string, unknown>
|
||||
): string {
|
||||
let rendered = template.template_content;
|
||||
|
||||
// Replace {{variable}} patterns
|
||||
rendered = rendered.replace(/\{\{(\w+)\}\}/g, (match, varName) => {
|
||||
const value = variables[varName];
|
||||
if (value === undefined || value === null) {
|
||||
return match; // Keep placeholder if variable not provided
|
||||
}
|
||||
return String(value);
|
||||
});
|
||||
|
||||
// Support nested variables {{object.property}}
|
||||
rendered = rendered.replace(/\{\{(\w+(?:\.\w+)+)\}\}/g, (match, path) => {
|
||||
const parts = path.split('.');
|
||||
let value: unknown = variables;
|
||||
for (const part of parts) {
|
||||
if (value && typeof value === 'object' && part in value) {
|
||||
value = (value as Record<string, unknown>)[part];
|
||||
} else {
|
||||
return match; // Keep placeholder if path not found
|
||||
}
|
||||
}
|
||||
return value !== undefined && value !== null ? String(value) : match;
|
||||
});
|
||||
|
||||
return rendered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract variables from template
|
||||
*/
|
||||
export function extractTemplateVariables(template_content: string): string[] {
|
||||
const variables = new Set<string>();
|
||||
const matches = template_content.matchAll(/\{\{(\w+(?:\.\w+)*)\}\}/g);
|
||||
|
||||
for (const match of matches) {
|
||||
variables.add(match[1]);
|
||||
}
|
||||
|
||||
return Array.from(variables).sort();
|
||||
}
|
||||
|
||||
268
packages/database/src/document-versions.ts
Normal file
268
packages/database/src/document-versions.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Document Version Management
|
||||
* Handles document versioning, revision history, and version control
|
||||
*/
|
||||
|
||||
import { query } from './client';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const DocumentVersionSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
document_id: z.string().uuid(),
|
||||
version_number: z.number().int().positive(),
|
||||
version_label: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
file_url: z.string().url().optional(),
|
||||
file_hash: z.string().optional(),
|
||||
file_size: z.number().int().nonnegative().optional(),
|
||||
mime_type: z.string().optional(),
|
||||
change_summary: z.string().optional(),
|
||||
change_type: z.enum(['created', 'modified', 'restored', 'merged']),
|
||||
created_by: z.string().uuid().optional(),
|
||||
created_at: z.date(),
|
||||
});
|
||||
|
||||
export type DocumentVersion = z.infer<typeof DocumentVersionSchema>;
|
||||
|
||||
export interface CreateDocumentVersionInput {
|
||||
document_id: string;
|
||||
version_number?: number;
|
||||
version_label?: string;
|
||||
content?: string;
|
||||
file_url?: string;
|
||||
file_hash?: string;
|
||||
file_size?: number;
|
||||
mime_type?: string;
|
||||
change_summary?: string;
|
||||
change_type?: 'created' | 'modified' | 'restored' | 'merged';
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new document version
|
||||
*/
|
||||
export async function createDocumentVersion(
|
||||
input: CreateDocumentVersionInput
|
||||
): Promise<DocumentVersion> {
|
||||
// Get next version number if not provided
|
||||
let version_number = input.version_number;
|
||||
if (!version_number) {
|
||||
const maxVersion = await query<{ max_version: number | null }>(
|
||||
`SELECT MAX(version_number) as max_version
|
||||
FROM document_versions
|
||||
WHERE document_id = $1`,
|
||||
[input.document_id]
|
||||
);
|
||||
version_number = (maxVersion.rows[0]?.max_version || 0) + 1;
|
||||
}
|
||||
|
||||
const result = await query<DocumentVersion>(
|
||||
`INSERT INTO document_versions
|
||||
(document_id, version_number, version_label, content, file_url, file_hash,
|
||||
file_size, mime_type, change_summary, change_type, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.document_id,
|
||||
version_number,
|
||||
input.version_label || null,
|
||||
input.content || null,
|
||||
input.file_url || null,
|
||||
input.file_hash || null,
|
||||
input.file_size || null,
|
||||
input.mime_type || null,
|
||||
input.change_summary || null,
|
||||
input.change_type || 'modified',
|
||||
input.created_by || null,
|
||||
]
|
||||
);
|
||||
|
||||
const version = result.rows[0]!;
|
||||
|
||||
// Update document's current version
|
||||
await query(
|
||||
`UPDATE documents
|
||||
SET current_version = $1, latest_version_id = $2, updated_at = NOW()
|
||||
WHERE id = $3`,
|
||||
[version_number, version.id, input.document_id]
|
||||
);
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get document version by ID
|
||||
*/
|
||||
export async function getDocumentVersion(id: string): Promise<DocumentVersion | null> {
|
||||
const result = await query<DocumentVersion>(
|
||||
`SELECT * FROM document_versions WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all versions for a document
|
||||
*/
|
||||
export async function getDocumentVersions(
|
||||
document_id: string,
|
||||
limit = 100,
|
||||
offset = 0
|
||||
): Promise<DocumentVersion[]> {
|
||||
const result = await query<DocumentVersion>(
|
||||
`SELECT * FROM document_versions
|
||||
WHERE document_id = $1
|
||||
ORDER BY version_number DESC
|
||||
LIMIT $2 OFFSET $3`,
|
||||
[document_id, limit, offset]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific version by number
|
||||
*/
|
||||
export async function getDocumentVersionByNumber(
|
||||
document_id: string,
|
||||
version_number: number
|
||||
): Promise<DocumentVersion | null> {
|
||||
const result = await query<DocumentVersion>(
|
||||
`SELECT * FROM document_versions
|
||||
WHERE document_id = $1 AND version_number = $2`,
|
||||
[document_id, version_number]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest version of a document
|
||||
*/
|
||||
export async function getLatestDocumentVersion(
|
||||
document_id: string
|
||||
): Promise<DocumentVersion | null> {
|
||||
const result = await query<DocumentVersion>(
|
||||
`SELECT * FROM document_versions
|
||||
WHERE document_id = $1
|
||||
ORDER BY version_number DESC
|
||||
LIMIT 1`,
|
||||
[document_id]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two document versions
|
||||
*/
|
||||
export interface VersionComparison {
|
||||
version1: DocumentVersion;
|
||||
version2: DocumentVersion;
|
||||
differences: {
|
||||
field: string;
|
||||
old_value: unknown;
|
||||
new_value: unknown;
|
||||
}[];
|
||||
}
|
||||
|
||||
export async function compareDocumentVersions(
|
||||
version1_id: string,
|
||||
version2_id: string
|
||||
): Promise<VersionComparison | null> {
|
||||
const v1 = await getDocumentVersion(version1_id);
|
||||
const v2 = await getDocumentVersion(version2_id);
|
||||
|
||||
if (!v1 || !v2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const differences: VersionComparison['differences'] = [];
|
||||
|
||||
if (v1.content !== v2.content) {
|
||||
differences.push({
|
||||
field: 'content',
|
||||
old_value: v1.content,
|
||||
new_value: v2.content,
|
||||
});
|
||||
}
|
||||
|
||||
if (v1.file_url !== v2.file_url) {
|
||||
differences.push({
|
||||
field: 'file_url',
|
||||
old_value: v1.file_url,
|
||||
new_value: v2.file_url,
|
||||
});
|
||||
}
|
||||
|
||||
if (v1.file_hash !== v2.file_hash) {
|
||||
differences.push({
|
||||
field: 'file_hash',
|
||||
old_value: v1.file_hash,
|
||||
new_value: v2.file_hash,
|
||||
});
|
||||
}
|
||||
|
||||
if (v1.change_summary !== v2.change_summary) {
|
||||
differences.push({
|
||||
field: 'change_summary',
|
||||
old_value: v1.change_summary,
|
||||
new_value: v2.change_summary,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
version1: v1,
|
||||
version2: v2,
|
||||
differences,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a document to a previous version
|
||||
*/
|
||||
export async function restoreDocumentVersion(
|
||||
document_id: string,
|
||||
version_id: string,
|
||||
restored_by: string,
|
||||
change_summary?: string
|
||||
): Promise<DocumentVersion> {
|
||||
const targetVersion = await getDocumentVersion(version_id);
|
||||
if (!targetVersion || targetVersion.document_id !== document_id) {
|
||||
throw new Error('Version not found or does not belong to document');
|
||||
}
|
||||
|
||||
// Create new version from the restored one
|
||||
return createDocumentVersion({
|
||||
document_id,
|
||||
version_label: `Restored from v${targetVersion.version_number}`,
|
||||
content: targetVersion.content,
|
||||
file_url: targetVersion.file_url,
|
||||
file_hash: targetVersion.file_hash,
|
||||
file_size: targetVersion.file_size,
|
||||
mime_type: targetVersion.mime_type,
|
||||
change_summary: change_summary || `Restored from version ${targetVersion.version_number}`,
|
||||
change_type: 'restored',
|
||||
created_by: restored_by,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version history with change summaries
|
||||
*/
|
||||
export interface VersionHistoryEntry extends DocumentVersion {
|
||||
created_by_name?: string;
|
||||
created_by_email?: string;
|
||||
}
|
||||
|
||||
export async function getDocumentVersionHistory(
|
||||
document_id: string
|
||||
): Promise<VersionHistoryEntry[]> {
|
||||
const result = await query<VersionHistoryEntry>(
|
||||
`SELECT dv.*, u.name as created_by_name, u.email as created_by_email
|
||||
FROM document_versions dv
|
||||
LEFT JOIN users u ON dv.created_by = u.id
|
||||
WHERE dv.document_id = $1
|
||||
ORDER BY dv.version_number DESC`,
|
||||
[document_id]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
314
packages/database/src/document-workflows.ts
Normal file
314
packages/database/src/document-workflows.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Document Workflow Management
|
||||
* Handles approval, review, signing, and filing workflows
|
||||
*/
|
||||
|
||||
import { query } from './client';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const DocumentWorkflowSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
document_id: z.string().uuid(),
|
||||
workflow_type: z.string(),
|
||||
status: z.enum(['pending', 'in_progress', 'completed', 'rejected', 'cancelled']),
|
||||
priority: z.enum(['low', 'normal', 'high', 'urgent']).optional(),
|
||||
initiated_by: z.string().uuid(),
|
||||
initiated_at: z.date(),
|
||||
completed_at: z.date().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
created_at: z.date(),
|
||||
updated_at: z.date(),
|
||||
});
|
||||
|
||||
export type DocumentWorkflow = z.infer<typeof DocumentWorkflowSchema>;
|
||||
|
||||
export const WorkflowStepSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
workflow_id: z.string().uuid(),
|
||||
step_number: z.number().int().positive(),
|
||||
step_type: z.string(),
|
||||
assigned_to: z.string().uuid().optional(),
|
||||
assigned_role: z.string().optional(),
|
||||
status: z.enum(['pending', 'in_progress', 'approved', 'rejected', 'skipped']),
|
||||
due_date: z.date().optional(),
|
||||
completed_at: z.date().optional(),
|
||||
comments: z.string().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
created_at: z.date(),
|
||||
updated_at: z.date(),
|
||||
});
|
||||
|
||||
export type WorkflowStep = z.infer<typeof WorkflowStepSchema>;
|
||||
|
||||
export type WorkflowType =
|
||||
| 'approval'
|
||||
| 'review'
|
||||
| 'signing'
|
||||
| 'filing'
|
||||
| 'publication'
|
||||
| 'custom';
|
||||
|
||||
export interface CreateDocumentWorkflowInput {
|
||||
document_id: string;
|
||||
workflow_type: WorkflowType;
|
||||
priority?: 'low' | 'normal' | 'high' | 'urgent';
|
||||
initiated_by: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
steps?: CreateWorkflowStepInput[];
|
||||
}
|
||||
|
||||
export interface CreateWorkflowStepInput {
|
||||
step_number: number;
|
||||
step_type: 'approval' | 'review' | 'signature' | 'notification';
|
||||
assigned_to?: string;
|
||||
assigned_role?: string;
|
||||
due_date?: Date | string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a document workflow
|
||||
*/
|
||||
export async function createDocumentWorkflow(
|
||||
input: CreateDocumentWorkflowInput
|
||||
): Promise<DocumentWorkflow> {
|
||||
const result = await query<DocumentWorkflow>(
|
||||
`INSERT INTO document_workflows
|
||||
(document_id, workflow_type, status, priority, initiated_by, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.document_id,
|
||||
input.workflow_type,
|
||||
'pending',
|
||||
input.priority || 'normal',
|
||||
input.initiated_by,
|
||||
input.metadata ? JSON.stringify(input.metadata) : null,
|
||||
]
|
||||
);
|
||||
|
||||
const workflow = result.rows[0]!;
|
||||
|
||||
// Create workflow steps if provided
|
||||
if (input.steps && input.steps.length > 0) {
|
||||
for (const step of input.steps) {
|
||||
await createWorkflowStep(workflow.id, step);
|
||||
}
|
||||
}
|
||||
|
||||
return workflow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workflow by ID
|
||||
*/
|
||||
export async function getDocumentWorkflow(id: string): Promise<DocumentWorkflow | null> {
|
||||
const result = await query<DocumentWorkflow>(
|
||||
`SELECT * FROM document_workflows WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workflows for a document
|
||||
*/
|
||||
export async function getDocumentWorkflows(
|
||||
document_id: string,
|
||||
status?: string
|
||||
): Promise<DocumentWorkflow[]> {
|
||||
const conditions = ['document_id = $1'];
|
||||
const params: unknown[] = [document_id];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (status) {
|
||||
conditions.push(`status = $${paramIndex++}`);
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
const result = await query<DocumentWorkflow>(
|
||||
`SELECT * FROM document_workflows
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY created_at DESC`,
|
||||
params
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update workflow status
|
||||
*/
|
||||
export async function updateWorkflowStatus(
|
||||
id: string,
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'rejected' | 'cancelled'
|
||||
): Promise<DocumentWorkflow | null> {
|
||||
const updates: string[] = [`status = $1`, `updated_at = NOW()`];
|
||||
const values: unknown[] = [status];
|
||||
|
||||
if (status === 'completed' || status === 'rejected' || status === 'cancelled') {
|
||||
updates.push(`completed_at = NOW()`);
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
|
||||
const result = await query<DocumentWorkflow>(
|
||||
`UPDATE document_workflows
|
||||
SET ${updates.join(', ')}
|
||||
WHERE id = $${values.length}
|
||||
RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create workflow step
|
||||
*/
|
||||
export async function createWorkflowStep(
|
||||
workflow_id: string,
|
||||
input: CreateWorkflowStepInput
|
||||
): Promise<WorkflowStep> {
|
||||
const result = await query<WorkflowStep>(
|
||||
`INSERT INTO workflow_steps
|
||||
(workflow_id, step_number, step_type, assigned_to, assigned_role,
|
||||
status, due_date, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *`,
|
||||
[
|
||||
workflow_id,
|
||||
input.step_number,
|
||||
input.step_type,
|
||||
input.assigned_to || null,
|
||||
input.assigned_role || null,
|
||||
'pending',
|
||||
input.due_date ? new Date(input.due_date) : null,
|
||||
input.metadata ? JSON.stringify(input.metadata) : null,
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workflow steps
|
||||
*/
|
||||
export async function getWorkflowSteps(workflow_id: string): Promise<WorkflowStep[]> {
|
||||
const result = await query<WorkflowStep>(
|
||||
`SELECT * FROM workflow_steps
|
||||
WHERE workflow_id = $1
|
||||
ORDER BY step_number ASC`,
|
||||
[workflow_id]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update workflow step status
|
||||
*/
|
||||
export async function updateWorkflowStepStatus(
|
||||
id: string,
|
||||
status: 'pending' | 'in_progress' | 'approved' | 'rejected' | 'skipped',
|
||||
comments?: string
|
||||
): Promise<WorkflowStep | null> {
|
||||
const updates: string[] = [`status = $1`, `updated_at = NOW()`];
|
||||
const values: unknown[] = [status];
|
||||
|
||||
if (status === 'approved' || status === 'rejected') {
|
||||
updates.push(`completed_at = NOW()`);
|
||||
}
|
||||
|
||||
if (comments) {
|
||||
updates.push(`comments = $${values.length + 1}`);
|
||||
values.push(comments);
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
|
||||
const result = await query<WorkflowStep>(
|
||||
`UPDATE workflow_steps
|
||||
SET ${updates.join(', ')}
|
||||
WHERE id = $${values.length}
|
||||
RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending workflows for a user
|
||||
*/
|
||||
export async function getPendingWorkflowsForUser(
|
||||
user_id: string,
|
||||
role?: string
|
||||
): Promise<WorkflowStep[]> {
|
||||
const conditions = ['ws.status = $1'];
|
||||
const params: unknown[] = ['pending'];
|
||||
let paramIndex = 2;
|
||||
|
||||
conditions.push(`(ws.assigned_to = $${paramIndex++} OR ws.assigned_role = $${paramIndex++})`);
|
||||
params.push(user_id);
|
||||
params.push(role || user_id); // Fallback to user_id if no role
|
||||
|
||||
const result = await query<WorkflowStep>(
|
||||
`SELECT ws.*, dw.document_id, dw.workflow_type, dw.priority
|
||||
FROM workflow_steps ws
|
||||
JOIN document_workflows dw ON ws.workflow_id = dw.id
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY dw.priority DESC, ws.due_date ASC NULLS LAST`,
|
||||
params
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workflow progress
|
||||
*/
|
||||
export interface WorkflowProgress {
|
||||
workflow: DocumentWorkflow;
|
||||
steps: WorkflowStep[];
|
||||
completed_steps: number;
|
||||
total_steps: number;
|
||||
current_step?: WorkflowStep;
|
||||
progress_percentage: number;
|
||||
}
|
||||
|
||||
export async function getWorkflowProgress(
|
||||
workflow_id: string
|
||||
): Promise<WorkflowProgress | null> {
|
||||
const workflow = await getDocumentWorkflow(workflow_id);
|
||||
if (!workflow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const steps = await getWorkflowSteps(workflow_id);
|
||||
const completed_steps = steps.filter(
|
||||
(s) => s.status === 'approved' || s.status === 'rejected' || s.status === 'skipped'
|
||||
).length;
|
||||
|
||||
const current_step = steps.find((s) => s.status === 'pending' || s.status === 'in_progress');
|
||||
|
||||
return {
|
||||
workflow,
|
||||
steps,
|
||||
completed_steps,
|
||||
total_steps: steps.length,
|
||||
current_step,
|
||||
progress_percentage: steps.length > 0 ? (completed_steps / steps.length) * 100 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Aliases for route compatibility
|
||||
export const listDocumentWorkflows = getDocumentWorkflows;
|
||||
export const assignWorkflowStep = createWorkflowStep;
|
||||
export async function completeWorkflowStep(
|
||||
step_id: string,
|
||||
status: 'approved' | 'rejected',
|
||||
comments?: string
|
||||
): Promise<WorkflowStep | null> {
|
||||
return updateWorkflowStepStatus(step_id, status, comments);
|
||||
}
|
||||
|
||||
@@ -5,17 +5,43 @@
|
||||
export * from './client';
|
||||
export * from './schema';
|
||||
export * from './credential-lifecycle';
|
||||
export * from './credential-templates';
|
||||
export * from './audit-search';
|
||||
export * from './query-cache';
|
||||
export * from './eresidency-applications';
|
||||
export * from './document-versions';
|
||||
export * from './legal-matters';
|
||||
export * from './document-comments';
|
||||
export * from './document-workflows';
|
||||
export * from './court-filings';
|
||||
export * from './clause-library';
|
||||
export * from './document-checkout';
|
||||
export * from './document-retention';
|
||||
export * from './document-search';
|
||||
|
||||
// Re-export template functions for convenience
|
||||
// Export credential templates (excluding createTemplateVersion to avoid conflict)
|
||||
export {
|
||||
getCredentialTemplateByName,
|
||||
renderCredentialFromTemplate,
|
||||
createCredentialTemplate,
|
||||
getCredentialTemplate,
|
||||
updateCredentialTemplate,
|
||||
listCredentialTemplates,
|
||||
CredentialTemplateSchema,
|
||||
type CredentialTemplate,
|
||||
} from './credential-templates';
|
||||
|
||||
// Export document templates (with createTemplateVersion)
|
||||
export * from './document-templates';
|
||||
|
||||
// Export audit search (excluding getAuditStatistics to avoid conflict)
|
||||
export {
|
||||
searchAuditLogs,
|
||||
type AuditSearchFilters,
|
||||
type AuditSearchResult,
|
||||
} from './audit-search';
|
||||
|
||||
// Export document audit (with getAuditStatistics)
|
||||
export * from './document-audit';
|
||||
|
||||
// Re-export query types
|
||||
export type { QueryResult, QueryResultRow } from './client';
|
||||
|
||||
|
||||
423
packages/database/src/legal-matters.ts
Normal file
423
packages/database/src/legal-matters.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* Legal Matter Management
|
||||
* Handles legal matters, cases, and matter-document relationships
|
||||
*/
|
||||
|
||||
import { query } from './client';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const LegalMatterSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
matter_number: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
matter_type: z.string().optional(),
|
||||
status: z.enum(['open', 'closed', 'on_hold', 'archived']),
|
||||
priority: z.enum(['low', 'normal', 'high', 'urgent']).optional(),
|
||||
client_id: z.string().uuid().optional(),
|
||||
responsible_attorney_id: z.string().uuid().optional(),
|
||||
practice_area: z.string().optional(),
|
||||
jurisdiction: z.string().optional(),
|
||||
court_name: z.string().optional(),
|
||||
case_number: z.string().optional(),
|
||||
opened_date: z.date().optional(),
|
||||
closed_date: z.date().optional(),
|
||||
billing_code: z.string().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
created_by: z.string().uuid().optional(),
|
||||
created_at: z.date(),
|
||||
updated_at: z.date(),
|
||||
});
|
||||
|
||||
export type LegalMatter = z.infer<typeof LegalMatterSchema>;
|
||||
|
||||
export interface CreateLegalMatterInput {
|
||||
matter_number: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
matter_type?: string;
|
||||
status?: 'open' | 'closed' | 'on_hold' | 'archived';
|
||||
priority?: 'low' | 'normal' | 'high' | 'urgent';
|
||||
client_id?: string;
|
||||
responsible_attorney_id?: string;
|
||||
practice_area?: string;
|
||||
jurisdiction?: string;
|
||||
court_name?: string;
|
||||
case_number?: string;
|
||||
opened_date?: Date | string;
|
||||
closed_date?: Date | string;
|
||||
billing_code?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a legal matter
|
||||
*/
|
||||
export async function createLegalMatter(input: CreateLegalMatterInput): Promise<LegalMatter> {
|
||||
const result = await query<LegalMatter>(
|
||||
`INSERT INTO legal_matters
|
||||
(matter_number, title, description, matter_type, status, priority,
|
||||
client_id, responsible_attorney_id, practice_area, jurisdiction,
|
||||
court_name, case_number, opened_date, closed_date, billing_code,
|
||||
metadata, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.matter_number,
|
||||
input.title,
|
||||
input.description || null,
|
||||
input.matter_type || null,
|
||||
input.status || 'open',
|
||||
input.priority || 'normal',
|
||||
input.client_id || null,
|
||||
input.responsible_attorney_id || null,
|
||||
input.practice_area || null,
|
||||
input.jurisdiction || null,
|
||||
input.court_name || null,
|
||||
input.case_number || null,
|
||||
input.opened_date ? new Date(input.opened_date) : null,
|
||||
input.closed_date ? new Date(input.closed_date) : null,
|
||||
input.billing_code || null,
|
||||
input.metadata ? JSON.stringify(input.metadata) : null,
|
||||
input.created_by || null,
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get matter by ID
|
||||
*/
|
||||
export async function getLegalMatter(id: string): Promise<LegalMatter | null> {
|
||||
const result = await query<LegalMatter>(
|
||||
`SELECT * FROM legal_matters WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get matter by matter number
|
||||
*/
|
||||
export async function getLegalMatterByNumber(
|
||||
matter_number: string
|
||||
): Promise<LegalMatter | null> {
|
||||
const result = await query<LegalMatter>(
|
||||
`SELECT * FROM legal_matters WHERE matter_number = $1`,
|
||||
[matter_number]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List matters with filters
|
||||
*/
|
||||
export interface MatterFilters {
|
||||
status?: string | string[];
|
||||
matter_type?: string;
|
||||
client_id?: string;
|
||||
responsible_attorney_id?: string;
|
||||
practice_area?: string;
|
||||
jurisdiction?: string;
|
||||
case_number?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export async function listLegalMatters(
|
||||
filters: MatterFilters = {},
|
||||
limit = 100,
|
||||
offset = 0
|
||||
): Promise<LegalMatter[]> {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (filters.status) {
|
||||
if (Array.isArray(filters.status)) {
|
||||
conditions.push(`status = ANY($${paramIndex++})`);
|
||||
params.push(filters.status);
|
||||
} else {
|
||||
conditions.push(`status = $${paramIndex++}`);
|
||||
params.push(filters.status);
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.matter_type) {
|
||||
conditions.push(`matter_type = $${paramIndex++}`);
|
||||
params.push(filters.matter_type);
|
||||
}
|
||||
|
||||
if (filters.client_id) {
|
||||
conditions.push(`client_id = $${paramIndex++}`);
|
||||
params.push(filters.client_id);
|
||||
}
|
||||
|
||||
if (filters.responsible_attorney_id) {
|
||||
conditions.push(`responsible_attorney_id = $${paramIndex++}`);
|
||||
params.push(filters.responsible_attorney_id);
|
||||
}
|
||||
|
||||
if (filters.practice_area) {
|
||||
conditions.push(`practice_area = $${paramIndex++}`);
|
||||
params.push(filters.practice_area);
|
||||
}
|
||||
|
||||
if (filters.jurisdiction) {
|
||||
conditions.push(`jurisdiction = $${paramIndex++}`);
|
||||
params.push(filters.jurisdiction);
|
||||
}
|
||||
|
||||
if (filters.case_number) {
|
||||
conditions.push(`case_number = $${paramIndex++}`);
|
||||
params.push(filters.case_number);
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
conditions.push(
|
||||
`(matter_number ILIKE $${paramIndex} OR title ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`
|
||||
);
|
||||
params.push(`%${filters.search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const result = await query<LegalMatter>(
|
||||
`SELECT * FROM legal_matters
|
||||
${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
||||
[...params, limit, offset]
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update matter
|
||||
*/
|
||||
export async function updateLegalMatter(
|
||||
id: string,
|
||||
updates: Partial<
|
||||
Pick<
|
||||
LegalMatter,
|
||||
| 'title'
|
||||
| 'description'
|
||||
| 'status'
|
||||
| 'priority'
|
||||
| 'client_id'
|
||||
| 'responsible_attorney_id'
|
||||
| 'practice_area'
|
||||
| 'jurisdiction'
|
||||
| 'court_name'
|
||||
| 'case_number'
|
||||
| 'opened_date'
|
||||
| 'closed_date'
|
||||
| 'billing_code'
|
||||
| 'metadata'
|
||||
>
|
||||
>
|
||||
): Promise<LegalMatter | null> {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
const fieldMap: Record<string, string> = {
|
||||
title: 'title',
|
||||
description: 'description',
|
||||
status: 'status',
|
||||
priority: 'priority',
|
||||
client_id: 'client_id',
|
||||
responsible_attorney_id: 'responsible_attorney_id',
|
||||
practice_area: 'practice_area',
|
||||
jurisdiction: 'jurisdiction',
|
||||
court_name: 'court_name',
|
||||
case_number: 'case_number',
|
||||
opened_date: 'opened_date',
|
||||
closed_date: 'closed_date',
|
||||
billing_code: 'billing_code',
|
||||
metadata: 'metadata',
|
||||
};
|
||||
|
||||
for (const [key, dbField] of Object.entries(fieldMap)) {
|
||||
if (key in updates && updates[key as keyof typeof updates] !== undefined) {
|
||||
const value = updates[key as keyof typeof updates];
|
||||
if (key === 'opened_date' || key === 'closed_date') {
|
||||
fields.push(`${dbField} = $${paramIndex++}`);
|
||||
values.push(value ? new Date(value as Date | string) : null);
|
||||
} else if (key === 'metadata') {
|
||||
fields.push(`${dbField} = $${paramIndex++}`);
|
||||
values.push(JSON.stringify(value));
|
||||
} else {
|
||||
fields.push(`${dbField} = $${paramIndex++}`);
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return getLegalMatter(id);
|
||||
}
|
||||
|
||||
fields.push(`updated_at = NOW()`);
|
||||
values.push(id);
|
||||
|
||||
const result = await query<LegalMatter>(
|
||||
`UPDATE legal_matters
|
||||
SET ${fields.join(', ')}
|
||||
WHERE id = $${paramIndex}
|
||||
RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matter Participants
|
||||
*/
|
||||
export interface MatterParticipant {
|
||||
id: string;
|
||||
matter_id: string;
|
||||
user_id?: string;
|
||||
role: string;
|
||||
organization_name?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
is_primary: boolean;
|
||||
access_level: string;
|
||||
notes?: string;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export async function addMatterParticipant(
|
||||
matter_id: string,
|
||||
participant: {
|
||||
user_id?: string;
|
||||
role: string;
|
||||
organization_name?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
is_primary?: boolean;
|
||||
access_level?: string;
|
||||
notes?: string;
|
||||
}
|
||||
): Promise<MatterParticipant> {
|
||||
const result = await query<MatterParticipant>(
|
||||
`INSERT INTO matter_participants
|
||||
(matter_id, user_id, role, organization_name, email, phone,
|
||||
is_primary, access_level, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING *`,
|
||||
[
|
||||
matter_id,
|
||||
participant.user_id || null,
|
||||
participant.role,
|
||||
participant.organization_name || null,
|
||||
participant.email || null,
|
||||
participant.phone || null,
|
||||
participant.is_primary || false,
|
||||
participant.access_level || 'read',
|
||||
participant.notes || null,
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0]!;
|
||||
}
|
||||
|
||||
export async function getMatterParticipants(matter_id: string): Promise<MatterParticipant[]> {
|
||||
const result = await query<MatterParticipant>(
|
||||
`SELECT * FROM matter_participants
|
||||
WHERE matter_id = $1
|
||||
ORDER BY is_primary DESC, created_at ASC`,
|
||||
[matter_id]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matter-Document Relationships
|
||||
*/
|
||||
export interface MatterDocument {
|
||||
id: string;
|
||||
matter_id: string;
|
||||
document_id: string;
|
||||
relationship_type: string;
|
||||
folder_path?: string;
|
||||
is_primary: boolean;
|
||||
display_order?: number;
|
||||
notes?: string;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export async function linkDocumentToMatter(
|
||||
matter_id: string,
|
||||
document_id: string,
|
||||
relationship_type: string,
|
||||
options?: {
|
||||
folder_path?: string;
|
||||
is_primary?: boolean;
|
||||
display_order?: number;
|
||||
notes?: string;
|
||||
}
|
||||
): Promise<MatterDocument> {
|
||||
const result = await query<MatterDocument>(
|
||||
`INSERT INTO matter_documents
|
||||
(matter_id, document_id, relationship_type, folder_path, is_primary, display_order, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (matter_id, document_id)
|
||||
DO UPDATE SET relationship_type = EXCLUDED.relationship_type,
|
||||
folder_path = EXCLUDED.folder_path,
|
||||
is_primary = EXCLUDED.is_primary,
|
||||
display_order = EXCLUDED.display_order,
|
||||
notes = EXCLUDED.notes
|
||||
RETURNING *`,
|
||||
[
|
||||
matter_id,
|
||||
document_id,
|
||||
relationship_type,
|
||||
options?.folder_path || null,
|
||||
options?.is_primary || false,
|
||||
options?.display_order || null,
|
||||
options?.notes || null,
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0]!;
|
||||
}
|
||||
|
||||
export async function getMatterDocuments(
|
||||
matter_id: string,
|
||||
relationship_type?: string
|
||||
): Promise<MatterDocument[]> {
|
||||
const conditions = ['matter_id = $1'];
|
||||
const params: unknown[] = [matter_id];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (relationship_type) {
|
||||
conditions.push(`relationship_type = $${paramIndex++}`);
|
||||
params.push(relationship_type);
|
||||
}
|
||||
|
||||
const result = await query<MatterDocument>(
|
||||
`SELECT * FROM matter_documents
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY display_order NULLS LAST, created_at ASC`,
|
||||
params
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function getDocumentMatters(document_id: string): Promise<MatterDocument[]> {
|
||||
const result = await query<MatterDocument>(
|
||||
`SELECT * FROM matter_documents
|
||||
WHERE document_id = $1
|
||||
ORDER BY created_at ASC`,
|
||||
[document_id]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
357
packages/database/src/migrations/005_document_management.sql
Normal file
357
packages/database/src/migrations/005_document_management.sql
Normal file
@@ -0,0 +1,357 @@
|
||||
-- Document Management System Migration
|
||||
-- Comprehensive schema for law firm and court document management
|
||||
|
||||
-- Document Versions Table
|
||||
CREATE TABLE IF NOT EXISTS document_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
version_number INTEGER NOT NULL,
|
||||
version_label VARCHAR(50), -- e.g., "v1.0", "Draft", "Final"
|
||||
content TEXT,
|
||||
file_url TEXT,
|
||||
file_hash VARCHAR(64), -- SHA-256 hash for integrity
|
||||
file_size BIGINT,
|
||||
mime_type VARCHAR(100),
|
||||
change_summary TEXT,
|
||||
change_type VARCHAR(50), -- 'created', 'modified', 'restored', 'merged'
|
||||
created_by UUID REFERENCES users(id),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(document_id, version_number)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_versions_document_id ON document_versions(document_id);
|
||||
CREATE INDEX idx_document_versions_created_at ON document_versions(created_at);
|
||||
CREATE INDEX idx_document_versions_created_by ON document_versions(created_by);
|
||||
|
||||
-- Document Templates Table
|
||||
CREATE TABLE IF NOT EXISTS document_templates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
category VARCHAR(100), -- 'contract', 'pleading', 'motion', 'brief', 'corporate', etc.
|
||||
subcategory VARCHAR(100),
|
||||
template_content TEXT NOT NULL, -- Template with variables {{variable_name}}
|
||||
variables JSONB, -- Schema/definition of variables
|
||||
metadata JSONB, -- Additional metadata (jurisdiction, practice area, etc.)
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
is_public BOOLEAN NOT NULL DEFAULT FALSE, -- Public library vs private
|
||||
tags TEXT[], -- For searchability
|
||||
created_by UUID REFERENCES users(id),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(name, version)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_templates_category ON document_templates(category);
|
||||
CREATE INDEX idx_document_templates_active ON document_templates(is_active);
|
||||
CREATE INDEX idx_document_templates_tags ON document_templates USING GIN(tags);
|
||||
CREATE INDEX idx_document_templates_public ON document_templates(is_public);
|
||||
|
||||
-- Legal Matters Table
|
||||
CREATE TABLE IF NOT EXISTS legal_matters (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
matter_number VARCHAR(100) UNIQUE NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
matter_type VARCHAR(100), -- 'litigation', 'transaction', 'advisory', 'regulatory', etc.
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'open', -- 'open', 'closed', 'on_hold', 'archived'
|
||||
priority VARCHAR(20) DEFAULT 'normal', -- 'low', 'normal', 'high', 'urgent'
|
||||
client_id UUID REFERENCES users(id), -- Primary client
|
||||
responsible_attorney_id UUID REFERENCES users(id),
|
||||
practice_area VARCHAR(100),
|
||||
jurisdiction VARCHAR(100),
|
||||
court_name VARCHAR(255),
|
||||
case_number VARCHAR(100),
|
||||
opened_date DATE,
|
||||
closed_date DATE,
|
||||
billing_code VARCHAR(50),
|
||||
metadata JSONB, -- Additional matter-specific data
|
||||
created_by UUID REFERENCES users(id),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_legal_matters_matter_number ON legal_matters(matter_number);
|
||||
CREATE INDEX idx_legal_matters_status ON legal_matters(status);
|
||||
CREATE INDEX idx_legal_matters_client_id ON legal_matters(client_id);
|
||||
CREATE INDEX idx_legal_matters_responsible_attorney ON legal_matters(responsible_attorney_id);
|
||||
CREATE INDEX idx_legal_matters_case_number ON legal_matters(case_number);
|
||||
|
||||
-- Matter Participants (attorneys, clients, parties, etc.)
|
||||
CREATE TABLE IF NOT EXISTS matter_participants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
matter_id UUID NOT NULL REFERENCES legal_matters(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES users(id),
|
||||
role VARCHAR(50) NOT NULL, -- 'attorney', 'client', 'opposing_counsel', 'witness', 'expert', etc.
|
||||
organization_name VARCHAR(255), -- For non-user participants
|
||||
email VARCHAR(255),
|
||||
phone VARCHAR(50),
|
||||
is_primary BOOLEAN DEFAULT FALSE,
|
||||
access_level VARCHAR(50) DEFAULT 'read', -- 'read', 'write', 'admin'
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(matter_id, user_id, role)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_matter_participants_matter_id ON matter_participants(matter_id);
|
||||
CREATE INDEX idx_matter_participants_user_id ON matter_participants(user_id);
|
||||
|
||||
-- Matter-Document Relationships
|
||||
CREATE TABLE IF NOT EXISTS matter_documents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
matter_id UUID NOT NULL REFERENCES legal_matters(id) ON DELETE CASCADE,
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
relationship_type VARCHAR(50) NOT NULL, -- 'pleading', 'exhibit', 'correspondence', 'discovery', 'motion', etc.
|
||||
folder_path VARCHAR(500), -- Virtual folder structure
|
||||
is_primary BOOLEAN DEFAULT FALSE, -- Primary document for matter
|
||||
display_order INTEGER,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(matter_id, document_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_matter_documents_matter_id ON matter_documents(matter_id);
|
||||
CREATE INDEX idx_matter_documents_document_id ON matter_documents(document_id);
|
||||
CREATE INDEX idx_matter_documents_relationship_type ON matter_documents(relationship_type);
|
||||
|
||||
-- Document Audit Log
|
||||
CREATE TABLE IF NOT EXISTS document_audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
document_id UUID REFERENCES documents(id) ON DELETE SET NULL,
|
||||
version_id UUID REFERENCES document_versions(id) ON DELETE SET NULL,
|
||||
matter_id UUID REFERENCES legal_matters(id) ON DELETE SET NULL,
|
||||
action VARCHAR(50) NOT NULL, -- 'created', 'viewed', 'downloaded', 'modified', 'deleted', 'shared', 'filed', etc.
|
||||
performed_by UUID REFERENCES users(id),
|
||||
performed_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
details JSONB, -- Additional action-specific details
|
||||
metadata JSONB
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_audit_log_document_id ON document_audit_log(document_id);
|
||||
CREATE INDEX idx_document_audit_log_performed_by ON document_audit_log(performed_by);
|
||||
CREATE INDEX idx_document_audit_log_performed_at ON document_audit_log(performed_at);
|
||||
CREATE INDEX idx_document_audit_log_action ON document_audit_log(action);
|
||||
CREATE INDEX idx_document_audit_log_matter_id ON document_audit_log(matter_id);
|
||||
|
||||
-- Document Comments/Annotations
|
||||
CREATE TABLE IF NOT EXISTS document_comments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
version_id UUID REFERENCES document_versions(id) ON DELETE SET NULL,
|
||||
parent_comment_id UUID REFERENCES document_comments(id) ON DELETE CASCADE, -- For threaded comments
|
||||
comment_text TEXT NOT NULL,
|
||||
comment_type VARCHAR(50) DEFAULT 'comment', -- 'comment', 'suggestion', 'question', 'resolution'
|
||||
status VARCHAR(50) DEFAULT 'open', -- 'open', 'resolved', 'dismissed'
|
||||
page_number INTEGER,
|
||||
x_position DECIMAL(10,2), -- For PDF annotations
|
||||
y_position DECIMAL(10,2),
|
||||
highlight_text TEXT, -- Selected text being commented on
|
||||
author_id UUID NOT NULL REFERENCES users(id),
|
||||
resolved_by UUID REFERENCES users(id),
|
||||
resolved_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_comments_document_id ON document_comments(document_id);
|
||||
CREATE INDEX idx_document_comments_version_id ON document_comments(version_id);
|
||||
CREATE INDEX idx_document_comments_author_id ON document_comments(author_id);
|
||||
CREATE INDEX idx_document_comments_status ON document_comments(status);
|
||||
CREATE INDEX idx_document_comments_parent ON document_comments(parent_comment_id);
|
||||
|
||||
-- Document Workflows
|
||||
CREATE TABLE IF NOT EXISTS document_workflows (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
workflow_type VARCHAR(50) NOT NULL, -- 'approval', 'review', 'signing', 'filing', 'publication'
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'pending', -- 'pending', 'in_progress', 'completed', 'rejected', 'cancelled'
|
||||
priority VARCHAR(20) DEFAULT 'normal',
|
||||
initiated_by UUID NOT NULL REFERENCES users(id),
|
||||
initiated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
completed_at TIMESTAMP,
|
||||
metadata JSONB, -- Workflow-specific configuration
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_workflows_document_id ON document_workflows(document_id);
|
||||
CREATE INDEX idx_document_workflows_status ON document_workflows(status);
|
||||
CREATE INDEX idx_document_workflows_type ON document_workflows(workflow_type);
|
||||
|
||||
-- Workflow Steps (approval, review, etc.)
|
||||
CREATE TABLE IF NOT EXISTS workflow_steps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workflow_id UUID NOT NULL REFERENCES document_workflows(id) ON DELETE CASCADE,
|
||||
step_number INTEGER NOT NULL,
|
||||
step_type VARCHAR(50) NOT NULL, -- 'approval', 'review', 'signature', 'notification'
|
||||
assigned_to UUID REFERENCES users(id),
|
||||
assigned_role VARCHAR(100), -- Role-based assignment
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'pending', -- 'pending', 'in_progress', 'approved', 'rejected', 'skipped'
|
||||
due_date TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
comments TEXT,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(workflow_id, step_number)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_workflow_steps_workflow_id ON workflow_steps(workflow_id);
|
||||
CREATE INDEX idx_workflow_steps_assigned_to ON workflow_steps(assigned_to);
|
||||
CREATE INDEX idx_workflow_steps_status ON workflow_steps(status);
|
||||
|
||||
-- Court Filings
|
||||
CREATE TABLE IF NOT EXISTS court_filings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
matter_id UUID NOT NULL REFERENCES legal_matters(id) ON DELETE CASCADE,
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
filing_type VARCHAR(100) NOT NULL, -- 'pleading', 'motion', 'brief', 'exhibit', etc.
|
||||
court_name VARCHAR(255) NOT NULL,
|
||||
court_system VARCHAR(100), -- 'federal', 'state', 'municipal', etc.
|
||||
case_number VARCHAR(100),
|
||||
docket_number VARCHAR(100),
|
||||
filing_date DATE,
|
||||
filing_deadline DATE,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'draft', -- 'draft', 'submitted', 'accepted', 'rejected', 'filed'
|
||||
filing_reference VARCHAR(255), -- Court's reference number
|
||||
filing_confirmation TEXT,
|
||||
submitted_by UUID REFERENCES users(id),
|
||||
submitted_at TIMESTAMP,
|
||||
accepted_at TIMESTAMP,
|
||||
rejection_reason TEXT,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_court_filings_matter_id ON court_filings(matter_id);
|
||||
CREATE INDEX idx_court_filings_document_id ON court_filings(document_id);
|
||||
CREATE INDEX idx_court_filings_case_number ON court_filings(case_number);
|
||||
CREATE INDEX idx_court_filings_status ON court_filings(status);
|
||||
CREATE INDEX idx_court_filings_filing_date ON court_filings(filing_date);
|
||||
|
||||
-- Clause Library (for document assembly)
|
||||
CREATE TABLE IF NOT EXISTS clause_library (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
title VARCHAR(255),
|
||||
clause_text TEXT NOT NULL,
|
||||
category VARCHAR(100), -- 'warranty', 'indemnification', 'termination', 'governing_law', etc.
|
||||
subcategory VARCHAR(100),
|
||||
jurisdiction VARCHAR(100),
|
||||
practice_area VARCHAR(100),
|
||||
variables JSONB, -- Variables in clause
|
||||
metadata JSONB,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
is_public BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
tags TEXT[],
|
||||
created_by UUID REFERENCES users(id),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(name, version)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_clause_library_category ON clause_library(category);
|
||||
CREATE INDEX idx_clause_library_tags ON clause_library USING GIN(tags);
|
||||
CREATE INDEX idx_clause_library_active ON clause_library(is_active);
|
||||
|
||||
-- Document Checkout (for preventing concurrent edits)
|
||||
CREATE TABLE IF NOT EXISTS document_checkouts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
checked_out_by UUID NOT NULL REFERENCES users(id),
|
||||
checked_out_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
lock_type VARCHAR(50) DEFAULT 'exclusive', -- 'exclusive', 'shared_read'
|
||||
notes TEXT,
|
||||
UNIQUE(document_id) -- Only one checkout per document
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_checkouts_document_id ON document_checkouts(document_id);
|
||||
CREATE INDEX idx_document_checkouts_user_id ON document_checkouts(checked_out_by);
|
||||
CREATE INDEX idx_document_checkouts_expires_at ON document_checkouts(expires_at);
|
||||
|
||||
-- Document Retention Policies
|
||||
CREATE TABLE IF NOT EXISTS document_retention_policies (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
document_type VARCHAR(100), -- Applies to specific document types
|
||||
matter_type VARCHAR(100), -- Applies to specific matter types
|
||||
retention_period_years INTEGER NOT NULL,
|
||||
retention_trigger VARCHAR(50) DEFAULT 'creation', -- 'creation', 'matter_close', 'last_access'
|
||||
disposal_action VARCHAR(50) DEFAULT 'archive', -- 'archive', 'delete', 'review'
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_by UUID REFERENCES users(id),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_retention_policies_active ON document_retention_policies(is_active);
|
||||
CREATE INDEX idx_retention_policies_document_type ON document_retention_policies(document_type);
|
||||
|
||||
-- Document Retention Records
|
||||
CREATE TABLE IF NOT EXISTS document_retention_records (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
policy_id UUID NOT NULL REFERENCES document_retention_policies(id),
|
||||
retention_start_date DATE NOT NULL,
|
||||
retention_end_date DATE NOT NULL,
|
||||
status VARCHAR(50) DEFAULT 'active', -- 'active', 'expired', 'disposed', 'extended'
|
||||
disposed_at TIMESTAMP,
|
||||
disposed_by UUID REFERENCES users(id),
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_retention_records_document_id ON document_retention_records(document_id);
|
||||
CREATE INDEX idx_retention_records_end_date ON document_retention_records(retention_end_date);
|
||||
CREATE INDEX idx_retention_records_status ON document_retention_records(status);
|
||||
|
||||
-- Update documents table to add version tracking
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS current_version INTEGER DEFAULT 1;
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS latest_version_id UUID REFERENCES document_versions(id);
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS is_checked_out BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS checked_out_by UUID REFERENCES users(id);
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS checked_out_at TIMESTAMP;
|
||||
|
||||
-- Full-text search support (using PostgreSQL's full-text search)
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS search_vector tsvector;
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_search_vector ON documents USING GIN(search_vector);
|
||||
|
||||
-- Function to update search vector
|
||||
CREATE OR REPLACE FUNCTION update_document_search_vector() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.search_vector :=
|
||||
setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') ||
|
||||
setweight(to_tsvector('english', COALESCE(NEW.content, '')), 'B');
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER documents_search_vector_update
|
||||
BEFORE INSERT OR UPDATE ON documents
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_document_search_vector();
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE document_versions IS 'Version history for documents with full revision tracking';
|
||||
COMMENT ON TABLE document_templates IS 'Legal document templates with variable substitution';
|
||||
COMMENT ON TABLE legal_matters IS 'Legal matters/cases with full case management';
|
||||
COMMENT ON TABLE matter_participants IS 'Participants in legal matters (attorneys, clients, parties)';
|
||||
COMMENT ON TABLE matter_documents IS 'Relationship between matters and documents';
|
||||
COMMENT ON TABLE document_audit_log IS 'Comprehensive audit trail for all document actions';
|
||||
COMMENT ON TABLE document_comments IS 'Comments and annotations on documents';
|
||||
COMMENT ON TABLE document_workflows IS 'Workflow management for document approval, review, signing';
|
||||
COMMENT ON TABLE workflow_steps IS 'Individual steps in document workflows';
|
||||
COMMENT ON TABLE court_filings IS 'Court filing records and e-filing tracking';
|
||||
COMMENT ON TABLE clause_library IS 'Reusable clause library for document assembly';
|
||||
COMMENT ON TABLE document_checkouts IS 'Document checkout/lock system to prevent concurrent edits';
|
||||
COMMENT ON TABLE document_retention_policies IS 'Document retention and disposal policies';
|
||||
COMMENT ON TABLE document_retention_records IS 'Retention tracking for individual documents';
|
||||
|
||||
@@ -138,12 +138,24 @@ export async function getDocumentById(id: string): Promise<Document | null> {
|
||||
|
||||
export async function updateDocument(
|
||||
id: string,
|
||||
updates: Partial<Pick<Document, 'status' | 'classification' | 'ocr_text' | 'extracted_data'>>
|
||||
): Promise<Document> {
|
||||
updates: Partial<Pick<Document, 'title' | 'content' | 'file_url' | 'status' | 'classification' | 'ocr_text' | 'extracted_data'>>
|
||||
): Promise<Document | null> {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (updates.title !== undefined) {
|
||||
fields.push(`title = $${paramIndex++}`);
|
||||
values.push(updates.title);
|
||||
}
|
||||
if (updates.content !== undefined) {
|
||||
fields.push(`content = $${paramIndex++}`);
|
||||
values.push(updates.content);
|
||||
}
|
||||
if (updates.file_url !== undefined) {
|
||||
fields.push(`file_url = $${paramIndex++}`);
|
||||
values.push(updates.file_url);
|
||||
}
|
||||
if (updates.status !== undefined) {
|
||||
fields.push(`status = $${paramIndex++}`);
|
||||
values.push(updates.status);
|
||||
@@ -161,6 +173,10 @@ export async function updateDocument(
|
||||
values.push(JSON.stringify(updates.extracted_data));
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return getDocumentById(id);
|
||||
}
|
||||
|
||||
fields.push(`updated_at = NOW()`);
|
||||
values.push(id);
|
||||
|
||||
@@ -168,7 +184,26 @@ export async function updateDocument(
|
||||
`UPDATE documents SET ${fields.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
|
||||
values
|
||||
);
|
||||
return result.rows[0]!;
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
export async function listDocuments(
|
||||
type?: string,
|
||||
limit = 100,
|
||||
offset = 0
|
||||
): Promise<Document[]> {
|
||||
if (type) {
|
||||
const result = await query<Document>(
|
||||
`SELECT * FROM documents WHERE type = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`,
|
||||
[type, limit, offset]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
const result = await query<Document>(
|
||||
`SELECT * FROM documents ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
|
||||
[limit, offset]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
// Deal operations
|
||||
|
||||
Reference in New Issue
Block a user