Compare commits

..
Author SHA1 Message Date
mmintel 9e4e296e3b feat: adds aquisition extension to cms
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 11s
Monorepo Pipeline / 🚀 Release (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
2026-02-10 21:30:23 +01:00
130 changed files with 7984 additions and 8363 deletions
-82
View File
@@ -1,82 +0,0 @@
---
description: How to manage and deploy Directus CMS infrastructure changes.
---
# Directus CMS Infrastructure Workflow
This workflow ensures "Industrial Grade" consistency and stability across local, testing, and production environments for the `at-mintel` Directus CMS.
## 1. Local Development Lifecycle
### Starting the CMS
To start the local Directus instance with extensions:
```bash
cd packages/cms-infra
npm run up
```
### Modifying Schema
1. **Directus UI**: Make your changes directly in the local Directus Admin UI (Collections, Fields, Relations).
2. **Take Snapshot**:
```bash
cd packages/cms-infra
npm run snapshot:local
```
This updates `packages/cms-infra/schema/snapshot.yaml`.
3. **Commit**: Commit the updated `snapshot.yaml`.
## 2. Deploying Schema Changes
### To Local Environment (Reconciliation)
If you pull changes from Git and need to apply them to your local database:
```bash
cd packages/cms-infra
npm run schema:apply:local
```
> [!IMPORTANT]
> This command automatically runs `scripts/cms-reconcile.sh` to prevent "Field already exists" errors by registering database columns in Directus metadata first.
### To Production (Infra)
To deploy the local snapshot to the production server:
```bash
cd packages/cms-infra
npm run schema:apply:infra
```
This script:
1. Syncs built extensions via rsync.
2. Injects the `snapshot.yaml` into the remote container.
3. Runs `directus schema apply`.
4. Restarts Directus to clear the schema cache.
## 3. Data Synchronization
### Pulling from Production
To update your local environment with production data and assets:
```bash
cd packages/cms-infra
npm run sync:pull
```
### Pushing to Production
> [!CAUTION]
> This will overwrite production data. Use with extreme care.
```bash
cd packages/cms-infra
npm run sync:push
```
## 4. Extension Management
When modifying extensions in `packages/*-manager`:
1. Extensions are automatically built and synced when running `npm run up`.
2. To sync manually without restarting the stack:
```bash
cd packages/cms-infra
npm run build:extensions
```
## 5. Troubleshooting "Field already exists"
If `schema:apply` fails with "Field already exists", run:
```bash
./scripts/cms-reconcile.sh
```
This script ensures the database state matches Directus's internal field registry (`directus_fields`).
-7
View File
@@ -1,7 +0,0 @@
---
"@mintel/monorepo": patch
"acquisition-manager": patch
"feedback-commander": patch
---
fix: make directus extension build scripts more resilient
-36
View File
@@ -1,36 +0,0 @@
# Project
IMAGE_TAG=v1.8.2
PROJECT_NAME=at-mintel
PROJECT_COLOR=#82ed20
GITEA_TOKEN=ccce002e30fe16a31a6c9d5a414740af2f72a582
# Authentication
GATEKEEPER_PASSWORD=mintel
AUTH_COOKIE_NAME=mintel_gatekeeper_session
# Host Config (Local)
TRAEFIK_HOST=at-mintel.localhost
DIRECTUS_HOST=cms.localhost
# Next.js
NEXT_PUBLIC_BASE_URL=http://at-mintel.localhost
# Directus
DIRECTUS_URL=http://cms.localhost
DIRECTUS_KEY=F9IIfahEjPq6NZhKyRLw516D8GotuFj79EGK7pGfIWg=
DIRECTUS_SECRET=OZfxMu8lBxzaEnFGRKreNBoJpRiRu58U+HsVg2yWk4o=
CORS_ENABLED=true
CORS_ORIGIN=true
LOG_LEVEL=debug
DIRECTUS_ADMIN_EMAIL=mmintel@mintel.me
DIRECTUS_ADMIN_PASSWORD=Tim300493.
DIRECTUS_DB_NAME=directus
DIRECTUS_DB_USER=directus
DIRECTUS_DB_PASSWORD=mintel-db-pass
# Sentry / Glitchtip
SENTRY_DSN=
# Analytics (Umami)
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
+1 -1
View File
@@ -1,5 +1,5 @@
# Project # Project
IMAGE_TAG=v1.8.2 IMAGE_TAG=v1.7.0
PROJECT_NAME=sample-website PROJECT_NAME=sample-website
PROJECT_COLOR=#82ed20 PROJECT_COLOR=#82ed20
-4
View File
@@ -1,4 +0,0 @@
**/index.js
**/dist/**
packages/cms-infra/extensions/**
packages/cms-infra/extensions/**
-44
View File
@@ -1,44 +0,0 @@
name: 🏥 Server Maintenance
on:
schedule:
- cron: '0 3 * * *' # Every day at 3:00 AM
workflow_dispatch: # Allow manual trigger
jobs:
maintenance:
name: 🧹 Prune & Clean
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: 🚀 Execute Maintenance via SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
# Run the prune script on the host
# We transfer the script and execute it to ensure it matches the repo version
scp packages/infra/scripts/prune-registry.sh root@${{ secrets.SSH_HOST }}:/tmp/prune-registry.sh
ssh root@${{ secrets.SSH_HOST }} "bash /tmp/prune-registry.sh && rm /tmp/prune-registry.sh"
- name: 🔔 Notification - Success
if: success()
run: |
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=🏥 Maintenance Complete" \
-F "message=Server-Wartung erfolgreich ausgeführt.\nRegistry & Docker Ressourcen bereinigt." \
-F "priority=2" || true
- name: 🔔 Notification - Failure
if: failure()
run: |
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=❌ Maintenance FAILED" \
-F "message=Die automatische Server-Wartung ist fehlgeschlagen!\nBitte Logs prüfen." \
-F "priority=8" || true
+26 -100
View File
@@ -12,56 +12,8 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
prioritize: qa:
name: ⚡ Prioritize Release name: 🧪 Quality Assurance
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: 🛑 Cancel Redundant Runs
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
REF: ${{ github.ref }}
REF_NAME: ${{ github.ref_name }}
EVENT: ${{ github.event_name }}
run: |
echo "🔎 Debug: Event=$EVENT, Ref=$REF, RefName=$REF_NAME, RunId=$RUN_ID"
case "$REF" in
refs/tags/v*)
echo "🚀 Release detected ($REF_NAME). Cancelling non-tag runs..."
# Fetch all runs
RUNS=$(curl -s -H "Authorization: token $GITEA_TOKEN" "https://git.infra.mintel.me/api/v1/repos/$REPO/actions/runs")
# Identify runs to cancel: in_progress/queued, NOT this run, and NOT a tag run
echo "$RUNS" | jq -c '.workflow_runs[] | select(.status == "in_progress" or .status == "queued") | select(.id | tostring != "'$RUN_ID'")' | while read -r run; do
ID=$(echo "$run" | jq -r '.id')
RUN_REF=$(echo "$run" | jq -r '.ref')
TITLE=$(echo "$run" | jq -r '.display_title')
case "$RUN_REF" in
refs/tags/v*)
echo "⏭️ Skipping parallel release run $ID ($TITLE) on $RUN_REF"
;;
*)
echo "🛑 Cancelling redundant branch run $ID ($TITLE) on $RUN_REF..."
curl -X POST -s -H "Authorization: token $GITEA_TOKEN" "https://git.infra.mintel.me/api/v1/repos/$REPO/actions/runs/$ID/cancel"
;;
esac
done
;;
*)
echo "ℹ️ Regular push. No prioritization needed."
;;
esac
lint:
name: 🧹 Lint
needs: prioritize
if: always() && !cancelled() && (needs.prioritize.result == 'success' || needs.prioritize.result == 'skipped')
runs-on: docker runs-on: docker
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
@@ -70,66 +22,36 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node_version: 20 node_version: 20
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10.2.0 --activate
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts --no-color run: pnpm install --frozen-lockfile
- name: 🏷️ Sync Versions (if Tagged)
if: startsWith(github.ref, 'refs/tags/v')
run: pnpm sync-versions
- name: Lint - name: Lint
run: pnpm lint run: pnpm lint
test:
name: 🧪 Test
needs: prioritize
if: always() && !cancelled() && (needs.prioritize.result == 'success' || needs.prioritize.result == 'skipped')
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node_version: 20
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10.2.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts --no-color
- name: Test - name: Test
run: pnpm test run: pnpm test
build:
name: 🏗️ Build
needs: prioritize
if: always() && !cancelled() && (needs.prioritize.result == 'success' || needs.prioritize.result == 'skipped')
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node_version: 20
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10.2.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts --no-color
- name: Build - name: Build
run: pnpm build run: pnpm build
release: release:
name: 🚀 Release name: 🚀 Release
needs: [lint, test, build] needs: qa
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
runs-on: docker runs-on: docker
container: container:
@@ -142,16 +64,20 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node_version: 20 node_version: 20
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10.2.0 --activate
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts --no-color run: pnpm install --frozen-lockfile
- name: 🏷️ Sync Versions (if Tagged)
run: pnpm sync-versions
- name: 🏷️ Release Packages (Tag-Driven) - name: 🏷️ Release Packages (Tag-Driven)
run: | run: |
echo "🏷️ Tag detected [${{ github.ref_name }}], performing sync release..." echo "🏷️ Tag detected [${{ github.ref_name }}], performing sync release..."
@@ -159,7 +85,7 @@ jobs:
build-images: build-images:
name: 🐳 Build ${{ matrix.name }} name: 🐳 Build ${{ matrix.name }}
needs: [lint, test, build] needs: qa
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
runs-on: docker runs-on: docker
container: container:
+6 -27
View File
@@ -1,37 +1,16 @@
# Check if we are pushing a tag # Check if we are pushing a tag
while read local_ref local_sha remote_ref remote_sha while read local_ref local_sha remote_ref remote_sha
do do
if [[ "$remote_ref" == refs/tags/v* ]]; then if [[ "$remote_ref" == refs/tags/v* ]]; then
TAG=${remote_ref#refs/tags/} TAG=${remote_ref#refs/tags/}
echo "🏷️ Tag detected: $TAG, ensuring versions are synced..." echo "🏷️ Tag detected: $TAG, syncing versions..."
# Run sync script
pnpm sync-versions "$TAG" pnpm sync-versions "$TAG"
# Check for changes in relevant files # Stage the changed files
SYNC_FILES="package.json packages/*/package.json apps/*/package.json .env.example" git add package.json packages/*/package.json apps/*/package.json .env .env.example
CHANGES=$(git status --porcelain $SYNC_FILES)
if [[ -n "$CHANGES" ]]; then echo "⚠️ package.json and .env files updated to match tag $TAG."
echo "📝 Version sync made changes. Integrating into tag..." echo "⚠️ Note: You might need to push again if these changes were not already in your commit/tag."
# Stage and commit
git add $SYNC_FILES
git commit -m "chore: sync versions to $TAG" --no-verify
# Force update the local tag to point to the new commit
git tag -f "$TAG" > /dev/null
echo "✅ Tag $TAG has been updated locally with synced versions."
echo "🚀 Auto-pushing updated tag..."
# Push the updated tag directly (using --no-verify to avoid recursion)
git push origin "$TAG" --force --no-verify
echo "✨ All done! Hook integrated the sync and pushed for you."
exit 1 # Still exit 1 to abort the original (now outdated) push attempt
else
echo "✨ Versions already in sync for $TAG."
fi
fi fi
done done
-56
View File
@@ -1,56 +0,0 @@
# Stage 1: Builder
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
WORKDIR /app
# Clean the workspace in case the base image is dirty
RUN rm -rf ./*
# Arguments for build-time configuration
ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_TARGET
ARG DIRECTUS_URL
ARG NPM_TOKEN
# Environment variables for Next.js build
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
ENV DIRECTUS_URL=$DIRECTUS_URL
ENV SKIP_RUNTIME_ENV_VALIDATION=true
ENV CI=true
# Enable pnpm
RUN corepack enable
# Copy lockfile and manifest for dependency installation caching
COPY pnpm-lock.yaml package.json .npmrc* ./
# Install dependencies with cache mount
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
--mount=type=secret,id=NPM_TOKEN \
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN 2>/dev/null || echo $NPM_TOKEN) && \
pnpm install --frozen-lockfile
# Copy source code
COPY . .
# Build application
RUN pnpm build
# Stage 2: Runner
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
WORKDIR /app
ENV HOSTNAME="0.0.0.0"
ENV PORT=3000
ENV NODE_ENV=production
# Copy standalone output and static files
# Adjust paths if using a monorepo structure (e.g., /app/apps/web/public)
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache
USER nextjs
CMD ["node", "server.js"]
-1
View File
@@ -80,4 +80,3 @@ Client websites scaffolded via the CLI use a **tag-based deployment** strategy:
- **Git Tag `v*.*.*`**: Deploys to the `production` environment. - **Git Tag `v*.*.*`**: Deploys to the `production` environment.
See the [`@mintel/infra`](packages/infra/README.md) package for detailed template documentation. See the [`@mintel/infra`](packages/infra/README.md) package for detailed template documentation.
+3 -2
View File
@@ -1,5 +1,6 @@
# Start from the pre-built Nextjs Base image # Start from the pre-built Nextjs Base image
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder ARG IMAGE_TAG=latest
FROM registry.infra.mintel.me/mintel/nextjs:${IMAGE_TAG} AS builder
WORKDIR /app WORKDIR /app
@@ -20,7 +21,7 @@ ENV DIRECTUS_URL=$DIRECTUS_URL
RUN pnpm --filter sample-website build RUN pnpm --filter sample-website build
# Production runner image # Production runner image
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner FROM registry.infra.mintel.me/mintel/runtime:${IMAGE_TAG} AS runner
WORKDIR /app WORKDIR /app
COPY --from=builder /app/apps/sample-website/public ./apps/sample-website/public COPY --from=builder /app/apps/sample-website/public ./apps/sample-website/public
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "sample-website", "name": "sample-website",
"version": "1.8.2", "version": "1.7.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
-1
View File
@@ -1 +0,0 @@
S9WsV
+2 -8
View File
@@ -24,12 +24,6 @@ services:
directus: directus:
image: registry.infra.mintel.me/mintel/directus:${IMAGE_TAG:-latest} 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 restart: always
networks: networks:
- infra - infra
@@ -41,7 +35,7 @@ services:
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL:-admin@mintel.me} ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL:-admin@mintel.me}
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD:-mintel-admin} ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD:-mintel-admin}
DB_CLIENT: 'pg' DB_CLIENT: 'pg'
DB_HOST: 'at-mintel-directus-db' DB_HOST: 'directus-db'
DB_PORT: '5432' DB_PORT: '5432'
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus} DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
DB_USER: ${DIRECTUS_DB_USER:-directus} DB_USER: ${DIRECTUS_DB_USER:-directus}
@@ -59,7 +53,7 @@ services:
- "traefik.http.routers.sample-website-directus.rule=Host(`${DIRECTUS_HOST:-cms.sample-website.localhost}`)" - "traefik.http.routers.sample-website-directus.rule=Host(`${DIRECTUS_HOST:-cms.sample-website.localhost}`)"
- "traefik.http.services.sample-website-directus.loadbalancer.server.port=8055" - "traefik.http.services.sample-website-directus.loadbalancer.server.port=8055"
at-mintel-directus-db: directus-db:
image: postgres:15-alpine image: postgres:15-alpine
restart: always restart: always
networks: networks:
+1 -1
View File
@@ -5,7 +5,7 @@ export default [
{ {
ignores: [ ignores: [
"packages/cms-infra/extensions/**", "packages/cms-infra/extensions/**",
"**/index.js", "packages/customer-manager/index.js",
"**/*.db", "**/*.db",
"**/build/**", "**/build/**",
"**/data/**", "**/data/**",
+1 -4
View File
@@ -15,9 +15,6 @@
"cms:schema:snapshot": "./scripts/cms-snapshot.sh", "cms:schema:snapshot": "./scripts/cms-snapshot.sh",
"cms:schema:apply": "./scripts/cms-apply.sh local", "cms:schema:apply": "./scripts/cms-apply.sh local",
"cms:schema:apply:infra": "./scripts/cms-apply.sh infra", "cms:schema:apply:infra": "./scripts/cms-apply.sh infra",
"cms:up": "./scripts/sync-extensions.sh && cd packages/cms-infra && npm run up -- --force-recreate",
"cms:down": "cd packages/cms-infra && npm run down",
"cms:logs": "cd packages/cms-infra && npm run logs",
"dev:infra": "docker-compose up -d directus directus-db", "dev:infra": "docker-compose up -d directus directus-db",
"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",
@@ -55,7 +52,7 @@
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"require-in-the-middle": "^8.0.1" "require-in-the-middle": "^8.0.1"
}, },
"version": "1.8.2", "version": "1.7.0",
"pnpm": { "pnpm": {
"overrides": { "overrides": {
"next": "16.1.6", "next": "16.1.6",
File diff suppressed because one or more lines are too long
-27
View File
@@ -1,27 +0,0 @@
{
"name": "acquisition-manager",
"description": "Custom High-Fidelity Acquisition Management for Directus",
"icon": "account_balance_wallet",
"version": "1.8.2",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"directus:extension": {
"type": "module",
"path": "index.js",
"source": "src/index.ts",
"host": "*",
"name": "Acquisition Manager"
},
"scripts": {
"build": "directus-extension build && cp -f dist/index.js index.js 2>/dev/null || true",
"dev": "directus-extension build -w"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.0"
}
}
+30 -25
View File
@@ -1,27 +1,32 @@
{ {
"name": "acquisition", "name": "@mintel/acquisition",
"version": "1.8.2", "version": "1.0.0",
"type": "module", "private": true,
"directus:extension": { "type": "module",
"type": "endpoint", "main": "./dist/index.js",
"path": "dist/index.js", "module": "./dist/index.js",
"source": "src/index.ts", "types": "./dist/index.d.ts",
"host": "^11.0.0" "scripts": {
}, "build": "tsup src/index.ts --format esm --dts --clean",
"scripts": { "dev": "tsup src/index.ts --format esm --watch --dts",
"build": "node build.mjs", "lint": "eslint src",
"dev": "node build.mjs --watch" "test": "vitest run",
}, "typecheck": "tsc --noEmit"
"devDependencies": { },
"@directus/extensions-sdk": "11.0.2", "dependencies": {
"@mintel/pdf": "workspace:*", "axios": "^1.7.9",
"@mintel/mail": "workspace:*", "crawlee": "^3.12.2",
"esbuild": "^0.25.0", "cheerio": "^1.0.0",
"typescript": "^5.6.3" "react": "^19.0.0",
}, "@react-pdf/renderer": "^4.3.0",
"dependencies": { "framer-motion": "^12.4.2"
"jquery": "^3.7.1", },
"react": "^19.2.4", "devDependencies": {
"react-dom": "^19.2.4" "@mintel/tsconfig": "workspace:*",
} "@mintel/eslint-config": "workspace:*",
"tsup": "^8.3.5",
"typescript": "^5.0.0",
"vitest": "^3.0.4",
"@types/node": "^20.17.16"
}
} }
@@ -18,8 +18,6 @@ import { calculatePositions } from "../logic/pricing/calculator.js";
interface PDFProps { interface PDFProps {
state: any; state: any;
totalPrice: number; totalPrice: number;
monthlyPrice?: number;
totalPagesCount?: number;
pricing: any; pricing: any;
headerIcon?: string; headerIcon?: string;
footerLogo?: string; footerLogo?: string;
@@ -0,0 +1,401 @@
"use client";
import * as React from "react";
import {
View as PDFView,
Text as PDFText,
StyleSheet,
Image as PDFImage,
} from "@react-pdf/renderer";
// INDUSTRIAL DESIGN SYSTEM TOKENS
export const COLORS = {
CHARCOAL: "#0f172a", // Slate 900
TEXT_MAIN: "#334155", // Slate 700
TEXT_DIM: "#64748b", // Slate 500
TEXT_LIGHT: "#94a3b8", // Slate 400
DIVIDER: "#cbd5e1", // Slate 300
GRID: "#f1f5f9", // Slate 100
BLUEPRINT: "#e2e8f0", // Slate 200
WHITE: "#ffffff",
};
export const FONT_SIZES = {
HERO: 24, // Main Page Titles
HEADING: 14, // Section Headers
BODY: 11, // Standard Content
LABEL: 10, // Bold Labels / Keys
SMALL: 9, // Descriptions / Footnotes
TINY: 8, // Metadata / Unit prices
};
export const pdfStyles = StyleSheet.create({
page: {
paddingTop: 45, // DIN 5008
paddingLeft: 70, // ~25mm
paddingRight: 57, // ~20mm
paddingBottom: 80, // Safe buffer for absolute footer
backgroundColor: COLORS.WHITE,
fontFamily: "Helvetica",
fontSize: FONT_SIZES.BODY,
color: COLORS.CHARCOAL,
},
titlePage: {
width: "100%",
height: "100%",
backgroundColor: COLORS.WHITE,
fontFamily: "Helvetica",
color: COLORS.CHARCOAL,
padding: 0,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 20,
minHeight: 120,
},
addressBlock: {
width: "55%",
marginTop: 45,
},
senderLine: {
fontSize: FONT_SIZES.TINY,
textDecoration: "underline",
color: COLORS.TEXT_DIM,
marginBottom: 8,
},
recipientAddress: {
fontSize: FONT_SIZES.BODY,
lineHeight: 1.4,
},
brandLogoContainer: {
width: "40%",
alignItems: "flex-end",
},
brandIconContainer: {
width: 40,
height: 40,
backgroundColor: "#0f172a",
borderRadius: 8,
alignItems: "center",
justifyContent: "center",
marginBottom: 12,
},
brandIconText: {
color: COLORS.WHITE,
fontSize: 20,
fontWeight: "bold",
},
titleInfo: {
marginBottom: 24,
},
mainTitle: {
fontSize: FONT_SIZES.HEADING,
fontWeight: "bold",
marginBottom: 4,
color: COLORS.CHARCOAL,
letterSpacing: 0.5,
},
subTitle: {
fontSize: FONT_SIZES.BODY,
color: COLORS.TEXT_DIM,
marginTop: 2,
lineHeight: 1.4,
},
section: {
marginBottom: 32,
},
sectionTitle: {
fontSize: FONT_SIZES.LABEL,
fontWeight: "bold",
textTransform: "uppercase",
letterSpacing: 1,
color: COLORS.TEXT_LIGHT,
marginBottom: 8,
},
footer: {
position: "absolute",
bottom: 32,
left: 70,
right: 57,
borderTopWidth: 1,
borderTopColor: COLORS.GRID,
paddingTop: 16,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
},
footerColumn: {
flex: 1,
alignItems: "flex-start",
},
footerLogo: {
height: 20,
width: "auto",
objectFit: "contain",
marginBottom: 8,
},
footerText: {
fontSize: FONT_SIZES.TINY,
color: COLORS.TEXT_LIGHT,
lineHeight: 1.4,
},
asymmetryContainer: {
flexDirection: "row",
gap: 32,
},
asymmetryLeft: {
width: "32%",
},
asymmetryRight: {
width: "63%",
},
specRow: {
flexDirection: "row",
justifyContent: "space-between",
paddingVertical: 6,
borderBottomWidth: 1,
borderBottomColor: COLORS.GRID,
},
specLabel: {
fontSize: FONT_SIZES.TINY,
fontWeight: "bold",
color: COLORS.TEXT_LIGHT,
textTransform: "uppercase",
letterSpacing: 0.5,
},
specValue: {
fontSize: FONT_SIZES.SMALL,
color: COLORS.CHARCOAL,
fontWeight: "bold",
},
blueprintBox: {
borderWidth: 1,
borderColor: COLORS.GRID,
padding: 16,
backgroundColor: "#fafafa",
},
footerLabel: {
fontWeight: "bold",
color: COLORS.TEXT_DIM,
},
pageNumber: {
fontSize: FONT_SIZES.TINY,
color: COLORS.DIVIDER,
fontWeight: "bold",
marginTop: 8,
textAlign: "right",
},
foldingMark: {
position: "absolute",
left: 20,
width: 10,
borderTopWidth: 0.5,
borderTopColor: COLORS.DIVIDER,
},
divider: {
width: "100%",
height: 1,
backgroundColor: COLORS.DIVIDER,
marginVertical: 12,
},
industrialListItem: {
flexDirection: "row",
alignItems: "flex-start",
marginBottom: 6,
},
industrialBulletBox: {
width: 6,
height: 6,
backgroundColor: COLORS.DIVIDER,
marginRight: 8,
marginTop: 5,
},
industrialTitle: {
fontSize: FONT_SIZES.HERO,
fontWeight: "bold",
color: COLORS.CHARCOAL,
marginBottom: 6,
letterSpacing: 0,
},
});
export const IndustrialListItem = ({
children,
}: {
children: React.ReactNode;
}) => (
<PDFView style={pdfStyles.industrialListItem}>
<PDFView style={pdfStyles.industrialBulletBox} />
{children}
</PDFView>
);
export const Divider = ({ style = {} }: { style?: any }) => (
<PDFView style={[pdfStyles.divider, style]} />
);
export const Footer = ({
logo,
companyData,
showDetails = true,
showPageNumber = true,
}: {
logo?: string;
companyData: any;
showDetails?: boolean;
showPageNumber?: boolean;
}) => (
<PDFView style={pdfStyles.footer}>
<PDFView style={pdfStyles.footerColumn}>
{logo ? (
<PDFImage src={logo} style={pdfStyles.footerLogo} />
) : (
<PDFText style={{ fontSize: 12, fontWeight: "bold", marginBottom: 8 }}>
marc mintel
</PDFText>
)}
</PDFView>
{showDetails && (
<>
<PDFView style={pdfStyles.footerColumn}>
<PDFText style={pdfStyles.footerText}>
<PDFText style={pdfStyles.footerLabel}>{companyData.name}</PDFText>
{"\n"}
{companyData.address1}
{"\n"}
{companyData.address2}
{"\n"}UST: {companyData.ustId}
</PDFText>
</PDFView>
<PDFView style={[pdfStyles.footerColumn, { alignItems: "flex-end" }]}>
{showPageNumber && (
<PDFText
style={pdfStyles.pageNumber}
render={({ pageNumber, totalPages }) =>
`${pageNumber} / ${totalPages}`
}
fixed
/>
)}
</PDFView>
</>
)}
</PDFView>
);
export const Header = ({
sender,
recipient,
icon,
showAddress = true,
}: {
sender?: string;
recipient?: {
title: string;
subtitle?: string;
email?: string;
address?: string;
phone?: string;
taxId?: string;
};
icon?: string;
showAddress?: boolean;
}) => (
<PDFView
style={[
pdfStyles.header,
showAddress ? {} : { minHeight: 40, marginBottom: 0 },
]}
>
<PDFView style={pdfStyles.addressBlock}>
{showAddress && sender && (
<>
<PDFText style={pdfStyles.senderLine}>{sender}</PDFText>
{recipient && (
<PDFView style={pdfStyles.recipientAddress}>
<PDFText style={{ fontWeight: "bold" }}>
{recipient.title}
</PDFText>
{recipient.subtitle && <PDFText>{recipient.subtitle}</PDFText>}
{recipient.address && <PDFText>{recipient.address}</PDFText>}
{recipient.phone && <PDFText>{recipient.phone}</PDFText>}
{recipient.email && <PDFText>{recipient.email}</PDFText>}
{recipient.taxId && <PDFText>USt-ID: {recipient.taxId}</PDFText>}
</PDFView>
)}
</>
)}
</PDFView>
<PDFView style={pdfStyles.brandLogoContainer}>
<PDFView style={pdfStyles.brandIconContainer}>
{icon ? (
<PDFImage src={icon} style={{ width: 24, height: 24 }} />
) : (
<PDFText style={pdfStyles.brandIconText}>M</PDFText>
)}
</PDFView>
</PDFView>
</PDFView>
);
export const DocumentTitle = ({
title,
subLines,
isHero = false,
}: {
title: string;
subLines?: string[];
isHero?: boolean;
}) => (
<PDFView style={pdfStyles.titleInfo}>
<PDFText
style={[
pdfStyles.mainTitle,
{ fontSize: isHero ? FONT_SIZES.HERO : FONT_SIZES.HEADING },
]}
>
{title}
</PDFText>
{subLines?.map((line, i) => (
<PDFText
key={i}
style={[
pdfStyles.subTitle,
i === 1 ? { fontWeight: "bold", color: COLORS.CHARCOAL } : {},
]}
>
{line}
</PDFText>
))}
</PDFView>
);
export const TechnicalSpec = ({
label,
value,
}: {
label: string;
value: string;
}) => (
<PDFView style={pdfStyles.specRow}>
<PDFText style={pdfStyles.specLabel}>{label}</PDFText>
<PDFText style={pdfStyles.specValue}>{value}</PDFText>
</PDFView>
);
export const AsymmetryView = ({
left,
right,
style = {},
}: {
left: React.ReactNode;
right: React.ReactNode;
style?: any;
}) => (
<PDFView style={[pdfStyles.asymmetryContainer, style]}>
<PDFView style={pdfStyles.asymmetryLeft}>{left}</PDFView>
<PDFView style={pdfStyles.asymmetryRight}>{right}</PDFView>
</PDFView>
);
@@ -33,7 +33,6 @@ interface SimpleLayoutProps {
icon?: string; icon?: string;
footerLogo?: string; footerLogo?: string;
companyData: any; companyData: any;
bankData?: any;
showPageNumber?: boolean; showPageNumber?: boolean;
} }
@@ -43,7 +42,6 @@ export const SimpleLayout = ({
icon, icon,
footerLogo, footerLogo,
companyData, companyData,
bankData,
showPageNumber = true showPageNumber = true
}: SimpleLayoutProps) => { }: SimpleLayoutProps) => {
return ( return (
@@ -58,7 +56,6 @@ export const SimpleLayout = ({
<Footer <Footer
logo={footerLogo} logo={footerLogo}
companyData={companyData} companyData={companyData}
bankData={bankData}
showDetails={false} showDetails={false}
showPageNumber={showPageNumber} showPageNumber={showPageNumber}
/> />
@@ -0,0 +1,69 @@
"use client";
import * as React from "react";
import {
View as PDFView,
Text as PDFText,
StyleSheet,
} from "@react-pdf/renderer";
import { DocumentTitle, COLORS, FONT_SIZES } from "../SharedUI.js";
const styles = StyleSheet.create({
section: { marginBottom: 24 },
sectionTitle: {
fontSize: FONT_SIZES.LABEL,
fontWeight: "bold",
marginBottom: 8,
color: COLORS.CHARCOAL,
},
visionText: {
fontSize: FONT_SIZES.BODY,
color: COLORS.TEXT_MAIN,
lineHeight: 1.4,
textAlign: "justify",
},
});
export const BriefingModule = ({ state }: any) => (
<>
<DocumentTitle title="Projektdetails" isHero={true} />
{state.briefingSummary && (
<PDFView style={styles.section}>
<PDFText style={styles.sectionTitle}>Briefing Analyse</PDFText>
<PDFText
style={{
fontSize: FONT_SIZES.BODY,
color: COLORS.TEXT_MAIN,
lineHeight: 1.6,
textAlign: "justify",
}}
>
{state.briefingSummary}
</PDFText>
</PDFView>
)}
{state.designVision && (
<PDFView
style={[
styles.section,
{
padding: 12,
borderLeftWidth: 2,
borderLeftColor: COLORS.DIVIDER,
backgroundColor: COLORS.GRID,
},
]}
>
<PDFText
style={[
styles.sectionTitle,
{ color: COLORS.CHARCOAL, marginBottom: 4 },
]}
>
Strategische Vision
</PDFText>
<PDFText style={styles.visionText}>{state.designVision}</PDFText>
</PDFView>
)}
</>
);
@@ -0,0 +1,56 @@
"use client";
import * as React from "react";
import { View as PDFView, Text as PDFText, StyleSheet } from "@react-pdf/renderer";
import { DocumentTitle, COLORS, FONT_SIZES, IndustrialListItem } from "../SharedUI.js";
const styles = StyleSheet.create({
section: { marginBottom: 24 },
categoryBox: {
marginBottom: 20,
padding: 12,
backgroundColor: COLORS.GRID,
borderLeftWidth: 2,
borderLeftColor: COLORS.DIVIDER,
},
categoryTitle: {
fontSize: FONT_SIZES.TINY,
fontWeight: "bold",
color: COLORS.TEXT_LIGHT,
textTransform: "uppercase",
marginBottom: 10,
letterSpacing: 1,
},
pageTitle: {
fontSize: FONT_SIZES.LABEL,
fontWeight: "bold",
color: COLORS.CHARCOAL,
marginBottom: 2,
},
pageDesc: {
fontSize: FONT_SIZES.TINY,
color: COLORS.TEXT_DIM,
lineHeight: 1.4,
},
});
export const SitemapModule = ({ state }: any) => (
<>
<DocumentTitle title="Informations-Architektur" isHero={true} />
<PDFView style={styles.section}>
{state.sitemap?.map((cat: any, i: number) => (
<PDFView key={i} style={styles.categoryBox}>
<PDFText style={styles.categoryTitle}>{cat.category}</PDFText>
{cat.pages?.map((p: any, j: number) => (
<IndustrialListItem key={j}>
<PDFView style={{ marginBottom: 8 }}>
<PDFText style={styles.pageTitle}>{p.title}</PDFText>
<PDFText style={styles.pageDesc}>{p.desc}</PDFText>
</PDFView>
</IndustrialListItem>
))}
</PDFView>
))}
</PDFView>
</>
);
+6 -176
View File
@@ -1,176 +1,6 @@
import { defineEndpoint } from "@directus/extensions-sdk"; export * from "./logic/pricing/types.js";
import { AcquisitionService, PdfEngine } from "@mintel/pdf/server"; export * from "./logic/pricing/constants.js";
import { render, SiteAuditTemplate, ProjectEstimateTemplate } from "@mintel/mail"; export * from "./logic/pricing/calculator.js";
import { createElement } from "react"; export * from "./services/AcquisitionService.js";
import * as path from "path"; export * from "./services/PdfEngine.js";
import * as fs from "fs"; export * from "./components/EstimationPDF.js";
export default defineEndpoint((router, { services, env }) => {
const { ItemsService, MailService } = services;
router.get("/ping", (req, res) => res.send("pong"));
router.post("/audit/:id", async (req: any, res: any) => {
const { id } = req.params;
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
try {
const lead = await leadsService.readOne(id);
if (!lead) return res.status(404).send({ error: "Lead not found" });
await leadsService.updateOne(id, { status: "auditing" });
const acqService = new AcquisitionService(env.OPENROUTER_API_KEY);
const result = await acqService.runFullSequence(lead.website_url, lead.briefing, lead.comments);
await leadsService.updateOne(id, {
status: "audit_ready",
ai_state: result.state,
audit_context: JSON.stringify(result.usage),
});
res.send({ success: true, result });
} catch (error: any) {
console.error("Audit failed:", error);
await leadsService.updateOne(id, { status: "new", comments: `Audit failed: ${error.message}` });
res.status(500).send({ error: error.message });
}
});
router.post("/audit-email/:id", async (req: any, res: any) => {
const { id } = req.params;
const { ItemsService, MailService } = services;
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability });
const companiesService = new ItemsService("companies", { schema: req.schema, accountability: req.accountability });
const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
try {
const lead = await leadsService.readOne(id, { fields: ["*", "company.*", "contact_person.*"] });
if (!lead || !lead.ai_state) return res.status(400).send({ error: "Lead or Audit not ready" });
let recipientEmail = lead.contact_email;
let companyName = lead.company?.name || lead.company_name;
if (lead.contact_person) {
recipientEmail = lead.contact_person.email || recipientEmail;
if (lead.contact_person.company) {
const personCompany = await companiesService.readOne(lead.contact_person.company);
companyName = personCompany?.name || companyName;
}
}
if (!recipientEmail) return res.status(400).send({ error: "No recipient email found" });
const auditHighlights = [
`Projekt-Typ: ${lead.ai_state.projectType === "website" ? "Website" : "Web App"}`,
...(lead.ai_state.sitemap || []).slice(0, 3).map((item: any) => `Potenzial in: ${item.category}`),
];
const html = await render(createElement(SiteAuditTemplate, {
companyName: companyName,
websiteUrl: lead.website_url,
auditHighlights
}));
await mailService.send({
to: recipientEmail,
subject: `Analyse Ihrer Webpräsenz: ${companyName}`,
html
});
await leadsService.updateOne(id, {
status: "contacted",
last_contacted_at: new Date().toISOString(),
});
res.send({ success: true });
} catch (error: any) {
console.error("Audit Email failed:", error);
res.status(500).send({ error: error.message });
}
});
router.post("/estimate/:id", async (req: any, res: any) => {
const { id } = req.params;
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
try {
const lead = await leadsService.readOne(id);
if (!lead || !lead.ai_state) return res.status(400).send({ error: "Lead or AI state not found" });
const pdfEngine = new PdfEngine();
const filename = `estimate_${id}_${Date.now()}.pdf`;
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
const outputPath = path.join(storageRoot, filename);
await pdfEngine.generateEstimatePdf(lead.ai_state, outputPath);
await leadsService.updateOne(id, {
audit_pdf_path: filename,
});
res.send({ success: true, filename });
} catch (error: any) {
console.error("PDF Generation failed:", error);
res.status(500).send({ error: error.message });
}
});
router.post("/estimate-email/:id", async (req: any, res: any) => {
const { id } = req.params;
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability });
const companiesService = new ItemsService("companies", { schema: req.schema, accountability: req.accountability });
const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
try {
const lead = await leadsService.readOne(id, { fields: ["*", "company.*", "contact_person.*"] });
if (!lead || !lead.audit_pdf_path) return res.status(400).send({ error: "PDF not generated" });
let recipientEmail = lead.contact_email;
let companyName = lead.company?.name || lead.company_name;
if (lead.contact_person) {
recipientEmail = lead.contact_person.email || recipientEmail;
if (lead.contact_person.company) {
const personCompany = await companiesService.readOne(lead.contact_person.company);
companyName = personCompany?.name || companyName;
}
}
if (!recipientEmail) return res.status(400).send({ error: "No recipient email found" });
const html = await render(createElement(ProjectEstimateTemplate, {
companyName: companyName,
}));
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
const attachmentPath = path.join(storageRoot, lead.audit_pdf_path);
await mailService.send({
to: recipientEmail,
subject: `Ihre Projekt-Schätzung: ${companyName}`,
html,
attachments: [
{
filename: `Angebot_${companyName}.pdf`,
content: fs.readFileSync(attachmentPath)
}
]
});
await leadsService.updateOne(id, {
status: "contacted",
last_contacted_at: new Date().toISOString(),
});
res.send({ success: true });
} catch (error: any) {
console.error("Estimate Email failed:", error);
res.status(500).send({ error: error.message });
}
});
});
@@ -1,4 +1,4 @@
import { CheerioCrawler } from "@crawlee/cheerio"; import { CheerioCrawler } from "crawlee";
import axios from "axios"; import axios from "axios";
import { FileCacheAdapter } from "../utils/cache/FileCacheAdapter.js"; import { FileCacheAdapter } from "../utils/cache/FileCacheAdapter.js";
import { initialState } from "../logic/pricing/constants.js"; import { initialState } from "../logic/pricing/constants.js";
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect } from "vitest";
import { calculateTotals, calculatePositions } from "../src/logic/pricing/calculator.js";
import { PRICING, initialState } from "../src/logic/pricing/constants.js";
import { FormState } from "../src/logic/pricing/types.js";
describe("Pricing Logic", () => {
it("should calculate base website price correctly", () => {
const state: FormState = {
...initialState,
projectType: "website",
selectedPages: [] // Clear for base test
};
const totals = calculateTotals(state, PRICING);
expect(totals.totalPrice).toBe(PRICING.BASE_WEBSITE);
});
it("should add page costs correctly", () => {
const state: FormState = {
...initialState,
projectType: "website",
selectedPages: [], // Clear for clean test
otherPagesCount: 5
};
const totals = calculateTotals(state, PRICING);
expect(totals.totalPrice).toBe(PRICING.BASE_WEBSITE + (5 * PRICING.PAGE));
});
it("should apply multi-language multiplier", () => {
const state: FormState = {
...initialState,
projectType: "website",
selectedPages: [], // Clear for clean test
languagesList: ["Deutsch", "Englisch"]
};
const totals = calculateTotals(state, PRICING);
expect(totals.totalPrice).toBe(Math.round(PRICING.BASE_WEBSITE * 1.2));
});
it("should generate correct positions for a website", () => {
const state: FormState = {
...initialState,
projectType: "website",
selectedPages: ["Home"],
otherPagesCount: 2
};
const positions = calculatePositions(state, PRICING);
// Find "Fundament" position (Das technische Fundament)
const basePos = positions.find(p => p.title.includes("Fundament"));
expect(basePos).toBeDefined();
expect(basePos?.price).toBe(PRICING.BASE_WEBSITE);
// Find "Individuelle Seiten" position
const pagesPos = positions.find(p => p.title.includes("Seiten"));
expect(pagesPos).toBeDefined();
expect(pagesPos?.qty).toBe(3); // 1 selected + 2 other
expect(pagesPos?.price).toBe(3 * PRICING.PAGE);
});
});
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "@mintel/tsconfig/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx"
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist"
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@mintel/cli", "name": "@mintel/cli",
"version": "1.8.2", "version": "1.7.0",
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://npm.infra.mintel.me" "registry": "https://npm.infra.mintel.me"
-41
View File
@@ -1,41 +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 entryPoints = [
resolve(__dirname, 'src/index.ts')
];
try {
mkdirSync(resolve(__dirname, 'dist'), { recursive: true });
} catch (e) { }
console.log(`Building entry point...`);
build({
entryPoints: entryPoints,
bundle: true,
platform: 'node',
target: 'node18',
outdir: resolve(__dirname, 'dist'),
format: 'esm',
loader: {
'.ts': 'ts',
'.js': 'js',
},
external: ["playwright", "crawlee", "axios", "cheerio", "fs", "path", "os", "http", "https", "url", "stream", "util", "child_process"],
}).then(() => {
console.log("Build succeeded!");
}).catch((e) => {
if (e.errors) {
console.error("Build failed with errors:");
e.errors.forEach(err => console.error(` ${err.text} at ${err.location?.file}:${err.location?.line}`));
} else {
console.error("Build failed:", e);
}
process.exit(1);
});
-30
View File
@@ -1,30 +0,0 @@
{
"name": "@mintel/cloner",
"version": "1.8.2",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "node build.mjs",
"dev": "node build.mjs --watch"
},
"devDependencies": {
"esbuild": "^0.25.0",
"typescript": "^5.6.3",
"@types/node": "^22.0.0"
},
"dependencies": {
"playwright": "^1.40.0",
"crawlee": "^3.7.0",
"axios": "^1.6.0",
"cheerio": "^1.0.0-rc.12"
}
}
@@ -1,93 +0,0 @@
import axios from "axios";
import fs from "node:fs";
import path from "node:path";
export interface AssetMap {
[originalUrl: string]: string;
}
export class AssetManager {
private userAgent: string;
constructor(userAgent: string = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36") {
this.userAgent = userAgent;
}
public sanitizePath(rawPath: string): string {
return rawPath
.split("/")
.map((p) => p.replace(/[^a-z0-9._-]/gi, "_"))
.join("/");
}
public async downloadFile(url: string, assetsDir: string): Promise<string | null> {
if (url.startsWith("//")) url = `https:${url}`;
if (!url.startsWith("http")) return null;
try {
const u = new URL(url);
const relPath = this.sanitizePath(u.hostname + u.pathname);
const dest = path.join(assetsDir, relPath);
if (fs.existsSync(dest)) return `./assets/${relPath}`;
const res = await axios.get(url, {
responseType: "arraybuffer",
headers: { "User-Agent": this.userAgent },
timeout: 15000,
validateStatus: () => true,
});
if (res.status !== 200) return null;
if (!fs.existsSync(path.dirname(dest)))
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, Buffer.from(res.data));
return `./assets/${relPath}`;
} catch {
return null;
}
}
public async processCssRecursively(
cssContent: string,
cssUrl: string,
assetsDir: string,
urlMap: AssetMap,
depth = 0,
): Promise<string> {
if (depth > 5) return cssContent;
const urlRegex = /(?:url\(["']?|@import\s+["'])([^"'\)]+)["']?\)?/gi;
let match;
let newContent = cssContent;
while ((match = urlRegex.exec(cssContent)) !== null) {
const originalUrl = match[1];
if (originalUrl.startsWith("data:") || originalUrl.startsWith("blob:"))
continue;
try {
const absUrl = new URL(originalUrl, cssUrl).href;
const local = await this.downloadFile(absUrl, assetsDir);
if (local) {
const u = new URL(cssUrl);
const cssPath = u.hostname + u.pathname;
const assetPath = new URL(absUrl).hostname + new URL(absUrl).pathname;
const rel = path.relative(
path.dirname(this.sanitizePath(cssPath)),
this.sanitizePath(assetPath),
);
newContent = newContent.split(originalUrl).join(rel);
urlMap[absUrl] = local;
}
} catch {
// Ignore
}
}
return newContent;
}
}
-184
View File
@@ -1,184 +0,0 @@
import { chromium, Browser, BrowserContext, Page } from "playwright";
import fs from "node:fs";
import path from "node:path";
import axios from "axios";
import { AssetManager, AssetMap } from "./AssetManager.js";
export interface PageClonerOptions {
outputDir: string;
userAgent?: string;
}
export class PageCloner {
private options: PageClonerOptions;
private assetManager: AssetManager;
private userAgent: string;
constructor(options: PageClonerOptions) {
this.options = options;
this.userAgent = options.userAgent || "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36";
this.assetManager = new AssetManager(this.userAgent);
}
public async clone(targetUrl: string): Promise<string> {
const urlObj = new URL(targetUrl);
const domainSlug = urlObj.hostname.replace("www.", "");
const domainDir = path.resolve(this.options.outputDir, domainSlug);
const assetsDir = path.join(domainDir, "assets");
if (!fs.existsSync(assetsDir)) fs.mkdirSync(assetsDir, { recursive: true });
let pageSlug = urlObj.pathname.split("/").filter(Boolean).join("-");
if (!pageSlug) pageSlug = "index";
const htmlFilename = `${pageSlug}.html`;
console.log(`🚀 INDUSTRIAL CLONE: ${targetUrl}`);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
userAgent: this.userAgent,
viewport: { width: 1920, height: 1080 },
});
const page = await context.newPage();
const urlMap: AssetMap = {};
const foundAssets = new Set<string>();
page.on("response", (response) => {
if (response.status() === 200) {
const url = response.url();
if (url.match(/\.(css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|mp4|webm|webp|ico)/i)) {
foundAssets.add(url);
}
}
});
try {
await page.goto(targetUrl, { waitUntil: "networkidle", timeout: 90000 });
// Scroll Wave
await page.evaluate(async () => {
await new Promise((resolve) => {
let totalHeight = 0;
const distance = 400;
const timer = setInterval(() => {
const scrollHeight = document.body.scrollHeight;
window.scrollBy(0, distance);
totalHeight += distance;
if (totalHeight >= scrollHeight) {
clearInterval(timer);
window.scrollTo(0, 0);
resolve(true);
}
}, 100);
});
});
const fullHeight = await page.evaluate(() => document.body.scrollHeight);
await page.setViewportSize({ width: 1920, height: fullHeight + 1000 });
await page.waitForTimeout(3000);
// Sanitization
await page.evaluate(() => {
const assetPattern = /\.(jpg|jpeg|png|gif|svg|webp|mp4|webm|woff2?|ttf|otf)/i;
document.querySelectorAll("*").forEach((el) => {
if (["META", "LINK", "HEAD", "SCRIPT", "STYLE", "SVG", "PATH"].includes(el.tagName)) return;
const htmlEl = el as HTMLElement;
const style = window.getComputedStyle(htmlEl);
if (style.opacity === "0" || style.visibility === "hidden") {
htmlEl.style.setProperty("opacity", "1", "important");
htmlEl.style.setProperty("visibility", "visible", "important");
}
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
const val = attr.value;
if (assetPattern.test(val) || name.includes("src") || name.includes("image")) {
if (el.tagName === "IMG") {
const img = el as HTMLImageElement;
if (name.includes("srcset")) img.srcset = val;
else if (!img.src || img.src.includes("data:")) img.src = val;
}
if (el.tagName === "SOURCE") (el as HTMLSourceElement).srcset = val;
if (el.tagName === "VIDEO" || el.tagName === "AUDIO") (el as HTMLMediaElement).src = val;
if (val.match(/^(https?:\/\/|\/\/|\/)/) && !name.includes("href")) {
const bg = htmlEl.style.backgroundImage;
if (!bg || bg === "none") htmlEl.style.backgroundImage = `url('${val}')`;
}
}
}
});
if (document.body) {
document.body.style.setProperty("opacity", "1", "important");
document.body.style.setProperty("visibility", "visible", "important");
}
});
await page.waitForLoadState("networkidle");
await page.waitForTimeout(1000);
let content = await page.content();
const regexPatterns = [
/(?:src|href|url|data-[a-z-]+|srcset)=["']([^"'<>\s]+?\.(?:css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|mp4|webm|webp|ico)(?:\?[^"']*)?)["']/gi,
/url\(["']?([^"'\)]+)["']?\)/gi,
];
for (const pattern of regexPatterns) {
let match;
while ((match = pattern.exec(content)) !== null) {
try { foundAssets.add(new URL(match[1], targetUrl).href); } catch { }
}
}
for (const url of foundAssets) {
const local = await this.assetManager.downloadFile(url, assetsDir);
if (local) {
urlMap[url] = local;
const clean = url.split("?")[0];
urlMap[clean] = local;
if (clean.endsWith(".css")) {
try {
const { data } = await axios.get(url, { headers: { "User-Agent": this.userAgent } });
const processedCss = await this.assetManager.processCssRecursively(data, url, assetsDir, urlMap);
const relPath = this.assetManager.sanitizePath(new URL(url).hostname + new URL(url).pathname);
fs.writeFileSync(path.join(assetsDir, relPath), processedCss);
} catch { }
}
}
}
let finalContent = content;
const sortedUrls = Object.keys(urlMap).sort((a, b) => b.length - a.length);
if (sortedUrls.length > 0) {
const escaped = sortedUrls.map((u) => u.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const masterRegex = new RegExp(`(${escaped.join("|")})`, "g");
finalContent = finalContent.replace(masterRegex, (match) => urlMap[match] || match);
}
const commonDirs = ["/wp-content/", "/wp-includes/", "/assets/", "/static/", "/images/"];
for (const dir of commonDirs) {
const localDir = `./assets/${urlObj.hostname}${dir}`;
finalContent = finalContent.split(`"${dir}`).join(`"${localDir}`).split(`'${dir}`).join(`'${localDir}`).split(`(${dir}`).join(`(${localDir}`);
}
const domainPattern = new RegExp(`https?://(www\\.)?${urlObj.hostname.replace(/\./g, "\\.")}[^"']*`, "gi");
finalContent = finalContent.replace(domainPattern, () => "./");
finalContent = finalContent.replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, (match, scriptContent) => {
const lower = scriptContent.toLowerCase();
return (lower.includes("google-analytics") || lower.includes("gtag") || lower.includes("fbq") || lower.includes("lazy") || lower.includes("tracker")) ? "" : match;
});
const headEnd = finalContent.indexOf("</head>");
if (headEnd > -1) {
const stabilityCss = `\n<style>* { transition: none !important; animation: none !important; scroll-behavior: auto !important; } [data-aos], .reveal, .lazypath, .lazy-load, [data-src] { opacity: 1 !important; visibility: visible !important; transform: none !important; clip-path: none !important; } img, video, iframe { max-width: 100%; display: block; } a { pointer-events: none; cursor: default; } </style>`;
finalContent = finalContent.slice(0, headEnd) + stabilityCss + finalContent.slice(headEnd);
}
const finalPath = path.join(domainDir, htmlFilename);
fs.writeFileSync(finalPath, finalContent);
return finalPath;
} finally {
await browser.close();
}
}
}
@@ -1,123 +0,0 @@
import { PlaywrightCrawler, RequestQueue } from 'crawlee';
import * as path from 'node:path';
import * as fs from 'node:fs';
import { execSync } from 'node:child_process';
export interface WebsiteClonerOptions {
baseOutputDir: string;
maxRequestsPerCrawl?: number;
maxConcurrency?: number;
}
export class WebsiteCloner {
private options: WebsiteClonerOptions;
constructor(options: WebsiteClonerOptions) {
this.options = {
maxRequestsPerCrawl: 100,
maxConcurrency: 3,
...options
};
}
public async clone(targetUrl: string, outputDirName?: string): Promise<string> {
const urlObj = new URL(targetUrl);
const domain = urlObj.hostname;
const finalOutputDirName = outputDirName || domain.replace(/\./g, '-');
const baseOutputDir = path.resolve(this.options.baseOutputDir, finalOutputDirName);
if (fs.existsSync(baseOutputDir)) {
fs.rmSync(baseOutputDir, { recursive: true, force: true });
}
fs.mkdirSync(baseOutputDir, { recursive: true });
console.log(`🚀 Starting perfect recursive clone of ${targetUrl}...`);
console.log(`📂 Output: ${baseOutputDir}`);
const requestQueue = await RequestQueue.open();
await requestQueue.addRequest({ url: targetUrl });
const crawler = new PlaywrightCrawler({
requestQueue,
maxRequestsPerCrawl: this.options.maxRequestsPerCrawl,
maxConcurrency: this.options.maxConcurrency,
async requestHandler({ request, enqueueLinks, log }) {
const url = request.url;
log.info(`Capturing ${url}...`);
const u = new URL(url);
let relPath = u.pathname;
if (relPath === '/' || relPath === '') relPath = '/index.html';
if (!relPath.endsWith('.html') && !path.extname(relPath)) relPath += '/index.html';
if (relPath.startsWith('/')) relPath = relPath.substring(1);
const fullPath = path.join(baseOutputDir, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
try {
// Note: This assumes single-file-cli is available in the environment
execSync(`npx single-file-cli "${url}" "${fullPath}" --browser-headless=true --browser-wait-until=networkidle0`, {
stdio: 'inherit'
});
} catch (e) {
log.error(`Failed to capture ${url} with SingleFile`);
}
await enqueueLinks({
strategy: 'same-domain',
transformRequestFunction: (req) => {
if (/\.(download|pdf|zip|gz|exe|png|jpg|jpeg|gif|svg|css|js)$/i.test(req.url)) return false;
return req;
}
});
},
});
await crawler.run();
console.log('🔗 Rewriting internal links for offline navigation...');
const allFiles = this.getFiles(baseOutputDir).filter(f => f.endsWith('.html'));
for (const file of allFiles) {
let content = fs.readFileSync(file, 'utf8');
const fileRelToRoot = path.relative(baseOutputDir, file);
content = content.replace(/href="([^"]+)"/g, (match, href) => {
if (href.startsWith(targetUrl) || href.startsWith('/') || (!href.includes('://') && !href.startsWith('data:'))) {
try {
const linkUrl = new URL(href, targetUrl);
if (linkUrl.hostname === domain) {
let linkPath = linkUrl.pathname;
if (linkPath === '/' || linkPath === '') linkPath = '/index.html';
if (!linkPath.endsWith('.html') && !path.extname(linkPath)) linkPath += '/index.html';
if (linkPath.startsWith('/')) linkPath = linkPath.substring(1);
const relativeLink = path.relative(path.dirname(fileRelToRoot), linkPath);
return `href="${relativeLink}"`;
}
} catch (e) { }
}
return match;
});
fs.writeFileSync(file, content);
}
console.log(`\n✅ Done! Perfect clone complete in: ${baseOutputDir}`);
return baseOutputDir;
}
private getFiles(dir: string, fileList: string[] = []) {
const files = fs.readdirSync(dir);
for (const file of files) {
const name = path.join(dir, file);
if (fs.statSync(name).isDirectory()) {
this.getFiles(name, fileList);
} else {
fileList.push(name);
}
}
return fileList;
}
}
-3
View File
@@ -1,3 +0,0 @@
export * from "./AssetManager.js";
export * from "./PageCloner.js";
export * from "./WebsiteCloner.js";
-17
View File
@@ -1,17 +0,0 @@
{
"extends": "../tsconfig/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"emitDeclarationOnly": true,
"module": "ESNext",
"target": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": false
},
"include": [
"src/**/*"
]
}
+42
View File
@@ -0,0 +1,42 @@
# Build Stage
FROM node:20-slim AS builder
WORKDIR /app
# Core environment for pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Copy root configurations
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
# Copy all packages for extensions build
COPY packages ./packages
# Install dependencies (only what's needed for extensions)
RUN pnpm install --no-frozen-lockfile \
--filter "@mintel/directus-extension-*" \
--filter "acquisition" \
--filter "acquisition-manager" \
--filter "customer-manager" \
--filter "feedback-commander" \
--filter "people-manager" \
--filter "./packages/acquisition" \
--filter "./packages/mail"
# Runtime Stage
FROM directus/directus:11
WORKDIR /directus
# Copy built extensions
COPY --from=builder /app/packages/cms-infra/extensions ./extensions
# Environment defaults (can be overridden)
ENV KEY="infra-cms-key"
ENV SECRET="infra-cms-secret"
ENV DB_CLIENT="sqlite3"
ENV DB_FILENAME="/directus/database/data.db"
# Expose port
EXPOSE 8055
BIN
View File
Binary file not shown.
+5 -1
View File
@@ -1,6 +1,9 @@
services: services:
infra-cms: infra-cms:
image: directus/directus:11 build:
context: ../../
dockerfile: packages/cms-infra/Dockerfile
image: mintel/cms-infra:latest
ports: ports:
- "8059:8055" - "8059:8055"
networks: networks:
@@ -21,6 +24,7 @@ services:
EMAIL_SMTP_PASSWORD: "4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6" EMAIL_SMTP_PASSWORD: "4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6"
EMAIL_SMTP_SECURE: "false" EMAIL_SMTP_SECURE: "false"
EMAIL_FROM: "postmaster@mg.mintel.me" EMAIL_FROM: "postmaster@mg.mintel.me"
LOG_LEVEL: "trace"
volumes: volumes:
- ./database:/directus/database - ./database:/directus/database
- ./uploads:/directus/uploads - ./uploads:/directus/uploads
@@ -1,30 +1,20 @@
{ {
"name": "acquisition-manager", "name": "acquisition-manager",
"description": "Custom High-Fidelity Acquisition Management for Directus", "version": "1.0.0",
"icon": "account_balance_wallet",
"version": "1.7.12",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"files": [
"dist"
],
"directus:extension": {
"type": "module", "type": "module",
"path": "index.js", "directus:extension": {
"source": "src/index.ts", "type": "module",
"host": "*", "path": "dist/index.js",
"name": "Acquisition Manager" "source": "src/index.ts",
}, "host": "^11.0.0"
"scripts": { },
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)", "scripts": {
"dev": "directus-extension build -w" "build": "directus-extension build",
}, "dev": "directus-extension build -w"
"devDependencies": { },
"@directus/extensions-sdk": "11.0.2", "devDependencies": {
"vue": "^3.4.0" "@directus/extensions-sdk": "11.0.2",
} "vue": "^3.4.34",
"typescript": "^5.6.3"
}
} }
@@ -23,7 +23,7 @@
<v-icon :name="getStatusIcon(lead.status)" :color="getStatusColor(lead.status)" /> <v-icon :name="getStatusIcon(lead.status)" :color="getStatusColor(lead.status)" />
</v-list-item-icon> </v-list-item-icon>
<v-list-item-content> <v-list-item-content>
<v-text-overflow :text="getCompanyName(lead)" /> <v-text-overflow :text="lead.company_name" />
</v-list-item-content> </v-list-item-content>
</v-list-item> </v-list-item>
</v-list> </v-list>
@@ -50,7 +50,7 @@
<template v-else> <template v-else>
<header class="header"> <header class="header">
<div class="header-left"> <div class="header-left">
<h1 class="title">{{ getCompanyName(selectedLead) }}</h1> <h1 class="title">{{ selectedLead.company_name }}</h1>
<p class="subtitle"> <p class="subtitle">
<v-icon name="language" x-small /> <v-icon name="language" x-small />
<a :href="selectedLead.website_url" target="_blank" class="url-link"> <a :href="selectedLead.website_url" target="_blank" class="url-link">
@@ -156,15 +156,7 @@
<div class="drawer-content"> <div class="drawer-content">
<div class="form-section"> <div class="form-section">
<div class="field"> <div class="field">
<span class="label">Organisation / Firma (Zentral)</span> <span class="label">Firma</span>
<v-select
v-model="newLead.company"
:items="companyOptions"
placeholder="Bestehende Firma auswählen..."
/>
</div>
<div class="field">
<span class="label">Organisation / Firma (Legacy / Neu)</span>
<v-input v-model="newLead.company_name" placeholder="z.B. Schmidt GmbH" autofocus /> <v-input v-model="newLead.company_name" placeholder="z.B. Schmidt GmbH" autofocus />
</div> </div>
<div class="field"> <div class="field">
@@ -216,26 +208,17 @@ const savingLead = ref(false);
const notice = ref<{ type: string; message: string } | null>(null); const notice = ref<{ type: string; message: string } | null>(null);
const newLead = ref({ const newLead = ref({
company_name: '', // Legacy company_name: '',
company: null,
website_url: '', website_url: '',
contact_name: '', // Legacy contact_name: '',
contact_email: '', // Legacy contact_email: '',
contact_person: null, contact_person: null,
briefing: '', briefing: '',
status: 'new' status: 'new'
}); });
const companies = ref<any[]>([]);
const people = ref<any[]>([]); const people = ref<any[]>([]);
const companyOptions = computed(() =>
companies.value.map(c => ({
text: c.name,
value: c.id
}))
);
const peopleOptions = computed(() => const peopleOptions = computed(() =>
people.value.map(p => ({ people.value.map(p => ({
text: `${p.first_name} ${p.last_name}`, text: `${p.first_name} ${p.last_name}`,
@@ -243,16 +226,7 @@ const peopleOptions = computed(() =>
})) }))
); );
function getCompanyName(lead: any) { function getPersonName(id: string) {
if (lead.company) {
return typeof lead.company === 'object' ? lead.company.name : (companies.value.find(c => c.id === lead.company)?.name || lead.company_name);
}
return lead.company_name;
}
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); const person = people.value.find(p => p.id === id);
return person ? `${person.first_name} ${person.last_name}` : id; return person ? `${person.first_name} ${person.last_name}` : id;
} }
@@ -264,32 +238,20 @@ function goToPerson(id: string) {
const selectedLead = computed(() => leads.value.find(l => l.id === selectedLeadId.value)); const selectedLead = computed(() => leads.value.find(l => l.id === selectedLeadId.value));
onMounted(fetchData); onMounted(fetchLeads);
async function fetchData() { async function fetchLeads() {
const [leadsResp, peopleResp, companiesResp] = await Promise.all([ const [leadsResp, peopleResp] = await Promise.all([
api.get('/items/leads', { api.get('/items/leads', { params: { sort: '-date_created' } }),
params: { api.get('/items/people', { params: { sort: 'last_name' } })
sort: '-date_created',
fields: '*.*'
}
}),
api.get('/items/people', { params: { sort: 'last_name' } }),
api.get('/items/companies', { params: { sort: 'name' } })
]); ]);
leads.value = leadsResp.data.data; leads.value = leadsResp.data.data;
people.value = peopleResp.data.data; people.value = peopleResp.data.data;
companies.value = companiesResp.data.data;
if (!selectedLeadId.value && leads.value.length > 0) { if (!selectedLeadId.value && leads.value.length > 0) {
selectedLeadId.value = leads.value[0].id; selectedLeadId.value = leads.value[0].id;
} }
} }
async function fetchLeads() {
await fetchData();
}
function selectLead(id: string) { function selectLead(id: string) {
selectedLeadId.value = id; selectedLeadId.value = id;
} }
@@ -356,10 +318,7 @@ function openPdf() {
} }
async function saveLead() { async function saveLead() {
if (!newLead.value.company_name && !newLead.value.company) { if (!newLead.value.company_name) return;
notice.value = { type: 'danger', message: 'Firma oder Firmenname erforderlich.' };
return;
}
savingLead.value = true; savingLead.value = true;
try { try {
const payload = { const payload = {
@@ -373,7 +332,6 @@ async function saveLead() {
selectedLeadId.value = payload.id; selectedLeadId.value = payload.id;
newLead.value = { newLead.value = {
company_name: '', company_name: '',
company: null,
website_url: '', website_url: '',
contact_name: '', contact_name: '',
contact_email: '', contact_email: '',
@@ -21,24 +21,25 @@ build({
platform: 'node', platform: 'node',
target: 'node18', target: 'node18',
outfile: outfile, outfile: outfile,
jsx: 'automatic', format: 'esm',
loader: { // Bundle everything, including Directus SDK, to avoid resolution issues in Docker
'.tsx': 'tsx', external: [],
'.ts': 'ts',
'.js': 'js',
},
external: ["@react-pdf/renderer", "react", "react-dom", "jsdom", "jsdom/*", "jquery", "jquery/*", "canvas", "fs", "path", "os", "http", "https", "zlib", "stream", "util", "url", "net", "tls", "crypto"],
plugins: [{ plugins: [{
name: 'mock-jquery',
setup(build) {
build.onResolve({ filter: /^jquery$/ }, args => ({ path: args.path, namespace: 'mock-jquery' }));
build.onLoad({ filter: /.*/, namespace: 'mock-jquery' }, () => ({ contents: 'export default {};', loader: 'js' }));
}
}, {
name: 'mock-canvas', name: 'mock-canvas',
setup(build) { setup(build) {
build.onResolve({ filter: /^canvas/ }, args => ({ path: args.path, namespace: 'mock-canvas' })); build.onResolve({ filter: /^canvas$/ }, args => ({ path: args.path, namespace: 'mock-canvas' }));
build.onLoad({ filter: /.*/, namespace: 'mock-canvas' }, () => ({ contents: 'export default {};', loader: 'js' })); build.onLoad({ filter: /.*/, namespace: 'mock-canvas' }, () => ({ contents: 'export default {};', loader: 'js' }));
} }
}, { }, {
name: 'mock-jsdom', name: 'mock-jsdom',
setup(build) { setup(build) {
build.onResolve({ filter: /^jsdom/ }, args => ({ path: args.path, namespace: 'mock-jsdom' })); return;
build.onLoad({ filter: /.*/, namespace: 'mock-jsdom' }, () => ({ contents: 'export default {};', loader: 'js' }));
} }
}] }]
}).then(() => { }).then(() => {
File diff suppressed because one or more lines are too long
@@ -0,0 +1,27 @@
{
"name": "acquisition",
"version": "1.0.0",
"type": "module",
"directus:extension": {
"type": "endpoint",
"path": "dist/index.js",
"source": "src/index.ts",
"host": "^11.0.0"
},
"scripts": {
"build": "node build.js",
"dev": "node build.js --watch"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"@mintel/acquisition": "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"
}
}
@@ -0,0 +1,172 @@
import "./shim";
import { defineEndpoint } from "@directus/extensions-sdk";
import { AcquisitionService, PdfEngine } from "@mintel/acquisition";
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 leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability });
const mailService = new MailService({ 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 Audit not ready" });
let recipientEmail = lead.contact_email;
let companyName = lead.company_name;
if (lead.contact_person) {
const person = await peopleService.readOne(lead.contact_person);
if (person && person.email) {
recipientEmail = person.email;
companyName = person.company || lead.company_name;
}
}
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 mailService = new MailService({ schema: req.schema, accountability: req.accountability });
try {
const lead = await leadsService.readOne(id);
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;
if (lead.contact_person) {
const person = await peopleService.readOne(lead.contact_person);
if (person && person.email) {
recipientEmail = person.email;
companyName = person.company || lead.company_name;
}
}
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 });
}
});
});
@@ -0,0 +1,22 @@
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { createRequire } from 'module';
try {
const url = import.meta?.url;
// Hardcode fallback path for Directus Docker environment
const fallbackPath = '/directus/extensions/acquisition/dist/index.js';
const filename = url ? fileURLToPath(url) : fallbackPath;
const dir = dirname(filename);
// @ts-ignore
globalThis.__filename = filename;
// @ts-ignore
globalThis.__dirname = dir;
// @ts-ignore
globalThis.require = createRequire(url || `file://${fallbackPath}`);
console.log(`[Shim] Loaded. __dirname: ${dir}`);
} catch (e) {
console.warn("[Shim] Failed to shim __dirname/require", e);
}
File diff suppressed because one or more lines are too long
@@ -1,30 +1,20 @@
{ {
"name": "customer-manager", "name": "customer-manager",
"description": "Custom High-Fidelity Customer & Company Management for Directus", "version": "1.0.0",
"icon": "supervisor_account",
"version": "1.7.12",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"files": [
"dist"
],
"directus:extension": {
"type": "module", "type": "module",
"path": "index.js", "directus:extension": {
"source": "src/index.ts", "type": "module",
"host": "*", "path": "dist/index.js",
"name": "Customer Manager" "source": "src/index.ts",
}, "host": "^11.0.0"
"scripts": { },
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)", "scripts": {
"dev": "directus-extension build -w" "build": "directus-extension build",
}, "dev": "directus-extension build -w"
"devDependencies": { },
"@directus/extensions-sdk": "11.0.2", "devDependencies": {
"vue": "^3.4.0" "@directus/extensions-sdk": "11.0.2",
} "vue": "^3.4.34",
"typescript": "^5.6.3"
}
} }
@@ -0,0 +1,14 @@
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,
},
],
});
@@ -0,0 +1,399 @@
<template>
<private-view title="Customer Manager">
<template #navigation>
<v-list nav>
<v-list-item @click="openCreateCompany" 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="company-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 #title-outer:after>
<v-notice v-if="notice" :type="notice.type" @close="notice = null" dismissible>
{{ notice.message }}
</v-notice>
</template>
<div class="content-wrapper">
<div v-if="!selectedCompany" class="empty-state">
<v-info title="Firmen auswählen" icon="business" center>
Wähle eine Firma in der Navigation aus oder
<v-button x-small @click="openCreateCompany">erstelle eine neue Firma</v-button>.
</v-info>
</div>
<template v-else>
<header class="header">
<div class="header-left">
<h1 class="title">{{ selectedCompany.name }}</h1>
<p class="subtitle">{{ employees.length }} Kunden-Mitarbeiter</p>
</div>
<div class="header-right">
<v-button secondary rounded icon v-tooltip.bottom="'Firma bearbeiten'" @click="openEditCompany">
<v-icon name="edit" />
</v-button>
<v-button primary @click="openCreateEmployee">
Mitarbeiter hinzufügen
</v-button>
</div>
</header>
<v-table
:headers="tableHeaders"
:items="employees"
: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>
</template>
</div>
<!-- Drawer: Company Form -->
<v-drawer
v-model="drawerCompanyActive"
:title="isEditingCompany ? 'Firma bearbeiten' : 'Neue Firma anlegen'"
icon="business"
@cancel="drawerCompanyActive = false"
>
<div v-if="drawerCompanyActive" class="drawer-content">
<div class="form-section">
<div class="field">
<span class="label">Firmenname</span>
<v-input v-model="companyForm.name" placeholder="z.B. KLZ Cables" autofocus />
</div>
</div>
<div class="drawer-actions">
<v-button primary block :loading="saving" @click="saveCompany">Speichern</v-button>
</div>
</div>
</v-drawer>
<!-- Drawer: Employee Form -->
<v-drawer
v-model="drawerEmployeeActive"
:title="isEditingEmployee ? 'Mitarbeiter bearbeiten' : 'Neuen Mitarbeiter anlegen'"
icon="person"
@cancel="drawerEmployeeActive = false"
>
<div v-if="drawerEmployeeActive" class="drawer-content">
<div class="form-section">
<div class="field">
<span class="label">Vorname</span>
<v-input v-model="employeeForm.first_name" placeholder="Vorname" autofocus />
</div>
<div class="field">
<span class="label">Nachname</span>
<v-input v-model="employeeForm.last_name" placeholder="Nachname" />
</div>
<div class="field">
<span class="label">E-Mail</span>
<v-input v-model="employeeForm.email" placeholder="E-Mail Adresse" type="email" />
</div>
<div class="field">
<span class="label">Zugehörige Person (Zentral)</span>
<v-select
v-model="employeeForm.contact_person"
:items="peopleOptions"
placeholder="Zentrale Person auswählen..."
show-deselect
/>
<p class="field-note">Verknüpft diesen Mitarbeiter mit dem globalen Personen-Verzeichnis.</p>
</div>
<v-divider v-if="isEditingEmployee" />
<div v-if="isEditingEmployee" class="field">
<span class="label">Temporäres Passwort</span>
<v-input v-model="employeeForm.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="saveEmployee">Daten speichern</v-button>
<template v-if="isEditingEmployee">
<v-divider />
<v-button
v-tooltip.bottom="'Generiert PW, speichert es und sendet E-Mail'"
secondary
block
:loading="invitingId === employeeForm.id"
@click="inviteUser(employeeForm)"
>
<v-icon name="send" left /> Zugangsdaten senden
</v-button>
</template>
</div>
</div>
</v-drawer>
</private-view>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick, computed } from 'vue';
import { useApi } from '@directus/extensions-sdk';
const api = useApi();
const companies = ref<any[]>([]);
const selectedCompany = ref<any>(null);
const employees = ref<any[]>([]);
const people = 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 peopleOptions = computed(() =>
people.value.map(p => ({
text: `${p.first_name} ${p.last_name} (${p.company || 'Keine Firma'})`,
value: p.id
}))
);
// Forms State
const drawerCompanyActive = ref(false);
const isEditingCompany = ref(false);
const companyForm = ref({ id: '', name: '' });
const drawerEmployeeActive = ref(false);
const isEditingEmployee = ref(false);
const employeeForm = ref({
id: '',
first_name: '',
last_name: '',
email: '',
contact_person: null as string | null,
temporary_password: ''
});
const tableHeaders = [
{ text: 'Name', value: 'name', sortable: true },
{ text: 'E-Mail', value: 'email', sortable: true },
{ text: 'Zuletzt eingeladen', value: 'last_invited', sortable: true }
];
async function fetchCompanies() {
const [companyRes, peopleRes] = await Promise.all([
api.get('/items/companies', { params: { fields: ['id', 'name'], sort: 'name' } }),
api.get('/items/people', { params: { sort: 'last_name' } })
]);
companies.value = companyRes.data.data;
people.value = peopleRes.data.data;
}
async function selectCompany(company: any) {
selectedCompany.value = company;
loading.value = true;
try {
const res = await api.get('/items/client_users', {
params: {
filter: { company: { _eq: company.id } },
fields: ['*'],
sort: 'first_name',
},
});
employees.value = res.data.data;
} finally {
loading.value = false;
}
}
// Company Actions
function openCreateCompany() {
isEditingCompany.value = false;
companyForm.value = { id: '', name: '' };
drawerCompanyActive.value = true;
}
async function openEditCompany() {
if (!selectedCompany.value) return;
companyForm.value = {
id: selectedCompany.value.id,
name: selectedCompany.value.name
};
isEditingCompany.value = true;
await nextTick();
drawerCompanyActive.value = true;
}
async function saveCompany() {
if (!companyForm.value.name) return;
saving.value = true;
try {
if (isEditingCompany.value) {
await api.patch(`/items/companies/${companyForm.value.id}`, { name: companyForm.value.name });
notice.value = { type: 'success', message: 'Firma aktualisiert!' };
} else {
await api.post('/items/companies', { name: companyForm.value.name });
notice.value = { type: 'success', message: 'Firma angelegt!' };
}
drawerCompanyActive.value = false;
await fetchCompanies();
if (selectedCompany.value?.id === companyForm.value.id) {
selectedCompany.value.name = companyForm.value.name;
}
} catch (e: any) {
notice.value = { type: 'danger', message: e.message };
} finally {
saving.value = false;
}
}
// Employee Actions
function openCreateEmployee() {
isEditingEmployee.value = false;
employeeForm.value = { id: '', first_name: '', last_name: '', email: '', contact_person: null, temporary_password: '' };
drawerEmployeeActive.value = true;
}
async function openEditEmployee(item: any) {
employeeForm.value = {
id: item.id || '',
first_name: item.first_name || '',
last_name: item.last_name || '',
email: item.email || '',
contact_person: item.contact_person || null,
temporary_password: item.temporary_password || ''
};
isEditingEmployee.value = true;
await nextTick();
drawerEmployeeActive.value = true;
}
async function saveEmployee() {
if (!employeeForm.value.email || !selectedCompany.value) return;
saving.value = true;
try {
if (isEditingEmployee.value) {
await api.patch(`/items/client_users/${employeeForm.value.id}`, {
first_name: employeeForm.value.first_name,
last_name: employeeForm.value.last_name,
email: employeeForm.value.email,
contact_person: employeeForm.value.contact_person
});
notice.value = { type: 'success', message: 'Mitarbeiter aktualisiert!' };
} else {
await api.post('/items/client_users', {
first_name: employeeForm.value.first_name,
last_name: employeeForm.value.last_name,
email: employeeForm.value.email,
company: selectedCompany.value.id,
contact_person: employeeForm.value.contact_person
});
notice.value = { type: 'success', message: 'Mitarbeiter angelegt!' };
}
drawerEmployeeActive.value = false;
await selectCompany(selectedCompany.value);
} catch (e: any) {
notice.value = { type: 'danger', message: e.message };
} 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 für ${user.first_name} versendet. 📧` };
await selectCompany(selectedCompany.value);
if (drawerEmployeeActive.value && employeeForm.value.id === user.id) {
const updated = employees.value.find(e => e.id === user.id);
if (updated) {
employeeForm.value.temporary_password = updated.temporary_password;
}
}
} catch (e: any) {
notice.value = { type: 'danger', message: `Fehler: ${e.message}` };
} finally {
invitingId.value = null;
}
}
function onRowClick(event: any) {
const item = event?.item || event;
if (item && item.id) {
openEditEmployee(item);
}
}
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'
});
}
onMounted(() => {
fetchCompanies();
});
</script>
<style scoped>
.content-wrapper { padding: 32px; height: 100%; display: flex; flex-direction: column; }
.company-item { cursor: pointer; }
.header { margin-bottom: 24px; display: flex; justify-content: space-between; align-items: flex-end; }
.header-right { display: flex; gap: 12px; }
.title { font-size: 24px; font-weight: 800; margin-bottom: 4px; }
.subtitle { color: var(--theme--foreground-subdued); font-size: 14px; }
.empty-state { height: 100%; display: flex; align-items: center; justify-content: center; }
.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; }
:deep(.v-list-item) { cursor: pointer !important; }
</style>
File diff suppressed because one or more lines are too long
@@ -1,30 +1,20 @@
{ {
"name": "feedback-commander", "name": "feedback-commander",
"description": "Custom High-Fidelity Feedback Management Extension for Directus", "version": "1.0.0",
"icon": "view_kanban",
"version": "1.7.12",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"files": [
"dist"
],
"directus:extension": {
"type": "module", "type": "module",
"path": "index.js", "directus:extension": {
"source": "src/index.ts", "type": "module",
"host": "*", "path": "dist/index.js",
"name": "Feedback Commander" "source": "src/index.ts",
}, "host": "^11.0.0"
"scripts": { },
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)", "scripts": {
"dev": "directus-extension build -w" "build": "directus-extension build",
}, "dev": "directus-extension build -w"
"devDependencies": { },
"@directus/extensions-sdk": "11.0.2", "devDependencies": {
"vue": "^3.4.0" "@directus/extensions-sdk": "11.0.2",
} "vue": "^3.4.34",
"typescript": "^5.6.3"
}
} }
@@ -0,0 +1,14 @@
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,
},
],
});
@@ -0,0 +1,746 @@
<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.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-select
v-model="selectedItem.contact_person"
:items="peopleOptions"
inline
placeholder="Bezugsperson..."
show-deselect
@update:model-value="updatePerson"
/>
<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 }}</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="public" x-small /> Website</label>
<strong>{{ selectedItem.project }}</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 people = 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.project).filter(Boolean));
return Array.from(projSet).sort();
});
const peopleOptions = computed(() =>
people.value.map(p => ({
text: `${p.first_name} ${p.last_name}`,
value: p.id
}))
);
async function fetchData() {
loading.value = true;
fetchError.value = null;
try {
const [feedbackRes, peopleRes] = await Promise.all([
api.get('/items/visual_feedback', {
params: {
sort: '-date_created,-id',
limit: 300
}
}),
api.get('/items/people', { params: { sort: 'last_name' } })
]);
items.value = feedbackRes.data.data;
people.value = peopleRes.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'
}
});
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 updatePerson(val) {
if (!selectedItem.value) return;
try {
await api.patch(`/items/visual_feedback/${selectedItem.value.id}`, {
contact_person: val
});
fetchData();
} catch (e) {
console.error(e);
}
}
async function sendReply() {
if (!replyText.value.trim() || !selectedItem.value) return;
sending.value = true;
try {
const response = await api.post('/items/visual_feedback_comments', {
feedback_id: selectedItem.value.id,
user_name: 'Operator',
text: replyText.value
});
comments.value.unshift(response.data.data);
replyText.value = '';
} catch (e) {
console.error(e);
} finally {
sending.value = false;
}
}
function formatDate(dateStr) {
if (!dateStr || typeof dateStr === 'number') return 'Legacy';
return new Date(dateStr).toLocaleDateString() + ' ' + new Date(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function formatUrl(url) {
if (!url) return '';
return url.replace(/^https?:\/\//, '');
}
function capitalize(s) {
return s.charAt(0).toUpperCase() + s.slice(1).replace('_', ' ');
}
function getDeepLinkUrl(item) {
if (!item || !item.url) return '';
try {
const url = new URL(item.url);
url.searchParams.set('fb_id', item.id);
return url.toString();
} catch (e) {
return item.url + '?fb_id=' + item.id;
}
}
function openDeepLink(item) {
const url = getDeepLinkUrl(item);
if (url) window.open(url, '_blank');
}
function openExternal(url) {
if (url) window.open(url, '_blank');
}
function getAssetUrl(id) {
if (!id) return '';
return `/assets/${id}`;
}
function getStatusColor(status) {
const s = statuses.find(st => st.value === status);
return s ? s.color : 'var(--foreground-subdued)';
}
onMounted(() => {
fetchData();
});
</script>
<style scoped>
.feedback-container {
height: calc(100vh - 64px);
display: flex;
flex-direction: column;
background: var(--background-subdued);
}
.operational-layout {
display: flex;
height: 100%;
}
/* Triage Lane Polish */
.triage-lane {
width: 360px;
height: 100%;
display: flex;
flex-direction: column;
background: var(--background-normal);
border-right: 1px solid var(--border-normal);
box-shadow: 2px 0 8px rgba(0,0,0,0.02);
}
.lane-header {
padding: 16px;
background: var(--background-normal);
border-bottom: 1px solid var(--border-normal);
}
.lane-content {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.feedback-card {
background: var(--background-normal);
border: 1px solid var(--border-subdued);
border-radius: 8px;
display: flex;
overflow: hidden;
cursor: pointer;
transition: all var(--transition);
}
.feedback-card:hover {
border-color: var(--border-normal);
background: var(--background-subdued);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.04);
}
.feedback-card.active {
border-color: var(--primary);
background: var(--background-accent);
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.1);
}
.card-status-bar {
width: 4px;
}
.card-body {
flex: 1;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.card-header {
display: flex;
justify-content: space-between;
font-size: 11px;
}
.card-user { font-weight: bold; color: var(--foreground-normal); }
.card-date { color: var(--foreground-subdued); }
.card-text {
font-size: 13px;
line-height: 1.5;
color: var(--foreground-normal);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.meta-tags {
display: flex;
gap: 8px;
align-items: center;
}
/* Processing Desk Refinement */
.processing-desk {
flex: 1;
height: 100%;
overflow-y: auto;
padding: 32px;
}
.desk-content {
max-width: 1100px;
margin: 0 auto;
}
.desk-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 32px;
border-bottom: 2px solid var(--border-normal);
padding-bottom: 20px;
}
.headline-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
color: var(--foreground-subdued);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-text { letter-spacing: 0.5px; }
.header-actions {
display: flex;
gap: 16px;
align-items: center;
}
.desk-grid {
display: grid;
grid-template-columns: 1fr 300px;
gap: 24px;
align-items: start;
}
.content-card {
border-radius: 12px;
overflow: hidden;
}
.feedback-body {
font-size: 18px;
line-height: 1.6;
padding: 24px;
color: var(--foreground-normal);
display: flex;
flex-direction: column;
gap: 20px;
}
.visual-proof {
display: flex;
flex-direction: column;
gap: 8px;
}
.proof-label {
font-size: 10px;
text-transform: uppercase;
font-weight: 800;
color: var(--foreground-subdued);
letter-spacing: 0.5px;
}
.screenshot-img {
width: 100%;
border-radius: 8px;
border: 1px solid var(--border-normal);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
background: var(--background-subdued);
}
.main-text {
white-space: pre-wrap;
}
.reply-section {
margin-top: 40px;
}
.section-divider {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
}
.divider-label {
font-size: 11px;
text-transform: uppercase;
font-weight: 800;
color: var(--foreground-subdued);
white-space: nowrap;
letter-spacing: 1px;
}
.thread {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 32px;
}
.reply-bubble {
padding: 16px;
border-radius: 12px;
background: var(--background-normal);
border: 1px solid var(--border-subdued);
}
.reply-header {
display: flex;
justify-content: space-between;
font-size: 11px;
margin-bottom: 8px;
}
.reply-user { font-weight: 800; color: var(--primary); }
.reply-date { color: var(--foreground-subdued); }
.reply-text { font-size: 14px; line-height: 1.5; }
.composer {
background: var(--background-normal);
border: 1px solid var(--border-normal);
border-radius: 12px;
padding: 16px;
}
.composer-actions {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.meta-card {
border-radius: 12px;
}
.meta-list {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
}
.meta-item {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
}
.meta-item label {
font-size: 10px;
text-transform: uppercase;
font-weight: bold;
color: var(--foreground-subdued);
display: flex;
align-items: center;
gap: 4px;
}
.truncate-path {
color: var(--primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trace-code, .id-code {
background: var(--background-subdued);
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
word-break: break-all;
}
.coords { font-weight: bold; font-family: var(--family-monospace); }
.help-box {
margin-top: 20px;
padding: 16px;
background: rgba(var(--primary-rgb), 0.05);
border-radius: 12px;
font-size: 12px;
color: var(--primary);
display: flex;
gap: 8px;
line-height: 1.4;
}
.no-selection-desk {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.empty-state-mini {
text-align: center;
padding: 24px;
font-size: 12px;
color: var(--foreground-subdued);
background: var(--background-subdued);
border-radius: 12px;
border: 1px dashed var(--border-normal);
}
/* Animations */
.list-enter-active, .list-leave-active { transition: all 0.3s ease; }
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(-20px); }
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
.fade-enter-from { opacity: 0; transform: translateY(10px); }
.fade-leave-to { opacity: 0; transform: translateY(-10px); }
.thread-list-enter-active { transition: all 0.4s ease; transform-origin: top; }
.thread-list-enter-from { opacity: 0; transform: scaleY(0.9); }
.scrollbar::-webkit-scrollbar { width: 6px; }
.scrollbar::-webkit-scrollbar-track { background: transparent; }
.scrollbar::-webkit-scrollbar-thumb { background: var(--border-subdued); border-radius: 3px; }
.scrollbar::-webkit-scrollbar-thumb:hover { background: var(--border-normal); }
</style>
@@ -1,30 +1,20 @@
{ {
"name": "people-manager", "name": "people-manager",
"description": "Custom High-Fidelity People Management for Directus", "version": "1.0.0",
"icon": "person",
"version": "1.7.12",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"files": [
"dist"
],
"directus:extension": {
"type": "module", "type": "module",
"path": "index.js", "directus:extension": {
"source": "src/index.ts", "type": "module",
"host": "*", "path": "dist/index.js",
"name": "People Manager" "source": "src/index.ts",
}, "host": "^11.0.0"
"scripts": { },
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)", "scripts": {
"dev": "directus-extension build -w" "build": "directus-extension build",
}, "dev": "directus-extension build -w"
"devDependencies": { },
"@directus/extensions-sdk": "11.0.2", "devDependencies": {
"vue": "^3.4.0" "@directus/extensions-sdk": "11.0.2",
} "vue": "^3.4.34",
"typescript": "^5.6.3"
}
} }
@@ -64,8 +64,8 @@
<div class="details-grid"> <div class="details-grid">
<div class="detail-item"> <div class="detail-item">
<span class="label">Organisation / Firma</span> <span class="label">Organisation</span>
<p class="value">{{ getCompanyName(selectedPerson) }}</p> <p class="value">{{ selectedPerson.company || '---' }}</p>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<span class="label">Telefon</span> <span class="label">Telefon</span>
@@ -98,16 +98,8 @@
<v-input v-model="form.email" placeholder="email@beispiel.de" type="email" /> <v-input v-model="form.email" placeholder="email@beispiel.de" type="email" />
</div> </div>
<div class="field"> <div class="field">
<span class="label">Zentrale Firma</span> <span class="label">Organisation / Firma</span>
<v-select <v-input v-model="form.company" placeholder="z.B. Mintel" />
v-model="form.company"
:items="companyOptions"
placeholder="Bestehende Firma auswählen..."
/>
</div>
<div class="field">
<span class="label">Firma (Legacy / Neu)</span>
<v-input v-model="form.company_name" placeholder="z.B. Mintel" />
</div> </div>
<div class="field"> <div class="field">
<span class="label">Telefon</span> <span class="label">Telefon</span>
@@ -127,12 +119,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue'; import { ref, onMounted } from 'vue';
import { useApi } from '@directus/extensions-sdk'; import { useApi } from '@directus/extensions-sdk';
const api = useApi(); const api = useApi();
const people = ref([]); const people = ref([]);
const companies = ref([]);
const selectedPerson = ref(null); const selectedPerson = ref(null);
const feedback = ref(null); const feedback = ref(null);
const saving = ref(false); const saving = ref(false);
@@ -144,43 +135,18 @@ const form = ref({
first_name: '', first_name: '',
last_name: '', last_name: '',
email: '', email: '',
company: null, company: '',
company_name: '',
phone: '' phone: ''
}); });
const companyOptions = computed(() => async function fetchPeople() {
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 || person.company_name);
}
return person.company_name || '---';
}
async function fetchData() {
try { try {
const [peopleResp, companiesResp] = await Promise.all([ const response = await api.get('/items/people', {
api.get('/items/people', { params: { sort: 'last_name' }
params: { });
sort: 'last_name', people.ref = response.data.data;
fields: '*.*'
}
}),
api.get('/items/companies', {
params: { sort: 'name' }
})
]);
people.value = peopleResp.data.data;
companies.value = companiesResp.data.data;
} catch (error) { } catch (error) {
console.error('Failed to fetch data:', error); console.error('Failed to fetch people:', error);
} }
} }
@@ -190,39 +156,13 @@ function selectPerson(person) {
function openCreateDrawer() { function openCreateDrawer() {
isEditing.value = false; isEditing.value = false;
form.value = { form.value = { id: null, first_name: '', last_name: '', email: '', company: '', phone: '' };
id: null,
first_name: '',
last_name: '',
email: '',
company: null,
company_name: '',
phone: ''
};
drawerActive.value = true; drawerActive.value = true;
} }
function openEditDrawer() { function openEditDrawer() {
isEditing.value = true; isEditing.value = true;
const person = selectedPerson.value; form.value = { ...selectedPerson.value };
let companyId = null;
let companyName = person.company_name || '';
if (person.company) {
if (typeof person.company === 'object') {
companyId = person.company.id;
} else if (person.company.length === 36) { // Assume UUID
companyId = person.company;
} else {
companyName = person.company;
}
}
form.value = {
...person,
company: companyId,
company_name: companyName
};
drawerActive.value = true; drawerActive.value = true;
} }
@@ -242,9 +182,9 @@ async function savePerson() {
feedback.value = { type: 'success', message: 'Person angelegt!' }; feedback.value = { type: 'success', message: 'Person angelegt!' };
} }
drawerActive.value = false; drawerActive.value = false;
await fetchData(); await fetchPeople();
if (isEditing.value) { if (isEditing.value) {
selectedPerson.value = people.value.find(p => p.id === form.value.id); selectedPerson.value = form.value;
} }
} catch (error) { } catch (error) {
feedback.value = { type: 'danger', message: error.message }; feedback.value = { type: 'danger', message: error.message };
@@ -260,13 +200,13 @@ async function deletePerson() {
await api.delete(`/items/people/${selectedPerson.value.id}`); await api.delete(`/items/people/${selectedPerson.value.id}`);
feedback.value = { type: 'success', message: 'Person gelöscht.' }; feedback.value = { type: 'success', message: 'Person gelöscht.' };
selectedPerson.value = null; selectedPerson.value = null;
await fetchData(); await fetchPeople();
} catch (error) { } catch (error) {
feedback.value = { type: 'danger', message: error.message }; feedback.value = { type: 'danger', message: error.message };
} }
} }
onMounted(fetchData); onMounted(fetchPeople);
</script> </script>
<style scoped> <style scoped>
+6 -9
View File
@@ -1,17 +1,14 @@
{ {
"name": "@mintel/cms-infra", "name": "@mintel/cms-infra",
"version": "1.8.2", "version": "1.7.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"up": "npm run build:extensions && docker compose up -d", "build": "pnpm --filter \"./extensions/**\" build",
"dev": "pnpm --filter \"./extensions/**\" dev",
"up": "docker compose up -d",
"up:build": "docker compose up -d --build",
"down": "docker compose down", "down": "docker compose down",
"logs": "docker compose logs -f", "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"
} }
} }
@@ -0,0 +1,3 @@
export default (router) => {
router.get('/ping', (req, res) => res.send('pong'));
};
@@ -0,0 +1,10 @@
{
"name": "test-extension",
"version": "1.0.0",
"type": "module",
"directus:extension": {
"type": "endpoint",
"path": "index.js",
"host": "^11.0.0"
}
}
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
@@ -0,0 +1,75 @@
#!/bin/bash
# Configuration
API_URL="http://localhost:8059"
EMAIL="marc@mintel.me"
PASSWORD="Tim300493."
echo "Logging in to Directus..."
TOKEN=$(curl -s -X POST "${API_URL}/auth/login" \
-H "Content-Type: application/json" \
-d "{\"email\":\"${EMAIL}\", \"password\":\"${PASSWORD}\"}" | grep -o '"access_token":"[^"]*' | cut -d'"' -f4)
if [ -z "$TOKEN" ]; then
echo "Login failed!"
exit 1
fi
echo "Hiding 'leads' collection..."
curl -s -X PATCH "${API_URL}/collections/leads" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"meta": {"hidden": true}}'
echo "Creating 'people' collection..."
curl -s -X POST "${API_URL}/collections" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"collection": "people",
"schema": {},
"meta": {
"icon": "person",
"display_template": "{{first_name}} {{last_name}}",
"show_status_indicator": true
}
}'
echo "Adding fields to 'people'..."
FIELDS='[
{"field": "first_name", "type": "string", "meta": {"interface": "input", "width": "half"}},
{"field": "last_name", "type": "string", "meta": {"interface": "input", "width": "half"}},
{"field": "email", "type": "string", "meta": {"interface": "input", "width": "half"}},
{"field": "phone", "type": "string", "meta": {"interface": "input", "width": "half"}},
{"field": "company", "type": "string", "meta": {"interface": "input", "width": "full"}}
]'
for field in $(echo "${FIELDS}" | jq -c '.[]'); do
curl -s -X POST "${API_URL}/fields/people" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d "${field}"
done
echo "Adding 'contact_person' to 'leads', 'client_users', and 'visual_feedback'..."
for collection in leads client_users visual_feedback; do
curl -s -X POST "${API_URL}/fields/${collection}" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"field": "contact_person",
"type": "uuid",
"meta": {
"interface": "select-dropdown-m2o",
"options": {
"template": "{{first_name}} {{last_name}}"
}
},
"schema": {
"foreign_key_column": "id",
"foreign_key_table": "people"
}
}'
done
echo "Done!"
-27
View File
@@ -1,27 +0,0 @@
{
"name": "company-manager",
"description": "Central Company Management for Directus",
"icon": "business",
"version": "1.8.2",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"directus:extension": {
"type": "module",
"path": "index.js",
"source": "src/index.ts",
"host": "*",
"name": "Company Manager"
},
"scripts": {
"build": "directus-extension build && cp -f dist/index.js index.js 2>/dev/null || true",
"dev": "directus-extension build -w"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.0"
}
}
+5 -3
View File
@@ -2,13 +2,15 @@
"name": "customer-manager", "name": "customer-manager",
"description": "Custom High-Fidelity Customer & Company Management for Directus", "description": "Custom High-Fidelity Customer & Company Management for Directus",
"icon": "supervisor_account", "icon": "supervisor_account",
"version": "1.8.2", "version": "1.7.0",
"type": "module",
"keywords": [ "keywords": [
"directus", "directus",
"directus-extension", "directus-extension",
"directus-extension-module" "directus-extension-module"
], ],
"files": [
"dist"
],
"directus:extension": { "directus:extension": {
"type": "module", "type": "module",
"path": "index.js", "path": "index.js",
@@ -17,7 +19,7 @@
"name": "Customer Manager" "name": "Customer Manager"
}, },
"scripts": { "scripts": {
"build": "directus-extension build && cp -f dist/index.js index.js 2>/dev/null || true", "build": "directus-extension build",
"dev": "directus-extension build -w" "dev": "directus-extension build -w"
}, },
"devDependencies": { "devDependencies": {
+13 -34
View File
@@ -127,15 +127,6 @@
<v-input v-model="employeeForm.email" placeholder="E-Mail Adresse" type="email" /> <v-input v-model="employeeForm.email" placeholder="E-Mail Adresse" type="email" />
</div> </div>
<div class="field">
<span class="label">Zentrale Person (Verknüpfung)</span>
<v-select
v-model="employeeForm.person"
:items="peopleOptions"
placeholder="Person aus dem People Manager auswählen..."
/>
</div>
<v-divider v-if="isEditingEmployee" /> <v-divider v-if="isEditingEmployee" />
<div v-if="isEditingEmployee" class="field"> <div v-if="isEditingEmployee" class="field">
@@ -167,7 +158,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, nextTick, computed } from 'vue'; import { ref, onMounted, nextTick } from 'vue';
import { useApi } from '@directus/extensions-sdk'; import { useApi } from '@directus/extensions-sdk';
const api = useApi(); const api = useApi();
@@ -192,7 +183,6 @@ const employeeForm = ref({
first_name: '', first_name: '',
last_name: '', last_name: '',
email: '', email: '',
person: null,
temporary_password: '' temporary_password: ''
}); });
@@ -202,22 +192,14 @@ const tableHeaders = [
{ text: 'Zuletzt eingeladen', value: 'last_invited', sortable: true } { text: 'Zuletzt eingeladen', value: 'last_invited', sortable: true }
]; ];
const people = ref<any[]>([]); async function fetchCompanies() {
const res = await api.get('/items/companies', {
const peopleOptions = computed(() => params: {
people.value.map(p => ({ fields: ['id', 'name'],
text: `${p.first_name} ${p.last_name} (${p.email})`, sort: 'name',
value: p.id },
})) });
); companies.value = res.data.data;
async function fetchData() {
const [companiesResp, peopleResp] = await Promise.all([
api.get('/items/companies', { params: { sort: 'name', fields: ['id', 'name'] } }),
api.get('/items/people', { params: { sort: 'last_name' } })
]);
companies.value = companiesResp.data.data;
people.value = peopleResp.data.data;
} }
async function selectCompany(company: any) { async function selectCompany(company: any) {
@@ -227,7 +209,7 @@ async function selectCompany(company: any) {
const res = await api.get('/items/client_users', { const res = await api.get('/items/client_users', {
params: { params: {
filter: { company: { _eq: company.id } }, filter: { company: { _eq: company.id } },
fields: ['*', 'person.*'], fields: ['*'],
sort: 'first_name', sort: 'first_name',
}, },
}); });
@@ -291,7 +273,6 @@ async function openEditEmployee(item: any) {
first_name: item.first_name || '', first_name: item.first_name || '',
last_name: item.last_name || '', last_name: item.last_name || '',
email: item.email || '', email: item.email || '',
person: item.person?.id || item.person || null,
temporary_password: item.temporary_password || '' temporary_password: item.temporary_password || ''
}; };
isEditingEmployee.value = true; isEditingEmployee.value = true;
@@ -307,8 +288,7 @@ async function saveEmployee() {
await api.patch(`/items/client_users/${employeeForm.value.id}`, { await api.patch(`/items/client_users/${employeeForm.value.id}`, {
first_name: employeeForm.value.first_name, first_name: employeeForm.value.first_name,
last_name: employeeForm.value.last_name, last_name: employeeForm.value.last_name,
email: employeeForm.value.email, email: employeeForm.value.email
person: employeeForm.value.person
}); });
notice.value = { type: 'success', message: 'Mitarbeiter aktualisiert!' }; notice.value = { type: 'success', message: 'Mitarbeiter aktualisiert!' };
} else { } else {
@@ -316,8 +296,7 @@ async function saveEmployee() {
first_name: employeeForm.value.first_name, first_name: employeeForm.value.first_name,
last_name: employeeForm.value.last_name, last_name: employeeForm.value.last_name,
email: employeeForm.value.email, email: employeeForm.value.email,
company: selectedCompany.value.id, company: selectedCompany.value.id
person: employeeForm.value.person
}); });
notice.value = { type: 'success', message: 'Mitarbeiter angelegt!' }; notice.value = { type: 'success', message: 'Mitarbeiter angelegt!' };
} }
@@ -364,7 +343,7 @@ function formatDate(dateStr: string) {
} }
onMounted(() => { onMounted(() => {
fetchData(); fetchCompanies();
}); });
</script> </script>
@@ -1,31 +0,0 @@
{
"name": "@mintel/directus-extension-toolkit",
"version": "1.8.2",
"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"
}
}
@@ -1,102 +0,0 @@
<template>
<private-view :title="title">
<template #navigation>
<slot name="navigation" />
</template>
<template #title-outer:after>
<v-notice v-if="notice" :type="notice.type" @close="$emit('close-notice')" dismissible>
{{ notice.message }}
</v-notice>
</template>
<div class="mintel-manager-layout">
<div v-if="isEmpty" class="empty-state">
<v-info :title="emptyTitle" :icon="emptyIcon" center>
<slot name="empty-state" />
</v-info>
</div>
<template v-else>
<header class="mintel-header">
<div class="header-left">
<h1 class="mintel-title">{{ itemTitle }}</h1>
<p class="mintel-subtitle">
<slot name="subtitle" />
</p>
</div>
<div class="header-right">
<slot name="actions" />
</div>
</header>
<v-divider />
<div class="mintel-content">
<slot />
</div>
</template>
</div>
</private-view>
</template>
<script setup lang="ts">
defineProps<{
title: string;
itemTitle?: string;
isEmpty?: boolean;
emptyTitle?: string;
emptyIcon?: string;
notice?: { type: string; message: string } | null;
}>();
defineEmits(['close-notice']);
</script>
<style scoped>
.mintel-manager-layout {
padding: 32px;
height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.mintel-header {
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.mintel-title {
font-size: 24px;
font-weight: 800;
margin-bottom: 4px;
color: var(--theme--foreground);
}
.mintel-subtitle {
color: var(--theme--foreground-subdued);
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.header-right {
display: flex;
gap: 12px;
}
.empty-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.mintel-content {
margin-top: 32px;
}
</style>
@@ -1,62 +0,0 @@
<template>
<div class="mintel-select">
<v-select
:model-value="modelValue"
:items="items"
:placeholder="placeholder"
:searchable="searchable"
:show-deselect="showDeselect"
@update:model-value="$emit('update:modelValue', $event)"
/>
<v-button v-if="allowAdd" secondary rounded icon x-small class="add-button" @click="$emit('add')">
<v-icon name="add" />
</v-button>
</div>
</template>
<script setup lang="ts">
const props = defineProps({
modelValue: {
type: [String, Number],
default: null
},
items: {
type: Array as () => Array<{ text: string; value: string | number }>,
required: true
},
placeholder: {
type: String,
default: 'Auswählen...'
},
searchable: {
type: Boolean,
default: true
},
showDeselect: {
type: Boolean,
default: true
},
allowAdd: {
type: Boolean,
default: false
}
});
defineEmits(['update:modelValue', 'add']);
</script>
<style scoped>
.mintel-select {
display: flex;
align-items: center;
gap: 8px;
}
.mintel-select :deep(.v-select) {
flex: 1;
}
.add-button {
flex-shrink: 0;
}
</style>
@@ -1,84 +0,0 @@
<template>
<div class="mintel-stat-card" @click="$emit('click')">
<div class="stat-icon">
<v-icon :name="icon" large />
</div>
<div class="stat-content">
<span class="stat-label">{{ label }}</span>
<span class="stat-value">{{ value }}</span>
</div>
<v-icon name="chevron_right" class="arrow" />
</div>
</template>
<script setup lang="ts">
defineProps<{
label: string;
value: string | number;
icon: string;
}>();
defineEmits(['click']);
</script>
<style scoped>
.mintel-stat-card {
background: var(--theme--background-normal);
border: 1px solid var(--theme--border);
padding: 24px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 20px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.mintel-stat-card:hover {
border-color: var(--theme--primary);
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0,0,0,0.1);
}
.stat-icon {
width: 56px;
height: 56px;
background: var(--theme--background-subdued);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--theme--primary);
}
.stat-content {
display: flex;
flex-direction: column;
}
.stat-label {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
color: var(--theme--foreground-subdued);
letter-spacing: 0.5px;
}
.stat-value {
font-size: 28px;
font-weight: 800;
color: var(--theme--foreground);
}
.arrow {
position: absolute;
right: 24px;
opacity: 0.2;
}
.mintel-stat-card:hover .arrow {
opacity: 1;
color: var(--theme--primary);
}
</style>
@@ -1,3 +0,0 @@
export { default as MintelSelect } from './MintelSelect.vue';
export { default as MintelManagerLayout } from './MintelManagerLayout.vue';
export { default as MintelStatCard } from './MintelStatCard.vue';
@@ -1,24 +0,0 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';
export default defineConfig({
plugins: [vue()],
build: {
lib: {
entry: resolve('src/index.ts'),
name: 'MintelDirectusToolkit',
fileName: 'index',
formats: ['es']
},
rollupOptions: {
external: ['vue', '@directus/extensions-sdk'],
output: {
globals: {
vue: 'Vue',
'@directus/extensions-sdk': 'DirectusExtensionsSDK'
}
}
}
}
});
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@mintel/eslint-config", "name": "@mintel/eslint-config",
"version": "1.8.2", "version": "1.7.0",
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://npm.infra.mintel.me" "registry": "https://npm.infra.mintel.me"
File diff suppressed because one or more lines are too long
+7 -5
View File
@@ -1,23 +1,25 @@
{ {
"name": "feedback-commander", "name": "@mintel/extension-feedback-commander",
"description": "Custom High-Fidelity Feedback Management Extension for Directus", "description": "Custom High-Fidelity Feedback Management Extension for Directus",
"icon": "view_kanban", "icon": "view_kanban",
"version": "1.8.2", "version": "1.7.0",
"type": "module",
"keywords": [ "keywords": [
"directus", "directus",
"directus-extension", "directus-extension",
"directus-extension-module" "directus-extension-module"
], ],
"files": [
"dist"
],
"directus:extension": { "directus:extension": {
"type": "module", "type": "module",
"path": "index.js", "path": "dist/index.js",
"source": "src/index.ts", "source": "src/index.ts",
"host": "*", "host": "*",
"name": "Feedback Commander" "name": "Feedback Commander"
}, },
"scripts": { "scripts": {
"build": "directus-extension build && cp -f dist/index.js index.js 2>/dev/null || true", "build": "directus-extension build",
"dev": "directus-extension build -w" "dev": "directus-extension build -w"
}, },
"devDependencies": { "devDependencies": {
+8 -16
View File
@@ -78,7 +78,7 @@
<div class="card-text">{{ item.text }}</div> <div class="card-text">{{ item.text }}</div>
<footer class="card-footer"> <footer class="card-footer">
<div class="meta-tags"> <div class="meta-tags">
<v-chip x-small outline>{{ item.company?.name || item.project }}</v-chip> <v-chip x-small outline>{{ item.project }}</v-chip>
<v-icon :name="item.type === 'bug' ? 'bug_report' : 'lightbulb'" :color="item.type === 'bug' ? '#E91E63' : '#FFC107'" small /> <v-icon :name="item.type === 'bug' ? 'bug_report' : 'lightbulb'" :color="item.type === 'bug' ? '#E91E63' : '#FFC107'" small />
</div> </div>
<v-icon v-if="selectedItem?.id === item.id" name="chevron_right" small /> <v-icon v-if="selectedItem?.id === item.id" name="chevron_right" small />
@@ -142,8 +142,7 @@
<TransitionGroup name="thread-list"> <TransitionGroup name="thread-list">
<div v-for="reply in comments" :key="reply.id" class="reply-bubble"> <div v-for="reply in comments" :key="reply.id" class="reply-bubble">
<header class="reply-header"> <header class="reply-header">
<span class="reply-user">{{ reply.user_name || 'System' }}</span> <span class="reply-user">{{ reply.user_name }}</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> <span class="reply-date">{{ formatDate(reply.date_created || reply.id) }}</span>
</header> </header>
<div class="reply-text">{{ reply.text }}</div> <div class="reply-text">{{ reply.text }}</div>
@@ -169,12 +168,8 @@
<v-card-title>Context</v-card-title> <v-card-title>Context</v-card-title>
<v-card-text class="meta-list"> <v-card-text class="meta-list">
<div class="meta-item"> <div class="meta-item">
<label><v-icon name="business" x-small /> Organisation / Firma</label> <label><v-icon name="public" x-small /> Website</label>
<strong>{{ selectedItem.company?.name || selectedItem.project }}</strong> <strong>{{ 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>
<div class="meta-item"> <div class="meta-item">
<label><v-icon name="link" x-small /> Source Path</label> <label><v-icon name="link" x-small /> Source Path</label>
@@ -243,14 +238,13 @@ const statusOptions = [
]; ];
const projects = computed(() => { const projects = computed(() => {
const projSet = new Set(items.value.map(i => i.company?.name || i.project).filter(Boolean)); const projSet = new Set(items.value.map(i => i.project).filter(Boolean));
return Array.from(projSet).sort(); return Array.from(projSet).sort();
}); });
const filteredItems = computed(() => { const filteredItems = computed(() => {
return items.value.filter(item => { return items.value.filter(item => {
const projectName = item.company?.name || item.project; const matchProject = currentProject.value === 'all' || item.project === currentProject.value;
const matchProject = currentProject.value === 'all' || projectName === currentProject.value;
const status = item.status || 'open'; const status = item.status || 'open';
const matchStatus = currentStatusFilter.value === 'all' || status === currentStatusFilter.value; const matchStatus = currentStatusFilter.value === 'all' || status === currentStatusFilter.value;
return matchProject && matchStatus; return matchProject && matchStatus;
@@ -264,8 +258,7 @@ async function fetchData() {
const response = await api.get('/items/visual_feedback', { const response = await api.get('/items/visual_feedback', {
params: { params: {
sort: '-date_created,-id', sort: '-date_created,-id',
limit: 300, limit: 300
fields: ['*', 'company.*', 'person.*']
} }
}); });
items.value = response.data.data; items.value = response.data.data;
@@ -285,8 +278,7 @@ async function selectItem(item) {
const response = await api.get('/items/visual_feedback_comments', { const response = await api.get('/items/visual_feedback_comments', {
params: { params: {
filter: { feedback_id: { _eq: item.id } }, filter: { feedback_id: { _eq: item.id } },
sort: '-date_created,-id', sort: '-date_created,-id'
fields: ['*', 'person.*']
} }
}); });
comments.value = response.data.data; comments.value = response.data.data;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@mintel/gatekeeper", "name": "@mintel/gatekeeper",
"version": "1.8.2", "version": "1.7.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -11,8 +11,6 @@ export async function GET(req: NextRequest) {
// 1. URL Parameter Bypass (for automated tests/staging) // 1. URL Parameter Bypass (for automated tests/staging)
const originalUrl = req.headers.get("x-forwarded-uri") || "/"; const originalUrl = req.headers.get("x-forwarded-uri") || "/";
console.log(`[Verify] Check: ${originalUrl} | Cookie: ${session ? "Found" : "Missing"}`);
const host = const host =
req.headers.get("x-forwarded-host") || req.headers.get("host") || ""; req.headers.get("x-forwarded-host") || req.headers.get("host") || "";
const proto = req.headers.get("x-forwarded-proto") || "https"; const proto = req.headers.get("x-forwarded-proto") || "https";
@@ -56,17 +54,15 @@ export async function GET(req: NextRequest) {
if (session?.value) { if (session?.value) {
if (session.value === password) { if (session.value === password) {
isAuthenticated = true; isAuthenticated = true;
console.log(`[Verify] Legacy password match`);
} else { } else {
try { try {
const payload = JSON.parse(session.value); const payload = JSON.parse(session.value);
if (payload.identity) { if (payload.identity) {
isAuthenticated = true; isAuthenticated = true;
identity = payload.identity; identity = payload.identity;
console.log(`[Verify] Identity verified: ${identity}`);
} }
} catch (_e) { } catch (_e) {
console.log(`[Verify] JSON Parse failed for cookie: ${session.value.substring(0, 10)}...`); // Fallback or old format
} }
} }
} }
+10 -14
View File
@@ -17,8 +17,8 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
async function login(formData: FormData) { async function login(formData: FormData) {
"use server"; "use server";
const email = (formData.get("email") as string || "").trim(); const email = formData.get("email") as string;
const password = (formData.get("password") as string || "").trim(); const password = formData.get("password") as string;
const expectedCode = process.env.GATEKEEPER_PASSWORD || "mintel"; const expectedCode = process.env.GATEKEEPER_PASSWORD || "mintel";
const adminEmail = process.env.DIRECTUS_ADMIN_EMAIL; const adminEmail = process.env.DIRECTUS_ADMIN_EMAIL;
@@ -31,19 +31,19 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
let userIdentity = ""; let userIdentity = "";
let userCompany: any = null; let userCompany: any = null;
// 1. Check Generic Code (Guest) - High Priority to prevent autofill traps // 1. Check Global Admin (from ENV)
if (password === expectedCode) { if (
userIdentity = "Guest";
}
// 2. Check Global Admin (from ENV)
else if (
adminEmail && adminEmail &&
adminPassword && adminPassword &&
email === adminEmail.trim() && email === adminEmail &&
password === adminPassword.trim() password === adminPassword
) { ) {
userIdentity = "Admin"; userIdentity = "Admin";
} }
// 2. Check Generic Code (Guest)
else if (!email && password === expectedCode) {
userIdentity = "Guest";
}
// 3. Check Lightweight Client Users (dedicated collection) // 3. Check Lightweight Client Users (dedicated collection)
if (email && password && process.env.INFRA_DIRECTUS_URL) { if (email && password && process.env.INFRA_DIRECTUS_URL) {
try { try {
@@ -116,7 +116,6 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
} }
if (userIdentity) { if (userIdentity) {
console.log(`[Login] Success: ${userIdentity} | Redirect: ${targetRedirect}`);
const cookieStore = await cookies(); const cookieStore = await cookies();
// Store identity in the cookie (simplified for now, ideally signed) // Store identity in the cookie (simplified for now, ideally signed)
const sessionValue = JSON.stringify({ const sessionValue = JSON.stringify({
@@ -127,8 +126,6 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
const isDev = process.env.NODE_ENV === "development"; const isDev = process.env.NODE_ENV === "development";
console.log(`[Login] Setting Cookie: ${authCookieName} | Domain: ${cookieDomain || "Default"}`);
cookieStore.set(authCookieName, sessionValue, { cookieStore.set(authCookieName, sessionValue, {
httpOnly: true, httpOnly: true,
secure: !isDev, secure: !isDev,
@@ -139,7 +136,6 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
}); });
redirect(targetRedirect); redirect(targetRedirect);
} else { } else {
console.log(`[Login] Failed for inputs. Redirecting back with error.`);
redirect(`/login?error=1&redirect=${encodeURIComponent(targetRedirect)}`); redirect(`/login?error=1&redirect=${encodeURIComponent(targetRedirect)}`);
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@mintel/husky-config", "name": "@mintel/husky-config",
"version": "1.8.2", "version": "1.7.0",
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://npm.infra.mintel.me" "registry": "https://npm.infra.mintel.me"
@@ -1,5 +1,6 @@
# Start from the pre-built Nextjs Base image # Start from the pre-built Nextjs Base image
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder ARG IMAGE_TAG=latest
FROM registry.infra.mintel.me/mintel/nextjs:${IMAGE_TAG} AS builder
WORKDIR /app WORKDIR /app
@@ -20,7 +21,7 @@ ENV DIRECTUS_URL=$DIRECTUS_URL
RUN pnpm --filter ${APP_NAME:-app} build RUN pnpm --filter ${APP_NAME:-app} build
# Production runner image # Production runner image
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner FROM registry.infra.mintel.me/mintel/runtime:${IMAGE_TAG} AS runner
WORKDIR /app WORKDIR /app
# Copy standalone output and static files # Copy standalone output and static files
+35 -10
View File
@@ -1,13 +1,38 @@
# Step 1: Base image for Next.js builds # Step 1: Builder image
FROM node:20-alpine FROM node:20-alpine AS builder
RUN apk add --no-cache libc6-compat curl RUN apk add --no-cache libc6-compat curl
# Enable pnpm
RUN corepack enable pnpm && \
corepack prepare pnpm@10.2.0 --activate
WORKDIR /app WORKDIR /app
RUN corepack enable pnpm
# Final environment # Step 2: Install dependencies
ENV NODE_ENV=production ENV NPM_TOKEN=placeholder
ENV NEXT_TELEMETRY_DISABLED=1 # Copy manifest files specifically for better layer caching
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc ./
# Copy package manifest files individually to preserve directory structure
COPY packages/cli/package.json ./packages/cli/
COPY packages/cms-infra/package.json ./packages/cms-infra/
COPY packages/customer-manager/package.json ./packages/customer-manager/
COPY packages/eslint-config/package.json ./packages/eslint-config/
COPY packages/feedback-commander/package.json ./packages/feedback-commander/
COPY packages/gatekeeper/package.json ./packages/gatekeeper/
COPY packages/husky-config/package.json ./packages/husky-config/
COPY packages/infra/package.json ./packages/infra/
COPY packages/mail/package.json ./packages/mail/
COPY packages/next-config/package.json ./packages/next-config/
COPY packages/next-feedback/package.json ./packages/next-feedback/
COPY packages/next-observability/package.json ./packages/next-observability/
COPY packages/next-utils/package.json ./packages/next-utils/
COPY packages/observability/package.json ./packages/observability/
COPY packages/tsconfig/package.json ./packages/tsconfig/
# packages/ui does not have a package.json
# Use a secret for NPM_TOKEN and a standardized cache mount
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
--mount=type=secret,id=NPM_TOKEN \
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
pnpm config set store-dir /pnpm/store && \
pnpm i --frozen-lockfile
# Step 3: Build shared packages
COPY . .
RUN pnpm --filter "./packages/*" -r build
-4
View File
@@ -275,10 +275,6 @@ jobs:
docker system prune -f --filter "until=24h" docker system prune -f --filter "until=24h"
EOF EOF
- name: 🧹 Post-Deploy Cleanup (Runner)
if: always()
run: docker builder prune -f --filter "until=1h"
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# JOB 5: Notifications # JOB 5: Notifications
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@mintel/infra", "name": "@mintel/infra",
"version": "1.8.2", "version": "1.7.0",
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://npm.infra.mintel.me" "registry": "https://npm.infra.mintel.me"
+26 -28
View File
@@ -2,7 +2,7 @@
set -e set -e
# Configuration # Configuration
REGISTRY_DATA="/mnt/HC_Volume_104575103/registry-data/docker/registry/v2" REGISTRY_DATA="/opt/infra/registry/data/docker/registry/v2"
KEEP_TAGS=3 KEEP_TAGS=3
echo "🏥 Starting Aggressive Registry & Docker Maintenance..." echo "🏥 Starting Aggressive Registry & Docker Maintenance..."
@@ -15,26 +15,31 @@ for repo_dir in "$REGISTRY_DATA/repositories/mintel/"*; do
if [ -d "$tags_dir" ]; then if [ -d "$tags_dir" ]; then
echo "🔍 Processing repository: mintel/$repo_name" echo "🔍 Processing repository: mintel/$repo_name"
# Prune various tag patterns # Prune main-* tags
PATTERNS=("main-*" "testing-*" "branch-*" "v*" "rc*" "[0-9a-f]*") echo " 📦 Pruning main tags..."
main_tags=$(ls -dt "$tags_dir"/main-* 2>/dev/null || true)
for pattern in "${PATTERNS[@]}"; do count=0
echo " 📦 Pruning $pattern tags..." for tag_path in $main_tags; do
tags=$(ls -dt "$tags_dir"/${pattern} 2>/dev/null || true) ((++count))
count=0 if [ $count -gt $KEEP_TAGS ]; then
for tag_path in $tags; do echo " 🗑️ Deleting old main tag: $(basename "$tag_path")"
tag_name=$(basename "$tag_path") rm -rf "$tag_path"
if [[ "$tag_name" == "latest" ]]; then continue; fi fi
((++count))
if [ $count -gt $KEEP_TAGS ]; then
echo " 🗑️ Deleting old tag: $tag_name"
rm -rf "$tag_path"
fi
done
done done
# Always prune buildcache # Prune version tags (v* and rc*)
echo " 🏷️ Pruning version tags..."
version_tags=$(ls -dt "$tags_dir"/v1* 2>/dev/null || true)
count=0
for tag_path in $version_tags; do
((++count))
if [ $count -gt $KEEP_TAGS ]; then
echo " 🗑️ Deleting old version tag: $(basename "$tag_path")"
rm -rf "$tag_path"
fi
done
# Always prune buildcache (as it rebuilds quickly)
if [ -d "$tags_dir/buildcache" ]; then if [ -d "$tags_dir/buildcache" ]; then
echo " 🧹 Deleting buildcache tag" echo " 🧹 Deleting buildcache tag"
rm -rf "$tags_dir/buildcache" rm -rf "$tags_dir/buildcache"
@@ -43,15 +48,8 @@ for repo_dir in "$REGISTRY_DATA/repositories/mintel/"*; do
done done
# 2. Run Garbage Collection # 2. Run Garbage Collection
echo "♻️ Detecting Registry Container..." echo "♻️ Running Registry Garbage Collection..."
REGISTRY_CONTAINER=$(docker ps --format "{{.Names}}" | grep registry | head -1 || true) docker exec registry-registry-1 bin/registry garbage-collect /etc/docker/registry/config.yml
if [ -n "$REGISTRY_CONTAINER" ]; then
echo "♻️ Running Registry Garbage Collection on $REGISTRY_CONTAINER..."
docker exec "$REGISTRY_CONTAINER" bin/registry garbage-collect /etc/docker/registry/config.yml --delete-untagged
else
echo "⚠️ Registry container not found. Skipping GC."
fi
# 3. Prune Host Docker resources (Shorter window: 24h) # 3. Prune Host Docker resources (Shorter window: 24h)
echo "🧹 Pruning Host Docker resources..." echo "🧹 Pruning Host Docker resources..."
@@ -1,93 +0,0 @@
#!/bin/bash
set -e
# wait-for-upstream.sh
# Usage: ./wait-for-upstream.sh <org/repo> <version_tag> [poll_interval_sec]
REPO=$1
TAG=$2
INTERVAL=${3:-30}
MAX_RETRIES=40 # ~20 minutes default
if [[ -z "$REPO" || -z "$TAG" ]]; then
echo "❌ Error: REPO and TAG are required."
echo "Usage: $0 <org/repo> <version_tag>"
exit 1
fi
if [[ -z "$GITEA_TOKEN" ]]; then
echo "❌ Error: GITEA_TOKEN is not set."
exit 1
fi
GITEA_API="https://git.infra.mintel.me/api/v1"
echo "🔎 Searching for upstream release $TAG in $REPO..."
# 1. Get the SHA of the tag to be more precise
TAG_INFO=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$GITEA_API/repos/$REPO/tags/$TAG")
TARGET_SHA=$(echo "$TAG_INFO" | jq -r '.commit.sha // empty')
if [[ -z "$TARGET_SHA" || "$TARGET_SHA" == "null" ]]; then
echo "⚠️ Warning: Tag $TAG not found yet. Upstream might be lagging."
echo " Waiting 15s for tag to appear..."
sleep 15
TAG_INFO=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$GITEA_API/repos/$REPO/tags/$TAG")
TARGET_SHA=$(echo "$TAG_INFO" | jq -r '.commit.sha // empty')
if [[ -z "$TARGET_SHA" || "$TARGET_SHA" == "null" ]]; then
echo "❌ Error: Tag $TAG does not exist in $REPO."
exit 1
fi
fi
echo "✅ Target SHA for $TAG is $TARGET_SHA"
# 2. Find the run for the specific SHA
# We list recent runs and filter by head_sha
RUN_QUERY=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$GITEA_API/repos/$REPO/actions/runs?limit=30")
RUN_ID=$(echo "$RUN_QUERY" | jq -r ".workflow_runs[] | select(.head_sha == \"$TARGET_SHA\") | .id" | head -n 1)
if [[ -z "$RUN_ID" || "$RUN_ID" == "null" ]]; then
echo "ℹ️ No recent action run found for SHA $TARGET_SHA yet."
echo " Checking if we should wait or if it was already successful..."
# Fallback: wait a bit more for new tags
echo "⏳ waiting for run to appear..."
sleep 20
RUN_QUERY=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$GITEA_API/repos/$REPO/actions/runs?limit=30")
RUN_ID=$(echo "$RUN_QUERY" | jq -r ".workflow_runs[] | select(.head_sha == \"$TARGET_SHA\") | .id" | head -n 1)
if [[ -z "$RUN_ID" || "$RUN_ID" == "null" ]]; then
echo "✅ No run found but Tag exists. Assuming manual release or already completed. Proceeding."
exit 0
fi
fi
echo "⏳ Waiting for upstream run $RUN_ID status..."
RETRY_COUNT=0
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
STATUS_QUERY=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$GITEA_API/repos/$REPO/actions/runs/$RUN_ID")
STATUS=$(echo "$STATUS_QUERY" | jq -r '.status')
CONCLUSION=$(echo "$STATUS_QUERY" | jq -r '.conclusion')
echo " - Current Status: $STATUS (Conclusion: $CONCLUSION)"
if [[ "$STATUS" == "success" || "$CONCLUSION" == "success" ]]; then
echo "✅ Upstream release $TAG is READY."
exit 0
fi
if [[ "$STATUS" == "failure" || "$CONCLUSION" == "failure" || "$CONCLUSION" == "cancelled" ]]; then
echo "❌ Error: Upstream release $TAG FAILED or was CANCELLED."
exit 1
fi
echo " - Still working... waiting $INTERVAL seconds (Attempt $((RETRY_COUNT+1))/$MAX_RETRIES)"
sleep $INTERVAL
RETRY_COUNT=$((RETRY_COUNT+1))
done
echo "❌ Error: Timeout waiting for upstream release $TAG."
exit 1
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@mintel/mail", "name": "@mintel/mail",
"version": "1.8.2", "version": "1.7.0",
"private": false, "private": false,
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
@@ -24,7 +24,8 @@
"build": "tsup src/index.ts src/templates/*.tsx --format esm --dts --clean", "build": "tsup src/index.ts src/templates/*.tsx --format esm --dts --clean",
"dev": "tsup src/index.ts src/templates/*.tsx --format esm --watch --dts", "dev": "tsup src/index.ts src/templates/*.tsx --format esm --watch --dts",
"lint": "eslint src", "lint": "eslint src",
"test": "vitest run" "test": "vitest run",
"typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@react-email/components": "^0.0.33" "@react-email/components": "^0.0.33"
+4 -24
View File
@@ -1,27 +1,7 @@
import { render as reactEmailRender } from "@react-email/components";
import { ReactElement } from "react";
/**
* Renders a React email template to HTML.
*/
export async function render(
template: ReactElement,
options?: any,
): Promise<string> {
return reactEmailRender(template, options);
}
// Export Components
export * from "./components/MintelLogo";
// Export Layouts
export * from "./layouts/BaseLayout";
export * from "./layouts/MintelLayout";
export * from "./layouts/ClientLayout";
// Export Templates
export * from "./templates/ContactFormNotification";
export * from "./templates/ConfirmationMessage"; export * from "./templates/ConfirmationMessage";
export * from "./templates/ContactFormNotification";
export * from "./templates/SiteAuditTemplate";
export * from "./templates/FollowUpTemplate"; export * from "./templates/FollowUpTemplate";
export * from "./templates/ProjectEstimateTemplate"; export * from "./templates/ProjectEstimateTemplate";
export * from "./templates/SiteAuditTemplate"; export * from "./layouts/MintelLayout";
export { render } from "@react-email/components";
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@mintel/next-config", "name": "@mintel/next-config",
"version": "1.8.2", "version": "1.7.0",
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://npm.infra.mintel.me" "registry": "https://npm.infra.mintel.me"

Some files were not shown because too many files have changed in this diff Show More