chore: remove Directus CMS and related dependencies
All checks were successful
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

View File

@@ -15,11 +15,12 @@
"pagespeed:test": "mintel pagespeed" "pagespeed:test": "mintel pagespeed"
}, },
"dependencies": { "dependencies": {
"@mintel/image-processor": "workspace:*",
"@mintel/next-observability": "workspace:*",
"@mintel/next-utils": "workspace:*", "@mintel/next-utils": "workspace:*",
"@mintel/observability": "workspace:*", "@mintel/observability": "workspace:*",
"@mintel/next-observability": "workspace:*",
"@mintel/image-processor": "workspace:*",
"@sentry/nextjs": "10.38.0", "@sentry/nextjs": "10.38.0",
"@tensorflow/tfjs-backend-wasm": "^4.22.0",
"next": "16.1.6", "next": "16.1.6",
"next-intl": "^4.8.2", "next-intl": "^4.8.2",
"react": "^19.0.0", "react": "^19.0.0",

View File

@@ -11,8 +11,6 @@ services:
restart: always restart: always
networks: networks:
- infra - infra
environment:
- DIRECTUS_URL=${DIRECTUS_URL:-http://directus:8055}
env_file: env_file:
- .env - .env
ports: ports:
@@ -24,60 +22,6 @@ services:
- "caddy=http://${TRAEFIK_HOST:-acquisition.localhost}" - "caddy=http://${TRAEFIK_HOST:-acquisition.localhost}"
- "caddy.reverse_proxy={{upstreams 3000}}" - "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: networks:
infra: infra:
external: true external: true
volumes:
directus-db-data:

View File

@@ -10,16 +10,6 @@
"changeset": "changeset", "changeset": "changeset",
"version-packages": "changeset version", "version-packages": "changeset version",
"sync-versions": "tsx scripts/sync-versions.ts --", "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": "pnpm build && changeset publish",
"release:tag": "pnpm build && pnpm -r publish --no-git-checks --access public", "release:tag": "pnpm build && pnpm -r publish --no-git-checks --access public",
"prepare": "husky" "prepare": "husky"

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"
}
}

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
}
],
});

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>

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);
});

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"
}
}

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 });
}
});
});

View File

