update docs
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
const crypto = require('crypto');
|
||||
const { sql, getPool } = require('./db');
|
||||
const { normalizeDocumentFileName } = require('./document-file-name');
|
||||
|
||||
const PASSWORD_HASH_PREFIX = 'pbkdf2';
|
||||
const PASSWORD_HASH_ITERATIONS = 120000;
|
||||
@@ -11,6 +12,7 @@ const EMAIL_CONFIRMATION_EXPIRES_MS = Number(process.env.EMAIL_CONFIRMATION_EXPI
|
||||
let emailConfirmationSchemaPromise;
|
||||
let applicationOpenUrlSchemaPromise;
|
||||
let packageTypeSchemaPromise;
|
||||
let documentSchemaPromise;
|
||||
|
||||
function padDatePart(value) {
|
||||
return String(value).padStart(2, '0');
|
||||
@@ -167,7 +169,8 @@ function mapUserRow(row) {
|
||||
createdAt: formatDate(row.CreatedAt),
|
||||
updatedAt: formatDate(row.UpdatedAt),
|
||||
packageCount: Number(row.PackageCount || 0),
|
||||
applicationCount: Number(row.ApplicationCount || 0)
|
||||
applicationCount: Number(row.ApplicationCount || 0),
|
||||
documentCount: Number(row.DocumentCount || 0)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -190,7 +193,7 @@ function duplicateApplicationError() {
|
||||
}
|
||||
|
||||
function userHasOwnedDataError() {
|
||||
const error = new Error('User owns packages or applications.');
|
||||
const error = new Error('User owns packages, applications, or documents.');
|
||||
error.code = 'USER_HAS_OWNED_DATA';
|
||||
return error;
|
||||
}
|
||||
@@ -358,6 +361,69 @@ async function ensurePackageTypeSchema() {
|
||||
return packageTypeSchemaPromise;
|
||||
}
|
||||
|
||||
async function ensureDocumentSchema() {
|
||||
if (!documentSchemaPromise) {
|
||||
documentSchemaPromise = getPool().then((pool) => pool.request().query(`
|
||||
IF OBJECT_ID(N'dbo.Documents', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.Documents
|
||||
(
|
||||
Id UNIQUEIDENTIFIER NOT NULL
|
||||
CONSTRAINT PK_Documents PRIMARY KEY CLUSTERED
|
||||
CONSTRAINT DF_Documents_Id DEFAULT NEWSEQUENTIALID(),
|
||||
Title NVARCHAR(200) NOT NULL,
|
||||
Category NVARCHAR(50) NOT NULL
|
||||
CONSTRAINT DF_Documents_Category DEFAULT N'other',
|
||||
Summary NVARCHAR(1000) NULL,
|
||||
Content NVARCHAR(MAX) NULL,
|
||||
FilePath NVARCHAR(1000) NULL,
|
||||
OriginalFileName NVARCHAR(260) NULL,
|
||||
MimeType NVARCHAR(200) NULL,
|
||||
FileSizeBytes BIGINT NULL,
|
||||
CreatedByUserId UNIQUEIDENTIFIER NOT NULL,
|
||||
CreatedAt DATETIME2(3) NOT NULL
|
||||
CONSTRAINT DF_Documents_CreatedAt DEFAULT SYSUTCDATETIME(),
|
||||
UpdatedAt DATETIME2(3) NULL,
|
||||
CONSTRAINT FK_Documents_CreatedByUser
|
||||
FOREIGN KEY (CreatedByUserId) REFERENCES dbo.Users(Id),
|
||||
CONSTRAINT CK_Documents_Title_NotBlank CHECK (LEN(LTRIM(RTRIM(Title))) > 0),
|
||||
CONSTRAINT CK_Documents_Category CHECK (
|
||||
Category IN (N'introduction', N'guide', N'user-guide', N'technical', N'policy', N'other')
|
||||
),
|
||||
CONSTRAINT CK_Documents_FileSizeBytes CHECK (FileSizeBytes IS NULL OR FileSizeBytes >= 0),
|
||||
CONSTRAINT CK_Documents_HasReadableContent CHECK (
|
||||
NULLIF(LTRIM(RTRIM(Content)), N'') IS NOT NULL OR FilePath IS NOT NULL
|
||||
)
|
||||
);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.indexes
|
||||
WHERE name = N'IX_Documents_Category_UpdatedAt'
|
||||
AND object_id = OBJECT_ID(N'dbo.Documents')
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX IX_Documents_Category_UpdatedAt
|
||||
ON dbo.Documents(Category, UpdatedAt DESC, CreatedAt DESC);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.indexes
|
||||
WHERE name = N'IX_Documents_CreatedByUserId'
|
||||
AND object_id = OBJECT_ID(N'dbo.Documents')
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX IX_Documents_CreatedByUserId
|
||||
ON dbo.Documents(CreatedByUserId);
|
||||
END;
|
||||
`));
|
||||
}
|
||||
|
||||
return documentSchemaPromise;
|
||||
}
|
||||
|
||||
function normalizePackageStatus(isActive) {
|
||||
return isActive ? 'Active' : 'Archived';
|
||||
}
|
||||
@@ -438,6 +504,28 @@ function mapApplicationPackageRow(row) {
|
||||
};
|
||||
}
|
||||
|
||||
function mapDocumentRow(row) {
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
id: String(row.Id),
|
||||
title: row.Title,
|
||||
category: row.Category,
|
||||
summary: row.Summary || '',
|
||||
content: row.Content || '',
|
||||
hasContent: row.HasContent === undefined ? Boolean(row.Content) : Boolean(row.HasContent),
|
||||
filePath: row.FilePath || '',
|
||||
originalFileName: normalizeDocumentFileName(row.OriginalFileName || ''),
|
||||
mimeType: row.MimeType || '',
|
||||
fileSizeBytes: Number(row.FileSizeBytes || 0),
|
||||
fileSize: formatFileSize(row.FileSizeBytes),
|
||||
createdByUserId: row.CreatedByUserId ? String(row.CreatedByUserId) : '',
|
||||
createdBy: row.CreatedByUsername || '',
|
||||
createdAt: formatDate(row.CreatedAt),
|
||||
updatedAt: formatDate(row.UpdatedAt || row.CreatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname) {
|
||||
const host = String(hostname || '').toLowerCase();
|
||||
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
||||
@@ -494,23 +582,27 @@ async function getUserById(id) {
|
||||
}
|
||||
|
||||
async function getUserOwnershipCounts(userId) {
|
||||
await ensureDocumentSchema();
|
||||
const pool = await getPool();
|
||||
const result = await pool.request()
|
||||
.input('UserId', sql.UniqueIdentifier, userId)
|
||||
.query(`
|
||||
SELECT
|
||||
(SELECT COUNT_BIG(*) FROM dbo.Packages WHERE CreatedByUserId = @UserId) AS PackageCount,
|
||||
(SELECT COUNT_BIG(*) FROM dbo.Applications WHERE CreatedByUserId = @UserId) AS ApplicationCount;
|
||||
(SELECT COUNT_BIG(*) FROM dbo.Applications WHERE CreatedByUserId = @UserId) AS ApplicationCount,
|
||||
(SELECT COUNT_BIG(*) FROM dbo.Documents WHERE CreatedByUserId = @UserId) AS DocumentCount;
|
||||
`);
|
||||
const row = result.recordset[0];
|
||||
|
||||
return {
|
||||
packageCount: Number(row.PackageCount || 0),
|
||||
applicationCount: Number(row.ApplicationCount || 0)
|
||||
applicationCount: Number(row.ApplicationCount || 0),
|
||||
documentCount: Number(row.DocumentCount || 0)
|
||||
};
|
||||
}
|
||||
|
||||
async function listUsers() {
|
||||
await ensureDocumentSchema();
|
||||
const pool = await getPool();
|
||||
const result = await pool.request().query(`
|
||||
SELECT
|
||||
@@ -523,7 +615,8 @@ async function listUsers() {
|
||||
u.CreatedAt,
|
||||
u.UpdatedAt,
|
||||
package_count.PackageCount,
|
||||
application_count.ApplicationCount
|
||||
application_count.ApplicationCount,
|
||||
document_count.DocumentCount
|
||||
FROM dbo.Users AS u
|
||||
OUTER APPLY (
|
||||
SELECT COUNT_BIG(*) AS PackageCount
|
||||
@@ -535,6 +628,11 @@ async function listUsers() {
|
||||
FROM dbo.Applications AS a
|
||||
WHERE a.CreatedByUserId = u.Id
|
||||
) AS application_count
|
||||
OUTER APPLY (
|
||||
SELECT COUNT_BIG(*) AS DocumentCount
|
||||
FROM dbo.Documents AS d
|
||||
WHERE d.CreatedByUserId = u.Id
|
||||
) AS document_count
|
||||
ORDER BY u.CreatedAt DESC, u.Username ASC;
|
||||
`);
|
||||
|
||||
@@ -865,7 +963,7 @@ async function updateUser(input) {
|
||||
async function deleteUser(userId) {
|
||||
const counts = await getUserOwnershipCounts(userId);
|
||||
|
||||
if (counts.packageCount > 0 || counts.applicationCount > 0) {
|
||||
if (counts.packageCount > 0 || counts.applicationCount > 0 || counts.documentCount > 0) {
|
||||
throw userHasOwnedDataError();
|
||||
}
|
||||
|
||||
@@ -1588,6 +1686,174 @@ async function removeApplicationPackage(applicationId, packageId) {
|
||||
return result.recordset.length > 0;
|
||||
}
|
||||
|
||||
async function listDocuments() {
|
||||
await ensureDocumentSchema();
|
||||
const pool = await getPool();
|
||||
const result = await pool.request().query(`
|
||||
SELECT
|
||||
d.Id,
|
||||
d.Title,
|
||||
d.Category,
|
||||
d.Summary,
|
||||
CASE WHEN NULLIF(LTRIM(RTRIM(d.Content)), N'') IS NULL THEN 0 ELSE 1 END AS HasContent,
|
||||
d.FilePath,
|
||||
d.OriginalFileName,
|
||||
d.MimeType,
|
||||
d.FileSizeBytes,
|
||||
d.CreatedByUserId,
|
||||
d.CreatedAt,
|
||||
d.UpdatedAt,
|
||||
u.Username AS CreatedByUsername
|
||||
FROM dbo.Documents AS d
|
||||
INNER JOIN dbo.Users AS u
|
||||
ON u.Id = d.CreatedByUserId
|
||||
ORDER BY COALESCE(d.UpdatedAt, d.CreatedAt) DESC, d.Title ASC;
|
||||
`);
|
||||
|
||||
return result.recordset.map(mapDocumentRow);
|
||||
}
|
||||
|
||||
async function getDocumentById(documentId) {
|
||||
await ensureDocumentSchema();
|
||||
const pool = await getPool();
|
||||
const result = await pool.request()
|
||||
.input('Id', sql.NVarChar(100), String(documentId || '').trim())
|
||||
.query(`
|
||||
SELECT TOP (1)
|
||||
d.Id,
|
||||
d.Title,
|
||||
d.Category,
|
||||
d.Summary,
|
||||
d.Content,
|
||||
d.FilePath,
|
||||
d.OriginalFileName,
|
||||
d.MimeType,
|
||||
d.FileSizeBytes,
|
||||
d.CreatedByUserId,
|
||||
d.CreatedAt,
|
||||
d.UpdatedAt,
|
||||
u.Username AS CreatedByUsername
|
||||
FROM dbo.Documents AS d
|
||||
INNER JOIN dbo.Users AS u
|
||||
ON u.Id = d.CreatedByUserId
|
||||
WHERE CONVERT(NVARCHAR(36), d.Id) = @Id;
|
||||
`);
|
||||
|
||||
return mapDocumentRow(result.recordset[0]);
|
||||
}
|
||||
|
||||
async function createDocument(input) {
|
||||
await ensureDocumentSchema();
|
||||
const pool = await getPool();
|
||||
const result = await pool.request()
|
||||
.input('Title', sql.NVarChar(200), String(input.title || '').trim())
|
||||
.input('Category', sql.NVarChar(50), input.category)
|
||||
.input('Summary', sql.NVarChar(1000), String(input.summary || '').trim() || null)
|
||||
.input('Content', sql.NVarChar(sql.MAX), String(input.content || '').trim() || null)
|
||||
.input('FilePath', sql.NVarChar(1000), input.filePath || null)
|
||||
.input('OriginalFileName', sql.NVarChar(260), input.originalFileName || null)
|
||||
.input('MimeType', sql.NVarChar(200), input.mimeType || null)
|
||||
.input('FileSizeBytes', sql.BigInt, input.fileSizeBytes ?? null)
|
||||
.input('CreatedByUserId', sql.UniqueIdentifier, input.createdByUserId)
|
||||
.query(`
|
||||
INSERT dbo.Documents
|
||||
(
|
||||
Title,
|
||||
Category,
|
||||
Summary,
|
||||
Content,
|
||||
FilePath,
|
||||
OriginalFileName,
|
||||
MimeType,
|
||||
FileSizeBytes,
|
||||
CreatedByUserId
|
||||
)
|
||||
OUTPUT inserted.Id
|
||||
VALUES
|
||||
(
|
||||
@Title,
|
||||
@Category,
|
||||
@Summary,
|
||||
@Content,
|
||||
@FilePath,
|
||||
@OriginalFileName,
|
||||
@MimeType,
|
||||
@FileSizeBytes,
|
||||
@CreatedByUserId
|
||||
);
|
||||
`);
|
||||
|
||||
return String(result.recordset[0].Id);
|
||||
}
|
||||
|
||||
async function updateDocument(input) {
|
||||
await ensureDocumentSchema();
|
||||
const pool = await getPool();
|
||||
const replaceAttachment = Boolean(input.replaceAttachment);
|
||||
const result = await pool.request()
|
||||
.input('Id', sql.UniqueIdentifier, input.documentId)
|
||||
.input('Title', sql.NVarChar(200), String(input.title || '').trim())
|
||||
.input('Category', sql.NVarChar(50), input.category)
|
||||
.input('Summary', sql.NVarChar(1000), String(input.summary || '').trim() || null)
|
||||
.input('Content', sql.NVarChar(sql.MAX), String(input.content || '').trim() || null)
|
||||
.input('ReplaceAttachment', sql.Bit, replaceAttachment ? 1 : 0)
|
||||
.input('FilePath', sql.NVarChar(1000), input.filePath || null)
|
||||
.input('OriginalFileName', sql.NVarChar(260), input.originalFileName || null)
|
||||
.input('MimeType', sql.NVarChar(200), input.mimeType || null)
|
||||
.input('FileSizeBytes', sql.BigInt, input.fileSizeBytes ?? null)
|
||||
.query(`
|
||||
UPDATE dbo.Documents
|
||||
SET Title = @Title,
|
||||
Category = @Category,
|
||||
Summary = @Summary,
|
||||
Content = @Content,
|
||||
FilePath = CASE WHEN @ReplaceAttachment = 1 THEN @FilePath ELSE FilePath END,
|
||||
OriginalFileName = CASE WHEN @ReplaceAttachment = 1 THEN @OriginalFileName ELSE OriginalFileName END,
|
||||
MimeType = CASE WHEN @ReplaceAttachment = 1 THEN @MimeType ELSE MimeType END,
|
||||
FileSizeBytes = CASE WHEN @ReplaceAttachment = 1 THEN @FileSizeBytes ELSE FileSizeBytes END,
|
||||
UpdatedAt = SYSUTCDATETIME()
|
||||
OUTPUT
|
||||
inserted.Id,
|
||||
deleted.FilePath AS PreviousFilePath,
|
||||
inserted.FilePath AS CurrentFilePath
|
||||
WHERE Id = @Id;
|
||||
`);
|
||||
const row = result.recordset[0];
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
id: String(row.Id),
|
||||
previousFilePath: row.PreviousFilePath || '',
|
||||
currentFilePath: row.CurrentFilePath || '',
|
||||
attachmentChanged: replaceAttachment
|
||||
};
|
||||
}
|
||||
|
||||
async function deleteDocument(documentId) {
|
||||
await ensureDocumentSchema();
|
||||
const pool = await getPool();
|
||||
const result = await pool.request()
|
||||
.input('Id', sql.UniqueIdentifier, documentId)
|
||||
.query(`
|
||||
DELETE FROM dbo.Documents
|
||||
OUTPUT
|
||||
deleted.Id,
|
||||
deleted.FilePath,
|
||||
deleted.OriginalFileName
|
||||
WHERE Id = @Id;
|
||||
`);
|
||||
const row = result.recordset[0];
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
id: String(row.Id),
|
||||
filePath: row.FilePath || '',
|
||||
originalFileName: row.OriginalFileName || ''
|
||||
};
|
||||
}
|
||||
|
||||
async function getPageData(currentUser) {
|
||||
const [stats, packageRows, applications, activity] = await Promise.all([
|
||||
getStats(),
|
||||
@@ -1635,5 +1901,10 @@ module.exports = {
|
||||
updateApplication,
|
||||
updateApplicationStatus,
|
||||
deleteApplication,
|
||||
removeApplicationPackage
|
||||
removeApplicationPackage,
|
||||
listDocuments,
|
||||
getDocumentById,
|
||||
createDocument,
|
||||
updateDocument,
|
||||
deleteDocument
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user