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:
defiQUG
2025-11-13 09:32:55 -08:00
parent 92cc41d26d
commit 6a8582e54d
202 changed files with 22699 additions and 981 deletions

View 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,
};
}

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

View 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;
}

View 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;
}

View 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;
}

View 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;
}

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

View 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();
}

View 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;
}

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

View File

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

View 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;
}

View 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';

View File

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