mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -122,7 +122,7 @@ async function applyAdvertisementFields(advertisement, body) {
|
|||||||
} else {
|
} else {
|
||||||
const asset = await mdl_Assets.findOne({ where: { asset_id: body.image_asset_id, deletedAt: null } });
|
const asset = await mdl_Assets.findOne({ where: { asset_id: body.image_asset_id, deletedAt: null } });
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
const err = new Error("Selected image asset was not found.");
|
const err = new Error("Selected image file was not found.");
|
||||||
err.status = 400;
|
err.status = 400;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,10 +245,10 @@ exports.getAssets = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await attachStreamTokens(result.data, req);
|
const data = await attachStreamTokens(result.data, req);
|
||||||
return R.success(res, "Assets retrieved.", { ...result, data });
|
return R.success(res, "Files retrieved.", { ...result, data });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[ASSET][GET ALL]", err);
|
console.error("[ASSET][GET ALL]", err);
|
||||||
return R.error(res, "Could not retrieve assets.", 500);
|
return R.error(res, "Could not retrieve files.", 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -257,7 +257,7 @@ exports.getAssets = async (req, res) => {
|
|||||||
exports.getAsset = async (req, res) => {
|
exports.getAsset = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { assetId } = req.params;
|
const { assetId } = req.params;
|
||||||
if (!assetId || assetId === "undefined") return R.error(res, "Invalid asset ID.", 400);
|
if (!assetId || assetId === "undefined") return R.error(res, "Invalid file ID.", 400);
|
||||||
|
|
||||||
const asset = await Asset.findOne({
|
const asset = await Asset.findOne({
|
||||||
where: { asset_id: assetId, ...notDeleted },
|
where: { asset_id: assetId, ...notDeleted },
|
||||||
@@ -270,7 +270,7 @@ exports.getAsset = async (req, res) => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!asset) return R.error(res, "Asset not found.", 404);
|
if (!asset) return R.error(res, "File not found.", 404);
|
||||||
|
|
||||||
const json = asset.toJSON();
|
const json = asset.toJSON();
|
||||||
|
|
||||||
@@ -298,7 +298,7 @@ exports.getAsset = async (req, res) => {
|
|||||||
delete json.storage_key;
|
delete json.storage_key;
|
||||||
|
|
||||||
redactS3Url(json);
|
redactS3Url(json);
|
||||||
return R.success(res, "Asset retrieved.", { data: json });
|
return R.success(res, "File retrieved.", { data: json });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[ASSET][GET ONE]", err);
|
console.error("[ASSET][GET ONE]", err);
|
||||||
return R.error(res, "Internal server error.", 500);
|
return R.error(res, "Internal server error.", 500);
|
||||||
@@ -578,7 +578,7 @@ exports.uploadAsset = async (req, res) => {
|
|||||||
const { storage_key, thumbnail_storage_key, original_name } = req.body;
|
const { storage_key, thumbnail_storage_key, original_name } = req.body;
|
||||||
const asset = await finalizeAssetFromStorage({ storage_key, thumbnail_storage_key, original_name, body: req.body, user: req.user });
|
const asset = await finalizeAssetFromStorage({ storage_key, thumbnail_storage_key, original_name, body: req.body, user: req.user });
|
||||||
invalidateListCache();
|
invalidateListCache();
|
||||||
return R.success(res, "Asset uploaded.", { data: asset }, 201);
|
return R.success(res, "File uploaded.", { data: asset }, 201);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[ASSET][UPLOAD]", err);
|
console.error("[ASSET][UPLOAD]", err);
|
||||||
@@ -594,10 +594,10 @@ exports.updateAsset = async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { assetId } = req.params;
|
const { assetId } = req.params;
|
||||||
if (!assetId || assetId === "undefined") return R.error(res, "Invalid asset ID.", 400);
|
if (!assetId || assetId === "undefined") return R.error(res, "Invalid file ID.", 400);
|
||||||
|
|
||||||
const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } });
|
const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } });
|
||||||
if (!asset) return R.error(res, "Asset not found.", 404);
|
if (!asset) return R.error(res, "File not found.", 404);
|
||||||
|
|
||||||
// Browser already PUT the replacement file straight to storage via a
|
// Browser already PUT the replacement file straight to storage via a
|
||||||
// presigned URL (see presignAssetUpload) — this is plain JSON, no
|
// presigned URL (see presignAssetUpload) — this is plain JSON, no
|
||||||
@@ -607,7 +607,7 @@ exports.updateAsset = async (req, res) => {
|
|||||||
const isDocument = asset.file_type === "document";
|
const isDocument = asset.file_type === "document";
|
||||||
|
|
||||||
if (isDocument && storage_key) return R.error(res, "Document files cannot be replaced.", 400);
|
if (isDocument && storage_key) return R.error(res, "Document files cannot be replaced.", 400);
|
||||||
if (isVideo && storage_key && !req.body.is_thumbnail) return R.error(res, "Video files cannot be replaced. Upload a new asset instead.", 400);
|
if (isVideo && storage_key && !req.body.is_thumbnail) return R.error(res, "Video files cannot be replaced. Upload a new file instead.", 400);
|
||||||
|
|
||||||
const storageProvider = asset.storage_provider;
|
const storageProvider = asset.storage_provider;
|
||||||
const usesProvider = ["chibisafe", "s3"].includes(storageProvider);
|
const usesProvider = ["chibisafe", "s3"].includes(storageProvider);
|
||||||
@@ -641,7 +641,7 @@ exports.updateAsset = async (req, res) => {
|
|||||||
|
|
||||||
invalidateListCache();
|
invalidateListCache();
|
||||||
logActivity(req.user?.user_id, 'update_asset', { entityType: 'asset', entityId: Number(assetId) });
|
logActivity(req.user?.user_id, 'update_asset', { entityType: 'asset', entityId: Number(assetId) });
|
||||||
return R.success(res, "Asset updated.", { data: asset });
|
return R.success(res, "File updated.", { data: asset });
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (newUpload) await rollbackUploads([newUpload]);
|
if (newUpload) await rollbackUploads([newUpload]);
|
||||||
@@ -656,16 +656,16 @@ exports.updateAsset = async (req, res) => {
|
|||||||
exports.archiveAsset = async (req, res) => {
|
exports.archiveAsset = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { assetId } = req.params;
|
const { assetId } = req.params;
|
||||||
if (!assetId || assetId === "undefined") return R.error(res, "Invalid asset ID.", 400);
|
if (!assetId || assetId === "undefined") return R.error(res, "Invalid file ID.", 400);
|
||||||
|
|
||||||
const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } });
|
const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } });
|
||||||
if (!asset) return R.error(res, "Asset not found.", 404);
|
if (!asset) return R.error(res, "File not found.", 404);
|
||||||
|
|
||||||
await asset.update({ deletedBy: req.body.deletedBy ?? null });
|
await asset.update({ deletedBy: req.body.deletedBy ?? null });
|
||||||
await asset.destroy();
|
await asset.destroy();
|
||||||
invalidateListCache();
|
invalidateListCache();
|
||||||
logActivity(req.user?.user_id, 'archive_asset', { entityType: 'asset', entityId: Number(assetId) });
|
logActivity(req.user?.user_id, 'archive_asset', { entityType: 'asset', entityId: Number(assetId) });
|
||||||
return R.success(res, "Asset archived.");
|
return R.success(res, "File archived.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[ASSET][ARCHIVE]", err);
|
console.error("[ASSET][ARCHIVE]", err);
|
||||||
return R.error(res, "Internal server error.", 500);
|
return R.error(res, "Internal server error.", 500);
|
||||||
@@ -680,7 +680,7 @@ exports.archiveAssets = async (req, res) => {
|
|||||||
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
|
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
|
||||||
|
|
||||||
const assets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids }, ...notDeleted } });
|
const assets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids }, ...notDeleted } });
|
||||||
if (!assets.length) return R.error(res, "No assets found.", 404);
|
if (!assets.length) return R.error(res, "No files found.", 404);
|
||||||
|
|
||||||
const activeIds = assets.map((a) => a.asset_id);
|
const activeIds = assets.map((a) => a.asset_id);
|
||||||
|
|
||||||
@@ -689,7 +689,7 @@ exports.archiveAssets = async (req, res) => {
|
|||||||
|
|
||||||
invalidateListCache();
|
invalidateListCache();
|
||||||
logActivity(req.user?.user_id, 'bulk_archive_assets', { entityType: 'asset', details: { ids: activeIds, count: activeIds.length } });
|
logActivity(req.user?.user_id, 'bulk_archive_assets', { entityType: 'asset', details: { ids: activeIds, count: activeIds.length } });
|
||||||
return R.success(res, `${activeIds.length} asset(s) archived.`, {
|
return R.success(res, `${activeIds.length} file(s) archived.`, {
|
||||||
archived_ids: activeIds,
|
archived_ids: activeIds,
|
||||||
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
||||||
});
|
});
|
||||||
@@ -706,14 +706,14 @@ exports.restoreAsset = async (req, res) => {
|
|||||||
const { assetId } = req.params;
|
const { assetId } = req.params;
|
||||||
|
|
||||||
const asset = await Asset.findOne({ where: { asset_id: assetId }, paranoid: false });
|
const asset = await Asset.findOne({ where: { asset_id: assetId }, paranoid: false });
|
||||||
if (!asset) return R.error(res, "Asset not found.", 404);
|
if (!asset) return R.error(res, "File not found.", 404);
|
||||||
if (!asset.deletedAt) return R.error(res, "Asset is not archived.", 400);
|
if (!asset.deletedAt) return R.error(res, "File is not archived.", 400);
|
||||||
|
|
||||||
await asset.restore();
|
await asset.restore();
|
||||||
await asset.update({ deletedBy: null });
|
await asset.update({ deletedBy: null });
|
||||||
invalidateListCache();
|
invalidateListCache();
|
||||||
logActivity(req.user?.user_id, 'restore_asset', { entityType: 'asset', entityId: Number(assetId) });
|
logActivity(req.user?.user_id, 'restore_asset', { entityType: 'asset', entityId: Number(assetId) });
|
||||||
return R.success(res, "Asset restored.", { data: asset });
|
return R.success(res, "File restored.", { data: asset });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[ASSET][RESTORE]", err);
|
console.error("[ASSET][RESTORE]", err);
|
||||||
return R.error(res, "Internal server error.", 500);
|
return R.error(res, "Internal server error.", 500);
|
||||||
@@ -728,10 +728,10 @@ exports.restoreAssets = async (req, res) => {
|
|||||||
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
|
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
|
||||||
|
|
||||||
const assets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids } }, paranoid: false });
|
const assets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids } }, paranoid: false });
|
||||||
if (!assets.length) return R.error(res, "No assets found.", 404);
|
if (!assets.length) return R.error(res, "No files found.", 404);
|
||||||
|
|
||||||
const archivedAssets = assets.filter((a) => a.deletedAt);
|
const archivedAssets = assets.filter((a) => a.deletedAt);
|
||||||
if (!archivedAssets.length) return R.error(res, "All selected assets are already active.", 400);
|
if (!archivedAssets.length) return R.error(res, "All selected files are already active.", 400);
|
||||||
|
|
||||||
const archivedIds = archivedAssets.map((a) => a.asset_id);
|
const archivedIds = archivedAssets.map((a) => a.asset_id);
|
||||||
|
|
||||||
@@ -740,7 +740,7 @@ exports.restoreAssets = async (req, res) => {
|
|||||||
|
|
||||||
invalidateListCache();
|
invalidateListCache();
|
||||||
logActivity(req.user?.user_id, 'bulk_restore_assets', { entityType: 'asset', details: { ids: archivedIds, count: archivedIds.length } });
|
logActivity(req.user?.user_id, 'bulk_restore_assets', { entityType: 'asset', details: { ids: archivedIds, count: archivedIds.length } });
|
||||||
return R.success(res, `${archivedIds.length} asset(s) restored.`, {
|
return R.success(res, `${archivedIds.length} file(s) restored.`, {
|
||||||
restored_ids: archivedIds,
|
restored_ids: archivedIds,
|
||||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||||
});
|
});
|
||||||
@@ -757,8 +757,8 @@ exports.permanentlyDeleteAsset = async (req, res) => {
|
|||||||
const { assetId } = req.params;
|
const { assetId } = req.params;
|
||||||
|
|
||||||
const asset = await Asset.findOne({ where: { asset_id: assetId }, paranoid: false });
|
const asset = await Asset.findOne({ where: { asset_id: assetId }, paranoid: false });
|
||||||
if (!asset) return R.error(res, "Asset not found.", 404);
|
if (!asset) return R.error(res, "File not found.", 404);
|
||||||
if (!asset.deletedAt) return R.error(res, "Asset must be archived before it can be permanently deleted.", 400);
|
if (!asset.deletedAt) return R.error(res, "File must be archived before it can be permanently deleted.", 400);
|
||||||
|
|
||||||
const { storage_provider, storage_key, thumbnail_storage_key } = asset;
|
const { storage_provider, storage_key, thumbnail_storage_key } = asset;
|
||||||
|
|
||||||
@@ -778,7 +778,7 @@ exports.permanentlyDeleteAsset = async (req, res) => {
|
|||||||
|
|
||||||
invalidateListCache();
|
invalidateListCache();
|
||||||
logActivity(req.user?.user_id, 'permanently_delete_asset', { entityType: 'asset', entityId: Number(assetId) });
|
logActivity(req.user?.user_id, 'permanently_delete_asset', { entityType: 'asset', entityId: Number(assetId) });
|
||||||
return R.success(res, "Asset permanently deleted.");
|
return R.success(res, "File permanently deleted.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[ASSET][PERMANENT DELETE]", err);
|
console.error("[ASSET][PERMANENT DELETE]", err);
|
||||||
return R.error(res, "Internal server error.", 500);
|
return R.error(res, "Internal server error.", 500);
|
||||||
@@ -793,10 +793,10 @@ exports.permanentlyDeleteAssets = async (req, res) => {
|
|||||||
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
|
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
|
||||||
|
|
||||||
const assets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids } }, paranoid: false });
|
const assets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids } }, paranoid: false });
|
||||||
if (!assets.length) return R.error(res, "No assets found.", 404);
|
if (!assets.length) return R.error(res, "No files found.", 404);
|
||||||
|
|
||||||
const archivedAssets = assets.filter((a) => a.deletedAt);
|
const archivedAssets = assets.filter((a) => a.deletedAt);
|
||||||
if (!archivedAssets.length) return R.error(res, "All selected assets must be archived before they can be permanently deleted.", 400);
|
if (!archivedAssets.length) return R.error(res, "All selected files must be archived before they can be permanently deleted.", 400);
|
||||||
|
|
||||||
const archivedIds = archivedAssets.map((a) => a.asset_id);
|
const archivedIds = archivedAssets.map((a) => a.asset_id);
|
||||||
|
|
||||||
@@ -817,7 +817,7 @@ exports.permanentlyDeleteAssets = async (req, res) => {
|
|||||||
|
|
||||||
invalidateListCache();
|
invalidateListCache();
|
||||||
logActivity(req.user?.user_id, 'bulk_permanently_delete_assets', { entityType: 'asset', details: { ids: archivedIds, count: archivedIds.length } });
|
logActivity(req.user?.user_id, 'bulk_permanently_delete_assets', { entityType: 'asset', details: { ids: archivedIds, count: archivedIds.length } });
|
||||||
return R.success(res, `${archivedIds.length} asset(s) permanently deleted.`, {
|
return R.success(res, `${archivedIds.length} file(s) permanently deleted.`, {
|
||||||
deleted_ids: archivedIds,
|
deleted_ids: archivedIds,
|
||||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||||
});
|
});
|
||||||
@@ -840,10 +840,10 @@ exports.getArchivedAssets = async (req, res) => {
|
|||||||
findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } },
|
findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } },
|
||||||
});
|
});
|
||||||
result.data = result.data.map(redactS3Url);
|
result.data = result.data.map(redactS3Url);
|
||||||
return R.success(res, "Archived assets retrieved.", result);
|
return R.success(res, "Archived files retrieved.", result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[ASSET][GET ARCHIVED]", err);
|
console.error("[ASSET][GET ARCHIVED]", err);
|
||||||
return R.error(res, "Could not retrieve archived assets.", 500);
|
return R.error(res, "Could not retrieve archived files.", 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -30,14 +30,14 @@ exports.issueToken = async (req, res) => {
|
|||||||
attributes: ["asset_id", "file_type", "storage_provider", "storage_key", "mime_type", "thumbnail_storage_key"],
|
attributes: ["asset_id", "file_type", "storage_provider", "storage_key", "mime_type", "thumbnail_storage_key"],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!asset) return R.error(res, "Asset not found.", 404);
|
if (!asset) return R.error(res, "File not found.", 404);
|
||||||
|
|
||||||
if (!mediaToken.SUPPORTED_TYPES.includes(asset.file_type)) {
|
if (!mediaToken.SUPPORTED_TYPES.includes(asset.file_type)) {
|
||||||
return R.error(res, `Asset type "${asset.file_type}" is not supported.`, 400);
|
return R.error(res, `File type "${asset.file_type}" is not supported.`, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (asset.storage_provider !== "s3") {
|
if (asset.storage_provider !== "s3") {
|
||||||
return R.error(res, "Token flow is for S3 assets only. Use the raw file_url for other providers.", 400);
|
return R.error(res, "Token flow is for S3 files only. Use the raw file_url for other providers.", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ip = mediaToken.resolveIp(req);
|
const ip = mediaToken.resolveIp(req);
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ async function validateImageAssetId(image_asset_id) {
|
|||||||
if (!image_asset_id) return null;
|
if (!image_asset_id) return null;
|
||||||
const asset = await mdl_Assets.findOne({ where: { asset_id: image_asset_id, deletedAt: null } });
|
const asset = await mdl_Assets.findOne({ where: { asset_id: image_asset_id, deletedAt: null } });
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
const err = new Error("Selected image asset was not found.");
|
const err = new Error("Selected image file was not found.");
|
||||||
err.status = 400;
|
err.status = 400;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ exports.getCategories = async (req, res) => {
|
|||||||
include: withBadge,
|
include: withBadge,
|
||||||
order: [['rank', 'ASC']],
|
order: [['rank', 'ASC']],
|
||||||
});
|
});
|
||||||
return R.success(res, 'Tier categories retrieved.', categories);
|
return R.success(res, 'Subscription categories retrieved.', categories);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ADMIN][GET TIER CATEGORIES]', err);
|
console.error('[ADMIN][GET TIER CATEGORIES]', err);
|
||||||
return R.error(res, 'Could not retrieve tier categories.', 500);
|
return R.error(res, 'Could not retrieve subscription categories.', 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -32,11 +32,11 @@ exports.getCategories = async (req, res) => {
|
|||||||
exports.getCategory = async (req, res) => {
|
exports.getCategory = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const cat = await mdl_TierCategories.findByPk(req.params.id, { include: withBadge });
|
const cat = await mdl_TierCategories.findByPk(req.params.id, { include: withBadge });
|
||||||
if (!cat) return R.error(res, 'Tier category not found.', 404);
|
if (!cat) return R.error(res, 'Subscription category not found.', 404);
|
||||||
return R.success(res, 'Tier category retrieved.', cat);
|
return R.success(res, 'Subscription category retrieved.', cat);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ADMIN][GET TIER CATEGORY]', err);
|
console.error('[ADMIN][GET TIER CATEGORY]', err);
|
||||||
return R.error(res, 'Could not retrieve tier category.', 500);
|
return R.error(res, 'Could not retrieve subscription category.', 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -48,10 +48,10 @@ exports.createCategory = async (req, res) => {
|
|||||||
if (!slug || !name) return R.error(res, 'slug and name are required.', 400);
|
if (!slug || !name) return R.error(res, 'slug and name are required.', 400);
|
||||||
|
|
||||||
const parsedRank = Number(rank ?? 1);
|
const parsedRank = Number(rank ?? 1);
|
||||||
if (parsedRank <= 0) return R.error(res, 'Non-default tier categories must have rank greater than 0.', 400);
|
if (parsedRank <= 0) return R.error(res, 'Non-default subscription categories must have rank greater than 0.', 400);
|
||||||
|
|
||||||
const exists = await mdl_TierCategories.findOne({ where: { slug } });
|
const exists = await mdl_TierCategories.findOne({ where: { slug } });
|
||||||
if (exists) return R.error(res, `A tier category with slug "${slug}" already exists.`, 409);
|
if (exists) return R.error(res, `A subscription category with slug "${slug}" already exists.`, 409);
|
||||||
|
|
||||||
const cat = await mdl_TierCategories.create({
|
const cat = await mdl_TierCategories.create({
|
||||||
slug, name,
|
slug, name,
|
||||||
@@ -69,10 +69,10 @@ exports.createCategory = async (req, res) => {
|
|||||||
logActivity(req.user?.user_id, 'create_tier_category', { entityType: 'tier_category', details: { slug, name } });
|
logActivity(req.user?.user_id, 'create_tier_category', { entityType: 'tier_category', details: { slug, name } });
|
||||||
|
|
||||||
const result = await mdl_TierCategories.findByPk(cat.tier_category_id, { include: withBadge });
|
const result = await mdl_TierCategories.findByPk(cat.tier_category_id, { include: withBadge });
|
||||||
return R.success(res, 'Tier category created.', result, 201);
|
return R.success(res, 'Subscription category created.', result, 201);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ADMIN][CREATE TIER CATEGORY]', err);
|
console.error('[ADMIN][CREATE TIER CATEGORY]', err);
|
||||||
return R.error(res, 'Could not create tier category.', 500);
|
return R.error(res, 'Could not create subscription category.', 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -81,13 +81,13 @@ exports.createCategory = async (req, res) => {
|
|||||||
exports.updateCategory = async (req, res) => {
|
exports.updateCategory = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const cat = await mdl_TierCategories.findByPk(req.params.id);
|
const cat = await mdl_TierCategories.findByPk(req.params.id);
|
||||||
if (!cat) return R.error(res, 'Tier category not found.', 404);
|
if (!cat) return R.error(res, 'Subscription category not found.', 404);
|
||||||
|
|
||||||
const { name, description, rank, color, badge_asset_id, badge_icon, badge_label, is_active, is_special } = req.body;
|
const { name, description, rank, color, badge_asset_id, badge_icon, badge_label, is_active, is_special } = req.body;
|
||||||
|
|
||||||
if (!cat.is_default && rank !== undefined) {
|
if (!cat.is_default && rank !== undefined) {
|
||||||
const parsedRank = Number(rank);
|
const parsedRank = Number(rank);
|
||||||
if (parsedRank <= 0) return R.error(res, 'Non-default tier categories must have rank greater than 0.', 400);
|
if (parsedRank <= 0) return R.error(res, 'Non-default subscription categories must have rank greater than 0.', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
await cat.update({
|
await cat.update({
|
||||||
@@ -106,10 +106,10 @@ exports.updateCategory = async (req, res) => {
|
|||||||
logActivity(req.user?.user_id, 'update_tier_category', { entityType: 'tier_category', details: { id: cat.tier_category_id, slug: cat.slug } });
|
logActivity(req.user?.user_id, 'update_tier_category', { entityType: 'tier_category', details: { id: cat.tier_category_id, slug: cat.slug } });
|
||||||
|
|
||||||
const result = await mdl_TierCategories.findByPk(cat.tier_category_id, { include: withBadge });
|
const result = await mdl_TierCategories.findByPk(cat.tier_category_id, { include: withBadge });
|
||||||
return R.success(res, 'Tier category updated.', result);
|
return R.success(res, 'Subscription category updated.', result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ADMIN][UPDATE TIER CATEGORY]', err);
|
console.error('[ADMIN][UPDATE TIER CATEGORY]', err);
|
||||||
return R.error(res, 'Could not update tier category.', 500);
|
return R.error(res, 'Could not update subscription category.', 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -118,8 +118,8 @@ exports.updateCategory = async (req, res) => {
|
|||||||
exports.deleteCategory = async (req, res) => {
|
exports.deleteCategory = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const cat = await mdl_TierCategories.findByPk(req.params.id);
|
const cat = await mdl_TierCategories.findByPk(req.params.id);
|
||||||
if (!cat) return R.error(res, 'Tier category not found.', 404);
|
if (!cat) return R.error(res, 'Subscription category not found.', 404);
|
||||||
if (cat.is_default) return R.error(res, 'The default (Free) tier category cannot be deleted.', 400);
|
if (cat.is_default) return R.error(res, 'The default (Free) subscription category cannot be deleted.', 400);
|
||||||
|
|
||||||
// Block deletion if active plans still reference this category
|
// Block deletion if active plans still reference this category
|
||||||
const activePlans = await mdl_TierPlans.count({
|
const activePlans = await mdl_TierPlans.count({
|
||||||
@@ -130,9 +130,9 @@ exports.deleteCategory = async (req, res) => {
|
|||||||
|
|
||||||
await cat.destroy();
|
await cat.destroy();
|
||||||
logActivity(req.user?.user_id, 'delete_tier_category', { entityType: 'tier_category', details: { slug: cat.slug } });
|
logActivity(req.user?.user_id, 'delete_tier_category', { entityType: 'tier_category', details: { slug: cat.slug } });
|
||||||
return R.success(res, 'Tier category deleted.');
|
return R.success(res, 'Subscription category deleted.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ADMIN][DELETE TIER CATEGORY]', err);
|
console.error('[ADMIN][DELETE TIER CATEGORY]', err);
|
||||||
return R.error(res, 'Could not delete tier category.', 500);
|
return R.error(res, 'Could not delete subscription category.', 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -117,10 +117,10 @@ exports.createPlan = async (req, res) => {
|
|||||||
|
|
||||||
const category = await mdl_TierCategories.findByPk(tier_category_id);
|
const category = await mdl_TierCategories.findByPk(tier_category_id);
|
||||||
if (!category || !category.is_active)
|
if (!category || !category.is_active)
|
||||||
return R.error(res, 'Tier category not found or inactive.', 404);
|
return R.error(res, 'Subscription category not found or inactive.', 404);
|
||||||
|
|
||||||
if (category.is_default)
|
if (category.is_default)
|
||||||
return R.error(res, 'Plans cannot be created under the default (Free) tier. Free access is automatic.', 400);
|
return R.error(res, 'Plans cannot be created under the default (Free) subscription. Free access is automatic.', 400);
|
||||||
|
|
||||||
const duration_days = computeDurationDays(duration_value, duration_unit);
|
const duration_days = computeDurationDays(duration_value, duration_unit);
|
||||||
|
|
||||||
@@ -166,9 +166,9 @@ exports.updatePlan = async (req, res) => {
|
|||||||
if (updates.tier_category_id) {
|
if (updates.tier_category_id) {
|
||||||
const category = await mdl_TierCategories.findByPk(updates.tier_category_id);
|
const category = await mdl_TierCategories.findByPk(updates.tier_category_id);
|
||||||
if (!category || !category.is_active)
|
if (!category || !category.is_active)
|
||||||
return R.error(res, 'Tier category not found or inactive.', 404);
|
return R.error(res, 'Subscription category not found or inactive.', 404);
|
||||||
if (category.is_default)
|
if (category.is_default)
|
||||||
return R.error(res, 'Plans cannot be moved to the Free tier category.', 400);
|
return R.error(res, 'Plans cannot be moved to the Free subscription category.', 400);
|
||||||
updates.tier = category.slug;
|
updates.tier = category.slug;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,10 +428,10 @@ exports.getUserTiers = async (req, res) => {
|
|||||||
],
|
],
|
||||||
order: [['createdAt', 'DESC']],
|
order: [['createdAt', 'DESC']],
|
||||||
});
|
});
|
||||||
return R.success(res, 'User tiers retrieved.', tiers);
|
return R.success(res, 'User subscriptions retrieved.', tiers);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ADMIN][GET USER TIERS]', err);
|
console.error('[ADMIN][GET USER TIERS]', err);
|
||||||
return R.error(res, 'Could not retrieve user tiers.', 500);
|
return R.error(res, 'Could not retrieve user subscriptions.', 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -472,18 +472,18 @@ exports.grantTier = async (req, res) => {
|
|||||||
await snapshotPlanGrants(newTier, plan_id);
|
await snapshotPlanGrants(newTier, plan_id);
|
||||||
|
|
||||||
logActivity(req.user.user_id, 'grant_tier', { entityType: 'tier', details: { user_id, tier, plan_id } });
|
logActivity(req.user.user_id, 'grant_tier', { entityType: 'tier', details: { user_id, tier, plan_id } });
|
||||||
return R.success(res, 'Tier granted.', newTier, 201);
|
return R.success(res, 'Subscription granted.', newTier, 201);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ADMIN][GRANT TIER]', err);
|
console.error('[ADMIN][GRANT TIER]', err);
|
||||||
return R.error(res, 'Could not grant tier.', 500);
|
return R.error(res, 'Could not grant subscription.', 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.revokeTier = async (req, res) => {
|
exports.revokeTier = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const tierRecord = await mdl_UserTiers.findByPk(req.params.tid);
|
const tierRecord = await mdl_UserTiers.findByPk(req.params.tid);
|
||||||
if (!tierRecord) return R.error(res, 'Tier record not found.', 404);
|
if (!tierRecord) return R.error(res, 'Subscription record not found.', 404);
|
||||||
if (tierRecord.status !== 'active') return R.error(res, 'Tier is not active.', 400);
|
if (tierRecord.status !== 'active') return R.error(res, 'Subscription is not active.', 400);
|
||||||
|
|
||||||
await tierRecord.update({
|
await tierRecord.update({
|
||||||
status: 'revoked',
|
status: 'revoked',
|
||||||
@@ -510,10 +510,10 @@ exports.revokeTier = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logActivity(req.user.user_id, 'revoke_tier', { entityType: 'tier', details: { user_id: tierRecord.user_id, tier: tierRecord.tier } });
|
logActivity(req.user.user_id, 'revoke_tier', { entityType: 'tier', details: { user_id: tierRecord.user_id, tier: tierRecord.tier } });
|
||||||
return R.success(res, remainingActive === 0 ? 'Tier revoked. User downgraded to free.' : 'Tier revoked.');
|
return R.success(res, remainingActive === 0 ? 'Subscription revoked. User downgraded to free.' : 'Subscription revoked.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ADMIN][REVOKE TIER]', err);
|
console.error('[ADMIN][REVOKE TIER]', err);
|
||||||
return R.error(res, 'Could not revoke tier.', 500);
|
return R.error(res, 'Could not revoke subscription.', 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -160,14 +160,14 @@ exports.issueToken = async (req, res) => {
|
|||||||
attributes: ["asset_id", "file_type", "storage_provider", "storage_key", "mime_type", "thumbnail_storage_key"],
|
attributes: ["asset_id", "file_type", "storage_provider", "storage_key", "mime_type", "thumbnail_storage_key"],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!asset) return R.error(res, "Asset not found.", 404);
|
if (!asset) return R.error(res, "File not found.", 404);
|
||||||
|
|
||||||
if (!SUPPORTED_TYPES.includes(asset.file_type)) {
|
if (!SUPPORTED_TYPES.includes(asset.file_type)) {
|
||||||
return R.error(res, `Asset type "${asset.file_type}" is not supported.`, 400);
|
return R.error(res, `File type "${asset.file_type}" is not supported.`, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (asset.storage_provider !== "s3") {
|
if (asset.storage_provider !== "s3") {
|
||||||
return R.error(res, "Token flow is for S3 assets only. Use the raw file_url for other providers.", 400);
|
return R.error(res, "Token flow is for S3 files only. Use the raw file_url for other providers.", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Bind token to the requester's IP ──────────────────────────────────────
|
// ── Bind token to the requester's IP ──────────────────────────────────────
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ exports.getMyTier = async (req, res) => {
|
|||||||
const freeTier = { tier: 'free', status: 'active', category: freeCategory ?? null };
|
const freeTier = { tier: 'free', status: 'active', category: freeCategory ?? null };
|
||||||
// Spread freeTier at top level too — keeps `myTier.tier`/`myTier.status`/`myTier.category`
|
// Spread freeTier at top level too — keeps `myTier.tier`/`myTier.status`/`myTier.category`
|
||||||
// working for existing frontend code that predates the active_tiers/top_tier shape.
|
// working for existing frontend code that predates the active_tiers/top_tier shape.
|
||||||
return R.success(res, 'Active tier retrieved.', {
|
return R.success(res, 'Active subscription retrieved.', {
|
||||||
...freeTier,
|
...freeTier,
|
||||||
active_tiers: [freeTier],
|
active_tiers: [freeTier],
|
||||||
top_tier: 'free',
|
top_tier: 'free',
|
||||||
@@ -126,10 +126,10 @@ exports.getMyTier = async (req, res) => {
|
|||||||
// Spread topTierObj at top level too — keeps `myTier.tier`/`myTier.status`/
|
// Spread topTierObj at top level too — keeps `myTier.tier`/`myTier.status`/
|
||||||
// `myTier.category`/`myTier.expires_at` working for existing frontend code
|
// `myTier.category`/`myTier.expires_at` working for existing frontend code
|
||||||
// that predates the active_tiers/top_tier shape (it'll just see the best tier).
|
// that predates the active_tiers/top_tier shape (it'll just see the best tier).
|
||||||
return R.success(res, 'Active tier retrieved.', { ...topTierObj, active_tiers, top_tier, just_expired, my_grants });
|
return R.success(res, 'Active subscription retrieved.', { ...topTierObj, active_tiers, top_tier, just_expired, my_grants });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[CLIENT][GET MY TIER]', err);
|
console.error('[CLIENT][GET MY TIER]', err);
|
||||||
return R.error(res, 'Could not retrieve tier.', 500);
|
return R.error(res, 'Could not retrieve subscription.', 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -139,10 +139,10 @@ exports.getMyTierHistory = async (req, res) => {
|
|||||||
where: { user_id: req.user.user_id },
|
where: { user_id: req.user.user_id },
|
||||||
order: [['createdAt', 'DESC']],
|
order: [['createdAt', 'DESC']],
|
||||||
});
|
});
|
||||||
return R.success(res, 'Tier history retrieved.', history);
|
return R.success(res, 'Subscription history retrieved.', history);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[CLIENT][GET MY TIER HISTORY]', err);
|
console.error('[CLIENT][GET MY TIER HISTORY]', err);
|
||||||
return R.error(res, 'Could not retrieve tier history.', 500);
|
return R.error(res, 'Could not retrieve subscription history.', 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -392,7 +392,7 @@ exports.captureOrder = async (req, res) => {
|
|||||||
expires_at: expiresAt,
|
expires_at: expiresAt,
|
||||||
granted_by: null,
|
granted_by: null,
|
||||||
});
|
});
|
||||||
successMessage = 'Payment successful. Tier activated.';
|
successMessage = 'Payment successful. Subscription activated.';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot the plan's current bundle contents into user_tier_grants —
|
// Snapshot the plan's current bundle contents into user_tier_grants —
|
||||||
@@ -471,7 +471,7 @@ exports.refundOrder = async (req, res) => {
|
|||||||
where: activeTierWhere,
|
where: activeTierWhere,
|
||||||
order: [['createdAt', 'DESC']],
|
order: [['createdAt', 'DESC']],
|
||||||
});
|
});
|
||||||
if (!activeTierCandidates.length) return R.error(res, 'No active tier to refund.', 404);
|
if (!activeTierCandidates.length) return R.error(res, 'No active subscription to refund.', 404);
|
||||||
if (activeTierCandidates.length > 1) {
|
if (activeTierCandidates.length > 1) {
|
||||||
return R.error(res, 'You have more than one active subscription — specify plan_id to refund a specific one.', 400);
|
return R.error(res, 'You have more than one active subscription — specify plan_id to refund a specific one.', 400);
|
||||||
}
|
}
|
||||||
@@ -481,7 +481,7 @@ exports.refundOrder = async (req, res) => {
|
|||||||
where: { user_id, tier_id: activeTier.tier_id, status: 'completed' },
|
where: { user_id, tier_id: activeTier.tier_id, status: 'completed' },
|
||||||
order: [['paid_at', 'DESC']],
|
order: [['paid_at', 'DESC']],
|
||||||
});
|
});
|
||||||
if (!payment) return R.error(res, 'No completed payment found for this tier.', 404);
|
if (!payment) return R.error(res, 'No completed payment found for this subscription.', 404);
|
||||||
|
|
||||||
// Load plan's payment policy to get the configured refund window
|
// Load plan's payment policy to get the configured refund window
|
||||||
const policy = await paymentSvc.getPolicyForPlan(payment.plan_id);
|
const policy = await paymentSvc.getPolicyForPlan(payment.plan_id);
|
||||||
@@ -608,10 +608,10 @@ exports.getCategories = async (req, res) => {
|
|||||||
include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'storage_provider', 'file_url', 'display_name'], required: false }],
|
include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'storage_provider', 'file_url', 'display_name'], required: false }],
|
||||||
order: [['rank', 'ASC']],
|
order: [['rank', 'ASC']],
|
||||||
});
|
});
|
||||||
return R.success(res, 'Tier categories retrieved.', categories);
|
return R.success(res, 'Subscription categories retrieved.', categories);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[CLIENT][GET TIER CATEGORIES]', err);
|
console.error('[CLIENT][GET TIER CATEGORIES]', err);
|
||||||
return R.error(res, 'Could not retrieve tier categories.', 500);
|
return R.error(res, 'Could not retrieve subscription categories.', 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -44,17 +44,17 @@ exports.issueToken = async (req, res) => {
|
|||||||
attributes: ["asset_id", "file_type", "storage_provider", "storage_key", "mime_type", "is_public"],
|
attributes: ["asset_id", "file_type", "storage_provider", "storage_key", "mime_type", "is_public"],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!asset) return R.error(res, "Asset not found.", 404);
|
if (!asset) return R.error(res, "File not found.", 404);
|
||||||
|
|
||||||
// Private assets are never served through the public endpoint
|
// Private assets are never served through the public endpoint
|
||||||
if (!asset.is_public) return R.error(res, "Forbidden.", 403);
|
if (!asset.is_public) return R.error(res, "Forbidden.", 403);
|
||||||
|
|
||||||
if (!SUPPORTED_TYPES.includes(asset.file_type)) {
|
if (!SUPPORTED_TYPES.includes(asset.file_type)) {
|
||||||
return R.error(res, `Asset type "${asset.file_type}" is not supported.`, 400);
|
return R.error(res, `File type "${asset.file_type}" is not supported.`, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (asset.storage_provider !== "s3") {
|
if (asset.storage_provider !== "s3") {
|
||||||
return R.error(res, "Token flow is for S3 assets only. Use the raw file_url for other providers.", 400);
|
return R.error(res, "Token flow is for S3 files only. Use the raw file_url for other providers.", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ip = resolveIp(req);
|
const ip = resolveIp(req);
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ const emailTemplates = {
|
|||||||
<tr><td><strong>Status</strong></td><td>Access Revoked</td></tr>
|
<tr><td><strong>Status</strong></td><td>Access Revoked</td></tr>
|
||||||
<tr><td><strong>Refund</strong></td><td>Not applicable — this subscription is non-refundable</td></tr>
|
<tr><td><strong>Refund</strong></td><td>Not applicable — this subscription is non-refundable</td></tr>
|
||||||
</table>
|
</table>
|
||||||
<p>Your account no longer has access to the courses, units, and lessons included in this plan. If you were not concurrently subscribed to another active plan, your account has reverted to the Free tier.</p>
|
<p>Your account no longer has access to the courses, units, and lessons included in this plan. If you were not concurrently subscribed to another active plan, your account has reverted to the Free subscription.</p>
|
||||||
<p>If you believe this was done in error or have questions about this change, please contact our support team.</p>
|
<p>If you believe this was done in error or have questions about this change, please contact our support team.</p>
|
||||||
`),
|
`),
|
||||||
}),
|
}),
|
||||||
@@ -188,7 +188,7 @@ const emailTemplates = {
|
|||||||
<tr><td><strong>Refund ID</strong></td><td>${refundId}</td></tr>
|
<tr><td><strong>Refund ID</strong></td><td>${refundId}</td></tr>
|
||||||
<tr><td><strong>Status</strong></td><td>Refunded</td></tr>
|
<tr><td><strong>Status</strong></td><td>Refunded</td></tr>
|
||||||
</table>
|
</table>
|
||||||
<p>Your access to the courses, units, and lessons included in this plan has been revoked effective immediately. If you were not concurrently subscribed to another active plan, your account has reverted to the Free tier.</p>
|
<p>Your access to the courses, units, and lessons included in this plan has been revoked effective immediately. If you were not concurrently subscribed to another active plan, your account has reverted to the Free subscription.</p>
|
||||||
<p>Please allow a few business days for the refunded amount to reflect on your original payment method, depending on your provider.</p>
|
<p>Please allow a few business days for the refunded amount to reflect on your original payment method, depending on your provider.</p>
|
||||||
<p>If you have any questions about this refund, please contact our support team.</p>
|
<p>If you have any questions about this refund, please contact our support team.</p>
|
||||||
`),
|
`),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
const PLACEMENTS = [
|
const PLACEMENTS = [
|
||||||
{ key: "dashboard.hero", format: "hero", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Hero (top of page)" },
|
{ key: "dashboard.hero", format: "hero", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Hero (top of page)" },
|
||||||
{ key: "tier_plans.banner", format: "banner", page: "tier_plans", pageLabel: "Tier Plans", slotLabel: "Banner (above plan cards)" },
|
{ key: "tier_plans.banner", format: "banner", page: "tier_plans", pageLabel: "Subscriptions", slotLabel: "Banner (above plan cards)" },
|
||||||
{ key: "course_details.banner", format: "banner", page: "course_details", pageLabel: "Course Details", slotLabel: "Banner (below hero)" },
|
{ key: "course_details.banner", format: "banner", page: "course_details", pageLabel: "Course Details", slotLabel: "Banner (below hero)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user