@bernierllc/content-storage-adapter 1.2.5 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ContentBatchOperations.js +1 -0
- package/dist/ContentBatchOperations.js.map +1 -1
- package/dist/ContentStorageAdapter.js +1 -0
- package/dist/ContentStorageAdapter.js.map +1 -1
- package/dist/errors/ContentStorageAdapterError.d.ts +12 -0
- package/dist/errors/ContentStorageAdapterError.d.ts.map +1 -0
- package/dist/errors/ContentStorageAdapterError.js +25 -0
- package/dist/errors/ContentStorageAdapterError.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/package.json +8 -3
- package/.eslintrc.json +0 -22
- package/CHANGELOG.md +0 -101
- package/__tests__/ContentBatchOperations.test.ts +0 -130
- package/__tests__/ContentStorageAdapter.test.ts +0 -604
- package/__tests__/mocks/MockDatabaseAdapter.test.ts +0 -97
- package/__tests__/mocks/MockDatabaseAdapter.ts +0 -85
- package/jest.config.cjs +0 -30
- package/src/ContentBatchOperations.ts +0 -72
- package/src/ContentStorageAdapter.ts +0 -476
- package/src/errors/ContentNotFoundError.ts +0 -17
- package/src/errors/DuplicateSlugError.ts +0 -17
- package/src/errors/OptimisticLockError.ts +0 -17
- package/src/index.ts +0 -33
- package/src/models/IContent.ts +0 -122
- package/src/models/IContentFilters.ts +0 -38
- package/src/models/IContentVersion.ts +0 -22
- package/tsconfig.json +0 -26
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
Copyright (c) 2025 Bernier LLC
|
|
3
|
-
|
|
4
|
-
This file is licensed to the client under a limited-use license.
|
|
5
|
-
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
-
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { IDatabaseAdapter, IQueryResult } from '@bernierllc/database-adapter-core';
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Mock database adapter for testing
|
|
13
|
-
*/
|
|
14
|
-
export class MockDatabaseAdapter implements IDatabaseAdapter {
|
|
15
|
-
private queryResults: Map<string, IQueryResult<Record<string, unknown>>> = new Map();
|
|
16
|
-
private connected = false;
|
|
17
|
-
|
|
18
|
-
async connect(): Promise<void> {
|
|
19
|
-
this.connected = true;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
async disconnect(): Promise<void> {
|
|
23
|
-
this.connected = false;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
async query<T = Record<string, unknown>>(
|
|
27
|
-
sql: string,
|
|
28
|
-
_params?: unknown[]
|
|
29
|
-
): Promise<IQueryResult<T>> {
|
|
30
|
-
if (!this.connected) {
|
|
31
|
-
throw new Error('Database not connected');
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Find matching mock result
|
|
35
|
-
const key = this.generateKey(sql);
|
|
36
|
-
const result = this.queryResults.get(key);
|
|
37
|
-
|
|
38
|
-
if (!result) {
|
|
39
|
-
// Return empty result for unregistered queries
|
|
40
|
-
return {
|
|
41
|
-
rows: [],
|
|
42
|
-
rowCount: 0
|
|
43
|
-
} as IQueryResult<T>;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
return result as IQueryResult<T>;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
async beginTransaction(): Promise<void> {
|
|
50
|
-
// Mock implementation
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
async commit(): Promise<void> {
|
|
54
|
-
// Mock implementation
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
async rollback(): Promise<void> {
|
|
58
|
-
// Mock implementation
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Set mock result for a query pattern
|
|
63
|
-
*/
|
|
64
|
-
setQueryResult<T = Record<string, unknown>>(
|
|
65
|
-
sqlPattern: string,
|
|
66
|
-
result: IQueryResult<T>
|
|
67
|
-
): void {
|
|
68
|
-
const key = this.generateKey(sqlPattern);
|
|
69
|
-
this.queryResults.set(key, result as IQueryResult<Record<string, unknown>>);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Clear all mock results
|
|
74
|
-
*/
|
|
75
|
-
clearMocks(): void {
|
|
76
|
-
this.queryResults.clear();
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Generate key from SQL pattern (normalize whitespace)
|
|
81
|
-
*/
|
|
82
|
-
private generateKey(sql: string): string {
|
|
83
|
-
return sql.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
84
|
-
}
|
|
85
|
-
}
|
package/jest.config.cjs
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
Copyright (c) 2025 Bernier LLC
|
|
3
|
-
|
|
4
|
-
This file is licensed to the client under a limited-use license.
|
|
5
|
-
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
-
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
module.exports = {
|
|
10
|
-
preset: 'ts-jest',
|
|
11
|
-
testEnvironment: '<rootDir>/../../../jest-environment-node-fixed.cjs',
|
|
12
|
-
roots: ['<rootDir>/__tests__'],
|
|
13
|
-
testMatch: ['**/*.test.ts'],
|
|
14
|
-
collectCoverageFrom: [
|
|
15
|
-
'src/**/*.ts',
|
|
16
|
-
'!src/**/*.d.ts',
|
|
17
|
-
'!src/**/index.ts'
|
|
18
|
-
],
|
|
19
|
-
coverageThreshold: {
|
|
20
|
-
global: {
|
|
21
|
-
branches: 90,
|
|
22
|
-
functions: 90,
|
|
23
|
-
lines: 90,
|
|
24
|
-
statements: 90
|
|
25
|
-
}
|
|
26
|
-
},
|
|
27
|
-
moduleNameMapper: {
|
|
28
|
-
'^@/(.*)$': '<rootDir>/src/$1'
|
|
29
|
-
}
|
|
30
|
-
};
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
Copyright (c) 2025 Bernier LLC
|
|
3
|
-
|
|
4
|
-
This file is licensed to the client under a limited-use license.
|
|
5
|
-
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
-
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { ContentStorageAdapter } from './ContentStorageAdapter';
|
|
10
|
-
import { ContentStatus } from './models/IContent';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Batch operations for content management
|
|
14
|
-
*/
|
|
15
|
-
export class ContentBatchOperations {
|
|
16
|
-
constructor(private adapter: ContentStorageAdapter) {}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Bulk update status for multiple content items
|
|
20
|
-
*/
|
|
21
|
-
async bulkUpdateStatus(
|
|
22
|
-
ids: string[],
|
|
23
|
-
status: ContentStatus
|
|
24
|
-
): Promise<number> {
|
|
25
|
-
let updated = 0;
|
|
26
|
-
|
|
27
|
-
for (const id of ids) {
|
|
28
|
-
try {
|
|
29
|
-
await this.adapter.update(id, { status });
|
|
30
|
-
updated++;
|
|
31
|
-
} catch (error) {
|
|
32
|
-
// Continue on individual failures
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
return updated;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Bulk soft delete
|
|
41
|
-
*/
|
|
42
|
-
async bulkDelete(ids: string[]): Promise<number> {
|
|
43
|
-
const db = (this.adapter as unknown as { db: { query: (sql: string, params: unknown[]) => Promise<{ rowCount: number }> } }).db;
|
|
44
|
-
const result = await db.query(
|
|
45
|
-
`
|
|
46
|
-
UPDATE content
|
|
47
|
-
SET deleted_at = NOW()
|
|
48
|
-
WHERE id = ANY($1) AND deleted_at IS NULL
|
|
49
|
-
`,
|
|
50
|
-
[ids]
|
|
51
|
-
);
|
|
52
|
-
|
|
53
|
-
return result.rowCount;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Bulk restore
|
|
58
|
-
*/
|
|
59
|
-
async bulkRestore(ids: string[]): Promise<number> {
|
|
60
|
-
const db = (this.adapter as unknown as { db: { query: (sql: string, params: unknown[]) => Promise<{ rowCount: number }> } }).db;
|
|
61
|
-
const result = await db.query(
|
|
62
|
-
`
|
|
63
|
-
UPDATE content
|
|
64
|
-
SET deleted_at = NULL
|
|
65
|
-
WHERE id = ANY($1) AND deleted_at IS NOT NULL
|
|
66
|
-
`,
|
|
67
|
-
[ids]
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
return result.rowCount;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
@@ -1,476 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
Copyright (c) 2025 Bernier LLC
|
|
3
|
-
|
|
4
|
-
This file is licensed to the client under a limited-use license.
|
|
5
|
-
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
-
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { IDatabaseAdapter } from '@bernierllc/database-adapter-core';
|
|
10
|
-
import { generateSlug } from '@bernierllc/slug-generator';
|
|
11
|
-
import {
|
|
12
|
-
IContent,
|
|
13
|
-
IContentCreateData,
|
|
14
|
-
IContentUpdateData,
|
|
15
|
-
ContentType,
|
|
16
|
-
ContentFormat,
|
|
17
|
-
ContentStatus
|
|
18
|
-
} from './models/IContent';
|
|
19
|
-
import { IContentVersion } from './models/IContentVersion';
|
|
20
|
-
import { IContentFilters, IPaginatedResult } from './models/IContentFilters';
|
|
21
|
-
import { ContentNotFoundError } from './errors/ContentNotFoundError';
|
|
22
|
-
import { OptimisticLockError } from './errors/OptimisticLockError';
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Content storage adapter
|
|
26
|
-
* Provides type-safe CRUD operations for content management
|
|
27
|
-
*/
|
|
28
|
-
export class ContentStorageAdapter {
|
|
29
|
-
constructor(private db: IDatabaseAdapter) {}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Create new content with automatic slug generation
|
|
33
|
-
*/
|
|
34
|
-
async create(data: IContentCreateData): Promise<IContent> {
|
|
35
|
-
// Generate unique slug from title
|
|
36
|
-
const baseSlug = generateSlug(data.title);
|
|
37
|
-
const slug = await this.ensureUniqueSlug(baseSlug);
|
|
38
|
-
|
|
39
|
-
const result = await this.db.query<Record<string, unknown>>(
|
|
40
|
-
`
|
|
41
|
-
INSERT INTO content (
|
|
42
|
-
user_id, title, slug, body, excerpt, type, format, status,
|
|
43
|
-
meta_title, meta_description, keywords,
|
|
44
|
-
ai_reviewed, view_count, share_count, generated_from_commit
|
|
45
|
-
) VALUES (
|
|
46
|
-
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
|
|
47
|
-
)
|
|
48
|
-
RETURNING *
|
|
49
|
-
`,
|
|
50
|
-
[
|
|
51
|
-
data.userId,
|
|
52
|
-
data.title,
|
|
53
|
-
slug,
|
|
54
|
-
data.body,
|
|
55
|
-
data.excerpt || null,
|
|
56
|
-
data.type || ContentType.BLOG,
|
|
57
|
-
data.format || ContentFormat.MARKDOWN,
|
|
58
|
-
data.status || ContentStatus.DRAFT,
|
|
59
|
-
data.metaTitle || null,
|
|
60
|
-
data.metaDescription || null,
|
|
61
|
-
data.keywords || [],
|
|
62
|
-
false, // ai_reviewed
|
|
63
|
-
0, // view_count
|
|
64
|
-
0, // share_count
|
|
65
|
-
false // generated_from_commit
|
|
66
|
-
]
|
|
67
|
-
);
|
|
68
|
-
|
|
69
|
-
return this.mapRowToContent(result.rows[0]);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Get content by ID (with soft-delete check)
|
|
74
|
-
*/
|
|
75
|
-
async getById(
|
|
76
|
-
id: string,
|
|
77
|
-
options: { includeDeleted?: boolean } = {}
|
|
78
|
-
): Promise<IContent | null> {
|
|
79
|
-
const whereClause = options.includeDeleted
|
|
80
|
-
? 'id = $1'
|
|
81
|
-
: 'id = $1 AND deleted_at IS NULL';
|
|
82
|
-
|
|
83
|
-
const result = await this.db.query<Record<string, unknown>>(
|
|
84
|
-
`SELECT * FROM content WHERE ${whereClause}`,
|
|
85
|
-
[id]
|
|
86
|
-
);
|
|
87
|
-
|
|
88
|
-
return result.rows.length > 0
|
|
89
|
-
? this.mapRowToContent(result.rows[0])
|
|
90
|
-
: null;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Get content by slug
|
|
95
|
-
*/
|
|
96
|
-
async getBySlug(slug: string): Promise<IContent | null> {
|
|
97
|
-
const result = await this.db.query<Record<string, unknown>>(
|
|
98
|
-
'SELECT * FROM content WHERE slug = $1 AND deleted_at IS NULL',
|
|
99
|
-
[slug]
|
|
100
|
-
);
|
|
101
|
-
|
|
102
|
-
return result.rows.length > 0
|
|
103
|
-
? this.mapRowToContent(result.rows[0])
|
|
104
|
-
: null;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Update content with optimistic locking
|
|
109
|
-
*/
|
|
110
|
-
async update(
|
|
111
|
-
id: string,
|
|
112
|
-
data: IContentUpdateData,
|
|
113
|
-
expectedUpdatedAt?: Date
|
|
114
|
-
): Promise<IContent> {
|
|
115
|
-
// Build dynamic UPDATE query
|
|
116
|
-
const updates: string[] = [];
|
|
117
|
-
const params: unknown[] = [];
|
|
118
|
-
let paramIndex = 1;
|
|
119
|
-
|
|
120
|
-
if (data.title !== undefined) {
|
|
121
|
-
updates.push(`title = $${paramIndex++}`);
|
|
122
|
-
params.push(data.title);
|
|
123
|
-
}
|
|
124
|
-
if (data.body !== undefined) {
|
|
125
|
-
updates.push(`body = $${paramIndex++}`);
|
|
126
|
-
params.push(data.body);
|
|
127
|
-
}
|
|
128
|
-
if (data.excerpt !== undefined) {
|
|
129
|
-
updates.push(`excerpt = $${paramIndex++}`);
|
|
130
|
-
params.push(data.excerpt);
|
|
131
|
-
}
|
|
132
|
-
if (data.status !== undefined) {
|
|
133
|
-
updates.push(`status = $${paramIndex++}`);
|
|
134
|
-
params.push(data.status);
|
|
135
|
-
}
|
|
136
|
-
if (data.workflowStage !== undefined) {
|
|
137
|
-
updates.push(`workflow_stage = $${paramIndex++}`);
|
|
138
|
-
params.push(data.workflowStage);
|
|
139
|
-
}
|
|
140
|
-
if (data.assignedTo !== undefined) {
|
|
141
|
-
updates.push(`assigned_to = $${paramIndex++}`);
|
|
142
|
-
params.push(data.assignedTo);
|
|
143
|
-
}
|
|
144
|
-
if (data.publishedAt !== undefined) {
|
|
145
|
-
updates.push(`published_at = $${paramIndex++}`);
|
|
146
|
-
params.push(data.publishedAt);
|
|
147
|
-
}
|
|
148
|
-
if (data.scheduledFor !== undefined) {
|
|
149
|
-
updates.push(`scheduled_for = $${paramIndex++}`);
|
|
150
|
-
params.push(data.scheduledFor);
|
|
151
|
-
}
|
|
152
|
-
if (data.metaTitle !== undefined) {
|
|
153
|
-
updates.push(`meta_title = $${paramIndex++}`);
|
|
154
|
-
params.push(data.metaTitle);
|
|
155
|
-
}
|
|
156
|
-
if (data.metaDescription !== undefined) {
|
|
157
|
-
updates.push(`meta_description = $${paramIndex++}`);
|
|
158
|
-
params.push(data.metaDescription);
|
|
159
|
-
}
|
|
160
|
-
if (data.keywords !== undefined) {
|
|
161
|
-
updates.push(`keywords = $${paramIndex++}`);
|
|
162
|
-
params.push(data.keywords);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
if (updates.length === 0) {
|
|
166
|
-
throw new Error('No fields to update');
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Add optimistic locking check
|
|
170
|
-
params.push(id);
|
|
171
|
-
const idParam = paramIndex++;
|
|
172
|
-
|
|
173
|
-
let whereClause = `id = $${idParam} AND deleted_at IS NULL`;
|
|
174
|
-
if (expectedUpdatedAt) {
|
|
175
|
-
params.push(expectedUpdatedAt);
|
|
176
|
-
whereClause += ` AND updated_at = $${paramIndex++}`;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const sql = `
|
|
180
|
-
UPDATE content
|
|
181
|
-
SET ${updates.join(', ')}, updated_at = NOW()
|
|
182
|
-
WHERE ${whereClause}
|
|
183
|
-
RETURNING *
|
|
184
|
-
`;
|
|
185
|
-
|
|
186
|
-
const result = await this.db.query<Record<string, unknown>>(sql, params);
|
|
187
|
-
|
|
188
|
-
if (result.rows.length === 0) {
|
|
189
|
-
if (expectedUpdatedAt) {
|
|
190
|
-
throw new OptimisticLockError(id);
|
|
191
|
-
}
|
|
192
|
-
throw new ContentNotFoundError(id);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
return this.mapRowToContent(result.rows[0]);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Soft delete content
|
|
200
|
-
*/
|
|
201
|
-
async delete(id: string): Promise<void> {
|
|
202
|
-
const result = await this.db.query(
|
|
203
|
-
`
|
|
204
|
-
UPDATE content
|
|
205
|
-
SET deleted_at = NOW()
|
|
206
|
-
WHERE id = $1 AND deleted_at IS NULL
|
|
207
|
-
`,
|
|
208
|
-
[id]
|
|
209
|
-
);
|
|
210
|
-
|
|
211
|
-
if (result.rowCount === 0) {
|
|
212
|
-
throw new ContentNotFoundError(id);
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Permanently delete content (use sparingly)
|
|
218
|
-
*/
|
|
219
|
-
async hardDelete(id: string): Promise<void> {
|
|
220
|
-
await this.db.query('DELETE FROM content WHERE id = $1', [id]);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Restore soft-deleted content
|
|
225
|
-
*/
|
|
226
|
-
async restore(id: string): Promise<IContent> {
|
|
227
|
-
const result = await this.db.query<Record<string, unknown>>(
|
|
228
|
-
`
|
|
229
|
-
UPDATE content
|
|
230
|
-
SET deleted_at = NULL
|
|
231
|
-
WHERE id = $1 AND deleted_at IS NOT NULL
|
|
232
|
-
RETURNING *
|
|
233
|
-
`,
|
|
234
|
-
[id]
|
|
235
|
-
);
|
|
236
|
-
|
|
237
|
-
if (result.rows.length === 0) {
|
|
238
|
-
throw new Error('Content not found or not deleted');
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
return this.mapRowToContent(result.rows[0]);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* List content with filters and pagination
|
|
246
|
-
*/
|
|
247
|
-
async list(
|
|
248
|
-
filters: IContentFilters = {}
|
|
249
|
-
): Promise<IPaginatedResult<IContent>> {
|
|
250
|
-
// Build WHERE clause
|
|
251
|
-
const conditions: string[] = [];
|
|
252
|
-
const params: unknown[] = [];
|
|
253
|
-
let paramIndex = 1;
|
|
254
|
-
|
|
255
|
-
if (!filters.includeDeleted) {
|
|
256
|
-
conditions.push('deleted_at IS NULL');
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
if (filters.userId) {
|
|
260
|
-
conditions.push(`user_id = $${paramIndex++}`);
|
|
261
|
-
params.push(filters.userId);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
if (filters.status) {
|
|
265
|
-
if (Array.isArray(filters.status)) {
|
|
266
|
-
conditions.push(`status = ANY($${paramIndex++})`);
|
|
267
|
-
params.push(filters.status);
|
|
268
|
-
} else {
|
|
269
|
-
conditions.push(`status = $${paramIndex++}`);
|
|
270
|
-
params.push(filters.status);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
if (filters.type) {
|
|
275
|
-
if (Array.isArray(filters.type)) {
|
|
276
|
-
conditions.push(`type = ANY($${paramIndex++})`);
|
|
277
|
-
params.push(filters.type);
|
|
278
|
-
} else {
|
|
279
|
-
conditions.push(`type = $${paramIndex++}`);
|
|
280
|
-
params.push(filters.type);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
if (filters.workflowStage) {
|
|
285
|
-
conditions.push(`workflow_stage = $${paramIndex++}`);
|
|
286
|
-
params.push(filters.workflowStage);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
if (filters.publishedAfter) {
|
|
290
|
-
conditions.push(`published_at >= $${paramIndex++}`);
|
|
291
|
-
params.push(filters.publishedAfter);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
if (filters.publishedBefore) {
|
|
295
|
-
conditions.push(`published_at <= $${paramIndex++}`);
|
|
296
|
-
params.push(filters.publishedBefore);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
if (filters.searchQuery) {
|
|
300
|
-
conditions.push(`(
|
|
301
|
-
title ILIKE $${paramIndex} OR
|
|
302
|
-
body ILIKE $${paramIndex} OR
|
|
303
|
-
excerpt ILIKE $${paramIndex}
|
|
304
|
-
)`);
|
|
305
|
-
params.push(`%${filters.searchQuery}%`);
|
|
306
|
-
paramIndex++;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
const whereClause = conditions.length > 0
|
|
310
|
-
? `WHERE ${conditions.join(' AND ')}`
|
|
311
|
-
: '';
|
|
312
|
-
|
|
313
|
-
// Build ORDER BY
|
|
314
|
-
const orderBy = filters.orderBy || 'createdAt';
|
|
315
|
-
const orderDirection = filters.orderDirection || 'DESC';
|
|
316
|
-
const orderClause = `ORDER BY ${this.mapOrderField(orderBy)} ${orderDirection}`;
|
|
317
|
-
|
|
318
|
-
// Pagination
|
|
319
|
-
const limit = filters.limit || 20;
|
|
320
|
-
const offset = filters.offset || 0;
|
|
321
|
-
|
|
322
|
-
// Get total count
|
|
323
|
-
const countResult = await this.db.query<{ count: string }>(
|
|
324
|
-
`SELECT COUNT(*) as count FROM content ${whereClause}`,
|
|
325
|
-
params
|
|
326
|
-
);
|
|
327
|
-
const total = parseInt(countResult.rows[0].count);
|
|
328
|
-
|
|
329
|
-
// Get data
|
|
330
|
-
params.push(limit, offset);
|
|
331
|
-
const dataResult = await this.db.query<Record<string, unknown>>(
|
|
332
|
-
`
|
|
333
|
-
SELECT * FROM content
|
|
334
|
-
${whereClause}
|
|
335
|
-
${orderClause}
|
|
336
|
-
LIMIT $${paramIndex++} OFFSET $${paramIndex++}
|
|
337
|
-
`,
|
|
338
|
-
params
|
|
339
|
-
);
|
|
340
|
-
|
|
341
|
-
return {
|
|
342
|
-
data: dataResult.rows.map(row => this.mapRowToContent(row)),
|
|
343
|
-
total,
|
|
344
|
-
limit,
|
|
345
|
-
offset,
|
|
346
|
-
hasMore: offset + limit < total
|
|
347
|
-
};
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* Get content versions
|
|
352
|
-
*/
|
|
353
|
-
async getVersions(contentId: string): Promise<IContentVersion[]> {
|
|
354
|
-
const result = await this.db.query<Record<string, unknown>>(
|
|
355
|
-
`
|
|
356
|
-
SELECT * FROM content_versions
|
|
357
|
-
WHERE content_id = $1
|
|
358
|
-
ORDER BY version_number DESC
|
|
359
|
-
`,
|
|
360
|
-
[contentId]
|
|
361
|
-
);
|
|
362
|
-
|
|
363
|
-
return result.rows.map(row => this.mapRowToVersion(row));
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* Get specific version
|
|
368
|
-
*/
|
|
369
|
-
async getVersion(
|
|
370
|
-
contentId: string,
|
|
371
|
-
versionNumber: number
|
|
372
|
-
): Promise<IContentVersion | null> {
|
|
373
|
-
const result = await this.db.query<Record<string, unknown>>(
|
|
374
|
-
`
|
|
375
|
-
SELECT * FROM content_versions
|
|
376
|
-
WHERE content_id = $1 AND version_number = $2
|
|
377
|
-
`,
|
|
378
|
-
[contentId, versionNumber]
|
|
379
|
-
);
|
|
380
|
-
|
|
381
|
-
return result.rows.length > 0
|
|
382
|
-
? this.mapRowToVersion(result.rows[0])
|
|
383
|
-
: null;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* Ensure slug is unique by appending number if needed
|
|
388
|
-
*/
|
|
389
|
-
private async ensureUniqueSlug(baseSlug: string): Promise<string> {
|
|
390
|
-
let slug = baseSlug;
|
|
391
|
-
let counter = 1;
|
|
392
|
-
|
|
393
|
-
while (await this.slugExists(slug)) {
|
|
394
|
-
slug = `${baseSlug}-${counter}`;
|
|
395
|
-
counter++;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
return slug;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* Check if slug exists
|
|
403
|
-
*/
|
|
404
|
-
private async slugExists(slug: string): Promise<boolean> {
|
|
405
|
-
const result = await this.db.query<{ exists: boolean }>(
|
|
406
|
-
'SELECT EXISTS(SELECT 1 FROM content WHERE slug = $1 AND deleted_at IS NULL) as exists',
|
|
407
|
-
[slug]
|
|
408
|
-
);
|
|
409
|
-
return result.rows[0].exists;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/**
|
|
413
|
-
* Map database row to content model
|
|
414
|
-
*/
|
|
415
|
-
private mapRowToContent(row: Record<string, unknown>): IContent {
|
|
416
|
-
return {
|
|
417
|
-
id: row.id as string,
|
|
418
|
-
userId: row.user_id as string,
|
|
419
|
-
title: row.title as string,
|
|
420
|
-
slug: row.slug as string,
|
|
421
|
-
body: row.body as string,
|
|
422
|
-
excerpt: row.excerpt as string | undefined,
|
|
423
|
-
type: row.type as ContentType,
|
|
424
|
-
format: row.format as ContentFormat,
|
|
425
|
-
status: row.status as ContentStatus,
|
|
426
|
-
workflowStage: row.workflow_stage as string | undefined,
|
|
427
|
-
assignedTo: row.assigned_to as string | undefined,
|
|
428
|
-
aiReviewed: row.ai_reviewed as boolean,
|
|
429
|
-
aiReviewScore: row.ai_review_score as number | undefined,
|
|
430
|
-
aiProvider: row.ai_provider as string | undefined,
|
|
431
|
-
publishedAt: row.published_at as Date | undefined,
|
|
432
|
-
scheduledFor: row.scheduled_for as Date | undefined,
|
|
433
|
-
deletedAt: row.deleted_at as Date | undefined,
|
|
434
|
-
metaTitle: row.meta_title as string | undefined,
|
|
435
|
-
metaDescription: row.meta_description as string | undefined,
|
|
436
|
-
keywords: row.keywords as string[] | undefined,
|
|
437
|
-
viewCount: row.view_count as number,
|
|
438
|
-
shareCount: row.share_count as number,
|
|
439
|
-
generatedFromCommit: row.generated_from_commit as boolean,
|
|
440
|
-
sourceCommitSha: row.source_commit_sha as string | undefined,
|
|
441
|
-
sourceRepository: row.source_repository as string | undefined,
|
|
442
|
-
createdAt: row.created_at as Date,
|
|
443
|
-
updatedAt: row.updated_at as Date
|
|
444
|
-
};
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
/**
|
|
448
|
-
* Map database row to version model
|
|
449
|
-
*/
|
|
450
|
-
private mapRowToVersion(row: Record<string, unknown>): IContentVersion {
|
|
451
|
-
return {
|
|
452
|
-
id: row.id as string,
|
|
453
|
-
contentId: row.content_id as string,
|
|
454
|
-
versionNumber: row.version_number as number,
|
|
455
|
-
title: row.title as string,
|
|
456
|
-
body: row.body as string,
|
|
457
|
-
changeSummary: row.change_summary as string | undefined,
|
|
458
|
-
isAutosave: row.is_autosave as boolean,
|
|
459
|
-
createdAt: row.created_at as Date,
|
|
460
|
-
createdBy: row.created_by as string
|
|
461
|
-
};
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
/**
|
|
465
|
-
* Map order field to database column
|
|
466
|
-
*/
|
|
467
|
-
private mapOrderField(field: string): string {
|
|
468
|
-
const fieldMap: Record<string, string> = {
|
|
469
|
-
createdAt: 'created_at',
|
|
470
|
-
updatedAt: 'updated_at',
|
|
471
|
-
publishedAt: 'published_at',
|
|
472
|
-
title: 'title'
|
|
473
|
-
};
|
|
474
|
-
return fieldMap[field] || 'created_at';
|
|
475
|
-
}
|
|
476
|
-
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
Copyright (c) 2025 Bernier LLC
|
|
3
|
-
|
|
4
|
-
This file is licensed to the client under a limited-use license.
|
|
5
|
-
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
-
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Error thrown when content is not found
|
|
11
|
-
*/
|
|
12
|
-
export class ContentNotFoundError extends Error {
|
|
13
|
-
constructor(id: string) {
|
|
14
|
-
super(`Content not found or already deleted: ${id}`);
|
|
15
|
-
this.name = 'ContentNotFoundError';
|
|
16
|
-
}
|
|
17
|
-
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
Copyright (c) 2025 Bernier LLC
|
|
3
|
-
|
|
4
|
-
This file is licensed to the client under a limited-use license.
|
|
5
|
-
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
-
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Error thrown when slug already exists
|
|
11
|
-
*/
|
|
12
|
-
export class DuplicateSlugError extends Error {
|
|
13
|
-
constructor(slug: string) {
|
|
14
|
-
super(`Slug already exists: ${slug}`);
|
|
15
|
-
this.name = 'DuplicateSlugError';
|
|
16
|
-
}
|
|
17
|
-
}
|