feat(projects): gate Project Overview behind a projects feature flag + cockpit email actions
- Migration 120 seeds the projects flag (default OFF), idempotent. - Backend feature-flags whitelist + DEFAULT_FLAGS + clients derivation. - adminProjects routes 403 PROJECTS_DISABLED when the flag is off. - projectService email actions (resend/cancel/retry/send-now) + routes. - Frontend flag type, DEFAULT_FLAGS, Features tab card (en+de).
This commit is contained in:
@@ -62,6 +62,10 @@ const KNOWN_FLAGS = [
|
||||
// upload). Seeded block bodies are EXAMPLES ONLY; admins must have a
|
||||
// lawyer review before sending. See docs/crm-disclaimers.md.
|
||||
'contracts',
|
||||
// Projects (migration 120). Admin-only grouping layer above events +
|
||||
// the Project Overview cockpit ("book to project" hours control, 360°
|
||||
// rollup feed). Lights up the Clients section. Customers never see it.
|
||||
'projects',
|
||||
];
|
||||
|
||||
// Spec defaults for any flag missing from the DB (e.g. a row added by a
|
||||
@@ -84,6 +88,7 @@ const DEFAULT_FLAGS = {
|
||||
taxReport: false,
|
||||
hoursLogging: false,
|
||||
contracts: false,
|
||||
projects: false,
|
||||
};
|
||||
|
||||
async function readAllFlags() {
|
||||
@@ -123,6 +128,8 @@ function applyDependencyRules(flags) {
|
||||
|| out.taxReport
|
||||
|| out.hoursLogging
|
||||
|| out.contracts
|
||||
// Migration 120 — admin-only Project Overview cockpit lives under Clients.
|
||||
|| out.projects
|
||||
// Migration 137 — admin calendar lights up the Clients section.
|
||||
// (calendarBooking is gated behind `calendar` so adding the parent
|
||||
// is sufficient.)
|
||||
|
||||
@@ -14,10 +14,23 @@ const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission, userHasAnyPermission } = require('../middleware/permissions');
|
||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||
const projectService = require('../services/projectService');
|
||||
const { db } = require('../database/db');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(adminAuth);
|
||||
|
||||
// Projects is feature-flagged like bills/quotes — when off, the whole cockpit
|
||||
// (and the "book to project" hours control) is hidden, and the API 403s.
|
||||
async function requireProjectsFlag(req, res, next) {
|
||||
try {
|
||||
const row = await db('feature_flags').where({ key: 'projects' }).first();
|
||||
const enabled = row && (row.value === true || row.value === 1 || row.value === '1');
|
||||
if (!enabled) return res.status(403).json({ error: 'Projects feature is disabled', code: 'PROJECTS_DISABLED' });
|
||||
next();
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
router.use(requireProjectsFlag);
|
||||
|
||||
// List
|
||||
router.get('/', requirePermission('events.view'), handleAsync(async (req, res) => {
|
||||
const projects = await projectService.listProjects({
|
||||
@@ -99,4 +112,16 @@ router.get('/email/:emailId/preview', requirePermission('events.view'), [param('
|
||||
return successResponse(res, preview);
|
||||
}));
|
||||
|
||||
// Email actions from the feed (need email.send). Resend (sent→fresh copy),
|
||||
// Cancel (pending→cancelled), Retry (failed→pending), Send now (flush this one).
|
||||
const emailAction = (fn) => handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const result = await projectService[fn](parseInt(req.params.emailId, 10));
|
||||
return successResponse(res, result);
|
||||
});
|
||||
router.post('/email/:emailId/resend', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], emailAction('resendEmail'));
|
||||
router.post('/email/:emailId/cancel', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], emailAction('cancelEmail'));
|
||||
router.post('/email/:emailId/retry', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], emailAction('retryEmail'));
|
||||
router.post('/email/:emailId/send-now', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], emailAction('sendEmailNow'));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -196,6 +196,48 @@ async function getEmailPreview(emailId) {
|
||||
};
|
||||
}
|
||||
|
||||
// ── Email actions (from the cockpit feed) ───────────────────────────────
|
||||
|
||||
async function resendEmail(emailId) {
|
||||
const row = await db('email_queue').where({ id: emailId }).first();
|
||||
if (!row) throw new AppError('Email not found', 404);
|
||||
const insert = await db('email_queue').insert({
|
||||
recipient_email: row.recipient_email,
|
||||
email_type: row.email_type,
|
||||
email_data: row.email_data,
|
||||
event_id: row.event_id,
|
||||
status: 'pending',
|
||||
retry_count: 0,
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
const id = (insert[0] && typeof insert[0] === 'object') ? insert[0].id : insert[0];
|
||||
return { id, status: 'pending' };
|
||||
}
|
||||
|
||||
async function cancelEmail(emailId) {
|
||||
const row = await db('email_queue').where({ id: emailId }).first();
|
||||
if (!row) throw new AppError('Email not found', 404);
|
||||
if (row.status !== 'pending') throw new AppError('Only pending emails can be cancelled', 409);
|
||||
await db('email_queue').where({ id: emailId }).update({ status: 'cancelled' });
|
||||
return { id: emailId, status: 'cancelled' };
|
||||
}
|
||||
|
||||
async function retryEmail(emailId) {
|
||||
const row = await db('email_queue').where({ id: emailId }).first();
|
||||
if (!row) throw new AppError('Email not found', 404);
|
||||
await db('email_queue').where({ id: emailId })
|
||||
.update({ status: 'pending', retry_count: 0, error_message: null, scheduled_at: null });
|
||||
return { id: emailId, status: 'pending' };
|
||||
}
|
||||
|
||||
async function sendEmailNow(emailId) {
|
||||
const row = await db('email_queue').where({ id: emailId }).first();
|
||||
if (!row) throw new AppError('Email not found', 404);
|
||||
await db('email_queue').where({ id: emailId }).update({ status: 'pending', scheduled_at: null });
|
||||
const { processEmailQueue } = require('./emailProcessor');
|
||||
return processEmailQueue({ ignoreSchedule: true, limit: 50 });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listProjects,
|
||||
getProjectById,
|
||||
@@ -204,4 +246,8 @@ module.exports = {
|
||||
assignEvent,
|
||||
getProjectOverview,
|
||||
getEmailPreview,
|
||||
resendEmail,
|
||||
cancelEmail,
|
||||
retryEmail,
|
||||
sendEmailNow,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user