update docs

This commit is contained in:
2026-07-22 14:11:18 +07:00
parent a836645728
commit 166276d19b
14 changed files with 1308 additions and 17 deletions

View File

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