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:
Luca
2026-06-06 12:59:57 +02:00
parent 874c91f944
commit 1bf0b34ea5
9 changed files with 136 additions and 1 deletions
@@ -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();
};
+7
View File
@@ -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.)
+25
View File
@@ -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;
+46
View File
@@ -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 */}
+5
View File
@@ -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": {
+5
View File
@@ -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>;