chore: remove Directus CMS and related dependencies
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 3s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m19s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m5s
Monorepo Pipeline / 🏗️ Build (push) Successful in 1m26s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped

This commit is contained in:
2026-02-27 19:06:06 +01:00
parent fbf2153430
commit 7702310a9c
79 changed files with 1733 additions and 14597 deletions
+3 -2
View File
@@ -15,11 +15,12 @@
"pagespeed:test": "mintel pagespeed"
},
"dependencies": {
"@mintel/image-processor": "workspace:*",
"@mintel/next-observability": "workspace:*",
"@mintel/next-utils": "workspace:*",
"@mintel/observability": "workspace:*",
"@mintel/next-observability": "workspace:*",
"@mintel/image-processor": "workspace:*",
"@sentry/nextjs": "10.38.0",
"@tensorflow/tfjs-backend-wasm": "^4.22.0",
"next": "16.1.6",
"next-intl": "^4.8.2",
"react": "^19.0.0",
-56
View File
@@ -11,8 +11,6 @@ services:
restart: always
networks:
- infra
environment:
- DIRECTUS_URL=${DIRECTUS_URL:-http://directus:8055}
env_file:
- .env
ports:
@@ -24,60 +22,6 @@ services:
- "caddy=http://${TRAEFIK_HOST:-acquisition.localhost}"
- "caddy.reverse_proxy={{upstreams 3000}}"
directus:
image: registry.infra.mintel.me/mintel/directus:${IMAGE_TAG:-latest}
healthcheck:
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8055/server/health" ]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
restart: always
networks:
- infra
env_file:
- .env
environment:
KEY: ${DIRECTUS_KEY:-mintel-key}
SECRET: ${DIRECTUS_SECRET:-mintel-secret}
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL:-admin@mintel.me}
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD:-mintel-admin}
DB_CLIENT: 'pg'
DB_HOST: 'at-mintel-directus-db'
DB_PORT: '5432'
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
DB_USER: ${DIRECTUS_DB_USER:-directus}
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-mintel-db-pass}
WEBSOCKETS_ENABLED: 'true'
PUBLIC_URL: ${DIRECTUS_URL:-http://localhost:8055}
ports:
- "8055:8055"
volumes:
- ./directus/uploads:/directus/uploads
- ./directus/extensions:/directus/extensions
- ./directus/schema:/directus/schema
labels:
- "traefik.enable=true"
- "traefik.http.routers.sample-website-directus.rule=Host(`${DIRECTUS_HOST:-cms.sample-website.localhost}`)"
- "traefik.http.services.sample-website-directus.loadbalancer.server.port=8055"
- "caddy=http://${DIRECTUS_HOST:-cms.at.localhost}"
- "caddy.reverse_proxy={{upstreams 8055}}"
at-mintel-directus-db:
image: postgres:15-alpine
restart: always
networks:
- infra
environment:
POSTGRES_DB: ${DIRECTUS_DB_NAME:-directus}
POSTGRES_USER: ${DIRECTUS_DB_USER:-directus}
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-mintel-db-pass}
volumes:
- directus-db-data:/var/lib/postgresql/data
networks:
infra:
external: true
volumes:
directus-db-data:
-10
View File
@@ -10,16 +10,6 @@
"changeset": "changeset",
"version-packages": "changeset version",
"sync-versions": "tsx scripts/sync-versions.ts --",
"cms:dev": "pnpm --filter @mintel/cms-infra dev",
"cms:up": "pnpm --filter @mintel/cms-infra up",
"cms:down": "pnpm --filter @mintel/cms-infra down",
"cms:logs": "pnpm --filter @mintel/cms-infra logs",
"cms:schema:snapshot": "./scripts/cms-snapshot.sh",
"cms:schema:apply": "./scripts/cms-apply.sh local",
"cms:schema:apply:infra": "./scripts/cms-apply.sh infra",
"cms:sync:push": "./scripts/sync-directus.sh push infra",
"cms:sync:pull": "./scripts/sync-directus.sh pull infra",
"build:extensions": "./scripts/sync-extensions.sh",
"release": "pnpm build && changeset publish",
"release:tag": "pnpm build && pnpm -r publish --no-git-checks --access public",
"prepare": "husky"
-34
View File
@@ -1,34 +0,0 @@
{
"name": "acquisition-manager",
"description": "Custom High-Fidelity Management for Directus",
"icon": "extension",
"version": "1.8.21",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"directus:extension": {
"type": "module",
"path": "dist/index.js",
"source": "src/index.ts",
"host": "app",
"name": "acquisition manager"
},
"scripts": {
"build": "directus-extension build",
"dev": "directus-extension build -w"
},
"dependencies": {
"@mintel/directus-extension-toolkit": "workspace:*"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.0"
},
"repository": {
"type": "git",
"url": "https://git.infra.mintel.me/mmintel/at-mintel.git"
}
}
-19
View File
@@ -1,19 +0,0 @@
import { defineModule } from "@directus/extensions-sdk";
import ModuleComponent from "./module.vue";
export default defineModule({
id: "acquisition-manager",
name: "Acquisition",
icon: "auto_awesome",
routes: [
{
path: "",
component: ModuleComponent,
},
{
path: ":id",
component: ModuleComponent,
props: true
}
],
});
-468
View File
@@ -1,468 +0,0 @@
<template>
<MintelManagerLayout
title="Acquisition Manager"
:item-title="getCompanyName(selectedLead) || 'Lead wählen'"
:is-empty="!selectedLead"
empty-title="Lead auswählen"
empty-icon="auto_awesome"
:notice="notice"
@close-notice="notice = null"
>
<template #navigation>
<v-list nav>
<v-list-item @click="openCreateDrawer" clickable>
<v-list-item-icon><v-icon name="add" color="var(--theme--primary)" /></v-list-item-icon>
<v-list-item-content>
<v-text-overflow text="Neuen Lead anlegen" />
</v-list-item-content>
</v-list-item>
<v-divider />
<v-list-item
v-for="lead in leads"
:key="lead.id"
:active="selectedLeadId === lead.id"
class="nav-item"
clickable
@click="selectLead(lead.id)"
>
<v-list-item-icon>
<v-icon :name="getStatusIcon(lead.status)" :color="getStatusColor(lead.status)" />
</v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="getCompanyName(lead)" />
</v-list-item-content>
</v-list-item>
</v-list>
</template>
<template #subtitle>
<template v-if="selectedLead">
<v-icon name="language" x-small />
<a :href="selectedLead.website_url" target="_blank" class="url-link">
{{ selectedLead.website_url.replace(/^https?:\/\//, '') }}
</a>
&middot; Status: {{ selectedLead.status.toUpperCase() }}
</template>
</template>
<template #actions>
<v-button
v-if="selectedLead?.status === 'new'"
secondary
:loading="loadingAudit"
@click="runAudit"
>
<v-icon name="settings_suggest" left />
Audit starten
</v-button>
<template v-if="selectedLead?.status === 'audit_ready'">
<v-button secondary :loading="loadingEmail" @click="sendAuditEmail">
<v-icon name="mail" left />
Audit E-Mail
</v-button>
<v-button :loading="loadingPdf" @click="generatePdf">
<v-icon name="picture_as_pdf" left />
PDF Erstellen
</v-button>
</template>
<v-button v-if="selectedLead?.audit_pdf_path" secondary icon v-tooltip.bottom="'PDF öffnen'" @click="openPdf">
<v-icon name="open_in_new" />
</v-button>
<v-button
v-if="selectedLead && !isCustomer(selectedLead.company)"
secondary
@click="linkAsCustomer"
>
<v-icon name="handshake" left />
Kunde verlinken
</v-button>
<v-button
v-if="selectedLead?.audit_pdf_path"
primary
:loading="loadingEmail"
@click="sendEstimateEmail"
>
<v-icon name="send" left />
Angebot senden
</v-button>
</template>
<template #empty-state>
Wähle einen Lead in der Navigation aus oder
<v-button x-small @click="openCreateDrawer">registriere einen neuen Lead</v-button>.
</template>
<div v-if="selectedLead" class="sections">
<div class="main-info">
<div class="form-grid">
<div class="field">
<span class="label">Kontaktperson</span>
<div v-if="selectedLead.contact_person" class="value person-link" @click="goToPerson(selectedLead.contact_person)">
{{ getPersonName(selectedLead.contact_person) }}
</div>
<div v-else class="value text-subdued">Keine Person verknüpft</div>
</div>
<div class="field full">
<span class="label">Briefing / Fokus</span>
<div class="value text-block">{{ selectedLead.briefing || 'Kein Briefing hinterlegt.' }}</div>
</div>
</div>
</div>
<v-divider />
<div v-if="selectedLead.ai_state" class="ai-observations">
<h3 class="section-title">AI Observations & Estimation</h3>
<div class="metrics">
<MintelStatCard label="Projekt-Modus" :value="selectedLead.ai_state.projectType || 'Unbekannt'" icon="category" />
<MintelStatCard label="Seitenanzahl" :value="selectedLead.ai_state.sitemap?.length || '0'" icon="description" />
</div>
<v-table
v-if="selectedLead.ai_state.sitemap"
:headers="[ { text: 'Seite', value: 'title' }, { text: 'URL', value: 'url' } ]"
:items="selectedLead.ai_state.sitemap"
class="observation-table"
>
<template #[`item.title`]="{ item }">
<span class="page-title">{{ item.title }}</span>
</template>
<template #[`item.url`]="{ item }">
<span class="page-url">{{ item.url }}</span>
</template>
</v-table>
</div>
</div>
<!-- Drawer: New Lead -->
<v-drawer
v-model="drawerActive"
title="Neuen Lead registrieren"
icon="person_add"
@cancel="drawerActive = false"
>
<div class="drawer-content">
<div class="form-section">
<div class="field">
<span class="label">Organisation / Firma (Zentral)</span>
<MintelSelect
v-model="newLead.company"
:items="companyOptions"
placeholder="Bestehende Firma auswählen..."
allow-add
@add="openQuickAdd('company')"
/>
</div>
<div class="field">
<span class="label">Website URL</span>
<v-input v-model="newLead.website_url" placeholder="https://..." />
</div>
<div class="field">
<span class="label">Briefing / Fokus</span>
<v-textarea v-model="newLead.briefing" placeholder="Besonderheiten für das Audit..." />
</div>
<div class="field">
<span class="label">Kontaktperson (Optional)</span>
<MintelSelect
v-model="newLead.contact_person"
:items="peopleOptions"
placeholder="Person auswählen..."
allow-add
@add="openQuickAdd('person')"
/>
</div>
</div>
<div class="drawer-actions">
<v-button primary block :loading="savingLead" @click="saveLead">Lead speichern</v-button>
</div>
</div>
</v-drawer>
</MintelManagerLayout>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useApi } from '@directus/extensions-sdk';
import { useRoute, useRouter } from 'vue-router';
import { MintelManagerLayout, MintelSelect, MintelStatCard } from '@mintel/directus-extension-toolkit';
const api = useApi();
const route = useRoute();
const router = useRouter();
const leads = ref<any[]>([]);
const selectedLeadId = ref<string | null>(null);
const loadingAudit = ref(false);
const loadingPdf = ref(false);
const loadingEmail = ref(false);
const drawerActive = ref(false);
const savingLead = ref(false);
const notice = ref<{ type: string; message: string } | null>(null);
const newLead = ref({
company: null,
website_url: '',
contact_person: null,
briefing: '',
status: 'new'
});
const companies = ref<any[]>([]);
const people = ref<any[]>([]);
const customers = ref<any[]>([]);
const companyOptions = computed(() =>
companies.value.map(c => ({
text: c.name,
value: c.id
}))
);
const peopleOptions = computed(() =>
people.value.map(p => ({
text: `${p.first_name} ${p.last_name}`,
value: p.id
}))
);
function getCompanyName(lead: any) {
if (!lead) return '';
if (lead.company) {
return typeof lead.company === 'object' ? lead.company.name : (companies.value.find(c => c.id === lead.company)?.name || 'Unbekannte Firma');
}
return 'Unbekannte Organisation';
}
function getPersonName(id: string | any) {
if (!id) return '';
if (typeof id === 'object') return `${id.first_name} ${id.last_name}`;
const person = people.value.find(p => p.id === id);
return person ? `${person.first_name} ${person.last_name}` : id;
}
function goToPerson(id: string) {
notice.value = { type: 'info', message: `Navigiere zu Person: ${id}` };
}
const selectedLead = computed(() => leads.value.find(l => l.id === selectedLeadId.value));
async function fetchData() {
try {
const [leadsResp, peopleResp, companiesResp, customersResp] = await Promise.all([
api.get('/items/leads', {
params: {
sort: '-date_created',
fields: '*.*'
}
}),
api.get('/items/people', { params: { sort: 'last_name' } }),
api.get('/items/companies', { params: { sort: 'name' } }),
api.get('/items/customers', { params: { fields: ['company'] } })
]);
leads.value = leadsResp.data.data;
people.value = peopleResp.data.data;
companies.value = companiesResp.data.data;
customers.value = customersResp.data.data;
if (!selectedLeadId.value && leads.value.length > 0) {
selectedLeadId.value = leads.value[0].id;
}
} catch (e: any) {
console.error('Fetch error:', e);
}
}
function isCustomer(companyId: string | any) {
if (!companyId) return false;
const id = typeof companyId === 'object' ? companyId.id : companyId;
return customers.value.some(c => (typeof c.company === 'object' ? c.company.id : c.company) === id);
}
async function linkAsCustomer() {
if (!selectedLead.value) return;
const companyId = selectedLead.value.company
? (typeof selectedLead.value.company === 'object' ? selectedLead.value.company.id : selectedLead.value.company)
: null;
const personId = selectedLead.value.contact_person
? (typeof selectedLead.value.contact_person === 'object' ? selectedLead.value.contact_person.id : selectedLead.value.contact_person)
: null;
router.push({
name: 'module-customer-manager',
query: {
create: 'true',
company: companyId,
contact_person: personId
}
});
}
async function fetchLeads() {
await fetchData();
}
function selectLead(id: string) {
selectedLeadId.value = id;
}
function openCreateDrawer() {
newLead.value = {
company: null,
website_url: '',
contact_person: null,
briefing: '',
status: 'new'
};
drawerActive.value = true;
}
async function runAudit() {
if (!selectedLeadId.value) return;
loadingAudit.value = true;
try {
await api.post(`/acquisition/audit/${selectedLeadId.value}`);
notice.value = { type: 'success', message: 'Audit erfolgreich gestartet!' };
await fetchLeads();
} catch (e: any) {
notice.value = { type: 'danger', message: `Fehler beim Audit: ${e.message}` };
} finally {
loadingAudit.value = false;
}
}
async function sendAuditEmail() {
if (!selectedLeadId.value) return;
loadingEmail.value = true;
try {
await api.post(`/acquisition/audit-email/${selectedLeadId.value}`);
notice.value = { type: 'success', message: 'Audit E-Mail versendet!' };
await fetchLeads();
} catch (e: any) {
notice.value = { type: 'danger', message: `Fehler beim Versenden: ${e.message}` };
} finally {
loadingEmail.value = false;
}
}
async function generatePdf() {
if (!selectedLeadId.value) return;
loadingPdf.value = true;
try {
await api.post(`/acquisition/estimate/${selectedLeadId.value}`);
notice.value = { type: 'success', message: 'Angebot (PDF) wurde generiert!' };
await fetchLeads();
} catch (e: any) {
notice.value = { type: 'danger', message: `Fehler bei PDF Generierung: ${e.message}` };
} finally {
loadingPdf.value = false;
}
}
async function sendEstimateEmail() {
if (!selectedLeadId.value) return;
loadingEmail.value = true;
try {
await api.post(`/acquisition/estimate-email/${selectedLeadId.value}`);
notice.value = { type: 'success', message: 'Angebot erfolgreich versendet!' };
await fetchLeads();
} catch (e: any) {
notice.value = { type: 'danger', message: `Fehler beim Versenden: ${e.message}` };
} finally {
loadingEmail.value = false;
}
}
function openPdf() {
if (!selectedLead.value?.audit_pdf_path) return;
window.open(`${window.location.origin}/assets/${selectedLead.value.audit_pdf_path}`, '_blank');
}
async function saveLead() {
if (!newLead.value.company) {
notice.value = { type: 'danger', message: 'Organisation erforderlich.' };
return;
}
savingLead.value = true;
try {
const payload = {
id: crypto.randomUUID(),
...newLead.value
};
await api.post('/items/leads', payload);
notice.value = { type: 'success', message: 'Lead erfolgreich registriert!' };
drawerActive.value = false;
await fetchLeads();
selectedLeadId.value = payload.id;
} catch (e: any) {
notice.value = { type: 'danger', message: `Fehler beim Speichern: ${e.message}` };
} finally {
savingLead.value = false;
}
}
function openQuickAdd(type: string) {
notice.value = { type: 'info', message: `${type === 'company' ? 'Firma' : 'Person'} im jeweiligen Manager anlegen.` };
}
function getStatusIcon(status: string) {
switch(status) {
case 'new': return 'fiber_new';
case 'auditing': return 'hourglass_empty';
case 'audit_ready': return 'check_circle';
case 'contacted': return 'mail_outline';
default: return 'help_outline';
}
}
function getStatusColor(status: string) {
switch(status) {
case 'new': return 'var(--theme--primary)';
case 'auditing': return 'var(--theme--warning)';
case 'audit_ready': return 'var(--theme--success)';
case 'contacted': return 'var(--theme--secondary)';
default: return 'var(--theme--foreground-subdued)';
}
}
onMounted(async () => {
await fetchData();
if (route.query.create === 'true') {
openCreateDrawer();
}
});
</script>
<style scoped>
.url-link { color: inherit; text-decoration: none; border-bottom: 1px solid transparent; }
.url-link:hover { border-bottom-color: currentColor; }
.sections { display: flex; flex-direction: column; gap: 32px; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
.field { display: flex; flex-direction: column; gap: 8px; }
.field.full { grid-column: span 2; }
.label { font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px; }
.value { font-size: 15px; color: var(--theme--foreground); }
.text-block { line-height: 1.6; white-space: pre-wrap; background: var(--theme--background-subdued); padding: 16px; border-radius: 8px; }
.ai-observations { display: flex; flex-direction: column; gap: 16px; }
.section-title { font-size: 16px; font-weight: 700; color: var(--theme--foreground); margin-bottom: 8px; }
.metrics { display: flex; gap: 24px; margin-bottom: 16px; }
.observation-table { border: 1px solid var(--theme--border); border-radius: 8px; overflow: hidden; }
.page-title { font-weight: 600; }
.page-url { font-family: var(--family-monospace); font-size: 12px; color: var(--theme--foreground-subdued); }
.drawer-content { padding: 24px; display: flex; flex-direction: column; gap: 32px; }
.form-section { display: flex; flex-direction: column; gap: 20px; }
.drawer-actions { margin-top: 24px; display: flex; flex-direction: column; gap: 12px; }
</style>
-55
View File
@@ -1,55 +0,0 @@
import { build } from 'esbuild';
import { resolve, dirname } from 'path';
import { mkdirSync } from 'fs';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const entryPoint = resolve(__dirname, 'src/index.ts');
const outfile = resolve(__dirname, 'dist/index.js');
try {
mkdirSync(dirname(outfile), { recursive: true });
} catch {
// ignore
}
console.log(`Building from ${entryPoint} to ${outfile}...`);
build({
entryPoints: [entryPoint],
bundle: true,
platform: 'node',
target: 'node18',
outfile: outfile,
jsx: 'automatic',
format: 'esm',
// footer: {
// js: "module.exports = module.exports.default || module.exports;",
// },
loader: {
'.tsx': 'tsx',
'.ts': 'ts',
'.js': 'js',
},
external: ["canvas", "fs", "path", "os", "http", "https", "zlib", "stream", "util", "url", "net", "tls", "crypto"],
plugins: [{
name: 'mock-canvas',
setup(build) {
build.onResolve({ filter: /^canvas/ }, args => ({ path: args.path, namespace: 'mock-canvas' }));
build.onLoad({ filter: /.*/, namespace: 'mock-canvas' }, () => ({ contents: 'export default {};', loader: 'js' }));
}
}, {
name: 'mock-jsdom',
setup(build) {
build.onResolve({ filter: /^jsdom/ }, args => ({ path: args.path, namespace: 'mock-jsdom' }));
build.onLoad({ filter: /.*/, namespace: 'mock-jsdom' }, () => ({ contents: 'export default {};', loader: 'js' }));
}
}]
}).then(() => {
console.log("Build succeeded!");
}).catch((e) => {
console.error("Build failed:", e);
process.exit(1);
});
-31
View File
@@ -1,31 +0,0 @@
{
"name": "acquisition",
"version": "1.8.21",
"type": "module",
"directus:extension": {
"type": "endpoint",
"path": "dist/index.js",
"source": "src/index.ts",
"host": "^11.0.0"
},
"scripts": {
"build": "node build.mjs",
"dev": "node build.mjs --watch"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"@mintel/pdf": "workspace:*",
"@mintel/mail": "workspace:*",
"esbuild": "^0.25.0",
"typescript": "^5.6.3"
},
"dependencies": {
"jquery": "^3.7.1",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"repository": {
"type": "git",
"url": "https://git.infra.mintel.me/mmintel/at-mintel.git"
}
}
-236
View File
@@ -1,236 +0,0 @@
import { defineEndpoint } from "@directus/extensions-sdk";
import { AcquisitionService, PdfEngine } from "@mintel/pdf/server";
import {
render,
SiteAuditTemplate,
ProjectEstimateTemplate,
} from "@mintel/mail";
import { createElement } from "react";
import * as path from "path";
import * as fs from "fs";
export default defineEndpoint((router, { services, env }) => {
const { ItemsService, MailService } = services;
router.get("/ping", (req, res) => res.send("pong"));
router.post("/audit/:id", async (req: any, res: any) => {
const { id } = req.params;
const leadsService = new ItemsService("leads", {
schema: req.schema,
accountability: req.accountability,
});
try {
const lead = await leadsService.readOne(id);
if (!lead) return res.status(404).send({ error: "Lead not found" });
await leadsService.updateOne(id, { status: "auditing" });
const acqService = new AcquisitionService(env.OPENROUTER_API_KEY);
const result = await acqService.runFullSequence(
lead.website_url,
lead.briefing,
lead.comments,
);
await leadsService.updateOne(id, {
status: "audit_ready",
ai_state: result.state,
audit_context: JSON.stringify(result.usage),
});
res.send({ success: true, result });
} catch (error: any) {
console.error("Audit failed:", error);
await leadsService.updateOne(id, {
status: "new",
comments: `Audit failed: ${error.message}`,
});
res.status(500).send({ error: error.message });
}
});
router.post("/audit-email/:id", async (req: any, res: any) => {
const { id } = req.params;
const { ItemsService, MailService } = services;
const leadsService = new ItemsService("leads", {
schema: req.schema,
accountability: req.accountability,
});
const _peopleService = new ItemsService("people", {
schema: req.schema,
accountability: req.accountability,
});
const companiesService = new ItemsService("companies", {
schema: req.schema,
accountability: req.accountability,
});
const mailService = new MailService({
schema: req.schema,
accountability: req.accountability,
});
try {
const lead = await leadsService.readOne(id, {
fields: ["*", "company.*", "contact_person.*"],
});
if (!lead || !lead.ai_state)
return res.status(400).send({ error: "Lead or Audit not ready" });
let recipientEmail = lead.contact_email;
let companyName = lead.company?.name || lead.company_name;
if (lead.contact_person) {
recipientEmail = lead.contact_person.email || recipientEmail;
if (lead.contact_person.company) {
const personCompany = await companiesService.readOne(
lead.contact_person.company,
);
companyName = personCompany?.name || companyName;
}
}
if (!recipientEmail)
return res.status(400).send({ error: "No recipient email found" });
const auditHighlights = [
`Projekt-Typ: ${lead.ai_state.projectType === "website" ? "Website" : "Web App"}`,
...(lead.ai_state.sitemap || [])
.slice(0, 3)
.map((item: any) => `Potenzial in: ${item.category}`),
];
const html = await render(
createElement(SiteAuditTemplate, {
companyName: companyName,
websiteUrl: lead.website_url,
auditHighlights,
}),
);
await mailService.send({
to: recipientEmail,
subject: `Analyse Ihrer Webpräsenz: ${companyName}`,
html,
});
await leadsService.updateOne(id, {
status: "contacted",
last_contacted_at: new Date().toISOString(),
});
res.send({ success: true });
} catch (error: any) {
console.error("Audit Email failed:", error);
res.status(500).send({ error: error.message });
}
});
router.post("/estimate/:id", async (req: any, res: any) => {
const { id } = req.params;
const leadsService = new ItemsService("leads", {
schema: req.schema,
accountability: req.accountability,
});
try {
const lead = await leadsService.readOne(id);
if (!lead || !lead.ai_state)
return res.status(400).send({ error: "Lead or AI state not found" });
const pdfEngine = new PdfEngine();
const filename = `estimate_${id}_${Date.now()}.pdf`;
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
const outputPath = path.join(storageRoot, filename);
await pdfEngine.generateEstimatePdf(lead.ai_state, outputPath);
await leadsService.updateOne(id, {
audit_pdf_path: filename,
});
res.send({ success: true, filename });
} catch (error: any) {
console.error("PDF Generation failed:", error);
res.status(500).send({ error: error.message });
}
});
router.post("/estimate-email/:id", async (req: any, res: any) => {
const { id } = req.params;
const leadsService = new ItemsService("leads", {
schema: req.schema,
accountability: req.accountability,
});
const _peopleService = new ItemsService("people", {
schema: req.schema,
accountability: req.accountability,
});
const companiesService = new ItemsService("companies", {
schema: req.schema,
accountability: req.accountability,
});
const mailService = new MailService({
schema: req.schema,
accountability: req.accountability,
});
try {
const lead = await leadsService.readOne(id, {
fields: ["*", "company.*", "contact_person.*"],
});
if (!lead || !lead.audit_pdf_path)
return res.status(400).send({ error: "PDF not generated" });
let recipientEmail = lead.contact_email;
let companyName = lead.company?.name || lead.company_name;
if (lead.contact_person) {
recipientEmail = lead.contact_person.email || recipientEmail;
if (lead.contact_person.company) {
const personCompany = await companiesService.readOne(
lead.contact_person.company,
);
companyName = personCompany?.name || companyName;
}
}
if (!recipientEmail)
return res.status(400).send({ error: "No recipient email found" });
const html = await render(
createElement(ProjectEstimateTemplate, {
companyName: companyName,
}),
);
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
const attachmentPath = path.join(storageRoot, lead.audit_pdf_path);
await mailService.send({
to: recipientEmail,
subject: `Ihre Projekt-Schätzung: ${companyName}`,
html,
attachments: [
{
filename: `Angebot_${companyName}.pdf`,
content: fs.readFileSync(attachmentPath),
},
],
});
await leadsService.updateOne(id, {
status: "contacted",
last_contacted_at: new Date().toISOString(),
});
res.send({ success: true });
} catch (error: any) {
console.error("Estimate Email failed:", error);
res.status(500).send({ error: error.message });
}
});
});
+1 -167
View File
@@ -36,153 +36,15 @@ program
console.log(
chalk.yellow(`
📱 App: http://localhost:3000
🗄️ CMS: http://localhost:8055/admin
🚦 Traefik: http://localhost:8080
`),
);
execSync(
"docker compose down --remove-orphans && docker compose up -d app directus at-mintel-directus-db",
"docker compose down --remove-orphans && docker compose up -d app",
{ stdio: "inherit" },
);
});
const directus = program
.command("directus")
.description("Directus management commands");
directus
.command("bootstrap")
.description("Setup Directus branding and settings")
.action(async () => {
const { execSync } = await import("child_process");
console.log(chalk.blue("🎨 Bootstrapping Directus..."));
execSync("npx tsx --env-file=.env scripts/setup-directus.ts", {
stdio: "inherit",
});
});
directus
.command("bootstrap-feedback")
.description("Setup Directus collections and flows for Feedback")
.action(async () => {
const { execSync } = await import("child_process");
console.log(chalk.blue("📧 Bootstrapping Visual Feedback System..."));
// Use the logic from setup-feedback-hardened.ts
const bootstrapScript = `
import { createDirectus, rest, authentication, createCollection, createDashboard, createPanel, createItems, createPermission, readPolicies, readRoles, readUsers } from '@directus/sdk';
async function setup() {
const url = process.env.DIRECTUS_URL || 'http://localhost:8055';
const email = process.env.DIRECTUS_ADMIN_EMAIL;
const password = process.env.DIRECTUS_ADMIN_PASSWORD;
if (!email || !password) {
console.error('❌ DIRECTUS_ADMIN_EMAIL or DIRECTUS_ADMIN_PASSWORD not set');
process.exit(1);
}
const client = createDirectus(url).with(authentication('json')).with(rest());
try {
console.log('🔑 Authenticating...');
await client.login(email, password);
const roles = await client.request(readRoles());
const adminRole = roles.find(r => r.name === 'Administrator');
const policies = await client.request(readPolicies());
const adminPolicy = policies.find(p => p.name === 'Administrator');
console.log('🏗️ Creating Collection "visual_feedback"...');
try {
await client.request(createCollection({
collection: 'visual_feedback',
meta: { icon: 'feedback', display_template: '{{user_name}}: {{text}}' },
fields: [
{ field: 'id', type: 'uuid', schema: { is_primary_key: true } },
{ field: 'status', type: 'string', schema: { default_value: 'open' }, meta: { interface: 'select-dropdown' } },
{ field: 'url', type: 'string' },
{ field: 'selector', type: 'string' },
{ field: 'x', type: 'float' },
{ field: 'y', type: 'float' },
{ field: 'type', type: 'string' },
{ field: 'text', type: 'text' },
{ field: 'user_name', type: 'string' },
{ field: 'user_identity', type: 'string' },
{ field: 'screenshot', type: 'uuid', meta: { interface: 'file' } },
{ field: 'date_created', type: 'timestamp', schema: { default_value: 'NOW()' } }
]
} as any));
} catch (_e) { console.log(' (Collection might already exist)'); }
try {
await client.request(createCollection({
collection: 'visual_feedback_comments',
meta: { icon: 'comment' },
fields: [
{ field: 'id', type: 'integer', schema: { is_primary_key: true, has_auto_increment: true } },
{ field: 'feedback_id', type: 'uuid', meta: { interface: 'select-dropdown' } },
{ field: 'user_name', type: 'string' },
{ field: 'text', type: 'text' },
{ field: 'date_created', type: 'timestamp', schema: { default_value: 'NOW()' } }
]
} as any));
} catch (e) { }
if (adminPolicy) {
console.log('🔐 Granting ALL permissions to Administrator Policy...');
for (const coll of ['visual_feedback', 'visual_feedback_comments']) {
for (const action of ['create', 'read', 'update', 'delete']) {
try {
await client.request(createPermission({
collection: coll,
action,
fields: ['*'],
policy: adminPolicy.id
} as any));
} catch (_e) { }
}
}
}
console.log('📊 Creating Dashboard...');
try {
const dash = await client.request(createDashboard({ name: 'Visual Feedback', icon: 'feedback', color: '#6366f1' }));
await client.request(createPanel({
dashboard: dash.id,
name: 'Total Feedbacks',
type: 'metric',
width: 12, height: 6, position_x: 1, position_y: 1,
options: { collection: 'visual_feedback', function: 'count', field: 'id' }
} as any));
} catch (e) { }
console.log('✨ FEEDBACK BOOTSTRAP DONE.');
} catch (e) { console.error('❌ FAILURE:', e); }
}
setup();
`;
const tempFile = path.join(process.cwd(), "temp-bootstrap-feedback.ts");
await fs.writeFile(tempFile, bootstrapScript);
try {
execSync("npx tsx --env-file=.env " + tempFile, { stdio: "inherit" });
} finally {
await fs.remove(tempFile);
}
});
directus
.command("sync <action> <env>")
.description("Sync Directus data (push/pull) for a specific environment")
.action(async (action, env) => {
const { execSync } = await import("child_process");
console.log(
chalk.blue(`📥 Executing Directus sync: ${action} -> ${env}...`),
);
execSync(`./scripts/sync-directus.sh ${action} ${env}`, {
stdio: "inherit",
});
});
program
.command("pagespeed")
.description("Run PageSpeed (Lighthouse) tests")
@@ -221,13 +83,6 @@ program
lint: "next lint",
typecheck: "tsc --noEmit",
test: "vitest run --passWithNoTests",
"directus:bootstrap": "mintel directus bootstrap",
"directus:push:testing": "mintel directus sync push testing",
"directus:pull:testing": "mintel directus sync pull testing",
"directus:push:staging": "mintel directus sync push staging",
"directus:pull:staging": "mintel directus sync pull staging",
"directus:push:prod": "mintel directus sync push production",
"directus:pull:prod": "mintel directus sync pull production",
"pagespeed:test": "mintel pagespeed",
},
dependencies: {
@@ -236,7 +91,6 @@ program
"react-dom": "^19.0.0",
"@mintel/next-utils": "workspace:*",
"@mintel/next-observability": "workspace:*",
"@directus/sdk": "^21.0.0",
},
devDependencies: {
"@types/node": "^20.0.0",
@@ -473,15 +327,6 @@ export default function Home() {
}
}
// Create Directus structure
await fs.ensureDir(path.join(fullPath, "directus/uploads"));
await fs.ensureDir(path.join(fullPath, "directus/extensions"));
await fs.writeFile(path.join(fullPath, "directus/uploads/.gitkeep"), "");
await fs.writeFile(
path.join(fullPath, "directus/extensions/.gitkeep"),
"",
);
// Create .env.example
const envExample = `# Project
PROJECT_NAME=${projectName}
@@ -493,21 +338,10 @@ AUTH_COOKIE_NAME=mintel_gatekeeper_session
# Host Config (Local)
TRAEFIK_HOST=\`${projectName}.localhost\`
DIRECTUS_HOST=\`cms.${projectName}.localhost\`
# Next.js
NEXT_PUBLIC_BASE_URL=http://${projectName}.localhost
# Directus
DIRECTUS_URL=http://cms.${projectName}.localhost
DIRECTUS_KEY=$(openssl rand -hex 32 2>/dev/null || echo "mintel-key")
DIRECTUS_SECRET=$(openssl rand -hex 32 2>/dev/null || echo "mintel-secret")
DIRECTUS_ADMIN_EMAIL=admin@mintel.me
DIRECTUS_ADMIN_PASSWORD=mintel-admin-pass
DIRECTUS_DB_NAME=directus
DIRECTUS_DB_USER=directus
DIRECTUS_DB_PASSWORD=mintel-db-pass
# Sentry / Glitchtip
SENTRY_DSN=
-11
View File
@@ -1,11 +0,0 @@
FROM directus/directus:11
USER root
# Install dependencies in a way that avoids metadata conflicts in the root
RUN mkdir -p /directus/lib-dependencies && \
cd /directus/lib-dependencies && \
npm init -y && \
npm install vue @vueuse/core vue-router
# Ensure they are in the NODE_PATH
ENV NODE_PATH="/directus/lib-dependencies/node_modules:${NODE_PATH}"
USER node
Binary file not shown.
-53
View File
@@ -1,53 +0,0 @@
services:
infra-cms:
image: directus/directus:11.15.2
ports:
- "8059:8055"
networks:
- default
- infra
environment:
KEY: "infra-cms-key"
SECRET: "infra-cms-secret"
ADMIN_EMAIL: "marc@mintel.me"
ADMIN_PASSWORD: "Tim300493."
DB_CLIENT: "sqlite3"
DB_FILENAME: "/directus/database/data.db"
WEBSOCKETS_ENABLED: "true"
PUBLIC_URL: "http://cms.localhost"
EMAIL_TRANSPORT: "smtp"
EMAIL_SMTP_HOST: "smtp.eu.mailgun.org"
EMAIL_SMTP_PORT: "587"
EMAIL_SMTP_USER: "postmaster@mg.mintel.me"
EMAIL_SMTP_PASSWORD: "4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6"
EMAIL_SMTP_SECURE: "false"
EMAIL_FROM: "postmaster@mg.mintel.me"
LOG_LEVEL: "debug"
SERVE_APP: "true"
EXTENSIONS_AUTO_RELOAD: "true"
EXTENSIONS_SANDBOX: "false"
CONTENT_SECURITY_POLICY: "false"
volumes:
- ./database:/directus/database
- ./uploads:/directus/uploads
- ./schema:/directus/schema
- ./extensions:/directus/extensions
healthcheck:
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8055/server/health" ]
interval: 10s
timeout: 5s
retries: 5
labels:
traefik.enable: "true"
traefik.http.routers.at-mintel-infra-cms.rule: "Host(`cms.localhost`)"
traefik.docker.network: "infra"
caddy: "http://cms.localhost"
caddy.reverse_proxy: "{{upstreams 8055}}"
caddy.header.Cache-Control: "no-store, no-cache, must-revalidate, max-age=0"
networks:
default:
name: at-mintel-cms-network
infra:
external: true
-18
View File
@@ -1,18 +0,0 @@
{
"name": "@mintel/cms-infra",
"version": "1.8.21",
"private": true,
"type": "module",
"scripts": {
"dev": "npm run up -- --link",
"up": "../../scripts/cms-up.sh",
"down": "docker compose down",
"logs": "docker compose logs -f",
"build:extensions": "../../scripts/sync-extensions.sh",
"schema:apply:local": "../../scripts/cms-apply.sh local",
"schema:apply:infra": "../../scripts/cms-apply.sh infra",
"snapshot:local": "../../scripts/cms-snapshot.sh",
"sync:push": "../../scripts/sync-directus.sh push infra",
"sync:pull": "../../scripts/sync-directus.sh pull infra"
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-34
View File
@@ -1,34 +0,0 @@
{
"name": "company-manager",
"description": "Custom High-Fidelity Management for Directus",
"icon": "extension",
"version": "1.8.21",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"directus:extension": {
"type": "module",
"path": "dist/index.js",
"source": "src/index.ts",
"host": "app",
"name": "company manager"
},
"scripts": {
"build": "directus-extension build",
"dev": "directus-extension build -w"
},
"dependencies": {
"@mintel/directus-extension-toolkit": "workspace:*"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.0"
},
"repository": {
"type": "git",
"url": "https://git.infra.mintel.me/mmintel/at-mintel.git"
}
}
-14
View File
@@ -1,14 +0,0 @@
import { defineModule } from '@directus/extensions-sdk';
import ModuleComponent from './module.vue';
export default defineModule({
id: 'company-manager',
name: 'Company Manager',
icon: 'business',
routes: [
{
path: '',
component: ModuleComponent,
},
],
});
-224
View File
@@ -1,224 +0,0 @@
<template>
<MintelManagerLayout
title="Company Manager"
:item-title="selectedCompany?.name || 'Firma wählen'"
:is-empty="!selectedCompany"
empty-title="Firma auswählen"
empty-icon="business"
:notice="feedback"
@close-notice="feedback = null"
>
<template #navigation>
<v-list nav>
<v-list-item @click="openCreateDrawer" clickable>
<v-list-item-icon>
<v-icon name="add" color="var(--theme--primary)" />
</v-list-item-icon>
<v-list-item-content>
<v-text-overflow text="Neue Firma anlegen" />
</v-list-item-content>
</v-list-item>
<v-divider />
<v-list-item
v-for="company in companies"
:key="company.id"
:active="selectedCompany?.id === company.id"
class="nav-item"
clickable
@click="selectCompany(company)"
>
<v-list-item-icon>
<v-icon name="business" />
</v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="company.name" />
</v-list-item-content>
</v-list-item>
</v-list>
</template>
<template #subtitle>
<template v-if="selectedCompany">
{{ selectedCompany.domain || 'Keine Domain angegeben' }}
</template>
</template>
<template #actions>
<v-button secondary rounded icon v-tooltip.bottom="'Firma bearbeiten'" @click="openEditDrawer">
<v-icon name="edit" />
</v-button>
<v-button danger rounded icon v-tooltip.bottom="'Firma löschen'" @click="deleteCompany">
<v-icon name="delete" />
</v-button>
</template>
<template #empty-state>
Wähle eine Firma in der Navigation aus oder
<v-button x-small @click="openCreateDrawer">erstelle eine neue Firma</v-button>.
</template>
<div v-if="selectedCompany" class="details-grid">
<div class="detail-item full">
<span class="label">Notizen / Adresse</span>
<p class="value">{{ selectedCompany.notes || '---' }}</p>
</div>
</div>
<!-- Create/Edit Drawer -->
<v-drawer
v-model="drawerActive"
:title="isEditing ? 'Firma bearbeiten' : 'Neue Firma anlegen'"
icon="business"
@cancel="drawerActive = false"
>
<template #default>
<div class="drawer-content">
<div class="form-section">
<div class="field">
<span class="label">Firmenname</span>
<v-input v-model="form.name" placeholder="z.B. Schmidt GmbH" autofocus />
</div>
<div class="field">
<span class="label">Domain / Website</span>
<v-input v-model="form.domain" placeholder="example.com" />
</div>
<div class="field">
<span class="label">Notizen / Adresse</span>
<v-textarea v-model="form.notes" placeholder="z.B. Branche, Adresse, etc." />
</div>
</div>
<div class="drawer-actions">
<v-button primary block :loading="saving" @click="saveCompany">
Firma speichern
</v-button>
</div>
</div>
</template>
</v-drawer>
</MintelManagerLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useApi } from '@directus/extensions-sdk';
import { useRoute } from 'vue-router';
import { MintelManagerLayout } from '@mintel/directus-extension-toolkit';
const api = useApi();
const route = useRoute();
const companies = ref([]);
const selectedCompany = ref(null);
const feedback = ref(null);
const saving = ref(false);
const drawerActive = ref(false);
const isEditing = ref(false);
const form = ref({
id: null,
name: '',
domain: '',
notes: ''
});
async function fetchData() {
try {
const resp = await api.get('/items/companies', {
params: { sort: 'name' }
});
companies.value = resp.data.data;
} catch (error) {
console.error('Failed to fetch companies:', error);
}
}
function selectCompany(company: any) {
selectedCompany.value = company;
}
function openCreateDrawer() {
isEditing.value = false;
form.value = {
id: null,
name: '',
domain: '',
notes: ''
};
drawerActive.value = true;
}
function openEditDrawer() {
isEditing.value = true;
form.value = {
id: selectedCompany.value.id,
name: selectedCompany.value.name,
domain: selectedCompany.value.domain,
notes: selectedCompany.value.notes
};
drawerActive.value = true;
}
async function saveCompany() {
if (!form.value.name) {
feedback.value = { type: 'danger', message: 'Firmenname ist erforderlich.' };
return;
}
saving.value = true;
try {
let updatedItem;
if (isEditing.value) {
const res = await api.patch(`/items/companies/${form.value.id}`, form.value);
updatedItem = res.data.data;
feedback.value = { type: 'success', message: 'Firma aktualisiert!' };
} else {
const res = await api.post('/items/companies', form.value);
updatedItem = res.data.data;
feedback.value = { type: 'success', message: 'Firma angelegt!' };
}
drawerActive.value = false;
await fetchData();
if (updatedItem) {
selectedCompany.value = companies.value.find(c => c.id === updatedItem.id) || updatedItem;
}
} catch (error) {
feedback.value = { type: 'danger', message: error.message };
} finally {
saving.value = false;
}
}
async function deleteCompany() {
if (!confirm('Soll diese Firma wirklich gelöscht werden?')) return;
try {
await api.delete(`/items/companies/${selectedCompany.value.id}`);
feedback.value = { type: 'success', message: 'Firma gelöscht.' };
selectedCompany.value = null;
await fetchData();
} catch (error) {
feedback.value = { type: 'danger', message: error.message };
}
}
onMounted(async () => {
await fetchData();
if (route.query.create === 'true') {
openCreateDrawer();
}
});
</script>
<style scoped>
.details-grid { display: flex; flex-direction: column; gap: 24px; }
.detail-item { display: flex; flex-direction: column; gap: 8px; }
.detail-item.full { width: 100%; }
.label { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px; }
.value { font-size: 16px; font-weight: 500; }
.drawer-content { padding: 24px; display: flex; flex-direction: column; gap: 32px; }
.form-section { display: flex; flex-direction: column; gap: 20px; }
.field { display: flex; flex-direction: column; gap: 8px; }
.drawer-actions { margin-top: 24px; }
</style>
+26 -27
View File
@@ -1,40 +1,39 @@
import { config as dotenvConfig } from 'dotenv';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import { EstimationPipeline } from './pipeline.js';
import { config as dotenvConfig } from "dotenv";
import * as path from "node:path";
import * as fs from "node:fs/promises";
import { ConceptPipeline } from "./pipeline.js";
dotenvConfig({ path: path.resolve(process.cwd(), '../../.env') });
dotenvConfig({ path: path.resolve(process.cwd(), "../../.env") });
const briefing = await fs.readFile(
path.resolve(process.cwd(), '../../data/briefings/etib.txt'),
'utf8',
path.resolve(process.cwd(), "../../data/briefings/etib.txt"),
"utf8",
);
console.log(`Briefing loaded: ${briefing.length} chars`);
const pipeline = new EstimationPipeline(
{
openrouterKey: process.env.OPENROUTER_API_KEY || '',
zyteApiKey: process.env.ZYTE_API_KEY,
outputDir: path.resolve(process.cwd(), '../../out/estimations'),
crawlDir: path.resolve(process.cwd(), '../../data/crawls'),
},
{
onStepStart: (id, name) => console.log(`[CB] Starting: ${id}`),
onStepComplete: (id) => console.log(`[CB] Done: ${id}`),
onStepError: (id, err) => console.error(`[CB] Error in ${id}: ${err}`),
},
const pipeline = new ConceptPipeline(
{
openrouterKey: process.env.OPENROUTER_API_KEY || "",
zyteApiKey: process.env.ZYTE_API_KEY,
outputDir: path.resolve(process.cwd(), "../../out/estimations"),
crawlDir: path.resolve(process.cwd(), "../../data/crawls"),
},
{
onStepStart: (id, _name) => console.log(`[CB] Starting: ${id}`),
onStepComplete: (id) => console.log(`[CB] Done: ${id}`),
onStepError: (id, err) => console.error(`[CB] Error in ${id}: ${err}`),
},
);
try {
const result = await pipeline.run({
briefing,
url: 'https://www.e-tib.com',
});
await pipeline.run({
briefing,
url: "https://www.e-tib.com",
});
console.log('\n✨ Pipeline complete!');
console.log('Validation:', result.validationResult?.passed ? 'PASSED' : 'FAILED');
console.log("\n✨ Pipeline complete!");
} catch (err: any) {
console.error('\n❌ Pipeline failed:', err.message);
console.error(err.stack);
console.error("\n❌ Pipeline failed:", err.message);
console.error(err.stack);
}
+127 -113
View File
@@ -18,132 +18,146 @@ dotenvConfig({ path: path.resolve(process.cwd(), ".env") });
const program = new Command();
program
.name("concept")
.description("AI-powered project concept generator")
.version("1.0.0");
.name("concept")
.description("AI-powered project concept generator")
.version("1.0.0");
program
.command("run")
.description("Run the full concept pipeline")
.argument("[briefing]", "Briefing text or @path/to/file.txt")
.option("--url <url>", "Target website URL")
.option("--comments <comments>", "Additional notes")
.option("--clear-cache", "Clear crawl cache and re-crawl")
.option("--output <dir>", "Output directory", "../../out/concepts")
.option("--crawl-dir <dir>", "Crawl data directory", "../../data/crawls")
.action(async (briefingArg: string | undefined, options: any) => {
const openrouterKey = process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
if (!openrouterKey) {
console.error("❌ OPENROUTER_API_KEY not found in environment.");
process.exit(1);
}
.command("run")
.description("Run the full concept pipeline")
.argument("[briefing]", "Briefing text or @path/to/file.txt")
.option("--url <url>", "Target website URL")
.option("--comments <comments>", "Additional notes")
.option("--clear-cache", "Clear crawl cache and re-crawl")
.option("--output <dir>", "Output directory", "../../out/concepts")
.option("--crawl-dir <dir>", "Crawl data directory", "../../data/crawls")
.action(async (briefingArg: string | undefined, options: any) => {
const openrouterKey =
process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
if (!openrouterKey) {
console.error("❌ OPENROUTER_API_KEY not found in environment.");
process.exit(1);
}
let briefing = briefingArg || "";
let briefing = briefingArg || "";
// Handle @file references
if (briefing.startsWith("@")) {
const rawPath = briefing.substring(1);
const filePath = rawPath.startsWith("/")
? rawPath
: path.resolve(process.cwd(), rawPath);
if (!existsSync(filePath)) {
console.error(`❌ Briefing file not found: ${filePath}`);
process.exit(1);
}
briefing = await fs.readFile(filePath, "utf8");
console.log(`📄 Loaded briefing from: ${filePath}`);
}
// Handle @file references
if (briefing.startsWith("@")) {
const rawPath = briefing.substring(1);
const filePath = rawPath.startsWith("/")
? rawPath
: path.resolve(process.cwd(), rawPath);
if (!existsSync(filePath)) {
console.error(`❌ Briefing file not found: ${filePath}`);
process.exit(1);
}
briefing = await fs.readFile(filePath, "utf8");
console.log(`📄 Loaded briefing from: ${filePath}`);
}
// Auto-discover URL from briefing
let url = options.url;
if (!url && briefing) {
const urlMatch = briefing.match(/https?:\/\/[^\s]+/);
if (urlMatch) {
url = urlMatch[0];
console.log(`🔗 Discovered URL in briefing: ${url}`);
}
}
// Auto-discover URL from briefing
let url = options.url;
if (!url && briefing) {
const urlMatch = briefing.match(/https?:\/\/[^\s]+/);
if (urlMatch) {
url = urlMatch[0];
console.log(`🔗 Discovered URL in briefing: ${url}`);
}
}
if (!briefing && !url) {
console.error("❌ Provide a briefing text or --url");
process.exit(1);
}
if (!briefing && !url) {
console.error("❌ Provide a briefing text or --url");
process.exit(1);
}
const pipeline = new ConceptPipeline(
{
openrouterKey,
zyteApiKey: process.env.ZYTE_API_KEY,
outputDir: path.resolve(process.cwd(), options.output),
crawlDir: path.resolve(process.cwd(), options.crawlDir),
},
{
onStepStart: (id, name) => {
// Will be enhanced with Ink spinner later
},
onStepComplete: (id, result) => {
// Will be enhanced with Ink UI later
},
},
);
const pipeline = new ConceptPipeline(
{
openrouterKey,
zyteApiKey: process.env.ZYTE_API_KEY,
outputDir: path.resolve(process.cwd(), options.output),
crawlDir: path.resolve(process.cwd(), options.crawlDir),
},
{
onStepStart: (_id, _name) => {
// Will be enhanced with Ink spinner later
},
onStepComplete: (_id, _result) => {
// Will be enhanced with Ink UI later
},
},
);
try {
await pipeline.run({
briefing,
url,
comments: options.comments,
clearCache: options.clearCache,
});
try {
await pipeline.run({
briefing,
url,
comments: options.comments,
clearCache: options.clearCache,
});
console.log("\n✨ Concept generation complete!");
} catch (err) {
console.error(`\n❌ Pipeline failed: ${(err as Error).message}`);
process.exit(1);
}
});
console.log("\n✨ Concept generation complete!");
} catch (err) {
console.error(`\n❌ Pipeline failed: ${(err as Error).message}`);
process.exit(1);
}
});
program
.command("analyze")
.description("Only crawl and analyze a website (no LLM)")
.argument("<url>", "Website URL to analyze")
.option("--crawl-dir <dir>", "Crawl data directory", "../../data/crawls")
.option("--clear-cache", "Clear existing crawl cache")
.action(async (url: string, options: any) => {
const { crawlSite } = await import("./scraper.js");
const { analyzeSite } = await import("./analyzer.js");
.command("analyze")
.description("Only crawl and analyze a website (no LLM)")
.argument("<url>", "Website URL to analyze")
.option("--crawl-dir <dir>", "Crawl data directory", "../../data/crawls")
.option("--clear-cache", "Clear existing crawl cache")
.action(async (url: string, options: any) => {
const { crawlSite } = await import("./scraper.js");
const { analyzeSite } = await import("./analyzer.js");
if (options.clearCache) {
const { clearCrawlCache } = await import("./scraper.js");
const domain = new URL(url).hostname;
await clearCrawlCache(path.resolve(process.cwd(), options.crawlDir), domain);
}
if (options.clearCache) {
const { clearCrawlCache } = await import("./scraper.js");
const domain = new URL(url).hostname;
await clearCrawlCache(
path.resolve(process.cwd(), options.crawlDir),
domain,
);
}
const pages = await crawlSite(url, {
zyteApiKey: process.env.ZYTE_API_KEY,
crawlDir: path.resolve(process.cwd(), options.crawlDir),
});
const domain = new URL(url).hostname;
const profile = analyzeSite(pages, domain);
console.log("\n📊 Site Profile:");
console.log(` Domain: ${profile.domain}`);
console.log(` Total Pages: ${profile.totalPages}`);
console.log(` Navigation: ${profile.navigation.map((n) => n.label).join(", ")}`);
console.log(` Features: ${profile.existingFeatures.join(", ") || "none"}`);
console.log(` Services: ${profile.services.join(", ") || "none"}`);
console.log(` External Domains: ${profile.externalDomains.join(", ") || "none"}`);
console.log(` Company: ${profile.companyInfo.name || "unbekannt"}`);
console.log(` Tax ID: ${profile.companyInfo.taxId || "unbekannt"}`);
console.log(` Colors: ${profile.colors.join(", ")}`);
console.log(` Images Found: ${profile.images.length}`);
console.log(` Social: ${Object.entries(profile.socialLinks).map(([k, v]) => `${k}`).join(", ") || "none"}`);
const outputPath = path.join(
path.resolve(process.cwd(), options.crawlDir),
domain.replace(/\./g, "-"),
"_site_profile.json",
);
console.log(`\n📦 Full profile saved to: ${outputPath}`);
const pages = await crawlSite(url, {
zyteApiKey: process.env.ZYTE_API_KEY,
crawlDir: path.resolve(process.cwd(), options.crawlDir),
});
const domain = new URL(url).hostname;
const profile = analyzeSite(pages, domain);
console.log("\n📊 Site Profile:");
console.log(` Domain: ${profile.domain}`);
console.log(` Total Pages: ${profile.totalPages}`);
console.log(
` Navigation: ${profile.navigation.map((n) => n.label).join(", ")}`,
);
console.log(` Features: ${profile.existingFeatures.join(", ") || "none"}`);
console.log(` Services: ${profile.services.join(", ") || "none"}`);
console.log(
` External Domains: ${profile.externalDomains.join(", ") || "none"}`,
);
console.log(` Company: ${profile.companyInfo.name || "unbekannt"}`);
console.log(` Tax ID: ${profile.companyInfo.taxId || "unbekannt"}`);
console.log(` Colors: ${profile.colors.join(", ")}`);
console.log(` Images Found: ${profile.images.length}`);
console.log(
` Social: ${
Object.entries(profile.socialLinks)
.map(([_k, _v]) => `${_k}`)
.join(", ") || "none"
}`,
);
const outputPath = path.join(
path.resolve(process.cwd(), options.crawlDir),
domain.replace(/\./g, "-"),
"_site_profile.json",
);
console.log(`\n📦 Full profile saved to: ${outputPath}`);
});
program.parse();
@@ -0,0 +1,7 @@
import { describe, it, expect } from "vitest";
describe("concept-engine", () => {
it("should pass", () => {
expect(true).toBe(true);
});
});
+96 -87
View File
@@ -5,20 +5,20 @@
import axios from "axios";
interface LLMRequestOptions {
model: string;
systemPrompt: string;
userPrompt: string;
jsonMode?: boolean;
apiKey: string;
model: string;
systemPrompt: string;
userPrompt: string;
jsonMode?: boolean;
apiKey: string;
}
interface LLMResponse {
content: string;
usage: {
promptTokens: number;
completionTokens: number;
cost: number;
};
content: string;
usage: {
promptTokens: number;
completionTokens: number;
cost: number;
};
}
/**
@@ -26,108 +26,117 @@ interface LLMResponse {
* Handles markdown fences, control chars, trailing commas.
*/
export function cleanJson(str: string): string {
let cleaned = str.replace(/```json\n?|```/g, "").trim();
cleaned = cleaned.replace(
/[\u0000-\u0009\u000B\u000C\u000E-\u001F\u007F-\u009F]/g,
" ",
);
cleaned = cleaned.replace(/,\s*([\]}])/g, "$1");
return cleaned;
let cleaned = str.replace(/```json\n?|```/g, "").trim();
// eslint-disable-next-line no-control-regex
cleaned = cleaned.replace(/[\x00-\x1f\x7f-\x9f]/gi, " ");
cleaned = cleaned.replace(/,\s*([\]}])/g, "$1");
return cleaned;
}
/**
* Send a request to an LLM via OpenRouter.
*/
export async function llmRequest(options: LLMRequestOptions): Promise<LLMResponse> {
const { model, systemPrompt, userPrompt, jsonMode = true, apiKey } = options;
export async function llmRequest(
options: LLMRequestOptions,
): Promise<LLMResponse> {
const { model, systemPrompt, userPrompt, jsonMode = true, apiKey } = options;
const startTime = Date.now();
const resp = await axios.post(
"https://openrouter.ai/api/v1/chat/completions",
{
model,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
...(jsonMode ? { response_format: { type: "json_object" } } : {}),
const resp = await axios
.post(
"https://openrouter.ai/api/v1/chat/completions",
{
model,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
...(jsonMode ? { response_format: { type: "json_object" } } : {}),
},
{
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
{
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
timeout: 120000,
},
).catch(err => {
if (err.response) {
console.error("OpenRouter API Error:", JSON.stringify(err.response.data, null, 2));
}
throw err;
timeout: 120000,
},
)
.catch((err) => {
if (err.response) {
console.error(
"OpenRouter API Error:",
JSON.stringify(err.response.data, null, 2),
);
}
throw err;
});
const content = resp.data.choices?.[0]?.message?.content;
if (!content) {
throw new Error(`LLM returned no content. Model: ${model}`);
}
const content = resp.data.choices?.[0]?.message?.content;
if (!content) {
throw new Error(`LLM returned no content. Model: ${model}`);
}
let cost = 0;
const usage = resp.data.usage || {};
if (usage.cost !== undefined) {
cost = usage.cost;
} else {
// Fallback estimation
cost =
(usage.prompt_tokens || 0) * (0.1 / 1_000_000) +
(usage.completion_tokens || 0) * (0.4 / 1_000_000);
}
let cost = 0;
const usage = resp.data.usage || {};
if (usage.cost !== undefined) {
cost = usage.cost;
} else {
// Fallback estimation
cost =
(usage.prompt_tokens || 0) * (0.1 / 1_000_000) +
(usage.completion_tokens || 0) * (0.4 / 1_000_000);
}
return {
content,
usage: {
promptTokens: usage.prompt_tokens || 0,
completionTokens: usage.completion_tokens || 0,
cost,
},
};
return {
content,
usage: {
promptTokens: usage.prompt_tokens || 0,
completionTokens: usage.completion_tokens || 0,
cost,
},
};
}
/**
* Send a request and parse the response as JSON.
*/
export async function llmJsonRequest<T = any>(
options: LLMRequestOptions,
options: LLMRequestOptions,
): Promise<{ data: T; usage: LLMResponse["usage"] }> {
const response = await llmRequest({ ...options, jsonMode: true });
const cleaned = cleanJson(response.content);
const response = await llmRequest({ ...options, jsonMode: true });
const cleaned = cleanJson(response.content);
let parsed: T;
try {
parsed = JSON.parse(cleaned);
} catch (e) {
throw new Error(
`Failed to parse LLM JSON response: ${(e as Error).message}\nRaw: ${cleaned.substring(0, 500)}`,
);
}
let parsed: T;
try {
parsed = JSON.parse(cleaned);
} catch (e) {
throw new Error(
`Failed to parse LLM JSON response: ${(e as Error).message}\nRaw: ${cleaned.substring(0, 500)}`,
);
}
// Unwrap common LLM artifacts: {"0": {...}}, {"state": {...}}, etc.
const unwrapped = unwrapResponse(parsed);
// Unwrap common LLM artifacts: {"0": {...}}, {"state": {...}}, etc.
const unwrapped = unwrapResponse(parsed);
return { data: unwrapped as T, usage: response.usage };
return { data: unwrapped as T, usage: response.usage };
}
/**
* Recursively unwrap common LLM wrapping patterns.
*/
function unwrapResponse(obj: any): any {
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return obj;
const keys = Object.keys(obj);
if (keys.length === 1) {
const key = keys[0];
if (key === "0" || key === "state" || key === "facts" || key === "result" || key === "data") {
return unwrapResponse(obj[key]);
}
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return obj;
const keys = Object.keys(obj);
if (keys.length === 1) {
const key = keys[0];
if (
key === "0" ||
key === "state" ||
key === "facts" ||
key === "result" ||
key === "data"
) {
return unwrapResponse(obj[key]);
}
return obj;
}
return obj;
}
+256 -217
View File
@@ -5,7 +5,6 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { existsSync } from "node:fs";
import { crawlSite, clearCrawlCache } from "./scraper.js";
import { analyzeSite } from "./analyzer.js";
import { executeResearch } from "./steps/00b-research.js";
@@ -15,18 +14,17 @@ import { executeAudit } from "./steps/02-audit.js";
import { executeStrategize } from "./steps/03-strategize.js";
import { executeArchitect } from "./steps/04-architect.js";
import type {
PipelineConfig,
PipelineInput,
ConceptState,
ProjectConcept,
StepResult,
StepUsage,
PipelineConfig,
PipelineInput,
ConceptState,
ProjectConcept,
StepResult,
} from "./types.js";
export interface PipelineCallbacks {
onStepStart?: (stepId: string, stepName: string) => void;
onStepComplete?: (stepId: string, result: StepResult) => void;
onStepError?: (stepId: string, error: string) => void;
onStepStart?: (stepId: string, stepName: string) => void;
onStepComplete?: (stepId: string, result: StepResult) => void;
onStepError?: (stepId: string, error: string) => void;
}
/**
@@ -34,224 +32,265 @@ export interface PipelineCallbacks {
* Runs conceptual steps sequentially and builds the ProjectConcept.
*/
export class ConceptPipeline {
private config: PipelineConfig;
private state: ConceptState;
private callbacks: PipelineCallbacks;
private config: PipelineConfig;
private state: ConceptState;
private callbacks: PipelineCallbacks;
constructor(config: PipelineConfig, callbacks: PipelineCallbacks = {}) {
this.config = config;
this.callbacks = callbacks;
this.state = this.createInitialState();
}
constructor(config: PipelineConfig, callbacks: PipelineCallbacks = {}) {
this.config = config;
this.callbacks = callbacks;
this.state = this.createInitialState();
}
private createInitialState(): ConceptState {
return {
briefing: "",
private createInitialState(): ConceptState {
return {
briefing: "",
usage: {
totalPromptTokens: 0,
totalCompletionTokens: 0,
totalCost: 0,
perStep: [],
},
};
}
/**
* Run the full concept pipeline from scratch.
*/
async run(input: PipelineInput): Promise<ProjectConcept> {
this.state.briefing = input.briefing;
this.state.url = input.url;
this.state.comments = input.comments;
// Ensure output directories
await fs.mkdir(this.config.outputDir, { recursive: true });
await fs.mkdir(this.config.crawlDir, { recursive: true });
// Step 0: Scrape & Analyze (deterministic)
if (input.url) {
if (input.clearCache) {
const domain = new URL(input.url).hostname;
await clearCrawlCache(this.config.crawlDir, domain);
}
await this.runStep(
"00-scrape",
"Scraping & Analyzing Website",
async () => {
const pages = await crawlSite(input.url!, {
zyteApiKey: this.config.zyteApiKey,
crawlDir: this.config.crawlDir,
});
const domain = new URL(input.url!).hostname;
const siteProfile = analyzeSite(pages, domain);
this.state.siteProfile = siteProfile;
this.state.crawlDir = path.join(
this.config.crawlDir,
domain.replace(/\./g, "-"),
);
// Save site profile
await fs.writeFile(
path.join(this.state.crawlDir!, "_site_profile.json"),
JSON.stringify(siteProfile, null, 2),
);
return {
success: true,
data: siteProfile,
usage: {
totalPromptTokens: 0,
totalCompletionTokens: 0,
totalCost: 0,
perStep: [],
step: "00-scrape",
model: "none",
promptTokens: 0,
completionTokens: 0,
cost: 0,
durationMs: 0,
},
};
};
},
);
}
/**
* Run the full concept pipeline from scratch.
*/
async run(input: PipelineInput): Promise<ProjectConcept> {
this.state.briefing = input.briefing;
this.state.url = input.url;
this.state.comments = input.comments;
// Ensure output directories
await fs.mkdir(this.config.outputDir, { recursive: true });
await fs.mkdir(this.config.crawlDir, { recursive: true });
// Step 0: Scrape & Analyze (deterministic)
if (input.url) {
if (input.clearCache) {
const domain = new URL(input.url).hostname;
await clearCrawlCache(this.config.crawlDir, domain);
}
await this.runStep("00-scrape", "Scraping & Analyzing Website", async () => {
const pages = await crawlSite(input.url!, {
zyteApiKey: this.config.zyteApiKey,
crawlDir: this.config.crawlDir,
});
const domain = new URL(input.url!).hostname;
const siteProfile = analyzeSite(pages, domain);
this.state.siteProfile = siteProfile;
this.state.crawlDir = path.join(this.config.crawlDir, domain.replace(/\./g, "-"));
// Save site profile
await fs.writeFile(
path.join(this.state.crawlDir!, "_site_profile.json"),
JSON.stringify(siteProfile, null, 2),
);
return {
success: true,
data: siteProfile,
usage: { step: "00-scrape", model: "none", promptTokens: 0, completionTokens: 0, cost: 0, durationMs: 0 },
};
});
// Step 00a: Site Audit (DataForSEO)
await this.runStep(
"00a-site-audit",
"IST-Analysis (DataForSEO)",
async () => {
const result = await executeSiteAudit(this.state, this.config);
if (result.success && result.data) {
this.state.siteAudit = result.data;
}
return result;
},
);
// Step 00a: Site Audit (DataForSEO)
await this.runStep("00a-site-audit", "IST-Analysis (DataForSEO)", async () => {
const result = await executeSiteAudit(this.state, this.config);
if (result.success && result.data) {
this.state.siteAudit = result.data;
}
return result;
});
// Step 00b: Research (real web data via journaling)
await this.runStep("00b-research", "Industry & Company Research", async () => {
const result = await executeResearch(this.state);
if (result.success && result.data) {
this.state.researchData = result.data;
}
return result;
});
// Step 1: Extract facts
await this.runStep("01-extract", "Extracting Facts from Briefing", async () => {
const result = await executeExtract(this.state, this.config);
if (result.success) this.state.facts = result.data;
return result;
});
// Step 2: Audit features
await this.runStep("02-audit", "Auditing Features (Skeptical Review)", async () => {
const result = await executeAudit(this.state, this.config);
if (result.success) this.state.auditedFacts = result.data;
return result;
});
// Step 3: Strategic analysis
await this.runStep("03-strategize", "Strategic Analysis", async () => {
const result = await executeStrategize(this.state, this.config);
if (result.success) {
this.state.briefingSummary = result.data.briefingSummary;
this.state.designVision = result.data.designVision;
}
return result;
});
// Step 4: Sitemap architecture
await this.runStep("04-architect", "Information Architecture", async () => {
const result = await executeArchitect(this.state, this.config);
if (result.success) {
this.state.sitemap = result.data.sitemap;
this.state.websiteTopic = result.data.websiteTopic;
}
return result;
});
const projectConcept = this.buildProjectConcept();
await this.saveState(projectConcept);
return projectConcept;
}
/**
* Run a single step with callbacks and error handling.
*/
private async runStep(
stepId: string,
stepName: string,
executor: () => Promise<StepResult>,
): Promise<void> {
this.callbacks.onStepStart?.(stepId, stepName);
console.log(`\n📍 ${stepName}...`);
try {
const result = await executor();
if (result.usage) {
this.state.usage.perStep.push(result.usage);
this.state.usage.totalPromptTokens += result.usage.promptTokens;
this.state.usage.totalCompletionTokens += result.usage.completionTokens;
this.state.usage.totalCost += result.usage.cost;
}
if (result.success) {
const cost = result.usage?.cost ? ` ($${result.usage.cost.toFixed(4)})` : "";
const duration = result.usage?.durationMs ? ` [${(result.usage.durationMs / 1000).toFixed(1)}s]` : "";
console.log(` ✅ ${stepName} complete${cost}${duration}`);
this.callbacks.onStepComplete?.(stepId, result);
} else {
console.error(` ❌ ${stepName} failed: ${result.error}`);
this.callbacks.onStepError?.(stepId, result.error || "Unknown error");
throw new Error(result.error);
}
} catch (err) {
const errorMsg = (err as Error).message;
this.callbacks.onStepError?.(stepId, errorMsg);
throw err;
// Step 00b: Research (real web data via journaling)
await this.runStep(
"00b-research",
"Industry & Company Research",
async () => {
const result = await executeResearch(this.state);
if (result.success && result.data) {
this.state.researchData = result.data;
}
return result;
},
);
// Step 1: Extract facts
await this.runStep(
"01-extract",
"Extracting Facts from Briefing",
async () => {
const result = await executeExtract(this.state, this.config);
if (result.success) this.state.facts = result.data;
return result;
},
);
// Step 2: Audit features
await this.runStep(
"02-audit",
"Auditing Features (Skeptical Review)",
async () => {
const result = await executeAudit(this.state, this.config);
if (result.success) this.state.auditedFacts = result.data;
return result;
},
);
// Step 3: Strategic analysis
await this.runStep("03-strategize", "Strategic Analysis", async () => {
const result = await executeStrategize(this.state, this.config);
if (result.success) {
this.state.briefingSummary = result.data.briefingSummary;
this.state.designVision = result.data.designVision;
}
return result;
});
// Step 4: Sitemap architecture
await this.runStep("04-architect", "Information Architecture", async () => {
const result = await executeArchitect(this.state, this.config);
if (result.success) {
this.state.sitemap = result.data.sitemap;
this.state.websiteTopic = result.data.websiteTopic;
}
return result;
});
const projectConcept = this.buildProjectConcept();
await this.saveState(projectConcept);
return projectConcept;
}
/**
* Run a single step with callbacks and error handling.
*/
private async runStep(
stepId: string,
stepName: string,
executor: () => Promise<StepResult>,
): Promise<void> {
this.callbacks.onStepStart?.(stepId, stepName);
console.log(`\n📍 ${stepName}...`);
try {
const result = await executor();
if (result.usage) {
this.state.usage.perStep.push(result.usage);
this.state.usage.totalPromptTokens += result.usage.promptTokens;
this.state.usage.totalCompletionTokens += result.usage.completionTokens;
this.state.usage.totalCost += result.usage.cost;
}
if (result.success) {
const cost = result.usage?.cost
? ` ($${result.usage.cost.toFixed(4)})`
: "";
const duration = result.usage?.durationMs
? ` [${(result.usage.durationMs / 1000).toFixed(1)}s]`
: "";
console.log(` ✅ ${stepName} complete${cost}${duration}`);
this.callbacks.onStepComplete?.(stepId, result);
} else {
console.error(` ❌ ${stepName} failed: ${result.error}`);
this.callbacks.onStepError?.(stepId, result.error || "Unknown error");
throw new Error(result.error);
}
} catch (err) {
const errorMsg = (err as Error).message;
this.callbacks.onStepError?.(stepId, errorMsg);
throw err;
}
}
/**
* Build the final Concept object.
*/
private buildProjectConcept(): ProjectConcept {
return {
domain: this.state.siteProfile?.domain || "unknown",
timestamp: new Date().toISOString(),
briefing: this.state.briefing,
auditedFacts: this.state.auditedFacts || {},
siteProfile: this.state.siteProfile,
siteAudit: this.state.siteAudit,
researchData: this.state.researchData,
strategy: {
briefingSummary: this.state.briefingSummary || "",
designVision: this.state.designVision || "",
},
architecture: {
websiteTopic: this.state.websiteTopic || "",
sitemap: this.state.sitemap || [],
},
usage: this.state.usage,
};
/**
* Build the final Concept object.
*/
private buildProjectConcept(): ProjectConcept {
return {
domain: this.state.siteProfile?.domain || "unknown",
timestamp: new Date().toISOString(),
briefing: this.state.briefing,
auditedFacts: this.state.auditedFacts || {},
siteProfile: this.state.siteProfile,
siteAudit: this.state.siteAudit,
researchData: this.state.researchData,
strategy: {
briefingSummary: this.state.briefingSummary || "",
designVision: this.state.designVision || "",
},
architecture: {
websiteTopic: this.state.websiteTopic || "",
sitemap: this.state.sitemap || [],
},
usage: this.state.usage,
};
}
/**
* Save the full concept generated state to disk.
*/
private async saveState(concept: ProjectConcept): Promise<void> {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const companyName = this.state.auditedFacts?.companyName || "unknown";
const stateDir = path.join(this.config.outputDir, "concepts");
await fs.mkdir(stateDir, { recursive: true });
const statePath = path.join(stateDir, `${companyName}_${timestamp}.json`);
await fs.writeFile(statePath, JSON.stringify(concept, null, 2));
console.log(`\n📦 Saved Project Concept to: ${statePath}`);
// Save debug trace
const debugPath = path.join(
stateDir,
`${companyName}_${timestamp}_debug.json`,
);
await fs.writeFile(debugPath, JSON.stringify(this.state, null, 2));
// Print usage summary
console.log("\n──────────────────────────────────────────────");
console.log("📊 PIPELINE USAGE SUMMARY");
console.log("──────────────────────────────────────────────");
for (const step of this.state.usage.perStep) {
if (step.cost > 0) {
console.log(
` ${step.step}: ${step.model} — $${step.cost.toFixed(6)} (${(step.durationMs / 1000).toFixed(1)}s)`,
);
}
}
console.log("──────────────────────────────────────────────");
console.log(` TOTAL: $${this.state.usage.totalCost.toFixed(6)}`);
console.log(
` Tokens: ${(this.state.usage.totalPromptTokens + this.state.usage.totalCompletionTokens).toLocaleString()}`,
);
console.log("──────────────────────────────────────────────\n");
}
/**
* Save the full concept generated state to disk.
*/
private async saveState(concept: ProjectConcept): Promise<void> {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const companyName = this.state.auditedFacts?.companyName || "unknown";
const stateDir = path.join(this.config.outputDir, "concepts");
await fs.mkdir(stateDir, { recursive: true });
const statePath = path.join(stateDir, `${companyName}_${timestamp}.json`);
await fs.writeFile(statePath, JSON.stringify(concept, null, 2));
console.log(`\n📦 Saved Project Concept to: ${statePath}`);
// Save debug trace
const debugPath = path.join(stateDir, `${companyName}_${timestamp}_debug.json`);
await fs.writeFile(debugPath, JSON.stringify(this.state, null, 2));
// Print usage summary
console.log("\n──────────────────────────────────────────────");
console.log("📊 PIPELINE USAGE SUMMARY");
console.log("──────────────────────────────────────────────");
for (const step of this.state.usage.perStep) {
if (step.cost > 0) {
console.log(` ${step.step}: ${step.model} — $${step.cost.toFixed(6)} (${(step.durationMs / 1000).toFixed(1)}s)`);
}
}
console.log("──────────────────────────────────────────────");
console.log(` TOTAL: $${this.state.usage.totalCost.toFixed(6)}`);
console.log(` Tokens: ${(this.state.usage.totalPromptTokens + this.state.usage.totalCompletionTokens).toLocaleString()}`);
console.log("──────────────────────────────────────────────\n");
}
/** Get the current internal state (for CLI inspection). */
getState(): ConceptState {
return this.state;
}
/** Get the current internal state (for CLI inspection). */
getState(): ConceptState {
return this.state;
}
}
+366 -331
View File
@@ -10,268 +10,289 @@ import { existsSync } from "node:fs";
import type { CrawledPage, PageType } from "./types.js";
interface ScraperConfig {
zyteApiKey?: string;
crawlDir: string;
maxPages?: number;
zyteApiKey?: string;
crawlDir: string;
maxPages?: number;
}
/**
* Classify a URL pathname into a page type.
*/
function classifyPage(pathname: string): PageType {
const p = pathname.toLowerCase();
if (p === "/" || p === "" || p === "/index.html") return "home";
if (p.includes("service") || p.includes("leistung") || p.includes("kompetenz"))
return "service";
if (p.includes("about") || p.includes("ueber") || p.includes("über") || p.includes("unternehmen"))
return "about";
if (p.includes("contact") || p.includes("kontakt")) return "contact";
if (p.includes("job") || p.includes("karriere") || p.includes("career") || p.includes("human-resources"))
return "career";
if (p.includes("portfolio") || p.includes("referenz") || p.includes("projekt") || p.includes("case-study"))
return "portfolio";
if (p.includes("blog") || p.includes("news") || p.includes("aktuelles") || p.includes("magazin"))
return "blog";
if (p.includes("legal") || p.includes("impressum") || p.includes("datenschutz") || p.includes("privacy") || p.includes("agb"))
return "legal";
return "other";
const p = pathname.toLowerCase();
if (p === "/" || p === "" || p === "/index.html") return "home";
if (
p.includes("service") ||
p.includes("leistung") ||
p.includes("kompetenz")
)
return "service";
if (
p.includes("about") ||
p.includes("ueber") ||
p.includes("über") ||
p.includes("unternehmen")
)
return "about";
if (p.includes("contact") || p.includes("kontakt")) return "contact";
if (
p.includes("job") ||
p.includes("karriere") ||
p.includes("career") ||
p.includes("human-resources")
)
return "career";
if (
p.includes("portfolio") ||
p.includes("referenz") ||
p.includes("projekt") ||
p.includes("case-study")
)
return "portfolio";
if (
p.includes("blog") ||
p.includes("news") ||
p.includes("aktuelles") ||
p.includes("magazin")
)
return "blog";
if (
p.includes("legal") ||
p.includes("impressum") ||
p.includes("datenschutz") ||
p.includes("privacy") ||
p.includes("agb")
)
return "legal";
return "other";
}
/**
* Detect interactive features present on a page.
*/
function detectFeatures($: cheerio.CheerioAPI): string[] {
const features: string[] = [];
const features: string[] = [];
// Search
if (
$('input[type="search"]').length > 0 ||
$('form[role="search"]').length > 0 ||
$(".search-form, .search-box, #search, .searchbar").length > 0 ||
$('input[name="q"], input[name="s"], input[name="search"]').length > 0
) {
features.push("search");
}
// Search
if (
$('input[type="search"]').length > 0 ||
$('form[role="search"]').length > 0 ||
$(".search-form, .search-box, #search, .searchbar").length > 0 ||
$('input[name="q"], input[name="s"], input[name="search"]').length > 0
) {
features.push("search");
}
// Forms (beyond search)
const formCount = $("form").length;
const searchForms = $('form[role="search"], .search-form').length;
if (formCount > searchForms) {
features.push("forms");
}
// Forms (beyond search)
const formCount = $("form").length;
const searchForms = $('form[role="search"], .search-form').length;
if (formCount > searchForms) {
features.push("forms");
}
// Maps
if (
$('iframe[src*="google.com/maps"], iframe[src*="openstreetmap"], .map-container, #map, [data-map]').length > 0
) {
features.push("maps");
}
// Maps
if (
$(
'iframe[src*="google.com/maps"], iframe[src*="openstreetmap"], .map-container, #map, [data-map]',
).length > 0
) {
features.push("maps");
}
// Video
if (
$("video, iframe[src*='youtube'], iframe[src*='vimeo'], .video-container").length > 0
) {
features.push("video");
}
// Video
if (
$("video, iframe[src*='youtube'], iframe[src*='vimeo'], .video-container")
.length > 0
) {
features.push("video");
}
// Calendar / Events
if ($(".calendar, .event, [data-calendar]").length > 0) {
features.push("calendar");
}
// Calendar / Events
if ($(".calendar, .event, [data-calendar]").length > 0) {
features.push("calendar");
}
// Cookie consent
if ($(".cookie-banner, .cookie-consent, #cookie-notice, [data-cookie]").length > 0) {
features.push("cookie-consent");
}
// Cookie consent
if (
$(".cookie-banner, .cookie-consent, #cookie-notice, [data-cookie]").length >
0
) {
features.push("cookie-consent");
}
return features;
return features;
}
/**
* Extract all internal links from a page.
*/
function extractInternalLinks($: cheerio.CheerioAPI, origin: string): string[] {
const links: string[] = [];
$("a[href]").each((_, el) => {
const href = $(el).attr("href");
if (!href) return;
try {
const url = new URL(href, origin);
if (url.origin === origin) {
// Skip assets
if (/\.(pdf|zip|jpg|jpeg|png|svg|webp|gif|css|js|ico|woff|woff2|ttf|eot)$/i.test(url.pathname)) return;
// Skip anchors-only
if (url.pathname === "/" && url.hash) return;
links.push(url.pathname);
}
} catch {
// Invalid URL, skip
}
});
return [...new Set(links)];
const links: string[] = [];
$("a[href]").each((_, el) => {
const href = $(el).attr("href");
if (!href) return;
try {
const url = new URL(href, origin);
if (url.origin === origin) {
// Skip assets
if (
/\.(pdf|zip|jpg|jpeg|png|svg|webp|gif|css|js|ico|woff|woff2|ttf|eot)$/i.test(
url.pathname,
)
)
return;
// Skip anchors-only
if (url.pathname === "/" && url.hash) return;
links.push(url.pathname);
}
} catch {
// Invalid URL, skip
}
});
return [...new Set(links)];
}
/**
* Extract all images from a page.
*/
function extractImages($: cheerio.CheerioAPI, origin: string): string[] {
const images: string[] = [];
const images: string[] = [];
// Regular img tags
$("img[src]").each((_, el) => {
const src = $(el).attr("src");
if (src) images.push(src);
});
// Regular img tags
$("img[src]").each((_, el) => {
const src = $(el).attr("src");
if (src) images.push(src);
});
// CSS background images (inline styles)
$("[style*='background-image']").each((_, el) => {
const style = $(el).attr("style");
const match = style?.match(/url\(['"]?(.*?)['"]?\)/);
if (match && match[1]) {
images.push(match[1]);
}
});
// Resolve URLs to absolute
const absoluteImages: string[] = [];
for (const img of images) {
if (img.startsWith("data:image")) continue; // Skip inline base64
try {
const url = new URL(img, origin);
// Ignore small tracking pixels or generic vectors
if (url.pathname.endsWith(".svg") && !url.pathname.includes("logo")) continue;
absoluteImages.push(url.href);
} catch {
// Invalid URL
}
// CSS background images (inline styles)
$("[style*='background-image']").each((_, el) => {
const style = $(el).attr("style");
const match = style?.match(/url\(['"]?(.*?)['"]?\)/);
if (match && match[1]) {
images.push(match[1]);
}
});
return [...new Set(absoluteImages)];
}
/**
* Extract services/competencies from text content.
*/
function extractServices(text: string): string[] {
const services: string[] = [];
// Common pattern: bulleted or newline-separated service lists
const lines = text.split(/\n/).map((l) => l.trim()).filter((l) => l.length > 3 && l.length < 100);
for (const line of lines) {
// Skip generic boilerplate
if (/cookie|datenschutz|impressum|copyright|©/i.test(line)) continue;
if (/^(tel|fax|e-mail|mobil|web|http)/i.test(line)) continue;
services.push(line);
// Resolve URLs to absolute
const absoluteImages: string[] = [];
for (const img of images) {
if (img.startsWith("data:image")) continue; // Skip inline base64
try {
const url = new URL(img, origin);
// Ignore small tracking pixels or generic vectors
if (url.pathname.endsWith(".svg") && !url.pathname.includes("logo"))
continue;
absoluteImages.push(url.href);
} catch {
// Invalid URL
}
return services;
}
return [...new Set(absoluteImages)];
}
/**
* Fetch a page via Zyte API with browser rendering.
*/
async function fetchWithZyte(url: string, apiKey: string): Promise<string> {
try {
const auth = Buffer.from(`${apiKey}:`).toString("base64");
const resp = await fetch("https://api.zyte.com/v1/extract", {
method: "POST",
headers: {
"Authorization": `Basic ${auth}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
url,
browserHtml: true,
}),
signal: AbortSignal.timeout(60000),
});
const auth = Buffer.from(`${apiKey}:`).toString("base64");
const resp = await fetch("https://api.zyte.com/v1/extract", {
method: "POST",
headers: {
Authorization: `Basic ${auth}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
url,
browserHtml: true,
}),
signal: AbortSignal.timeout(60000),
});
if (!resp.ok) {
const errorText = await resp.text();
console.error(` ❌ Zyte API error ${resp.status} for ${url}: ${errorText}`);
// Rate limited — wait and retry once
if (resp.status === 429) {
console.log(" ⏳ Rate limited, waiting 5s and retrying...");
await new Promise((r) => setTimeout(r, 5000));
return fetchWithZyte(url, apiKey);
}
throw new Error(`HTTP ${resp.status}: ${errorText}`);
}
const data = await resp.json();
const html = data.browserHtml || "";
if (!html) {
console.warn(` ⚠️ Zyte returned empty browserHtml for ${url}`);
}
return html;
} catch (err: any) {
throw err;
if (!resp.ok) {
const errorText = await resp.text();
console.error(
` ❌ Zyte API error ${resp.status} for ${url}: ${errorText}`,
);
// Rate limited — wait and retry once
if (resp.status === 429) {
console.log(" ⏳ Rate limited, waiting 5s and retrying...");
await new Promise((r) => setTimeout(r, 5000));
return fetchWithZyte(url, apiKey);
}
}
throw new Error(`HTTP ${resp.status}: ${errorText}`);
}
const data = await resp.json();
const html = data.browserHtml || "";
if (!html) {
console.warn(` ⚠️ Zyte returned empty browserHtml for ${url}`);
}
return html;
}
/**
* Fetch a page via simple HTTP GET (fallback).
*/
async function fetchDirect(url: string): Promise<string> {
try {
const resp = await fetch(url, {
headers: {
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
},
signal: AbortSignal.timeout(30000),
});
if (!resp.ok) return "";
return await resp.text();
} catch {
return "";
}
const resp = await fetch(url, {
headers: {
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
},
signal: AbortSignal.timeout(30000),
}).catch(() => null);
if (!resp || !resp.ok) return "";
return await resp.text();
}
/**
* Parse an HTML string into a CrawledPage.
*/
function parsePage(html: string, url: string): CrawledPage {
const $ = cheerio.load(html);
const urlObj = new URL(url);
const $ = cheerio.load(html);
const urlObj = new URL(url);
const title = $("title").text().trim();
const headings = $("h1, h2, h3")
.map((_, el) => $(el).text().trim())
.get()
.filter((h) => h.length > 0);
const title = $("title").text().trim();
const headings = $("h1, h2, h3")
.map((_, el) => $(el).text().trim())
.get()
.filter((h) => h.length > 0);
const navItems = $("nav a")
.map((_, el) => $(el).text().trim())
.get()
.filter((t) => t.length > 0 && t.length < 100);
const navItems = $("nav a")
.map((_, el) => $(el).text().trim())
.get()
.filter((t) => t.length > 0 && t.length < 100);
const bodyText = $("body")
.text()
.replace(/\s+/g, " ")
.substring(0, 50000)
.trim();
const bodyText = $("body")
.text()
.replace(/\s+/g, " ")
.substring(0, 50000)
.trim();
const features = detectFeatures($);
const links = extractInternalLinks($, urlObj.origin);
const images = extractImages($, urlObj.origin);
const features = detectFeatures($);
const links = extractInternalLinks($, urlObj.origin);
const images = extractImages($, urlObj.origin);
const description = $('meta[name="description"]').attr("content") || undefined;
const ogTitle = $('meta[property="og:title"]').attr("content") || undefined;
const ogImage = $('meta[property="og:image"]').attr("content") || undefined;
const description =
$('meta[name="description"]').attr("content") || undefined;
const ogTitle = $('meta[property="og:title"]').attr("content") || undefined;
const ogImage = $('meta[property="og:image"]').attr("content") || undefined;
return {
url,
pathname: urlObj.pathname,
title,
html,
text: bodyText,
headings,
navItems,
features,
type: classifyPage(urlObj.pathname),
links,
images,
meta: { description, ogTitle, ogImage },
};
return {
url,
pathname: urlObj.pathname,
title,
html,
text: bodyText,
headings,
navItems,
features,
type: classifyPage(urlObj.pathname),
links,
images,
meta: { description, ogTitle, ogImage },
};
}
/**
@@ -280,164 +301,178 @@ function parsePage(html: string, url: string): CrawledPage {
* Returns an array of CrawledPage objects.
*/
export async function crawlSite(
targetUrl: string,
config: ScraperConfig,
targetUrl: string,
config: ScraperConfig,
): Promise<CrawledPage[]> {
const urlObj = new URL(targetUrl);
const origin = urlObj.origin;
const domain = urlObj.hostname;
const domainDir = path.join(config.crawlDir, domain.replace(/\./g, "-"));
const urlObj = new URL(targetUrl);
const origin = urlObj.origin;
const domain = urlObj.hostname;
const domainDir = path.join(config.crawlDir, domain.replace(/\./g, "-"));
// Check for existing crawl
const metaFile = path.join(domainDir, "_crawl_meta.json");
if (existsSync(metaFile)) {
console.log(`📦 Found existing crawl for ${domain}. Loading from disk...`);
return loadCrawlFromDisk(domainDir);
}
// Check for existing crawl
const metaFile = path.join(domainDir, "_crawl_meta.json");
if (existsSync(metaFile)) {
console.log(`📦 Found existing crawl for ${domain}. Loading from disk...`);
return loadCrawlFromDisk(domainDir);
}
console.log(`🔍 Crawling ${targetUrl} via ${config.zyteApiKey ? "Zyte API" : "direct HTTP"}...`);
console.log(
`🔍 Crawling ${targetUrl} via ${config.zyteApiKey ? "Zyte API" : "direct HTTP"}...`,
);
// Ensure output dir
await fs.mkdir(domainDir, { recursive: true });
// Ensure output dir
await fs.mkdir(domainDir, { recursive: true });
const maxPages = config.maxPages || 30;
const visited = new Set<string>();
const queue: string[] = [targetUrl];
const pages: CrawledPage[] = [];
const maxPages = config.maxPages || 30;
const visited = new Set<string>();
const queue: string[] = [targetUrl];
const pages: CrawledPage[] = [];
while (queue.length > 0 && visited.size < maxPages) {
const url = queue.shift()!;
const urlPath = new URL(url).pathname;
while (queue.length > 0 && visited.size < maxPages) {
const url = queue.shift()!;
const urlPath = new URL(url).pathname;
if (visited.has(urlPath)) continue;
visited.add(urlPath);
if (visited.has(urlPath)) continue;
visited.add(urlPath);
try {
console.log(` ↳ Fetching ${url} (${visited.size}/${maxPages})...`);
try {
console.log(` ↳ Fetching ${url} (${visited.size}/${maxPages})...`);
let html: string;
if (config.zyteApiKey) {
html = await fetchWithZyte(url, config.zyteApiKey);
} else {
html = await fetchDirect(url);
}
let html: string;
if (config.zyteApiKey) {
html = await fetchWithZyte(url, config.zyteApiKey);
} else {
html = await fetchDirect(url);
}
if (!html || html.length < 100) {
console.warn(` ⚠️ Empty/tiny response for ${url}, skipping.`);
continue;
}
if (!html || html.length < 100) {
console.warn(` ⚠️ Empty/tiny response for ${url}, skipping.`);
continue;
}
const page = parsePage(html, url);
pages.push(page);
const page = parsePage(html, url);
pages.push(page);
// Save HTML + metadata to disk
const safeName = urlPath === "/" ? "index" : urlPath.replace(/\//g, "_").replace(/^_/, "");
await fs.writeFile(path.join(domainDir, `${safeName}.html`), html);
await fs.writeFile(
path.join(domainDir, `${safeName}.meta.json`),
JSON.stringify(
{
url: page.url,
pathname: page.pathname,
title: page.title,
type: page.type,
headings: page.headings,
navItems: page.navItems,
features: page.features,
links: page.links,
images: page.images,
meta: page.meta,
},
null,
2,
),
);
// Discover new links
for (const link of page.links) {
if (!visited.has(link)) {
const fullUrl = `${origin}${link}`;
queue.push(fullUrl);
}
}
} catch (err) {
console.warn(` ⚠️ Failed to fetch ${url}: ${(err as Error).message}`);
}
}
// Save crawl metadata
await fs.writeFile(
metaFile,
// Save HTML + metadata to disk
const safeName =
urlPath === "/"
? "index"
: urlPath.replace(/\//g, "_").replace(/^_/, "");
await fs.writeFile(path.join(domainDir, `${safeName}.html`), html);
await fs.writeFile(
path.join(domainDir, `${safeName}.meta.json`),
JSON.stringify(
{
domain,
crawledAt: new Date().toISOString(),
totalPages: pages.length,
urls: pages.map((p) => p.url),
},
null,
2,
{
url: page.url,
pathname: page.pathname,
title: page.title,
type: page.type,
headings: page.headings,
navItems: page.navItems,
features: page.features,
links: page.links,
images: page.images,
meta: page.meta,
},
null,
2,
),
);
);
console.log(`✅ Crawled ${pages.length} pages for ${domain}. Saved to ${domainDir}`);
return pages;
// Discover new links
for (const link of page.links) {
if (!visited.has(link)) {
const fullUrl = `${origin}${link}`;
queue.push(fullUrl);
}
}
} catch (err) {
console.warn(` ⚠️ Failed to fetch ${url}: ${(err as Error).message}`);
}
}
// Save crawl metadata
await fs.writeFile(
metaFile,
JSON.stringify(
{
domain,
crawledAt: new Date().toISOString(),
totalPages: pages.length,
urls: pages.map((p) => p.url),
},
null,
2,
),
);
console.log(
`✅ Crawled ${pages.length} pages for ${domain}. Saved to ${domainDir}`,
);
return pages;
}
/**
* Load a previously crawled site from disk.
*/
async function loadCrawlFromDisk(domainDir: string): Promise<CrawledPage[]> {
const files = await fs.readdir(domainDir);
const metaFiles = files.filter((f) => f.endsWith(".meta.json") && f !== "_crawl_meta.json");
const files = await fs.readdir(domainDir);
const metaFiles = files.filter(
(f) => f.endsWith(".meta.json") && f !== "_crawl_meta.json",
);
const pages: CrawledPage[] = [];
for (const metaFile of metaFiles) {
const baseName = metaFile.replace(".meta.json", "");
const htmlFile = `${baseName}.html`;
const pages: CrawledPage[] = [];
for (const metaFile of metaFiles) {
const baseName = metaFile.replace(".meta.json", "");
const htmlFile = `${baseName}.html`;
const meta = JSON.parse(await fs.readFile(path.join(domainDir, metaFile), "utf8"));
let html = "";
if (files.includes(htmlFile)) {
html = await fs.readFile(path.join(domainDir, htmlFile), "utf8");
}
const text = html
? cheerio
.load(html)("body")
.text()
.replace(/\s+/g, " ")
.substring(0, 50000)
.trim()
: "";
pages.push({
url: meta.url,
pathname: meta.pathname,
title: meta.title,
html,
text,
headings: meta.headings || [],
navItems: meta.navItems || [],
features: meta.features || [],
type: meta.type || "other",
links: meta.links || [],
images: meta.images || [],
meta: meta.meta || {},
});
const meta = JSON.parse(
await fs.readFile(path.join(domainDir, metaFile), "utf8"),
);
let html = "";
if (files.includes(htmlFile)) {
html = await fs.readFile(path.join(domainDir, htmlFile), "utf8");
}
console.log(` 📂 Loaded ${pages.length} cached pages from disk.`);
return pages;
const text = html
? cheerio
.load(html)("body")
.text()
.replace(/\s+/g, " ")
.substring(0, 50000)
.trim()
: "";
pages.push({
url: meta.url,
pathname: meta.pathname,
title: meta.title,
html,
text,
headings: meta.headings || [],
navItems: meta.navItems || [],
features: meta.features || [],
type: meta.type || "other",
links: meta.links || [],
images: meta.images || [],
meta: meta.meta || {},
});
}
console.log(` 📂 Loaded ${pages.length} cached pages from disk.`);
return pages;
}
/**
* Delete a cached crawl to force re-crawl.
*/
export async function clearCrawlCache(crawlDir: string, domain: string): Promise<void> {
const domainDir = path.join(crawlDir, domain.replace(/\./g, "-"));
if (existsSync(domainDir)) {
await fs.rm(domainDir, { recursive: true, force: true });
console.log(`🧹 Cleared crawl cache for ${domain}`);
}
export async function clearCrawlCache(
crawlDir: string,
domain: string,
): Promise<void> {
const domainDir = path.join(crawlDir, domain.replace(/\./g, "-"));
if (existsSync(domainDir)) {
await fs.rm(domainDir, { recursive: true, force: true });
console.log(`🧹 Cleared crawl cache for ${domain}`);
}
}
+26 -8
View File
@@ -212,7 +212,11 @@ export class AiBlogPostOrchestrator {
console.log(`✅ Saved optimized file to: ${finalPath}`);
}
async generateSlug(content: string, title?: string, instructions?: string): Promise<string> {
async generateSlug(
content: string,
title?: string,
instructions?: string,
): Promise<string> {
const response = await this.openai.chat.completions.create({
model: "google/gemini-3-flash-preview",
messages: [
@@ -223,21 +227,32 @@ Return ONLY a JSON object with a single string field "slug".
Example: {"slug": "how-to-optimize-react-performance"}
Rules: Use lowercase letters, numbers, and hyphens only. No special characters. Keep it concise (2-5 words).`,
},
{ role: "user", content: `Title: ${title || "Unknown"}\n\nContent:\n${content.slice(0, 3000)}...${instructions ? `\n\nEDITOR INSTRUCTIONS:\nPlease strictly follow these instructions from the editor when generating the slug:\n${instructions}` : ""}` },
{
role: "user",
content: `Title: ${title || "Unknown"}\n\nContent:\n${content.slice(0, 3000)}...${instructions ? `\n\nEDITOR INSTRUCTIONS:\nPlease strictly follow these instructions from the editor when generating the slug:\n${instructions}` : ""}`,
},
],
response_format: { type: "json_object" },
});
try {
const parsed = JSON.parse(response.choices[0].message.content || '{"slug": ""}');
let slug = parsed.slug || "new-post";
return slug.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
const parsed = JSON.parse(
response.choices[0].message.content || '{"slug": ""}',
);
const slug = parsed.slug || "new-post";
return slug
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
} catch {
return "new-post";
}
}
public async generateVisualPrompt(content: string, instructions?: string): Promise<string> {
public async generateVisualPrompt(
content: string,
instructions?: string,
): Promise<string> {
const response = await this.openai.chat.completions.create({
model: this.model,
messages: [
@@ -253,7 +268,10 @@ FOCUS: The core metaphor or technical concept of the article.
Example output: "A complex network of glowing fiber optic nodes forming a recursive pyramid structure, technical blue lineart style."`,
},
{ role: "user", content: `${content.slice(0, 5000)}${instructions ? `\n\nEDITOR INSTRUCTIONS:\nPlease strictly follow these instructions from the editor when generating the visual prompt:\n${instructions}` : ""}` },
{
role: "user",
content: `${content.slice(0, 5000)}${instructions ? `\n\nEDITOR INSTRUCTIONS:\nPlease strictly follow these instructions from the editor when generating the visual prompt:\n${instructions}` : ""}`,
},
],
max_tokens: 100,
});
@@ -329,7 +347,7 @@ Example output: "A complex network of glowing fiber optic nodes forming a recurs
);
const realPosts = await this.researchAgent.fetchRealSocialPosts(
task.content.slice(0, 500),
task.customSources
task.customSources,
);
socialPosts.push(...realPosts);
}
-34
View File
@@ -1,34 +0,0 @@
{
"name": "customer-manager",
"description": "Custom High-Fidelity Management for Directus",
"icon": "extension",
"version": "1.8.21",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"directus:extension": {
"type": "module",
"path": "dist/index.js",
"source": "src/index.ts",
"host": "app",
"name": "customer manager"
},
"scripts": {
"build": "directus-extension build",
"dev": "directus-extension build -w"
},
"dependencies": {
"@mintel/directus-extension-toolkit": "workspace:*"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.0"
},
"repository": {
"type": "git",
"url": "https://git.infra.mintel.me/mmintel/at-mintel.git"
}
}
-14
View File
@@ -1,14 +0,0 @@
import { defineModule } from '@directus/extensions-sdk';
import ModuleComponent from './module.vue';
export default defineModule({
id: 'customer-manager',
name: 'Customer Manager',
icon: 'supervisor_account',
routes: [
{
path: '',
component: ModuleComponent,
},
],
});
-538
View File
@@ -1,538 +0,0 @@
<template>
<MintelManagerLayout
title="Customer Manager"
:item-title="selectedItem?.company?.name || 'Kunde wählen'"
:is-empty="!selectedItem"
empty-title="Kunde auswählen"
empty-icon="handshake"
:notice="notice"
@close-notice="notice = null"
>
<template #navigation>
<v-list nav>
<v-list-item @click="openCreateDrawer" clickable>
<v-list-item-icon><v-icon name="add" color="var(--theme--primary)" /></v-list-item-icon>
<v-list-item-content>
<v-text-overflow text="Neuen Kunden verlinken" />
</v-list-item-content>
</v-list-item>
<v-divider />
<v-list-item
v-for="item in items"
:key="item.id"
:active="selectedItem?.id === item.id"
class="nav-item"
clickable
@click="selectItem(item)"
>
<v-list-item-icon><v-icon name="business" /></v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="item.company?.name" />
</v-list-item-content>
</v-list-item>
</v-list>
</template>
<template #subtitle>
<template v-if="selectedItem">
{{ clientUsers.length }} Portal-Nutzer &middot; {{ selectedItem.company?.domain }}
</template>
</template>
<template #actions>
<v-button secondary rounded icon v-tooltip.bottom="'Kunden-Verlinkung bearbeiten'" @click="openEditDrawer">
<v-icon name="edit" />
</v-button>
<div @click="onDebugClick" style="display: inline-block; border: 2px solid lime;">
<v-button primary @click="openCreateClientUser">
Portal-Nutzer hinzufügen
</v-button>
</div>
<button style="background: red; color: white; padding: 4px 8px; border-radius: 4px; border: none; cursor: pointer; margin-left: 10px;" @click="onDebugClick">DEBUG</button>
<button style="background: blue; color: white; padding: 8px 16px; border-radius: 4px; border: none; cursor: pointer; margin-left: 10px;" @click="openCreateClientUser">NATIVE: Portal-Nutzer</button>
</template>
<template #empty-state>
Wähle einen Kunden aus der Liste oder
<v-button x-small @click="openCreateDrawer">verlinke eine neue Firma</v-button>.
<button id="debug-click-test" style="background: red; color: white; padding: 4px 8px; border-radius: 4px; border: none; cursor: pointer; margin-left: 10px;" @click="onDebugClick">DEBUG CLICK</button>
</template>
<!-- Main Content: Client Users Table -->
<v-table
:headers="tableHeaders"
:items="clientUsers"
:loading="loading"
class="clickable-table"
fixed-header
@click:row="onRowClick"
>
<template #[`item.name`]="{ item }">
<div class="user-cell">
<v-avatar :name="item.first_name" x-small />
<span class="user-name">{{ item.first_name }} {{ item.last_name }}</span>
</div>
</template>
<template #[`item.last_invited`]="{ item }">
<span v-if="item.last_invited" class="status-date">
{{ formatDate(item.last_invited) }}
</span>
<v-chip v-else x-small>Noch nie</v-chip>
</template>
</v-table>
<!-- Drawer: Customer (Link) Form -->
<v-drawer
v-model="drawerActive"
:title="isEditing ? 'Kunden-Verlinkung bearbeiten' : 'Kunden verlinken'"
icon="handshake"
@cancel="drawerActive = false"
>
<div v-if="drawerActive" class="drawer-content">
<div class="form-section">
<div class="field">
<span class="label">Organisation / Firma</span>
<MintelSelect
v-model="form.company"
:items="companyOptions"
placeholder="Firma auswählen..."
allow-add
@add="openQuickAdd('company')"
/>
</div>
<div class="field">
<span class="label">Haupt-Ansprechpartner (optional)</span>
<MintelSelect
v-model="form.contact_person"
:items="peopleOptions"
placeholder="Person auswählen..."
allow-add
@add="openQuickAdd('person')"
/>
</div>
<div class="field">
<span class="label">Status</span>
<v-select
v-model="form.status"
:items="[
{ text: 'Aktiv', value: 'active' },
{ text: 'Inaktiv', value: 'inactive' }
]"
/>
</div>
<div class="field">
<span class="label">Notizen</span>
<v-textarea v-model="form.notes" placeholder="Besonderheiten zu diesem Kunden..." />
</div>
</div>
<div class="drawer-actions">
<v-button primary block :loading="saving" @click="saveItem">Speichern</v-button>
</div>
</div>
</v-drawer>
<!-- Drawer: Client User Form -->
<v-drawer
v-model="drawerUserActive"
:title="isEditingUser ? 'Portal-Nutzer bearbeiten' : 'Neuen Portal-Nutzer anlegen'"
icon="person"
@cancel="drawerUserActive = false"
>
<div v-if="drawerUserActive" class="drawer-content">
<div class="form-section">
<div class="field">
<span class="label">Vorname</span>
<v-input v-model="userForm.first_name" placeholder="Vorname" autofocus />
</div>
<div class="field">
<span class="label">Nachname</span>
<v-input v-model="userForm.last_name" placeholder="Nachname" />
</div>
<div class="field">
<span class="label">E-Mail</span>
<v-input v-model="userForm.email" placeholder="E-Mail Adresse" type="email" />
</div>
<div class="field">
<span class="label">Zentrale Person (Verknüpfung)</span>
<v-select
v-model="userForm.person"
:items="peopleOptions"
placeholder="Master-Person auswählen..."
/>
</div>
<v-divider v-if="isEditingUser" />
<div v-if="isEditingUser" class="field">
<span class="label">Temporäres Passwort</span>
<v-input v-model="userForm.temporary_password" readonly class="password-input" />
<p class="field-note">Wird beim Senden der Zugangsdaten automatisch generiert.</p>
</div>
</div>
<div class="drawer-actions">
<v-button primary block :loading="saving" @click="saveClientUser">Daten speichern</v-button>
<template v-if="isEditingUser">
<v-divider />
<v-button
v-tooltip.bottom="'Generiert PW, speichert es und sendet E-Mail'"
secondary
block
:loading="invitingId === userForm.id"
@click="inviteUser(userForm)"
>
<v-icon name="send" left /> Zugangsdaten senden
</v-button>
</template>
</div>
</div>
</v-drawer>
<!-- Drawer: Quick Add Company -->
<v-drawer
v-model="quickAddCompanyActive"
title="Firma schnell anlegen"
icon="business"
@cancel="quickAddCompanyActive = false"
>
<div class="drawer-content">
<div class="form-section">
<div class="field">
<span class="label">Firmenname</span>
<v-input v-model="quickCompanyForm.name" placeholder="z.B. Schmidt GmbH" autofocus />
</div>
<div class="field">
<span class="label">Domain / Website</span>
<v-input v-model="quickCompanyForm.domain" placeholder="example.com" />
</div>
</div>
<div class="drawer-actions">
<v-button primary block :loading="savingQuick" @click="saveQuickCompany">Firma speichern</v-button>
</div>
</div>
</v-drawer>
<!-- Drawer: Quick Add Person -->
<v-drawer
v-model="quickAddPersonActive"
title="Person schnell anlegen"
icon="person"
@cancel="quickAddPersonActive = false"
>
<div class="drawer-content">
<div class="form-section">
<div class="field">
<span class="label">Vorname</span>
<v-input v-model="quickPersonForm.first_name" placeholder="Vorname" autofocus />
</div>
<div class="field">
<span class="label">Nachname</span>
<v-input v-model="quickPersonForm.last_name" placeholder="Nachname" />
</div>
<div class="field">
<span class="label">E-Mail</span>
<v-input v-model="quickPersonForm.email" placeholder="email@example.com" type="email" />
</div>
</div>
<div class="drawer-actions">
<v-button primary block :loading="savingQuick" @click="saveQuickPerson">Person speichern</v-button>
</div>
</div>
</v-drawer>
</MintelManagerLayout>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick, computed, watch } from 'vue';
import { useApi } from '@directus/extensions-sdk';
import { useRoute } from 'vue-router';
import { MintelManagerLayout, MintelSelect } from '@mintel/directus-extension-toolkit';
const api = useApi();
const route = useRoute();
function onDebugClick() {
console.log("=== [Customer Manager] DEBUG CLICK TRAPPED ===");
alert("Interactivity OK!");
}
const items = ref<any[]>([]);
const selectedItem = ref<any>(null);
const clientUsers = ref<any[]>([]);
const loading = ref(false);
const saving = ref(false);
const invitingId = ref<string | null>(null);
const notice = ref<{ type: string; message: string } | null>(null);
const companies = ref<any[]>([]);
const people = ref<any[]>([]);
const drawerActive = ref(false);
const isEditing = ref(false);
const form = ref({ id: null, company: null, contact_person: null, status: 'active', notes: '' });
const drawerUserActive = ref(false);
const isEditingUser = ref(false);
const userForm = ref({ id: '', first_name: '', last_name: '', email: '', person: null, temporary_password: '' });
const quickAddCompanyActive = ref(false);
const quickAddPersonActive = ref(false);
const savingQuick = ref(false);
const quickCompanyForm = ref({ name: '', domain: '' });
const quickPersonForm = ref({ first_name: '', last_name: '', email: '' });
const tableHeaders = [
{ text: 'Name', value: 'name', sortable: true },
{ text: 'E-Mail', value: 'email', sortable: true },
{ text: 'Zuletzt eingeladen', value: 'last_invited', sortable: true }
];
const companyOptions = computed(() => companies.value.map(c => ({ text: c.name, value: c.id })));
const peopleOptions = computed(() => people.value.map(p => ({ text: `${p.first_name} ${p.last_name} (${p.email})`, value: p.id })));
async function fetchData() {
loading.value = true;
try {
const [custResp, compResp, peopleResp] = await Promise.all([
api.get('/items/customers', { params: { fields: ['*', 'company.*', 'contact_person.*'], sort: 'company.name' } }),
api.get('/items/companies', { params: { sort: 'name' } }),
api.get('/items/people', { params: { sort: 'last_name' } })
]);
items.value = custResp.data.data;
companies.value = compResp.data.data;
people.value = peopleResp.data.data;
} finally {
loading.value = false;
}
}
async function selectItem(item: any) {
selectedItem.value = item;
loading.value = true;
try {
const res = await api.get('/items/client_users', {
params: {
filter: { company: { _eq: item.company.id } },
fields: ['*', 'person.*'],
sort: 'first_name',
},
});
clientUsers.value = res.data.data;
} finally {
loading.value = false;
}
}
function openCreateDrawer() {
isEditing.value = false;
form.value = { id: null, company: null, contact_person: null, status: 'active', notes: '' };
drawerActive.value = true;
}
function openEditDrawer() {
if (!selectedItem.value) return;
isEditing.value = true;
form.value = {
id: selectedItem.value.id,
company: selectedItem.value.company?.id || selectedItem.value.company,
contact_person: selectedItem.value.contact_person?.id || selectedItem.value.contact_person,
status: selectedItem.value.status,
notes: selectedItem.value.notes
};
drawerActive.value = true;
}
async function saveItem() {
if (!form.value.company) {
notice.value = { type: 'danger', message: 'Bitte wählen Sie eine Firma aus.' };
return;
}
saving.value = true;
try {
if (isEditing.value) {
await api.patch(`/items/customers/${form.value.id}`, form.value);
notice.value = { type: 'success', message: 'Kunde aktualisiert!' };
} else {
await api.post('/items/customers', form.value);
notice.value = { type: 'success', message: 'Neuer Kunde verlinkt!' };
}
drawerActive.value = false;
await fetchData();
if (form.value.id) {
const updated = items.value.find(i => i.id === form.value.id);
if (updated) selectItem(updated);
}
} catch (e: any) {
notice.value = {
type: 'danger',
message: e.response?.data?.errors?.[0]?.message || e.message || 'Speichern fehlgeschlagen'
};
} finally {
saving.value = false;
}
}
// Client User Actions
function openCreateClientUser() {
isEditingUser.value = false;
userForm.value = { id: '', first_name: '', last_name: '', email: '', person: null, temporary_password: '' };
drawerUserActive.value = true;
}
function onRowClick(event: any) {
const item = event?.item || event;
if (item && item.id) {
userForm.value = {
id: item.id,
first_name: item.first_name,
last_name: item.last_name,
email: item.email,
person: item.person?.id || item.person,
temporary_password: item.temporary_password
};
isEditingUser.value = true;
drawerUserActive.value = true;
}
}
async function saveClientUser() {
if (!userForm.value.email || !selectedItem.value) return;
saving.value = true;
try {
const payload = {
first_name: userForm.value.first_name,
last_name: userForm.value.last_name,
email: userForm.value.email,
person: userForm.value.person,
company: selectedItem.value.company.id
};
if (isEditingUser.value) {
await api.patch(`/items/client_users/${userForm.value.id}`, payload);
} else {
await api.post('/items/client_users', payload);
}
drawerUserActive.value = false;
await selectItem(selectedItem.value);
} finally {
saving.value = false;
}
}
async function inviteUser(user: any) {
invitingId.value = user.id;
try {
await api.post(`/flows/trigger/33443f6b-cec7-4668-9607-f33ea674d501`, [user.id]);
notice.value = { type: 'success', message: `Zugangsdaten versendet. 📧` };
await selectItem(selectedItem.value);
} finally {
invitingId.value = null;
}
}
function openQuickAdd(type: 'company' | 'person') {
if (type === 'company') {
quickCompanyForm.value = { name: '', domain: '' };
quickAddCompanyActive.value = true;
} else {
quickPersonForm.value = { first_name: '', last_name: '', email: '' };
quickAddPersonActive.value = true;
}
}
async function saveQuickCompany() {
if (!quickCompanyForm.value.name) return;
savingQuick.value = true;
try {
const res = await api.post('/items/companies', quickCompanyForm.value);
await fetchData();
form.value.company = res.data.data.id;
quickAddCompanyActive.value = false;
notice.value = { type: 'success', message: 'Firma angelegt und ausgewählt.' };
} catch (e: any) {
notice.value = { type: 'danger', message: e.message };
} finally {
savingQuick.value = false;
}
}
async function saveQuickPerson() {
if (!quickPersonForm.value.first_name || !quickPersonForm.value.last_name) return;
savingQuick.value = true;
try {
const res = await api.post('/items/people', quickPersonForm.value);
await fetchData();
form.value.contact_person = res.data.data.id;
quickAddPersonActive.value = false;
notice.value = { type: 'success', message: 'Person angelegt und ausgewählt.' };
} catch (e: any) {
notice.value = { type: 'danger', message: e.message };
} finally {
savingQuick.value = false;
}
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
}
function handleDeepLink() {
if (route.query.create === 'true') {
// Only open if not already open to avoid resetting form if user is already typing
if (!drawerActive.value) {
openCreateDrawer();
}
if (route.query.company) {
form.value.company = route.query.company as any;
}
if (route.query.contact_person) {
form.value.contact_person = route.query.contact_person as any;
}
}
}
watch(() => route.query.create, (newVal) => {
if (newVal === 'true') handleDeepLink();
});
onMounted(async () => {
await fetchData();
handleDeepLink();
});
</script>
<style scoped>
.user-cell { display: flex; align-items: center; gap: 12px; }
.user-name { font-weight: 600; }
.status-date { font-size: 12px; color: var(--theme--foreground-subdued); }
.drawer-content { padding: 24px; display: flex; flex-direction: column; gap: 32px; }
.form-section { display: flex; flex-direction: column; gap: 20px; }
.field { display: flex; flex-direction: column; gap: 8px; }
.label { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px; }
.field-note { font-size: 11px; color: var(--theme--foreground-subdued); margin-top: 4px; }
.drawer-actions { margin-top: 24px; display: flex; flex-direction: column; gap: 12px; }
.password-input :deep(textarea) {
font-family: var(--family-monospace);
font-weight: 800;
color: var(--theme--primary) !important;
background: var(--theme--background-subdued) !important;
}
.clickable-table :deep(tbody tr) { cursor: pointer; transition: background-color 0.2s ease; }
.clickable-table :deep(tbody tr:hover) { background-color: var(--theme--background-subdued) !important; }
</style>
@@ -1,35 +0,0 @@
{
"name": "@mintel/directus-extension-toolkit",
"version": "1.8.21",
"description": "Shared toolkit for Directus extensions in the Mintel ecosystem",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"files": [
"dist"
],
"exports": {
".": {
"import": "./dist/index.js"
}
},
"scripts": {
"build": "vite build",
"dev": "vite build --watch"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"@vitejs/plugin-vue": "^6.0.4",
"typescript": "^5.0.0",
"vite": "^5.0.0",
"vue": "^3.4.0"
},
"peerDependencies": {
"@directus/extensions-sdk": "*",
"vue": "^3.4.0"
},
"repository": {
"type": "git",
"url": "https://git.infra.mintel.me/mmintel/at-mintel.git"
}
}
@@ -1,102 +0,0 @@
<template>
<private-view :title="title">
<template #navigation>
<slot name="navigation" />
</template>
<template #title-outer:after>
<v-notice v-if="notice" :type="notice.type" @close="$emit('close-notice')" dismissible>
{{ notice.message }}
</v-notice>
</template>
<div class="mintel-manager-layout">
<div v-if="isEmpty" class="empty-state">
<v-info :title="emptyTitle" :icon="emptyIcon" center>
<slot name="empty-state" />
</v-info>
</div>
<template v-else>
<header class="mintel-header">
<div class="header-left">
<h1 class="mintel-title">{{ itemTitle }}</h1>
<p class="mintel-subtitle">
<slot name="subtitle" />
</p>
</div>
<div class="header-right">
<slot name="actions" />
</div>
</header>
<v-divider />
<div class="mintel-content">
<slot />
</div>
</template>
</div>
</private-view>
</template>
<script setup lang="ts">
defineProps<{
title: string;
itemTitle?: string;
isEmpty?: boolean;
emptyTitle?: string;
emptyIcon?: string;
notice?: { type: string; message: string } | null;
}>();
defineEmits(['close-notice']);
</script>
<style scoped>
.mintel-manager-layout {
padding: 32px;
height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.mintel-header {
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.mintel-title {
font-size: 24px;
font-weight: 800;
margin-bottom: 4px;
color: var(--theme--foreground);
}
.mintel-subtitle {
color: var(--theme--foreground-subdued);
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.header-right {
display: flex;
gap: 12px;
}
.empty-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.mintel-content {
margin-top: 32px;
}
</style>
@@ -1,62 +0,0 @@
<template>
<div class="mintel-select">
<v-select
:model-value="modelValue"
:items="items"
:placeholder="placeholder"
:searchable="searchable"
:show-deselect="showDeselect"
@update:model-value="$emit('update:modelValue', $event)"
/>
<v-button v-if="allowAdd" secondary rounded icon x-small class="add-button" @click="$emit('add')">
<v-icon name="add" />
</v-button>
</div>
</template>
<script setup lang="ts">
const props = defineProps({
modelValue: {
type: [String, Number],
default: null
},
items: {
type: Array as () => Array<{ text: string; value: string | number }>,
required: true
},
placeholder: {
type: String,
default: 'Auswählen...'
},
searchable: {
type: Boolean,
default: true
},
showDeselect: {
type: Boolean,
default: true
},
allowAdd: {
type: Boolean,
default: false
}
});
defineEmits(['update:modelValue', 'add']);
</script>
<style scoped>
.mintel-select {
display: flex;
align-items: center;
gap: 8px;
}
.mintel-select :deep(.v-select) {
flex: 1;
}
.add-button {
flex-shrink: 0;
}
</style>
@@ -1,84 +0,0 @@
<template>
<div class="mintel-stat-card" @click="$emit('click')">
<div class="stat-icon">
<v-icon :name="icon" large />
</div>
<div class="stat-content">
<span class="stat-label">{{ label }}</span>
<span class="stat-value">{{ value }}</span>
</div>
<v-icon name="chevron_right" class="arrow" />
</div>
</template>
<script setup lang="ts">
defineProps<{
label: string;
value: string | number;
icon: string;
}>();
defineEmits(['click']);
</script>
<style scoped>
.mintel-stat-card {
background: var(--theme--background-normal);
border: 1px solid var(--theme--border);
padding: 24px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 20px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.mintel-stat-card:hover {
border-color: var(--theme--primary);
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0,0,0,0.1);
}
.stat-icon {
width: 56px;
height: 56px;
background: var(--theme--background-subdued);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--theme--primary);
}
.stat-content {
display: flex;
flex-direction: column;
}
.stat-label {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
color: var(--theme--foreground-subdued);
letter-spacing: 0.5px;
}
.stat-value {
font-size: 28px;
font-weight: 800;
color: var(--theme--foreground);
}
.arrow {
position: absolute;
right: 24px;
opacity: 0.2;
}
.mintel-stat-card:hover .arrow {
opacity: 1;
color: var(--theme--primary);
}
</style>
@@ -1,3 +0,0 @@
export { default as MintelSelect } from './MintelSelect.vue';
export { default as MintelManagerLayout } from './MintelManagerLayout.vue';
export { default as MintelStatCard } from './MintelStatCard.vue';
@@ -1,24 +0,0 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';
export default defineConfig({
plugins: [vue()],
build: {
lib: {
entry: resolve('src/index.ts'),
name: 'MintelDirectusToolkit',
fileName: 'index',
formats: ['es']
},
rollupOptions: {
external: ['vue', '@directus/extensions-sdk'],
output: {
globals: {
vue: 'Vue',
'@directus/extensions-sdk': 'DirectusExtensionsSDK'
}
}
}
}
});
@@ -1,40 +1,62 @@
import { config as dotenvConfig } from 'dotenv';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import { EstimationPipeline } from './pipeline.js';
import { config as dotenvConfig } from "dotenv";
import * as path from "node:path";
import * as fs from "node:fs/promises";
import { EstimationPipeline } from "./pipeline.js";
dotenvConfig({ path: path.resolve(process.cwd(), '../../.env') });
dotenvConfig({ path: path.resolve(process.cwd(), "../../.env") });
const briefing = await fs.readFile(
path.resolve(process.cwd(), '../../data/briefings/etib.txt'),
'utf8',
path.resolve(process.cwd(), "../../data/briefings/etib.txt"),
"utf8",
);
console.log(`Briefing loaded: ${briefing.length} chars`);
const pipeline = new EstimationPipeline(
{
openrouterKey: process.env.OPENROUTER_API_KEY || '',
zyteApiKey: process.env.ZYTE_API_KEY,
outputDir: path.resolve(process.cwd(), '../../out/estimations'),
crawlDir: path.resolve(process.cwd(), '../../data/crawls'),
},
{
onStepStart: (id, name) => console.log(`[CB] Starting: ${id}`),
onStepComplete: (id) => console.log(`[CB] Done: ${id}`),
onStepError: (id, err) => console.error(`[CB] Error in ${id}: ${err}`),
},
{
openrouterKey: process.env.OPENROUTER_API_KEY || "",
zyteApiKey: process.env.ZYTE_API_KEY,
outputDir: path.resolve(process.cwd(), "../../out/estimations"),
crawlDir: path.resolve(process.cwd(), "../../data/crawls"),
},
{
onStepStart: (id, _name) => console.log(`[CB] Starting: ${id}`),
onStepComplete: (id) => console.log(`[CB] Done: ${id}`),
onStepError: (id, err) => console.error(`[CB] Error in ${id}: ${err}`),
},
);
try {
const result = await pipeline.run({
briefing,
url: 'https://www.e-tib.com',
});
const result = await pipeline.run({
concept: {
strategy: {
briefingSummary: briefing,
projectGoals: [],
targetAudience: [],
coreMessage: "",
designVision: "",
uniqueValueProposition: "",
competitorAnalysis: "",
},
architecture: {
sitemap: [],
recommendedTechStack: [],
integrations: [],
websiteTopic: "",
dataModels: [],
},
auditedFacts: {
companyName: "E-TIB",
},
} as any,
});
console.log('\n✨ Pipeline complete!');
console.log('Validation:', result.validationResult?.passed ? 'PASSED' : 'FAILED');
console.log("\n✨ Pipeline complete!");
console.log(
"Validation:",
result.validationResult?.passed ? "PASSED" : "FAILED",
);
} catch (err: any) {
console.error('\n❌ Pipeline failed:', err.message);
console.error(err.stack);
console.error("\n❌ Pipeline failed:", err.message);
console.error(err.stack);
}
+53 -50
View File
@@ -18,61 +18,64 @@ dotenvConfig({ path: path.resolve(process.cwd(), ".env") });
const program = new Command();
program
.name("estimate")
.description("AI-powered project estimation engine")
.version("1.0.0");
.name("estimate")
.description("AI-powered project estimation engine")
.version("1.0.0");
program
.command("run")
.description("Run the financial estimation pipeline from a concept file")
.argument("<concept-file>", "Path to the ProjectConcept JSON file")
.option("--budget <budget>", "Budget constraint (e.g. '15.000 €')")
.option("--output <dir>", "Output directory", "../../out/estimations")
.action(async (conceptFile: string, options: any) => {
const openrouterKey = process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
if (!openrouterKey) {
console.error("❌ OPENROUTER_API_KEY not found in environment.");
process.exit(1);
}
.command("run")
.description("Run the financial estimation pipeline from a concept file")
.argument("<concept-file>", "Path to the ProjectConcept JSON file")
.option("--budget <budget>", "Budget constraint (e.g. '15.000 €')")
.option("--output <dir>", "Output directory", "../../out/estimations")
.action(async (conceptFile: string, options: any) => {
const openrouterKey =
process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
if (!openrouterKey) {
console.error("❌ OPENROUTER_API_KEY not found in environment.");
process.exit(1);
}
const filePath = path.resolve(process.cwd(), conceptFile);
if (!existsSync(filePath)) {
console.error(`❌ Concept file not found: ${filePath}`);
process.exit(1);
}
const filePath = path.resolve(process.cwd(), conceptFile);
if (!existsSync(filePath)) {
console.error(`❌ Concept file not found: ${filePath}`);
process.exit(1);
}
console.log(`📄 Loading concept from: ${filePath}`);
const rawConcept = await fs.readFile(filePath, "utf8");
const concept = JSON.parse(rawConcept) as ProjectConcept;
console.log(`📄 Loading concept from: ${filePath}`);
const rawConcept = await fs.readFile(filePath, "utf8");
const concept = JSON.parse(rawConcept) as ProjectConcept;
const pipeline = new EstimationPipeline(
{
openrouterKey,
outputDir: path.resolve(process.cwd(), options.output),
crawlDir: "" // No longer needed here
},
{
onStepStart: (id, name) => { },
onStepComplete: (id, result) => { },
},
const pipeline = new EstimationPipeline(
{
openrouterKey,
outputDir: path.resolve(process.cwd(), options.output),
crawlDir: "", // No longer needed here
},
{
onStepStart: (_id, _name) => {},
onStepComplete: (_id, _result) => {},
},
);
try {
const result = await pipeline.run({
concept,
budget: options.budget,
});
console.log("\n✨ Estimation complete!");
if (result.validationResult && !result.validationResult.passed) {
console.log(
`\n⚠️ ${result.validationResult.errors.length} validation issues found.`,
);
try {
const result = await pipeline.run({
concept,
budget: options.budget,
});
console.log("\n✨ Estimation complete!");
if (result.validationResult && !result.validationResult.passed) {
console.log(`\n⚠️ ${result.validationResult.errors.length} validation issues found.`);
console.log(" Review the output JSON and re-run problematic steps.");
}
} catch (err) {
console.error(`\n❌ Pipeline failed: ${(err as Error).message}`);
process.exit(1);
}
});
console.log(" Review the output JSON and re-run problematic steps.");
}
} catch (err) {
console.error(`\n❌ Pipeline failed: ${(err as Error).message}`);
process.exit(1);
}
});
program.parse();
+88 -84
View File
@@ -5,20 +5,20 @@
import axios from "axios";
interface LLMRequestOptions {
model: string;
systemPrompt: string;
userPrompt: string;
jsonMode?: boolean;
apiKey: string;
model: string;
systemPrompt: string;
userPrompt: string;
jsonMode?: boolean;
apiKey: string;
}
interface LLMResponse {
content: string;
usage: {
promptTokens: number;
completionTokens: number;
cost: number;
};
content: string;
usage: {
promptTokens: number;
completionTokens: number;
cost: number;
};
}
/**
@@ -26,103 +26,107 @@ interface LLMResponse {
* Handles markdown fences, control chars, trailing commas.
*/
export function cleanJson(str: string): string {
let cleaned = str.replace(/```json\n?|```/g, "").trim();
cleaned = cleaned.replace(
/[\u0000-\u0009\u000B\u000C\u000E-\u001F\u007F-\u009F]/g,
" ",
);
cleaned = cleaned.replace(/,\s*([\]}])/g, "$1");
return cleaned;
let cleaned = str.replace(/```json\n?|```/g, "").trim();
// eslint-disable-next-line no-control-regex
cleaned = cleaned.replace(/[\x00-\x1f\x7f-\x9f]/gi, " ");
cleaned = cleaned.replace(/,\s*([\]}])/g, "$1");
return cleaned;
}
/**
* Send a request to an LLM via OpenRouter.
*/
export async function llmRequest(options: LLMRequestOptions): Promise<LLMResponse> {
const { model, systemPrompt, userPrompt, jsonMode = true, apiKey } = options;
export async function llmRequest(
options: LLMRequestOptions,
): Promise<LLMResponse> {
const { model, systemPrompt, userPrompt, jsonMode = true, apiKey } = options;
const startTime = Date.now();
const resp = await axios.post(
"https://openrouter.ai/api/v1/chat/completions",
{
model,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
...(jsonMode ? { response_format: { type: "json_object" } } : {}),
},
{
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
timeout: 120000,
},
);
const resp = await axios.post(
"https://openrouter.ai/api/v1/chat/completions",
{
model,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
...(jsonMode ? { response_format: { type: "json_object" } } : {}),
},
{
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
timeout: 120000,
},
);
const content = resp.data.choices?.[0]?.message?.content;
if (!content) {
throw new Error(`LLM returned no content. Model: ${model}`);
}
const content = resp.data.choices?.[0]?.message?.content;
if (!content) {
throw new Error(`LLM returned no content. Model: ${model}`);
}
let cost = 0;
const usage = resp.data.usage || {};
if (usage.cost !== undefined) {
cost = usage.cost;
} else {
// Fallback estimation
cost =
(usage.prompt_tokens || 0) * (0.1 / 1_000_000) +
(usage.completion_tokens || 0) * (0.4 / 1_000_000);
}
let cost = 0;
const usage = resp.data.usage || {};
if (usage.cost !== undefined) {
cost = usage.cost;
} else {
// Fallback estimation
cost =
(usage.prompt_tokens || 0) * (0.1 / 1_000_000) +
(usage.completion_tokens || 0) * (0.4 / 1_000_000);
}
return {
content,
usage: {
promptTokens: usage.prompt_tokens || 0,
completionTokens: usage.completion_tokens || 0,
cost,
},
};
return {
content,
usage: {
promptTokens: usage.prompt_tokens || 0,
completionTokens: usage.completion_tokens || 0,
cost,
},
};
}
/**
* Send a request and parse the response as JSON.
*/
export async function llmJsonRequest<T = any>(
options: LLMRequestOptions,
options: LLMRequestOptions,
): Promise<{ data: T; usage: LLMResponse["usage"] }> {
const response = await llmRequest({ ...options, jsonMode: true });
const cleaned = cleanJson(response.content);
const response = await llmRequest({ ...options, jsonMode: true });
const cleaned = cleanJson(response.content);
let parsed: T;
try {
parsed = JSON.parse(cleaned);
} catch (e) {
throw new Error(
`Failed to parse LLM JSON response: ${(e as Error).message}\nRaw: ${cleaned.substring(0, 500)}`,
);
}
let parsed: T;
try {
parsed = JSON.parse(cleaned);
} catch (e) {
throw new Error(
`Failed to parse LLM JSON response: ${(e as Error).message}\nRaw: ${cleaned.substring(0, 500)}`,
);
}
// Unwrap common LLM artifacts: {"0": {...}}, {"state": {...}}, etc.
const unwrapped = unwrapResponse(parsed);
// Unwrap common LLM artifacts: {"0": {...}}, {"state": {...}}, etc.
const unwrapped = unwrapResponse(parsed);
return { data: unwrapped as T, usage: response.usage };
return { data: unwrapped as T, usage: response.usage };
}
/**
* Recursively unwrap common LLM wrapping patterns.
*/
function unwrapResponse(obj: any): any {
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return obj;
const keys = Object.keys(obj);
if (keys.length === 1) {
const key = keys[0];
if (key === "0" || key === "state" || key === "facts" || key === "result" || key === "data") {
return unwrapResponse(obj[key]);
}
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return obj;
const keys = Object.keys(obj);
if (keys.length === 1) {
const key = keys[0];
if (
key === "0" ||
key === "state" ||
key === "facts" ||
key === "result" ||
key === "data"
) {
return unwrapResponse(obj[key]);
}
return obj;
}
return obj;
}
+219 -191
View File
@@ -5,22 +5,20 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { existsSync } from "node:fs";
import { validateEstimation } from "./validators.js";
import { executeSynthesize } from "./steps/05-synthesize.js";
import { executeCritique } from "./steps/06-critique.js";
import type {
PipelineConfig,
PipelineInput,
EstimationState,
StepResult,
StepUsage,
PipelineConfig,
PipelineInput,
EstimationState,
StepResult,
} from "./types.js";
export interface PipelineCallbacks {
onStepStart?: (stepId: string, stepName: string) => void;
onStepComplete?: (stepId: string, result: StepResult) => void;
onStepError?: (stepId: string, error: string) => void;
onStepStart?: (stepId: string, stepName: string) => void;
onStepComplete?: (stepId: string, result: StepResult) => void;
onStepError?: (stepId: string, error: string) => void;
}
/**
@@ -28,201 +26,231 @@ export interface PipelineCallbacks {
* Runs steps sequentially, persists state between steps, supports re-entry.
*/
export class EstimationPipeline {
private config: PipelineConfig;
private state: EstimationState;
private callbacks: PipelineCallbacks;
private config: PipelineConfig;
private state: EstimationState;
private callbacks: PipelineCallbacks;
constructor(config: PipelineConfig, callbacks: PipelineCallbacks = {}) {
this.config = config;
this.callbacks = callbacks;
this.state = this.createInitialState();
}
constructor(config: PipelineConfig, callbacks: PipelineCallbacks = {}) {
this.config = config;
this.callbacks = callbacks;
this.state = this.createInitialState();
}
private createInitialState(): EstimationState {
return {
concept: null as any, // Will be set in run()
usage: {
totalPromptTokens: 0,
totalCompletionTokens: 0,
totalCost: 0,
perStep: [],
},
};
}
private createInitialState(): EstimationState {
return {
concept: null as any, // Will be set in run()
usage: {
totalPromptTokens: 0,
totalCompletionTokens: 0,
totalCost: 0,
perStep: [],
},
};
}
/**
* Run the full estimation pipeline from a completed project concept.
*/
async run(input: PipelineInput): Promise<EstimationState> {
this.state.concept = input.concept;
this.state.budget = input.budget;
/**
* Run the full estimation pipeline from a completed project concept.
*/
async run(input: PipelineInput): Promise<EstimationState> {
this.state.concept = input.concept;
this.state.budget = input.budget;
// Ensure output directories
await fs.mkdir(this.config.outputDir, { recursive: true });
// Ensure output directories
await fs.mkdir(this.config.outputDir, { recursive: true });
// Step 5: Position synthesis
await this.runStep("05-synthesize", "Position Descriptions", async () => {
const result = await executeSynthesize(this.state, this.config);
if (result.success) this.state.positionDescriptions = result.data;
return result;
});
// Step 5: Position synthesis
await this.runStep("05-synthesize", "Position Descriptions", async () => {
const result = await executeSynthesize(this.state, this.config);
if (result.success) this.state.positionDescriptions = result.data;
return result;
});
// Step 6: Quality critique
await this.runStep("06-critique", "Quality Gate (Industrial Critic)", async () => {
const result = await executeCritique(this.state, this.config);
if (result.success) {
this.state.critiquePassed = result.data.passed;
this.state.critiqueErrors = result.data.errors?.map((e: any) => `${e.field}: ${e.issue}`) || [];
// Step 6: Quality critique
await this.runStep(
"06-critique",
"Quality Gate (Industrial Critic)",
async () => {
const result = await executeCritique(this.state, this.config);
if (result.success) {
this.state.critiquePassed = result.data.passed;
this.state.critiqueErrors =
result.data.errors?.map((e: any) => `${e.field}: ${e.issue}`) || [];
// Apply corrections
if (result.data.corrections) {
const corrections = result.data.corrections;
// Note: We only correct the positionDescriptions since briefing/design/sitemap are locked in the concept phase.
// If the critique suggests changes to those, it should be a warning or failure.
if (corrections.positionDescriptions) {
this.state.positionDescriptions = {
...this.state.positionDescriptions,
...corrections.positionDescriptions,
};
}
}
// Apply corrections
if (result.data.corrections) {
const corrections = result.data.corrections;
// Note: We only correct the positionDescriptions since briefing/design/sitemap are locked in the concept phase.
// If the critique suggests changes to those, it should be a warning or failure.
if (corrections.positionDescriptions) {
this.state.positionDescriptions = {
...this.state.positionDescriptions,
...corrections.positionDescriptions,
};
}
return result;
});
// Step 7: Deterministic validation
await this.runStep("07-validate", "Deterministic Validation", async () => {
// Build the merged form state first
this.state.formState = this.buildFormState();
const validationResult = validateEstimation(this.state);
this.state.validationResult = validationResult;
if (!validationResult.passed) {
console.log("\n⚠️ Validation Issues:");
for (const error of validationResult.errors) {
console.log(` ❌ [${error.code}] ${error.message}`);
}
}
if (validationResult.warnings.length > 0) {
console.log("\n⚡ Warnings:");
for (const warning of validationResult.warnings) {
console.log(` ⚡ [${warning.code}] ${warning.message}`);
if (warning.suggestion) console.log(` → ${warning.suggestion}`);
}
}
return {
success: true,
data: validationResult,
usage: { step: "07-validate", model: "none", promptTokens: 0, completionTokens: 0, cost: 0, durationMs: 0 },
};
});
// Save final state
await this.saveState();
return this.state;
}
/**
* Run a single step with callbacks and error handling.
*/
private async runStep(
stepId: string,
stepName: string,
executor: () => Promise<StepResult>,
): Promise<void> {
this.callbacks.onStepStart?.(stepId, stepName);
console.log(`\n📍 ${stepName}...`);
try {
const result = await executor();
if (result.usage) {
this.state.usage.perStep.push(result.usage);
this.state.usage.totalPromptTokens += result.usage.promptTokens;
this.state.usage.totalCompletionTokens += result.usage.completionTokens;
this.state.usage.totalCost += result.usage.cost;
}
if (result.success) {
const cost = result.usage?.cost ? ` ($${result.usage.cost.toFixed(4)})` : "";
const duration = result.usage?.durationMs ? ` [${(result.usage.durationMs / 1000).toFixed(1)}s]` : "";
console.log(` ✅ ${stepName} complete${cost}${duration}`);
this.callbacks.onStepComplete?.(stepId, result);
} else {
console.error(` ❌ ${stepName} failed: ${result.error}`);
this.callbacks.onStepError?.(stepId, result.error || "Unknown error");
throw new Error(result.error);
}
} catch (err) {
const errorMsg = (err as Error).message;
this.callbacks.onStepError?.(stepId, errorMsg);
throw err;
}
}
}
return result;
},
);
/**
* Build the final FormState compatible with @mintel/pdf.
*/
private buildFormState(): Record<string, any> {
const facts = this.state.concept.auditedFacts || {};
return {
projectType: "website",
...facts,
briefingSummary: this.state.concept.strategy.briefingSummary || "",
designVision: this.state.concept.strategy.designVision || "",
sitemap: this.state.concept.architecture.sitemap || [],
positionDescriptions: this.state.positionDescriptions || {},
websiteTopic: this.state.concept.architecture.websiteTopic || facts.websiteTopic || "",
statusQuo: facts.isRelaunch ? "Relaunch" : "Neuentwicklung",
name: facts.personName || "",
email: facts.email || "",
};
}
// Step 7: Deterministic validation
await this.runStep("07-validate", "Deterministic Validation", async () => {
// Build the merged form state first
this.state.formState = this.buildFormState();
const validationResult = validateEstimation(this.state);
this.state.validationResult = validationResult;
/**
* Save the full state to disk for later re-use.
*/
private async saveState(): Promise<void> {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const companyName = this.state.concept.auditedFacts?.companyName || "unknown";
// Save full state
const stateDir = path.join(this.config.outputDir, "json");
await fs.mkdir(stateDir, { recursive: true });
const statePath = path.join(stateDir, `${companyName}_${timestamp}.json`);
await fs.writeFile(statePath, JSON.stringify(this.state.formState, null, 2));
console.log(`\n📦 Saved state to: ${statePath}`);
// Save full pipeline state (for debugging / re-entry)
const debugPath = path.join(stateDir, `${companyName}_${timestamp}_debug.json`);
await fs.writeFile(debugPath, JSON.stringify(this.state, null, 2));
// Print usage summary
console.log("\n──────────────────────────────────────────────");
console.log("📊 PIPELINE USAGE SUMMARY");
console.log("──────────────────────────────────────────────");
for (const step of this.state.usage.perStep) {
if (step.cost > 0) {
console.log(` ${step.step}: ${step.model} — $${step.cost.toFixed(6)} (${(step.durationMs / 1000).toFixed(1)}s)`);
}
if (!validationResult.passed) {
console.log("\n⚠️ Validation Issues:");
for (const error of validationResult.errors) {
console.log(` ❌ [${error.code}] ${error.message}`);
}
console.log("──────────────────────────────────────────────");
console.log(` TOTAL: $${this.state.usage.totalCost.toFixed(6)}`);
console.log(` Tokens: ${(this.state.usage.totalPromptTokens + this.state.usage.totalCompletionTokens).toLocaleString()}`);
console.log("──────────────────────────────────────────────\n");
}
}
if (validationResult.warnings.length > 0) {
console.log("\n⚡ Warnings:");
for (const warning of validationResult.warnings) {
console.log(` ⚡ [${warning.code}] ${warning.message}`);
if (warning.suggestion) console.log(` → ${warning.suggestion}`);
}
}
/** Get the current state (for CLI inspection). */
getState(): EstimationState {
return this.state;
}
return {
success: true,
data: validationResult,
usage: {
step: "07-validate",
model: "none",
promptTokens: 0,
completionTokens: 0,
cost: 0,
durationMs: 0,
},
};
});
/** Load a saved state from JSON. */
async loadState(jsonPath: string): Promise<void> {
const raw = await fs.readFile(jsonPath, "utf8");
const formState = JSON.parse(raw);
this.state.formState = formState;
// Save final state
await this.saveState();
return this.state;
}
/**
* Run a single step with callbacks and error handling.
*/
private async runStep(
stepId: string,
stepName: string,
executor: () => Promise<StepResult>,
): Promise<void> {
this.callbacks.onStepStart?.(stepId, stepName);
console.log(`\n📍 ${stepName}...`);
try {
const result = await executor();
if (result.usage) {
this.state.usage.perStep.push(result.usage);
this.state.usage.totalPromptTokens += result.usage.promptTokens;
this.state.usage.totalCompletionTokens += result.usage.completionTokens;
this.state.usage.totalCost += result.usage.cost;
}
if (result.success) {
const cost = result.usage?.cost
? ` ($${result.usage.cost.toFixed(4)})`
: "";
const duration = result.usage?.durationMs
? ` [${(result.usage.durationMs / 1000).toFixed(1)}s]`
: "";
console.log(` ✅ ${stepName} complete${cost}${duration}`);
this.callbacks.onStepComplete?.(stepId, result);
} else {
console.error(` ❌ ${stepName} failed: ${result.error}`);
this.callbacks.onStepError?.(stepId, result.error || "Unknown error");
throw new Error(result.error);
}
} catch (err) {
const errorMsg = (err as Error).message;
this.callbacks.onStepError?.(stepId, errorMsg);
throw err;
}
}
/**
* Build the final FormState compatible with @mintel/pdf.
*/
private buildFormState(): Record<string, any> {
const facts = this.state.concept.auditedFacts || {};
return {
projectType: "website",
...facts,
briefingSummary: this.state.concept.strategy.briefingSummary || "",
designVision: this.state.concept.strategy.designVision || "",
sitemap: this.state.concept.architecture.sitemap || [],
positionDescriptions: this.state.positionDescriptions || {},
websiteTopic:
this.state.concept.architecture.websiteTopic ||
facts.websiteTopic ||
"",
statusQuo: facts.isRelaunch ? "Relaunch" : "Neuentwicklung",
name: facts.personName || "",
email: facts.email || "",
};
}
/**
* Save the full state to disk for later re-use.
*/
private async saveState(): Promise<void> {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const companyName =
this.state.concept.auditedFacts?.companyName || "unknown";
// Save full state
const stateDir = path.join(this.config.outputDir, "json");
await fs.mkdir(stateDir, { recursive: true });
const statePath = path.join(stateDir, `${companyName}_${timestamp}.json`);
await fs.writeFile(
statePath,
JSON.stringify(this.state.formState, null, 2),
);
console.log(`\n📦 Saved state to: ${statePath}`);
// Save full pipeline state (for debugging / re-entry)
const debugPath = path.join(
stateDir,
`${companyName}_${timestamp}_debug.json`,
);
await fs.writeFile(debugPath, JSON.stringify(this.state, null, 2));
// Print usage summary
console.log("\n──────────────────────────────────────────────");
console.log("📊 PIPELINE USAGE SUMMARY");
console.log("──────────────────────────────────────────────");
for (const step of this.state.usage.perStep) {
if (step.cost > 0) {
console.log(
` ${step.step}: ${step.model} — $${step.cost.toFixed(6)} (${(step.durationMs / 1000).toFixed(1)}s)`,
);
}
}
console.log("──────────────────────────────────────────────");
console.log(` TOTAL: $${this.state.usage.totalCost.toFixed(6)}`);
console.log(
` Tokens: ${(this.state.usage.totalPromptTokens + this.state.usage.totalCompletionTokens).toLocaleString()}`,
);
console.log("──────────────────────────────────────────────\n");
}
/** Get the current state (for CLI inspection). */
getState(): EstimationState {
return this.state;
}
/** Load a saved state from JSON. */
async loadState(jsonPath: string): Promise<void> {
const raw = await fs.readFile(jsonPath, "utf8");
const formState = JSON.parse(raw);
this.state.formState = formState;
}
}
+61 -61
View File
@@ -2,112 +2,112 @@
// @mintel/estimation-engine — Core Type Definitions
// ============================================================================
import type { ProjectConcept, SitemapCategory } from "@mintel/concept-engine";
import type { ProjectConcept } from "@mintel/concept-engine";
/** Configuration for the estimation pipeline */
export interface PipelineConfig {
openrouterKey: string;
zyteApiKey?: string;
outputDir: string;
crawlDir: string;
modelsOverride?: Partial<ModelConfig>;
openrouterKey: string;
zyteApiKey?: string;
outputDir: string;
crawlDir: string;
modelsOverride?: Partial<ModelConfig>;
}
/** Model routing configuration */
export interface ModelConfig {
flash: string;
pro: string;
opus: string;
flash: string;
pro: string;
opus: string;
}
export const DEFAULT_MODELS: ModelConfig = {
flash: "google/gemini-3-flash-preview",
pro: "google/gemini-3.1-pro-preview",
opus: "anthropic/claude-opus-4-6",
flash: "google/gemini-3-flash-preview",
pro: "google/gemini-3.1-pro-preview",
opus: "anthropic/claude-opus-4-6",
};
/** Input for the estimation pipeline */
export interface PipelineInput {
concept: ProjectConcept;
budget?: string;
concept: ProjectConcept;
budget?: string;
}
/** State that flows through all pipeline steps */
export interface EstimationState {
// Input
concept: ProjectConcept;
budget?: string;
// Input
concept: ProjectConcept;
budget?: string;
// Step 5 output: Position Synthesis
positionDescriptions?: Record<string, string>;
// Step 5 output: Position Synthesis
positionDescriptions?: Record<string, string>;
// Step 6 output: Critique
critiquePassed?: boolean;
critiqueErrors?: string[];
// Step 6 output: Critique
critiquePassed?: boolean;
critiqueErrors?: string[];
// Step 7 output: Validation
validationResult?: ValidationResult;
// Step 7 output: Validation
validationResult?: ValidationResult;
// Final merged form state for PDF generation
formState?: Record<string, any>;
// Final merged form state for PDF generation
formState?: Record<string, any>;
// Cost tracking
usage: UsageStats;
// Cost tracking
usage: UsageStats;
}
export interface UsageStats {
totalPromptTokens: number;
totalCompletionTokens: number;
totalCost: number;
perStep: StepUsage[];
totalPromptTokens: number;
totalCompletionTokens: number;
totalCost: number;
perStep: StepUsage[];
}
export interface StepUsage {
step: string;
model: string;
promptTokens: number;
completionTokens: number;
cost: number;
durationMs: number;
step: string;
model: string;
promptTokens: number;
completionTokens: number;
cost: number;
durationMs: number;
}
/** Result of a single pipeline step */
export interface StepResult<T = any> {
success: boolean;
data?: T;
error?: string;
usage?: StepUsage;
success: boolean;
data?: T;
error?: string;
usage?: StepUsage;
}
/** Validation result from the deterministic validator */
export interface ValidationResult {
passed: boolean;
errors: ValidationError[];
warnings: ValidationWarning[];
passed: boolean;
errors: ValidationError[];
warnings: ValidationWarning[];
}
export interface ValidationError {
code: string;
message: string;
field?: string;
expected?: any;
actual?: any;
code: string;
message: string;
field?: string;
expected?: any;
actual?: any;
}
export interface ValidationWarning {
code: string;
message: string;
suggestion?: string;
code: string;
message: string;
suggestion?: string;
}
/** Step definition for the pipeline */
export interface PipelineStep {
id: string;
name: string;
description: string;
model: "flash" | "pro" | "opus" | "none";
execute: (
state: EstimationState,
config: PipelineConfig,
) => Promise<StepResult>;
id: string;
name: string;
description: string;
model: "flash" | "pro" | "opus" | "none";
execute: (
state: EstimationState,
config: PipelineConfig,
) => Promise<StepResult>;
}
+304 -248
View File
@@ -3,53 +3,67 @@
// Catches all the issues reported by the user programmatically.
// ============================================================================
import type { EstimationState, ValidationResult, ValidationError, ValidationWarning } from "./types.js";
import type {
EstimationState,
ValidationResult,
ValidationError,
ValidationWarning,
} from "./types.js";
/**
* Run all deterministic validation checks on the final estimation state.
*/
export function validateEstimation(state: EstimationState): ValidationResult {
const errors: ValidationError[] = [];
const warnings: ValidationWarning[] = [];
if (!state.formState) {
return { passed: false, errors: [{ code: "NO_FORM_STATE", message: "No form state available for validation." }], warnings: [] };
}
const fs = state.formState;
// 1. PAGE COUNT PARITY
validatePageCountParity(fs, errors);
// 2. SORGLOS-BETRIEB IN SUMMARY
validateSorglosBetrieb(fs, errors, warnings);
// 3. NO VIDEOS AS PAGES
validateNoVideosAsPages(fs, errors);
// 4. EXTERNAL DOMAINS NOT AS PAGES
validateExternalDomains(fs, state.concept?.siteProfile, errors);
// 5. SERVICE COVERAGE
validateServiceCoverage(fs, state.concept?.siteProfile, warnings);
// 6. EXISTING FEATURE DETECTION
validateExistingFeatures(fs, state.concept?.siteProfile, warnings);
// 7. MULTILANG LABEL CORRECTNESS
validateMultilangLabeling(fs, errors);
// 8. INITIAL-PFLEGE UNITS
validateInitialPflegeUnits(fs, warnings);
// 9. SITEMAP vs PAGE LIST CONSISTENCY
validateSitemapConsistency(fs, errors);
const errors: ValidationError[] = [];
const warnings: ValidationWarning[] = [];
if (!state.formState) {
return {
passed: errors.length === 0,
errors,
warnings,
passed: false,
errors: [
{
code: "NO_FORM_STATE",
message: "No form state available for validation.",
},
],
warnings: [],
};
}
const fs = state.formState;
// 1. PAGE COUNT PARITY
validatePageCountParity(fs, errors);
// 2. SORGLOS-BETRIEB IN SUMMARY
validateSorglosBetrieb(fs, errors, warnings);
// 3. NO VIDEOS AS PAGES
validateNoVideosAsPages(fs, errors);
// 4. EXTERNAL DOMAINS NOT AS PAGES
validateExternalDomains(fs, state.concept?.siteProfile, errors);
// 5. SERVICE COVERAGE
validateServiceCoverage(fs, state.concept?.siteProfile, warnings);
// 6. EXISTING FEATURE DETECTION
validateExistingFeatures(fs, state.concept?.siteProfile, warnings);
// 7. MULTILANG LABEL CORRECTNESS
validateMultilangLabeling(fs, errors);
// 8. INITIAL-PFLEGE UNITS
validateInitialPflegeUnits(fs, warnings);
// 9. SITEMAP vs PAGE LIST CONSISTENCY
validateSitemapConsistency(fs, errors);
return {
passed: errors.length === 0,
errors,
warnings,
};
}
/**
@@ -63,57 +77,62 @@ export function validateEstimation(state: EstimationState): ValidationResult {
* Instead, we verify that the position description text lists the right count.
*/
function validatePageCountParity(
fs: Record<string, any>,
errors: ValidationError[],
fs: Record<string, any>,
errors: ValidationError[],
): void {
// Count pages listed in the sitemap (the source of truth)
let sitemapPageCount = 0;
if (Array.isArray(fs.sitemap)) {
for (const cat of fs.sitemap) {
sitemapPageCount += (cat.pages || []).length;
}
// Count pages listed in the sitemap (the source of truth)
let sitemapPageCount = 0;
if (Array.isArray(fs.sitemap)) {
for (const cat of fs.sitemap) {
sitemapPageCount += (cat.pages || []).length;
}
if (sitemapPageCount === 0) return;
}
if (sitemapPageCount === 0) return;
// Extract page names mentioned in the "Individuelle Seiten" position description
const positions = fs.positionDescriptions || {};
const pagesDesc = positions["Individuelle Seiten"] || positions["2. Individuelle Seiten"] || "";
if (!pagesDesc) return;
// Extract page names mentioned in the "Individuelle Seiten" position description
const positions = fs.positionDescriptions || {};
const pagesDesc =
positions["Individuelle Seiten"] ||
positions["2. Individuelle Seiten"] ||
"";
if (!pagesDesc) return;
const descStr = typeof pagesDesc === "string" ? pagesDesc : "";
const descStr = typeof pagesDesc === "string" ? pagesDesc : "";
// Count distinct page names mentioned (split by comma)
// We avoid splitting by "&" or "und" because actual page names like
// "Wartung & Störungsdienst" or "Genehmigungs- und Ausführungsplanung" contain them.
const afterColon = descStr.includes(":") ? descStr.split(":").slice(1).join(":") : descStr;
const segments = afterColon
.split(/,/)
.map((s: string) => s.replace(/\.$/, "").trim())
.filter((s: string) => s.length > 2);
// Count distinct page names mentioned (split by comma)
// We avoid splitting by "&" or "und" because actual page names like
// "Wartung & Störungsdienst" or "Genehmigungs- und Ausführungsplanung" contain them.
const afterColon = descStr.includes(":")
? descStr.split(":").slice(1).join(":")
: descStr;
const segments = afterColon
.split(/,/)
.map((s: string) => s.replace(/\.$/, "").trim())
.filter((s: string) => s.length > 2);
// Handle consolidated references like "Leistungen (6 Unterseiten)" or "(inkl. Messen)"
let mentionedCount = 0;
for (const seg of segments) {
const subPageMatch = seg.match(/\((\d+)\s+(?:Unter)?[Ss]eiten?\)/);
if (subPageMatch) {
mentionedCount += parseInt(subPageMatch[1], 10);
} else if (seg.match(/\(inkl\.\s+/)) {
// "Unternehmen (inkl. Messen)" = 2 pages
mentionedCount += 2;
} else {
mentionedCount += 1;
}
// Handle consolidated references like "Leistungen (6 Unterseiten)" or "(inkl. Messen)"
let mentionedCount = 0;
for (const seg of segments) {
const subPageMatch = seg.match(/\((\d+)\s+(?:Unter)?[Ss]eiten?\)/);
if (subPageMatch) {
mentionedCount += parseInt(subPageMatch[1], 10);
} else if (seg.match(/\(inkl\.\s+/)) {
// "Unternehmen (inkl. Messen)" = 2 pages
mentionedCount += 2;
} else {
mentionedCount += 1;
}
}
if (mentionedCount > 0 && Math.abs(mentionedCount - sitemapPageCount) > 2) {
errors.push({
code: "PAGE_COUNT_MISMATCH",
message: `Seiten-Beschreibung nennt ~${mentionedCount} Seiten, aber ${sitemapPageCount} Seiten in der Sitemap.`,
field: "positionDescriptions.Individuelle Seiten",
expected: sitemapPageCount,
actual: mentionedCount,
});
}
if (mentionedCount > 0 && Math.abs(mentionedCount - sitemapPageCount) > 2) {
errors.push({
code: "PAGE_COUNT_MISMATCH",
message: `Seiten-Beschreibung nennt ~${mentionedCount} Seiten, aber ${sitemapPageCount} Seiten in der Sitemap.`,
field: "positionDescriptions.Individuelle Seiten",
expected: sitemapPageCount,
actual: mentionedCount,
});
}
}
/**
@@ -121,25 +140,25 @@ function validatePageCountParity(
* "Zusammenfassung der Schätzung hat Sorglos-Betrieb nicht miteingenommen"
*/
function validateSorglosBetrieb(
fs: Record<string, any>,
errors: ValidationError[],
warnings: ValidationWarning[],
fs: Record<string, any>,
errors: ValidationError[],
_warnings: ValidationWarning[],
): void {
const positions = fs.positionDescriptions || {};
const hasPosition = Object.keys(positions).some(
(k) =>
k.toLowerCase().includes("sorglos") ||
k.toLowerCase().includes("betrieb") ||
k.toLowerCase().includes("pflege"),
);
const positions = fs.positionDescriptions || {};
const hasPosition = Object.keys(positions).some(
(k) =>
k.toLowerCase().includes("sorglos") ||
k.toLowerCase().includes("betrieb") ||
k.toLowerCase().includes("pflege"),
);
if (!hasPosition) {
errors.push({
code: "MISSING_SORGLOS_BETRIEB",
message: "Der Sorglos-Betrieb fehlt in den Position-Beschreibungen.",
field: "positionDescriptions",
});
}
if (!hasPosition) {
errors.push({
code: "MISSING_SORGLOS_BETRIEB",
message: "Der Sorglos-Betrieb fehlt in den Position-Beschreibungen.",
field: "positionDescriptions",
});
}
}
/**
@@ -147,27 +166,33 @@ function validateSorglosBetrieb(
* "Er hat Videos als eigene Seite aufgenommen"
*/
function validateNoVideosAsPages(
fs: Record<string, any>,
errors: ValidationError[],
fs: Record<string, any>,
errors: ValidationError[],
): void {
const allPages = [...(fs.selectedPages || []), ...(fs.otherPages || [])];
const sitemapPages = Array.isArray(fs.sitemap)
? fs.sitemap.flatMap((cat: any) => (cat.pages || []).map((p: any) => p.title))
: [];
const allPages = [...(fs.selectedPages || []), ...(fs.otherPages || [])];
const sitemapPages = Array.isArray(fs.sitemap)
? fs.sitemap.flatMap((cat: any) =>
(cat.pages || []).map((p: any) => p.title),
)
: [];
const allPageNames = [...allPages, ...sitemapPages];
const videoKeywords = ["video", "film", "messefilm", "imagefilm", "clip"];
const allPageNames = [...allPages, ...sitemapPages];
const videoKeywords = ["video", "film", "messefilm", "imagefilm", "clip"];
for (const pageName of allPageNames) {
const lower = (typeof pageName === "string" ? pageName : "").toLowerCase();
if (videoKeywords.some((kw) => lower.includes(kw) && !lower.includes("leistung"))) {
errors.push({
code: "VIDEO_AS_PAGE",
message: `"${pageName}" ist ein Video-Asset, keine eigene Seite.`,
field: "sitemap",
});
}
for (const pageName of allPageNames) {
const lower = (typeof pageName === "string" ? pageName : "").toLowerCase();
if (
videoKeywords.some(
(kw) => lower.includes(kw) && !lower.includes("leistung"),
)
) {
errors.push({
code: "VIDEO_AS_PAGE",
message: `"${pageName}" ist ein Video-Asset, keine eigene Seite.`,
field: "sitemap",
});
}
}
}
/**
@@ -175,32 +200,40 @@ function validateNoVideosAsPages(
* "er hat ingenieursgesellschaft als seite integriert, die haben aber eine eigene website"
*/
function validateExternalDomains(
fs: Record<string, any>,
siteProfile: any,
errors: ValidationError[],
fs: Record<string, any>,
siteProfile: any,
errors: ValidationError[],
): void {
if (!siteProfile?.externalDomains?.length) return;
if (!siteProfile?.externalDomains?.length) return;
const sitemapPages = Array.isArray(fs.sitemap)
? fs.sitemap.flatMap((cat: any) => (cat.pages || []).map((p: any) => p.title || ""))
: [];
const sitemapPages = Array.isArray(fs.sitemap)
? fs.sitemap.flatMap((cat: any) =>
(cat.pages || []).map((p: any) => p.title || ""),
)
: [];
for (const extDomain of siteProfile.externalDomains) {
// Extract base name (e.g. "etib-ing" from "etib-ing.com")
const baseName = extDomain.replace(/^www\./, "").split(".")[0].toLowerCase();
for (const extDomain of siteProfile.externalDomains) {
// Extract base name (e.g. "etib-ing" from "etib-ing.com")
const baseName = extDomain
.replace(/^www\./, "")
.split(".")[0]
.toLowerCase();
for (const pageTitle of sitemapPages) {
const lower = pageTitle.toLowerCase();
// Check if the page title references the external company
if (lower.includes(baseName) || (lower.includes("ingenieur") && extDomain.includes("ing"))) {
errors.push({
code: "EXTERNAL_DOMAIN_AS_PAGE",
message: `"${pageTitle}" hat eine eigene Website (${extDomain}) und darf nicht als Unterseite vorgeschlagen werden.`,
field: "sitemap",
});
}
}
for (const pageTitle of sitemapPages) {
const lower = pageTitle.toLowerCase();
// Check if the page title references the external company
if (
lower.includes(baseName) ||
(lower.includes("ingenieur") && extDomain.includes("ing"))
) {
errors.push({
code: "EXTERNAL_DOMAIN_AS_PAGE",
message: `"${pageTitle}" hat eine eigene Website (${extDomain}) und darf nicht als Unterseite vorgeschlagen werden.`,
field: "sitemap",
});
}
}
}
}
/**
@@ -208,30 +241,30 @@ function validateExternalDomains(
* "er hat leistungen ausgelassen die ganz klar auf der kompetenz seite genannt werden"
*/
function validateServiceCoverage(
fs: Record<string, any>,
siteProfile: any,
warnings: ValidationWarning[],
fs: Record<string, any>,
siteProfile: any,
warnings: ValidationWarning[],
): void {
if (!siteProfile?.services?.length) return;
if (!siteProfile?.services?.length) return;
const allContent = JSON.stringify(fs).toLowerCase();
const allContent = JSON.stringify(fs).toLowerCase();
for (const service of siteProfile.services) {
const keywords = service
.toLowerCase()
.split(/[\s,&-]+/)
.filter((w: string) => w.length > 4);
for (const service of siteProfile.services) {
const keywords = service
.toLowerCase()
.split(/[\s,&-]+/)
.filter((w: string) => w.length > 4);
const isCovered = keywords.some((kw: string) => allContent.includes(kw));
const isCovered = keywords.some((kw: string) => allContent.includes(kw));
if (!isCovered && service.length > 5) {
warnings.push({
code: "MISSING_SERVICE",
message: `Bestehende Leistung "${service}" ist nicht in der Schätzung berücksichtigt.`,
suggestion: `Prüfen ob "${service}" im Briefing gewünscht ist und ggf. in die Seitenstruktur aufnehmen.`,
});
}
if (!isCovered && service.length > 5) {
warnings.push({
code: "MISSING_SERVICE",
message: `Bestehende Leistung "${service}" ist nicht in der Schätzung berücksichtigt.`,
suggestion: `Prüfen ob "${service}" im Briefing gewünscht ist und ggf. in die Seitenstruktur aufnehmen.`,
});
}
}
}
/**
@@ -239,32 +272,32 @@ function validateServiceCoverage(
* "er hat die suchfunktion nicht bemerkt, die gibts schon auf der seite"
*/
function validateExistingFeatures(
fs: Record<string, any>,
siteProfile: any,
warnings: ValidationWarning[],
fs: Record<string, any>,
siteProfile: any,
warnings: ValidationWarning[],
): void {
if (!siteProfile?.existingFeatures?.length) return;
if (!siteProfile?.existingFeatures?.length) return;
const functions = fs.functions || [];
const features = fs.features || [];
const allSelected = [...functions, ...features];
const functions = fs.functions || [];
const features = fs.features || [];
const allSelected = [...functions, ...features];
for (const existingFeature of siteProfile.existingFeatures) {
if (existingFeature === "cookie-consent") continue; // Standard, don't flag
if (existingFeature === "video") continue; // Usually an asset, not a feature
for (const existingFeature of siteProfile.existingFeatures) {
if (existingFeature === "cookie-consent") continue; // Standard, don't flag
if (existingFeature === "video") continue; // Usually an asset, not a feature
const isMapped = allSelected.some(
(f: string) => f.toLowerCase() === existingFeature.toLowerCase(),
);
const isMapped = allSelected.some(
(f: string) => f.toLowerCase() === existingFeature.toLowerCase(),
);
if (!isMapped) {
warnings.push({
code: "EXISTING_FEATURE_IGNORED",
message: `Die bestehende Suchfunktion/Feature "${existingFeature}" wurde auf der aktuellen Website erkannt, aber nicht in der Schätzung berücksichtigt.`,
suggestion: `"${existingFeature}" als Function oder Feature aufnehmen, da es bereits existiert und der Kunde es erwartet.`,
});
}
if (!isMapped) {
warnings.push({
code: "EXISTING_FEATURE_IGNORED",
message: `Die bestehende Suchfunktion/Feature "${existingFeature}" wurde auf der aktuellen Website erkannt, aber nicht in der Schätzung berücksichtigt.`,
suggestion: `"${existingFeature}" als Function oder Feature aufnehmen, da es bereits existiert und der Kunde es erwartet.`,
});
}
}
}
/**
@@ -272,27 +305,30 @@ function validateExistingFeatures(
* "die +20% beziehen sich nicht auf API"
*/
function validateMultilangLabeling(
fs: Record<string, any>,
errors: ValidationError[],
fs: Record<string, any>,
errors: ValidationError[],
): void {
const positions = fs.positionDescriptions || {};
const positions = fs.positionDescriptions || {};
for (const [key, desc] of Object.entries(positions)) {
if (key.toLowerCase().includes("api") || key.toLowerCase().includes("schnittstelle")) {
const descStr = typeof desc === "string" ? desc : "";
if (
descStr.toLowerCase().includes("mehrsprach") ||
descStr.toLowerCase().includes("multilang") ||
descStr.toLowerCase().includes("20%")
) {
errors.push({
code: "MULTILANG_WRONG_POSITION",
message: `Mehrsprachigkeit (+20%) ist unter "${key}" eingeordnet, gehört aber nicht zu API/Schnittstellen.`,
field: key,
});
}
}
for (const [key, desc] of Object.entries(positions)) {
if (
key.toLowerCase().includes("api") ||
key.toLowerCase().includes("schnittstelle")
) {
const descStr = typeof desc === "string" ? desc : "";
if (
descStr.toLowerCase().includes("mehrsprach") ||
descStr.toLowerCase().includes("multilang") ||
descStr.toLowerCase().includes("20%")
) {
errors.push({
code: "MULTILANG_WRONG_POSITION",
message: `Mehrsprachigkeit (+20%) ist unter "${key}" eingeordnet, gehört aber nicht zu API/Schnittstellen.`,
field: key,
});
}
}
}
}
/**
@@ -300,81 +336,101 @@ function validateMultilangLabeling(
* "Initialpflege => 100€/Stk => damit sind keine Seiten sondern Datensätze"
*/
function validateInitialPflegeUnits(
fs: Record<string, any>,
warnings: ValidationWarning[],
fs: Record<string, any>,
warnings: ValidationWarning[],
): void {
const positions = fs.positionDescriptions || {};
const positions = fs.positionDescriptions || {};
for (const [key, desc] of Object.entries(positions)) {
if (key.toLowerCase().includes("pflege") || key.toLowerCase().includes("initial")) {
const descStr = typeof desc === "string" ? desc : "";
if (descStr.toLowerCase().includes("seiten") && !descStr.toLowerCase().includes("datensätz")) {
warnings.push({
code: "INITIALPFLEGE_WRONG_UNIT",
message: `"${key}" spricht von "Seiten", aber gemeint sind Datensätze (z.B. Produkte, Referenzen).`,
suggestion: `Beschreibung auf "Datensätze" statt "Seiten" ändern.`,
});
}
}
for (const [key, desc] of Object.entries(positions)) {
if (
key.toLowerCase().includes("pflege") ||
key.toLowerCase().includes("initial")
) {
const descStr = typeof desc === "string" ? desc : "";
if (
descStr.toLowerCase().includes("seiten") &&
!descStr.toLowerCase().includes("datensätz")
) {
warnings.push({
code: "INITIALPFLEGE_WRONG_UNIT",
message: `"${key}" spricht von "Seiten", aber gemeint sind Datensätze (z.B. Produkte, Referenzen).`,
suggestion: `Beschreibung auf "Datensätze" statt "Seiten" ändern.`,
});
}
}
}
}
/**
* 9. Position descriptions must match calculated quantities.
*/
/**
* 9. Position descriptions must match calculated quantities.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function validatePositionDescriptionsMath(
fs: Record<string, any>,
errors: ValidationError[],
fs: Record<string, any>,
errors: ValidationError[],
): void {
const positions = fs.positionDescriptions || {};
const positions = fs.positionDescriptions || {};
// Check pages description mentions correct count
const pagesDesc = positions["Individuelle Seiten"] || positions["2. Individuelle Seiten"] || "";
if (pagesDesc) {
// Use the sitemap as the authoritative source of truth for page count
let sitemapPageCount = 0;
if (Array.isArray(fs.sitemap)) {
for (const cat of fs.sitemap) {
sitemapPageCount += (cat.pages || []).length;
}
}
// Count how many page names are mentioned in the description
const descStr = typeof pagesDesc === "string" ? pagesDesc : "";
const mentionedPages = descStr.split(/,|und|&/).filter((s: string) => s.trim().length > 2);
if (sitemapPageCount > 0 && mentionedPages.length > 0 && Math.abs(mentionedPages.length - sitemapPageCount) > 2) {
errors.push({
code: "PAGES_DESC_COUNT_MISMATCH",
message: `Seiten-Beschreibung nennt ~${mentionedPages.length} Seiten, aber ${sitemapPageCount} in der Sitemap.`,
field: "positionDescriptions.Individuelle Seiten",
expected: sitemapPageCount,
actual: mentionedPages.length,
});
}
// Check pages description mentions correct count
const pagesDesc =
positions["Individuelle Seiten"] ||
positions["2. Individuelle Seiten"] ||
"";
if (pagesDesc) {
// Use the sitemap as the authoritative source of truth for page count
let sitemapPageCount = 0;
if (Array.isArray(fs.sitemap)) {
for (const cat of fs.sitemap) {
sitemapPageCount += (cat.pages || []).length;
}
}
// Count how many page names are mentioned in the description
const descStr = typeof pagesDesc === "string" ? pagesDesc : "";
const mentionedPages = descStr
.split(/,|und|&/)
.filter((s: string) => s.trim().length > 2);
if (
sitemapPageCount > 0 &&
mentionedPages.length > 0 &&
Math.abs(mentionedPages.length - sitemapPageCount) > 2
) {
errors.push({
code: "PAGES_DESC_COUNT_MISMATCH",
message: `Seiten-Beschreibung nennt ~${mentionedPages.length} Seiten, aber ${sitemapPageCount} in der Sitemap.`,
field: "positionDescriptions.Individuelle Seiten",
expected: sitemapPageCount,
actual: mentionedPages.length,
});
}
}
}
/**
* 10. Sitemap categories should be consistent with selected pages/features.
*/
function validateSitemapConsistency(
fs: Record<string, any>,
errors: ValidationError[],
fs: Record<string, any>,
errors: ValidationError[],
): void {
if (!Array.isArray(fs.sitemap)) return;
if (!Array.isArray(fs.sitemap)) return;
const sitemapTitles = fs.sitemap
.flatMap((cat: any) => (cat.pages || []).map((p: any) => (p.title || "").toLowerCase()));
const sitemapTitles = fs.sitemap.flatMap((cat: any) =>
(cat.pages || []).map((p: any) => (p.title || "").toLowerCase()),
);
// Check for "Verwaltung" page (hallucinated management page)
for (const title of sitemapTitles) {
if (title.includes("verwaltung") && !title.includes("inhalt")) {
errors.push({
code: "HALLUCINATED_MANAGEMENT_PAGE",
message: `"Verwaltung" als Seite ist vermutlich halluziniert. Verwaltung ist typischerweise eine interne Funktion, keine öffentliche Webseite.`,
field: "sitemap",
});
}
// Check for "Verwaltung" page (hallucinated management page)
for (const title of sitemapTitles) {
if (title.includes("verwaltung") && !title.includes("inhalt")) {
errors.push({
code: "HALLUCINATED_MANAGEMENT_PAGE",
message: `"Verwaltung" als Seite ist vermutlich halluziniert. Verwaltung ist typischerweise eine interne Funktion, keine öffentliche Webseite.`,
field: "sitemap",
});
}
}
}
-31
View File
@@ -1,31 +0,0 @@
{
"name": "feedback-commander",
"description": "Custom High-Fidelity Management for Directus",
"icon": "extension",
"version": "1.8.21",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"directus:extension": {
"type": "module",
"path": "dist/index.js",
"source": "src/index.ts",
"host": "app",
"name": "feedback commander"
},
"scripts": {
"build": "directus-extension build",
"dev": "directus-extension build -w"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.0"
},
"repository": {
"type": "git",
"url": "https://git.infra.mintel.me/mmintel/at-mintel.git"
}
}
-14
View File
@@ -1,14 +0,0 @@
import { defineModule } from '@directus/extensions-sdk';
import ModuleComponent from './module.vue';
export default defineModule({
id: 'feedback-commander',
name: 'Feedback Commander',
icon: 'view_kanban',
routes: [
{
path: '',
component: ModuleComponent,
},
],
});
-731
View File
@@ -1,731 +0,0 @@
<template>
<private-view title="Feedback Commander">
<template #headline>
<v-breadcrumb :items="[{ name: 'Feedback', to: '/feedback-commander' }]" />
</template>
<template #title-outer:after>
<v-chip v-if="loading" label color="blue" small>Loading...</v-chip>
<v-chip v-else-if="fetchError" label color="red" small>Fetch Error</v-chip>
<v-chip v-else label color="green" small>{{ items.length }} Items</v-chip>
</template>
<template #navigation>
<div class="sidebar-header">
<v-text-overflow text="Websites" class="header-text" />
</div>
<v-list nav>
<v-list-item
:active="currentProject === 'all'"
@click="currentProject = 'all'"
clickable
>
<v-list-item-icon><v-icon name="language" /></v-list-item-icon>
<v-list-item-content><v-text-overflow text="All Projects" /></v-list-item-content>
</v-list-item>
<v-list-item
v-for="project in projects"
:key="project"
:active="currentProject === project"
@click="currentProject = project"
clickable
>
<v-list-item-icon><v-icon name="public" color="var(--primary)" /></v-list-item-icon>
<v-list-item-content><v-text-overflow :text="project || 'Unknown'" /></v-list-item-content>
</v-list-item>
</v-list>
</template>
<div class="feedback-container">
<div v-if="!items.length && !loading && !fetchError" class="empty-state">
<v-info icon="inbox" title="Clean Inbox" center>
All feedback has been processed. Great job!
</v-info>
</div>
<div v-if="fetchError" class="empty-state">
<v-info icon="error" title="Fetch Failed" :description="fetchError" center />
<v-button @click="fetchData" secondary small>Retry</v-button>
</div>
<div class="operational-layout" v-else-if="items.length">
<!-- Detailed Triage Lane -->
<aside class="triage-lane">
<div class="lane-header">
<v-select
v-model="currentStatusFilter"
:items="statusOptions"
small
placeholder="Status Filter"
/>
</div>
<div class="lane-content scrollbar">
<TransitionGroup name="list">
<div
v-for="item in filteredItems"
:key="item.id"
class="feedback-card"
:class="{ active: selectedItem?.id === item.id }"
@click="selectItem(item)"
>
<div class="card-status-bar" :style="{ background: getStatusColor(item.status || 'open') }"></div>
<div class="card-body">
<header class="card-header">
<span class="card-user">{{ item.user_name }}</span>
<span class="card-date">{{ formatDate(item.date_created || item.id) }}</span>
</header>
<div class="card-text">{{ item.text }}</div>
<footer class="card-footer">
<div class="meta-tags">
<v-chip x-small outline>{{ item.company?.name || item.project }}</v-chip>
<v-icon :name="item.type === 'bug' ? 'bug_report' : 'lightbulb'" :color="item.type === 'bug' ? '#E91E63' : '#FFC107'" small />
</div>
<v-icon v-if="selectedItem?.id === item.id" name="chevron_right" small />
</footer>
</div>
</div>
</TransitionGroup>
</div>
</aside>
<!-- Elaborated Master-Detail Desk -->
<main class="processing-desk scrollbar">
<Transition name="fade" mode="out-in">
<div v-if="selectedItem" :key="selectedItem.id" class="desk-content">
<header class="desk-header">
<div class="headline-group">
<div class="status-indicator">
<div class="status-dot" :style="{ background: getStatusColor(selectedItem.status || 'open') }"></div>
<span class="status-text">{{ capitalize(selectedItem.status || 'open') }}</span>
</div>
<h2>{{ selectedItem.user_name }}'s Submission</h2>
</div>
<div class="header-actions">
<v-button primary @click="openDeepLink(selectedItem)">
<v-icon name="open_in_new" left /> Open & Highlight
</v-button>
<v-select
v-model="selectedItem.status"
:items="statuses"
inline
@update:model-value="updateStatus"
/>
</div>
</header>
<div class="desk-grid">
<!-- Message Container -->
<div class="main-column">
<v-card class="content-card">
<v-card-title>
<v-icon name="format_quote" left />
Feedback Content
</v-card-title>
<v-card-text class="feedback-body">
<div v-if="selectedItem.screenshot" class="visual-proof">
<label class="proof-label"><v-icon name="photo" x-small /> Element Snapshot</label>
<img :src="getAssetUrl(selectedItem.screenshot)" class="screenshot-img" />
</div>
<div class="main-text">{{ selectedItem.text }}</div>
</v-card-text>
</v-card>
<section class="reply-section">
<div class="section-divider">
<v-divider />
<span class="divider-label">Internal Communication</span>
<v-divider />
</div>
<div class="thread">
<TransitionGroup name="thread-list">
<div v-for="reply in comments" :key="reply.id" class="reply-bubble">
<header class="reply-header">
<span class="reply-user">{{ reply.user_name || 'System' }}</span>
<span v-if="reply.person" class="reply-person">({{ reply.person.first_name }} {{ reply.person.last_name }})</span>
<span class="reply-date">{{ formatDate(reply.date_created || reply.id) }}</span>
</header>
<div class="reply-text">{{ reply.text }}</div>
</div>
</TransitionGroup>
<div v-if="!comments.length" class="empty-state-mini">
<v-icon name="auto_awesome" small /> No replies yet. Start the thread.
</div>
</div>
<div class="composer">
<v-textarea v-model="replyText" placeholder="Compose internal response..." auto-grow />
<div class="composer-actions">
<v-button secondary :loading="sending" @click="sendReply">Post Reply</v-button>
</div>
</div>
</section>
</div>
<!-- Technical Sidebar -->
<aside class="meta-column">
<v-card class="meta-card">
<v-card-title>Context</v-card-title>
<v-card-text class="meta-list">
<div class="meta-item">
<label><v-icon name="business" x-small /> Organisation / Firma</label>
<strong>{{ selectedItem.company?.name || selectedItem.project }}</strong>
</div>
<div v-if="selectedItem.person" class="meta-item">
<label><v-icon name="person" x-small /> Zentrale Person</label>
<strong>{{ selectedItem.person.first_name }} {{ selectedItem.person.last_name }}</strong>
</div>
<div class="meta-item">
<label><v-icon name="link" x-small /> Source Path</label>
<span class="truncate-path" :title="selectedItem.url">{{ formatUrl(selectedItem.url) }}</span>
<v-button icon small @click="openExternal(selectedItem.url)"><v-icon name="launch" /></v-button>
</div>
<v-divider />
<div class="meta-item">
<label><v-icon name="layers" x-small /> Element Trace</label>
<code class="trace-code">{{ selectedItem.selector || 'Body' }}</code>
</div>
<div class="meta-item">
<label><v-icon name="location_searching" x-small /> Precise Mark</label>
<span class="coords">X: {{ Math.round(selectedItem.x) }}px / Y: {{ Math.round(selectedItem.y) }}px</span>
</div>
<div class="meta-item">
<label><v-icon name="fingerprint" x-small /> Reference ID</label>
<code class="id-code">{{ selectedItem.id }}</code>
</div>
</v-card-text>
</v-card>
<div class="help-box">
<v-icon name="help_outline" x-small />
<span>Click "Open & Highlight" to jump directly to this element on the live site.</span>
</div>
</aside>
</div>
</div>
<div v-else class="no-selection-desk">
<v-info icon="touch_app" title="Select Feedback" center>
Choose an entry from the triage list to view details and process.
</v-info>
</div>
</Transition>
</main>
</div>
</div>
</private-view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useApi } from '@directus/extensions-sdk';
const api = useApi();
const items = ref([]);
const comments = ref([]);
const loading = ref(true);
const fetchError = ref(null);
const sending = ref(false);
const selectedItem = ref(null);
const currentProject = ref('all');
const currentStatusFilter = ref('open');
const replyText = ref('');
const statuses = [
{ text: 'Open', value: 'open', icon: 'warning', color: '#E91E63' },
{ text: 'In Progress', value: 'in_progress', icon: 'play_arrow', color: '#2196F3' },
{ text: 'Resolved', value: 'resolved', icon: 'check_circle', color: '#4CAF50' }
];
const statusOptions = [
{ text: 'All Statuses', value: 'all' },
...statuses
];
const projects = computed(() => {
const projSet = new Set(items.value.map(i => i.company?.name || i.project).filter(Boolean));
return Array.from(projSet).sort();
});
const filteredItems = computed(() => {
return items.value.filter(item => {
const projectName = item.company?.name || item.project;
const matchProject = currentProject.value === 'all' || projectName === currentProject.value;
const status = item.status || 'open';
const matchStatus = currentStatusFilter.value === 'all' || status === currentStatusFilter.value;
return matchProject && matchStatus;
});
});
async function fetchData() {
loading.value = true;
fetchError.value = null;
try {
const response = await api.get('/items/visual_feedback', {
params: {
sort: '-date_created,-id',
limit: 300,
fields: ['*', 'company.*', 'person.*']
}
});
items.value = response.data.data;
} catch (e: any) {
fetchError.value = e.message;
} finally {
loading.value = false;
}
}
async function selectItem(item) {
selectedItem.value = null;
setTimeout(async () => {
selectedItem.value = item;
comments.value = [];
try {
const response = await api.get('/items/visual_feedback_comments', {
params: {
filter: { feedback_id: { _eq: item.id } },
sort: '-date_created,-id',
fields: ['*', 'person.*']
}
});
comments.value = response.data.data;
} catch (e) {
console.error(e);
}
}, 10);
}
async function updateStatus(val) {
if (!selectedItem.value) return;
try {
await api.patch(`/items/visual_feedback/${selectedItem.value.id}`, {
status: val
});
fetchData();
} catch (e) {
console.error(e);
}
}
async function sendReply() {
if (!replyText.value.trim() || !selectedItem.value) return;
sending.value = true;
try {
const response = await api.post('/items/visual_feedback_comments', {
feedback_id: selectedItem.value.id,
user_name: 'Operator',
text: replyText.value
});
comments.value.unshift(response.data.data);
replyText.value = '';
} catch (e) {
console.error(e);
} finally {
sending.value = false;
}
}
function formatDate(dateStr) {
if (!dateStr || typeof dateStr === 'number') return 'Legacy';
return new Date(dateStr).toLocaleDateString() + ' ' + new Date(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function formatUrl(url) {
if (!url) return '';
return url.replace(/^https?:\/\//, '');
}
function capitalize(s) {
return s.charAt(0).toUpperCase() + s.slice(1).replace('_', ' ');
}
function getDeepLinkUrl(item) {
if (!item || !item.url) return '';
try {
const url = new URL(item.url);
url.searchParams.set('fb_id', item.id);
return url.toString();
} catch (e) {
return item.url + '?fb_id=' + item.id;
}
}
function openDeepLink(item) {
const url = getDeepLinkUrl(item);
if (url) window.open(url, '_blank');
}
function openExternal(url) {
if (url) window.open(url, '_blank');
}
function getAssetUrl(id) {
if (!id) return '';
return `/assets/${id}`;
}
function getStatusColor(status) {
const s = statuses.find(st => st.value === status);
return s ? s.color : 'var(--foreground-subdued)';
}
onMounted(() => {
fetchData();
});
</script>
<style scoped>
.feedback-container {
height: calc(100vh - 64px);
display: flex;
flex-direction: column;
background: var(--background-subdued);
}
.operational-layout {
display: flex;
height: 100%;
}
/* Triage Lane Polish */
.triage-lane {
width: 360px;
height: 100%;
display: flex;
flex-direction: column;
background: var(--background-normal);
border-right: 1px solid var(--border-normal);
box-shadow: 2px 0 8px rgba(0,0,0,0.02);
}
.lane-header {
padding: 16px;
background: var(--background-normal);
border-bottom: 1px solid var(--border-normal);
}
.lane-content {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.feedback-card {
background: var(--background-normal);
border: 1px solid var(--border-subdued);
border-radius: 8px;
display: flex;
overflow: hidden;
cursor: pointer;
transition: all var(--transition);
}
.feedback-card:hover {
border-color: var(--border-normal);
background: var(--background-subdued);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.04);
}
.feedback-card.active {
border-color: var(--primary);
background: var(--background-accent);
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.1);
}
.card-status-bar {
width: 4px;
}
.card-body {
flex: 1;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.card-header {
display: flex;
justify-content: space-between;
font-size: 11px;
}
.card-user { font-weight: bold; color: var(--foreground-normal); }
.card-date { color: var(--foreground-subdued); }
.card-text {
font-size: 13px;
line-height: 1.5;
color: var(--foreground-normal);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.meta-tags {
display: flex;
gap: 8px;
align-items: center;
}
/* Processing Desk Refinement */
.processing-desk {
flex: 1;
height: 100%;
overflow-y: auto;
padding: 32px;
}
.desk-content {
max-width: 1100px;
margin: 0 auto;
}
.desk-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 32px;
border-bottom: 2px solid var(--border-normal);
padding-bottom: 20px;
}
.headline-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
color: var(--foreground-subdued);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-text { letter-spacing: 0.5px; }
.header-actions {
display: flex;
gap: 16px;
align-items: center;
}
.desk-grid {
display: grid;
grid-template-columns: 1fr 300px;
gap: 24px;
align-items: start;
}
.content-card {
border-radius: 12px;
overflow: hidden;
}
.feedback-body {
font-size: 18px;
line-height: 1.6;
padding: 24px;
color: var(--foreground-normal);
display: flex;
flex-direction: column;
gap: 20px;
}
.visual-proof {
display: flex;
flex-direction: column;
gap: 8px;
}
.proof-label {
font-size: 10px;
text-transform: uppercase;
font-weight: 800;
color: var(--foreground-subdued);
letter-spacing: 0.5px;
}
.screenshot-img {
width: 100%;
border-radius: 8px;
border: 1px solid var(--border-normal);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
background: var(--background-subdued);
}
.main-text {
white-space: pre-wrap;
}
.reply-section {
margin-top: 40px;
}
.section-divider {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
}
.divider-label {
font-size: 11px;
text-transform: uppercase;
font-weight: 800;
color: var(--foreground-subdued);
white-space: nowrap;
letter-spacing: 1px;
}
.thread {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 32px;
}
.reply-bubble {
padding: 16px;
border-radius: 12px;
background: var(--background-normal);
border: 1px solid var(--border-subdued);
}
.reply-header {
display: flex;
justify-content: space-between;
font-size: 11px;
margin-bottom: 8px;
}
.reply-user { font-weight: 800; color: var(--primary); }
.reply-date { color: var(--foreground-subdued); }
.reply-text { font-size: 14px; line-height: 1.5; }
.composer {
background: var(--background-normal);
border: 1px solid var(--border-normal);
border-radius: 12px;
padding: 16px;
}
.composer-actions {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.meta-card {
border-radius: 12px;
}
.meta-list {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
}
.meta-item {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
}
.meta-item label {
font-size: 10px;
text-transform: uppercase;
font-weight: bold;
color: var(--foreground-subdued);
display: flex;
align-items: center;
gap: 4px;
}
.truncate-path {
color: var(--primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trace-code, .id-code {
background: var(--background-subdued);
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
word-break: break-all;
}
.coords { font-weight: bold; font-family: var(--family-monospace); }
.help-box {
margin-top: 20px;
padding: 16px;
background: rgba(var(--primary-rgb), 0.05);
border-radius: 12px;
font-size: 12px;
color: var(--primary);
display: flex;
gap: 8px;
line-height: 1.4;
}
.no-selection-desk {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.empty-state-mini {
text-align: center;
padding: 24px;
font-size: 12px;
color: var(--foreground-subdued);
background: var(--background-subdued);
border-radius: 12px;
border: 1px dashed var(--border-normal);
}
/* Animations */
.list-enter-active, .list-leave-active { transition: all 0.3s ease; }
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(-20px); }
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
.fade-enter-from { opacity: 0; transform: translateY(10px); }
.fade-leave-to { opacity: 0; transform: translateY(-10px); }
.thread-list-enter-active { transition: all 0.4s ease; transform-origin: top; }
.thread-list-enter-from { opacity: 0; transform: scaleY(0.9); }
.scrollbar::-webkit-scrollbar { width: 6px; }
.scrollbar::-webkit-scrollbar-track { background: transparent; }
.scrollbar::-webkit-scrollbar-thumb { background: var(--border-subdued); border-radius: 3px; }
.scrollbar::-webkit-scrollbar-thumb:hover { background: var(--border-normal); }
</style>
+1
View File
@@ -19,6 +19,7 @@
},
"dependencies": {
"@tensorflow/tfjs": "^4.22.0",
"@tensorflow/tfjs-backend-wasm": "^4.22.0",
"@vladmandic/face-api": "^1.7.15",
"canvas": "^3.2.1",
"sharp": "^0.33.2"
@@ -3,7 +3,6 @@ import { Canvas, Image, ImageData } from "canvas";
// Use the ESM no-bundle build to avoid the default Node entrypoint
// which hardcodes require('@tensorflow/tfjs-node') and crashes in Docker.
// This build uses pure @tensorflow/tfjs (JS-only, no native C++ bindings).
// @ts-ignore - direct path import has no type declarations
import * as faceapi from "@vladmandic/face-api/dist/face-api.esm-nobundle.js";
import * as tf from "@tensorflow/tfjs";
import path from "path";
+13 -15
View File
@@ -1,19 +1,17 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
clean: true,
// Bundle face-api and tensorflow inline (they're pure JS).
// Keep sharp and canvas external (they have native C++ bindings).
noExternal: [
"@vladmandic/face-api",
"@tensorflow/tfjs",
"@tensorflow/tfjs-backend-wasm"
],
external: [
"sharp",
"canvas"
],
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
clean: true,
// Bundle face-api and tensorflow inline (they're pure JS).
// Keep sharp and canvas external (they have native C++ bindings).
noExternal: ["@vladmandic/face-api", "@tensorflow/tfjs"],
external: [
"sharp",
"canvas",
"@tensorflow/tfjs-backend-wasm",
"@tensorflow/tfjs-backend-wasm/dist/index.js",
],
});
-1
View File
@@ -11,7 +11,6 @@
"templates"
],
"devDependencies": {
"@directus/sdk": "^21.0.0",
"@mintel/next-utils": "workspace:*",
"@mintel/tsconfig": "workspace:*",
"typescript": "^5.0.0"
@@ -1,53 +0,0 @@
import {
createMintelDirectusClient,
ensureDirectusAuthenticated,
} from "@mintel/next-utils";
import { updateSettings } from "@directus/sdk";
const client = createMintelDirectusClient();
async function setupBranding() {
const prjName = process.env.PROJECT_NAME || "Mintel Project";
const prjColor = process.env.PROJECT_COLOR || "#82ed20";
console.log(`🎨 Setup Directus Branding for ${prjName}...`);
await ensureDirectusAuthenticated(client);
const cssInjection = `
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
body, .v-app { font-family: 'Inter', sans-serif !important; }
.public-view .v-card {
backdrop-filter: blur(20px);
border-radius: 32px !important;
box-shadow: 0 50px 100px -20px rgba(0, 0, 0, 0.4) !important;
}
.v-navigation-drawer { background: #000c24 !important; }
</style>
<div style="font-family: 'Inter', sans-serif; text-align: center; margin-top: 24px;">
<p style="color: rgba(255,255,255,0.7); font-size: 14px; margin-bottom: 4px; font-weight: 500;">MINTEL INFRASTRUCTURE ENGINE</p>
<h1 style="color: #ffffff; font-size: 18px; font-weight: 700; margin: 0;">${prjName.toUpperCase()} <span style="color: ${prjColor};">RELIABILITY.</span></h1>
</div>
`;
try {
await client.request(
updateSettings({
project_name: prjName,
project_color: prjColor,
public_note: cssInjection,
theme_light_overrides: {
primary: prjColor,
borderRadius: "16px",
navigationBackground: "#000c24",
navigationForeground: "#ffffff",
},
} as any),
);
console.log("✨ Branding applied!");
} catch (error) {
console.error("❌ Error setting up branding:", error);
}
}
setupBranding();
@@ -1,12 +0,0 @@
import {
createMintelDirectusClient,
ensureDirectusAuthenticated,
} from "@mintel/next-utils";
const client = createMintelDirectusClient(process.env.DIRECTUS_URL);
export async function ensureAuthenticated() {
await ensureDirectusAuthenticated(client);
}
export default client;
@@ -42,7 +42,7 @@ export class DataCommonsClient {
/**
* Search for entities (places, etc.)
*/
async resolveEntity(name: string): Promise<string | null> {
async resolveEntity(_name: string): Promise<string | null> {
// Search API or simple mapping for now.
// DC doesn't have a simple "search" endpoint in v2 public API easily accessible without key sometimes?
// Let's rely on LLM to provide DCIDs for now, or implement a naive search if needed.
+1 -1
View File
@@ -67,7 +67,7 @@ Rules:
async getRelatedQueries(
keyword: string,
geo: string = "DE",
_geo: string = "DE",
): Promise<string[]> {
// Simple mock to avoid API calls
return [
-1
View File
@@ -26,7 +26,6 @@
"lint": "eslint src/"
},
"dependencies": {
"@directus/sdk": "^21.0.0",
"@medv/finder": "^4.0.2",
"clsx": "^2.1.1",
"framer-motion": "^11.5.4",
+7 -88
View File
@@ -1,105 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import {
createDirectus,
rest,
staticToken,
createItem,
readItems,
} from "@directus/sdk";
export interface CMSConfig {
url: string;
token: string;
}
export function createCMSClient(config: CMSConfig) {
return createDirectus(config.url)
.with(staticToken(config.token))
.with(rest());
}
export async function handleFeedbackRequest(
req: NextRequest,
config: CMSConfig,
_config: CMSConfig,
) {
const client = createCMSClient(config);
// Feedback functionality has been disabled as Directus was removed
if (req.method === "GET") {
try {
const items = await client.request(
readItems("visual_feedback", {
fields: ["*", { comments: ["*"] }],
sort: ["-date_created"],
}),
);
return NextResponse.json(items);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json([]);
}
if (req.method === "POST") {
try {
const body = await req.json();
const { action, screenshot_base64, ...data } = body;
if (action === "reply") {
const reply = await client.request(
createItem("visual_feedback_comments", {
feedback_id: data.feedbackId,
user_name: data.userName,
text: data.text,
}),
);
return NextResponse.json(reply);
}
let screenshotId = null;
if (screenshot_base64) {
try {
const base64Data = screenshot_base64.split(";base64,").pop();
const buffer = Buffer.from(base64Data, "base64");
const formData = new FormData();
const blob = new Blob([buffer], { type: "image/png" });
formData.append("file", blob, `feedback-${Date.now()}.png`);
const fileRes = await fetch(`${config.url}/files`, {
method: "POST",
headers: { Authorization: `Bearer ${config.token}` },
body: formData,
});
if (fileRes.ok) {
const fileData = await fileRes.json();
screenshotId = fileData.data.id;
}
} catch (e) {
console.error("Failed to upload screenshot:", e);
}
}
const feedback = await client.request(
createItem("visual_feedback", {
project: data.project || req.headers.get("host") || "unknown",
url: data.url,
selector: data.selector,
x: data.x,
y: data.y,
type: data.type,
text: data.text,
user_name: data.userName,
user_identity: data.userIdentity,
status: "open",
screenshot: screenshotId,
company: data.companyId,
}),
);
return NextResponse.json(feedback);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json(
{ error: "Feedback system disabled" },
{ status: 501 },
);
}
return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
-1
View File
@@ -15,7 +15,6 @@
"test": "vitest run"
},
"dependencies": {
"@directus/sdk": "^21.0.0",
"next": "16.1.6",
"next-intl": "^4.8.2",
"zod": "^3.0.0"
-72
View File
@@ -1,72 +0,0 @@
import {
createDirectus,
rest,
authentication,
DirectusClient,
RestClient,
AuthenticationClient,
} from "@directus/sdk";
export type MintelDirectusClient<Schema extends object = any> =
DirectusClient<Schema> & RestClient<Schema> & AuthenticationClient<Schema>;
/**
* Creates a Directus client configured with Mintel standards.
* Automatically handles internal vs. external URLs based on environment.
*/
export function createMintelDirectusClient<Schema extends object = any>(
url?: string,
): MintelDirectusClient<Schema> {
const isServer = typeof window === "undefined";
// 1. If an explicit URL is provided, use it.
if (url) {
return createDirectus<Schema>(url).with(rest()).with(authentication());
}
// 2. On server: Prioritize INTERNAL_DIRECTUS_URL, fallback to DIRECTUS_URL
if (isServer) {
const directusUrl =
process.env.INTERNAL_DIRECTUS_URL ||
process.env.DIRECTUS_URL ||
"http://localhost:8055";
return createDirectus<Schema>(directusUrl)
.with(rest())
.with(authentication());
}
// 3. In browser: Use a proxy path if we are on a different origin,
// or use the current origin if no DIRECTUS_URL is set.
const proxyPath = "/api/directus"; // Standard Mintel proxy path
const browserUrl =
typeof window !== "undefined"
? `${window.location.origin}${proxyPath}`
: proxyPath;
return createDirectus<Schema>(browserUrl).with(rest()).with(authentication());
}
/**
* Ensures the client is authenticated using either a static token or admin credentials
*/
export async function ensureDirectusAuthenticated(
client: MintelDirectusClient,
) {
const token = process.env.DIRECTUS_API_TOKEN || process.env.DIRECTUS_TOKEN;
const email = process.env.DIRECTUS_ADMIN_EMAIL;
const password = process.env.DIRECTUS_ADMIN_PASSWORD;
if (token) {
client.setToken(token);
return;
}
if (email && password) {
try {
await client.login({ email, password });
} catch (e) {
console.error("Failed to authenticate with Directus:", e);
throw e;
}
}
}
-1
View File
@@ -34,4 +34,3 @@ export * from "./lang";
export * from "./i18n";
export * from "./env";
export * from "./directus";
-1
View File
@@ -22,7 +22,6 @@
"dev": "node build.mjs --watch"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"esbuild": "^0.25.0",
"typescript": "^5.6.3"
},
-34
View File
@@ -1,34 +0,0 @@
{
"name": "people-manager",
"description": "Custom High-Fidelity Management for Directus",
"icon": "extension",
"version": "1.8.21",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"directus:extension": {
"type": "module",
"path": "dist/index.js",
"source": "src/index.ts",
"host": "app",
"name": "people manager"
},
"scripts": {
"build": "directus-extension build",
"dev": "directus-extension build -w"
},
"dependencies": {
"@mintel/directus-extension-toolkit": "workspace:*"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.0"
},
"repository": {
"type": "git",
"url": "https://git.infra.mintel.me/mmintel/at-mintel.git"
}
}
-14
View File
@@ -1,14 +0,0 @@
import { defineModule } from '@directus/extensions-sdk';
import ModuleComponent from './module.vue';
export default defineModule({
id: 'people-manager',
name: 'People Manager',
icon: 'person',
routes: [
{
path: '',
component: ModuleComponent,
},
],
});
-317
View File
@@ -1,317 +0,0 @@
<template>
<MintelManagerLayout
title="People Manager"
:item-title="`${selectedPerson?.first_name} ${selectedPerson?.last_name}` || 'Person wählen'"
:is-empty="!selectedPerson"
empty-title="Person auswählen"
empty-icon="person"
:notice="feedback"
@close-notice="feedback = null"
>
<template #navigation>
<v-list nav>
<v-list-item @click="openCreateDrawer" clickable>
<v-list-item-icon>
<v-icon name="add" color="var(--theme--primary)" />
</v-list-item-icon>
<v-list-item-content>
<v-text-overflow text="Neue Person anlegen" />
</v-list-item-content>
</v-list-item>
<v-divider />
<v-list-item
v-for="person in people"
:key="person.id"
:active="selectedPerson?.id === person.id"
class="nav-item"
clickable
@click="selectPerson(person)"
>
<v-list-item-icon>
<v-icon name="person" />
</v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="`${person.first_name} ${person.last_name}`" />
</v-list-item-content>
</v-list-item>
</v-list>
</template>
<template #subtitle>
<template v-if="selectedPerson">
{{ getCompanyName(selectedPerson) }}
</template>
</template>
<template #actions>
<v-button secondary rounded icon v-tooltip.bottom="'Person bearbeiten'" @click="openEditDrawer">
<v-icon name="edit" />
</v-button>
<v-button danger rounded icon v-tooltip.bottom="'Person löschen'" @click="deletePerson">
<v-icon name="delete" />
</v-button>
</template>
<template #empty-state>
Wähle eine Person in der Navigation aus oder
<v-button x-small @click="openCreateDrawer">erstelle eine neue Person</v-button>.
</template>
<div v-if="selectedPerson" class="details-grid">
<div class="detail-item">
<span class="label">Vorname</span>
<p class="value">{{ selectedPerson.first_name }}</p>
</div>
<div class="detail-item">
<span class="label">Nachname</span>
<p class="value">{{ selectedPerson.last_name }}</p>
</div>
<div class="detail-item">
<span class="label">E-Mail</span>
<p class="value">{{ selectedPerson.email || '---' }}</p>
</div>
<div class="detail-item">
<span class="label">Organisation</span>
<p class="value">{{ getCompanyName(selectedPerson) }}</p>
</div>
</div>
<!-- Create/Edit Drawer -->
<v-drawer
v-model="drawerActive"
:title="isEditing ? 'Person bearbeiten' : 'Neue Person anlegen'"
icon="person"
@cancel="drawerActive = false"
>
<template #default>
<div class="drawer-content">
<div class="form-section">
<div class="field">
<span class="label">Vorname</span>
<v-input v-model="form.first_name" placeholder="Vorname" autofocus />
</div>
<div class="field">
<span class="label">Nachname</span>
<v-input v-model="form.last_name" placeholder="Nachname" />
</div>
<div class="field">
<span class="label">E-Mail</span>
<v-input v-model="form.email" placeholder="E-Mail Adresse" type="email" />
</div>
<div class="field">
<span class="label">Zentrale Firma</span>
<MintelSelect
v-model="form.company"
:items="companyOptions"
placeholder="Bestehende Firma auswählen..."
allow-add
@add="openQuickAdd('company')"
/>
</div>
</div>
<div class="drawer-actions">
<v-button primary block :loading="saving" @click="savePerson">
Person speichern
</v-button>
</div>
</div>
</template>
</v-drawer>
</MintelManagerLayout>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue';
import { useApi } from '@directus/extensions-sdk';
import { useRoute } from 'vue-router';
import { MintelManagerLayout, MintelSelect } from '@mintel/directus-extension-toolkit';
const api = useApi();
const route = useRoute();
const people = ref([]);
const companies = ref([]);
const selectedPerson = ref(null);
const feedback = ref(null);
const saving = ref(false);
const drawerActive = ref(false);
const isEditing = ref(false);
const form = ref({
id: null,
first_name: '',
last_name: '',
email: '',
company: null
});
const companyOptions = computed(() =>
companies.value.map(c => ({
text: c.name,
value: c.id
}))
);
function getCompanyName(person: any) {
if (!person) return '---';
if (person.company) {
return typeof person.company === 'object' ? person.company.name : (companies.value.find(c => c.id === person.company)?.name || 'Unbekannte Firma');
}
return '---';
}
async function fetchData() {
try {
const [peopleResp, companiesResp] = await Promise.all([
api.get('/items/people', {
params: {
sort: 'last_name',
fields: '*.*'
}
}),
api.get('/items/companies', {
params: { sort: 'name' }
})
]);
people.value = peopleResp.data.data;
companies.value = companiesResp.data.data;
} catch (error) {
console.error('Failed to fetch data:', error);
}
}
function selectPerson(person) {
selectedPerson.value = person;
}
function openCreateDrawer() {
isEditing.value = false;
form.value = {
id: null,
first_name: '',
last_name: '',
email: '',
company: null
};
drawerActive.value = true;
}
function openEditDrawer() {
isEditing.value = true;
const person = selectedPerson.value;
form.value = {
id: person.id,
first_name: person.first_name,
last_name: person.last_name,
email: person.email,
company: person.company?.id || person.company
};
drawerActive.value = true;
}
async function savePerson() {
if (!form.value.first_name || !form.value.last_name) {
feedback.value = { type: 'danger', message: 'Vor- und Nachname sind erforderlich.' };
return;
}
saving.value = true;
try {
let updatedItem;
if (isEditing.value) {
const res = await api.patch(`/items/people/${form.value.id}`, form.value);
updatedItem = res.data.data;
feedback.value = { type: 'success', message: 'Person aktualisiert!' };
} else {
const res = await api.post('/items/people', form.value);
updatedItem = res.data.data;
feedback.value = { type: 'success', message: 'Person angelegt!' };
}
drawerActive.value = false;
await fetchData();
if (updatedItem) {
selectedPerson.value = people.value.find(p => p.id === updatedItem.id) || updatedItem;
}
} catch (error) {
feedback.value = { type: 'danger', message: error.message };
} finally {
saving.value = false;
}
}
async function deletePerson() {
if (!confirm('Soll diese Person wirklich gelöscht werden?')) return;
try {
await api.delete(`/items/people/${selectedPerson.value.id}`);
feedback.value = { type: 'success', message: 'Person gelöscht.' };
selectedPerson.value = null;
await fetchData();
} catch (error) {
feedback.value = { type: 'danger', message: error.message };
}
}
function openQuickAdd(type: string) {
feedback.value = { type: 'info', message: `Firma im Company Manager anlegen.` };
}
onMounted(async () => {
await fetchData();
if (route.query.create === 'true') {
openCreateDrawer();
}
});
</script>
<style scoped>
.details-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 32px;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.label {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
color: var(--theme--foreground-subdued);
letter-spacing: 0.5px;
}
.value {
font-size: 16px;
font-weight: 500;
}
.drawer-content {
padding: 24px;
display: flex;
flex-direction: column;
gap: 32px;
}
.form-section {
display: flex;
flex-direction: column;
gap: 20px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
.drawer-actions {
margin-top: 24px;
}
</style>
-31
View File
@@ -1,31 +0,0 @@
{
"name": "unified-dashboard",
"description": "Custom High-Fidelity Management for Directus",
"icon": "extension",
"version": "1.8.21",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"directus:extension": {
"type": "module",
"path": "dist/index.js",
"source": "src/index.ts",
"host": "app",
"name": "unified dashboard"
},
"scripts": {
"build": "directus-extension build",
"dev": "directus-extension build -w"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.0"
},
"repository": {
"type": "git",
"url": "https://git.infra.mintel.me/mmintel/at-mintel.git"
}
}
-14
View File
@@ -1,14 +0,0 @@
import { defineModule } from '@directus/extensions-sdk';
import ModuleComponent from './module.vue';
export default defineModule({
id: 'unified-dashboard',
name: 'Overview',
icon: 'dashboard',
routes: [
{
path: '',
component: ModuleComponent,
},
],
});
-146
View File
@@ -1,146 +0,0 @@
<template>
<private-view title="Overview">
<div class="dashboard">
<header class="dashboard-header">
<h1 class="title">Infrastructure Stack</h1>
<p class="subtitle">Zentrale Schnittstelle für Firmen, Personen und Leads.</p>
</header>
<div class="stats-grid">
<div class="stat-card" @click="navigateTo('company-manager')">
<div class="stat-icon"><v-icon name="business" large /></div>
<div class="stat-content">
<span class="stat-label">Firmen</span>
<span class="stat-value">{{ stats.companies }}</span>
</div>
<v-icon name="chevron_right" class="arrow" />
</div>
<div class="stat-card" @click="navigateTo('people-manager')">
<div class="stat-icon"><v-icon name="person" large /></div>
<div class="stat-content">
<span class="stat-label">Personen</span>
<span class="stat-value">{{ stats.people }}</span>
</div>
<v-icon name="chevron_right" class="arrow" />
</div>
<div class="stat-card" @click="navigateTo('acquisition-manager')">
<div class="stat-icon"><v-icon name="auto_awesome" large /></div>
<div class="stat-content">
<span class="stat-label">Leads</span>
<span class="stat-value">{{ stats.leads }}</span>
</div>
<v-icon name="chevron_right" class="arrow" />
</div>
</div>
<div class="recent-activity">
<h2 class="section-title">Schnellzugriff</h2>
<div class="action-grid">
<v-button secondary block @click="navigateTo('people-manager', { create: 'true' })">
<v-icon name="person_add" left />
Neue Person anlegen
</v-button>
<v-button secondary block @click="navigateTo('acquisition-manager', { create: 'true' })">
<v-icon name="add_link" left />
Neuen Lead registrieren
</v-button>
<v-button secondary block @click="navigateTo('customer-manager', { create: 'true' })">
<v-icon name="handshake" left />
Kunden verlinken
</v-button>
</div>
</div>
</div>
</private-view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useApi } from '@directus/extensions-sdk';
import { useRouter } from 'vue-router';
const api = useApi();
const router = useRouter();
const stats = ref({
companies: 0,
people: 0,
leads: 0
});
async function fetchStats() {
try {
const [comp, peop, lead] = await Promise.all([
api.get('/items/companies?aggregate[count]=*'),
api.get('/items/people?aggregate[count]=*'),
api.get('/items/leads?aggregate[count]=*')
]);
stats.value = {
companies: comp.data.data[0].count,
people: peop.data.data[0].count,
leads: lead.data.data[0].count
};
} catch (error) {
console.error('Failed to fetch stats:', error);
}
}
function navigateTo(id: string, query?: any) {
console.log(`[Unified Dashboard] Navigating to ${id}...`);
router.push({ name: id, query });
}
onMounted(fetchStats);
</script>
<style scoped>
.dashboard { padding: 40px; }
.dashboard-header { margin-bottom: 48px; }
.title { font-size: 32px; font-weight: 800; letter-spacing: -0.5px; margin-bottom: 8px; }
.subtitle { color: var(--theme--foreground-subdued); font-size: 16px; }
.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; margin-bottom: 48px; }
.stat-card {
background: var(--theme--background-normal);
border: 1px solid var(--theme--border);
padding: 24px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 20px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.stat-card:hover {
border-color: var(--theme--primary);
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0,0,0,0.1);
}
.stat-icon {
width: 56px;
height: 56px;
background: var(--theme--background-subdued);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--theme--primary);
}
.stat-content { display: flex; flex-direction: column; }
.stat-label { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px; }
.stat-value { font-size: 28px; font-weight: 800; color: var(--theme--foreground); }
.arrow { position: absolute; right: 24px; opacity: 0.2; }
.stat-card:hover .arrow { opacity: 1; color: var(--theme--primary); }
.recent-activity { max-width: 600px; }
.section-title { font-size: 18px; font-weight: 700; margin-bottom: 24px; }
.action-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
</style>
+29 -2654
View File
File diff suppressed because it is too large Load Diff
-77
View File
@@ -1,77 +0,0 @@
#!/bin/bash
# Configuration
PROJECT="infra-cms"
LOCAL_SCHEMA_PATH="./packages/cms-infra/schema/snapshot.yaml"
REMOTE_HOST="root@infra.mintel.me"
REMOTE_DIR="/opt/infra/directus"
ENV=$1
if [ -z "$ENV" ]; then
echo "Usage: ./scripts/cms-apply.sh [local|infra]"
exit 1
fi
case $ENV in
local)
PROJECT="infra-cms"
# Derive monorepo root
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
ROOT_DIR="$( dirname "$SCRIPT_DIR" )"
CMD_PREFIX="docker compose -f $ROOT_DIR/packages/cms-infra/docker-compose.yml"
LOCAL_CONTAINER=$($CMD_PREFIX ps -q $PROJECT)
if [ -z "$LOCAL_CONTAINER" ]; then
echo "❌ Local $PROJECT container not found. Is it running?"
exit 1
fi
echo "🧹 Reconciling database metadata..."
./scripts/cms-reconcile.sh
echo "🚀 Applying schema to LOCAL $PROJECT..."
docker exec "$LOCAL_CONTAINER" npx directus schema apply -y /directus/schema/snapshot.yaml
;;
infra)
# 'infra' is the remote production server for at-mintel
PROJECT="directus" # Remote project name
echo "🔍 Detecting remote container..."
REMOTE_CONTAINER=$(ssh "$REMOTE_HOST" "docker ps --filter label=com.docker.compose.project=$PROJECT --filter label=com.docker.compose.service=directus -q")
if [ -z "$REMOTE_CONTAINER" ]; then
# Fallback to older name if labels fail
REMOTE_CONTAINER=$(ssh "$REMOTE_HOST" "docker ps -f name=directus-directus-1 -q")
fi
if [ -z "$REMOTE_CONTAINER" ]; then
echo "❌ Remote container for $ENV not found."
exit 1
fi
echo "📦 Syncing extensions to REMOTE $ENV..."
# Ensure remote directory exists
ssh "$REMOTE_HOST" "mkdir -p $REMOTE_DIR/extensions"
rsync -avz --delete ./packages/cms-infra/extensions/ "$REMOTE_HOST:$REMOTE_DIR/extensions/"
echo "📤 Injecting snapshot directly into container $REMOTE_CONTAINER..."
# Inject file via stdin to avoid needing a host-side mount or scp path matching
ssh "$REMOTE_HOST" "docker exec -i $REMOTE_CONTAINER sh -c 'cat > /tmp/snapshot.yaml'" < "$LOCAL_SCHEMA_PATH"
echo "🚀 Applying schema to REMOTE $ENV..."
ssh "$REMOTE_HOST" "docker exec $REMOTE_CONTAINER npx directus schema apply -y /tmp/snapshot.yaml"
echo "🔄 Restarting remote Directus to clear cache..."
ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose restart directus"
# Cleanup
ssh "$REMOTE_HOST" "docker exec $REMOTE_CONTAINER rm /tmp/snapshot.yaml"
;;
*)
echo "❌ Invalid environment: $ENV. Supported: local, infra."
exit 1
;;
esac
echo "✨ Schema apply complete!"
-56
View File
@@ -1,56 +0,0 @@
#!/bin/bash
# Configuration
# Derive monorepo root
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
ROOT_DIR="$( dirname "$SCRIPT_DIR" )"
DB_PATH="$ROOT_DIR/packages/cms-infra/database/data.db"
if [ ! -f "$DB_PATH" ]; then
echo "❌ Database not found at $DB_PATH"
exit 1
fi
reconcile_table() {
local TABLE=$1
echo "🔍 Reconciling table: $TABLE"
# 1. Get all columns from SQLite
COLUMNS=$(sqlite3 "$DB_PATH" "PRAGMA table_info($TABLE);" | cut -d'|' -f2)
for COL in $COLUMNS; do
# Skip system columns if needed, but usually it's safer to just check if they exist in Directus
# 2. Check if field exists in directus_fields
EXISTS=$(sqlite3 "$DB_PATH" "SELECT count(*) FROM directus_fields WHERE collection = '$TABLE' AND field = '$COL';")
if [ "$EXISTS" -eq 0 ]; then
echo "➕ Registering missing field: $TABLE.$COL"
# Determine a basic interface based on column name or type (very simplified)
INTERFACE="input"
case $COL in
*id) INTERFACE="numeric" ;;
*text) INTERFACE="input-multiline" ;;
company|person|user_created|user_updated|feedback_id) INTERFACE="select-dropdown-m2o" ;;
date_created|date_updated) INTERFACE="datetime" ;;
screenshot|logo) INTERFACE="file" ;;
status|type) INTERFACE="select-dropdown" ;;
esac
sqlite3 "$DB_PATH" "INSERT INTO directus_fields (collection, field, interface) VALUES ('$TABLE', '$COL', '$INTERFACE');"
else
echo "✅ Field already registered: $TABLE.$COL"
fi
done
}
# Run for known problematic tables
reconcile_table "visual_feedback"
reconcile_table "visual_feedback_comments"
reconcile_table "people"
reconcile_table "leads"
reconcile_table "client_users"
reconcile_table "companies"
echo "✨ SQL Reconciliation complete!"
-23
View File
@@ -1,23 +0,0 @@
#!/bin/bash
# Configuration
PROJECT="infra-cms"
SCHEMA_PATH="./packages/cms-infra/schema/snapshot.yaml"
CMD_PREFIX="docker-compose -f packages/cms-infra/docker-compose.yml"
# Detect local container
LOCAL_CONTAINER=$($CMD_PREFIX ps -q $PROJECT)
if [ -z "$LOCAL_CONTAINER" ]; then
echo "❌ Local $PROJECT container not found. Is it running?"
exit 1
fi
echo "📸 Creating schema snapshot for local $PROJECT..."
# Note: we save it to the mounted volume path inside the container
docker exec "$LOCAL_CONTAINER" npx directus schema snapshot -y /directus/schema/snapshot.yaml
echo "🛠️ Repairing snapshot for Postgres compatibility..."
python3 ./scripts/fix_snapshot_v3.py
echo "✅ Snapshot saved and repaired at $SCHEMA_PATH"
-27
View File
@@ -1,27 +0,0 @@
#!/bin/bash
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
echo "🚀 Starting CMS infrastructure..."
# 1. Build extensions (pass all arguments to handle flags like --link)
"$SCRIPT_DIR/sync-extensions.sh" "$@"
# Filter out --link before passing to docker compose
DOCKER_ARGS=()
for arg in "$@"; do
if [ "$arg" != "--link" ]; then
DOCKER_ARGS+=("$arg")
fi
done
# 2. Docker compose up with arguments
cd "$REPO_ROOT/packages/cms-infra"
docker compose up -d "${DOCKER_ARGS[@]}"
# 3. Apply core patch
"$SCRIPT_DIR/patch-cms.sh"
echo "✨ CMS is up and patched!"
-96
View File
@@ -1,96 +0,0 @@
import sys
import os
path = '/Users/marcmintel/Projects/at-mintel/packages/cms-infra/schema/snapshot.yaml'
if not os.path.exists(path):
print(f"File not found: {path}")
sys.exit(1)
with open(path, 'r') as f:
lines = f.readlines()
new_lines = []
current_collection = None
current_field = None
in_schema = False
fix_fields = {'id', 'company', 'user_created', 'user_updated', 'screenshot', 'logo', 'feedback_id'}
uuid_fields = {'id', 'company', 'user_created', 'user_updated'}
# For multi-pass logic
snapshot_has_feedback_id = False
for line in lines:
stripped = line.strip()
if stripped.startswith('- collection:'):
current_collection = stripped.split(':')[-1].strip()
in_schema = False
elif stripped.startswith('field:'):
current_field = stripped.split(':')[-1].strip()
if current_collection == 'visual_feedback_comments' and current_field == 'feedback_id':
snapshot_has_feedback_id = True
elif stripped == 'schema:':
in_schema = True
elif stripped == 'meta:' or stripped.startswith('- collection:') or (not line.startswith(' ') and line.strip() and not line.startswith('-')):
in_schema = False
# Top-level field type
if not in_schema and stripped.startswith('type:') and current_field in uuid_fields:
line = line.replace('type: string', 'type: uuid')
# Schema data type
if in_schema and current_field in fix_fields:
if 'data_type: char' in line or 'data_type: varchar' in line:
line = line.replace('data_type: char', 'data_type: uuid').replace('data_type: varchar', 'data_type: uuid')
if 'max_length:' in line:
line = ' max_length: null\n'
new_lines.append(line)
# Handle Missing feedback_id Injection
if not snapshot_has_feedback_id:
# We find systemFields and inject before it
injected = False
final_lines = []
feedback_id_block = """ - collection: visual_feedback_comments
field: feedback_id
type: integer
meta:
collection: visual_feedback_comments
field: feedback_id
interface: select-dropdown-m2o
required: true
sort: 4
width: full
schema:
name: feedback_id
table: visual_feedback_comments
data_type: integer
is_nullable: false
is_indexed: true
foreign_key_table: visual_feedback
foreign_key_column: id
"""
for line in new_lines:
if 'systemFields:' in line and not injected:
final_lines.append(feedback_id_block)
injected = True
final_lines.append(line)
new_lines = final_lines
# Second pass for primary key nullability
final_lines = []
for i in range(len(new_lines)):
line = new_lines[i]
if 'is_primary_key: true' in line:
# Search backwards and forwards
for j in range(max(0, i-10), min(len(new_lines), i+10)):
if 'is_nullable: true' in new_lines[j]:
new_lines[j] = new_lines[j].replace('is_nullable: true', 'is_nullable: false')
final_lines.append(line)
with open(path, 'w') as f:
f.writelines(new_lines)
print("SUCCESS: Full normalization and field injection complete.")
-116
View File
@@ -1,116 +0,0 @@
#!/bin/bash
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONTAINERS=("cms-infra-infra-cms-1" "at-mintel-directus-1")
echo "🔧 Checking for Directus containers to patch..."
for CONTAINER in "${CONTAINERS[@]}"; do
if [ "$(docker ps -q -f name=^/${CONTAINER}$)" ]; then
echo "🔧 Applying core patches to: $CONTAINER..."
# Capture output to determine if restart is needed
OUTPUT=$(docker exec -i "$CONTAINER" node << 'EOF'
const fs = require("node:fs");
const { execSync } = require("node:child_process");
let patched = false;
try {
// 1. Patch @directus/extensions node.js (Entrypoints)
const findNodeCmd = "find /directus/node_modules -path \"*/@directus/extensions/dist/node.js\"";
const nodePaths = execSync(findNodeCmd).toString().trim().split("\n").filter(Boolean);
nodePaths.forEach(targetPath => {
let content = fs.readFileSync(targetPath, "utf8");
let modified = false;
const filterPatch = 'extension.host === "app" && (extension.entrypoint.app || extension.entrypoint)';
// Only replace if the OLD pattern exists
if (content.includes('extension.host === "app" && !!extension.entrypoint.app')) {
content = content.replace(/extension\.host === "app" && !!extension\.entrypoint\.app/g, filterPatch);
modified = true;
}
// Only replace if the OLD pattern exists for entrypoint
// We check if "extension.entrypoint.app" is present but NOT part of our patch
// This is a simple heuristic: if the patch string is NOT present, but the target IS.
if (!content.includes("(extension.entrypoint.app || extension.entrypoint)")) {
if (content.includes("extension.entrypoint.app")) {
content = content.replace(/extension\.entrypoint\.app/g, "(extension.entrypoint.app || extension.entrypoint)");
modified = true;
}
}
if (modified) {
fs.writeFileSync(targetPath, content);
console.log(`✅ Entrypoint patched.`);
patched = true;
}
});
// 2. Patch @directus/api manager.js (HTML Injection)
const findManagerCmd = "find /directus/node_modules -path \"*/@directus/api/dist/extensions/manager.js\"";
const managerPaths = execSync(findManagerCmd).toString().trim().split("\n").filter(Boolean);
managerPaths.forEach(targetPath => {
let content = fs.readFileSync(targetPath, "utf8");
const original = "head: wrapEmbeds('Custom Embed Head', this.hookEmbedsHead),";
const injection = "head: '<script type=\"module\" src=\"/extensions/sources/index.js\"></script>\\n' + wrapEmbeds('Custom Embed Head', this.hookEmbedsHead),";
if (content.includes(original) && !content.includes("/extensions/sources/index.js")) {
content = content.replace(original, injection);
fs.writeFileSync(targetPath, content);
console.log(`✅ Injection patched.`);
patched = true;
}
});
// 3. Patch @directus/api app.js (CSP for unsafe-inline)
const findAppCmd = "find /directus/node_modules -path \"*/@directus/api/dist/app.js\"";
const appPaths = execSync(findAppCmd).toString().trim().split("\n").filter(Boolean);
appPaths.forEach(targetPath => {
let content = fs.readFileSync(targetPath, "utf8");
let modified = false;
const original = "scriptSrc: [\"'self'\", \"'unsafe-eval'\"],";
const patchedStr = "scriptSrc: [\"'self'\", \"'unsafe-eval'\", \"'unsafe-inline'\"],";
if (content.includes(original)) {
content = content.replace(original, patchedStr);
modified = true;
}
if (modified) {
fs.writeFileSync(targetPath, content);
console.log(`✅ CSP patched in app.js.`);
patched = true;
}
});
if (patched) process.exit(100); // Signal restart needed
} catch (error) {
console.error("❌ Error applying patch:", error.message);
process.exit(1);
}
EOF
)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -eq 100 ]; then
echo "🔄 Patches applied. Restarting Directus container: $CONTAINER..."
docker restart "$CONTAINER"
else
echo "✅ Container $CONTAINER is already patched. No restart needed."
fi
else
echo "ℹ️ Container $CONTAINER not found. Skipping."
fi
done
echo "✨ All patches check complete."
-123
View File
@@ -1,123 +0,0 @@
#!/bin/bash
# Configuration
REMOTE_HOST="root@infra.mintel.me"
REMOTE_DIR="/opt/infra/directus"
# DB Details (matching docker-compose defaults)
DB_USER="directus"
DB_NAME="directus"
ACTION=$1
ENV=$2
# Help
if [ -z "$ACTION" ] || [ -z "$ENV" ]; then
echo "Usage: ./scripts/sync-directus.sh [push|pull] [infra|testing|staging|production]"
echo ""
echo "Commands:"
echo " push Sync LOCAL data -> REMOTE"
echo " pull Sync REMOTE data -> LOCAL"
echo ""
echo "Environments:"
echo " infra (infra.mintel.me)"
exit 1
fi
# Map Environment
case $ENV in
infra)
PROJECT_NAME="directus"
;;
*)
echo "❌ Invalid environment: $ENV. Only 'infra' is currently configured for monorepo sync."
exit 1
;;
esac
# Detect local containers
echo "🔍 Detecting local database..."
LOCAL_DB_CONTAINER=$(docker compose ps -q at-mintel-directus-db)
if [ -z "$LOCAL_DB_CONTAINER" ]; then
echo "❌ Local directus-db container not found. Is it running? (npm run dev)"
exit 1
fi
if [ "$ACTION" == "push" ]; then
echo "🚀 Pushing Local Data to $ENV..."
# 1. DB Dump
echo "📦 Dumping local database..."
docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$DB_USER" --clean --if-exists --no-owner --no-privileges "$DB_NAME" > dump.sql
# 2. Upload Dump
echo "📤 Uploading dump to remote server..."
scp dump.sql "$REMOTE_HOST:$REMOTE_DIR/dump.sql"
# 3. Restore on Remote
echo "🔄 Restoring dump on $ENV..."
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-postgres")
if [ -z "$REMOTE_DB_CONTAINER" ]; then
echo "❌ Remote $ENV-db container not found!"
exit 1
fi
# Wipe remote DB clean before restore to avoid constraint errors
echo "🧹 Wiping remote database schema..."
ssh "$REMOTE_HOST" "docker exec $REMOTE_DB_CONTAINER psql -U $DB_USER $DB_NAME -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'"
echo "⚡ Restoring database..."
ssh "$REMOTE_HOST" "docker exec -i $REMOTE_DB_CONTAINER psql -U $DB_USER $DB_NAME < $REMOTE_DIR/dump.sql"
# 4. Sync Uploads
echo "📁 Syncing uploads (Local -> $ENV)..."
rsync -avz --progress ./directus/uploads/ "$REMOTE_HOST:$REMOTE_DIR/uploads/"
# Clean up
rm dump.sql
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
# 5. Restart Directus to trigger migrations and refresh schema cache
echo "🔄 Restarting remote Directus to apply migrations..."
ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME restart directus"
echo "✨ Push to $ENV complete!"
elif [ "$ACTION" == "pull" ]; then
echo "📥 Pulling $ENV Data to Local..."
# 1. DB Dump on Remote
echo "📦 Dumping remote database ($ENV)..."
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-postgres")
if [ -z "$REMOTE_DB_CONTAINER" ]; then
echo "❌ Remote $ENV-db container not found!"
exit 1
fi
ssh "$REMOTE_HOST" "docker exec $REMOTE_DB_CONTAINER pg_dump -U $DB_USER --clean --if-exists --no-owner --no-privileges $DB_NAME > $REMOTE_DIR/dump.sql"
# 2. Download Dump
echo "📥 Downloading dump..."
scp "$REMOTE_HOST:$REMOTE_DIR/dump.sql" dump.sql
# Wipe local DB clean before restore to avoid constraint errors
echo "🧹 Wiping local database schema..."
docker exec "$LOCAL_DB_CONTAINER" psql -U "$DB_USER" "$DB_NAME" -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'
echo "⚡ Restoring database locally..."
docker exec -i "$LOCAL_DB_CONTAINER" psql -U "$DB_USER" "$DB_NAME" < dump.sql
# 4. Sync Uploads
echo "📁 Syncing uploads ($ENV -> Local)..."
rsync -avz --progress "$REMOTE_HOST:$REMOTE_DIR/uploads/" ./directus/uploads/
# Clean up
rm dump.sql
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
echo "✨ Pull to Local complete!"
else
echo "Invalid action: $ACTION. Use push or pull."
exit 1
fi
-138
View File
@@ -1,138 +0,0 @@
#!/bin/bash
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
EXTENSIONS_ROOT="$REPO_ROOT/packages"
# Strict local targets for bombproof isolation
TARGET_DIRS=(
"$REPO_ROOT/packages/cms-infra/extensions"
"$REPO_ROOT/directus/extensions"
)
# List of extension packages to sync
EXTENSION_PACKAGES=(
"acquisition"
"acquisition-manager"
"company-manager"
"customer-manager"
"feedback-commander"
"people-manager"
"unified-dashboard"
)
# Parse flags
LINK_MODE=false
for arg in "$@"; do
if [ "$arg" == "--link" ]; then
LINK_MODE=true
fi
done
echo "🚀 Starting isolated extension sync..."
# Ensure target directories exist
for TARGET in "${TARGET_DIRS[@]}"; do
mkdir -p "$TARGET"
done
# Build the acquisition library if it exists
if [ -d "$REPO_ROOT/packages/acquisition" ]; then
echo "📦 Building acquisition..."
(cd "$REPO_ROOT/packages/acquisition" && pnpm build)
fi
for PKG in "${EXTENSION_PACKAGES[@]}"; do
PKG_PATH="$EXTENSIONS_ROOT/$PKG"
if [ -d "$PKG_PATH" ]; then
echo "📦 Processing $PKG..."
# 1. Build the extension
(cd "$PKG_PATH" && pnpm build)
EXT_NAME="$PKG"
echo "🚚 Syncing $EXT_NAME..."
# 3. Sync to each target directory
for TARGET_BASE in "${TARGET_DIRS[@]}"; do
# FLAT STRUCTURE: Directus 11.15.x local scanner is FLAT.
FINAL_TARGET="$TARGET_BASE/$EXT_NAME"
echo "🚚 Syncing $EXT_NAME to $FINAL_TARGET..."
# Clean target first to avoid ghost files
mkdir -p "$FINAL_TARGET"
rm -rf "${FINAL_TARGET:?}"/*
# Copy build artifacts
if [ -f "$PKG_PATH/dist/index.js" ]; then
cp "$PKG_PATH/dist/index.js" "$FINAL_TARGET/index.js"
elif [ -f "$PKG_PATH/index.js" ]; then
cp "$PKG_PATH/index.js" "$FINAL_TARGET/index.js"
fi
if [ -f "$PKG_PATH/package.json" ]; then
# We ALWAYS copy and patch package.json to avoid messing with source
cp "$PKG_PATH/package.json" "$FINAL_TARGET/"
# We force the registration path to index.js and ensure host/source are set
node -e "
const fs = require('fs');
const pkgPath = '$FINAL_TARGET/package.json';
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
if (!pkg['directus:extension']) pkg['directus:extension'] = {};
// Standard metadata for Directus 11.15.x (with core patch applied)
pkg['directus:extension'].path = 'index.js';
if (!pkg['directus:extension'].host) {
pkg['directus:extension'].host = pkg['directus:extension'].type === 'endpoint' ? 'api' : 'app';
}
if (!pkg['directus:extension'].source) {
pkg['directus:extension'].source = 'src/index.ts';
}
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));
"
fi
if [ -d "$PKG_PATH/dist" ]; then
if [ "$LINK_MODE" = true ]; then
REL_PATH=$(python3 -c "import os; print(os.path.relpath('$PKG_PATH/dist', '$FINAL_TARGET'))")
ln -sf "$REL_PATH" "$FINAL_TARGET/dist"
else
cp -r "$PKG_PATH/dist" "$FINAL_TARGET/"
fi
fi
done
echo "✅ $PKG synced."
else
echo "❌ Extension source not found: $PKG_PATH"
fi
done
# Cleanup: remove anything from extensions root that isn't in our whitelist
WHITELIST=("${EXTENSION_PACKAGES[@]}" "sources" "endpoints" "hooks" "layouts" "modules" "operations" "panels" "displays" "interfaces")
for TARGET_BASE in "${TARGET_DIRS[@]}"; do
echo "🧹 Cleaning up $TARGET_BASE..."
for ITEM in "$TARGET_BASE"/*; do
[ -e "$ITEM" ] || continue
BN=$(basename "$ITEM")
IS_ALLOWED=false
for W in "${WHITELIST[@]}"; do
if [[ "$BN" == "$W" ]]; then IS_ALLOWED=true; break; fi
done
if [ "$IS_ALLOWED" = false ]; then
echo " 🗑️ Removing extra/legacy item: $BN"
rm -rf "$ITEM"
fi
done
done
# Container patching is now handled by scripts/patch-cms.sh
# which should be run AFTER the containers are up.
echo "✨ Sync complete! Extensions are in packages/cms-infra/extensions."
-91
View File
@@ -1,91 +0,0 @@
#!/bin/bash
# Configuration
CONTAINER="cms-infra-infra-cms-1"
echo "🔍 Validating Directus Extension Stability..."
# 1. Verify Patches
echo "🛠️ Checking Core Patches..."
docker exec -i "$CONTAINER" node << 'EOF'
const fs = require('node:fs');
const { execSync } = require('node:child_process');
let failures = 0;
// Check Node.js patch
const findNode = 'find /directus/node_modules -path "*/@directus/extensions/dist/node.js"';
const nodePaths = execSync(findNode).toString().trim().split('\n').filter(Boolean);
nodePaths.forEach(p => {
const c = fs.readFileSync(p, 'utf8');
if (!c.includes('(extension.entrypoint.app || extension.entrypoint)')) {
console.error('❌ Missing node.js patch at ' + p);
failures++;
}
});
// Check Manager.js patch
const findManager = 'find /directus/node_modules -path "*/@directus/api/dist/extensions/manager.js"';
const managerPaths = execSync(findManager).toString().trim().split('\n').filter(Boolean);
managerPaths.forEach(p => {
const c = fs.readFileSync(p, 'utf8');
if (!c.includes('/extensions/sources/index.js')) {
console.error('❌ Missing manager.js patch at ' + p);
failures++;
}
});
if (failures === 0) {
console.log('✅ Core patches are healthy.');
}
process.exit(failures > 0 ? 1 : 0);
EOF
if [ $? -ne 0 ]; then
echo "⚠️ Core patches missing! Run 'bash scripts/patch-cms.sh' to fix."
fi
# 2. Verify Module Bar
echo "📋 Checking Sidebar Configuration..."
docker exec -i "$CONTAINER" node << 'EOF'
const sqlite3 = require('/directus/node_modules/.pnpm/sqlite3@5.1.7/node_modules/sqlite3');
const db = new sqlite3.Database('/directus/database/data.db');
const managerIds = ["unified-dashboard", "acquisition-manager", "company-manager", "customer-manager", "feedback-commander", "people-manager"];
db.get('SELECT module_bar FROM directus_settings WHERE id = 1', (err, row) => {
if (err) { console.error('❌ DB Error:', err.message); process.exit(1); }
let mb = [];
try { mb = JSON.parse(row.module_bar || '[]'); } catch(e) { mb = []; }
const existingIds = mb.map(m => m.id);
const missing = managerIds.filter(id => !existingIds.includes(id));
const disabled = mb.filter(m => managerIds.includes(m.id) && m.enabled === false);
if (missing.length === 0 && disabled.length === 0) {
console.log('✅ Sidebar is healthy with all manager modules enabled.');
process.exit(0);
} else {
if (missing.length > 0) console.log('⚠️ Missing modules:', missing.join(', '));
if (disabled.length > 0) console.log('⚠️ Disabled modules:', disabled.map(m => m.id).join(', '));
console.log('🔧 Self-healing in progress...');
// Construct Golden State Module Bar
const goldenMB = [
{ type: 'module', id: 'content', enabled: true },
{ type: 'module', id: 'users', enabled: true },
{ type: 'module', id: 'files', enabled: true },
{ type: 'module', id: 'insights', enabled: true },
...managerIds.map(id => ({ type: 'module', id, enabled: true })),
{ type: 'module', id: 'settings', enabled: true }
];
db.run('UPDATE directus_settings SET module_bar = ? WHERE id = 1', [JSON.stringify(goldenMB)], function(err) {
if (err) { console.error('❌ Repair failed:', err.message); process.exit(1); }
console.log('✨ Sidebar repaired successfully!');
process.exit(0);
});
}
});
EOF
-67
View File
@@ -1,67 +0,0 @@
#!/bin/bash
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
TARGET_DIRS=(
"$REPO_ROOT/packages/cms-infra/extensions"
"$REPO_ROOT/directus/extensions"
)
echo "🛡️ Directus Extension Validator"
echo "================================="
for TARGET in "${TARGET_DIRS[@]}"; do
echo ""
echo "📂 Checking: $TARGET"
if [ ! -d "$TARGET" ]; then
echo " ❌ Directory does not exist!"
continue
fi
CATEGORIES=("endpoints" "hooks" "layouts" "modules" "operations" "panels" "displays" "interfaces")
FOUND_ANY=false
for CAT in "${CATEGORIES[@]}"; do
CAT_PATH="$TARGET/$CAT"
if [ -d "$CAT_PATH" ]; then
EXTS=$(ls "$CAT_PATH")
if [ -n "$EXTS" ]; then
FOUND_ANY=true
echo " 📦 $CAT:"
for EXT in $EXTS; do
EXT_PATH="$CAT_PATH/$EXT"
if [ -f "$EXT_PATH/package.json" ]; then
VERSION=$(node -e "console.log(require('$EXT_PATH/package.json').version)")
echo " ✅ $EXT (v$VERSION)"
else
echo " ⚠️ $EXT (MISSING package.json!)"
fi
done
fi
fi
done
if [ "$FOUND_ANY" = false ]; then
echo " 📭 No extensions found in standard category folders."
fi
# Check for legacy files
LEGACY=$(find "$TARGET" -maxdepth 1 -not -path "$TARGET" -not -name ".*" -type d)
for L in $LEGACY; do
BN=$(basename "$L")
IS_CAT=false
for CAT in "${CATEGORIES[@]}"; do
if [ "$BN" == "$CAT" ]; then IS_CAT=true; break; fi
done
if [ "$IS_CAT" = false ]; then
echo " 🚨 LEGACY/UNRESOLVED FOLDER FOUND: $BN (Will NOT be loaded by Directus)"
fi
done
done
echo ""
echo "✨ Validation complete."
-100
View File
@@ -1,100 +0,0 @@
#!/bin/bash
# validate-sdk-imports.sh
# Validates that Directus extensions only use exports that exist in @directus/extensions-sdk.
# Prevents the "SyntaxError: doesn't provide an export named" runtime crash
# that silently breaks ALL extensions in the browser.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Valid exports from @directus/extensions-sdk in Directus 11.x
# If Directus is upgraded, update this list by running:
# curl -s http://cms.localhost/admin/assets/@directus_extensions-sdk.*.entry.js | grep -oE 'export\{[^}]+\}'
VALID_EXPORTS=(
"defineDisplay"
"defineEndpoint"
"defineHook"
"defineInterface"
"defineLayout"
"defineModule"
"defineOperationApi"
"defineOperationApp"
"definePanel"
"defineTheme"
"getFieldsFromTemplate"
"getRelationType"
"useApi"
"useCollection"
"useExtensions"
"useFilterFields"
"useItems"
"useLayout"
"useSdk"
"useStores"
"useSync"
)
ERRORS=0
echo "🔍 Validating @directus/extensions-sdk imports..."
echo ""
# Search all .ts and .vue files in extension directories
SEARCH_DIRS=(
"$REPO_ROOT/packages/cms-infra/extensions"
"$REPO_ROOT/packages/unified-dashboard"
"$REPO_ROOT/packages/customer-manager"
"$REPO_ROOT/packages/company-manager"
"$REPO_ROOT/packages/people-manager"
"$REPO_ROOT/packages/acquisition-manager"
"$REPO_ROOT/packages/feedback-commander"
)
for DIR in "${SEARCH_DIRS[@]}"; do
[ -d "$DIR" ] || continue
# Find all imports from @directus/extensions-sdk
while IFS= read -r line; do
FILE=$(echo "$line" | cut -d: -f1)
LINENUM=$(echo "$line" | cut -d: -f2)
CONTENT=$(echo "$line" | cut -d: -f3-)
# Extract imported names from the import statement
IMPORTS=$(echo "$CONTENT" | grep -oE '\{[^}]+\}' | tr -d '{}' | tr ',' '\n' | sed 's/^ *//;s/ *$//' | sed 's/ as .*//')
for IMPORT in $IMPORTS; do
[ -z "$IMPORT" ] && continue
FOUND=false
for VALID in "${VALID_EXPORTS[@]}"; do
if [ "$IMPORT" = "$VALID" ]; then
FOUND=true
break
fi
done
if [ "$FOUND" = false ]; then
echo "❌ INVALID IMPORT: '$IMPORT' in $FILE:$LINENUM"
echo " '$IMPORT' is NOT exported by @directus/extensions-sdk in Directus 11.x"
echo " This WILL crash ALL extensions at runtime!"
echo ""
ERRORS=$((ERRORS + 1))
fi
done
done < <(grep -rn "from ['\"]@directus/extensions-sdk['\"]" "$DIR" --include="*.ts" --include="*.vue" 2>/dev/null || true)
done
if [ "$ERRORS" -gt 0 ]; then
echo "💥 Found $ERRORS invalid import(s)!"
echo ""
echo "Valid exports from @directus/extensions-sdk:"
printf " %s\n" "${VALID_EXPORTS[@]}"
echo ""
echo "Common fixes:"
echo " useRouter → import { useRouter } from 'vue-router'"
echo " useRoute → import { useRoute } from 'vue-router'"
exit 1
else
echo "✅ All @directus/extensions-sdk imports are valid."
fi
-46
View File
@@ -1,46 +0,0 @@
#!/bin/bash
set -e
HOST="http://cms.localhost"
EXTENSIONS=("customer-manager" "people-manager" "company-manager" "feedback-commander" "unified-dashboard")
echo "🔍 Verifying extensions at $HOST..."
# 1. Check Main Manifest
MANIFEST=$(curl -s "$HOST/extensions/sources/index.js")
if [ -z "$MANIFEST" ]; then
echo "❌ Error: Manifest returned empty response."
exit 1
fi
echo "✅ Manifest loaded (${#MANIFEST} bytes)."
# 2. Check for unexpected 404/500
if echo "$MANIFEST" | grep -q "<!DOCTYPE html>"; then
echo "❌ Error: Manifest returned HTML (likely 404 or error page) instead of JS."
exit 1
fi
# 3. Verify each extension is in the bundle
FAILURE=0
for EXT in "${EXTENSIONS[@]}"; do
# Directus bundles strings usually, or imports them.
# We look for the ID or the unique module name from src (e.g. "Customer Manager")
# Or simply the path matching.
if echo "$MANIFEST" | grep -q "$EXT"; then
echo "✅ Found '$EXT' in manifest."
else
echo "❌ MISSING '$EXT' in manifest!"
FAILURE=1
fi
done
if [ $FAILURE -eq 1 ]; then
echo "🚨 VERIFICATION FAILED: One or more extensions are missing from the public bundle."
exit 1
else
echo "🎉 ALL EXTENSIONS VERIFIED."
exit 0
fi