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:
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Migration: seed the `projects` feature flag (default OFF).
|
||||||
|
*
|
||||||
|
* Gates the admin-only Project Overview cockpit + the "book to project" hours
|
||||||
|
* control. Off by default so existing installs don't suddenly surface a new
|
||||||
|
* top-level CRM area — the admin opts in under Settings → Features, exactly
|
||||||
|
* like bills/quotes/contracts/hours.
|
||||||
|
*
|
||||||
|
* Idempotent: inserts only when the row is missing (migration 088 already
|
||||||
|
* seeded the original flag set on fresh installs and won't re-run).
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function (knex) {
|
||||||
|
if (!(await knex.schema.hasTable('feature_flags'))) return;
|
||||||
|
const existing = await knex('feature_flags').where({ key: 'projects' }).first();
|
||||||
|
if (!existing) {
|
||||||
|
await knex('feature_flags').insert({ key: 'projects', value: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function (knex) {
|
||||||
|
if (!(await knex.schema.hasTable('feature_flags'))) return;
|
||||||
|
await knex('feature_flags').where({ key: 'projects' }).del();
|
||||||
|
};
|
||||||
@@ -62,6 +62,10 @@ const KNOWN_FLAGS = [
|
|||||||
// upload). Seeded block bodies are EXAMPLES ONLY; admins must have a
|
// upload). Seeded block bodies are EXAMPLES ONLY; admins must have a
|
||||||
// lawyer review before sending. See docs/crm-disclaimers.md.
|
// lawyer review before sending. See docs/crm-disclaimers.md.
|
||||||
'contracts',
|
'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
|
// 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,
|
taxReport: false,
|
||||||
hoursLogging: false,
|
hoursLogging: false,
|
||||||
contracts: false,
|
contracts: false,
|
||||||
|
projects: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
async function readAllFlags() {
|
async function readAllFlags() {
|
||||||
@@ -123,6 +128,8 @@ function applyDependencyRules(flags) {
|
|||||||
|| out.taxReport
|
|| out.taxReport
|
||||||
|| out.hoursLogging
|
|| out.hoursLogging
|
||||||
|| out.contracts
|
|| out.contracts
|
||||||
|
// Migration 120 — admin-only Project Overview cockpit lives under Clients.
|
||||||
|
|| out.projects
|
||||||
// Migration 137 — admin calendar lights up the Clients section.
|
// Migration 137 — admin calendar lights up the Clients section.
|
||||||
// (calendarBooking is gated behind `calendar` so adding the parent
|
// (calendarBooking is gated behind `calendar` so adding the parent
|
||||||
// is sufficient.)
|
// is sufficient.)
|
||||||
|
|||||||
@@ -14,10 +14,23 @@ const { adminAuth } = require('../middleware/auth');
|
|||||||
const { requirePermission, userHasAnyPermission } = require('../middleware/permissions');
|
const { requirePermission, userHasAnyPermission } = require('../middleware/permissions');
|
||||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||||
const projectService = require('../services/projectService');
|
const projectService = require('../services/projectService');
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
router.use(adminAuth);
|
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
|
// List
|
||||||
router.get('/', requirePermission('events.view'), handleAsync(async (req, res) => {
|
router.get('/', requirePermission('events.view'), handleAsync(async (req, res) => {
|
||||||
const projects = await projectService.listProjects({
|
const projects = await projectService.listProjects({
|
||||||
@@ -99,4 +112,16 @@ router.get('/email/:emailId/preview', requirePermission('events.view'), [param('
|
|||||||
return successResponse(res, preview);
|
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;
|
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 = {
|
module.exports = {
|
||||||
listProjects,
|
listProjects,
|
||||||
getProjectById,
|
getProjectById,
|
||||||
@@ -204,4 +246,8 @@ module.exports = {
|
|||||||
assignEvent,
|
assignEvent,
|
||||||
getProjectOverview,
|
getProjectOverview,
|
||||||
getEmailPreview,
|
getEmailPreview,
|
||||||
|
resendEmail,
|
||||||
|
cancelEmail,
|
||||||
|
retryEmail,
|
||||||
|
sendEmailNow,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ export const DEFAULT_FLAGS: FeatureFlags = {
|
|||||||
// Settings → Features once they've reviewed the seeded block
|
// Settings → Features once they've reviewed the seeded block
|
||||||
// library with their lawyer.
|
// library with their lawyer.
|
||||||
contracts: false,
|
contracts: false,
|
||||||
|
// Projects (migration 120). Admin-only grouping layer above events +
|
||||||
|
// the Project Overview cockpit. Off by default — admin opts in under
|
||||||
|
// Settings → Features once they want the CRM → Overview area.
|
||||||
|
projects: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const;
|
export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Briefcase,
|
Briefcase,
|
||||||
Wrench,
|
Wrench,
|
||||||
Calculator,
|
Calculator,
|
||||||
|
FolderKanban,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Card } from '../../../components/common';
|
import { Button, Card } from '../../../components/common';
|
||||||
@@ -291,6 +292,20 @@ export const FeaturesTab: React.FC = () => {
|
|||||||
enabled={staged.hoursLogging}
|
enabled={staged.hoursLogging}
|
||||||
onToggle={(next) => setFlag('hoursLogging', next)}
|
onToggle={(next) => setFlag('hoursLogging', next)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FeatureCard
|
||||||
|
icon={FolderKanban}
|
||||||
|
title={t('settings.features.projects.title', 'Projects')}
|
||||||
|
description={t(
|
||||||
|
'settings.features.projects.description',
|
||||||
|
'Admin-only grouping layer above events. Bundle several events under one project and open a 360° Project Overview cockpit — milestone timeline plus a dated feed of every email (with the actual sent preview + resend/cancel/retry actions), quote, contract, invoice, gallery and logged hour. Adds a "book to project" control when logging hours. Customers never see projects.',
|
||||||
|
)}
|
||||||
|
status="new"
|
||||||
|
statusLabel={statusLabel('new')}
|
||||||
|
sidebarLabel={t('settings.features.projects.sidebar', 'Overview')}
|
||||||
|
enabled={staged.projects}
|
||||||
|
onToggle={(next) => setFlag('projects', next)}
|
||||||
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Insights & Access */}
|
{/* Insights & Access */}
|
||||||
|
|||||||
@@ -1666,6 +1666,11 @@
|
|||||||
"title": "Stundenerfassung",
|
"title": "Stundenerfassung",
|
||||||
"description": "Zeiterfassung pro Kunde. Admin erfasst Datum + Start-/Endzeit + optionalen Satz-Override + Notiz. Kunden im Monatsmodus akkumulieren Stunden automatisch in den laufenden Monatsentwurf; Kunden pro Anlass sehen eine Schaltfläche „Entwurfsrechnung erstellen“, die eine eigenständige Entwurfsrechnung mit einer Zeile pro Eintrag erzeugt. Unabhängig von Rechnungen — Stunden erfassen, noch bevor die volle Abrechnungsoberfläche aktiviert ist.",
|
"description": "Zeiterfassung pro Kunde. Admin erfasst Datum + Start-/Endzeit + optionalen Satz-Override + Notiz. Kunden im Monatsmodus akkumulieren Stunden automatisch in den laufenden Monatsentwurf; Kunden pro Anlass sehen eine Schaltfläche „Entwurfsrechnung erstellen“, die eine eigenständige Entwurfsrechnung mit einer Zeile pro Eintrag erzeugt. Unabhängig von Rechnungen — Stunden erfassen, noch bevor die volle Abrechnungsoberfläche aktiviert ist.",
|
||||||
"sidebar": "Stunden"
|
"sidebar": "Stunden"
|
||||||
|
},
|
||||||
|
"projects": {
|
||||||
|
"title": "Projekte",
|
||||||
|
"description": "Nur-Admin-Gruppierungsebene über Events. Bündle mehrere Events unter einem Projekt und öffne ein 360°-Projektübersichts-Cockpit — Meilenstein-Zeitleiste plus ein datierter Verlauf aller E-Mails (mit der tatsächlich gesendeten Vorschau + Erneut-senden/Abbrechen/Wiederholen-Aktionen), Angebote, Verträge, Rechnungen, Galerien und erfassten Stunden. Fügt beim Erfassen von Stunden eine „Auf Projekt buchen“-Option hinzu. Kunden sehen Projekte nie.",
|
||||||
|
"sidebar": "Übersicht"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"customerSurface": {
|
"customerSurface": {
|
||||||
|
|||||||
@@ -1224,6 +1224,11 @@
|
|||||||
"title": "Hours logging",
|
"title": "Hours logging",
|
||||||
"description": "Per-customer time tracking. Admin logs date + start/end times + optional rate override + note. Monthly-mode customers auto-accumulate hours into the running monthly draft; per-event customers see a \"Create draft invoice\" button that mints a standalone draft invoice with one line per entry. Independent of Bills — log hours even before turning the full billing surface on.",
|
"description": "Per-customer time tracking. Admin logs date + start/end times + optional rate override + note. Monthly-mode customers auto-accumulate hours into the running monthly draft; per-event customers see a \"Create draft invoice\" button that mints a standalone draft invoice with one line per entry. Independent of Bills — log hours even before turning the full billing surface on.",
|
||||||
"sidebar": "Hours"
|
"sidebar": "Hours"
|
||||||
|
},
|
||||||
|
"projects": {
|
||||||
|
"title": "Projects",
|
||||||
|
"description": "Admin-only grouping layer above events. Bundle several events under one project and open a 360° Project Overview cockpit — milestone timeline plus a dated feed of every email (with the actual sent preview + resend/cancel/retry actions), quote, contract, invoice, gallery and logged hour. Adds a \"book to project\" control when logging hours. Customers never see projects.",
|
||||||
|
"sidebar": "Overview"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"customerSurface": {
|
"customerSurface": {
|
||||||
|
|||||||
@@ -41,7 +41,11 @@ export type FeatureKey =
|
|||||||
// upload. Independent of quotes / bills — contracts can be sent on
|
// upload. Independent of quotes / bills — contracts can be sent on
|
||||||
// their own. Seeded block bodies are examples only; admins must
|
// their own. Seeded block bodies are examples only; admins must
|
||||||
// have their lawyer review before sending. See docs/crm-disclaimers.md.
|
// have their lawyer review before sending. See docs/crm-disclaimers.md.
|
||||||
| 'contracts';
|
| 'contracts'
|
||||||
|
// Projects (migration 120). Admin-only grouping layer above events with the
|
||||||
|
// 360° Project Overview cockpit + the "book to project" hours control. Off
|
||||||
|
// by default; gates the CRM → Overview area entirely.
|
||||||
|
| 'projects';
|
||||||
|
|
||||||
export type FeatureFlags = Record<FeatureKey, boolean>;
|
export type FeatureFlags = Record<FeatureKey, boolean>;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user