1325 lines
40 KiB
JavaScript
1325 lines
40 KiB
JavaScript
const crypto = require('crypto');
|
|
const { sql, getPool } = require('./db');
|
|
|
|
const PASSWORD_HASH_PREFIX = 'pbkdf2';
|
|
const PASSWORD_HASH_ITERATIONS = 120000;
|
|
const PASSWORD_HASH_KEY_LENGTH = 32;
|
|
const PASSWORD_HASH_DIGEST = 'sha256';
|
|
const PLACEHOLDER_PASSWORD_HASH = 'ui-placeholder-password-hash';
|
|
const EMAIL_CONFIRMATION_EXPIRES_MS = Number(process.env.EMAIL_CONFIRMATION_EXPIRES_MS || 1000 * 60 * 60 * 24);
|
|
|
|
let emailConfirmationSchemaPromise;
|
|
|
|
function toDateInputValue(value) {
|
|
const date = value ? new Date(value) : new Date();
|
|
if (Number.isNaN(date.getTime())) return new Date().toISOString().slice(0, 10);
|
|
return date.toISOString().slice(0, 10);
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) return '';
|
|
return toDateInputValue(value);
|
|
}
|
|
|
|
function formatFileSize(bytes) {
|
|
if (!Number.isFinite(Number(bytes)) || Number(bytes) <= 0) return '';
|
|
|
|
const numericBytes = Number(bytes);
|
|
const units = ['B', 'KB', 'MB', 'GB'];
|
|
const index = Math.min(Math.floor(Math.log(numericBytes) / Math.log(1024)), units.length - 1);
|
|
const value = numericBytes / Math.pow(1024, index);
|
|
|
|
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
|
|
}
|
|
|
|
function normalizeRole(role) {
|
|
return role === 'Admin' ? 'Admin' : 'User';
|
|
}
|
|
|
|
function normalizeEmail(email) {
|
|
return String(email || '').trim().toLowerCase();
|
|
}
|
|
|
|
function isPasswordHashUsable(passwordHash) {
|
|
return String(passwordHash || '').startsWith(`${PASSWORD_HASH_PREFIX}$`);
|
|
}
|
|
|
|
function hashPassword(password) {
|
|
const salt = crypto.randomBytes(16).toString('base64url');
|
|
const hash = crypto.pbkdf2Sync(
|
|
password,
|
|
salt,
|
|
PASSWORD_HASH_ITERATIONS,
|
|
PASSWORD_HASH_KEY_LENGTH,
|
|
PASSWORD_HASH_DIGEST
|
|
).toString('base64url');
|
|
|
|
return `${PASSWORD_HASH_PREFIX}$${PASSWORD_HASH_DIGEST}$${PASSWORD_HASH_ITERATIONS}$${salt}$${hash}`;
|
|
}
|
|
|
|
function verifyPassword(password, storedHash) {
|
|
if (!isPasswordHashUsable(storedHash)) return false;
|
|
|
|
const [prefix, digest, iterationsText, salt, expectedHash] = String(storedHash).split('$');
|
|
const iterations = Number(iterationsText);
|
|
|
|
if (prefix !== PASSWORD_HASH_PREFIX || !digest || !iterations || !salt || !expectedHash) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const actualHash = crypto.pbkdf2Sync(
|
|
password,
|
|
salt,
|
|
iterations,
|
|
Buffer.from(expectedHash, 'base64url').length,
|
|
digest
|
|
).toString('base64url');
|
|
const actualBuffer = Buffer.from(actualHash);
|
|
const expectedBuffer = Buffer.from(expectedHash);
|
|
|
|
return actualBuffer.length === expectedBuffer.length && crypto.timingSafeEqual(actualBuffer, expectedBuffer);
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function mapCurrentUserRow(row) {
|
|
if (!row) return null;
|
|
|
|
return {
|
|
id: String(row.Id),
|
|
username: row.Username,
|
|
email: row.Email,
|
|
name: row.FullName || row.Username,
|
|
fullName: row.FullName || '',
|
|
role: row.Role,
|
|
isActive: Boolean(row.IsActive)
|
|
};
|
|
}
|
|
|
|
function mapUserRow(row) {
|
|
return {
|
|
...mapCurrentUserRow(row),
|
|
status: row.IsActive ? 'Active' : 'Inactive',
|
|
createdAt: formatDate(row.CreatedAt),
|
|
updatedAt: formatDate(row.UpdatedAt),
|
|
packageCount: Number(row.PackageCount || 0),
|
|
applicationCount: Number(row.ApplicationCount || 0)
|
|
};
|
|
}
|
|
|
|
function duplicateUserError() {
|
|
const error = new Error('Username or email already exists.');
|
|
error.code = 'DUPLICATE_USER';
|
|
return error;
|
|
}
|
|
|
|
function duplicatePackageError() {
|
|
const error = new Error('Package code or version already exists.');
|
|
error.code = 'DUPLICATE_PACKAGE';
|
|
return error;
|
|
}
|
|
|
|
function duplicateApplicationError() {
|
|
const error = new Error('Application code already exists.');
|
|
error.code = 'DUPLICATE_APPLICATION';
|
|
return error;
|
|
}
|
|
|
|
function userHasOwnedDataError() {
|
|
const error = new Error('User owns packages or applications.');
|
|
error.code = 'USER_HAS_OWNED_DATA';
|
|
return error;
|
|
}
|
|
|
|
function hashEmailToken(token) {
|
|
return crypto.createHash('sha256').update(String(token || '')).digest('hex');
|
|
}
|
|
|
|
async function ensureEmailConfirmationSchema() {
|
|
if (!emailConfirmationSchemaPromise) {
|
|
emailConfirmationSchemaPromise = getPool().then((pool) => pool.request().query(`
|
|
IF OBJECT_ID(N'dbo.EmailConfirmationTokens', N'U') IS NULL
|
|
BEGIN
|
|
CREATE TABLE dbo.EmailConfirmationTokens
|
|
(
|
|
Id UNIQUEIDENTIFIER NOT NULL
|
|
CONSTRAINT PK_EmailConfirmationTokens PRIMARY KEY CLUSTERED
|
|
CONSTRAINT DF_EmailConfirmationTokens_Id DEFAULT NEWSEQUENTIALID(),
|
|
UserId UNIQUEIDENTIFIER NOT NULL,
|
|
TokenHash CHAR(64) NOT NULL,
|
|
ExpiresAt DATETIME2(3) NOT NULL,
|
|
ConfirmedAt DATETIME2(3) NULL,
|
|
CreatedAt DATETIME2(3) NOT NULL
|
|
CONSTRAINT DF_EmailConfirmationTokens_CreatedAt DEFAULT SYSUTCDATETIME(),
|
|
CONSTRAINT FK_EmailConfirmationTokens_User
|
|
FOREIGN KEY (UserId) REFERENCES dbo.Users(Id) ON DELETE CASCADE
|
|
);
|
|
END;
|
|
|
|
IF NOT EXISTS (
|
|
SELECT 1
|
|
FROM sys.indexes
|
|
WHERE name = N'UX_EmailConfirmationTokens_TokenHash'
|
|
AND object_id = OBJECT_ID(N'dbo.EmailConfirmationTokens')
|
|
)
|
|
BEGIN
|
|
CREATE UNIQUE INDEX UX_EmailConfirmationTokens_TokenHash
|
|
ON dbo.EmailConfirmationTokens(TokenHash);
|
|
END;
|
|
|
|
IF NOT EXISTS (
|
|
SELECT 1
|
|
FROM sys.indexes
|
|
WHERE name = N'IX_EmailConfirmationTokens_UserId'
|
|
AND object_id = OBJECT_ID(N'dbo.EmailConfirmationTokens')
|
|
)
|
|
BEGIN
|
|
CREATE INDEX IX_EmailConfirmationTokens_UserId
|
|
ON dbo.EmailConfirmationTokens(UserId);
|
|
END;
|
|
`));
|
|
}
|
|
|
|
return emailConfirmationSchemaPromise;
|
|
}
|
|
|
|
function normalizePackageStatus(isActive) {
|
|
return isActive ? 'Active' : 'Archived';
|
|
}
|
|
|
|
function normalizeApplicationStatus(status) {
|
|
return ['Draft', 'Released', 'Archived'].includes(status) ? status : 'Draft';
|
|
}
|
|
|
|
function normalizeVersionStatus(row) {
|
|
if (row.IsLatest) return 'Latest';
|
|
if (row.IsDeprecated) return 'Deprecated';
|
|
return 'Stable';
|
|
}
|
|
|
|
function mapPackageRow(row) {
|
|
return {
|
|
id: String(row.Id),
|
|
code: row.PackageCode,
|
|
name: row.PackageName,
|
|
type: row.PackageType,
|
|
latestVersion: row.LatestVersion || '',
|
|
latestReleaseDate: formatDate(row.LatestReleaseDate),
|
|
status: normalizePackageStatus(row.IsActive),
|
|
owner: row.CreatedByUsername || '',
|
|
description: row.Description || '',
|
|
artifact: row.LatestFilePath || row.LatestDockerImage || '',
|
|
versionCount: Number(row.VersionCount || 0),
|
|
versions: []
|
|
};
|
|
}
|
|
|
|
function mapVersionRow(row) {
|
|
return {
|
|
id: String(row.Id),
|
|
packageId: String(row.PackageId),
|
|
version: row.Version,
|
|
releaseDate: formatDate(row.ReleaseDate),
|
|
uploadedBy: row.UploadedBy || '',
|
|
status: normalizeVersionStatus(row),
|
|
size: formatFileSize(row.FileSizeBytes),
|
|
changeLog: row.ChangeLog || '',
|
|
filePath: row.FilePath || '',
|
|
dockerImage: row.DockerImage || '',
|
|
checksum: row.FileChecksumSha256 || ''
|
|
};
|
|
}
|
|
|
|
function mapApplicationRow(row) {
|
|
return {
|
|
id: String(row.Id),
|
|
code: row.AppCode,
|
|
name: row.AppName,
|
|
version: row.AppVersion,
|
|
status: row.Status,
|
|
createdAt: formatDate(row.CreatedAt),
|
|
createdBy: row.CreatedByUsername || '',
|
|
notes: row.Notes || row.Description || '',
|
|
packageCount: Number(row.PackageCount || 0),
|
|
packages: []
|
|
};
|
|
}
|
|
|
|
function mapApplicationPackageRow(row) {
|
|
return {
|
|
id: String(row.Id),
|
|
applicationId: String(row.ApplicationId),
|
|
packageId: String(row.PackageId),
|
|
code: row.PackageCode,
|
|
name: row.PackageName,
|
|
type: row.PackageType,
|
|
selectedVersionId: row.SelectedVersionId ? String(row.SelectedVersionId) : '',
|
|
selectedVersion: row.SelectedVersion || '',
|
|
filePath: row.FilePath || '',
|
|
dockerImage: row.DockerImage || ''
|
|
};
|
|
}
|
|
|
|
function toAbsoluteUrl(baseUrl, filePath) {
|
|
if (!filePath) return '';
|
|
if (/^https?:\/\//i.test(filePath)) return filePath;
|
|
|
|
const normalizedBaseUrl = String(baseUrl || '').replace(/\/+$/, '');
|
|
const normalizedPath = String(filePath).startsWith('/') ? filePath : `/${filePath}`;
|
|
|
|
return `${normalizedBaseUrl}${normalizedPath}`;
|
|
}
|
|
|
|
async function getUserById(id) {
|
|
const pool = await getPool();
|
|
const result = await pool.request()
|
|
.input('Id', sql.NVarChar(100), id)
|
|
.query(`
|
|
SELECT TOP (1) Id, Username, Email, FullName, Role, IsActive
|
|
FROM dbo.Users
|
|
WHERE CONVERT(NVARCHAR(36), Id) = @Id;
|
|
`);
|
|
|
|
return mapCurrentUserRow(result.recordset[0]);
|
|
}
|
|
|
|
async function getUserOwnershipCounts(userId) {
|
|
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;
|
|
`);
|
|
const row = result.recordset[0];
|
|
|
|
return {
|
|
packageCount: Number(row.PackageCount || 0),
|
|
applicationCount: Number(row.ApplicationCount || 0)
|
|
};
|
|
}
|
|
|
|
async function listUsers() {
|
|
const pool = await getPool();
|
|
const result = await pool.request().query(`
|
|
SELECT
|
|
u.Id,
|
|
u.Username,
|
|
u.Email,
|
|
u.FullName,
|
|
u.Role,
|
|
u.IsActive,
|
|
u.CreatedAt,
|
|
u.UpdatedAt,
|
|
package_count.PackageCount,
|
|
application_count.ApplicationCount
|
|
FROM dbo.Users AS u
|
|
OUTER APPLY (
|
|
SELECT COUNT_BIG(*) AS PackageCount
|
|
FROM dbo.Packages AS p
|
|
WHERE p.CreatedByUserId = u.Id
|
|
) AS package_count
|
|
OUTER APPLY (
|
|
SELECT COUNT_BIG(*) AS ApplicationCount
|
|
FROM dbo.Applications AS a
|
|
WHERE a.CreatedByUserId = u.Id
|
|
) AS application_count
|
|
ORDER BY u.CreatedAt DESC, u.Username ASC;
|
|
`);
|
|
|
|
return result.recordset.map(mapUserRow);
|
|
}
|
|
|
|
async function countUsableUsers() {
|
|
const pool = await getPool();
|
|
const result = await pool.request()
|
|
.input('PasswordHashPrefix', sql.NVarChar(50), `${PASSWORD_HASH_PREFIX}$%`)
|
|
.query(`
|
|
SELECT COUNT_BIG(*) AS Total
|
|
FROM dbo.Users
|
|
WHERE PasswordHash LIKE @PasswordHashPrefix;
|
|
`);
|
|
|
|
return Number(result.recordset[0].Total || 0);
|
|
}
|
|
|
|
async function findUserAccountByIdentifier(identifier) {
|
|
const pool = await getPool();
|
|
const result = await pool.request()
|
|
.input('Identifier', sql.NVarChar(255), String(identifier || '').trim())
|
|
.query(`
|
|
SELECT TOP (1) Id, Username, Email, PasswordHash, FullName, Role, IsActive
|
|
FROM dbo.Users
|
|
WHERE Username = @Identifier OR Email = @Identifier;
|
|
`);
|
|
|
|
return result.recordset[0] || null;
|
|
}
|
|
|
|
async function getPendingUserByEmail(email) {
|
|
const pool = await getPool();
|
|
const result = await pool.request()
|
|
.input('Email', sql.NVarChar(255), normalizeEmail(email))
|
|
.input('PasswordHashPrefix', sql.NVarChar(50), `${PASSWORD_HASH_PREFIX}$%`)
|
|
.query(`
|
|
SELECT TOP (1) Id, Username, Email, FullName, Role, IsActive
|
|
FROM dbo.Users
|
|
WHERE Email = @Email
|
|
AND IsActive = 0
|
|
AND PasswordHash LIKE @PasswordHashPrefix;
|
|
`);
|
|
|
|
return mapCurrentUserRow(result.recordset[0]);
|
|
}
|
|
|
|
async function getRegistrationConflict({ username, email }) {
|
|
const pool = await getPool();
|
|
const result = await pool.request()
|
|
.input('Username', sql.NVarChar(100), String(username || '').trim())
|
|
.input('Email', sql.NVarChar(255), normalizeEmail(email))
|
|
.query(`
|
|
SELECT TOP (1) Id, Username, Email, FullName, Role, IsActive
|
|
FROM dbo.Users
|
|
WHERE Username = @Username OR Email = @Email
|
|
ORDER BY
|
|
CASE
|
|
WHEN Email = @Email THEN 0
|
|
WHEN Username = @Username THEN 1
|
|
ELSE 2
|
|
END,
|
|
CreatedAt ASC;
|
|
`);
|
|
const user = mapCurrentUserRow(result.recordset[0]);
|
|
|
|
if (!user) return null;
|
|
|
|
return {
|
|
user,
|
|
field: normalizeEmail(user.email) === normalizeEmail(email) ? 'email' : 'username'
|
|
};
|
|
}
|
|
|
|
async function authenticateUser(identifier, password) {
|
|
const row = await findUserAccountByIdentifier(identifier);
|
|
if (!row || !row.IsActive) return null;
|
|
if (!verifyPassword(String(password || ''), row.PasswordHash)) return null;
|
|
|
|
return mapCurrentUserRow(row);
|
|
}
|
|
|
|
async function createUser(input) {
|
|
const pool = await getPool();
|
|
const username = String(input.username || '').trim();
|
|
const email = normalizeEmail(input.email);
|
|
const fullName = String(input.fullName || '').trim() || null;
|
|
const passwordHash = hashPassword(String(input.password || ''));
|
|
const usableUserCount = await countUsableUsers();
|
|
const role = input.role ? normalizeRole(input.role) : (usableUserCount === 0 ? 'Admin' : 'User');
|
|
const isActive = input.isActive === undefined ? true : Boolean(input.isActive);
|
|
|
|
const existingResult = await pool.request()
|
|
.input('Username', sql.NVarChar(100), username)
|
|
.input('Email', sql.NVarChar(255), email)
|
|
.query(`
|
|
SELECT TOP (1) Id, PasswordHash
|
|
FROM dbo.Users
|
|
WHERE Username = @Username OR Email = @Email;
|
|
`);
|
|
const existing = existingResult.recordset[0];
|
|
|
|
if (existing) {
|
|
if (usableUserCount === 0 && existing.PasswordHash === PLACEHOLDER_PASSWORD_HASH) {
|
|
const updateResult = await pool.request()
|
|
.input('Id', sql.UniqueIdentifier, existing.Id)
|
|
.input('Username', sql.NVarChar(100), username)
|
|
.input('Email', sql.NVarChar(255), email)
|
|
.input('PasswordHash', sql.NVarChar(500), passwordHash)
|
|
.input('FullName', sql.NVarChar(200), fullName)
|
|
.input('Role', sql.NVarChar(50), role)
|
|
.input('IsActive', sql.Bit, isActive ? 1 : 0)
|
|
.query(`
|
|
UPDATE dbo.Users
|
|
SET Username = @Username,
|
|
Email = @Email,
|
|
PasswordHash = @PasswordHash,
|
|
FullName = @FullName,
|
|
Role = @Role,
|
|
IsActive = @IsActive,
|
|
UpdatedAt = SYSUTCDATETIME()
|
|
OUTPUT inserted.Id, inserted.Username, inserted.Email, inserted.FullName, inserted.Role, inserted.IsActive
|
|
WHERE Id = @Id;
|
|
`);
|
|
|
|
return mapCurrentUserRow(updateResult.recordset[0]);
|
|
}
|
|
|
|
throw duplicateUserError();
|
|
}
|
|
|
|
if (usableUserCount === 0) {
|
|
const placeholderResult = await pool.request()
|
|
.input('PasswordHash', sql.NVarChar(500), PLACEHOLDER_PASSWORD_HASH)
|
|
.query(`
|
|
SELECT TOP (1) Id
|
|
FROM dbo.Users
|
|
WHERE PasswordHash = @PasswordHash
|
|
ORDER BY CreatedAt ASC;
|
|
`);
|
|
const placeholder = placeholderResult.recordset[0];
|
|
|
|
if (placeholder) {
|
|
const updateResult = await pool.request()
|
|
.input('Id', sql.UniqueIdentifier, placeholder.Id)
|
|
.input('Username', sql.NVarChar(100), username)
|
|
.input('Email', sql.NVarChar(255), email)
|
|
.input('PasswordHash', sql.NVarChar(500), passwordHash)
|
|
.input('FullName', sql.NVarChar(200), fullName)
|
|
.input('Role', sql.NVarChar(50), role)
|
|
.input('IsActive', sql.Bit, isActive ? 1 : 0)
|
|
.query(`
|
|
UPDATE dbo.Users
|
|
SET Username = @Username,
|
|
Email = @Email,
|
|
PasswordHash = @PasswordHash,
|
|
FullName = @FullName,
|
|
Role = @Role,
|
|
IsActive = @IsActive,
|
|
UpdatedAt = SYSUTCDATETIME()
|
|
OUTPUT inserted.Id, inserted.Username, inserted.Email, inserted.FullName, inserted.Role, inserted.IsActive
|
|
WHERE Id = @Id;
|
|
`);
|
|
|
|
return mapCurrentUserRow(updateResult.recordset[0]);
|
|
}
|
|
}
|
|
|
|
try {
|
|
const result = await pool.request()
|
|
.input('Username', sql.NVarChar(100), username)
|
|
.input('Email', sql.NVarChar(255), email)
|
|
.input('PasswordHash', sql.NVarChar(500), passwordHash)
|
|
.input('FullName', sql.NVarChar(200), fullName)
|
|
.input('Role', sql.NVarChar(50), role)
|
|
.input('IsActive', sql.Bit, isActive ? 1 : 0)
|
|
.query(`
|
|
INSERT dbo.Users (Username, Email, PasswordHash, FullName, Role, IsActive)
|
|
OUTPUT inserted.Id, inserted.Username, inserted.Email, inserted.FullName, inserted.Role, inserted.IsActive
|
|
VALUES (@Username, @Email, @PasswordHash, @FullName, @Role, @IsActive);
|
|
`);
|
|
|
|
return mapCurrentUserRow(result.recordset[0]);
|
|
} catch (error) {
|
|
if (error.number === 2601 || error.number === 2627) {
|
|
throw duplicateUserError();
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function createEmailConfirmationToken(userId) {
|
|
await ensureEmailConfirmationSchema();
|
|
|
|
const pool = await getPool();
|
|
const token = crypto.randomBytes(32).toString('base64url');
|
|
const tokenHash = hashEmailToken(token);
|
|
const expiresAt = new Date(Date.now() + EMAIL_CONFIRMATION_EXPIRES_MS);
|
|
|
|
await pool.request()
|
|
.input('UserId', sql.UniqueIdentifier, userId)
|
|
.input('TokenHash', sql.Char(64), tokenHash)
|
|
.input('ExpiresAt', sql.DateTime2, expiresAt)
|
|
.query(`
|
|
DELETE FROM dbo.EmailConfirmationTokens
|
|
WHERE UserId = @UserId
|
|
AND ConfirmedAt IS NULL;
|
|
|
|
INSERT dbo.EmailConfirmationTokens (UserId, TokenHash, ExpiresAt)
|
|
VALUES (@UserId, @TokenHash, @ExpiresAt);
|
|
`);
|
|
|
|
return token;
|
|
}
|
|
|
|
async function confirmEmailToken(token) {
|
|
await ensureEmailConfirmationSchema();
|
|
|
|
const pool = await getPool();
|
|
const result = await pool.request()
|
|
.input('TokenHash', sql.Char(64), hashEmailToken(token))
|
|
.query(`
|
|
SET NOCOUNT ON;
|
|
SET XACT_ABORT ON;
|
|
|
|
BEGIN TRANSACTION;
|
|
|
|
DECLARE @TokenId UNIQUEIDENTIFIER;
|
|
DECLARE @UserId UNIQUEIDENTIFIER;
|
|
|
|
SELECT TOP (1)
|
|
@TokenId = Id,
|
|
@UserId = UserId
|
|
FROM dbo.EmailConfirmationTokens WITH (UPDLOCK, HOLDLOCK)
|
|
WHERE TokenHash = @TokenHash
|
|
AND ConfirmedAt IS NULL
|
|
AND ExpiresAt >= SYSUTCDATETIME();
|
|
|
|
IF @TokenId IS NULL
|
|
BEGIN
|
|
COMMIT TRANSACTION;
|
|
SELECT TOP (0) Id, Username, Email, FullName, Role, IsActive
|
|
FROM dbo.Users;
|
|
RETURN;
|
|
END;
|
|
|
|
UPDATE dbo.EmailConfirmationTokens
|
|
SET ConfirmedAt = SYSUTCDATETIME()
|
|
WHERE Id = @TokenId;
|
|
|
|
UPDATE dbo.Users
|
|
SET IsActive = 1,
|
|
UpdatedAt = SYSUTCDATETIME()
|
|
OUTPUT inserted.Id, inserted.Username, inserted.Email, inserted.FullName, inserted.Role, inserted.IsActive
|
|
WHERE Id = @UserId;
|
|
|
|
COMMIT TRANSACTION;
|
|
`);
|
|
|
|
return mapCurrentUserRow(result.recordset[0]);
|
|
}
|
|
|
|
async function updateUserAccess(input) {
|
|
const pool = await getPool();
|
|
await pool.request()
|
|
.input('Id', sql.UniqueIdentifier, input.userId)
|
|
.input('Role', sql.NVarChar(50), normalizeRole(input.role))
|
|
.input('IsActive', sql.Bit, input.isActive ? 1 : 0)
|
|
.query(`
|
|
UPDATE dbo.Users
|
|
SET Role = @Role,
|
|
IsActive = @IsActive,
|
|
UpdatedAt = SYSUTCDATETIME()
|
|
WHERE Id = @Id;
|
|
`);
|
|
}
|
|
|
|
async function updateUser(input) {
|
|
const pool = await getPool();
|
|
const username = String(input.username || '').trim();
|
|
const email = normalizeEmail(input.email);
|
|
const fullName = String(input.fullName || '').trim() || null;
|
|
const role = normalizeRole(input.role);
|
|
const isActive = input.isActive ? 1 : 0;
|
|
const passwordHash = input.password ? hashPassword(String(input.password)) : null;
|
|
|
|
const existingResult = await pool.request()
|
|
.input('Id', sql.UniqueIdentifier, input.userId)
|
|
.input('Username', sql.NVarChar(100), username)
|
|
.input('Email', sql.NVarChar(255), email)
|
|
.query(`
|
|
SELECT TOP (1) Id
|
|
FROM dbo.Users
|
|
WHERE (Username = @Username OR Email = @Email)
|
|
AND Id <> @Id;
|
|
`);
|
|
|
|
if (existingResult.recordset.length > 0) {
|
|
throw duplicateUserError();
|
|
}
|
|
|
|
const result = await pool.request()
|
|
.input('Id', sql.UniqueIdentifier, input.userId)
|
|
.input('Username', sql.NVarChar(100), username)
|
|
.input('Email', sql.NVarChar(255), email)
|
|
.input('FullName', sql.NVarChar(200), fullName)
|
|
.input('Role', sql.NVarChar(50), role)
|
|
.input('IsActive', sql.Bit, isActive)
|
|
.input('PasswordHash', sql.NVarChar(500), passwordHash)
|
|
.query(`
|
|
UPDATE dbo.Users
|
|
SET Username = @Username,
|
|
Email = @Email,
|
|
FullName = @FullName,
|
|
Role = @Role,
|
|
IsActive = @IsActive,
|
|
PasswordHash = COALESCE(@PasswordHash, PasswordHash),
|
|
UpdatedAt = SYSUTCDATETIME()
|
|
OUTPUT inserted.Id, inserted.Username, inserted.Email, inserted.FullName, inserted.Role, inserted.IsActive
|
|
WHERE Id = @Id;
|
|
`);
|
|
|
|
return mapCurrentUserRow(result.recordset[0]);
|
|
}
|
|
|
|
async function deleteUser(userId) {
|
|
const counts = await getUserOwnershipCounts(userId);
|
|
|
|
if (counts.packageCount > 0 || counts.applicationCount > 0) {
|
|
throw userHasOwnedDataError();
|
|
}
|
|
|
|
const pool = await getPool();
|
|
const result = await pool.request()
|
|
.input('Id', sql.UniqueIdentifier, userId)
|
|
.query(`
|
|
DELETE FROM dbo.Users
|
|
OUTPUT deleted.Id
|
|
WHERE Id = @Id;
|
|
`);
|
|
|
|
return result.recordset.length > 0;
|
|
}
|
|
|
|
async function listPackages() {
|
|
const pool = await getPool();
|
|
const result = await pool.request().query(`
|
|
SELECT *
|
|
FROM dbo.vw_PackageList
|
|
ORDER BY CreatedAt DESC, PackageName ASC;
|
|
`);
|
|
|
|
return result.recordset.map(mapPackageRow);
|
|
}
|
|
|
|
async function listPackageVersions() {
|
|
const pool = await getPool();
|
|
const result = await pool.request().query(`
|
|
SELECT *
|
|
FROM dbo.vw_PackageVersionList
|
|
ORDER BY PackageId, IsLatest DESC, ReleaseDate DESC, UploadedAt DESC;
|
|
`);
|
|
|
|
return result.recordset.map(mapVersionRow);
|
|
}
|
|
|
|
async function attachPackageVersions(packages) {
|
|
if (packages.length === 0) return packages;
|
|
|
|
const versions = await listPackageVersions();
|
|
const versionsByPackageId = new Map();
|
|
|
|
versions.forEach((version) => {
|
|
if (!versionsByPackageId.has(version.packageId)) {
|
|
versionsByPackageId.set(version.packageId, []);
|
|
}
|
|
|
|
versionsByPackageId.get(version.packageId).push(version);
|
|
});
|
|
|
|
return packages.map((packageItem) => ({
|
|
...packageItem,
|
|
versions: versionsByPackageId.get(packageItem.id) || []
|
|
}));
|
|
}
|
|
|
|
async function getPackageById(id) {
|
|
const pool = await getPool();
|
|
const packageResult = await pool.request()
|
|
.input('Id', sql.NVarChar(100), id)
|
|
.query(`
|
|
SELECT TOP (1) *
|
|
FROM dbo.vw_PackageList
|
|
WHERE CONVERT(NVARCHAR(36), Id) = @Id OR PackageCode = @Id;
|
|
`);
|
|
|
|
if (packageResult.recordset.length === 0) return null;
|
|
|
|
const packageItem = mapPackageRow(packageResult.recordset[0]);
|
|
const versions = await pool.request()
|
|
.input('PackageId', sql.UniqueIdentifier, packageItem.id)
|
|
.query(`
|
|
SELECT *
|
|
FROM dbo.vw_PackageVersionList
|
|
WHERE PackageId = @PackageId
|
|
ORDER BY IsLatest DESC, ReleaseDate DESC, UploadedAt DESC;
|
|
`);
|
|
|
|
packageItem.versions = versions.recordset.map(mapVersionRow);
|
|
return packageItem;
|
|
}
|
|
|
|
async function listApplications() {
|
|
const pool = await getPool();
|
|
const result = await pool.request().query(`
|
|
SELECT *
|
|
FROM dbo.vw_ApplicationList
|
|
ORDER BY CreatedAt DESC, AppName ASC;
|
|
`);
|
|
|
|
const applications = result.recordset.map(mapApplicationRow);
|
|
return attachApplicationPackages(applications);
|
|
}
|
|
|
|
async function listApplicationPackages(applicationId) {
|
|
const pool = await getPool();
|
|
const request = pool.request();
|
|
let whereClause = '';
|
|
|
|
if (applicationId) {
|
|
request.input('ApplicationId', sql.UniqueIdentifier, applicationId);
|
|
whereClause = 'WHERE ApplicationId = @ApplicationId';
|
|
}
|
|
|
|
const result = await request.query(`
|
|
SELECT *
|
|
FROM dbo.vw_ApplicationPackageDetails
|
|
${whereClause}
|
|
ORDER BY AppName ASC, PackageName ASC;
|
|
`);
|
|
|
|
return result.recordset.map(mapApplicationPackageRow);
|
|
}
|
|
|
|
async function attachApplicationPackages(applications) {
|
|
if (applications.length === 0) return applications;
|
|
|
|
const applicationPackages = await listApplicationPackages();
|
|
const packagesByApplicationId = new Map();
|
|
|
|
applicationPackages.forEach((packageItem) => {
|
|
if (!packagesByApplicationId.has(packageItem.applicationId)) {
|
|
packagesByApplicationId.set(packageItem.applicationId, []);
|
|
}
|
|
|
|
packagesByApplicationId.get(packageItem.applicationId).push(packageItem);
|
|
});
|
|
|
|
return applications.map((application) => {
|
|
const packages = packagesByApplicationId.get(application.id) || [];
|
|
|
|
return {
|
|
...application,
|
|
packageCount: packages.length,
|
|
packages
|
|
};
|
|
});
|
|
}
|
|
|
|
async function getApplicationById(id) {
|
|
const pool = await getPool();
|
|
const appResult = await pool.request()
|
|
.input('Id', sql.NVarChar(100), id)
|
|
.query(`
|
|
SELECT TOP (1) *
|
|
FROM dbo.vw_ApplicationList
|
|
WHERE CONVERT(NVARCHAR(36), Id) = @Id OR AppCode = @Id;
|
|
`);
|
|
|
|
if (appResult.recordset.length === 0) return null;
|
|
|
|
const application = mapApplicationRow(appResult.recordset[0]);
|
|
application.packages = await listApplicationPackages(application.id);
|
|
application.packageCount = application.packages.length;
|
|
return application;
|
|
}
|
|
|
|
async function getApplicationManifest(appCode, version, baseUrl) {
|
|
const pool = await getPool();
|
|
const appResult = await pool.request()
|
|
.input('AppCode', sql.NVarChar(100), String(appCode || '').trim())
|
|
.input('AppVersion', sql.NVarChar(50), String(version || '').trim())
|
|
.query(`
|
|
SELECT TOP (1) Id, AppCode, AppName, AppVersion
|
|
FROM dbo.Applications
|
|
WHERE AppCode = @AppCode
|
|
AND AppVersion = @AppVersion
|
|
AND Status = N'Released';
|
|
`);
|
|
|
|
const appRow = appResult.recordset[0];
|
|
if (!appRow) return null;
|
|
|
|
const componentResult = await pool.request()
|
|
.input('ApplicationId', sql.UniqueIdentifier, appRow.Id)
|
|
.query(`
|
|
SELECT
|
|
ap.Id,
|
|
p.PackageCode,
|
|
p.PackageName,
|
|
p.PackageType,
|
|
COALESCE(selected_version.Version, latest_version.Version) AS Version,
|
|
COALESCE(selected_version.FilePath, latest_version.FilePath) AS FilePath,
|
|
COALESCE(selected_version.DockerImage, latest_version.DockerImage) AS DockerImage,
|
|
COALESCE(selected_version.FileChecksumSha256, latest_version.FileChecksumSha256) AS FileChecksumSha256,
|
|
ROW_NUMBER() OVER (ORDER BY ap.AddedAt ASC, p.PackageCode ASC) * 10 AS InstallOrder
|
|
FROM dbo.ApplicationPackages AS ap
|
|
INNER JOIN dbo.Packages AS p
|
|
ON p.Id = ap.PackageId
|
|
LEFT JOIN dbo.PackageVersions AS selected_version
|
|
ON selected_version.Id = ap.SelectedVersionId
|
|
OUTER APPLY (
|
|
SELECT TOP (1) latest.*
|
|
FROM dbo.PackageVersions AS latest
|
|
WHERE latest.PackageId = p.Id
|
|
AND latest.IsLatest = 1
|
|
ORDER BY latest.ReleaseDate DESC, latest.UploadedAt DESC
|
|
) AS latest_version
|
|
WHERE ap.ApplicationId = @ApplicationId
|
|
ORDER BY ap.AddedAt ASC, p.PackageCode ASC;
|
|
`);
|
|
|
|
const components = componentResult.recordset.map((row) => {
|
|
const installOrder = Number(row.InstallOrder || 10);
|
|
|
|
if (row.PackageType === 'docker') {
|
|
return {
|
|
componentId: row.PackageCode,
|
|
type: 'docker',
|
|
installOrder,
|
|
required: true,
|
|
image: row.DockerImage || '',
|
|
tag: row.Version || 'latest',
|
|
containerName: row.PackageCode
|
|
};
|
|
}
|
|
|
|
return {
|
|
componentId: row.PackageCode,
|
|
type: 'deb',
|
|
installOrder,
|
|
required: true,
|
|
packageName: row.PackageCode,
|
|
version: row.Version || '',
|
|
downloadUrl: toAbsoluteUrl(baseUrl, row.FilePath),
|
|
sha256: row.FileChecksumSha256 || ''
|
|
};
|
|
});
|
|
|
|
return {
|
|
schemaVersion: '1.0',
|
|
appId: appRow.AppCode,
|
|
appName: appRow.AppName,
|
|
version: appRow.AppVersion,
|
|
architecture: 'amd64',
|
|
components
|
|
};
|
|
}
|
|
|
|
async function getStats() {
|
|
const pool = await getPool();
|
|
const result = await pool.request().query(`
|
|
SELECT
|
|
(SELECT COUNT_BIG(*) FROM dbo.Packages) AS TotalPackages,
|
|
(SELECT COUNT_BIG(*) FROM dbo.Packages WHERE IsActive = 1) AS ActivePackages,
|
|
(SELECT COUNT_BIG(*) FROM dbo.PackageVersions) AS TotalVersions,
|
|
(SELECT COUNT_BIG(*) FROM dbo.Applications) AS TotalApplications,
|
|
(SELECT COUNT_BIG(*) FROM dbo.Applications WHERE Status = N'Released') AS ReleasedApplications;
|
|
`);
|
|
|
|
const row = result.recordset[0];
|
|
|
|
return {
|
|
totalPackages: Number(row.TotalPackages || 0),
|
|
activePackages: Number(row.ActivePackages || 0),
|
|
totalVersions: Number(row.TotalVersions || 0),
|
|
totalApplications: Number(row.TotalApplications || 0),
|
|
releasedApplications: Number(row.ReleasedApplications || 0)
|
|
};
|
|
}
|
|
|
|
async function getActivity() {
|
|
const pool = await getPool();
|
|
const result = await pool.request().query(`
|
|
SELECT TOP (6)
|
|
p.PackageName,
|
|
p.PackageCode,
|
|
pv.Version,
|
|
pv.UploadedAt,
|
|
pv.IsLatest
|
|
FROM dbo.PackageVersions AS pv
|
|
INNER JOIN dbo.Packages AS p
|
|
ON p.Id = pv.PackageId
|
|
ORDER BY pv.UploadedAt DESC;
|
|
`);
|
|
|
|
return result.recordset.map((row) => ({
|
|
title: `${row.IsLatest ? 'Latest' : 'Upload'} ${row.Version}`,
|
|
detail: `${row.PackageName} (${row.PackageCode})`,
|
|
time: formatDate(row.UploadedAt),
|
|
icon: row.IsLatest ? 'stars' : 'upload_file'
|
|
}));
|
|
}
|
|
|
|
async function createPackageWithVersion(input) {
|
|
const pool = await getPool();
|
|
const transaction = new sql.Transaction(pool);
|
|
|
|
await transaction.begin();
|
|
|
|
try {
|
|
const packageResult = await new sql.Request(transaction)
|
|
.input('PackageCode', sql.NVarChar(100), input.packageCode)
|
|
.input('PackageName', sql.NVarChar(200), input.packageName)
|
|
.input('PackageType', sql.NVarChar(20), input.packageType)
|
|
.input('Description', sql.NVarChar(sql.MAX), input.description || null)
|
|
.input('CreatedByUserId', sql.UniqueIdentifier, input.createdByUserId)
|
|
.query(`
|
|
INSERT dbo.Packages (PackageCode, PackageName, PackageType, Description, CreatedByUserId)
|
|
OUTPUT inserted.Id
|
|
VALUES (@PackageCode, @PackageName, @PackageType, @Description, @CreatedByUserId);
|
|
`);
|
|
|
|
const packageId = String(packageResult.recordset[0].Id);
|
|
const versionResult = await new sql.Request(transaction)
|
|
.input('PackageId', sql.UniqueIdentifier, packageId)
|
|
.input('Version', sql.NVarChar(50), input.version)
|
|
.input('FilePath', sql.NVarChar(1000), input.filePath || null)
|
|
.input('DockerImage', sql.NVarChar(500), input.dockerImage || null)
|
|
.input('FileChecksumSha256', sql.Char(64), input.checksum || null)
|
|
.input('FileSizeBytes', sql.BigInt, input.fileSizeBytes || null)
|
|
.input('ChangeLog', sql.NVarChar(sql.MAX), input.changeLog || null)
|
|
.input('ReleaseDate', sql.DateTime2, input.releaseDate ? new Date(input.releaseDate) : new Date())
|
|
.query(`
|
|
INSERT dbo.PackageVersions (
|
|
PackageId, Version, FilePath, DockerImage, FileChecksumSha256,
|
|
FileSizeBytes, ChangeLog, ReleaseDate, IsLatest
|
|
)
|
|
OUTPUT inserted.Id
|
|
VALUES (
|
|
@PackageId, @Version, @FilePath, @DockerImage, @FileChecksumSha256,
|
|
@FileSizeBytes, @ChangeLog, @ReleaseDate, 1
|
|
);
|
|
`);
|
|
|
|
await transaction.commit();
|
|
|
|
return {
|
|
packageId,
|
|
versionId: String(versionResult.recordset[0].Id)
|
|
};
|
|
} catch (error) {
|
|
await transaction.rollback();
|
|
if (error.number === 2601 || error.number === 2627) {
|
|
throw duplicatePackageError();
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function addPackageVersion(input) {
|
|
const pool = await getPool();
|
|
let insertResult;
|
|
|
|
try {
|
|
insertResult = await pool.request()
|
|
.input('PackageId', sql.UniqueIdentifier, input.packageId)
|
|
.input('Version', sql.NVarChar(50), input.version)
|
|
.input('FilePath', sql.NVarChar(1000), input.filePath || null)
|
|
.input('DockerImage', sql.NVarChar(500), input.dockerImage || null)
|
|
.input('FileChecksumSha256', sql.Char(64), input.checksum || null)
|
|
.input('FileSizeBytes', sql.BigInt, input.fileSizeBytes || null)
|
|
.input('ChangeLog', sql.NVarChar(sql.MAX), input.changeLog || null)
|
|
.input('ReleaseDate', sql.DateTime2, input.releaseDate ? new Date(input.releaseDate) : new Date())
|
|
.query(`
|
|
INSERT dbo.PackageVersions (
|
|
PackageId, Version, FilePath, DockerImage, FileChecksumSha256,
|
|
FileSizeBytes, ChangeLog, ReleaseDate
|
|
)
|
|
OUTPUT inserted.Id
|
|
VALUES (
|
|
@PackageId, @Version, @FilePath, @DockerImage, @FileChecksumSha256,
|
|
@FileSizeBytes, @ChangeLog, @ReleaseDate
|
|
);
|
|
`);
|
|
} catch (error) {
|
|
if (error.number === 2601 || error.number === 2627) {
|
|
throw duplicatePackageError();
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
|
|
const versionId = String(insertResult.recordset[0].Id);
|
|
await pool.request()
|
|
.input('PackageVersionId', sql.UniqueIdentifier, versionId)
|
|
.execute('dbo.SetLatestPackageVersion');
|
|
|
|
return versionId;
|
|
}
|
|
|
|
async function deletePackage(packageId) {
|
|
const pool = await getPool();
|
|
const transaction = new sql.Transaction(pool);
|
|
|
|
await transaction.begin();
|
|
|
|
try {
|
|
await new sql.Request(transaction)
|
|
.input('Id', sql.UniqueIdentifier, packageId)
|
|
.query(`
|
|
DELETE FROM dbo.ApplicationPackages
|
|
WHERE PackageId = @Id;
|
|
`);
|
|
|
|
const result = await new sql.Request(transaction)
|
|
.input('Id', sql.UniqueIdentifier, packageId)
|
|
.query(`
|
|
DELETE FROM dbo.Packages
|
|
OUTPUT deleted.Id
|
|
WHERE Id = @Id;
|
|
`);
|
|
|
|
await transaction.commit();
|
|
return result.recordset.length > 0;
|
|
} catch (error) {
|
|
await transaction.rollback();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function setLatestPackageVersion(packageVersionId) {
|
|
const pool = await getPool();
|
|
await pool.request()
|
|
.input('PackageVersionId', sql.UniqueIdentifier, packageVersionId)
|
|
.execute('dbo.SetLatestPackageVersion');
|
|
}
|
|
|
|
async function deletePackageVersion(packageVersionId) {
|
|
const pool = await getPool();
|
|
const versionResult = await pool.request()
|
|
.input('Id', sql.UniqueIdentifier, packageVersionId)
|
|
.query(`
|
|
SELECT TOP (1) PackageId
|
|
FROM dbo.PackageVersions
|
|
WHERE Id = @Id;
|
|
`);
|
|
|
|
const version = versionResult.recordset[0];
|
|
if (!version) {
|
|
return {
|
|
deleted: false,
|
|
packageId: null
|
|
};
|
|
}
|
|
|
|
await pool.request()
|
|
.input('PackageVersionId', sql.UniqueIdentifier, packageVersionId)
|
|
.execute('dbo.DeletePackageVersion');
|
|
|
|
return {
|
|
deleted: true,
|
|
packageId: String(version.PackageId)
|
|
};
|
|
}
|
|
|
|
async function createApplication(input) {
|
|
const pool = await getPool();
|
|
const transaction = new sql.Transaction(pool);
|
|
|
|
await transaction.begin();
|
|
|
|
try {
|
|
const appResult = await new sql.Request(transaction)
|
|
.input('AppCode', sql.NVarChar(100), input.appCode)
|
|
.input('AppName', sql.NVarChar(200), input.appName)
|
|
.input('AppVersion', sql.NVarChar(50), input.appVersion)
|
|
.input('Notes', sql.NVarChar(500), input.notes || null)
|
|
.input('CreatedByUserId', sql.UniqueIdentifier, input.createdByUserId)
|
|
.input('Status', sql.NVarChar(50), normalizeApplicationStatus(input.status))
|
|
.query(`
|
|
INSERT dbo.Applications (AppCode, AppName, AppVersion, Notes, CreatedByUserId, Status)
|
|
OUTPUT inserted.Id
|
|
VALUES (@AppCode, @AppName, @AppVersion, @Notes, @CreatedByUserId, @Status);
|
|
`);
|
|
|
|
const applicationId = String(appResult.recordset[0].Id);
|
|
|
|
for (const packageItem of input.packages) {
|
|
await new sql.Request(transaction)
|
|
.input('ApplicationId', sql.UniqueIdentifier, applicationId)
|
|
.input('PackageId', sql.UniqueIdentifier, packageItem.packageId)
|
|
.input('SelectedVersionId', sql.UniqueIdentifier, packageItem.selectedVersionId || null)
|
|
.query(`
|
|
INSERT dbo.ApplicationPackages (ApplicationId, PackageId, SelectedVersionId)
|
|
VALUES (@ApplicationId, @PackageId, @SelectedVersionId);
|
|
`);
|
|
}
|
|
|
|
await transaction.commit();
|
|
return applicationId;
|
|
} catch (error) {
|
|
await transaction.rollback();
|
|
if (error.number === 2601 || error.number === 2627) {
|
|
throw duplicateApplicationError();
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function updateApplication(input) {
|
|
const pool = await getPool();
|
|
const transaction = new sql.Transaction(pool);
|
|
|
|
await transaction.begin();
|
|
|
|
try {
|
|
const conflictResult = await new sql.Request(transaction)
|
|
.input('ApplicationId', sql.UniqueIdentifier, input.applicationId)
|
|
.input('AppCode', sql.NVarChar(100), input.appCode)
|
|
.query(`
|
|
SELECT TOP (1) Id
|
|
FROM dbo.Applications
|
|
WHERE AppCode = @AppCode
|
|
AND Id <> @ApplicationId;
|
|
`);
|
|
|
|
if (conflictResult.recordset.length > 0) {
|
|
throw duplicateApplicationError();
|
|
}
|
|
|
|
const appResult = await new sql.Request(transaction)
|
|
.input('ApplicationId', sql.UniqueIdentifier, input.applicationId)
|
|
.input('AppCode', sql.NVarChar(100), input.appCode)
|
|
.input('AppName', sql.NVarChar(200), input.appName)
|
|
.input('AppVersion', sql.NVarChar(50), input.appVersion)
|
|
.input('Notes', sql.NVarChar(500), input.notes || null)
|
|
.input('Status', sql.NVarChar(50), normalizeApplicationStatus(input.status))
|
|
.query(`
|
|
UPDATE dbo.Applications
|
|
SET AppCode = @AppCode,
|
|
AppName = @AppName,
|
|
AppVersion = @AppVersion,
|
|
Notes = @Notes,
|
|
Status = @Status,
|
|
UpdatedAt = SYSUTCDATETIME()
|
|
OUTPUT inserted.Id
|
|
WHERE Id = @ApplicationId;
|
|
`);
|
|
|
|
if (appResult.recordset.length === 0) {
|
|
await transaction.commit();
|
|
return null;
|
|
}
|
|
|
|
await new sql.Request(transaction)
|
|
.input('ApplicationId', sql.UniqueIdentifier, input.applicationId)
|
|
.query(`
|
|
DELETE FROM dbo.ApplicationPackages
|
|
WHERE ApplicationId = @ApplicationId;
|
|
`);
|
|
|
|
for (const packageItem of input.packages) {
|
|
await new sql.Request(transaction)
|
|
.input('ApplicationId', sql.UniqueIdentifier, input.applicationId)
|
|
.input('PackageId', sql.UniqueIdentifier, packageItem.packageId)
|
|
.input('SelectedVersionId', sql.UniqueIdentifier, packageItem.selectedVersionId || null)
|
|
.query(`
|
|
INSERT dbo.ApplicationPackages (ApplicationId, PackageId, SelectedVersionId)
|
|
VALUES (@ApplicationId, @PackageId, @SelectedVersionId);
|
|
`);
|
|
}
|
|
|
|
await transaction.commit();
|
|
} catch (error) {
|
|
await transaction.rollback();
|
|
if (error.code === 'DUPLICATE_APPLICATION') {
|
|
throw error;
|
|
}
|
|
|
|
if (error.number === 2601 || error.number === 2627) {
|
|
throw duplicateApplicationError();
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
|
|
return getApplicationById(input.applicationId);
|
|
}
|
|
|
|
async function updateApplicationStatus(applicationId, status) {
|
|
const pool = await getPool();
|
|
const result = await pool.request()
|
|
.input('ApplicationId', sql.UniqueIdentifier, applicationId)
|
|
.input('Status', sql.NVarChar(50), normalizeApplicationStatus(status))
|
|
.query(`
|
|
UPDATE dbo.Applications
|
|
SET Status = @Status,
|
|
UpdatedAt = SYSUTCDATETIME()
|
|
OUTPUT inserted.Id
|
|
WHERE Id = @ApplicationId;
|
|
`);
|
|
|
|
return result.recordset.length > 0;
|
|
}
|
|
|
|
async function deleteApplication(applicationId) {
|
|
const pool = await getPool();
|
|
const result = await pool.request()
|
|
.input('ApplicationId', sql.UniqueIdentifier, applicationId)
|
|
.query(`
|
|
DELETE FROM dbo.Applications
|
|
OUTPUT deleted.Id
|
|
WHERE Id = @ApplicationId;
|
|
`);
|
|
|
|
return result.recordset.length > 0;
|
|
}
|
|
|
|
async function removeApplicationPackage(applicationId, packageId) {
|
|
const pool = await getPool();
|
|
const result = await pool.request()
|
|
.input('ApplicationId', sql.UniqueIdentifier, applicationId)
|
|
.input('PackageId', sql.UniqueIdentifier, packageId)
|
|
.query(`
|
|
DELETE FROM dbo.ApplicationPackages
|
|
OUTPUT deleted.Id
|
|
WHERE ApplicationId = @ApplicationId
|
|
AND PackageId = @PackageId;
|
|
`);
|
|
|
|
return result.recordset.length > 0;
|
|
}
|
|
|
|
async function getPageData(currentUser) {
|
|
const [stats, packageRows, applications, activity] = await Promise.all([
|
|
getStats(),
|
|
listPackages(),
|
|
listApplications(),
|
|
getActivity()
|
|
]);
|
|
const packages = await attachPackageVersions(packageRows);
|
|
|
|
return {
|
|
currentUser,
|
|
stats,
|
|
packages,
|
|
applications,
|
|
activity
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
authenticateUser,
|
|
confirmEmailToken,
|
|
createEmailConfirmationToken,
|
|
createUser,
|
|
deleteUser,
|
|
getUserById,
|
|
getPendingUserByEmail,
|
|
getRegistrationConflict,
|
|
listUsers,
|
|
updateUser,
|
|
updateUserAccess,
|
|
getPageData,
|
|
listPackages,
|
|
listApplications,
|
|
getApplicationManifest,
|
|
getPackageById,
|
|
getApplicationById,
|
|
createPackageWithVersion,
|
|
addPackageVersion,
|
|
deletePackage,
|
|
setLatestPackageVersion,
|
|
deletePackageVersion,
|
|
createApplication,
|
|
updateApplication,
|
|
updateApplicationStatus,
|
|
deleteApplication,
|
|
removeApplicationPackage
|
|
};
|