@@ -36,153 +36,15 @@ program
console.log( console.log(
chalk.yellow(` chalk.yellow(`
📱 App: http://localhost:3000 📱 App: http://localhost:3000
🗄️ CMS: http://localhost:8055/admin
🚦 Traefik: http://localhost:8080 🚦 Traefik: http://localhost:8080
`), `),
); );
execSync( 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" }, { 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 program
.command("pagespeed") .command("pagespeed")
.description("Run PageSpeed (Lighthouse) tests") .description("Run PageSpeed (Lighthouse) tests")
@@ -221,13 +83,6 @@ program
lint: "next lint", lint: "next lint",
typecheck: "tsc --noEmit", typecheck: "tsc --noEmit",
test: "vitest run --passWithNoTests", 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", "pagespeed:test": "mintel pagespeed",
}, },
dependencies: { dependencies: {
@@ -236,7 +91,6 @@ program
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"@mintel/next-utils": "workspace:*", "@mintel/next-utils": "workspace:*",
"@mintel/next-observability": "workspace:*", "@mintel/next-observability": "workspace:*",
"@directus/sdk": "^21.0.0",
}, },
devDependencies: { devDependencies: {
"@types/node": "^20.0.0", "@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 // Create .env.example
const envExample = `# Project const envExample = `# Project
PROJECT_NAME=${projectName} PROJECT_NAME=${projectName}
@@ -493,21 +338,10 @@ AUTH_COOKIE_NAME=mintel_gatekeeper_session
# Host Config (Local) # Host Config (Local)
TRAEFIK_HOST=\`${projectName}.localhost\` TRAEFIK_HOST=\`${projectName}.localhost\`
DIRECTUS_HOST=\`cms.${projectName}.localhost\`
# Next.js # Next.js
NEXT_PUBLIC_BASE_URL=http://${projectName}.localhost 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 / Glitchtip
SENTRY_DSN= SENTRY_DSN=

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.

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

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

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"
}
}

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,
},
],
});

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>

View File

@@ -1,40 +1,39 @@
import { config as dotenvConfig } from 'dotenv'; import { config as dotenvConfig } from "dotenv";
import * as path from 'node:path'; import * as path from "node:path";
import * as fs from 'node:fs/promises'; import * as fs from "node:fs/promises";
import { EstimationPipeline } from './pipeline.js'; import { ConceptPipeline } from "./pipeline.js";
dotenvConfig({ path: path.resolve(process.cwd(), '../../.env') }); dotenvConfig({ path: path.resolve(process.cwd(), "../../.env") });
const briefing = await fs.readFile( const briefing = await fs.readFile(
path.resolve(process.cwd(), '../../data/briefings/etib.txt'), path.resolve(process.cwd(), "../../data/briefings/etib.txt"),
'utf8', "utf8",
); );
console.log(`Briefing loaded: ${briefing.length} chars`); console.log(`Briefing loaded: ${briefing.length} chars`);
const pipeline = new EstimationPipeline( const pipeline = new ConceptPipeline(
{ {
openrouterKey: process.env.OPENROUTER_API_KEY || '', openrouterKey: process.env.OPENROUTER_API_KEY || "",
zyteApiKey: process.env.ZYTE_API_KEY, zyteApiKey: process.env.ZYTE_API_KEY,
outputDir: path.resolve(process.cwd(), '../../out/estimations'), outputDir: path.resolve(process.cwd(), "../../out/estimations"),
crawlDir: path.resolve(process.cwd(), '../../data/crawls'), crawlDir: path.resolve(process.cwd(), "../../data/crawls"),
}, },
{ {
onStepStart: (id, name) => console.log(`[CB] Starting: ${id}`), onStepStart: (id, _name) => console.log(`[CB] Starting: ${id}`),
onStepComplete: (id) => console.log(`[CB] Done: ${id}`), onStepComplete: (id) => console.log(`[CB] Done: ${id}`),
onStepError: (id, err) => console.error(`[CB] Error in ${id}: ${err}`), onStepError: (id, err) => console.error(`[CB] Error in ${id}: ${err}`),
}, },
); );
try { try {
const result = await pipeline.run({ await pipeline.run({
briefing, briefing,
url: 'https://www.e-tib.com', url: "https://www.e-tib.com",
}); });
console.log('\n✨ Pipeline complete!'); console.log("\n✨ Pipeline complete!");
console.log('Validation:', result.validationResult?.passed ? 'PASSED' : 'FAILED');
} catch (err: any) { } catch (err: any) {
console.error('\n❌ Pipeline failed:', err.message); console.error("\n❌ Pipeline failed:", err.message);
console.error(err.stack); console.error(err.stack);
} }

View File

@@ -32,7 +32,8 @@ program
.option("--output <dir>", "Output directory", "../../out/concepts") .option("--output <dir>", "Output directory", "../../out/concepts")
.option("--crawl-dir <dir>", "Crawl data directory", "../../data/crawls") .option("--crawl-dir <dir>", "Crawl data directory", "../../data/crawls")
.action(async (briefingArg: string | undefined, options: any) => { .action(async (briefingArg: string | undefined, options: any) => {
const openrouterKey = process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY; const openrouterKey =
process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
if (!openrouterKey) { if (!openrouterKey) {
console.error("❌ OPENROUTER_API_KEY not found in environment."); console.error("❌ OPENROUTER_API_KEY not found in environment.");
process.exit(1); process.exit(1);
@@ -77,10 +78,10 @@ program
crawlDir: path.resolve(process.cwd(), options.crawlDir), crawlDir: path.resolve(process.cwd(), options.crawlDir),
}, },
{ {
onStepStart: (id, name) => { onStepStart: (_id, _name) => {
// Will be enhanced with Ink spinner later // Will be enhanced with Ink spinner later
}, },
onStepComplete: (id, result) => { onStepComplete: (_id, _result) => {
// Will be enhanced with Ink UI later // Will be enhanced with Ink UI later
}, },
}, },
@@ -114,7 +115,10 @@ program
if (options.clearCache) { if (options.clearCache) {
const { clearCrawlCache } = await import("./scraper.js"); const { clearCrawlCache } = await import("./scraper.js");
const domain = new URL(url).hostname; const domain = new URL(url).hostname;
await clearCrawlCache(path.resolve(process.cwd(), options.crawlDir), domain); await clearCrawlCache(
path.resolve(process.cwd(), options.crawlDir),
domain,
);
} }
const pages = await crawlSite(url, { const pages = await crawlSite(url, {
@@ -128,15 +132,25 @@ program
console.log("\n📊 Site Profile:"); console.log("\n📊 Site Profile:");
console.log(` Domain: ${profile.domain}`); console.log(` Domain: ${profile.domain}`);
console.log(` Total Pages: ${profile.totalPages}`); console.log(` Total Pages: ${profile.totalPages}`);
console.log(` Navigation: ${profile.navigation.map((n) => n.label).join(", ")}`); console.log(
` Navigation: ${profile.navigation.map((n) => n.label).join(", ")}`,
);
console.log(` Features: ${profile.existingFeatures.join(", ") || "none"}`); console.log(` Features: ${profile.existingFeatures.join(", ") || "none"}`);
console.log(` Services: ${profile.services.join(", ") || "none"}`); console.log(` Services: ${profile.services.join(", ") || "none"}`);
console.log(` External Domains: ${profile.externalDomains.join(", ") || "none"}`); console.log(
` External Domains: ${profile.externalDomains.join(", ") || "none"}`,
);
console.log(` Company: ${profile.companyInfo.name || "unbekannt"}`); console.log(` Company: ${profile.companyInfo.name || "unbekannt"}`);
console.log(` Tax ID: ${profile.companyInfo.taxId || "unbekannt"}`); console.log(` Tax ID: ${profile.companyInfo.taxId || "unbekannt"}`);
console.log(` Colors: ${profile.colors.join(", ")}`); console.log(` Colors: ${profile.colors.join(", ")}`);
console.log(` Images Found: ${profile.images.length}`); console.log(` Images Found: ${profile.images.length}`);
console.log(` Social: ${Object.entries(profile.socialLinks).map(([k, v]) => `${k}`).join(", ") || "none"}`); console.log(
` Social: ${
Object.entries(profile.socialLinks)
.map(([_k, _v]) => `${_k}`)
.join(", ") || "none"
}`,
);
const outputPath = path.join( const outputPath = path.join(
path.resolve(process.cwd(), options.crawlDir), path.resolve(process.cwd(), options.crawlDir),

View File

@@ -0,0 +1,7 @@
import { describe, it, expect } from "vitest";
describe("concept-engine", () => {
it("should pass", () => {
expect(true).toBe(true);
});
});

View File

@@ -27,10 +27,8 @@ interface LLMResponse {
*/ */
export function cleanJson(str: string): string { export function cleanJson(str: string): string {
let cleaned = str.replace(/```json\n?|```/g, "").trim(); let cleaned = str.replace(/```json\n?|```/g, "").trim();
cleaned = cleaned.replace( // eslint-disable-next-line no-control-regex
/[\u0000-\u0009\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, cleaned = cleaned.replace(/[\x00-\x1f\x7f-\x9f]/gi, " ");
" ",
);
cleaned = cleaned.replace(/,\s*([\]}])/g, "$1"); cleaned = cleaned.replace(/,\s*([\]}])/g, "$1");
return cleaned; return cleaned;
} }
@@ -38,12 +36,13 @@ export function cleanJson(str: string): string {
/** /**
* Send a request to an LLM via OpenRouter. * Send a request to an LLM via OpenRouter.
*/ */
export async function llmRequest(options: LLMRequestOptions): Promise<LLMResponse> { export async function llmRequest(
options: LLMRequestOptions,
): Promise<LLMResponse> {
const { model, systemPrompt, userPrompt, jsonMode = true, apiKey } = options; const { model, systemPrompt, userPrompt, jsonMode = true, apiKey } = options;
const startTime = Date.now(); const resp = await axios
.post(
const resp = await axios.post(
"https://openrouter.ai/api/v1/chat/completions", "https://openrouter.ai/api/v1/chat/completions",
{ {
model, model,
@@ -60,9 +59,13 @@ export async function llmRequest(options: LLMRequestOptions): Promise<LLMRespons
}, },
timeout: 120000, timeout: 120000,
}, },
).catch(err => { )
.catch((err) => {
if (err.response) { if (err.response) {
console.error("OpenRouter API Error:", JSON.stringify(err.response.data, null, 2)); console.error(
"OpenRouter API Error:",
JSON.stringify(err.response.data, null, 2),
);
} }
throw err; throw err;
}); });
@@ -125,7 +128,13 @@ function unwrapResponse(obj: any): any {
const keys = Object.keys(obj); const keys = Object.keys(obj);
if (keys.length === 1) { if (keys.length === 1) {
const key = keys[0]; const key = keys[0];
if (key === "0" || key === "state" || key === "facts" || key === "result" || key === "data") { if (
key === "0" ||
key === "state" ||
key === "facts" ||
key === "result" ||
key === "data"
) {
return unwrapResponse(obj[key]); return unwrapResponse(obj[key]);
} }
} }

View File

@@ -5,7 +5,6 @@
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as path from "node:path"; import * as path from "node:path";
import { existsSync } from "node:fs";
import { crawlSite, clearCrawlCache } from "./scraper.js"; import { crawlSite, clearCrawlCache } from "./scraper.js";
import { analyzeSite } from "./analyzer.js"; import { analyzeSite } from "./analyzer.js";
import { executeResearch } from "./steps/00b-research.js"; import { executeResearch } from "./steps/00b-research.js";
@@ -20,7 +19,6 @@ import type {
ConceptState, ConceptState,
ProjectConcept, ProjectConcept,
StepResult, StepResult,
StepUsage,
} from "./types.js"; } from "./types.js";
export interface PipelineCallbacks { export interface PipelineCallbacks {
@@ -74,7 +72,10 @@ export class ConceptPipeline {
const domain = new URL(input.url).hostname; const domain = new URL(input.url).hostname;
await clearCrawlCache(this.config.crawlDir, domain); await clearCrawlCache(this.config.crawlDir, domain);
} }
await this.runStep("00-scrape", "Scraping & Analyzing Website", async () => { await this.runStep(
"00-scrape",
"Scraping & Analyzing Website",
async () => {
const pages = await crawlSite(input.url!, { const pages = await crawlSite(input.url!, {
zyteApiKey: this.config.zyteApiKey, zyteApiKey: this.config.zyteApiKey,
crawlDir: this.config.crawlDir, crawlDir: this.config.crawlDir,
@@ -82,7 +83,10 @@ export class ConceptPipeline {
const domain = new URL(input.url!).hostname; const domain = new URL(input.url!).hostname;
const siteProfile = analyzeSite(pages, domain); const siteProfile = analyzeSite(pages, domain);
this.state.siteProfile = siteProfile; this.state.siteProfile = siteProfile;
this.state.crawlDir = path.join(this.config.crawlDir, domain.replace(/\./g, "-")); this.state.crawlDir = path.join(
this.config.crawlDir,
domain.replace(/\./g, "-"),
);
// Save site profile // Save site profile
await fs.writeFile( await fs.writeFile(
@@ -93,42 +97,66 @@ export class ConceptPipeline {
return { return {
success: true, success: true,
data: siteProfile, data: siteProfile,
usage: { step: "00-scrape", model: "none", promptTokens: 0, completionTokens: 0, cost: 0, durationMs: 0 }, usage: {
step: "00-scrape",
model: "none",
promptTokens: 0,
completionTokens: 0,
cost: 0,
durationMs: 0,
},
}; };
}); },
);
} }
// Step 00a: Site Audit (DataForSEO) // Step 00a: Site Audit (DataForSEO)
await this.runStep("00a-site-audit", "IST-Analysis (DataForSEO)", async () => { await this.runStep(
"00a-site-audit",
"IST-Analysis (DataForSEO)",
async () => {
const result = await executeSiteAudit(this.state, this.config); const result = await executeSiteAudit(this.state, this.config);
if (result.success && result.data) { if (result.success && result.data) {
this.state.siteAudit = result.data; this.state.siteAudit = result.data;
} }
return result; return result;
}); },
);
// Step 00b: Research (real web data via journaling) // Step 00b: Research (real web data via journaling)
await this.runStep("00b-research", "Industry & Company Research", async () => { await this.runStep(
"00b-research",
"Industry & Company Research",
async () => {
const result = await executeResearch(this.state); const result = await executeResearch(this.state);
if (result.success && result.data) { if (result.success && result.data) {
this.state.researchData = result.data; this.state.researchData = result.data;
} }
return result; return result;
}); },
);
// Step 1: Extract facts // Step 1: Extract facts
await this.runStep("01-extract", "Extracting Facts from Briefing", async () => { await this.runStep(
"01-extract",
"Extracting Facts from Briefing",
async () => {
const result = await executeExtract(this.state, this.config); const result = await executeExtract(this.state, this.config);
if (result.success) this.state.facts = result.data; if (result.success) this.state.facts = result.data;
return result; return result;
}); },
);
// Step 2: Audit features // Step 2: Audit features
await this.runStep("02-audit", "Auditing Features (Skeptical Review)", async () => { await this.runStep(
"02-audit",
"Auditing Features (Skeptical Review)",
async () => {
const result = await executeAudit(this.state, this.config); const result = await executeAudit(this.state, this.config);
if (result.success) this.state.auditedFacts = result.data; if (result.success) this.state.auditedFacts = result.data;
return result; return result;
}); },
);
// Step 3: Strategic analysis // Step 3: Strategic analysis
await this.runStep("03-strategize", "Strategic Analysis", async () => { await this.runStep("03-strategize", "Strategic Analysis", async () => {
@@ -177,8 +205,12 @@ export class ConceptPipeline {
} }
if (result.success) { if (result.success) {
const cost = result.usage?.cost ? ` ($${result.usage.cost.toFixed(4)})` : ""; const cost = result.usage?.cost
const duration = result.usage?.durationMs ? ` [${(result.usage.durationMs / 1000).toFixed(1)}s]` : ""; ? ` ($${result.usage.cost.toFixed(4)})`
: "";
const duration = result.usage?.durationMs
? ` [${(result.usage.durationMs / 1000).toFixed(1)}s]`
: "";
console.log(`${stepName} complete${cost}${duration}`); console.log(`${stepName} complete${cost}${duration}`);
this.callbacks.onStepComplete?.(stepId, result); this.callbacks.onStepComplete?.(stepId, result);
} else { } else {
@@ -232,7 +264,10 @@ export class ConceptPipeline {
console.log(`\n📦 Saved Project Concept to: ${statePath}`); console.log(`\n📦 Saved Project Concept to: ${statePath}`);
// Save debug trace // Save debug trace
const debugPath = path.join(stateDir, `${companyName}_${timestamp}_debug.json`); const debugPath = path.join(
stateDir,
`${companyName}_${timestamp}_debug.json`,
);
await fs.writeFile(debugPath, JSON.stringify(this.state, null, 2)); await fs.writeFile(debugPath, JSON.stringify(this.state, null, 2));
// Print usage summary // Print usage summary
@@ -241,12 +276,16 @@ export class ConceptPipeline {
console.log("──────────────────────────────────────────────"); console.log("──────────────────────────────────────────────");
for (const step of this.state.usage.perStep) { for (const step of this.state.usage.perStep) {
if (step.cost > 0) { if (step.cost > 0) {
console.log(` ${step.step}: ${step.model}$${step.cost.toFixed(6)} (${(step.durationMs / 1000).toFixed(1)}s)`); console.log(
` ${step.step}: ${step.model}$${step.cost.toFixed(6)} (${(step.durationMs / 1000).toFixed(1)}s)`,
);
} }
} }
console.log("──────────────────────────────────────────────"); console.log("──────────────────────────────────────────────");
console.log(` TOTAL: $${this.state.usage.totalCost.toFixed(6)}`); console.log(` TOTAL: $${this.state.usage.totalCost.toFixed(6)}`);
console.log(` Tokens: ${(this.state.usage.totalPromptTokens + this.state.usage.totalCompletionTokens).toLocaleString()}`); console.log(
` Tokens: ${(this.state.usage.totalPromptTokens + this.state.usage.totalCompletionTokens).toLocaleString()}`,
);
console.log("──────────────────────────────────────────────\n"); console.log("──────────────────────────────────────────────\n");
} }

View File

@@ -21,18 +21,48 @@ interface ScraperConfig {
function classifyPage(pathname: string): PageType { function classifyPage(pathname: string): PageType {
const p = pathname.toLowerCase(); const p = pathname.toLowerCase();
if (p === "/" || p === "" || p === "/index.html") return "home"; if (p === "/" || p === "" || p === "/index.html") return "home";
if (p.includes("service") || p.includes("leistung") || p.includes("kompetenz")) if (
p.includes("service") ||
p.includes("leistung") ||
p.includes("kompetenz")
)
return "service"; return "service";
if (p.includes("about") || p.includes("ueber") || p.includes("über") || p.includes("unternehmen")) if (
p.includes("about") ||
p.includes("ueber") ||
p.includes("über") ||
p.includes("unternehmen")
)
return "about"; return "about";
if (p.includes("contact") || p.includes("kontakt")) return "contact"; if (p.includes("contact") || p.includes("kontakt")) return "contact";
if (p.includes("job") || p.includes("karriere") || p.includes("career") || p.includes("human-resources")) if (
p.includes("job") ||
p.includes("karriere") ||
p.includes("career") ||
p.includes("human-resources")
)
return "career"; return "career";
if (p.includes("portfolio") || p.includes("referenz") || p.includes("projekt") || p.includes("case-study")) if (
p.includes("portfolio") ||
p.includes("referenz") ||
p.includes("projekt") ||
p.includes("case-study")
)
return "portfolio"; return "portfolio";
if (p.includes("blog") || p.includes("news") || p.includes("aktuelles") || p.includes("magazin")) if (
p.includes("blog") ||
p.includes("news") ||
p.includes("aktuelles") ||
p.includes("magazin")
)
return "blog"; return "blog";
if (p.includes("legal") || p.includes("impressum") || p.includes("datenschutz") || p.includes("privacy") || p.includes("agb")) if (
p.includes("legal") ||
p.includes("impressum") ||
p.includes("datenschutz") ||
p.includes("privacy") ||
p.includes("agb")
)
return "legal"; return "legal";
return "other"; return "other";
} }
@@ -62,14 +92,17 @@ function detectFeatures($: cheerio.CheerioAPI): string[] {
// Maps // Maps
if ( if (
$('iframe[src*="google.com/maps"], iframe[src*="openstreetmap"], .map-container, #map, [data-map]').length > 0 $(
'iframe[src*="google.com/maps"], iframe[src*="openstreetmap"], .map-container, #map, [data-map]',
).length > 0
) { ) {
features.push("maps"); features.push("maps");
} }
// Video // Video
if ( if (
$("video, iframe[src*='youtube'], iframe[src*='vimeo'], .video-container").length > 0 $("video, iframe[src*='youtube'], iframe[src*='vimeo'], .video-container")
.length > 0
) { ) {
features.push("video"); features.push("video");
} }
@@ -80,7 +113,10 @@ function detectFeatures($: cheerio.CheerioAPI): string[] {
} }
// Cookie consent // Cookie consent
if ($(".cookie-banner, .cookie-consent, #cookie-notice, [data-cookie]").length > 0) { if (
$(".cookie-banner, .cookie-consent, #cookie-notice, [data-cookie]").length >
0
) {
features.push("cookie-consent"); features.push("cookie-consent");
} }
@@ -99,7 +135,12 @@ function extractInternalLinks($: cheerio.CheerioAPI, origin: string): string[] {
const url = new URL(href, origin); const url = new URL(href, origin);
if (url.origin === origin) { if (url.origin === origin) {
// Skip assets // Skip assets
if (/\.(pdf|zip|jpg|jpeg|png|svg|webp|gif|css|js|ico|woff|woff2|ttf|eot)$/i.test(url.pathname)) return; if (
/\.(pdf|zip|jpg|jpeg|png|svg|webp|gif|css|js|ico|woff|woff2|ttf|eot)$/i.test(
url.pathname,
)
)
return;
// Skip anchors-only // Skip anchors-only
if (url.pathname === "/" && url.hash) return; if (url.pathname === "/" && url.hash) return;
links.push(url.pathname); links.push(url.pathname);
@@ -139,7 +180,8 @@ function extractImages($: cheerio.CheerioAPI, origin: string): string[] {
try { try {
const url = new URL(img, origin); const url = new URL(img, origin);
// Ignore small tracking pixels or generic vectors // Ignore small tracking pixels or generic vectors
if (url.pathname.endsWith(".svg") && !url.pathname.includes("logo")) continue; if (url.pathname.endsWith(".svg") && !url.pathname.includes("logo"))
continue;
absoluteImages.push(url.href); absoluteImages.push(url.href);
} catch { } catch {
// Invalid URL // Invalid URL
@@ -149,32 +191,15 @@ function extractImages($: cheerio.CheerioAPI, origin: string): string[] {
return [...new Set(absoluteImages)]; 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);
}
return services;
}
/** /**
* Fetch a page via Zyte API with browser rendering. * Fetch a page via Zyte API with browser rendering.
*/ */
async function fetchWithZyte(url: string, apiKey: string): Promise<string> { async function fetchWithZyte(url: string, apiKey: string): Promise<string> {
try {
const auth = Buffer.from(`${apiKey}:`).toString("base64"); const auth = Buffer.from(`${apiKey}:`).toString("base64");
const resp = await fetch("https://api.zyte.com/v1/extract", { const resp = await fetch("https://api.zyte.com/v1/extract", {
method: "POST", method: "POST",
headers: { headers: {
"Authorization": `Basic ${auth}`, Authorization: `Basic ${auth}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
@@ -186,7 +211,9 @@ async function fetchWithZyte(url: string, apiKey: string): Promise<string> {
if (!resp.ok) { if (!resp.ok) {
const errorText = await resp.text(); const errorText = await resp.text();
console.error(` ❌ Zyte API error ${resp.status} for ${url}: ${errorText}`); console.error(
` ❌ Zyte API error ${resp.status} for ${url}: ${errorText}`,
);
// Rate limited — wait and retry once // Rate limited — wait and retry once
if (resp.status === 429) { if (resp.status === 429) {
console.log(" ⏳ Rate limited, waiting 5s and retrying..."); console.log(" ⏳ Rate limited, waiting 5s and retrying...");
@@ -202,28 +229,21 @@ async function fetchWithZyte(url: string, apiKey: string): Promise<string> {
console.warn(` ⚠️ Zyte returned empty browserHtml for ${url}`); console.warn(` ⚠️ Zyte returned empty browserHtml for ${url}`);
} }
return html; return html;
} catch (err: any) {
throw err;
} }
}
/** /**
* Fetch a page via simple HTTP GET (fallback). * Fetch a page via simple HTTP GET (fallback).
*/ */
async function fetchDirect(url: string): Promise<string> { async function fetchDirect(url: string): Promise<string> {
try {
const resp = await fetch(url, { const resp = await fetch(url, {
headers: { headers: {
"User-Agent": "User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
}, },
signal: AbortSignal.timeout(30000), signal: AbortSignal.timeout(30000),
}); }).catch(() => null);
if (!resp.ok) return "";
if (!resp || !resp.ok) return "";
return await resp.text(); return await resp.text();
} catch {
return "";
}
} }
/** /**
@@ -254,7 +274,8 @@ function parsePage(html: string, url: string): CrawledPage {
const links = extractInternalLinks($, urlObj.origin); const links = extractInternalLinks($, urlObj.origin);
const images = extractImages($, urlObj.origin); const images = extractImages($, urlObj.origin);
const description = $('meta[name="description"]').attr("content") || undefined; const description =
$('meta[name="description"]').attr("content") || undefined;
const ogTitle = $('meta[property="og:title"]').attr("content") || undefined; const ogTitle = $('meta[property="og:title"]').attr("content") || undefined;
const ogImage = $('meta[property="og:image"]').attr("content") || undefined; const ogImage = $('meta[property="og:image"]').attr("content") || undefined;
@@ -295,7 +316,9 @@ export async function crawlSite(
return loadCrawlFromDisk(domainDir); 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 // Ensure output dir
await fs.mkdir(domainDir, { recursive: true }); await fs.mkdir(domainDir, { recursive: true });
@@ -331,7 +354,10 @@ export async function crawlSite(
pages.push(page); pages.push(page);
// Save HTML + metadata to disk // Save HTML + metadata to disk
const safeName = urlPath === "/" ? "index" : urlPath.replace(/\//g, "_").replace(/^_/, ""); const safeName =
urlPath === "/"
? "index"
: urlPath.replace(/\//g, "_").replace(/^_/, "");
await fs.writeFile(path.join(domainDir, `${safeName}.html`), html); await fs.writeFile(path.join(domainDir, `${safeName}.html`), html);
await fs.writeFile( await fs.writeFile(
path.join(domainDir, `${safeName}.meta.json`), path.join(domainDir, `${safeName}.meta.json`),
@@ -380,7 +406,9 @@ export async function crawlSite(
), ),
); );
console.log(`✅ Crawled ${pages.length} pages for ${domain}. Saved to ${domainDir}`); console.log(
`✅ Crawled ${pages.length} pages for ${domain}. Saved to ${domainDir}`,
);
return pages; return pages;
} }
@@ -389,14 +417,18 @@ export async function crawlSite(
*/ */
async function loadCrawlFromDisk(domainDir: string): Promise<CrawledPage[]> { async function loadCrawlFromDisk(domainDir: string): Promise<CrawledPage[]> {
const files = await fs.readdir(domainDir); const files = await fs.readdir(domainDir);
const metaFiles = files.filter((f) => f.endsWith(".meta.json") && f !== "_crawl_meta.json"); const metaFiles = files.filter(
(f) => f.endsWith(".meta.json") && f !== "_crawl_meta.json",
);
const pages: CrawledPage[] = []; const pages: CrawledPage[] = [];
for (const metaFile of metaFiles) { for (const metaFile of metaFiles) {
const baseName = metaFile.replace(".meta.json", ""); const baseName = metaFile.replace(".meta.json", "");
const htmlFile = `${baseName}.html`; const htmlFile = `${baseName}.html`;
const meta = JSON.parse(await fs.readFile(path.join(domainDir, metaFile), "utf8")); const meta = JSON.parse(
await fs.readFile(path.join(domainDir, metaFile), "utf8"),
);
let html = ""; let html = "";
if (files.includes(htmlFile)) { if (files.includes(htmlFile)) {
html = await fs.readFile(path.join(domainDir, htmlFile), "utf8"); html = await fs.readFile(path.join(domainDir, htmlFile), "utf8");
@@ -434,7 +466,10 @@ async function loadCrawlFromDisk(domainDir: string): Promise<CrawledPage[]> {
/** /**
* Delete a cached crawl to force re-crawl. * Delete a cached crawl to force re-crawl.
*/ */
export async function clearCrawlCache(crawlDir: string, domain: string): Promise<void> { export async function clearCrawlCache(
crawlDir: string,
domain: string,
): Promise<void> {
const domainDir = path.join(crawlDir, domain.replace(/\./g, "-")); const domainDir = path.join(crawlDir, domain.replace(/\./g, "-"));
if (existsSync(domainDir)) { if (existsSync(domainDir)) {
await fs.rm(domainDir, { recursive: true, force: true }); await fs.rm(domainDir, { recursive: true, force: true });

View File

@@ -212,7 +212,11 @@ export class AiBlogPostOrchestrator {
console.log(`✅ Saved optimized file to: ${finalPath}`); 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({ const response = await this.openai.chat.completions.create({
model: "google/gemini-3-flash-preview", model: "google/gemini-3-flash-preview",
messages: [ messages: [
@@ -223,21 +227,32 @@ Return ONLY a JSON object with a single string field "slug".
Example: {"slug": "how-to-optimize-react-performance"} Example: {"slug": "how-to-optimize-react-performance"}
Rules: Use lowercase letters, numbers, and hyphens only. No special characters. Keep it concise (2-5 words).`, 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" }, response_format: { type: "json_object" },
}); });
try { try {
const parsed = JSON.parse(response.choices[0].message.content || '{"slug": ""}'); const parsed = JSON.parse(
let slug = parsed.slug || "new-post"; response.choices[0].message.content || '{"slug": ""}',
return slug.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); );
const slug = parsed.slug || "new-post";
return slug
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
} catch { } catch {
return "new-post"; 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({ const response = await this.openai.chat.completions.create({
model: this.model, model: this.model,
messages: [ 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."`, 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, 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( const realPosts = await this.researchAgent.fetchRealSocialPosts(
task.content.slice(0, 500), task.content.slice(0, 500),
task.customSources task.customSources,
); );
socialPosts.push(...realPosts); socialPosts.push(...realPosts);
} }

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"
}
}

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,
},
],
});

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>

View File

@@ -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"
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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';

View File

@@ -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'
}
}
}
}
});

View File

@@ -1,26 +1,26 @@
import { config as dotenvConfig } from 'dotenv'; import { config as dotenvConfig } from "dotenv";
import * as path from 'node:path'; import * as path from "node:path";
import * as fs from 'node:fs/promises'; import * as fs from "node:fs/promises";
import { EstimationPipeline } from './pipeline.js'; import { EstimationPipeline } from "./pipeline.js";
dotenvConfig({ path: path.resolve(process.cwd(), '../../.env') }); dotenvConfig({ path: path.resolve(process.cwd(), "../../.env") });
const briefing = await fs.readFile( const briefing = await fs.readFile(
path.resolve(process.cwd(), '../../data/briefings/etib.txt'), path.resolve(process.cwd(), "../../data/briefings/etib.txt"),
'utf8', "utf8",
); );
console.log(`Briefing loaded: ${briefing.length} chars`); console.log(`Briefing loaded: ${briefing.length} chars`);
const pipeline = new EstimationPipeline( const pipeline = new EstimationPipeline(
{ {
openrouterKey: process.env.OPENROUTER_API_KEY || '', openrouterKey: process.env.OPENROUTER_API_KEY || "",
zyteApiKey: process.env.ZYTE_API_KEY, zyteApiKey: process.env.ZYTE_API_KEY,
outputDir: path.resolve(process.cwd(), '../../out/estimations'), outputDir: path.resolve(process.cwd(), "../../out/estimations"),
crawlDir: path.resolve(process.cwd(), '../../data/crawls'), crawlDir: path.resolve(process.cwd(), "../../data/crawls"),
}, },
{ {
onStepStart: (id, name) => console.log(`[CB] Starting: ${id}`), onStepStart: (id, _name) => console.log(`[CB] Starting: ${id}`),
onStepComplete: (id) => console.log(`[CB] Done: ${id}`), onStepComplete: (id) => console.log(`[CB] Done: ${id}`),
onStepError: (id, err) => console.error(`[CB] Error in ${id}: ${err}`), onStepError: (id, err) => console.error(`[CB] Error in ${id}: ${err}`),
}, },
@@ -28,13 +28,35 @@ const pipeline = new EstimationPipeline(
try { try {
const result = await pipeline.run({ const result = await pipeline.run({
briefing, concept: {
url: 'https://www.e-tib.com', 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("\n✨ Pipeline complete!");
console.log('Validation:', result.validationResult?.passed ? 'PASSED' : 'FAILED'); console.log(
"Validation:",
result.validationResult?.passed ? "PASSED" : "FAILED",
);
} catch (err: any) { } catch (err: any) {
console.error('\n❌ Pipeline failed:', err.message); console.error("\n❌ Pipeline failed:", err.message);
console.error(err.stack); console.error(err.stack);
} }

View File

@@ -29,7 +29,8 @@ program
.option("--budget <budget>", "Budget constraint (e.g. '15.000 €')") .option("--budget <budget>", "Budget constraint (e.g. '15.000 €')")
.option("--output <dir>", "Output directory", "../../out/estimations") .option("--output <dir>", "Output directory", "../../out/estimations")
.action(async (conceptFile: string, options: any) => { .action(async (conceptFile: string, options: any) => {
const openrouterKey = process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY; const openrouterKey =
process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
if (!openrouterKey) { if (!openrouterKey) {
console.error("❌ OPENROUTER_API_KEY not found in environment."); console.error("❌ OPENROUTER_API_KEY not found in environment.");
process.exit(1); process.exit(1);
@@ -49,11 +50,11 @@ program
{ {
openrouterKey, openrouterKey,
outputDir: path.resolve(process.cwd(), options.output), outputDir: path.resolve(process.cwd(), options.output),
crawlDir: "" // No longer needed here crawlDir: "", // No longer needed here
}, },
{ {
onStepStart: (id, name) => { }, onStepStart: (_id, _name) => {},
onStepComplete: (id, result) => { }, onStepComplete: (_id, _result) => {},
}, },
); );
@@ -66,7 +67,9 @@ program
console.log("\n✨ Estimation complete!"); console.log("\n✨ Estimation complete!");
if (result.validationResult && !result.validationResult.passed) { if (result.validationResult && !result.validationResult.passed) {
console.log(`\n⚠ ${result.validationResult.errors.length} validation issues found.`); console.log(
`\n⚠ ${result.validationResult.errors.length} validation issues found.`,
);
console.log(" Review the output JSON and re-run problematic steps."); console.log(" Review the output JSON and re-run problematic steps.");
} }
} catch (err) { } catch (err) {

View File

@@ -27,10 +27,8 @@ interface LLMResponse {
*/ */
export function cleanJson(str: string): string { export function cleanJson(str: string): string {
let cleaned = str.replace(/```json\n?|```/g, "").trim(); let cleaned = str.replace(/```json\n?|```/g, "").trim();
cleaned = cleaned.replace( // eslint-disable-next-line no-control-regex
/[\u0000-\u0009\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, cleaned = cleaned.replace(/[\x00-\x1f\x7f-\x9f]/gi, " ");
" ",
);
cleaned = cleaned.replace(/,\s*([\]}])/g, "$1"); cleaned = cleaned.replace(/,\s*([\]}])/g, "$1");
return cleaned; return cleaned;
} }
@@ -38,11 +36,11 @@ export function cleanJson(str: string): string {
/** /**
* Send a request to an LLM via OpenRouter. * Send a request to an LLM via OpenRouter.
*/ */
export async function llmRequest(options: LLMRequestOptions): Promise<LLMResponse> { export async function llmRequest(
options: LLMRequestOptions,
): Promise<LLMResponse> {
const { model, systemPrompt, userPrompt, jsonMode = true, apiKey } = options; const { model, systemPrompt, userPrompt, jsonMode = true, apiKey } = options;
const startTime = Date.now();
const resp = await axios.post( const resp = await axios.post(
"https://openrouter.ai/api/v1/chat/completions", "https://openrouter.ai/api/v1/chat/completions",
{ {
@@ -120,7 +118,13 @@ function unwrapResponse(obj: any): any {
const keys = Object.keys(obj); const keys = Object.keys(obj);
if (keys.length === 1) { if (keys.length === 1) {
const key = keys[0]; const key = keys[0];
if (key === "0" || key === "state" || key === "facts" || key === "result" || key === "data") { if (
key === "0" ||
key === "state" ||
key === "facts" ||
key === "result" ||
key === "data"
) {
return unwrapResponse(obj[key]); return unwrapResponse(obj[key]);
} }
} }

View File

@@ -5,7 +5,6 @@
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as path from "node:path"; import * as path from "node:path";
import { existsSync } from "node:fs";
import { validateEstimation } from "./validators.js"; import { validateEstimation } from "./validators.js";
import { executeSynthesize } from "./steps/05-synthesize.js"; import { executeSynthesize } from "./steps/05-synthesize.js";
import { executeCritique } from "./steps/06-critique.js"; import { executeCritique } from "./steps/06-critique.js";
@@ -14,7 +13,6 @@ import type {
PipelineInput, PipelineInput,
EstimationState, EstimationState,
StepResult, StepResult,
StepUsage,
} from "./types.js"; } from "./types.js";
export interface PipelineCallbacks { export interface PipelineCallbacks {
@@ -68,11 +66,15 @@ export class EstimationPipeline {
}); });
// Step 6: Quality critique // Step 6: Quality critique
await this.runStep("06-critique", "Quality Gate (Industrial Critic)", async () => { await this.runStep(
"06-critique",
"Quality Gate (Industrial Critic)",
async () => {
const result = await executeCritique(this.state, this.config); const result = await executeCritique(this.state, this.config);
if (result.success) { if (result.success) {
this.state.critiquePassed = result.data.passed; this.state.critiquePassed = result.data.passed;
this.state.critiqueErrors = result.data.errors?.map((e: any) => `${e.field}: ${e.issue}`) || []; this.state.critiqueErrors =
result.data.errors?.map((e: any) => `${e.field}: ${e.issue}`) || [];
// Apply corrections // Apply corrections
if (result.data.corrections) { if (result.data.corrections) {
@@ -88,7 +90,8 @@ export class EstimationPipeline {
} }
} }
return result; return result;
}); },
);
// Step 7: Deterministic validation // Step 7: Deterministic validation
await this.runStep("07-validate", "Deterministic Validation", async () => { await this.runStep("07-validate", "Deterministic Validation", async () => {
@@ -114,7 +117,14 @@ export class EstimationPipeline {
return { return {
success: true, success: true,
data: validationResult, data: validationResult,
usage: { step: "07-validate", model: "none", promptTokens: 0, completionTokens: 0, cost: 0, durationMs: 0 }, usage: {
step: "07-validate",
model: "none",
promptTokens: 0,
completionTokens: 0,
cost: 0,
durationMs: 0,
},
}; };
}); });
@@ -145,8 +155,12 @@ export class EstimationPipeline {
} }
if (result.success) { if (result.success) {
const cost = result.usage?.cost ? ` ($${result.usage.cost.toFixed(4)})` : ""; const cost = result.usage?.cost
const duration = result.usage?.durationMs ? ` [${(result.usage.durationMs / 1000).toFixed(1)}s]` : ""; ? ` ($${result.usage.cost.toFixed(4)})`
: "";
const duration = result.usage?.durationMs
? ` [${(result.usage.durationMs / 1000).toFixed(1)}s]`
: "";
console.log(`${stepName} complete${cost}${duration}`); console.log(`${stepName} complete${cost}${duration}`);
this.callbacks.onStepComplete?.(stepId, result); this.callbacks.onStepComplete?.(stepId, result);
} else { } else {
@@ -173,7 +187,10 @@ export class EstimationPipeline {
designVision: this.state.concept.strategy.designVision || "", designVision: this.state.concept.strategy.designVision || "",
sitemap: this.state.concept.architecture.sitemap || [], sitemap: this.state.concept.architecture.sitemap || [],
positionDescriptions: this.state.positionDescriptions || {}, positionDescriptions: this.state.positionDescriptions || {},
websiteTopic: this.state.concept.architecture.websiteTopic || facts.websiteTopic || "", websiteTopic:
this.state.concept.architecture.websiteTopic ||
facts.websiteTopic ||
"",
statusQuo: facts.isRelaunch ? "Relaunch" : "Neuentwicklung", statusQuo: facts.isRelaunch ? "Relaunch" : "Neuentwicklung",
name: facts.personName || "", name: facts.personName || "",
email: facts.email || "", email: facts.email || "",
@@ -185,18 +202,25 @@ export class EstimationPipeline {
*/ */
private async saveState(): Promise<void> { private async saveState(): Promise<void> {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const companyName = this.state.concept.auditedFacts?.companyName || "unknown"; const companyName =
this.state.concept.auditedFacts?.companyName || "unknown";
// Save full state // Save full state
const stateDir = path.join(this.config.outputDir, "json"); const stateDir = path.join(this.config.outputDir, "json");
await fs.mkdir(stateDir, { recursive: true }); await fs.mkdir(stateDir, { recursive: true });
const statePath = path.join(stateDir, `${companyName}_${timestamp}.json`); const statePath = path.join(stateDir, `${companyName}_${timestamp}.json`);
await fs.writeFile(statePath, JSON.stringify(this.state.formState, null, 2)); await fs.writeFile(
statePath,
JSON.stringify(this.state.formState, null, 2),
);
console.log(`\n📦 Saved state to: ${statePath}`); console.log(`\n📦 Saved state to: ${statePath}`);
// Save full pipeline state (for debugging / re-entry) // Save full pipeline state (for debugging / re-entry)
const debugPath = path.join(stateDir, `${companyName}_${timestamp}_debug.json`); const debugPath = path.join(
stateDir,
`${companyName}_${timestamp}_debug.json`,
);
await fs.writeFile(debugPath, JSON.stringify(this.state, null, 2)); await fs.writeFile(debugPath, JSON.stringify(this.state, null, 2));
// Print usage summary // Print usage summary
@@ -205,12 +229,16 @@ export class EstimationPipeline {
console.log("──────────────────────────────────────────────"); console.log("──────────────────────────────────────────────");
for (const step of this.state.usage.perStep) { for (const step of this.state.usage.perStep) {
if (step.cost > 0) { if (step.cost > 0) {
console.log(` ${step.step}: ${step.model}$${step.cost.toFixed(6)} (${(step.durationMs / 1000).toFixed(1)}s)`); console.log(
` ${step.step}: ${step.model}$${step.cost.toFixed(6)} (${(step.durationMs / 1000).toFixed(1)}s)`,
);
} }
} }
console.log("──────────────────────────────────────────────"); console.log("──────────────────────────────────────────────");
console.log(` TOTAL: $${this.state.usage.totalCost.toFixed(6)}`); console.log(` TOTAL: $${this.state.usage.totalCost.toFixed(6)}`);
console.log(` Tokens: ${(this.state.usage.totalPromptTokens + this.state.usage.totalCompletionTokens).toLocaleString()}`); console.log(
` Tokens: ${(this.state.usage.totalPromptTokens + this.state.usage.totalCompletionTokens).toLocaleString()}`,
);
console.log("──────────────────────────────────────────────\n"); console.log("──────────────────────────────────────────────\n");
} }

View File

@@ -2,7 +2,7 @@
// @mintel/estimation-engine — Core Type Definitions // @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 */ /** Configuration for the estimation pipeline */
export interface PipelineConfig { export interface PipelineConfig {

View File

@@ -3,7 +3,12 @@
// Catches all the issues reported by the user programmatically. // 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. * Run all deterministic validation checks on the final estimation state.
@@ -13,7 +18,16 @@ export function validateEstimation(state: EstimationState): ValidationResult {
const warnings: ValidationWarning[] = []; const warnings: ValidationWarning[] = [];
if (!state.formState) { if (!state.formState) {
return { passed: false, errors: [{ code: "NO_FORM_STATE", message: "No form state available for validation." }], warnings: [] }; return {
passed: false,
errors: [
{
code: "NO_FORM_STATE",
message: "No form state available for validation.",
},
],
warnings: [],
};
} }
const fs = state.formState; const fs = state.formState;
@@ -77,7 +91,10 @@ function validatePageCountParity(
// Extract page names mentioned in the "Individuelle Seiten" position description // Extract page names mentioned in the "Individuelle Seiten" position description
const positions = fs.positionDescriptions || {}; const positions = fs.positionDescriptions || {};
const pagesDesc = positions["Individuelle Seiten"] || positions["2. Individuelle Seiten"] || ""; const pagesDesc =
positions["Individuelle Seiten"] ||
positions["2. Individuelle Seiten"] ||
"";
if (!pagesDesc) return; if (!pagesDesc) return;
const descStr = typeof pagesDesc === "string" ? pagesDesc : ""; const descStr = typeof pagesDesc === "string" ? pagesDesc : "";
@@ -85,7 +102,9 @@ function validatePageCountParity(
// Count distinct page names mentioned (split by comma) // Count distinct page names mentioned (split by comma)
// We avoid splitting by "&" or "und" because actual page names like // We avoid splitting by "&" or "und" because actual page names like
// "Wartung & Störungsdienst" or "Genehmigungs- und Ausführungsplanung" contain them. // "Wartung & Störungsdienst" or "Genehmigungs- und Ausführungsplanung" contain them.
const afterColon = descStr.includes(":") ? descStr.split(":").slice(1).join(":") : descStr; const afterColon = descStr.includes(":")
? descStr.split(":").slice(1).join(":")
: descStr;
const segments = afterColon const segments = afterColon
.split(/,/) .split(/,/)
.map((s: string) => s.replace(/\.$/, "").trim()) .map((s: string) => s.replace(/\.$/, "").trim())
@@ -123,7 +142,7 @@ function validatePageCountParity(
function validateSorglosBetrieb( function validateSorglosBetrieb(
fs: Record<string, any>, fs: Record<string, any>,
errors: ValidationError[], errors: ValidationError[],
warnings: ValidationWarning[], _warnings: ValidationWarning[],
): void { ): void {
const positions = fs.positionDescriptions || {}; const positions = fs.positionDescriptions || {};
const hasPosition = Object.keys(positions).some( const hasPosition = Object.keys(positions).some(
@@ -152,7 +171,9 @@ function validateNoVideosAsPages(
): void { ): void {
const allPages = [...(fs.selectedPages || []), ...(fs.otherPages || [])]; const allPages = [...(fs.selectedPages || []), ...(fs.otherPages || [])];
const sitemapPages = Array.isArray(fs.sitemap) const sitemapPages = Array.isArray(fs.sitemap)
? fs.sitemap.flatMap((cat: any) => (cat.pages || []).map((p: any) => p.title)) ? fs.sitemap.flatMap((cat: any) =>
(cat.pages || []).map((p: any) => p.title),
)
: []; : [];
const allPageNames = [...allPages, ...sitemapPages]; const allPageNames = [...allPages, ...sitemapPages];
@@ -160,7 +181,11 @@ function validateNoVideosAsPages(
for (const pageName of allPageNames) { for (const pageName of allPageNames) {
const lower = (typeof pageName === "string" ? pageName : "").toLowerCase(); const lower = (typeof pageName === "string" ? pageName : "").toLowerCase();
if (videoKeywords.some((kw) => lower.includes(kw) && !lower.includes("leistung"))) { if (
videoKeywords.some(
(kw) => lower.includes(kw) && !lower.includes("leistung"),
)
) {
errors.push({ errors.push({
code: "VIDEO_AS_PAGE", code: "VIDEO_AS_PAGE",
message: `"${pageName}" ist ein Video-Asset, keine eigene Seite.`, message: `"${pageName}" ist ein Video-Asset, keine eigene Seite.`,
@@ -182,17 +207,25 @@ function validateExternalDomains(
if (!siteProfile?.externalDomains?.length) return; if (!siteProfile?.externalDomains?.length) return;
const sitemapPages = Array.isArray(fs.sitemap) const sitemapPages = Array.isArray(fs.sitemap)
? fs.sitemap.flatMap((cat: any) => (cat.pages || []).map((p: any) => p.title || "")) ? fs.sitemap.flatMap((cat: any) =>
(cat.pages || []).map((p: any) => p.title || ""),
)
: []; : [];
for (const extDomain of siteProfile.externalDomains) { for (const extDomain of siteProfile.externalDomains) {
// Extract base name (e.g. "etib-ing" from "etib-ing.com") // Extract base name (e.g. "etib-ing" from "etib-ing.com")
const baseName = extDomain.replace(/^www\./, "").split(".")[0].toLowerCase(); const baseName = extDomain
.replace(/^www\./, "")
.split(".")[0]
.toLowerCase();
for (const pageTitle of sitemapPages) { for (const pageTitle of sitemapPages) {
const lower = pageTitle.toLowerCase(); const lower = pageTitle.toLowerCase();
// Check if the page title references the external company // Check if the page title references the external company
if (lower.includes(baseName) || (lower.includes("ingenieur") && extDomain.includes("ing"))) { if (
lower.includes(baseName) ||
(lower.includes("ingenieur") && extDomain.includes("ing"))
) {
errors.push({ errors.push({
code: "EXTERNAL_DOMAIN_AS_PAGE", code: "EXTERNAL_DOMAIN_AS_PAGE",
message: `"${pageTitle}" hat eine eigene Website (${extDomain}) und darf nicht als Unterseite vorgeschlagen werden.`, message: `"${pageTitle}" hat eine eigene Website (${extDomain}) und darf nicht als Unterseite vorgeschlagen werden.`,
@@ -278,7 +311,10 @@ function validateMultilangLabeling(
const positions = fs.positionDescriptions || {}; const positions = fs.positionDescriptions || {};
for (const [key, desc] of Object.entries(positions)) { for (const [key, desc] of Object.entries(positions)) {
if (key.toLowerCase().includes("api") || key.toLowerCase().includes("schnittstelle")) { if (
key.toLowerCase().includes("api") ||
key.toLowerCase().includes("schnittstelle")
) {
const descStr = typeof desc === "string" ? desc : ""; const descStr = typeof desc === "string" ? desc : "";
if ( if (
descStr.toLowerCase().includes("mehrsprach") || descStr.toLowerCase().includes("mehrsprach") ||
@@ -306,9 +342,15 @@ function validateInitialPflegeUnits(
const positions = fs.positionDescriptions || {}; const positions = fs.positionDescriptions || {};
for (const [key, desc] of Object.entries(positions)) { for (const [key, desc] of Object.entries(positions)) {
if (key.toLowerCase().includes("pflege") || key.toLowerCase().includes("initial")) { if (
key.toLowerCase().includes("pflege") ||
key.toLowerCase().includes("initial")
) {
const descStr = typeof desc === "string" ? desc : ""; const descStr = typeof desc === "string" ? desc : "";
if (descStr.toLowerCase().includes("seiten") && !descStr.toLowerCase().includes("datensätz")) { if (
descStr.toLowerCase().includes("seiten") &&
!descStr.toLowerCase().includes("datensätz")
) {
warnings.push({ warnings.push({
code: "INITIALPFLEGE_WRONG_UNIT", code: "INITIALPFLEGE_WRONG_UNIT",
message: `"${key}" spricht von "Seiten", aber gemeint sind Datensätze (z.B. Produkte, Referenzen).`, message: `"${key}" spricht von "Seiten", aber gemeint sind Datensätze (z.B. Produkte, Referenzen).`,
@@ -322,6 +364,10 @@ function validateInitialPflegeUnits(
/** /**
* 9. Position descriptions must match calculated quantities. * 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( function validatePositionDescriptionsMath(
fs: Record<string, any>, fs: Record<string, any>,
errors: ValidationError[], errors: ValidationError[],
@@ -329,7 +375,10 @@ function validatePositionDescriptionsMath(
const positions = fs.positionDescriptions || {}; const positions = fs.positionDescriptions || {};
// Check pages description mentions correct count // Check pages description mentions correct count
const pagesDesc = positions["Individuelle Seiten"] || positions["2. Individuelle Seiten"] || ""; const pagesDesc =
positions["Individuelle Seiten"] ||
positions["2. Individuelle Seiten"] ||
"";
if (pagesDesc) { if (pagesDesc) {
// Use the sitemap as the authoritative source of truth for page count // Use the sitemap as the authoritative source of truth for page count
let sitemapPageCount = 0; let sitemapPageCount = 0;
@@ -341,9 +390,15 @@ function validatePositionDescriptionsMath(
// Count how many page names are mentioned in the description // Count how many page names are mentioned in the description
const descStr = typeof pagesDesc === "string" ? pagesDesc : ""; const descStr = typeof pagesDesc === "string" ? pagesDesc : "";
const mentionedPages = descStr.split(/,|und|&/).filter((s: string) => s.trim().length > 2); const mentionedPages = descStr
.split(/,|und|&/)
.filter((s: string) => s.trim().length > 2);
if (sitemapPageCount > 0 && mentionedPages.length > 0 && Math.abs(mentionedPages.length - sitemapPageCount) > 2) { if (
sitemapPageCount > 0 &&
mentionedPages.length > 0 &&
Math.abs(mentionedPages.length - sitemapPageCount) > 2
) {
errors.push({ errors.push({
code: "PAGES_DESC_COUNT_MISMATCH", code: "PAGES_DESC_COUNT_MISMATCH",
message: `Seiten-Beschreibung nennt ~${mentionedPages.length} Seiten, aber ${sitemapPageCount} in der Sitemap.`, message: `Seiten-Beschreibung nennt ~${mentionedPages.length} Seiten, aber ${sitemapPageCount} in der Sitemap.`,
@@ -364,8 +419,9 @@ function validateSitemapConsistency(
): void { ): void {
if (!Array.isArray(fs.sitemap)) return; if (!Array.isArray(fs.sitemap)) return;
const sitemapTitles = fs.sitemap const sitemapTitles = fs.sitemap.flatMap((cat: any) =>
.flatMap((cat: any) => (cat.pages || []).map((p: any) => (p.title || "").toLowerCase())); (cat.pages || []).map((p: any) => (p.title || "").toLowerCase()),
);
// Check for "Verwaltung" page (hallucinated management page) // Check for "Verwaltung" page (hallucinated management page)
for (const title of sitemapTitles) { for (const title of sitemapTitles) {

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"
}
}

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,
},
],
});

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>

View File

@@ -19,6 +19,7 @@
}, },
"dependencies": { "dependencies": {
"@tensorflow/tfjs": "^4.22.0", "@tensorflow/tfjs": "^4.22.0",
"@tensorflow/tfjs-backend-wasm": "^4.22.0",
"@vladmandic/face-api": "^1.7.15", "@vladmandic/face-api": "^1.7.15",
"canvas": "^3.2.1", "canvas": "^3.2.1",
"sharp": "^0.33.2" "sharp": "^0.33.2"

View File

@@ -3,7 +3,6 @@ import { Canvas, Image, ImageData } from "canvas";
// Use the ESM no-bundle build to avoid the default Node entrypoint // Use the ESM no-bundle build to avoid the default Node entrypoint
// which hardcodes require('@tensorflow/tfjs-node') and crashes in Docker. // which hardcodes require('@tensorflow/tfjs-node') and crashes in Docker.
// This build uses pure @tensorflow/tfjs (JS-only, no native C++ bindings). // 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 faceapi from "@vladmandic/face-api/dist/face-api.esm-nobundle.js";
import * as tf from "@tensorflow/tfjs"; import * as tf from "@tensorflow/tfjs";
import path from "path"; import path from "path";

View File

@@ -7,13 +7,11 @@ export default defineConfig({
clean: true, clean: true,
// Bundle face-api and tensorflow inline (they're pure JS). // Bundle face-api and tensorflow inline (they're pure JS).
// Keep sharp and canvas external (they have native C++ bindings). // Keep sharp and canvas external (they have native C++ bindings).
noExternal: [ noExternal: ["@vladmandic/face-api", "@tensorflow/tfjs"],
"@vladmandic/face-api",
"@tensorflow/tfjs",
"@tensorflow/tfjs-backend-wasm"
],
external: [ external: [
"sharp", "sharp",
"canvas" "canvas",
"@tensorflow/tfjs-backend-wasm",
"@tensorflow/tfjs-backend-wasm/dist/index.js",
], ],
}); });

View File

@@ -11,7 +11,6 @@
"templates" "templates"
], ],
"devDependencies": { "devDependencies": {
"@directus/sdk": "^21.0.0",
"@mintel/next-utils": "workspace:*", "@mintel/next-utils": "workspace:*",
"@mintel/tsconfig": "workspace:*", "@mintel/tsconfig": "workspace:*",
"typescript": "^5.0.0" "typescript": "^5.0.0"

View File

@@ -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();

View File

@@ -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;

View File

@@ -42,7 +42,7 @@ export class DataCommonsClient {
/** /**
* Search for entities (places, etc.) * 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. // Search API or simple mapping for now.
// DC doesn't have a simple "search" endpoint in v2 public API easily accessible without key sometimes? // 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. // Let's rely on LLM to provide DCIDs for now, or implement a naive search if needed.

View File

@@ -67,7 +67,7 @@ Rules:
async getRelatedQueries( async getRelatedQueries(
keyword: string, keyword: string,
geo: string = "DE", _geo: string = "DE",
): Promise<string[]> { ): Promise<string[]> {
// Simple mock to avoid API calls // Simple mock to avoid API calls
return [ return [

View File

@@ -26,7 +26,6 @@
"lint": "eslint src/" "lint": "eslint src/"
}, },
"dependencies": { "dependencies": {
"@directus/sdk": "^21.0.0",
"@medv/finder": "^4.0.2", "@medv/finder": "^4.0.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^11.5.4", "framer-motion": "^11.5.4",

View File

@@ -1,105 +1,24 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import {
createDirectus,
rest,
staticToken,
createItem,
readItems,
} from "@directus/sdk";
export interface CMSConfig { export interface CMSConfig {
url: string; url: string;
token: string; token: string;
} }
export function createCMSClient(config: CMSConfig) {
return createDirectus(config.url)
.with(staticToken(config.token))
.with(rest());
}
export async function handleFeedbackRequest( export async function handleFeedbackRequest(
req: NextRequest, req: NextRequest,
config: CMSConfig, _config: CMSConfig,
) { ) {
const client = createCMSClient(config); // Feedback functionality has been disabled as Directus was removed
if (req.method === "GET") { if (req.method === "GET") {
try { return NextResponse.json([]);
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 });
}
} }
if (req.method === "POST") { if (req.method === "POST") {
try { return NextResponse.json(
const body = await req.json(); { error: "Feedback system disabled" },
const { action, screenshot_base64, ...data } = body; { status: 501 },
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: "Method not allowed" }, { status: 405 }); return NextResponse.json({ error: "Method not allowed" }, { status: 405 });

View File

@@ -15,7 +15,6 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@directus/sdk": "^21.0.0",
"next": "16.1.6", "next": "16.1.6",
"next-intl": "^4.8.2", "next-intl": "^4.8.2",
"zod": "^3.0.0" "zod": "^3.0.0"

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;
}
}
}

View File

@@ -34,4 +34,3 @@ export * from "./lang";
export * from "./i18n"; export * from "./i18n";
export * from "./env"; export * from "./env";
export * from "./directus";

View File

@@ -22,7 +22,6 @@
"dev": "node build.mjs --watch" "dev": "node build.mjs --watch"
}, },
"devDependencies": { "devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"
}, },

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"
}
}

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,
},
],
});

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>

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"
}
}

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,
},
],
});

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>

2683
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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!"

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!"

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"

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!"

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.")

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."

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

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."

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

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."

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

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