Compare commits
60 Commits
4a5017124a
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 70de139cb0 | |||
| b015c62650 | |||
| b7dac5d463 | |||
| 10bdfdfe97 | |||
| 9ad63a0a82 | |||
| eb117cc0b8 | |||
| 23ee915194 | |||
| 3dff891023 | |||
| f55c27c43d | |||
| 3e04427646 | |||
| 6b51d63c8b | |||
| 60ca4ad656 | |||
| aae5275990 | |||
| b639fffe7f | |||
| ab15f7f35b | |||
| 025906889c | |||
| 760a6d6db3 | |||
| 7f8cea4728 | |||
| fb09b1de9a | |||
| cb4afe2e91 | |||
| 1f68234a49 | |||
| e2d68c2828 | |||
| cb6f133e0c | |||
| 7990189505 | |||
| 2167044543 | |||
| 0665e3e224 | |||
| 2bdcbfb907 | |||
| ac1e0081f7 | |||
| 4f452cf2a9 | |||
| 1404aa0406 | |||
| 9e10ce06ed | |||
| a400e6f94d | |||
| 2f95c8d968 | |||
| 9aa6f5f4d0 | |||
| 071302fe6b | |||
| cf3a96cead | |||
| af5f91e6f8 | |||
| 5e453418d6 | |||
| 10980ba8b3 | |||
| 6444aea5f6 | |||
| ad50929bf3 | |||
| 07928a182f | |||
| b493ce0ba0 | |||
| db445d0b76 | |||
| 22a6a06a4e | |||
| 4f66dd914c | |||
| bb54750085 | |||
| 5cbbd81384 | |||
| c167e36626 | |||
| 0fb872161d | |||
| a360ea6a98 | |||
| a537294832 | |||
| 459bdc6eda | |||
| 905ce98bc4 | |||
| ce63a1ac69 | |||
| 6444cf1e81 | |||
| 4b5609a75e | |||
| 8907963d57 | |||
| 844a5f5412 | |||
| 96de68a063 |
@@ -3,7 +3,7 @@ name: Build & Deploy
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "**"
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
@@ -13,6 +13,9 @@ on:
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: "true"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ (github.ref_type == 'tag' && !contains(github.ref_name, '-')) && 'prod' || (github.ref_name == 'main' && 'testing' || github.ref_name) }}
|
||||
cancel-in-progress: true
|
||||
@@ -76,7 +79,11 @@ jobs:
|
||||
TRAEFIK_HOST="staging.${DOMAIN}"
|
||||
fi
|
||||
else
|
||||
TARGET="skip"
|
||||
TARGET="branch"
|
||||
SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
|
||||
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
|
||||
ENV_FILE=".env.branch-${SLUG}"
|
||||
TRAEFIK_HOST="${SLUG}.branch.${DOMAIN}"
|
||||
fi
|
||||
|
||||
if [[ "$TARGET" != "skip" ]]; then
|
||||
@@ -97,20 +104,22 @@ jobs:
|
||||
echo "traefik_rule=$TRAEFIK_RULE"
|
||||
echo "next_public_url=https://$PRIMARY_HOST"
|
||||
echo "directus_url=https://cms.$PRIMARY_HOST"
|
||||
echo "project_name=$PRJ-$TARGET"
|
||||
if [[ "$TARGET" == "branch" ]]; then
|
||||
echo "project_name=$PRJ-branch-$SLUG"
|
||||
else
|
||||
echo "project_name=$PRJ-$TARGET"
|
||||
fi
|
||||
echo "short_sha=$SHORT_SHA"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ⏳ Wait for Upstream Packages/Images if Tagged
|
||||
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||
echo "🔎 Checking for @mintel dependencies in package.json..."
|
||||
# Extract any @mintel/ version (they should be synced in monorepo)
|
||||
UPSTREAM_VERSION=$(grep -o '"@mintel/.*": "[^"]*"' package.json | head -1 | cut -d'"' -f4 | sed 's/\^//; s/\~//')
|
||||
TAG_TO_WAIT="v$UPSTREAM_VERSION"
|
||||
|
||||
if [[ -n "$UPSTREAM_VERSION" && "$UPSTREAM_VERSION" != "workspace:"* ]]; then
|
||||
echo "⏳ This release depends on @mintel v$UPSTREAM_VERSION. Waiting for upstream build..."
|
||||
# Fetch script from monorepo (main)
|
||||
curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://git.infra.mintel.me/mmintel/at-mintel/raw/branch/main/packages/infra/scripts/wait-for-upstream.sh" > wait-for-upstream.sh
|
||||
chmod +x wait-for-upstream.sh
|
||||
@@ -123,7 +132,7 @@ jobs:
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 2: QA (Lint, Build Test)
|
||||
# JOB 2: QA (Lint, Typecheck, Test)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
qa:
|
||||
name: 🧪 QA
|
||||
@@ -143,28 +152,42 @@ jobs:
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: Provide sibling monorepo
|
||||
run: |
|
||||
git clone https://git.infra.mintel.me/mmintel/at-mintel.git _at-mintel
|
||||
sed -i 's|../../../at-mintel|../../_at-mintel|g' apps/web/package.json
|
||||
sed -i 's|../../../at-mintel|../../_at-mintel|g' apps/web/tsconfig.json
|
||||
sed -i 's|../at-mintel|./_at-mintel|g' package.json
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: 🧪 QA Checks
|
||||
if: github.event.inputs.skip_checks != 'true'
|
||||
- name: 🏗️ Compile Sibling Monorepo
|
||||
run: |
|
||||
pnpm lint
|
||||
pnpm --filter "@mintel/web" exec tsc --noEmit
|
||||
pnpm --filter "@mintel/web" test
|
||||
- name: 🏗️ Build Test
|
||||
cp .npmrc _at-mintel/
|
||||
cd _at-mintel
|
||||
pnpm install --no-frozen-lockfile
|
||||
pnpm build
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
- name: 🧹 Lint
|
||||
if: github.event.inputs.skip_checks != 'true'
|
||||
run: pnpm build
|
||||
run: pnpm --filter @mintel/web lint --max-warnings 999
|
||||
- name: 🔍 Typecheck
|
||||
if: github.event.inputs.skip_checks != 'true'
|
||||
run: pnpm --filter @mintel/web typecheck
|
||||
- name: 🧪 Test
|
||||
if: github.event.inputs.skip_checks != 'true'
|
||||
run: pnpm --filter @mintel/web test
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 3: Build & Push
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
build:
|
||||
name: 🏗️ Build
|
||||
needs: prepare
|
||||
needs: [prepare, qa]
|
||||
if: needs.prepare.outputs.target != 'skip'
|
||||
runs-on: docker
|
||||
container:
|
||||
@@ -172,6 +195,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Provide sibling monorepo (context)
|
||||
run: git clone https://git.infra.mintel.me/mmintel/at-mintel.git _at-mintel
|
||||
- name: 🐳 Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: 🔐 Registry Login
|
||||
@@ -181,7 +206,8 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/arm64
|
||||
provenance: false
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
||||
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||
@@ -214,10 +240,10 @@ jobs:
|
||||
postgres_DB_NAME: ${{ secrets.DIRECTUS_DB_NAME || vars.DIRECTUS_DB_NAME || 'directus' }}
|
||||
postgres_DB_USER: ${{ secrets.DIRECTUS_DB_USER || vars.DIRECTUS_DB_USER || 'directus' }}
|
||||
postgres_DB_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_DB_PASSWORD) || secrets.DIRECTUS_DB_PASSWORD || vars.DIRECTUS_DB_PASSWORD || 'directus' }}
|
||||
DATABASE_URI: postgres://${{ env.postgres_DB_USER }}:${{ env.postgres_DB_PASSWORD }}@postgres-db:5432/${{ env.postgres_DB_NAME }}
|
||||
DATABASE_URI: postgres://${{ secrets.DIRECTUS_DB_USER || vars.DIRECTUS_DB_USER || 'directus' }}:${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_DB_PASSWORD) || secrets.DIRECTUS_DB_PASSWORD || vars.DIRECTUS_DB_PASSWORD || 'directus' }}@postgres-db:5432/${{ secrets.DIRECTUS_DB_NAME || vars.DIRECTUS_DB_NAME || 'directus' }}
|
||||
PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET || 'secret' }}
|
||||
|
||||
# Secrets mapping (Mail)
|
||||
# Mail
|
||||
MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
|
||||
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
|
||||
MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }}
|
||||
@@ -235,6 +261,14 @@ jobs:
|
||||
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID || vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
|
||||
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||
PROJECT_COLOR: ${{ secrets.PROJECT_COLOR || vars.PROJECT_COLOR || '#ff00ff' }}
|
||||
|
||||
# S3 Object Storage
|
||||
S3_ENDPOINT: ${{ secrets.S3_ENDPOINT || vars.S3_ENDPOINT || 'https://fsn1.your-objectstorage.com' }}
|
||||
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY || vars.S3_ACCESS_KEY }}
|
||||
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY || vars.S3_SECRET_KEY }}
|
||||
S3_BUCKET: ${{ secrets.S3_BUCKET || vars.S3_BUCKET || 'mintel' }}
|
||||
S3_REGION: ${{ secrets.S3_REGION || vars.S3_REGION || 'fsn1' }}
|
||||
S3_PREFIX: ${{ secrets.S3_PREFIX || vars.S3_PREFIX || github.event.repository.name }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -246,7 +280,6 @@ jobs:
|
||||
GATEKEEPER_HOST: gatekeeper.${{ needs.prepare.outputs.traefik_host }}
|
||||
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||
run: |
|
||||
# Middleware & Auth Logic
|
||||
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
|
||||
STD_MW="${PROJECT_NAME}-forward,compress"
|
||||
|
||||
@@ -254,15 +287,16 @@ jobs:
|
||||
AUTH_MIDDLEWARE="$STD_MW"
|
||||
COMPOSE_PROFILES=""
|
||||
else
|
||||
# Order: Forward (Proto) -> Auth -> Compression
|
||||
AUTH_MIDDLEWARE="${PROJECT_NAME}-forward,${PROJECT_NAME}-auth,compress"
|
||||
COMPOSE_PROFILES="gatekeeper"
|
||||
fi
|
||||
|
||||
# Gatekeeper Origin
|
||||
GATEKEEPER_ORIGIN="$NEXT_PUBLIC_BASE_URL/gatekeeper"
|
||||
|
||||
# Generate Environment File
|
||||
if [[ "$UMAMI_API_ENDPOINT" != http* ]]; then
|
||||
UMAMI_API_ENDPOINT="https://$UMAMI_API_ENDPOINT"
|
||||
fi
|
||||
|
||||
cat > .env.deploy << EOF
|
||||
# Generated by CI - $TARGET
|
||||
IMAGE_TAG=$IMAGE_TAG
|
||||
@@ -271,32 +305,29 @@ jobs:
|
||||
SENTRY_DSN=$SENTRY_DSN
|
||||
PROJECT_COLOR=$PROJECT_COLOR
|
||||
LOG_LEVEL=$LOG_LEVEL
|
||||
|
||||
# Payload DB
|
||||
postgres_DB_NAME=$postgres_DB_NAME
|
||||
postgres_DB_USER=$postgres_DB_USER
|
||||
postgres_DB_PASSWORD=$postgres_DB_PASSWORD
|
||||
DATABASE_URI=$DATABASE_URI
|
||||
PAYLOAD_SECRET=$PAYLOAD_SECRET
|
||||
|
||||
# Mail
|
||||
MAIL_HOST=$MAIL_HOST
|
||||
MAIL_PORT=$MAIL_PORT
|
||||
MAIL_USERNAME=$MAIL_USERNAME
|
||||
MAIL_PASSWORD=$MAIL_PASSWORD
|
||||
MAIL_FROM=$MAIL_FROM
|
||||
MAIL_RECIPIENTS=$MAIL_RECIPIENTS
|
||||
|
||||
# Authentication
|
||||
GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD
|
||||
AUTH_COOKIE_NAME=$AUTH_COOKIE_NAME
|
||||
COOKIE_DOMAIN=$COOKIE_DOMAIN
|
||||
|
||||
# Analytics
|
||||
UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
|
||||
UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||
|
||||
S3_ENDPOINT=$S3_ENDPOINT
|
||||
S3_ACCESS_KEY=$S3_ACCESS_KEY
|
||||
S3_SECRET_KEY=$S3_SECRET_KEY
|
||||
S3_BUCKET=$S3_BUCKET
|
||||
S3_REGION=$S3_REGION
|
||||
S3_PREFIX=$S3_PREFIX
|
||||
TARGET=$TARGET
|
||||
SENTRY_ENVIRONMENT=$TARGET
|
||||
PROJECT_NAME=$PROJECT_NAME
|
||||
@@ -305,6 +336,9 @@ jobs:
|
||||
TRAEFIK_HOST='$TRAEFIK_HOST'
|
||||
COMPOSE_PROFILES=$COMPOSE_PROFILES
|
||||
TRAEFIK_MIDDLEWARES=$AUTH_MIDDLEWARE
|
||||
TRAEFIK_ENTRYPOINT=websecure
|
||||
TRAEFIK_TLS=true
|
||||
TRAEFIK_CERT_RESOLVER=le
|
||||
EOF
|
||||
|
||||
- name: 🚀 SSH Deploy
|
||||
@@ -317,57 +351,132 @@ jobs:
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
# Transfer and Restart
|
||||
SITE_DIR="/home/deploy/sites/mintel.me"
|
||||
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR/directus/schema $SITE_DIR/directus/uploads $SITE_DIR/directus/extensions"
|
||||
# SSH keepalive to prevent timeout during long docker pull
|
||||
cat > ~/.ssh/config <<SSHCFG
|
||||
Host alpha.mintel.me
|
||||
ServerAliveInterval 15
|
||||
ServerAliveCountMax 20
|
||||
ConnectTimeout 30
|
||||
SSHCFG
|
||||
chmod 600 ~/.ssh/config
|
||||
|
||||
if [[ "$TARGET" == "production" ]]; then
|
||||
SITE_DIR="/home/deploy/sites/mintel.me"
|
||||
elif [[ "$TARGET" == "testing" ]]; then
|
||||
SITE_DIR="/home/deploy/sites/testing.mintel.me"
|
||||
elif [[ "$TARGET" == "staging" ]]; then
|
||||
SITE_DIR="/home/deploy/sites/staging.mintel.me"
|
||||
else
|
||||
SITE_DIR="/home/deploy/sites/branch.mintel.me/${SLUG:-unknown}"
|
||||
fi
|
||||
|
||||
# Upload files
|
||||
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR/directus/schema $SITE_DIR/directus/uploads $SITE_DIR/directus/extensions"
|
||||
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
|
||||
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
|
||||
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && echo '${{ secrets.REGISTRY_PASS }}' | docker login registry.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin"
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' pull"
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans"
|
||||
|
||||
|
||||
|
||||
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
|
||||
# Deploy
|
||||
DB_CONTAINER="${{ needs.prepare.outputs.project_name }}-postgres-db-1"
|
||||
ssh root@alpha.mintel.me bash <<DEPLOYEOF
|
||||
set -e
|
||||
docker network create '${{ needs.prepare.outputs.project_name }}-internal' || true
|
||||
docker volume create 'mintel-me_payload-db-data' || true
|
||||
echo '${{ secrets.REGISTRY_PASS }}' | docker login registry.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin
|
||||
cd $SITE_DIR
|
||||
docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' pull
|
||||
docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans
|
||||
DEPLOYEOF
|
||||
|
||||
- name: 🧹 Post-Deploy Cleanup (Runner)
|
||||
if: always()
|
||||
run: docker builder prune -f --filter "until=1h"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 5: Health Check
|
||||
# JOB 5: Post-Deploy Verification
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
healthcheck:
|
||||
name: 🩺 Health Check
|
||||
post_deploy_checks:
|
||||
name: 🧪 Post-Deploy Verification
|
||||
needs: [prepare, deploy]
|
||||
if: needs.deploy.result == 'success'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: 🔍 Smoke Test
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: Provide sibling monorepo
|
||||
run: |
|
||||
URL="${{ needs.prepare.outputs.next_public_url }}"
|
||||
echo "Checking health of $URL..."
|
||||
for i in {1..12}; do
|
||||
if curl -s -f "$URL" > /dev/null; then
|
||||
echo "✅ Health check passed!"
|
||||
git clone https://git.infra.mintel.me/mmintel/at-mintel.git _at-mintel
|
||||
sed -i 's|../../../at-mintel|../../_at-mintel|g' apps/web/package.json
|
||||
sed -i 's|../../../at-mintel|../../_at-mintel|g' apps/web/tsconfig.json
|
||||
sed -i 's|../at-mintel|./_at-mintel|g' package.json
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install --no-frozen-lockfile
|
||||
- name: 🏥 App Health Check
|
||||
shell: bash
|
||||
env:
|
||||
DEPLOY_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
run: |
|
||||
echo "Waiting for app to start at $DEPLOY_URL ..."
|
||||
for i in {1..30}; do
|
||||
HTTP_CODE=$(curl -sk -o /dev/null -w '%{http_code}' "$DEPLOY_URL" 2>&1) || true
|
||||
echo "Attempt $i: HTTP $HTTP_CODE"
|
||||
if [[ "$HTTP_CODE" =~ ^2 ]]; then
|
||||
echo "✅ App is up (HTTP $HTTP_CODE)"
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting for service to be ready... ($i/12)"
|
||||
echo "⏳ Waiting... (got $HTTP_CODE)"
|
||||
sleep 10
|
||||
done
|
||||
echo "❌ Health check failed after 2 minutes."
|
||||
echo "❌ App health check failed after 30 attempts"
|
||||
exit 1
|
||||
- name: 🚀 OG Image Check
|
||||
env:
|
||||
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
run: pnpm --filter @mintel/web check:og
|
||||
- name: 📝 E2E Smoke Test
|
||||
env:
|
||||
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
PUPPETEER_SKIP_DOWNLOAD: "true"
|
||||
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
|
||||
run: |
|
||||
# Install system Chromium + dependencies (KLZ pattern)
|
||||
# Ubuntu's default 'chromium' is a snap wrapper, so we use xtradeb PPA for native binary
|
||||
sudo apt-get update && sudo apt-get install -y gnupg wget ca-certificates
|
||||
|
||||
# Setup xtradeb PPA for native chromium
|
||||
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x82BB6851C64F6880" | sudo gpg --dearmor -o /etc/apt/keyrings/xtradeb.gpg || true
|
||||
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" | sudo tee /etc/apt/sources.list.d/xtradeb-ppa.list
|
||||
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" | sudo tee /etc/apt/preferences.d/xtradeb
|
||||
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --allow-downgrades chromium libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libgbm1 libasound2t64
|
||||
|
||||
[ -f /usr/bin/chromium ] && sudo ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
||||
pnpm --filter @mintel/web check:forms
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 6: Notifications
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
notifications:
|
||||
name: 🔔 Notify
|
||||
needs: [prepare, deploy, healthcheck]
|
||||
needs: [prepare, deploy, post_deploy_checks]
|
||||
if: always()
|
||||
runs-on: docker
|
||||
container:
|
||||
@@ -375,11 +484,20 @@ jobs:
|
||||
steps:
|
||||
- name: 🔔 Gotify
|
||||
run: |
|
||||
STATUS="${{ needs.deploy.result }}"
|
||||
TITLE="mintel.me: $STATUS"
|
||||
[[ "$STATUS" == "success" ]] && PRIORITY=5 || PRIORITY=8
|
||||
DEPLOY="${{ needs.deploy.result }}"
|
||||
SMOKE="${{ needs.post_deploy_checks.result }}"
|
||||
TARGET="${{ needs.prepare.outputs.target }}"
|
||||
VERSION="${{ needs.prepare.outputs.image_tag }}"
|
||||
|
||||
if [[ "$DEPLOY" == "success" && "$SMOKE" == "success" ]]; then
|
||||
PRIORITY=5
|
||||
EMOJI="✅"
|
||||
else
|
||||
PRIORITY=8
|
||||
EMOJI="🚨"
|
||||
fi
|
||||
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=$TITLE" \
|
||||
-F "message=Deploy to ${{ needs.prepare.outputs.target }} finished with status $STATUS.\nVersion: ${{ needs.prepare.outputs.image_tag }}" \
|
||||
-F "title=$EMOJI mintel.me $VERSION -> $TARGET" \
|
||||
-F "message=Deploy: $DEPLOY | Smoke: $SMOKE" \
|
||||
-F "priority=$PRIORITY" || true
|
||||
|
||||
9
.gitignore
vendored
@@ -47,3 +47,12 @@ pnpm-debug.log*
|
||||
.cache/
|
||||
cloned-websites/
|
||||
storage/
|
||||
|
||||
# Estimation Engine Data
|
||||
data/crawls/
|
||||
apps/web/out/estimations/
|
||||
|
||||
# Backups
|
||||
backups/
|
||||
|
||||
.turbo
|
||||
5
.npmrc
@@ -1,3 +1,2 @@
|
||||
@mintel:registry=https://npm.infra.mintel.me/
|
||||
//npm.infra.mintel.me/:_authToken=${NPM_TOKEN}
|
||||
always-auth=true
|
||||
@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/
|
||||
//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=263e7f75d8ada27f3a2e71fd6bd9d95298d48a4d
|
||||
|
||||
1
.turbo/cache/41a721a9104bd76c-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{ "hash": "41a721a9104bd76c", "duration": 2524 }
|
||||
BIN
.turbo/cache/41a721a9104bd76c.tar.zst
vendored
Normal file
1
.turbo/cache/441277b34176cf11-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{ "hash": "441277b34176cf11", "duration": 2934 }
|
||||
BIN
.turbo/cache/441277b34176cf11.tar.zst
vendored
Normal file
1
.turbo/cache/708dc951079154e6-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{ "hash": "708dc951079154e6", "duration": 194 }
|
||||
BIN
.turbo/cache/708dc951079154e6.tar.zst
vendored
Normal file
1
.turbo/cache/84b66091bfb55705-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{ "hash": "84b66091bfb55705", "duration": 2417 }
|
||||
BIN
.turbo/cache/84b66091bfb55705.tar.zst
vendored
Normal file
1
.turbo/cache/ba4a4a0aae882f7f-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{ "hash": "ba4a4a0aae882f7f", "duration": 5009 }
|
||||
BIN
.turbo/cache/ba4a4a0aae882f7f.tar.zst
vendored
Normal file
13
Dockerfile
@@ -18,20 +18,25 @@ ENV CI=true
|
||||
# Copy manifest files specifically for better layer caching
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc* ./
|
||||
COPY apps/web/package.json ./apps/web/package.json
|
||||
# Copy sibling monorepo for linked dependencies (cloned during CI)
|
||||
COPY _at-mintel* /at-mintel/
|
||||
|
||||
# Install dependencies with cache mount and dynamic .npmrc (High Fidelity pattern)
|
||||
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) && \
|
||||
echo "@mintel:registry=https://npm.infra.mintel.me" > .npmrc && \
|
||||
echo "//npm.infra.mintel.me/:_authToken=\${NPM_TOKEN}" >> .npmrc && \
|
||||
pnpm install --frozen-lockfile && \
|
||||
rm .npmrc
|
||||
echo "@mintel:registry=https://npm.infra.mintel.me" > /at-mintel/.npmrc && \
|
||||
echo "//npm.infra.mintel.me/:_authToken=\${NPM_TOKEN}" >> /at-mintel/.npmrc && \
|
||||
cp /at-mintel/.npmrc .npmrc && \
|
||||
cd /at-mintel && pnpm install --no-frozen-lockfile && pnpm build && \
|
||||
cd /app && pnpm install --no-frozen-lockfile && \
|
||||
rm /at-mintel/.npmrc .npmrc
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build application (monorepo filter)
|
||||
ENV NODE_OPTIONS="--max_old_space_size=4096"
|
||||
RUN pnpm --filter @mintel/web build
|
||||
|
||||
# Stage 2: Runner
|
||||
|
||||
334
apps/web/.turbo/turbo-lint.log
Normal file
@@ -0,0 +1,334 @@
|
||||
|
||||
> @mintel/web@0.1.0 lint /Users/marcmintel/Projects/mintel.me/apps/web
|
||||
> eslint app src scripts video
|
||||
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/app/(site)/about/page.tsx
|
||||
3:8 warning 'Image' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
9:3 warning 'ResultIllustration' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
11:3 warning 'HeroLines' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
12:3 warning 'ParticleNetwork' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
13:3 warning 'GridLines' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
16:10 warning 'Check' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
31:3 warning 'CodeSnippet' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
32:3 warning 'AbstractCircuit' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
53:21 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/app/(site)/case-studies/klz-cables/page.tsx
|
||||
8:3 warning 'H1' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/app/(site)/not-found.tsx
|
||||
6:8 warning 'Link' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/app/(site)/page.tsx
|
||||
18:3 warning 'MonoLabel' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
21:16 warning 'Container' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
23:24 warning 'CodeSnippet' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
24:10 warning 'IconList' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
24:20 warning 'IconListItem' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/app/(site)/technologies/[slug]/data.tsx
|
||||
1:24 warning 'Database' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/scripts/ai-estimate.ts
|
||||
8:10 warning 'fileURLToPath' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/scripts/check-og-images.ts
|
||||
19:11 warning 'body' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/scripts/generate-thumbnail.ts
|
||||
28:18 warning 'e' is defined but never used. Allowed unused caught errors must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/scripts/migrate-posts.ts
|
||||
107:18 warning 'e' is defined but never used. Allowed unused caught errors must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/scripts/pagespeed-sitemap.ts
|
||||
109:14 warning 'err' is defined but never used. Allowed unused caught errors must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ArticleMeme.tsx
|
||||
110:21 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ArticleQuote.tsx
|
||||
20:5 warning 'role' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/BlogOGImageTemplate.tsx
|
||||
41:17 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/CombinedQuotePDF.tsx
|
||||
30:9 warning 'date' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ComponentShareButton.tsx
|
||||
126:30 warning 'e' is defined but never used. Allowed unused caught errors must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ContactForm/Configurator/ConfiguratorLayout.tsx
|
||||
24:3 warning 'title' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ContactForm/Configurator/ReferenceInput.tsx
|
||||
7:10 warning 'cn' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ContactForm/DirectMessageFlow.tsx
|
||||
3:10 warning 'motion' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ContactForm/EmailTemplates.tsx
|
||||
1:13 warning 'React' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ContactForm/pdf/LocalEstimationPDF.tsx
|
||||
94:9 warning 'getPageNum' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ContactForm/steps/BaseStep.tsx
|
||||
13:3 warning 'HelpCircle' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
14:3 warning 'ArrowRight' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ContactForm/steps/ContentStep.tsx
|
||||
103:25 warning 'index' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ContactForm/steps/DesignStep.tsx
|
||||
7:19 warning 'Palette' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
104:38 warning 'index' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ContactForm/steps/FeaturesStep.tsx
|
||||
8:18 warning 'AnimatePresence' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
9:10 warning 'Minus' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
9:17 warning 'Plus' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ContactForm/steps/FunctionsStep.tsx
|
||||
7:18 warning 'AnimatePresence' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
8:10 warning 'Minus' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
8:17 warning 'Plus' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ContactForm/steps/LanguageStep.tsx
|
||||
5:23 warning 'Plus' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
125:31 warning 'i' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ContactForm/steps/PresenceStep.tsx
|
||||
5:10 warning 'Checkbox' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/DiagramShareButton.tsx
|
||||
28:9 warning 'generateDiagramImage' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/DiagramState.tsx
|
||||
25:3 warning 'states' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/Effects/CMSVisualizer.tsx
|
||||
8:3 warning 'Edit3' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/Effects/CircuitBoard.tsx
|
||||
120:9 warning 'drawTrace' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
130:13 warning 'midX' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
131:13 warning 'midY' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/FAQSection.tsx
|
||||
5:10 warning 'Paragraph' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
7:11 warning 'FAQItem' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/FileExample.tsx
|
||||
3:27 warning 'useRef' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/IframeSection.tsx
|
||||
207:18 warning Empty block statement no-empty
|
||||
252:18 warning Empty block statement no-empty
|
||||
545:30 warning 'e' is defined but never used. Allowed unused caught errors must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ImageText.tsx
|
||||
25:17 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/MediumCard.tsx
|
||||
3:10 warning 'Card' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
34:13 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/Mermaid.tsx
|
||||
248:18 warning 'err' is defined but never used. Allowed unused caught errors must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/PayloadRichText.tsx
|
||||
177:31 warning 'node' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
180:26 warning 'node' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
181:34 warning 'node' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
186:27 warning 'node' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
191:29 warning 'node' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
196:32 warning 'node' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ShareModal.tsx
|
||||
7:8 warning 'IconBlack' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
181:23 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
231:21 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
258:13 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/blog/BlogClient.tsx
|
||||
27:11 warning 'trackEvent' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/blog/BlogPostHeader.tsx
|
||||
54:17 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/migrations/20260227_171023_crm_collections.ts
|
||||
3:32 warning 'payload' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
3:41 warning 'req' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
360:3 warning 'payload' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
361:3 warning 'req' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/migrations/20260301_151838.ts
|
||||
3:32 warning 'payload' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
3:41 warning 'req' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
110:3 warning 'payload' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
111:3 warning 'req' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/actions/generateField.ts
|
||||
3:10 warning 'config' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/actions/optimizePost.ts
|
||||
4:10 warning 'revalidatePath' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/ArchitectureBuilderBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/ArticleBlockquoteBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/ArticleMemeBlock.ts
|
||||
2:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/ArticleQuoteBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/BoldNumberBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/ButtonBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/CarouselBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/ComparisonRowBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/DiagramFlowBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/DiagramGanttBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/DiagramPieBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/DiagramSequenceBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/DiagramStateBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/DiagramTimelineBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/DigitalAssetVisualizerBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/ExternalLinkBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/FAQSectionBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
39:22 warning 'ai' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
39:26 warning 'render' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/IconListBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/ImageTextBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/LeadMagnetBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/LeadParagraphBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/LinkedInEmbedBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/LoadTimeSimulatorBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/MarkerBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/MemeCardBlock.ts
|
||||
2:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/MermaidBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/MetricBarBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/ParagraphBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/PerformanceChartBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/PerformanceROICalculatorBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/PremiumComparisonChartBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/RevealBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/RevenueLossCalculatorBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/SectionBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/StatsDisplayBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/StatsGridBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/TLDRBlock.ts
|
||||
2:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/TrackedLinkBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/TwitterEmbedBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/WaterfallChartBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/WebVitalsScoreBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/YouTubeEmbedBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/allBlocks.ts
|
||||
100:47 warning 'ai' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
100:51 warning 'render' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/collections/ContextFiles.ts
|
||||
2:8 warning 'fs' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
27:10 warning 'doc' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
27:15 warning 'operation' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/components/FieldGenerators/AiFieldButton.tsx
|
||||
13:11 warning 'value' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
59:14 warning 'e' is defined but never used. Allowed unused caught errors must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/components/FieldGenerators/GenerateSlugButton.tsx
|
||||
6:10 warning 'Button' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
23:19 warning 'replaceState' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
24:11 warning 'value' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/components/FieldGenerators/GenerateThumbnailButton.tsx
|
||||
6:10 warning 'Button' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
24:11 warning 'value' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/components/OptimizeButton.tsx
|
||||
6:10 warning 'Button' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
✖ 141 problems (0 errors, 141 warnings)
|
||||
|
||||
5
apps/web/.turbo/turbo-test.log
Normal file
@@ -0,0 +1,5 @@
|
||||
|
||||
> @mintel/web@0.1.0 test /Users/marcmintel/Projects/mintel.me/apps/web
|
||||
> echo "No tests configured"
|
||||
|
||||
No tests configured
|
||||
4
apps/web/.turbo/turbo-typecheck.log
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
> @mintel/web@0.1.0 typecheck /Users/marcmintel/Projects/mintel.me/apps/web
|
||||
> tsc --noEmit
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
import { handleServerFunctions as payloadHandleServerFunctions } from "@payloadcms/next/layouts";
|
||||
import config from "@payload-config";
|
||||
// @ts-expect-error - Payload generates this file during the build process
|
||||
import { importMap } from "./admin/importMap";
|
||||
|
||||
export const handleServerFunctions = async (args: any) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
|
||||
import configPromise from "@payload-config";
|
||||
import { RootPage, generatePageMetadata } from "@payloadcms/next/views";
|
||||
// @ts-expect-error - Payload generates this file during the build process
|
||||
import { importMap } from "../importMap";
|
||||
|
||||
type Args = {
|
||||
|
||||
@@ -1,99 +1 @@
|
||||
import { OptimizeButton as OptimizeButton_a629b3460534b7aa208597fdc5e30aec } from "@/src/payload/components/OptimizeButton";
|
||||
import { GenerateSlugButton as GenerateSlugButton_63aadb132a046b3f001fac7a715e5717 } from "@/src/payload/components/FieldGenerators/GenerateSlugButton";
|
||||
import { default as default_76cec558bd86098fa1dab70b12eb818f } from "@/src/payload/components/TagSelector";
|
||||
import { GenerateThumbnailButton as GenerateThumbnailButton_39d416c162062cbe7173a99e3239786e } from "@/src/payload/components/FieldGenerators/GenerateThumbnailButton";
|
||||
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from "@payloadcms/richtext-lexical/rsc";
|
||||
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from "@payloadcms/richtext-lexical/rsc";
|
||||
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from "@payloadcms/richtext-lexical/rsc";
|
||||
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { AiFieldButton as AiFieldButton_da42292f87769a8025025b774910be6d } from "@/src/payload/components/FieldGenerators/AiFieldButton";
|
||||
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { default as default_2ebf44fdf8ebc607cf0de30cff485248 } from "@/src/payload/components/ColorPicker";
|
||||
import { default as default_a1c6da8fb7dd9846a8b07123ff256d09 } from "@/src/payload/components/IconSelector";
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from "@payloadcms/next/rsc";
|
||||
|
||||
export const importMap = {
|
||||
"@/src/payload/components/OptimizeButton#OptimizeButton":
|
||||
OptimizeButton_a629b3460534b7aa208597fdc5e30aec,
|
||||
"@/src/payload/components/FieldGenerators/GenerateSlugButton#GenerateSlugButton":
|
||||
GenerateSlugButton_63aadb132a046b3f001fac7a715e5717,
|
||||
"@/src/payload/components/TagSelector#default":
|
||||
default_76cec558bd86098fa1dab70b12eb818f,
|
||||
"@/src/payload/components/FieldGenerators/GenerateThumbnailButton#GenerateThumbnailButton":
|
||||
GenerateThumbnailButton_39d416c162062cbe7173a99e3239786e,
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell":
|
||||
RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField":
|
||||
RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent":
|
||||
LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/client#BlocksFeatureClient":
|
||||
BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton":
|
||||
AiFieldButton_da42292f87769a8025025b774910be6d,
|
||||
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient":
|
||||
InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient":
|
||||
HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UploadFeatureClient":
|
||||
UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient":
|
||||
BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient":
|
||||
RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#LinkFeatureClient":
|
||||
LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient":
|
||||
ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient":
|
||||
OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient":
|
||||
UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#IndentFeatureClient":
|
||||
IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#AlignFeatureClient":
|
||||
AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HeadingFeatureClient":
|
||||
HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient":
|
||||
ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient":
|
||||
InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient":
|
||||
SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient":
|
||||
SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient":
|
||||
StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient":
|
||||
UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient":
|
||||
BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient":
|
||||
ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@/src/payload/components/ColorPicker#default":
|
||||
default_2ebf44fdf8ebc607cf0de30cff485248,
|
||||
"@/src/payload/components/IconSelector#default":
|
||||
default_a1c6da8fb7dd9846a8b07123ff256d09,
|
||||
"@payloadcms/next/rsc#CollectionCards":
|
||||
CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1,
|
||||
};
|
||||
export const importMap = {};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RootLayout } from "@payloadcms/next/layouts";
|
||||
import React from "react";
|
||||
|
||||
import { handleServerFunctions } from "./actions";
|
||||
// @ts-expect-error - Payload generates this file during the build process
|
||||
import { importMap } from "./admin/importMap";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -48,30 +48,10 @@ export const technologies: Record<string, TechInfo> = {
|
||||
'Using TypeScript means your application is robust and reliable from day one. It dramatically reduces the risk of "runtime errors" that could crash your site, saving time and money on bug fixes down the line.',
|
||||
color: "bg-blue-600 text-white",
|
||||
related: [
|
||||
{ name: "Directus CMS", slug: "directus-cms" },
|
||||
{ name: "Next.js 14", slug: "next-js-14" },
|
||||
],
|
||||
},
|
||||
"directus-cms": {
|
||||
title: "Directus CMS",
|
||||
subtitle: "The Open Data Platform",
|
||||
description:
|
||||
"Directus is a modern, headless Content Management System (CMS) that instantly turns any database into a beautiful, easy-to-use application for managing your content. Unlike traditional CMSs, it doesn't dictate how your website looks.",
|
||||
icon: Database,
|
||||
benefits: [
|
||||
"Intuitive interface for non-technical editors",
|
||||
"Complete freedom regarding front-end design",
|
||||
"Real-time updates and live previews",
|
||||
"Highly secure and role-based access control",
|
||||
],
|
||||
customerValue:
|
||||
"Directus gives you full control over your content without needing a developer for every text change. It separates your data from the design, ensuring your website can evolve visually without rebuilding your entire content library.",
|
||||
color: "bg-purple-600 text-white",
|
||||
related: [
|
||||
{ name: "Next.js 14", slug: "next-js-14" },
|
||||
{ name: "Tailwind CSS", slug: "tailwind-css" },
|
||||
],
|
||||
},
|
||||
|
||||
"tailwind-css": {
|
||||
title: "Tailwind CSS",
|
||||
subtitle: "Utility-First CSS Framework",
|
||||
|
||||
42
apps/web/app/api/health/cms/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPayload } from "payload";
|
||||
import configPromise from "@payload-config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Deep CMS Health Check
|
||||
* Validates that Payload CMS can actually query the database.
|
||||
* Used by post-deploy smoke tests to catch migration/schema issues.
|
||||
*/
|
||||
export async function GET() {
|
||||
const checks: Record<string, string> = {};
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
checks.init = "ok";
|
||||
|
||||
// Verify each collection can be queried (catches missing locale tables, broken migrations)
|
||||
// Adjusted for mintel.me collections
|
||||
const collections = ["posts", "projects", "media", "inquiries"] as const;
|
||||
for (const collection of collections) {
|
||||
try {
|
||||
await payload.find({ collection, limit: 1 });
|
||||
checks[collection] = "ok";
|
||||
} catch (e: any) {
|
||||
checks[collection] = `error: ${e.message?.substring(0, 100)}`;
|
||||
}
|
||||
}
|
||||
|
||||
const hasErrors = Object.values(checks).some((v) => v.startsWith("error"));
|
||||
return NextResponse.json(
|
||||
{ status: hasErrors ? "degraded" : "ok", checks },
|
||||
{ status: hasErrors ? 503 : 200 },
|
||||
);
|
||||
} catch (e: any) {
|
||||
return NextResponse.json(
|
||||
{ status: "error", message: e.message?.substring(0, 200), checks },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,29 @@ const dirname = path.dirname(filename);
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
serverExternalPackages: ['@mintel/content-engine'],
|
||||
serverExternalPackages: [
|
||||
'@mintel/content-engine',
|
||||
'@mintel/concept-engine',
|
||||
'@mintel/estimation-engine',
|
||||
'@mintel/pdf',
|
||||
'canvas',
|
||||
'sharp',
|
||||
'puppeteer',
|
||||
'require-in-the-middle',
|
||||
'import-in-the-middle' // Sentry 10+ instrumentation dependencies
|
||||
],
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '*.your-objectstorage.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'fsn1.your-objectstorage.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
// Umami proxy rewrite handled in app/stats/api/send/route.ts
|
||||
@@ -25,13 +47,7 @@ const nextConfig = {
|
||||
},
|
||||
];
|
||||
},
|
||||
webpack: (config) => {
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
'@mintel/content-engine': path.resolve(dirname, 'node_modules/@mintel/content-engine'),
|
||||
};
|
||||
return config;
|
||||
},
|
||||
outputFileTracingRoot: path.join(dirname, '../../'),
|
||||
};
|
||||
|
||||
const withMDX = createMDX({
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Technical problem solver's blog - practical insights and learning notes",
|
||||
"scripts": {
|
||||
"dev": "pnpm run seed:context && next dev --turbo",
|
||||
"dev:native": "pnpm run seed:context && DATABASE_URI=postgres://directus:directus@127.0.0.1:54321/directus PAYLOAD_SECRET=dev-secret next dev --webpack",
|
||||
"dev": "pnpm run seed:context && next dev --webpack --hostname 0.0.0.0",
|
||||
"dev:native": "DATABASE_URI=postgres://payload:payload@127.0.0.1:54321/payload PAYLOAD_SECRET=dev-secret pnpm run seed:context && DATABASE_URI=postgres://payload:payload@127.0.0.1:54321/payload PAYLOAD_SECRET=dev-secret next dev --webpack",
|
||||
"seed:context": "tsx ./seed-context.ts",
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "eslint app src scripts video",
|
||||
"test": "npm run test:links",
|
||||
"test": "echo \"No tests configured\"",
|
||||
"test:links": "tsx ./scripts/test-links.ts",
|
||||
"test:file-examples": "tsx ./scripts/test-file-examples-comprehensive.ts",
|
||||
"generate-estimate": "tsx ./scripts/generate-estimate.ts",
|
||||
@@ -21,16 +21,25 @@
|
||||
"video:render:button": "remotion render video/index.ts ButtonShowcase out/button-showcase.mp4 --concurrency=1 --codec=h264 --crf=16 --pixel-format=yuv420p --overwrite",
|
||||
"video:render:all": "npm run video:render:contact && npm run video:render:button",
|
||||
"pagespeed:test": "npx tsx ./scripts/pagespeed-sitemap.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check:og": "tsx scripts/check-og-images.ts",
|
||||
"check:forms": "tsx scripts/check-forms.ts",
|
||||
"cms:push:testing": "bash ./scripts/cms-sync.sh push testing",
|
||||
"cms:pull:testing": "bash ./scripts/cms-sync.sh pull testing",
|
||||
"cms:push:prod": "bash ./scripts/cms-sync.sh push prod",
|
||||
"cms:pull:prod": "bash ./scripts/cms-sync.sh pull prod"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.750.0",
|
||||
"@emotion/is-prop-valid": "^1.4.0",
|
||||
"@mdx-js/loader": "^3.1.1",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@mintel/cloner": "^1.8.0",
|
||||
"@mintel/cloner": "^1.9.0",
|
||||
"@mintel/concept-engine": "link:../../../at-mintel/packages/concept-engine",
|
||||
"@mintel/content-engine": "link:../../../at-mintel/packages/content-engine",
|
||||
"@mintel/estimation-engine": "link:../../../at-mintel/packages/estimation-engine",
|
||||
"@mintel/meme-generator": "link:../../../at-mintel/packages/meme-generator",
|
||||
"@mintel/pdf": "^1.8.0",
|
||||
"@mintel/pdf": "link:../../../at-mintel/packages/pdf-library",
|
||||
"@mintel/thumbnail-generator": "link:../../../at-mintel/packages/thumbnail-generator",
|
||||
"@next/mdx": "^16.1.6",
|
||||
"@next/third-parties": "^16.1.6",
|
||||
@@ -42,6 +51,7 @@
|
||||
"@payloadcms/email-nodemailer": "^3.77.0",
|
||||
"@payloadcms/next": "^3.77.0",
|
||||
"@payloadcms/richtext-lexical": "^3.77.0",
|
||||
"@payloadcms/storage-s3": "^3.77.0",
|
||||
"@payloadcms/ui": "^3.77.0",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"@remotion/bundler": "^4.0.414",
|
||||
@@ -60,10 +70,12 @@
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"clsx": "^2.1.1",
|
||||
"crawlee": "^3.15.3",
|
||||
"dotenv": "^17.3.1",
|
||||
"esbuild": "^0.27.3",
|
||||
"framer-motion": "^12.29.2",
|
||||
"graphql": "^16.12.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"import-in-the-middle": "^1.11.0",
|
||||
"ioredis": "^5.9.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"mermaid": "^11.12.2",
|
||||
@@ -81,6 +93,7 @@
|
||||
"react-tweet": "^3.3.0",
|
||||
"recharts": "^3.7.0",
|
||||
"remotion": "^4.0.414",
|
||||
"require-in-the-middle": "^8.0.1",
|
||||
"sharp": "^0.34.5",
|
||||
"shiki": "^1.24.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
@@ -94,14 +107,15 @@
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@eslint/js": "^10.0.0",
|
||||
"@lhci/cli": "^0.15.1",
|
||||
"@mintel/cli": "^1.7.3",
|
||||
"@mintel/eslint-config": "^1.7.3",
|
||||
"@mintel/husky-config": "^1.7.3",
|
||||
"@mintel/next-config": "^1.7.3",
|
||||
"@mintel/next-utils": "^1.7.15",
|
||||
"@mintel/tsconfig": "^1.7.3",
|
||||
"@mintel/cli": "^1.9.0",
|
||||
"@mintel/eslint-config": "^1.9.0",
|
||||
"@mintel/husky-config": "^1.9.0",
|
||||
"@mintel/next-config": "^1.9.0",
|
||||
"@mintel/next-utils": "^1.9.0",
|
||||
"@mintel/tsconfig": "^1.9.0",
|
||||
"@next/eslint-plugin-next": "^16.1.6",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/node": "^25.0.6",
|
||||
"@types/nodemailer": "^7.0.10",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
@@ -112,9 +126,14 @@
|
||||
"eslint": "10.0.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"mime-types": "^3.0.2",
|
||||
"postcss": "^8.4.49",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "^8.54.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@git.infra.mintel.me:mmintel/mintel.me.git"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,12 +73,30 @@ export interface Config {
|
||||
inquiries: Inquiry;
|
||||
redirects: Redirect;
|
||||
"context-files": ContextFile;
|
||||
"crm-accounts": CrmAccount;
|
||||
"crm-contacts": CrmContact;
|
||||
"crm-topics": CrmTopic;
|
||||
"crm-interactions": CrmInteraction;
|
||||
projects: Project;
|
||||
"payload-kv": PayloadKv;
|
||||
"payload-locked-documents": PayloadLockedDocument;
|
||||
"payload-preferences": PayloadPreference;
|
||||
"payload-migrations": PayloadMigration;
|
||||
};
|
||||
collectionsJoins: {};
|
||||
collectionsJoins: {
|
||||
"crm-accounts": {
|
||||
topics: "crm-topics";
|
||||
contacts: "crm-contacts";
|
||||
interactions: "crm-interactions";
|
||||
projects: "projects";
|
||||
};
|
||||
"crm-contacts": {
|
||||
interactions: "crm-interactions";
|
||||
};
|
||||
"crm-topics": {
|
||||
interactions: "crm-interactions";
|
||||
};
|
||||
};
|
||||
collectionsSelect: {
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
media: MediaSelect<false> | MediaSelect<true>;
|
||||
@@ -86,6 +104,13 @@ export interface Config {
|
||||
inquiries: InquiriesSelect<false> | InquiriesSelect<true>;
|
||||
redirects: RedirectsSelect<false> | RedirectsSelect<true>;
|
||||
"context-files": ContextFilesSelect<false> | ContextFilesSelect<true>;
|
||||
"crm-accounts": CrmAccountsSelect<false> | CrmAccountsSelect<true>;
|
||||
"crm-contacts": CrmContactsSelect<false> | CrmContactsSelect<true>;
|
||||
"crm-topics": CrmTopicsSelect<false> | CrmTopicsSelect<true>;
|
||||
"crm-interactions":
|
||||
| CrmInteractionsSelect<false>
|
||||
| CrmInteractionsSelect<true>;
|
||||
projects: ProjectsSelect<false> | ProjectsSelect<true>;
|
||||
"payload-kv": PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||
"payload-locked-documents":
|
||||
| PayloadLockedDocumentsSelect<false>
|
||||
@@ -164,6 +189,7 @@ export interface User {
|
||||
export interface Media {
|
||||
id: number;
|
||||
alt: string;
|
||||
prefix?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
url?: string | null;
|
||||
@@ -253,6 +279,10 @@ export interface Post {
|
||||
*/
|
||||
export interface Inquiry {
|
||||
id: number;
|
||||
/**
|
||||
* Has this inquiry been converted into a CRM Lead?
|
||||
*/
|
||||
processed?: boolean | null;
|
||||
name: string;
|
||||
email: string;
|
||||
companyName?: string | null;
|
||||
@@ -308,6 +338,261 @@ export interface ContextFile {
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* Accounts represent companies or organizations. They are the central hub linking Contacts and Interactions together. Use this to track the overall relationship status.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "crm-accounts".
|
||||
*/
|
||||
export interface CrmAccount {
|
||||
id: number;
|
||||
/**
|
||||
* Enter the official name of the business or the research project name.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The main website of the account. Required for triggering the AI Website Analysis.
|
||||
*/
|
||||
website?: string | null;
|
||||
/**
|
||||
* Current lifecycle stage of this business relation.
|
||||
*/
|
||||
status?: ("lead" | "client" | "partner" | "lost") | null;
|
||||
/**
|
||||
* Indicates how likely this lead is to convert soon.
|
||||
*/
|
||||
leadTemperature?: ("cold" | "warm" | "hot") | null;
|
||||
/**
|
||||
* The internal team member responsible for this account.
|
||||
*/
|
||||
assignedTo?: (number | null) | User;
|
||||
/**
|
||||
* All generated PDF estimates and strategy documents appear here.
|
||||
*/
|
||||
reports?: (number | Media)[] | null;
|
||||
/**
|
||||
* Projects, deals, or specific topics active for this client.
|
||||
*/
|
||||
topics?: {
|
||||
docs?: (number | CrmTopic)[];
|
||||
hasNextPage?: boolean;
|
||||
totalDocs?: number;
|
||||
};
|
||||
/**
|
||||
* All contacts associated with this account.
|
||||
*/
|
||||
contacts?: {
|
||||
docs?: (number | CrmContact)[];
|
||||
hasNextPage?: boolean;
|
||||
totalDocs?: number;
|
||||
};
|
||||
/**
|
||||
* Timeline of all communication logged against this account.
|
||||
*/
|
||||
interactions?: {
|
||||
docs?: (number | CrmInteraction)[];
|
||||
hasNextPage?: boolean;
|
||||
totalDocs?: number;
|
||||
};
|
||||
/**
|
||||
* All high-level projects associated with this account.
|
||||
*/
|
||||
projects?: {
|
||||
docs?: (number | Project)[];
|
||||
hasNextPage?: boolean;
|
||||
totalDocs?: number;
|
||||
};
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* Group your interactions (emails, calls, notes) into Topics. This helps you keep track of specific projects with a client.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "crm-topics".
|
||||
*/
|
||||
export interface CrmTopic {
|
||||
id: number;
|
||||
title: string;
|
||||
/**
|
||||
* Which account does this topic belong to?
|
||||
*/
|
||||
account: number | CrmAccount;
|
||||
status: "active" | "paused" | "won" | "lost";
|
||||
/**
|
||||
* Optional: What stage is this deal/project currently in?
|
||||
*/
|
||||
stage?: ("discovery" | "proposal" | "negotiation" | "implementation") | null;
|
||||
/**
|
||||
* Timeline of all emails and notes specifically related to this topic.
|
||||
*/
|
||||
interactions?: {
|
||||
docs?: (number | CrmInteraction)[];
|
||||
hasNextPage?: boolean;
|
||||
totalDocs?: number;
|
||||
};
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* Your CRM journal. Log what happened, when, on which channel, and attach any relevant files. This is for summaries and facts — not for sending messages.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "crm-interactions".
|
||||
*/
|
||||
export interface CrmInteraction {
|
||||
id: number;
|
||||
/**
|
||||
* Where did this communication take place?
|
||||
*/
|
||||
type:
|
||||
| "email"
|
||||
| "call"
|
||||
| "meeting"
|
||||
| "whatsapp"
|
||||
| "social"
|
||||
| "document"
|
||||
| "note";
|
||||
direction?: ("inbound" | "outbound") | null;
|
||||
/**
|
||||
* When did this happen?
|
||||
*/
|
||||
date: string;
|
||||
subject: string;
|
||||
/**
|
||||
* Who was involved?
|
||||
*/
|
||||
contact?: (number | null) | CrmContact;
|
||||
account?: (number | null) | CrmAccount;
|
||||
/**
|
||||
* Optional: Group this entry under a specific project or topic.
|
||||
*/
|
||||
topic?: (number | null) | CrmTopic;
|
||||
/**
|
||||
* Summarize what happened, what was decided, or what the next steps are.
|
||||
*/
|
||||
content?: {
|
||||
root: {
|
||||
type: string;
|
||||
children: {
|
||||
type: any;
|
||||
version: number;
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
direction: ("ltr" | "rtl") | null;
|
||||
format: "left" | "start" | "center" | "right" | "end" | "justify" | "";
|
||||
indent: number;
|
||||
version: number;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
} | null;
|
||||
/**
|
||||
* Attach received documents, screenshots, contracts, or any relevant files.
|
||||
*/
|
||||
attachments?: (number | Media)[] | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* Contacts are the individual people linked to an Account. A person should only be created once and can be assigned to a company here.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "crm-contacts".
|
||||
*/
|
||||
export interface CrmContact {
|
||||
id: number;
|
||||
fullName?: string | null;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
/**
|
||||
* Primary email address for communication tracking.
|
||||
*/
|
||||
email: string;
|
||||
phone?: string | null;
|
||||
linkedIn?: string | null;
|
||||
/**
|
||||
* e.g. CEO, Marketing Manager, Technical Lead
|
||||
*/
|
||||
role?: string | null;
|
||||
/**
|
||||
* Link this person to an organization from the Accounts collection.
|
||||
*/
|
||||
account?: (number | null) | CrmAccount;
|
||||
/**
|
||||
* Timeline of all communication logged directly with this person.
|
||||
*/
|
||||
interactions?: {
|
||||
docs?: (number | CrmInteraction)[];
|
||||
hasNextPage?: boolean;
|
||||
totalDocs?: number;
|
||||
};
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* Manage high-level projects for your clients.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "projects".
|
||||
*/
|
||||
export interface Project {
|
||||
id: number;
|
||||
title: string;
|
||||
/**
|
||||
* Which account is this project for?
|
||||
*/
|
||||
account: number | CrmAccount;
|
||||
/**
|
||||
* Key contacts from the client side involved in this project.
|
||||
*/
|
||||
contact?: (number | CrmContact)[] | null;
|
||||
status: "draft" | "in_progress" | "review" | "completed";
|
||||
startDate?: string | null;
|
||||
targetDate?: string | null;
|
||||
valueMin?: number | null;
|
||||
valueMax?: number | null;
|
||||
/**
|
||||
* Project briefing, requirements, or notes.
|
||||
*/
|
||||
briefing?: {
|
||||
root: {
|
||||
type: string;
|
||||
children: {
|
||||
type: any;
|
||||
version: number;
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
direction: ("ltr" | "rtl") | null;
|
||||
format: "left" | "start" | "center" | "right" | "end" | "justify" | "";
|
||||
indent: number;
|
||||
version: number;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
} | null;
|
||||
/**
|
||||
* Upload files, documents, or assets related to this project.
|
||||
*/
|
||||
attachments?: (number | Media)[] | null;
|
||||
/**
|
||||
* Granular deliverables or milestones within this project.
|
||||
*/
|
||||
milestones?:
|
||||
| {
|
||||
name: string;
|
||||
status: "todo" | "in_progress" | "done";
|
||||
priority?: ("low" | "medium" | "high") | null;
|
||||
startDate?: string | null;
|
||||
targetDate?: string | null;
|
||||
/**
|
||||
* Internal team member responsible for this milestone.
|
||||
*/
|
||||
assignee?: (number | null) | User;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-kv".
|
||||
@@ -355,6 +640,26 @@ export interface PayloadLockedDocument {
|
||||
| ({
|
||||
relationTo: "context-files";
|
||||
value: number | ContextFile;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: "crm-accounts";
|
||||
value: number | CrmAccount;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: "crm-contacts";
|
||||
value: number | CrmContact;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: "crm-topics";
|
||||
value: number | CrmTopic;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: "crm-interactions";
|
||||
value: number | CrmInteraction;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: "projects";
|
||||
value: number | Project;
|
||||
} | null);
|
||||
globalSlug?: string | null;
|
||||
user: {
|
||||
@@ -426,6 +731,7 @@ export interface UsersSelect<T extends boolean = true> {
|
||||
*/
|
||||
export interface MediaSelect<T extends boolean = true> {
|
||||
alt?: T;
|
||||
prefix?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
url?: T;
|
||||
@@ -498,6 +804,7 @@ export interface PostsSelect<T extends boolean = true> {
|
||||
* via the `definition` "inquiries_select".
|
||||
*/
|
||||
export interface InquiriesSelect<T extends boolean = true> {
|
||||
processed?: T;
|
||||
name?: T;
|
||||
email?: T;
|
||||
companyName?: T;
|
||||
@@ -528,6 +835,100 @@ export interface ContextFilesSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "crm-accounts_select".
|
||||
*/
|
||||
export interface CrmAccountsSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
website?: T;
|
||||
status?: T;
|
||||
leadTemperature?: T;
|
||||
assignedTo?: T;
|
||||
reports?: T;
|
||||
topics?: T;
|
||||
contacts?: T;
|
||||
interactions?: T;
|
||||
projects?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "crm-contacts_select".
|
||||
*/
|
||||
export interface CrmContactsSelect<T extends boolean = true> {
|
||||
fullName?: T;
|
||||
firstName?: T;
|
||||
lastName?: T;
|
||||
email?: T;
|
||||
phone?: T;
|
||||
linkedIn?: T;
|
||||
role?: T;
|
||||
account?: T;
|
||||
interactions?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "crm-topics_select".
|
||||
*/
|
||||
export interface CrmTopicsSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
account?: T;
|
||||
status?: T;
|
||||
stage?: T;
|
||||
interactions?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "crm-interactions_select".
|
||||
*/
|
||||
export interface CrmInteractionsSelect<T extends boolean = true> {
|
||||
type?: T;
|
||||
direction?: T;
|
||||
date?: T;
|
||||
subject?: T;
|
||||
contact?: T;
|
||||
account?: T;
|
||||
topic?: T;
|
||||
content?: T;
|
||||
attachments?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "projects_select".
|
||||
*/
|
||||
export interface ProjectsSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
account?: T;
|
||||
contact?: T;
|
||||
status?: T;
|
||||
startDate?: T;
|
||||
targetDate?: T;
|
||||
valueMin?: T;
|
||||
valueMax?: T;
|
||||
briefing?: T;
|
||||
attachments?: T;
|
||||
milestones?:
|
||||
| T
|
||||
| {
|
||||
name?: T;
|
||||
status?: T;
|
||||
priority?: T;
|
||||
startDate?: T;
|
||||
targetDate?: T;
|
||||
assignee?: T;
|
||||
id?: T;
|
||||
};
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-kv_select".
|
||||
|
||||
@@ -4,6 +4,7 @@ import { postgresAdapter } from "@payloadcms/db-postgres";
|
||||
import { lexicalEditor, BlocksFeature } from "@payloadcms/richtext-lexical";
|
||||
import { payloadBlocks } from "./src/payload/blocks/allBlocks";
|
||||
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
|
||||
import { s3Storage } from "@payloadcms/storage-s3";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import sharp from "sharp";
|
||||
@@ -11,9 +12,16 @@ import sharp from "sharp";
|
||||
import { Users } from "./src/payload/collections/Users";
|
||||
import { Media } from "./src/payload/collections/Media";
|
||||
import { Posts } from "./src/payload/collections/Posts";
|
||||
import { emailWebhookHandler } from "./src/payload/endpoints/emailWebhook";
|
||||
import { aiEndpointHandler } from "./src/payload/endpoints/aiEndpoint";
|
||||
import { Inquiries } from "./src/payload/collections/Inquiries";
|
||||
import { Redirects } from "./src/payload/collections/Redirects";
|
||||
import { ContextFiles } from "./src/payload/collections/ContextFiles";
|
||||
import { CrmAccounts } from "./src/payload/collections/CrmAccounts";
|
||||
import { CrmContacts } from "./src/payload/collections/CrmContacts";
|
||||
import { CrmInteractions } from "./src/payload/collections/CrmInteractions";
|
||||
import { CrmTopics } from "./src/payload/collections/CrmTopics";
|
||||
import { Projects } from "./src/payload/collections/Projects";
|
||||
|
||||
import { AiSettings } from "./src/payload/globals/AiSettings";
|
||||
|
||||
@@ -27,24 +35,33 @@ export default buildConfig({
|
||||
baseDir: path.resolve(dirname),
|
||||
},
|
||||
},
|
||||
collections: [Users, Media, Posts, Inquiries, Redirects, ContextFiles],
|
||||
collections: [
|
||||
Users,
|
||||
Media,
|
||||
Posts,
|
||||
Inquiries,
|
||||
Redirects,
|
||||
ContextFiles,
|
||||
CrmAccounts,
|
||||
CrmContacts,
|
||||
CrmTopics,
|
||||
CrmInteractions,
|
||||
Projects,
|
||||
],
|
||||
globals: [AiSettings],
|
||||
...(process.env.MAIL_HOST
|
||||
? {
|
||||
email: nodemailerAdapter({
|
||||
defaultFromAddress: process.env.MAIL_FROM || "info@mintel.me",
|
||||
defaultFromName: "Mintel.me",
|
||||
transportOptions: {
|
||||
host: process.env.MAIL_HOST,
|
||||
port: parseInt(process.env.MAIL_PORT || "587"),
|
||||
auth: {
|
||||
user: process.env.MAIL_USERNAME,
|
||||
pass: process.env.MAIL_PASSWORD,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
email: nodemailerAdapter({
|
||||
defaultFromAddress: process.env.MAIL_FROM || "info@mintel.me",
|
||||
defaultFromName: "Mintel.me",
|
||||
transportOptions: {
|
||||
host: process.env.MAIL_HOST || "localhost",
|
||||
port: parseInt(process.env.MAIL_PORT || "587", 10),
|
||||
auth: {
|
||||
user: process.env.MAIL_USERNAME || "user",
|
||||
pass: process.env.MAIL_PASSWORD || "pass",
|
||||
},
|
||||
...(process.env.MAIL_HOST ? {} : { ignoreTLS: true }),
|
||||
},
|
||||
}),
|
||||
editor: lexicalEditor({
|
||||
features: ({ defaultFeatures }) => [
|
||||
...defaultFeatures,
|
||||
@@ -64,5 +81,34 @@ export default buildConfig({
|
||||
},
|
||||
}),
|
||||
sharp,
|
||||
plugins: [],
|
||||
plugins: [
|
||||
...(process.env.S3_ENDPOINT
|
||||
? [
|
||||
s3Storage({
|
||||
collections: {
|
||||
media: {
|
||||
prefix: `${process.env.S3_PREFIX || "mintel-me"}/media`,
|
||||
},
|
||||
},
|
||||
bucket: process.env.S3_BUCKET || "",
|
||||
config: {
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY || "",
|
||||
secretAccessKey: process.env.S3_SECRET_KEY || "",
|
||||
},
|
||||
region: process.env.S3_REGION || "fsn1",
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
forcePathStyle: true,
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
endpoints: [
|
||||
{
|
||||
path: "/crm/incoming-email",
|
||||
method: "post",
|
||||
handler: emailWebhookHandler,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
After Width: | Height: | Size: 5.0 MiB |
BIN
apps/web/public/blog/build-first-digital-architecture.png
Normal file
|
After Width: | Height: | Size: 5.2 MiB |
BIN
apps/web/public/blog/builder-systems-threaten-independence.png
Normal file
|
After Width: | Height: | Size: 4.8 MiB |
BIN
apps/web/public/blog/clean-code-for-business-value.png
Normal file
|
After Width: | Height: | Size: 4.5 MiB |
BIN
apps/web/public/blog/crm-synchronization-headless.png
Normal file
|
After Width: | Height: | Size: 4.5 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
BIN
apps/web/public/blog/digital-longevity-architecture.png
Normal file
|
After Width: | Height: | Size: 4.9 MiB |
BIN
apps/web/public/blog/fixed-price-digital-projects.png
Normal file
|
After Width: | Height: | Size: 4.8 MiB |
BIN
apps/web/public/blog/gdpr-conformity-system-approach.png
Normal file
|
After Width: | Height: | Size: 5.6 MiB |
BIN
apps/web/public/blog/green-it-sustainable-web.png
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
BIN
apps/web/public/blog/hidden-costs-of-wordpress-plugins.png
Normal file
|
After Width: | Height: | Size: 4.5 MiB |
BIN
apps/web/public/blog/no-us-cloud-platforms.png
Normal file
|
After Width: | Height: | Size: 3.8 MiB |
BIN
apps/web/public/blog/professional-hosting-operations.png
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
BIN
apps/web/public/blog/responsive-design-high-fidelity.png
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
BIN
apps/web/public/blog/slow-loading-costs-customers.png
Normal file
|
After Width: | Height: | Size: 5.0 MiB |
BIN
apps/web/public/blog/website-without-cookie-banners.png
Normal file
|
After Width: | Height: | Size: 4.2 MiB |
BIN
apps/web/public/blog/why-agencies-are-slow.png
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
BIN
apps/web/public/blog/why-no-templates-matter.png
Normal file
|
After Width: | Height: | Size: 5.0 MiB |
BIN
apps/web/public/blog/why-websites-break-after-updates.png
Normal file
|
After Width: | Height: | Size: 4.8 MiB |
21
apps/web/scripts/backup-db.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
DB_CONTAINER="mintel-me-postgres-db-1"
|
||||
DB_USER="payload"
|
||||
DB_NAME="payload"
|
||||
|
||||
# Resolve backup dir relative to this script's location
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BACKUP_DIR="${SCRIPT_DIR}/../../../backups"
|
||||
TIMESTAMP=$(date +"%Y-%m-%d_%H-%M-%S")
|
||||
BACKUP_FILE="${BACKUP_DIR}/payload_backup_${TIMESTAMP}.dump"
|
||||
|
||||
echo "Creating backup directory at ${BACKUP_DIR}..."
|
||||
mkdir -p "${BACKUP_DIR}"
|
||||
|
||||
echo "Dumping database '${DB_NAME}' from container '${DB_CONTAINER}'..."
|
||||
docker exec ${DB_CONTAINER} pg_dump -U ${DB_USER} -F c ${DB_NAME} > "${BACKUP_FILE}"
|
||||
|
||||
echo "✅ Backup successful: ${BACKUP_FILE}"
|
||||
ls -lh "${BACKUP_FILE}"
|
||||
95
apps/web/scripts/check-forms.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import puppeteer from "puppeteer";
|
||||
|
||||
const targetUrl = process.env.TEST_URL || "http://localhost:3000";
|
||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || "secret";
|
||||
|
||||
async function main() {
|
||||
console.log(`\n🚀 Starting E2E Form Submission Check for: ${targetUrl}`);
|
||||
|
||||
// Launch browser with KLZ pattern: use system chromium via env
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
executablePath:
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
||||
process.env.CHROME_PATH ||
|
||||
undefined,
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
"--ignore-certificate-errors",
|
||||
],
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Enable console logging from the page for debugging
|
||||
page.on("console", (msg) => console.log(` [PAGE] ${msg.text()}`));
|
||||
page.on("pageerror", (err: Error) =>
|
||||
console.error(` [PAGE ERROR] ${err.message}`),
|
||||
);
|
||||
page.on("requestfailed", (req) =>
|
||||
console.error(
|
||||
` [REQUEST FAILED] ${req.url()} - ${req.failure()?.errorText}`,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
// Authenticate through Gatekeeper
|
||||
console.log(`\n🛡️ Authenticating through Gatekeeper...`);
|
||||
console.log(` Navigating to: ${targetUrl}`);
|
||||
|
||||
const response = await page.goto(targetUrl, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 60000,
|
||||
});
|
||||
console.log(` Response status: ${response?.status()}`);
|
||||
console.log(` Response URL: ${response?.url()}`);
|
||||
|
||||
const isGatekeeperPage = await page.$('input[name="password"]');
|
||||
if (isGatekeeperPage) {
|
||||
await page.type('input[name="password"]', gatekeeperPassword);
|
||||
await Promise.all([
|
||||
page.waitForNavigation({
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 60000,
|
||||
}),
|
||||
page.click('button[type="submit"]'),
|
||||
]);
|
||||
console.log(`✅ Gatekeeper authentication successful!`);
|
||||
} else {
|
||||
console.log(`✅ Already authenticated (no Gatekeeper gate detected).`);
|
||||
}
|
||||
|
||||
// Basic smoke test
|
||||
console.log(`\n🧪 Testing page load...`);
|
||||
const title = await page.title();
|
||||
console.log(`✅ Page Title: ${title}`);
|
||||
|
||||
if (title.toLowerCase().includes("mintel")) {
|
||||
console.log(`✅ Basic smoke test passed!`);
|
||||
} else {
|
||||
throw new Error(`Page title mismatch: "${title}"`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`❌ Test Failed: ${err.message}`);
|
||||
// Take a screenshot for debugging
|
||||
try {
|
||||
const screenshotPath = "/tmp/e2e-failure.png";
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
console.log(`📸 Screenshot saved to ${screenshotPath}`);
|
||||
} catch {
|
||||
/* ignore screenshot errors */
|
||||
}
|
||||
console.log(` Current URL: ${page.url()}`);
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(`\n🎉 SUCCESS: E2E smoke test passed!`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
104
apps/web/scripts/check-og-images.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
const BASE_URL = process.env.TEST_URL || "http://localhost:3000";
|
||||
|
||||
console.log(`\n🚀 Starting Dynamic OG Image Verification for ${BASE_URL}\n`);
|
||||
|
||||
const pages = ["/", "/about", "/contact"];
|
||||
|
||||
async function getOgImageUrl(pagePath: string): Promise<string | null> {
|
||||
const url = `${BASE_URL}${pagePath}`;
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch page: ${response.status}`);
|
||||
}
|
||||
const html = await response.text();
|
||||
|
||||
// Extract og:image content
|
||||
const match = html.match(/property="og:image"\s+content="([^"]+)"/);
|
||||
if (!match || !match[1]) {
|
||||
// Try name="twitter:image" as fallback or check if it's there
|
||||
const twitterMatch = html.match(
|
||||
/name="twitter:image"\s+content="([^"]+)"/,
|
||||
);
|
||||
return twitterMatch ? twitterMatch[1] : null;
|
||||
}
|
||||
return match[1];
|
||||
} catch (error) {
|
||||
console.error(` ❌ Failed to discover OG image for ${pagePath}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyImage(
|
||||
imageUrl: string,
|
||||
pagePath: string,
|
||||
): Promise<boolean> {
|
||||
// If the image URL is absolute and contains mintel.me (base domain),
|
||||
// we replace it with our BASE_URL to test the current environment's generated image
|
||||
let testUrl = imageUrl;
|
||||
if (imageUrl.startsWith("https://mintel.me")) {
|
||||
testUrl = imageUrl.replace("https://mintel.me", BASE_URL);
|
||||
} else if (imageUrl.startsWith("/")) {
|
||||
testUrl = `${BASE_URL}${imageUrl}`;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
const response = await fetch(testUrl);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
console.log(`Checking OG Image for ${pagePath}: ${testUrl}...`);
|
||||
|
||||
const body = await response.clone().text();
|
||||
const contentType = response.headers.get("content-type");
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
if (!contentType?.includes("image/")) {
|
||||
throw new Error(`Content-Type: ${contentType}`);
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
if (bytes.length < 1000) {
|
||||
throw new Error(`Image too small (${bytes.length} bytes)`);
|
||||
}
|
||||
|
||||
console.log(` ✅ OK (${bytes.length} bytes, ${duration}ms)`);
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
console.error(` ❌ FAILED:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
let allOk = true;
|
||||
|
||||
for (const page of pages) {
|
||||
console.log(`Discovering OG image for ${page}...`);
|
||||
const ogUrl = await getOgImageUrl(page);
|
||||
|
||||
if (!ogUrl) {
|
||||
console.error(` ❌ No OG image meta tag found for ${page}`);
|
||||
allOk = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const ok = await verifyImage(ogUrl, page);
|
||||
if (!ok) allOk = false;
|
||||
}
|
||||
|
||||
if (allOk) {
|
||||
console.log("\n✨ All OG images verified successfully!\n");
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error("\n❌ Some OG images failed verification.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
290
apps/web/scripts/cms-sync.sh
Executable file
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env bash
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# CMS Data Sync Tool (mintel.me)
|
||||
# Safely syncs the Payload CMS PostgreSQL database between environments.
|
||||
# Media is handled via S3 and does NOT need syncing.
|
||||
#
|
||||
# Usage:
|
||||
# npm run cms:push:testing – Push local → testing
|
||||
# npm run cms:push:prod – Push local → production
|
||||
# npm run cms:pull:testing – Pull testing → local
|
||||
# npm run cms:pull:prod – Pull production → local
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
SYNC_SUCCESS="false"
|
||||
LOCAL_BACKUP_FILE=""
|
||||
REMOTE_BACKUP_FILE=""
|
||||
|
||||
cleanup_on_exit() {
|
||||
local exit_code=$?
|
||||
if [ "$SYNC_SUCCESS" != "true" ] && [ $exit_code -ne 0 ]; then
|
||||
echo ""
|
||||
echo "❌ Sync aborted or failed! (Exit code: $exit_code)"
|
||||
if [ "${DIRECTION:-}" = "push" ] && [ -n "${REMOTE_BACKUP_FILE:-}" ]; then
|
||||
echo "🔄 Rolling back $TARGET database..."
|
||||
ssh "$SSH_HOST" "gunzip -c $REMOTE_BACKUP_FILE | docker exec -i $REMOTE_DB_CONTAINER psql -U $REMOTE_DB_USER -d $REMOTE_DB_NAME --quiet" || echo "⚠️ Rollback failed"
|
||||
echo "✅ Rollback complete."
|
||||
elif [ "${DIRECTION:-}" = "pull" ] && [ -n "${LOCAL_BACKUP_FILE:-}" ]; then
|
||||
echo "🔄 Rolling back local database..."
|
||||
gunzip -c "$LOCAL_BACKUP_FILE" | docker exec -i "$LOCAL_DB_CONTAINER" psql -U "$LOCAL_DB_USER" -d "$LOCAL_DB_NAME" --quiet || echo "⚠️ Rollback failed"
|
||||
echo "✅ Rollback complete."
|
||||
fi
|
||||
fi
|
||||
}
|
||||
trap 'cleanup_on_exit' EXIT
|
||||
|
||||
# Load environment variables
|
||||
if [ -f ../../.env ]; then
|
||||
set -a; source ../../.env; set +a
|
||||
fi
|
||||
if [ -f .env ]; then
|
||||
set -a; source .env; set +a
|
||||
fi
|
||||
|
||||
# ── Configuration ──────────────────────────────────────────────────────────
|
||||
DIRECTION="${1:-}" # push | pull
|
||||
TARGET="${2:-}" # testing | prod
|
||||
SSH_HOST="root@alpha.mintel.me"
|
||||
LOCAL_DB_USER="${postgres_DB_USER:-payload}"
|
||||
LOCAL_DB_NAME="${postgres_DB_NAME:-payload}"
|
||||
LOCAL_DB_CONTAINER="mintel-me-postgres-db-1"
|
||||
|
||||
# Resolve directories
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BACKUP_DIR="${SCRIPT_DIR}/../../../../backups"
|
||||
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
||||
|
||||
# Remote credentials (resolved per-target from server env files)
|
||||
REMOTE_DB_USER=""
|
||||
REMOTE_DB_NAME=""
|
||||
|
||||
# Auto-detect migrations from apps/web/src/migrations/*.ts
|
||||
MIGRATIONS=()
|
||||
BATCH=1
|
||||
for migration_file in $(ls "${SCRIPT_DIR}/../src/migrations"/*.ts 2>/dev/null | sort); do
|
||||
name=$(basename "$migration_file" .ts)
|
||||
MIGRATIONS+=("$name:$BATCH")
|
||||
((BATCH++))
|
||||
done
|
||||
if [ ${#MIGRATIONS[@]} -eq 0 ]; then
|
||||
echo "⚠️ No migration files found in src/migrations/"
|
||||
fi
|
||||
|
||||
# ── Resolve target environment ─────────────────────────────────────────────
|
||||
resolve_target() {
|
||||
case "$TARGET" in
|
||||
testing)
|
||||
REMOTE_PROJECT="mintel-me-testing"
|
||||
REMOTE_DB_CONTAINER="mintel-me-testing-postgres-db-1"
|
||||
REMOTE_APP_CONTAINER="mintel-me-testing-mintel-me-app-1"
|
||||
REMOTE_SITE_DIR="/home/deploy/sites/testing.mintel.me"
|
||||
;;
|
||||
staging)
|
||||
REMOTE_PROJECT="mintel-me-staging"
|
||||
REMOTE_DB_CONTAINER="mintel-me-staging-postgres-db-1"
|
||||
REMOTE_APP_CONTAINER="mintel-me-staging-app-1"
|
||||
REMOTE_SITE_DIR="/home/deploy/sites/staging.mintel.me"
|
||||
;;
|
||||
prod|production)
|
||||
REMOTE_PROJECT="mintel-me-production"
|
||||
REMOTE_DB_CONTAINER="mintel-me-production-postgres-db-1"
|
||||
REMOTE_APP_CONTAINER="mintel-me-production-app-1"
|
||||
REMOTE_SITE_DIR="/home/deploy/sites/mintel.me"
|
||||
;;
|
||||
branch-*)
|
||||
local SLUG=${TARGET#branch-}
|
||||
REMOTE_PROJECT="mintel-me-branch-$SLUG"
|
||||
REMOTE_DB_CONTAINER="${REMOTE_PROJECT}-postgres-db-1"
|
||||
REMOTE_APP_CONTAINER="${REMOTE_PROJECT}-app-1"
|
||||
REMOTE_SITE_DIR="/home/deploy/sites/branch.mintel.me/$SLUG"
|
||||
;;
|
||||
*)
|
||||
echo "❌ Unknown target: $TARGET"
|
||||
echo " Valid targets: testing, staging, prod, branch-<slug>"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Auto-detect remote DB credentials from the env file on the server
|
||||
echo "🔍 Detecting $TARGET database credentials..."
|
||||
REMOTE_DB_USER="directus"
|
||||
REMOTE_DB_NAME="directus"
|
||||
REMOTE_DB_USER="${REMOTE_DB_USER:-payload}"
|
||||
REMOTE_DB_NAME="${REMOTE_DB_NAME:-payload}"
|
||||
echo " User: $REMOTE_DB_USER | DB: $REMOTE_DB_NAME"
|
||||
}
|
||||
|
||||
# ── Ensure local DB is running ─────────────────────────────────────────────
|
||||
ensure_local_db() {
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "$LOCAL_DB_CONTAINER"; then
|
||||
echo "❌ Local DB container not running: $LOCAL_DB_CONTAINER"
|
||||
echo " Please start the local dev environment first via 'pnpm dev:docker'."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Sanitize migrations table ──────────────────────────────────────────────
|
||||
sanitize_migrations() {
|
||||
local container="$1"
|
||||
local db_user="$2"
|
||||
local db_name="$3"
|
||||
local is_remote="$4" # "true" or "false"
|
||||
|
||||
echo "🔧 Sanitizing payload_migrations table..."
|
||||
local SQL="DELETE FROM payload_migrations WHERE batch = -1;"
|
||||
for entry in "${MIGRATIONS[@]}"; do
|
||||
local name="${entry%%:*}"
|
||||
local batch="${entry##*:}"
|
||||
SQL="$SQL INSERT INTO payload_migrations (name, batch) SELECT '$name', $batch WHERE NOT EXISTS (SELECT 1 FROM payload_migrations WHERE name = '$name');"
|
||||
done
|
||||
|
||||
if [ "$is_remote" = "true" ]; then
|
||||
ssh "$SSH_HOST" "docker exec $container psql -U $db_user -d $db_name -c \"$SQL\""
|
||||
else
|
||||
docker exec "$container" psql -U "$db_user" -d "$db_name" -c "$SQL"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Safety: Create backup before overwriting ───────────────────────────────
|
||||
backup_local_db() {
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
local file="$BACKUP_DIR/mintel_pre_sync_${TIMESTAMP}.sql.gz"
|
||||
echo "📦 Creating safety backup of local DB → $file"
|
||||
docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$LOCAL_DB_USER" -d "$LOCAL_DB_NAME" --clean --if-exists | gzip > "$file"
|
||||
echo "✅ Backup: $file ($(du -h "$file" | cut -f1))"
|
||||
LOCAL_BACKUP_FILE="$file"
|
||||
}
|
||||
|
||||
backup_remote_db() {
|
||||
local file="/tmp/mintel_pre_sync_${TIMESTAMP}.sql.gz"
|
||||
echo "📦 Creating safety backup of $TARGET DB → $SSH_HOST:$file"
|
||||
ssh "$SSH_HOST" "docker exec $REMOTE_DB_CONTAINER pg_dump -U $REMOTE_DB_USER -d $REMOTE_DB_NAME --clean --if-exists | gzip > $file"
|
||||
echo "✅ Remote backup: $file"
|
||||
REMOTE_BACKUP_FILE="$file"
|
||||
}
|
||||
|
||||
# ── Pre-flight: Verify remote containers exist ─────────────────────────────
|
||||
check_remote_containers() {
|
||||
echo "🔍 Checking $TARGET containers..."
|
||||
local missing=0
|
||||
if ! ssh "$SSH_HOST" "docker ps -q -f name=$REMOTE_DB_CONTAINER" | grep -q .; then
|
||||
echo "❌ Database container '$REMOTE_DB_CONTAINER' not found on $SSH_HOST"
|
||||
echo " → Deploy $TARGET first: push to trigger pipeline, or manually up."
|
||||
missing=1
|
||||
fi
|
||||
if ! ssh "$SSH_HOST" "docker ps -q -f name=$REMOTE_APP_CONTAINER" | grep -q .; then
|
||||
echo "❌ App container '$REMOTE_APP_CONTAINER' not found on $SSH_HOST"
|
||||
missing=1
|
||||
fi
|
||||
if [ $missing -eq 1 ]; then
|
||||
echo ""
|
||||
echo "💡 The $TARGET environment hasn't been deployed yet."
|
||||
echo " Push to the branch or run the pipeline first."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ All $TARGET containers running."
|
||||
}
|
||||
|
||||
# ── PUSH: local → remote ──────────────────────────────────────────────────
|
||||
do_push() {
|
||||
echo ""
|
||||
echo "┌──────────────────────────────────────────────────┐"
|
||||
echo "│ 📤 PUSH: local → $TARGET "
|
||||
echo "│ This will OVERWRITE the $TARGET database! "
|
||||
echo "│ A safety backup will be created first. "
|
||||
echo "└──────────────────────────────────────────────────┘"
|
||||
echo ""
|
||||
read -p "Are you sure? (y/N) " -n 1 -r
|
||||
echo ""
|
||||
[[ ! $REPLY =~ ^[Yy]$ ]] && { echo "Cancelled."; exit 0; }
|
||||
|
||||
ensure_local_db
|
||||
check_remote_containers
|
||||
backup_remote_db
|
||||
|
||||
echo "📤 Dumping local database..."
|
||||
local dump="/tmp/mintel_push_${TIMESTAMP}.sql.gz"
|
||||
docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$LOCAL_DB_USER" -d "$LOCAL_DB_NAME" --clean --if-exists | gzip > "$dump"
|
||||
|
||||
echo "📤 Transferring to $SSH_HOST..."
|
||||
scp "$dump" "$SSH_HOST:/tmp/mintel_push.sql.gz"
|
||||
|
||||
echo "🔄 Restoring database on $TARGET..."
|
||||
ssh "$SSH_HOST" "gunzip -c /tmp/mintel_push.sql.gz | docker exec -i $REMOTE_DB_CONTAINER psql -U $REMOTE_DB_USER -d $REMOTE_DB_NAME --quiet"
|
||||
|
||||
sanitize_migrations "$REMOTE_DB_CONTAINER" "$REMOTE_DB_USER" "$REMOTE_DB_NAME" "true"
|
||||
|
||||
echo "🔄 Restarting $TARGET app container..."
|
||||
ssh "$SSH_HOST" "docker restart $REMOTE_APP_CONTAINER"
|
||||
|
||||
rm -f "$dump"
|
||||
ssh "$SSH_HOST" "rm -f /tmp/mintel_push.sql.gz"
|
||||
|
||||
SYNC_SUCCESS="true"
|
||||
echo ""
|
||||
echo "✅ DB Push to $TARGET complete!"
|
||||
}
|
||||
|
||||
# ── PULL: remote → local ──────────────────────────────────────────────────
|
||||
do_pull() {
|
||||
echo ""
|
||||
echo "┌──────────────────────────────────────────────────┐"
|
||||
echo "│ 📥 PULL: $TARGET → local "
|
||||
echo "│ This will OVERWRITE your local database! "
|
||||
echo "│ A safety backup will be created first. "
|
||||
echo "└──────────────────────────────────────────────────┘"
|
||||
echo ""
|
||||
read -p "Are you sure? (y/N) " -n 1 -r
|
||||
echo ""
|
||||
[[ ! $REPLY =~ ^[Yy]$ ]] && { echo "Cancelled."; exit 0; }
|
||||
|
||||
ensure_local_db
|
||||
check_remote_containers
|
||||
backup_local_db
|
||||
|
||||
echo "📥 Dumping $TARGET database..."
|
||||
ssh "$SSH_HOST" "docker exec $REMOTE_DB_CONTAINER pg_dump -U $REMOTE_DB_USER -d $REMOTE_DB_NAME --clean --if-exists | gzip > /tmp/mintel_pull.sql.gz"
|
||||
|
||||
echo "📥 Downloading from $SSH_HOST..."
|
||||
scp "$SSH_HOST:/tmp/mintel_pull.sql.gz" "/tmp/mintel_pull.sql.gz"
|
||||
|
||||
echo "🔄 Restoring database locally..."
|
||||
gunzip -c "/tmp/mintel_pull.sql.gz" | docker exec -i "$LOCAL_DB_CONTAINER" psql -U "$LOCAL_DB_USER" -d "$LOCAL_DB_NAME" --quiet
|
||||
|
||||
sanitize_migrations "$LOCAL_DB_CONTAINER" "$LOCAL_DB_USER" "$LOCAL_DB_NAME" "false"
|
||||
|
||||
rm -f "/tmp/mintel_pull.sql.gz"
|
||||
ssh "$SSH_HOST" "rm -f /tmp/mintel_pull.sql.gz"
|
||||
|
||||
SYNC_SUCCESS="true"
|
||||
echo ""
|
||||
echo "✅ DB Pull from $TARGET complete! Restart dev server to see changes."
|
||||
}
|
||||
|
||||
# ── Main ───────────────────────────────────────────────────────────────────
|
||||
if [ -z "$DIRECTION" ] || [ -z "$TARGET" ]; then
|
||||
echo "📦 CMS Data Sync Tool (mintel.me)"
|
||||
echo ""
|
||||
echo "Usage:"
|
||||
echo " npm run cms:push:testing Push local DB → testing"
|
||||
echo " npm run cms:push:staging Push local DB → staging"
|
||||
echo " npm run cms:push:prod Push local DB → production"
|
||||
echo " npm run cms:pull:testing Pull testing DB → local"
|
||||
echo " npm run cms:pull:staging Pull staging DB → local"
|
||||
echo " npm run cms:pull:prod Pull production DB → local"
|
||||
echo ""
|
||||
echo "Safety: A backup is always created before overwriting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
resolve_target
|
||||
|
||||
case "$DIRECTION" in
|
||||
push) do_push ;;
|
||||
pull) do_pull ;;
|
||||
*)
|
||||
echo "❌ Unknown direction: $DIRECTION (use 'push' or 'pull')"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
41
apps/web/scripts/create-user.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { getPayload } from "payload";
|
||||
import configPromise from "../payload.config";
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
const existing = await payload.find({
|
||||
collection: "users",
|
||||
where: { email: { equals: "marc@mintel.me" } },
|
||||
});
|
||||
|
||||
if (existing.totalDocs > 0) {
|
||||
console.log("User already exists, updating password...");
|
||||
await payload.update({
|
||||
collection: "users",
|
||||
where: { email: { equals: "marc@mintel.me" } },
|
||||
data: {
|
||||
password: "Tim300493.",
|
||||
},
|
||||
});
|
||||
console.log("Password updated.");
|
||||
} else {
|
||||
console.log("Creating user...");
|
||||
await payload.create({
|
||||
collection: "users",
|
||||
data: {
|
||||
email: "marc@mintel.me",
|
||||
password: "Tim300493.",
|
||||
},
|
||||
});
|
||||
console.log("User marc@mintel.me created.");
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error("Failed to create user:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
99
apps/web/scripts/download-thumbnails.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import dotenv from "dotenv";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const client = new S3Client({
|
||||
region: process.env.S3_REGION || "fsn1",
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY || "",
|
||||
secretAccessKey: process.env.S3_SECRET_KEY || "",
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
async function downloadFile(key: string, localPath: string) {
|
||||
try {
|
||||
const bucket = process.env.S3_BUCKET || "mintel";
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
});
|
||||
const response = await client.send(command);
|
||||
|
||||
if (response.Body) {
|
||||
const dir = path.dirname(localPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const stream = fs.createWriteStream(localPath);
|
||||
const reader = response.Body as any;
|
||||
|
||||
// Node.js stream handling
|
||||
if (typeof reader.pipe === "function") {
|
||||
reader.pipe(stream);
|
||||
} else {
|
||||
// Alternative for web streams if necessary, but in Node it should have pipe
|
||||
const arr = await response.Body.transformToByteArray();
|
||||
fs.writeFileSync(localPath, arr);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.on("finish", resolve);
|
||||
stream.on("error", reject);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to download ${key}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
function parseMatter(content: string) {
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
||||
if (!match) return { data: {}, content };
|
||||
const data: Record<string, any> = {};
|
||||
match[1].split("\n").forEach((line) => {
|
||||
const [key, ...rest] = line.split(":");
|
||||
if (key && rest.length) {
|
||||
const field = key.trim();
|
||||
let val = rest.join(":").trim();
|
||||
data[field] = val.replace(/^["']|["']$/g, "");
|
||||
}
|
||||
});
|
||||
return { data, content: match[2].trim() };
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const webDir = path.resolve(__dirname, "..");
|
||||
const contentDir = path.join(webDir, "content", "blog");
|
||||
const publicDir = path.join(webDir, "public");
|
||||
const prefix = `${process.env.S3_PREFIX || "mintel-me"}/media/`;
|
||||
|
||||
const files = fs.readdirSync(contentDir).filter((f) => f.endsWith(".mdx"));
|
||||
|
||||
for (const file of files) {
|
||||
const content = fs.readFileSync(path.join(contentDir, file), "utf-8");
|
||||
const { data } = parseMatter(content);
|
||||
|
||||
if (data.thumbnail) {
|
||||
const fileName = path.basename(data.thumbnail);
|
||||
const s3Key = `${prefix}${fileName}`;
|
||||
const localPath = path.join(publicDir, data.thumbnail.replace(/^\//, ""));
|
||||
|
||||
console.log(`Downloading ${s3Key} to ${localPath}...`);
|
||||
await downloadFile(s3Key, localPath);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Downloads complete.");
|
||||
}
|
||||
|
||||
run();
|
||||
44
apps/web/scripts/list-s3-media.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const client = new S3Client({
|
||||
region: process.env.S3_REGION || "fsn1",
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY || "",
|
||||
secretAccessKey: process.env.S3_SECRET_KEY || "",
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
const bucket = process.env.S3_BUCKET || "mintel";
|
||||
const prefix = `${process.env.S3_PREFIX || "mintel-me"}/media/`;
|
||||
|
||||
console.log(`Listing objects in bucket: ${bucket}, prefix: ${prefix}`);
|
||||
|
||||
const command = new ListObjectsV2Command({
|
||||
Bucket: bucket,
|
||||
Prefix: prefix,
|
||||
});
|
||||
|
||||
const response = await client.send(command);
|
||||
|
||||
if (!response.Contents) {
|
||||
console.log("No objects found.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${response.Contents.length} objects:`);
|
||||
response.Contents.forEach((obj) => {
|
||||
console.log(` - ${obj.Key} (${obj.Size} bytes)`);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error listing S3 objects:", err);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -2,7 +2,7 @@ import { getPayload } from "payload";
|
||||
import configPromise from "../payload.config";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { parseMarkdownToLexical } from "../src/payload/utils/lexicalParser";
|
||||
import { parseMarkdownToLexical } from "@mintel/payload-ai";
|
||||
|
||||
function parseMatter(content: string) {
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
||||
|
||||
130
apps/web/scripts/seed-blog-posts.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { getPayload } from "payload";
|
||||
import configPromise from "../payload.config";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { parseMarkdownToLexical } from "@mintel/payload-ai";
|
||||
|
||||
function extractFrontmatter(content: string) {
|
||||
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
||||
if (!fmMatch) return {};
|
||||
const fm = fmMatch[1];
|
||||
const titleMatch = fm.match(/title:\s*"?([^"\n]+)"?/);
|
||||
const descMatch = fm.match(/description:\s*"?([^"\n]+)"?/);
|
||||
const tagsMatch = fm.match(/tags:\s*\[(.*?)\]/);
|
||||
|
||||
return {
|
||||
title: titleMatch ? titleMatch[1] : "Untitled Draft",
|
||||
description: descMatch ? descMatch[1] : "No description",
|
||||
tags: tagsMatch
|
||||
? tagsMatch[1].split(",").map((s) => s.trim().replace(/"/g, ""))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
console.log("Payload initialized.");
|
||||
|
||||
const draftsDir = path.resolve(process.cwd(), "content/drafts");
|
||||
const publicBlogDir = path.resolve(process.cwd(), "public/blog");
|
||||
|
||||
if (!fs.existsSync(draftsDir)) {
|
||||
console.log(`Drafts directory not found at ${draftsDir}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(draftsDir).filter((f) => f.endsWith(".md"));
|
||||
let count = 0;
|
||||
|
||||
for (const file of files) {
|
||||
console.log(`Processing ${file}...`);
|
||||
const filePath = path.join(draftsDir, file);
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
|
||||
const fm = extractFrontmatter(content);
|
||||
const lexicalNodes = parseMarkdownToLexical(content);
|
||||
const lexicalContent = {
|
||||
root: {
|
||||
type: "root",
|
||||
format: "" as const,
|
||||
indent: 0,
|
||||
version: 1,
|
||||
direction: "ltr" as const,
|
||||
children: lexicalNodes,
|
||||
},
|
||||
};
|
||||
|
||||
// Upload thumbnail if exists
|
||||
let featuredImageId = null;
|
||||
const thumbPath = path.join(publicBlogDir, `${file}.png`);
|
||||
if (fs.existsSync(thumbPath)) {
|
||||
console.log(`Uploading thumbnail ${file}.png...`);
|
||||
const fileData = fs.readFileSync(thumbPath);
|
||||
const stat = fs.statSync(thumbPath);
|
||||
|
||||
try {
|
||||
const newMedia = await payload.create({
|
||||
collection: "media",
|
||||
data: {
|
||||
alt: `Thumbnail for ${fm.title}`,
|
||||
},
|
||||
file: {
|
||||
data: fileData,
|
||||
name: `optimized-${file}.png`,
|
||||
mimetype: "image/png",
|
||||
size: stat.size,
|
||||
},
|
||||
});
|
||||
featuredImageId = newMedia.id;
|
||||
} catch (e) {
|
||||
console.log("Failed to upload thumbnail", e);
|
||||
}
|
||||
}
|
||||
|
||||
const tagsArray = fm.tags.map((tag) => ({ tag }));
|
||||
|
||||
const slug = fm.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "")
|
||||
.substring(0, 60);
|
||||
|
||||
// Check if already exists
|
||||
const existing = await payload.find({
|
||||
collection: "posts",
|
||||
where: { slug: { equals: slug } },
|
||||
});
|
||||
|
||||
if (existing.totalDocs === 0) {
|
||||
await payload.create({
|
||||
collection: "posts",
|
||||
data: {
|
||||
title: fm.title,
|
||||
slug: slug,
|
||||
description: fm.description,
|
||||
date: new Date().toISOString(),
|
||||
tags: tagsArray,
|
||||
featuredImage: featuredImageId,
|
||||
content: lexicalContent,
|
||||
_status: "published",
|
||||
},
|
||||
});
|
||||
console.log(`Created CMS entry for ${file}.`);
|
||||
count++;
|
||||
} else {
|
||||
console.log(`Post with slug ${slug} already exists. Skipping.`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Migration successful! Added ${count} new optimized posts to the database.`,
|
||||
);
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
console.error("Migration failed:", e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -9,7 +9,40 @@ const __dirname = path.dirname(__filename);
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
let payload;
|
||||
let retries = 5;
|
||||
while (retries > 0) {
|
||||
try {
|
||||
console.log(
|
||||
`Connecting to database (URI: ${process.env.DATABASE_URI || "default"})...`,
|
||||
);
|
||||
payload = await getPayload({ config: configPromise });
|
||||
break;
|
||||
} catch (e: any) {
|
||||
if (
|
||||
e.code === "ECONNREFUSED" ||
|
||||
e.code === "ENOTFOUND" ||
|
||||
e.message?.includes("ECONNREFUSED") ||
|
||||
e.message?.includes("ENOTFOUND") ||
|
||||
e.message?.includes("cannot connect to Postgres")
|
||||
) {
|
||||
console.log(
|
||||
`Database not ready (${e.code || "UNKNOWN"}), retrying in 3 seconds... (${retries} retries left)`,
|
||||
);
|
||||
retries--;
|
||||
await new Promise((res) => setTimeout(res, 3000));
|
||||
} else {
|
||||
console.error("Fatal connection error:", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
throw new Error(
|
||||
"Failed to connect to the database after multiple retries.",
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await payload.find({
|
||||
collection: "context-files",
|
||||
|
||||
@@ -215,8 +215,6 @@ export const AgbsPDF = ({
|
||||
companyData={companyData}
|
||||
bankData={bankData}
|
||||
footerLogo={footerLogo}
|
||||
icon={headerIcon}
|
||||
pageNumber="10"
|
||||
showPageNumber={false}
|
||||
>
|
||||
{content}
|
||||
@@ -227,7 +225,12 @@ export const AgbsPDF = ({
|
||||
return (
|
||||
<PDFPage size="A4" style={pdfStyles.page}>
|
||||
<FoldingMarks />
|
||||
<Header icon={headerIcon} showAddress={false} />
|
||||
<Header
|
||||
icon={headerIcon}
|
||||
showAddress={false}
|
||||
sender={companyData as any}
|
||||
recipient={{} as any}
|
||||
/>
|
||||
{content}
|
||||
<Footer
|
||||
logo={footerLogo}
|
||||
|
||||
@@ -47,8 +47,7 @@ export const CombinedQuotePDF = ({
|
||||
};
|
||||
|
||||
const layoutProps = {
|
||||
date,
|
||||
icon: estimationProps.headerIcon,
|
||||
headerIcon: estimationProps.headerIcon,
|
||||
footerLogo: estimationProps.footerLogo,
|
||||
companyData,
|
||||
bankData,
|
||||
@@ -73,7 +72,7 @@ export const CombinedQuotePDF = ({
|
||||
footerLogo={estimationProps.footerLogo}
|
||||
/>
|
||||
)}
|
||||
<SimpleLayout {...layoutProps} pageNumber="END" showPageNumber={false}>
|
||||
<SimpleLayout {...layoutProps} showPageNumber={false}>
|
||||
<ClosingModule />
|
||||
</SimpleLayout>
|
||||
</PDFDocument>
|
||||
|
||||
@@ -77,12 +77,17 @@ export const LocalEstimationPDF = ({
|
||||
ustId: "DE367588065",
|
||||
};
|
||||
|
||||
const bankData = {
|
||||
name: "N26",
|
||||
bic: "NTSBDEB1XXX",
|
||||
iban: "DE50 1001 1001 2620 4328 65",
|
||||
};
|
||||
|
||||
const commonProps = {
|
||||
state,
|
||||
date,
|
||||
icon: headerIcon,
|
||||
headerIcon: headerIcon,
|
||||
footerLogo,
|
||||
companyData,
|
||||
bankData,
|
||||
};
|
||||
|
||||
let pageCounter = 1;
|
||||
@@ -103,12 +108,12 @@ export const LocalEstimationPDF = ({
|
||||
{/* BriefingModule Page REMOVED as per user request ("die zweite seite ist leer, weg damit") */}
|
||||
|
||||
{state.sitemap && state.sitemap.length > 0 && (
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<SimpleLayout {...commonProps} showPageNumber={false}>
|
||||
<SitemapModule state={state} />
|
||||
</SimpleLayout>
|
||||
)}
|
||||
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<SimpleLayout {...commonProps} showPageNumber={false}>
|
||||
<EstimationModule
|
||||
state={state}
|
||||
positions={positions}
|
||||
@@ -117,11 +122,11 @@ export const LocalEstimationPDF = ({
|
||||
/>
|
||||
</SimpleLayout>
|
||||
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<SimpleLayout {...commonProps} showPageNumber={false}>
|
||||
<TransparenzModule pricing={pricing} />
|
||||
</SimpleLayout>
|
||||
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<SimpleLayout {...commonProps} showPageNumber={false}>
|
||||
<ClosingModule />
|
||||
</SimpleLayout>
|
||||
</PDFDocument>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { calculatePositions as logicCalculatePositions } from "@mintel/pdf";
|
||||
import { FormState } from "./types";
|
||||
|
||||
// @ts-ignore
|
||||
export type { Position } from "@mintel/pdf";
|
||||
|
||||
export const calculatePositions = (state: FormState, pricing: any) =>
|
||||
|
||||
@@ -130,11 +130,7 @@ const jsxConverters: JSXConverters = {
|
||||
<mdxComponents.IconList>
|
||||
{node.fields.items?.map((item: any, i: number) => (
|
||||
// @ts-ignore
|
||||
<mdxComponents.IconListItem
|
||||
key={i}
|
||||
icon={item.icon || "check"}
|
||||
title={item.title}
|
||||
>
|
||||
<mdxComponents.IconListItem key={i} icon={item.icon || "check"}>
|
||||
{item.description}
|
||||
</mdxComponents.IconListItem>
|
||||
))}
|
||||
|
||||
2868
apps/web/src/migrations/20260227_171023_crm_collections.json
Normal file
392
apps/web/src/migrations/20260227_171023_crm_collections.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TYPE "public"."enum_posts_status" AS ENUM('draft', 'published');
|
||||
CREATE TYPE "public"."enum__posts_v_version_status" AS ENUM('draft', 'published');
|
||||
CREATE TYPE "public"."enum_crm_accounts_status" AS ENUM('lead', 'client', 'lost');
|
||||
CREATE TYPE "public"."enum_crm_accounts_lead_temperature" AS ENUM('cold', 'warm', 'hot');
|
||||
CREATE TYPE "public"."enum_crm_interactions_type" AS ENUM('email', 'call', 'meeting', 'note');
|
||||
CREATE TYPE "public"."enum_crm_interactions_direction" AS ENUM('inbound', 'outbound');
|
||||
CREATE TABLE "users_sessions" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"created_at" timestamp(3) with time zone,
|
||||
"expires_at" timestamp(3) with time zone NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "users" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"email" varchar NOT NULL,
|
||||
"reset_password_token" varchar,
|
||||
"reset_password_expiration" timestamp(3) with time zone,
|
||||
"salt" varchar,
|
||||
"hash" varchar,
|
||||
"login_attempts" numeric DEFAULT 0,
|
||||
"lock_until" timestamp(3) with time zone
|
||||
);
|
||||
|
||||
CREATE TABLE "media" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"alt" varchar NOT NULL,
|
||||
"prefix" varchar DEFAULT 'mintel-me/media',
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"url" varchar,
|
||||
"thumbnail_u_r_l" varchar,
|
||||
"filename" varchar,
|
||||
"mime_type" varchar,
|
||||
"filesize" numeric,
|
||||
"width" numeric,
|
||||
"height" numeric,
|
||||
"focal_x" numeric,
|
||||
"focal_y" numeric,
|
||||
"sizes_thumbnail_url" varchar,
|
||||
"sizes_thumbnail_width" numeric,
|
||||
"sizes_thumbnail_height" numeric,
|
||||
"sizes_thumbnail_mime_type" varchar,
|
||||
"sizes_thumbnail_filesize" numeric,
|
||||
"sizes_thumbnail_filename" varchar,
|
||||
"sizes_card_url" varchar,
|
||||
"sizes_card_width" numeric,
|
||||
"sizes_card_height" numeric,
|
||||
"sizes_card_mime_type" varchar,
|
||||
"sizes_card_filesize" numeric,
|
||||
"sizes_card_filename" varchar,
|
||||
"sizes_tablet_url" varchar,
|
||||
"sizes_tablet_width" numeric,
|
||||
"sizes_tablet_height" numeric,
|
||||
"sizes_tablet_mime_type" varchar,
|
||||
"sizes_tablet_filesize" numeric,
|
||||
"sizes_tablet_filename" varchar
|
||||
);
|
||||
|
||||
CREATE TABLE "posts_tags" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"tag" varchar
|
||||
);
|
||||
|
||||
CREATE TABLE "posts" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"title" varchar,
|
||||
"slug" varchar,
|
||||
"description" varchar,
|
||||
"date" timestamp(3) with time zone,
|
||||
"featured_image_id" integer,
|
||||
"content" jsonb,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"_status" "enum_posts_status" DEFAULT 'draft'
|
||||
);
|
||||
|
||||
CREATE TABLE "_posts_v_version_tags" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"tag" varchar,
|
||||
"_uuid" varchar
|
||||
);
|
||||
|
||||
CREATE TABLE "_posts_v" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"parent_id" integer,
|
||||
"version_title" varchar,
|
||||
"version_slug" varchar,
|
||||
"version_description" varchar,
|
||||
"version_date" timestamp(3) with time zone,
|
||||
"version_featured_image_id" integer,
|
||||
"version_content" jsonb,
|
||||
"version_updated_at" timestamp(3) with time zone,
|
||||
"version_created_at" timestamp(3) with time zone,
|
||||
"version__status" "enum__posts_v_version_status" DEFAULT 'draft',
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"latest" boolean
|
||||
);
|
||||
|
||||
CREATE TABLE "inquiries" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"email" varchar NOT NULL,
|
||||
"company_name" varchar,
|
||||
"project_type" varchar,
|
||||
"message" varchar,
|
||||
"is_free_text" boolean DEFAULT false,
|
||||
"config" jsonb,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "redirects" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"from" varchar NOT NULL,
|
||||
"to" varchar NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "context_files" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"filename" varchar NOT NULL,
|
||||
"content" varchar NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "crm_accounts" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"website" varchar,
|
||||
"status" "enum_crm_accounts_status" DEFAULT 'lead',
|
||||
"lead_temperature" "enum_crm_accounts_lead_temperature",
|
||||
"assigned_to_id" integer,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "crm_accounts_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"media_id" integer
|
||||
);
|
||||
|
||||
CREATE TABLE "crm_contacts" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"first_name" varchar NOT NULL,
|
||||
"last_name" varchar NOT NULL,
|
||||
"email" varchar NOT NULL,
|
||||
"phone" varchar,
|
||||
"linked_in" varchar,
|
||||
"role" varchar,
|
||||
"account_id" integer,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "crm_interactions" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"type" "enum_crm_interactions_type" DEFAULT 'email' NOT NULL,
|
||||
"direction" "enum_crm_interactions_direction",
|
||||
"date" timestamp(3) with time zone NOT NULL,
|
||||
"contact_id" integer,
|
||||
"account_id" integer,
|
||||
"subject" varchar NOT NULL,
|
||||
"content" jsonb,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_kv" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"key" varchar NOT NULL,
|
||||
"data" jsonb NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_locked_documents" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"global_slug" varchar,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_locked_documents_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"users_id" integer,
|
||||
"media_id" integer,
|
||||
"posts_id" integer,
|
||||
"inquiries_id" integer,
|
||||
"redirects_id" integer,
|
||||
"context_files_id" integer,
|
||||
"crm_accounts_id" integer,
|
||||
"crm_contacts_id" integer,
|
||||
"crm_interactions_id" integer
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_preferences" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"key" varchar,
|
||||
"value" jsonb,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_preferences_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"users_id" integer
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_migrations" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar,
|
||||
"batch" numeric,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "ai_settings_custom_sources" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"source_name" varchar NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "ai_settings" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone,
|
||||
"created_at" timestamp(3) with time zone
|
||||
);
|
||||
|
||||
ALTER TABLE "users_sessions" ADD CONSTRAINT "users_sessions_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "posts_tags" ADD CONSTRAINT "posts_tags_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "posts" ADD CONSTRAINT "posts_featured_image_id_media_id_fk" FOREIGN KEY ("featured_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "_posts_v_version_tags" ADD CONSTRAINT "_posts_v_version_tags_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."_posts_v"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "_posts_v" ADD CONSTRAINT "_posts_v_parent_id_posts_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."posts"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "_posts_v" ADD CONSTRAINT "_posts_v_version_featured_image_id_media_id_fk" FOREIGN KEY ("version_featured_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "crm_accounts" ADD CONSTRAINT "crm_accounts_assigned_to_id_users_id_fk" FOREIGN KEY ("assigned_to_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "crm_accounts_rels" ADD CONSTRAINT "crm_accounts_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."crm_accounts"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "crm_accounts_rels" ADD CONSTRAINT "crm_accounts_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "crm_contacts" ADD CONSTRAINT "crm_contacts_account_id_crm_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."crm_accounts"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "crm_interactions" ADD CONSTRAINT "crm_interactions_contact_id_crm_contacts_id_fk" FOREIGN KEY ("contact_id") REFERENCES "public"."crm_contacts"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "crm_interactions" ADD CONSTRAINT "crm_interactions_account_id_crm_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."crm_accounts"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."payload_locked_documents"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_posts_fk" FOREIGN KEY ("posts_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_inquiries_fk" FOREIGN KEY ("inquiries_id") REFERENCES "public"."inquiries"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_redirects_fk" FOREIGN KEY ("redirects_id") REFERENCES "public"."redirects"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_context_files_fk" FOREIGN KEY ("context_files_id") REFERENCES "public"."context_files"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_crm_accounts_fk" FOREIGN KEY ("crm_accounts_id") REFERENCES "public"."crm_accounts"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_crm_contacts_fk" FOREIGN KEY ("crm_contacts_id") REFERENCES "public"."crm_contacts"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_crm_interactions_fk" FOREIGN KEY ("crm_interactions_id") REFERENCES "public"."crm_interactions"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."payload_preferences"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "ai_settings_custom_sources" ADD CONSTRAINT "ai_settings_custom_sources_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."ai_settings"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "users_sessions_order_idx" ON "users_sessions" USING btree ("_order");
|
||||
CREATE INDEX "users_sessions_parent_id_idx" ON "users_sessions" USING btree ("_parent_id");
|
||||
CREATE INDEX "users_updated_at_idx" ON "users" USING btree ("updated_at");
|
||||
CREATE INDEX "users_created_at_idx" ON "users" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree ("email");
|
||||
CREATE INDEX "media_updated_at_idx" ON "media" USING btree ("updated_at");
|
||||
CREATE INDEX "media_created_at_idx" ON "media" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "media_filename_idx" ON "media" USING btree ("filename");
|
||||
CREATE INDEX "media_sizes_thumbnail_sizes_thumbnail_filename_idx" ON "media" USING btree ("sizes_thumbnail_filename");
|
||||
CREATE INDEX "media_sizes_card_sizes_card_filename_idx" ON "media" USING btree ("sizes_card_filename");
|
||||
CREATE INDEX "media_sizes_tablet_sizes_tablet_filename_idx" ON "media" USING btree ("sizes_tablet_filename");
|
||||
CREATE INDEX "posts_tags_order_idx" ON "posts_tags" USING btree ("_order");
|
||||
CREATE INDEX "posts_tags_parent_id_idx" ON "posts_tags" USING btree ("_parent_id");
|
||||
CREATE UNIQUE INDEX "posts_slug_idx" ON "posts" USING btree ("slug");
|
||||
CREATE INDEX "posts_featured_image_idx" ON "posts" USING btree ("featured_image_id");
|
||||
CREATE INDEX "posts_updated_at_idx" ON "posts" USING btree ("updated_at");
|
||||
CREATE INDEX "posts_created_at_idx" ON "posts" USING btree ("created_at");
|
||||
CREATE INDEX "posts__status_idx" ON "posts" USING btree ("_status");
|
||||
CREATE INDEX "_posts_v_version_tags_order_idx" ON "_posts_v_version_tags" USING btree ("_order");
|
||||
CREATE INDEX "_posts_v_version_tags_parent_id_idx" ON "_posts_v_version_tags" USING btree ("_parent_id");
|
||||
CREATE INDEX "_posts_v_parent_idx" ON "_posts_v" USING btree ("parent_id");
|
||||
CREATE INDEX "_posts_v_version_version_slug_idx" ON "_posts_v" USING btree ("version_slug");
|
||||
CREATE INDEX "_posts_v_version_version_featured_image_idx" ON "_posts_v" USING btree ("version_featured_image_id");
|
||||
CREATE INDEX "_posts_v_version_version_updated_at_idx" ON "_posts_v" USING btree ("version_updated_at");
|
||||
CREATE INDEX "_posts_v_version_version_created_at_idx" ON "_posts_v" USING btree ("version_created_at");
|
||||
CREATE INDEX "_posts_v_version_version__status_idx" ON "_posts_v" USING btree ("version__status");
|
||||
CREATE INDEX "_posts_v_created_at_idx" ON "_posts_v" USING btree ("created_at");
|
||||
CREATE INDEX "_posts_v_updated_at_idx" ON "_posts_v" USING btree ("updated_at");
|
||||
CREATE INDEX "_posts_v_latest_idx" ON "_posts_v" USING btree ("latest");
|
||||
CREATE INDEX "inquiries_updated_at_idx" ON "inquiries" USING btree ("updated_at");
|
||||
CREATE INDEX "inquiries_created_at_idx" ON "inquiries" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "redirects_from_idx" ON "redirects" USING btree ("from");
|
||||
CREATE INDEX "redirects_updated_at_idx" ON "redirects" USING btree ("updated_at");
|
||||
CREATE INDEX "redirects_created_at_idx" ON "redirects" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "context_files_filename_idx" ON "context_files" USING btree ("filename");
|
||||
CREATE INDEX "context_files_updated_at_idx" ON "context_files" USING btree ("updated_at");
|
||||
CREATE INDEX "context_files_created_at_idx" ON "context_files" USING btree ("created_at");
|
||||
CREATE INDEX "crm_accounts_assigned_to_idx" ON "crm_accounts" USING btree ("assigned_to_id");
|
||||
CREATE INDEX "crm_accounts_updated_at_idx" ON "crm_accounts" USING btree ("updated_at");
|
||||
CREATE INDEX "crm_accounts_created_at_idx" ON "crm_accounts" USING btree ("created_at");
|
||||
CREATE INDEX "crm_accounts_rels_order_idx" ON "crm_accounts_rels" USING btree ("order");
|
||||
CREATE INDEX "crm_accounts_rels_parent_idx" ON "crm_accounts_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "crm_accounts_rels_path_idx" ON "crm_accounts_rels" USING btree ("path");
|
||||
CREATE INDEX "crm_accounts_rels_media_id_idx" ON "crm_accounts_rels" USING btree ("media_id");
|
||||
CREATE UNIQUE INDEX "crm_contacts_email_idx" ON "crm_contacts" USING btree ("email");
|
||||
CREATE INDEX "crm_contacts_account_idx" ON "crm_contacts" USING btree ("account_id");
|
||||
CREATE INDEX "crm_contacts_updated_at_idx" ON "crm_contacts" USING btree ("updated_at");
|
||||
CREATE INDEX "crm_contacts_created_at_idx" ON "crm_contacts" USING btree ("created_at");
|
||||
CREATE INDEX "crm_interactions_contact_idx" ON "crm_interactions" USING btree ("contact_id");
|
||||
CREATE INDEX "crm_interactions_account_idx" ON "crm_interactions" USING btree ("account_id");
|
||||
CREATE INDEX "crm_interactions_updated_at_idx" ON "crm_interactions" USING btree ("updated_at");
|
||||
CREATE INDEX "crm_interactions_created_at_idx" ON "crm_interactions" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "payload_kv_key_idx" ON "payload_kv" USING btree ("key");
|
||||
CREATE INDEX "payload_locked_documents_global_slug_idx" ON "payload_locked_documents" USING btree ("global_slug");
|
||||
CREATE INDEX "payload_locked_documents_updated_at_idx" ON "payload_locked_documents" USING btree ("updated_at");
|
||||
CREATE INDEX "payload_locked_documents_created_at_idx" ON "payload_locked_documents" USING btree ("created_at");
|
||||
CREATE INDEX "payload_locked_documents_rels_order_idx" ON "payload_locked_documents_rels" USING btree ("order");
|
||||
CREATE INDEX "payload_locked_documents_rels_parent_idx" ON "payload_locked_documents_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_path_idx" ON "payload_locked_documents_rels" USING btree ("path");
|
||||
CREATE INDEX "payload_locked_documents_rels_users_id_idx" ON "payload_locked_documents_rels" USING btree ("users_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_media_id_idx" ON "payload_locked_documents_rels" USING btree ("media_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_posts_id_idx" ON "payload_locked_documents_rels" USING btree ("posts_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_inquiries_id_idx" ON "payload_locked_documents_rels" USING btree ("inquiries_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_redirects_id_idx" ON "payload_locked_documents_rels" USING btree ("redirects_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_context_files_id_idx" ON "payload_locked_documents_rels" USING btree ("context_files_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_crm_accounts_id_idx" ON "payload_locked_documents_rels" USING btree ("crm_accounts_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_crm_contacts_id_idx" ON "payload_locked_documents_rels" USING btree ("crm_contacts_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_crm_interactions_id_idx" ON "payload_locked_documents_rels" USING btree ("crm_interactions_id");
|
||||
CREATE INDEX "payload_preferences_key_idx" ON "payload_preferences" USING btree ("key");
|
||||
CREATE INDEX "payload_preferences_updated_at_idx" ON "payload_preferences" USING btree ("updated_at");
|
||||
CREATE INDEX "payload_preferences_created_at_idx" ON "payload_preferences" USING btree ("created_at");
|
||||
CREATE INDEX "payload_preferences_rels_order_idx" ON "payload_preferences_rels" USING btree ("order");
|
||||
CREATE INDEX "payload_preferences_rels_parent_idx" ON "payload_preferences_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "payload_preferences_rels_path_idx" ON "payload_preferences_rels" USING btree ("path");
|
||||
CREATE INDEX "payload_preferences_rels_users_id_idx" ON "payload_preferences_rels" USING btree ("users_id");
|
||||
CREATE INDEX "payload_migrations_updated_at_idx" ON "payload_migrations" USING btree ("updated_at");
|
||||
CREATE INDEX "payload_migrations_created_at_idx" ON "payload_migrations" USING btree ("created_at");
|
||||
CREATE INDEX "ai_settings_custom_sources_order_idx" ON "ai_settings_custom_sources" USING btree ("_order");
|
||||
CREATE INDEX "ai_settings_custom_sources_parent_id_idx" ON "ai_settings_custom_sources" USING btree ("_parent_id");`);
|
||||
}
|
||||
|
||||
export async function down({
|
||||
db,
|
||||
payload,
|
||||
req,
|
||||
}: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
DROP TABLE "users_sessions" CASCADE;
|
||||
DROP TABLE "users" CASCADE;
|
||||
DROP TABLE "media" CASCADE;
|
||||
DROP TABLE "posts_tags" CASCADE;
|
||||
DROP TABLE "posts" CASCADE;
|
||||
DROP TABLE "_posts_v_version_tags" CASCADE;
|
||||
DROP TABLE "_posts_v" CASCADE;
|
||||
DROP TABLE "inquiries" CASCADE;
|
||||
DROP TABLE "redirects" CASCADE;
|
||||
DROP TABLE "context_files" CASCADE;
|
||||
DROP TABLE "crm_accounts" CASCADE;
|
||||
DROP TABLE "crm_accounts_rels" CASCADE;
|
||||
DROP TABLE "crm_contacts" CASCADE;
|
||||
DROP TABLE "crm_interactions" CASCADE;
|
||||
DROP TABLE "payload_kv" CASCADE;
|
||||
DROP TABLE "payload_locked_documents" CASCADE;
|
||||
DROP TABLE "payload_locked_documents_rels" CASCADE;
|
||||
DROP TABLE "payload_preferences" CASCADE;
|
||||
DROP TABLE "payload_preferences_rels" CASCADE;
|
||||
DROP TABLE "payload_migrations" CASCADE;
|
||||
DROP TABLE "ai_settings_custom_sources" CASCADE;
|
||||
DROP TABLE "ai_settings" CASCADE;
|
||||
DROP TYPE "public"."enum_posts_status";
|
||||
DROP TYPE "public"."enum__posts_v_version_status";
|
||||
DROP TYPE "public"."enum_crm_accounts_status";
|
||||
DROP TYPE "public"."enum_crm_accounts_lead_temperature";
|
||||
DROP TYPE "public"."enum_crm_interactions_type";
|
||||
DROP TYPE "public"."enum_crm_interactions_direction";`);
|
||||
}
|
||||
3671
apps/web/src/migrations/20260301_151838.json
Normal file
155
apps/web/src/migrations/20260301_151838.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TYPE "public"."enum_crm_topics_status" AS ENUM('active', 'paused', 'won', 'lost');
|
||||
CREATE TYPE "public"."enum_crm_topics_stage" AS ENUM('discovery', 'proposal', 'negotiation', 'implementation');
|
||||
CREATE TYPE "public"."enum_projects_milestones_status" AS ENUM('todo', 'in_progress', 'done');
|
||||
CREATE TYPE "public"."enum_projects_milestones_priority" AS ENUM('low', 'medium', 'high');
|
||||
CREATE TYPE "public"."enum_projects_status" AS ENUM('draft', 'in_progress', 'review', 'completed');
|
||||
ALTER TYPE "public"."enum_crm_accounts_status" ADD VALUE 'partner' BEFORE 'lost';
|
||||
ALTER TYPE "public"."enum_crm_interactions_type" ADD VALUE 'whatsapp' BEFORE 'note';
|
||||
ALTER TYPE "public"."enum_crm_interactions_type" ADD VALUE 'social' BEFORE 'note';
|
||||
ALTER TYPE "public"."enum_crm_interactions_type" ADD VALUE 'document' BEFORE 'note';
|
||||
CREATE TABLE "crm_topics" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"title" varchar NOT NULL,
|
||||
"account_id" integer NOT NULL,
|
||||
"status" "enum_crm_topics_status" DEFAULT 'active' NOT NULL,
|
||||
"stage" "enum_crm_topics_stage",
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "crm_interactions_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"media_id" integer
|
||||
);
|
||||
|
||||
CREATE TABLE "projects_milestones" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"status" "enum_projects_milestones_status" DEFAULT 'todo' NOT NULL,
|
||||
"priority" "enum_projects_milestones_priority" DEFAULT 'medium',
|
||||
"start_date" timestamp(3) with time zone,
|
||||
"target_date" timestamp(3) with time zone,
|
||||
"assignee_id" integer
|
||||
);
|
||||
|
||||
CREATE TABLE "projects" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"title" varchar NOT NULL,
|
||||
"account_id" integer NOT NULL,
|
||||
"status" "enum_projects_status" DEFAULT 'draft' NOT NULL,
|
||||
"start_date" timestamp(3) with time zone,
|
||||
"target_date" timestamp(3) with time zone,
|
||||
"value_min" numeric,
|
||||
"value_max" numeric,
|
||||
"briefing" jsonb,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "projects_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"crm_contacts_id" integer,
|
||||
"media_id" integer
|
||||
);
|
||||
|
||||
ALTER TABLE "crm_interactions" ALTER COLUMN "type" SET DEFAULT 'note';
|
||||
ALTER TABLE "inquiries" ADD COLUMN "processed" boolean DEFAULT false;
|
||||
ALTER TABLE "crm_contacts" ADD COLUMN "full_name" varchar;
|
||||
ALTER TABLE "crm_interactions" ADD COLUMN "topic_id" integer;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "crm_topics_id" integer;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "projects_id" integer;
|
||||
ALTER TABLE "crm_topics" ADD CONSTRAINT "crm_topics_account_id_crm_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."crm_accounts"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "crm_interactions_rels" ADD CONSTRAINT "crm_interactions_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."crm_interactions"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "crm_interactions_rels" ADD CONSTRAINT "crm_interactions_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "projects_milestones" ADD CONSTRAINT "projects_milestones_assignee_id_users_id_fk" FOREIGN KEY ("assignee_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "projects_milestones" ADD CONSTRAINT "projects_milestones_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "projects" ADD CONSTRAINT "projects_account_id_crm_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."crm_accounts"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "projects_rels" ADD CONSTRAINT "projects_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "projects_rels" ADD CONSTRAINT "projects_rels_crm_contacts_fk" FOREIGN KEY ("crm_contacts_id") REFERENCES "public"."crm_contacts"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "projects_rels" ADD CONSTRAINT "projects_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "crm_topics_account_idx" ON "crm_topics" USING btree ("account_id");
|
||||
CREATE INDEX "crm_topics_updated_at_idx" ON "crm_topics" USING btree ("updated_at");
|
||||
CREATE INDEX "crm_topics_created_at_idx" ON "crm_topics" USING btree ("created_at");
|
||||
CREATE INDEX "crm_interactions_rels_order_idx" ON "crm_interactions_rels" USING btree ("order");
|
||||
CREATE INDEX "crm_interactions_rels_parent_idx" ON "crm_interactions_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "crm_interactions_rels_path_idx" ON "crm_interactions_rels" USING btree ("path");
|
||||
CREATE INDEX "crm_interactions_rels_media_id_idx" ON "crm_interactions_rels" USING btree ("media_id");
|
||||
CREATE INDEX "projects_milestones_order_idx" ON "projects_milestones" USING btree ("_order");
|
||||
CREATE INDEX "projects_milestones_parent_id_idx" ON "projects_milestones" USING btree ("_parent_id");
|
||||
CREATE INDEX "projects_milestones_assignee_idx" ON "projects_milestones" USING btree ("assignee_id");
|
||||
CREATE INDEX "projects_account_idx" ON "projects" USING btree ("account_id");
|
||||
CREATE INDEX "projects_updated_at_idx" ON "projects" USING btree ("updated_at");
|
||||
CREATE INDEX "projects_created_at_idx" ON "projects" USING btree ("created_at");
|
||||
CREATE INDEX "projects_rels_order_idx" ON "projects_rels" USING btree ("order");
|
||||
CREATE INDEX "projects_rels_parent_idx" ON "projects_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "projects_rels_path_idx" ON "projects_rels" USING btree ("path");
|
||||
CREATE INDEX "projects_rels_crm_contacts_id_idx" ON "projects_rels" USING btree ("crm_contacts_id");
|
||||
CREATE INDEX "projects_rels_media_id_idx" ON "projects_rels" USING btree ("media_id");
|
||||
ALTER TABLE "crm_interactions" ADD CONSTRAINT "crm_interactions_topic_id_crm_topics_id_fk" FOREIGN KEY ("topic_id") REFERENCES "public"."crm_topics"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_crm_topics_fk" FOREIGN KEY ("crm_topics_id") REFERENCES "public"."crm_topics"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_projects_fk" FOREIGN KEY ("projects_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "crm_interactions_topic_idx" ON "crm_interactions" USING btree ("topic_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_crm_topics_id_idx" ON "payload_locked_documents_rels" USING btree ("crm_topics_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_projects_id_idx" ON "payload_locked_documents_rels" USING btree ("projects_id");`);
|
||||
}
|
||||
|
||||
export async function down({
|
||||
db,
|
||||
payload,
|
||||
req,
|
||||
}: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "crm_topics" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "crm_interactions_rels" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "projects_milestones" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "projects" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "projects_rels" DISABLE ROW LEVEL SECURITY;
|
||||
DROP TABLE "crm_topics" CASCADE;
|
||||
DROP TABLE "crm_interactions_rels" CASCADE;
|
||||
DROP TABLE "projects_milestones" CASCADE;
|
||||
DROP TABLE "projects" CASCADE;
|
||||
DROP TABLE "projects_rels" CASCADE;
|
||||
ALTER TABLE "crm_interactions" DROP CONSTRAINT "crm_interactions_topic_id_crm_topics_id_fk";
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_crm_topics_fk";
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_projects_fk";
|
||||
|
||||
ALTER TABLE "crm_accounts" ALTER COLUMN "status" SET DATA TYPE text;
|
||||
ALTER TABLE "crm_accounts" ALTER COLUMN "status" SET DEFAULT 'lead'::text;
|
||||
DROP TYPE "public"."enum_crm_accounts_status";
|
||||
CREATE TYPE "public"."enum_crm_accounts_status" AS ENUM('lead', 'client', 'lost');
|
||||
ALTER TABLE "crm_accounts" ALTER COLUMN "status" SET DEFAULT 'lead'::"public"."enum_crm_accounts_status";
|
||||
ALTER TABLE "crm_accounts" ALTER COLUMN "status" SET DATA TYPE "public"."enum_crm_accounts_status" USING "status"::"public"."enum_crm_accounts_status";
|
||||
ALTER TABLE "crm_interactions" ALTER COLUMN "type" SET DATA TYPE text;
|
||||
ALTER TABLE "crm_interactions" ALTER COLUMN "type" SET DEFAULT 'email'::text;
|
||||
DROP TYPE "public"."enum_crm_interactions_type";
|
||||
CREATE TYPE "public"."enum_crm_interactions_type" AS ENUM('email', 'call', 'meeting', 'note');
|
||||
ALTER TABLE "crm_interactions" ALTER COLUMN "type" SET DEFAULT 'email'::"public"."enum_crm_interactions_type";
|
||||
ALTER TABLE "crm_interactions" ALTER COLUMN "type" SET DATA TYPE "public"."enum_crm_interactions_type" USING "type"::"public"."enum_crm_interactions_type";
|
||||
DROP INDEX "crm_interactions_topic_idx";
|
||||
DROP INDEX "payload_locked_documents_rels_crm_topics_id_idx";
|
||||
DROP INDEX "payload_locked_documents_rels_projects_id_idx";
|
||||
ALTER TABLE "inquiries" DROP COLUMN "processed";
|
||||
ALTER TABLE "crm_contacts" DROP COLUMN "full_name";
|
||||
ALTER TABLE "crm_interactions" DROP COLUMN "topic_id";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "crm_topics_id";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "projects_id";
|
||||
DROP TYPE "public"."enum_crm_topics_status";
|
||||
DROP TYPE "public"."enum_crm_topics_stage";
|
||||
DROP TYPE "public"."enum_projects_milestones_status";
|
||||
DROP TYPE "public"."enum_projects_milestones_priority";
|
||||
DROP TYPE "public"."enum_projects_status";`);
|
||||
}
|
||||
15
apps/web/src/migrations/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import * as migration_20260227_171023_crm_collections from "./20260227_171023_crm_collections";
|
||||
import * as migration_20260301_151838 from "./20260301_151838";
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
up: migration_20260227_171023_crm_collections.up,
|
||||
down: migration_20260227_171023_crm_collections.down,
|
||||
name: "20260227_171023_crm_collections",
|
||||
},
|
||||
{
|
||||
up: migration_20260301_151838.up,
|
||||
down: migration_20260301_151838.down,
|
||||
name: "20260301_151838",
|
||||
},
|
||||
];
|
||||
155
apps/web/src/payload/collections/CrmAccounts.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { aiEndpointHandler } from "../endpoints/aiEndpoint";
|
||||
|
||||
export const CrmAccounts: CollectionConfig = {
|
||||
slug: "crm-accounts",
|
||||
labels: {
|
||||
singular: "Account",
|
||||
plural: "Accounts",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "name",
|
||||
defaultColumns: ["name", "status", "leadTemperature", "updatedAt"],
|
||||
group: "CRM",
|
||||
description:
|
||||
"Accounts represent companies or organizations. They are the central hub linking Contacts and Interactions together. Use this to track the overall relationship status.",
|
||||
},
|
||||
endpoints: [
|
||||
{
|
||||
path: "/:id/analyze",
|
||||
method: "post",
|
||||
handler: aiEndpointHandler,
|
||||
},
|
||||
],
|
||||
access: {
|
||||
read: ({ req: { user } }) => Boolean(user), // Admin only
|
||||
create: ({ req: { user } }) => Boolean(user),
|
||||
update: ({ req: { user } }) => Boolean(user),
|
||||
delete: ({ req: { user } }) => Boolean(user),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "analyzeButton",
|
||||
type: "ui",
|
||||
admin: {
|
||||
components: {
|
||||
Field: "@/src/payload/components/AiAnalyzeButton#AiAnalyzeButton",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "name",
|
||||
type: "text",
|
||||
required: true,
|
||||
label: "Company / Project Name",
|
||||
admin: {
|
||||
description:
|
||||
"Enter the official name of the business or the research project name.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "website",
|
||||
type: "text",
|
||||
label: "Website URL",
|
||||
admin: {
|
||||
description:
|
||||
"The main website of the account. Required for triggering the AI Website Analysis.",
|
||||
placeholder: "https://example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
fields: [
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Lead (Prospect)", value: "lead" },
|
||||
{ label: "Active Client", value: "client" },
|
||||
{ label: "Business Partner", value: "partner" },
|
||||
{ label: "Lost / Archive", value: "lost" },
|
||||
],
|
||||
defaultValue: "lead",
|
||||
admin: {
|
||||
width: "50%",
|
||||
description: "Current lifecycle stage of this business relation.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "leadTemperature",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "❄️ Cold (New Research)", value: "cold" },
|
||||
{ label: "🔥 Warm (In Contact)", value: "warm" },
|
||||
{ label: "⚡ Hot (Negotiation / Quote)", value: "hot" },
|
||||
],
|
||||
admin: {
|
||||
condition: (data) => {
|
||||
return data?.status === "lead";
|
||||
},
|
||||
width: "50%",
|
||||
description: "Indicates how likely this lead is to convert soon.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "assignedTo",
|
||||
type: "relationship",
|
||||
relationTo: "users",
|
||||
label: "Account Manager (User)",
|
||||
admin: {
|
||||
description: "The internal team member responsible for this account.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reports",
|
||||
type: "relationship",
|
||||
relationTo: "media",
|
||||
hasMany: true,
|
||||
label: "AI Reports & Documents",
|
||||
admin: {
|
||||
description:
|
||||
"All generated PDF estimates and strategy documents appear here.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "topics",
|
||||
type: "join",
|
||||
collection: "crm-topics",
|
||||
on: "account",
|
||||
admin: {
|
||||
description:
|
||||
"Projects, deals, or specific topics active for this client.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "contacts",
|
||||
type: "join",
|
||||
collection: "crm-contacts",
|
||||
on: "account",
|
||||
admin: {
|
||||
description: "All contacts associated with this account.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "interactions",
|
||||
type: "join",
|
||||
collection: "crm-interactions",
|
||||
on: "account",
|
||||
admin: {
|
||||
description:
|
||||
"Timeline of all communication logged against this account.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "projects",
|
||||
type: "join",
|
||||
collection: "projects",
|
||||
on: "account",
|
||||
admin: {
|
||||
description: "All high-level projects associated with this account.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
131
apps/web/src/payload/collections/CrmContacts.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
export const CrmContacts: CollectionConfig = {
|
||||
slug: "crm-contacts",
|
||||
labels: {
|
||||
singular: "Contact",
|
||||
plural: "Contacts",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "fullName",
|
||||
defaultColumns: ["fullName", "email", "account"],
|
||||
group: "CRM",
|
||||
description:
|
||||
"Contacts are the individual people linked to an Account. A person should only be created once and can be assigned to a company here.",
|
||||
},
|
||||
access: {
|
||||
read: ({ req: { user } }) => Boolean(user),
|
||||
create: ({ req: { user } }) => Boolean(user),
|
||||
update: ({ req: { user } }) => Boolean(user),
|
||||
delete: ({ req: { user } }) => Boolean(user),
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
({ data }) => {
|
||||
if (data?.firstName || data?.lastName) {
|
||||
data.fullName =
|
||||
`${data.firstName || ""} ${data.lastName || ""}`.trim();
|
||||
}
|
||||
return data;
|
||||
},
|
||||
],
|
||||
afterRead: [
|
||||
({ doc }) => {
|
||||
if (!doc.fullName && (doc.firstName || doc.lastName)) {
|
||||
return {
|
||||
...doc,
|
||||
fullName: `${doc.firstName || ""} ${doc.lastName || ""}`.trim(),
|
||||
};
|
||||
}
|
||||
return doc;
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "fullName",
|
||||
type: "text",
|
||||
admin: {
|
||||
hidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
fields: [
|
||||
{
|
||||
name: "firstName",
|
||||
type: "text",
|
||||
required: true,
|
||||
admin: {
|
||||
width: "50%",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "lastName",
|
||||
type: "text",
|
||||
required: true,
|
||||
admin: {
|
||||
width: "50%",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "email",
|
||||
type: "email",
|
||||
required: true,
|
||||
unique: true,
|
||||
admin: {
|
||||
description: "Primary email address for communication tracking.",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
fields: [
|
||||
{
|
||||
name: "phone",
|
||||
type: "text",
|
||||
admin: {
|
||||
width: "50%",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "linkedIn",
|
||||
type: "text",
|
||||
admin: {
|
||||
width: "50%",
|
||||
placeholder: "https://linkedin.com/in/...",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "role",
|
||||
type: "text",
|
||||
label: "Job Title / Role",
|
||||
admin: {
|
||||
description: "e.g. CEO, Marketing Manager, Technical Lead",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "account",
|
||||
type: "relationship",
|
||||
relationTo: "crm-accounts",
|
||||
label: "Company / Account",
|
||||
admin: {
|
||||
description:
|
||||
"Link this person to an organization from the Accounts collection.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "interactions",
|
||||
type: "join",
|
||||
collection: "crm-interactions",
|
||||
on: "contact",
|
||||
admin: {
|
||||
description:
|
||||
"Timeline of all communication logged directly with this person.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
143
apps/web/src/payload/collections/CrmInteractions.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { lexicalEditor } from "@payloadcms/richtext-lexical";
|
||||
|
||||
export const CrmInteractions: CollectionConfig = {
|
||||
slug: "crm-interactions",
|
||||
labels: {
|
||||
singular: "Journal Entry",
|
||||
plural: "Journal",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "subject",
|
||||
defaultColumns: ["type", "subject", "date", "contact", "account"],
|
||||
group: "CRM",
|
||||
description:
|
||||
"Your CRM journal. Log what happened, when, on which channel, and attach any relevant files. This is for summaries and facts — not for sending messages.",
|
||||
},
|
||||
access: {
|
||||
read: ({ req: { user } }) => Boolean(user),
|
||||
create: ({ req: { user } }) => Boolean(user),
|
||||
update: ({ req: { user } }) => Boolean(user),
|
||||
delete: ({ req: { user } }) => Boolean(user),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
type: "row",
|
||||
fields: [
|
||||
{
|
||||
name: "type",
|
||||
type: "select",
|
||||
label: "Channel",
|
||||
options: [
|
||||
{ label: "📧 Email", value: "email" },
|
||||
{ label: "📞 Phone Call", value: "call" },
|
||||
{ label: "🤝 Meeting", value: "meeting" },
|
||||
{ label: "📱 WhatsApp", value: "whatsapp" },
|
||||
{ label: "🌐 Social Media", value: "social" },
|
||||
{ label: "📄 Document / File", value: "document" },
|
||||
{ label: "📝 Internal Note", value: "note" },
|
||||
],
|
||||
required: true,
|
||||
defaultValue: "note",
|
||||
admin: {
|
||||
width: "50%",
|
||||
description: "Where did this communication take place?",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "direction",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "📥 Incoming (from Client)", value: "inbound" },
|
||||
{ label: "📤 Outgoing (to Client)", value: "outbound" },
|
||||
],
|
||||
admin: {
|
||||
hidden: true, // Hide from UI to prevent usage, but keep in DB schema to avoid Drizzle prompts
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "date",
|
||||
type: "date",
|
||||
required: true,
|
||||
defaultValue: () => new Date().toISOString(),
|
||||
admin: {
|
||||
width: "50%",
|
||||
date: {
|
||||
pickerAppearance: "dayAndTime",
|
||||
},
|
||||
description: "When did this happen?",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "subject",
|
||||
type: "text",
|
||||
required: true,
|
||||
label: "Subject / Title",
|
||||
admin: {
|
||||
placeholder: "e.g. Herr X hat Website-Relaunch beauftragt",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
fields: [
|
||||
{
|
||||
name: "contact",
|
||||
type: "relationship",
|
||||
relationTo: "crm-contacts",
|
||||
label: "Contact Person",
|
||||
admin: {
|
||||
width: "50%",
|
||||
description: "Who was involved?",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "account",
|
||||
type: "relationship",
|
||||
relationTo: "crm-accounts",
|
||||
label: "Company / Account",
|
||||
admin: {
|
||||
width: "50%",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "topic",
|
||||
type: "relationship",
|
||||
relationTo: "crm-topics",
|
||||
label: "Related Topic",
|
||||
admin: {
|
||||
description:
|
||||
"Optional: Group this entry under a specific project or topic.",
|
||||
condition: (data) => {
|
||||
return Boolean(data?.account);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "content",
|
||||
type: "richText",
|
||||
label: "Summary / Notes",
|
||||
editor: lexicalEditor({
|
||||
features: ({ defaultFeatures }) => [...defaultFeatures],
|
||||
}),
|
||||
admin: {
|
||||
description:
|
||||
"Summarize what happened, what was decided, or what the next steps are.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "attachments",
|
||||
type: "relationship",
|
||||
relationTo: "media",
|
||||
hasMany: true,
|
||||
label: "Attachments",
|
||||
admin: {
|
||||
description:
|
||||
"Attach received documents, screenshots, contracts, or any relevant files.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
78
apps/web/src/payload/collections/CrmTopics.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
export const CrmTopics: CollectionConfig = {
|
||||
slug: "crm-topics",
|
||||
labels: {
|
||||
singular: "Topic",
|
||||
plural: "Topics",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "title",
|
||||
defaultColumns: ["title", "account", "status"],
|
||||
group: "CRM",
|
||||
description:
|
||||
"Group your interactions (emails, calls, notes) into Topics. This helps you keep track of specific projects with a client.",
|
||||
},
|
||||
access: {
|
||||
read: ({ req: { user } }) => Boolean(user),
|
||||
create: ({ req: { user } }) => Boolean(user),
|
||||
update: ({ req: { user } }) => Boolean(user),
|
||||
delete: ({ req: { user } }) => Boolean(user),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "title",
|
||||
type: "text",
|
||||
required: true,
|
||||
label: "Topic Name",
|
||||
admin: {
|
||||
placeholder: "e.g. Website Relaunch 2026",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "account",
|
||||
type: "relationship",
|
||||
relationTo: "crm-accounts",
|
||||
required: true,
|
||||
label: "Client / Account",
|
||||
admin: {
|
||||
description: "Which account does this topic belong to?",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "🟢 Active / Open", value: "active" },
|
||||
{ label: "🟡 On Hold", value: "paused" },
|
||||
{ label: "🔴 Closed / Won", value: "won" },
|
||||
{ label: "⚫ Closed / Lost", value: "lost" },
|
||||
],
|
||||
defaultValue: "active",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "stage",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Discovery / Briefing", value: "discovery" },
|
||||
{ label: "Proposal / Quote sent", value: "proposal" },
|
||||
{ label: "Negotiation", value: "negotiation" },
|
||||
{ label: "Implementation", value: "implementation" },
|
||||
],
|
||||
admin: {
|
||||
description: "Optional: What stage is this deal/project currently in?",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "interactions",
|
||||
type: "join",
|
||||
collection: "crm-interactions",
|
||||
on: "topic",
|
||||
admin: {
|
||||
description:
|
||||
"Timeline of all emails and notes specifically related to this topic.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { convertInquiryEndpoint } from "../endpoints/convertInquiryEndpoint";
|
||||
|
||||
export const Inquiries: CollectionConfig = {
|
||||
slug: "inquiries",
|
||||
@@ -17,7 +18,36 @@ export const Inquiries: CollectionConfig = {
|
||||
update: ({ req: { user } }) => Boolean(user),
|
||||
delete: ({ req: { user } }) => Boolean(user),
|
||||
},
|
||||
endpoints: [
|
||||
{
|
||||
path: "/:id/convert-to-lead",
|
||||
method: "post",
|
||||
handler: convertInquiryEndpoint,
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
name: "convertButton",
|
||||
type: "ui",
|
||||
admin: {
|
||||
components: {
|
||||
Field:
|
||||
"@/src/payload/components/ConvertInquiryButton#ConvertInquiryButton",
|
||||
},
|
||||
condition: (data) => {
|
||||
return !data?.processed;
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "processed",
|
||||
type: "checkbox",
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description: "Has this inquiry been converted into a CRM Lead?",
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "name",
|
||||
type: "text",
|
||||
|
||||
192
apps/web/src/payload/collections/Projects.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
export const Projects: CollectionConfig = {
|
||||
slug: "projects",
|
||||
labels: {
|
||||
singular: "Project",
|
||||
plural: "Projects",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "title",
|
||||
defaultColumns: ["title", "account", "status", "startDate", "targetDate"],
|
||||
group: "Project Management",
|
||||
description: "Manage high-level projects for your clients.",
|
||||
components: {
|
||||
beforeListTable: ["@/src/payload/views/GanttChart#GanttChartView"],
|
||||
},
|
||||
},
|
||||
access: {
|
||||
read: ({ req: { user } }) => Boolean(user),
|
||||
create: ({ req: { user } }) => Boolean(user),
|
||||
update: ({ req: { user } }) => Boolean(user),
|
||||
delete: ({ req: { user } }) => Boolean(user),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "title",
|
||||
type: "text",
|
||||
required: true,
|
||||
label: "Project Title",
|
||||
},
|
||||
{
|
||||
name: "account",
|
||||
type: "relationship",
|
||||
relationTo: "crm-accounts",
|
||||
required: true,
|
||||
label: "Client / Account",
|
||||
admin: {
|
||||
description: "Which account is this project for?",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "contact",
|
||||
type: "relationship",
|
||||
relationTo: "crm-contacts",
|
||||
hasMany: true,
|
||||
label: "Project Stakeholders",
|
||||
admin: {
|
||||
description:
|
||||
"Key contacts from the client side involved in this project.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Draft", value: "draft" },
|
||||
{ label: "In Progress", value: "in_progress" },
|
||||
{ label: "Review", value: "review" },
|
||||
{ label: "Completed", value: "completed" },
|
||||
],
|
||||
defaultValue: "draft",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
fields: [
|
||||
{
|
||||
name: "startDate",
|
||||
type: "date",
|
||||
label: "Start Date",
|
||||
admin: { width: "25%" },
|
||||
},
|
||||
{
|
||||
name: "targetDate",
|
||||
type: "date",
|
||||
label: "Target Date",
|
||||
admin: { width: "25%" },
|
||||
},
|
||||
{
|
||||
name: "valueMin",
|
||||
type: "number",
|
||||
label: "Value From (€)",
|
||||
admin: {
|
||||
width: "25%",
|
||||
placeholder: "z.B. 5000",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valueMax",
|
||||
type: "number",
|
||||
label: "Value To (€)",
|
||||
admin: {
|
||||
width: "25%",
|
||||
placeholder: "z.B. 8000",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "briefing",
|
||||
type: "richText",
|
||||
label: "Briefing",
|
||||
admin: {
|
||||
description: "Project briefing, requirements, or notes.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "attachments",
|
||||
type: "upload",
|
||||
relationTo: "media",
|
||||
hasMany: true,
|
||||
label: "Attachments",
|
||||
admin: {
|
||||
description:
|
||||
"Upload files, documents, or assets related to this project.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "milestones",
|
||||
type: "array",
|
||||
label: "Milestones",
|
||||
admin: {
|
||||
description: "Granular deliverables or milestones within this project.",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "name",
|
||||
type: "text",
|
||||
required: true,
|
||||
label: "Milestone Name",
|
||||
admin: {
|
||||
placeholder: "e.g. Authentication System",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
fields: [
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "To Do", value: "todo" },
|
||||
{ label: "In Progress", value: "in_progress" },
|
||||
{ label: "Done", value: "done" },
|
||||
],
|
||||
defaultValue: "todo",
|
||||
required: true,
|
||||
admin: { width: "50%" },
|
||||
},
|
||||
{
|
||||
name: "priority",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Low", value: "low" },
|
||||
{ label: "Medium", value: "medium" },
|
||||
{ label: "High", value: "high" },
|
||||
],
|
||||
defaultValue: "medium",
|
||||
admin: { width: "50%" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
fields: [
|
||||
{
|
||||
name: "startDate",
|
||||
type: "date",
|
||||
label: "Start Date",
|
||||
admin: { width: "50%" },
|
||||
},
|
||||
{
|
||||
name: "targetDate",
|
||||
type: "date",
|
||||
label: "Target Date",
|
||||
admin: { width: "50%" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "assignee",
|
||||
type: "relationship",
|
||||
relationTo: "users",
|
||||
label: "Assignee",
|
||||
admin: {
|
||||
description: "Internal team member responsible for this milestone.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
100
apps/web/src/payload/components/AiAnalyzeButton.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useDocumentInfo } from "@payloadcms/ui";
|
||||
import { toast } from "@payloadcms/ui";
|
||||
|
||||
export const AiAnalyzeButton: React.FC = () => {
|
||||
const { id } = useDocumentInfo();
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [hasWebsite, setHasWebsite] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Basic check if a website URL is likely present - would ideally check true document state
|
||||
// but the fields might not be fully available in this context depending on Payload version.
|
||||
// For now we just enable the button and the backend will validate.
|
||||
setHasWebsite(true);
|
||||
}, []);
|
||||
|
||||
const handleAnalyze = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (!id) return;
|
||||
|
||||
setIsAnalyzing(true);
|
||||
toast.info(
|
||||
"Starting AI analysis for this account. This may take a few minutes...",
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/crm-accounts/${id}/analyze`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || "Analysis failed");
|
||||
}
|
||||
|
||||
toast.success(
|
||||
result.message ||
|
||||
"Analysis started in background. The page will update when finished.",
|
||||
);
|
||||
// Removed router.refresh() here because the background task takes ~60s
|
||||
} catch (error) {
|
||||
console.error("Analysis error:", error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "An error occurred during analysis",
|
||||
);
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!id) return null; // Only show on existing documents, not when creating new
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: "2rem", marginTop: "1rem" }}>
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
disabled={isAnalyzing || !hasWebsite}
|
||||
className="btn btn--style-primary btn--icon-style-none btn--size-medium"
|
||||
type="button"
|
||||
style={{
|
||||
background: "var(--theme-elevation-150)",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
color: "var(--theme-text)",
|
||||
padding: "8px 16px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "14px",
|
||||
cursor: isAnalyzing || !hasWebsite ? "not-allowed" : "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
opacity: isAnalyzing || !hasWebsite ? 0.6 : 1,
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
{isAnalyzing ? "✨ AI analysiert..." : "✨ AI Website Analyse starten"}
|
||||
</button>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.85rem",
|
||||
color: "var(--theme-elevation-600)",
|
||||
marginTop: "0.75rem",
|
||||
maxWidth: "400px",
|
||||
lineHeight: "1.4",
|
||||
}}
|
||||
>
|
||||
<strong>Note:</strong> This will crawl the website, generate a strategy
|
||||
concept, and create a budget estimation. The resulting PDFs will be
|
||||
attached to the "AI Reports" field below.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
88
apps/web/src/payload/components/ConvertInquiryButton.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useDocumentInfo } from "@payloadcms/ui";
|
||||
import { toast } from "@payloadcms/ui";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export const ConvertInquiryButton: React.FC = () => {
|
||||
const { id } = useDocumentInfo();
|
||||
const router = useRouter();
|
||||
const [isConverting, setIsConverting] = useState(false);
|
||||
|
||||
const handleConvert = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (!id) return;
|
||||
|
||||
setIsConverting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/inquiries/${id}/convert-to-lead`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || "Conversion failed");
|
||||
}
|
||||
|
||||
toast.success(result.message || "Successfully converted to Lead!");
|
||||
// Redirect to the new account
|
||||
router.push(`/admin/collections/crm-accounts/${result.accountId}`);
|
||||
} catch (error) {
|
||||
console.error("Conversion error:", error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "An error occurred during conversion",
|
||||
);
|
||||
} finally {
|
||||
setIsConverting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!id) return null; // Only show on existing documents
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: "2rem", marginTop: "1rem" }}>
|
||||
<button
|
||||
onClick={handleConvert}
|
||||
disabled={isConverting}
|
||||
className="btn btn--style-primary btn--icon-style-none btn--size-medium"
|
||||
type="button"
|
||||
style={{
|
||||
background: "var(--theme-elevation-150)",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
color: "var(--theme-text)",
|
||||
padding: "8px 16px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "14px",
|
||||
cursor: isConverting ? "not-allowed" : "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
opacity: isConverting ? 0.6 : 1,
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
{isConverting ? "🔄 Konvertiere..." : "🎯 Lead in CRM anlegen"}
|
||||
</button>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.85rem",
|
||||
color: "var(--theme-elevation-600)",
|
||||
marginTop: "0.75rem",
|
||||
maxWidth: "400px",
|
||||
lineHeight: "1.4",
|
||||
}}
|
||||
>
|
||||
<strong>Info:</strong> Creates a new CRM Account, Contact, and logs the
|
||||
inquiry message in the CRM Journal.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
226
apps/web/src/payload/endpoints/aiEndpoint.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import type { PayloadRequest, PayloadHandler } from "payload";
|
||||
import { ConceptPipeline } from "@mintel/concept-engine";
|
||||
import { EstimationPipeline } from "@mintel/estimation-engine";
|
||||
import { PdfEngine } from "@mintel/pdf/server";
|
||||
import * as path from "node:path";
|
||||
import * as fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
|
||||
export const aiEndpointHandler: PayloadHandler = async (
|
||||
req: PayloadRequest,
|
||||
) => {
|
||||
const { id } = req.routeParams;
|
||||
const payload = req.payload;
|
||||
|
||||
try {
|
||||
// 1. Fetch the account
|
||||
const account = await payload.findByID({
|
||||
collection: "crm-accounts",
|
||||
id: String(id),
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
return Response.json({ error: "Account not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!account.website) {
|
||||
return Response.json(
|
||||
{ error: "Account does not have a website URL" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const targetUrl = account.website;
|
||||
|
||||
// 2. Immediate Response
|
||||
const response = Response.json(
|
||||
{
|
||||
message:
|
||||
"Analysis started in background. This will take ~60 seconds. You can safely close or navigate away from this page.",
|
||||
},
|
||||
{ status: 202 },
|
||||
);
|
||||
|
||||
// 3. Fire and Forget Background Task
|
||||
const runBackgroundAnalysis = async () => {
|
||||
let tempDir = "";
|
||||
try {
|
||||
const OPENROUTER_KEY =
|
||||
process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
|
||||
if (!OPENROUTER_KEY) {
|
||||
console.error(
|
||||
"AI Analysis Failed: OPENROUTER_API_KEY not configured",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crm-analysis-"));
|
||||
const monorepoRoot = path.resolve(process.cwd(), "../../");
|
||||
const crawlDir = path.join(
|
||||
path.resolve(monorepoRoot, "../at-mintel"),
|
||||
"data/crawls",
|
||||
);
|
||||
|
||||
const conceptPipeline = new ConceptPipeline({
|
||||
openrouterKey: OPENROUTER_KEY,
|
||||
zyteApiKey: process.env.ZYTE_API_KEY,
|
||||
outputDir: tempDir,
|
||||
crawlDir,
|
||||
});
|
||||
|
||||
const engine = new PdfEngine();
|
||||
|
||||
console.log(
|
||||
`[AI Analysis] Starting concept pipeline for ${targetUrl}...`,
|
||||
);
|
||||
const conceptResult = await conceptPipeline.run({
|
||||
briefing: targetUrl,
|
||||
url: targetUrl,
|
||||
comments: "Generated from CRM Analysis endpoint",
|
||||
clearCache: false,
|
||||
});
|
||||
|
||||
const companyName =
|
||||
conceptResult.auditedFacts?.companyName || account.name || "Company";
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const mediaIds: number[] = [];
|
||||
|
||||
// Attempt Concept PDF
|
||||
const conceptPdfPath = path.join(tempDir, `${companyName}_Konzept.pdf`);
|
||||
let conceptPdfSuccess = false;
|
||||
try {
|
||||
await (engine as any).generateConceptPdf(
|
||||
conceptResult,
|
||||
conceptPdfPath,
|
||||
);
|
||||
const conceptPdfBuffer = await fs.readFile(conceptPdfPath);
|
||||
const conceptMedia = await payload.create({
|
||||
collection: "media",
|
||||
data: { alt: `Concept PDF for ${companyName}` },
|
||||
file: {
|
||||
data: conceptPdfBuffer,
|
||||
mimetype: "application/pdf",
|
||||
name: `${companyName}_Konzept_${timestamp}.pdf`,
|
||||
size: conceptPdfBuffer.byteLength,
|
||||
},
|
||||
});
|
||||
mediaIds.push(Number(conceptMedia.id));
|
||||
conceptPdfSuccess = true;
|
||||
console.log(
|
||||
`[AI Analysis] Concept PDF generated and saved (Media ID: ${conceptMedia.id})`,
|
||||
);
|
||||
} catch (pdfErr) {
|
||||
console.error(
|
||||
`[AI Analysis] Failed to generate Concept PDF:`,
|
||||
pdfErr,
|
||||
);
|
||||
}
|
||||
|
||||
// If Concept PDF failed, save the raw JSON as a text file so data isn't lost
|
||||
if (!conceptPdfSuccess) {
|
||||
const jsonPath = path.join(
|
||||
tempDir,
|
||||
`${companyName}_Concept_Raw.json`,
|
||||
);
|
||||
await fs.writeFile(jsonPath, JSON.stringify(conceptResult, null, 2));
|
||||
const jsonBuffer = await fs.readFile(jsonPath);
|
||||
const jsonMedia = await payload.create({
|
||||
collection: "media",
|
||||
data: { alt: `Raw Concept JSON for ${companyName}` },
|
||||
file: {
|
||||
data: jsonBuffer,
|
||||
mimetype: "application/json",
|
||||
name: `${companyName}_Concept_Raw_${timestamp}.json`,
|
||||
size: jsonBuffer.byteLength,
|
||||
},
|
||||
});
|
||||
mediaIds.push(Number(jsonMedia.id));
|
||||
console.log(
|
||||
`[AI Analysis] Saved Raw Concept JSON as fallback (Media ID: ${jsonMedia.id})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Run Estimation Pipeline
|
||||
console.log(
|
||||
`[AI Analysis] Starting estimation pipeline for ${targetUrl}...`,
|
||||
);
|
||||
const estimationPipeline = new EstimationPipeline({
|
||||
openrouterKey: OPENROUTER_KEY,
|
||||
outputDir: tempDir,
|
||||
crawlDir: "", // not needed here
|
||||
});
|
||||
|
||||
const estimationResult = await estimationPipeline.run({
|
||||
concept: conceptResult,
|
||||
budget: "",
|
||||
});
|
||||
|
||||
if (estimationResult.formState) {
|
||||
const estimationPdfPath = path.join(
|
||||
tempDir,
|
||||
`${companyName}_Angebot.pdf`,
|
||||
);
|
||||
try {
|
||||
await engine.generateEstimatePdf(
|
||||
estimationResult.formState,
|
||||
estimationPdfPath,
|
||||
);
|
||||
const estPdfBuffer = await fs.readFile(estimationPdfPath);
|
||||
const estMedia = await payload.create({
|
||||
collection: "media",
|
||||
data: { alt: `Estimation PDF for ${companyName}` },
|
||||
file: {
|
||||
data: estPdfBuffer,
|
||||
mimetype: "application/pdf",
|
||||
name: `${companyName}_Angebot_${timestamp}.pdf`,
|
||||
size: estPdfBuffer.byteLength,
|
||||
},
|
||||
});
|
||||
mediaIds.push(Number(estMedia.id));
|
||||
console.log(
|
||||
`[AI Analysis] Estimation PDF generated and saved (Media ID: ${estMedia.id})`,
|
||||
);
|
||||
} catch (estPdfErr) {
|
||||
console.error(
|
||||
`[AI Analysis] Failed to generate Estimation PDF:`,
|
||||
estPdfErr,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update Account with new reports
|
||||
const existingReports = (account.reports || []).map((r: any) =>
|
||||
typeof r === "number" ? r : Number(r.id || r),
|
||||
);
|
||||
|
||||
await payload.update({
|
||||
collection: "crm-accounts",
|
||||
id: String(id),
|
||||
data: {
|
||||
reports: [...existingReports, ...mediaIds],
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
`[AI Analysis] Successfully attached ${mediaIds.length} media items to Account ${id}`,
|
||||
);
|
||||
} catch (bgError) {
|
||||
console.error("[AI Analysis] Fatal Background Flow Error:", bgError);
|
||||
} finally {
|
||||
if (tempDir) {
|
||||
fs.rm(tempDir, { recursive: true, force: true }).catch(console.error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Start background task
|
||||
runBackgroundAnalysis();
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("AI Endpoint Initial Error:", error);
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
89
apps/web/src/payload/endpoints/convertInquiryEndpoint.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { PayloadRequest, PayloadHandler } from "payload";
|
||||
|
||||
export const convertInquiryEndpoint: PayloadHandler = async (
|
||||
req: PayloadRequest,
|
||||
) => {
|
||||
const { id } = req.routeParams;
|
||||
const payload = req.payload;
|
||||
|
||||
try {
|
||||
const inquiry = await payload.findByID({
|
||||
collection: "inquiries",
|
||||
id: String(id),
|
||||
});
|
||||
|
||||
if (!inquiry) {
|
||||
return Response.json({ error: "Inquiry not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if ((inquiry as any).processed) {
|
||||
return Response.json(
|
||||
{ error: "Inquiry is already processed" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Create CrmAccount
|
||||
const companyName = inquiry.companyName || inquiry.name;
|
||||
const account = await payload.create({
|
||||
collection: "crm-accounts",
|
||||
data: {
|
||||
name: companyName,
|
||||
status: "lead",
|
||||
leadTemperature: "warm", // Warm because they reached out
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Create CrmContact
|
||||
const contact = await payload.create({
|
||||
collection: "crm-contacts",
|
||||
data: {
|
||||
firstName: inquiry.name.split(" ")[0] || inquiry.name,
|
||||
lastName: inquiry.name.split(" ").slice(1).join(" ") || "",
|
||||
email: inquiry.email,
|
||||
account: account.id,
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Create CrmInteraction (Journal)
|
||||
let journalSummary = `User submitted an inquiry.\n\nProject Type: ${inquiry.projectType || "N/A"}`;
|
||||
if (inquiry.message) {
|
||||
journalSummary += `\n\nMessage:\n${inquiry.message}`;
|
||||
}
|
||||
if (inquiry.config) {
|
||||
journalSummary += `\n\nConfigData:\n${JSON.stringify(inquiry.config, null, 2)}`;
|
||||
}
|
||||
|
||||
await payload.create({
|
||||
collection: "crm-interactions",
|
||||
data: {
|
||||
subject: `Website Anfrage: ${inquiry.projectType || "Allgemein"}`,
|
||||
type: "email", // Use "type" field underneath ("channel" label)
|
||||
date: new Date().toISOString(),
|
||||
summary: journalSummary,
|
||||
account: account.id,
|
||||
contact: contact.id,
|
||||
} as any,
|
||||
});
|
||||
|
||||
// 4. Mark Inquiry as processed
|
||||
await payload.update({
|
||||
collection: "inquiries",
|
||||
id: String(id),
|
||||
data: {
|
||||
processed: true,
|
||||
} as any,
|
||||
});
|
||||
|
||||
return Response.json({
|
||||
message: "Inquiry successfully converted to CRM Lead.",
|
||||
accountId: account.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Convert inquiry error:", error);
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
125
apps/web/src/payload/endpoints/emailWebhook.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { PayloadRequest, PayloadHandler } from "payload";
|
||||
|
||||
// Expected payload from Stalwart Webhook (Simplified for this use case)
|
||||
interface StalwartWebhookPayload {
|
||||
msgId: string;
|
||||
from: string;
|
||||
to: string[];
|
||||
subject: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export const emailWebhookHandler: PayloadHandler = async (
|
||||
req: PayloadRequest,
|
||||
) => {
|
||||
const payload = req.payload;
|
||||
|
||||
try {
|
||||
// 1. Authenticate webhook (e.g., via query param or header secret)
|
||||
const token = req.query?.token;
|
||||
const EXPECTED_TOKEN = process.env.STALWART_WEBHOOK_SECRET;
|
||||
|
||||
// If a secret is configured, enforce it.
|
||||
if (EXPECTED_TOKEN && token !== EXPECTED_TOKEN) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const data: StalwartWebhookPayload = await req.json();
|
||||
|
||||
// 2. Extract sender email
|
||||
// Stalwart from field might look like "John Doe <john@example.com>"
|
||||
const emailMatch = data.from.match(/<([^>]+)>/);
|
||||
const senderEmail = emailMatch
|
||||
? emailMatch[1].toLowerCase()
|
||||
: data.from.toLowerCase();
|
||||
|
||||
if (!senderEmail) {
|
||||
return Response.json({ error: "No sender email found" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 3. Find matching CrmContact
|
||||
const contactsRes = await payload.find({
|
||||
collection: "crm-contacts",
|
||||
where: {
|
||||
email: {
|
||||
equals: senderEmail,
|
||||
},
|
||||
},
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const contact = contactsRes.docs[0];
|
||||
|
||||
// If no contact, we can either drop it, or create a lead. For now, dropping/logging is safer.
|
||||
if (!contact) {
|
||||
console.log(
|
||||
`[Stalwart Webhook] Ignored email from unknown sender: ${senderEmail}`,
|
||||
);
|
||||
return Response.json(
|
||||
{ message: "Ignored: Sender not found in CRM" },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Create Interaction log
|
||||
const accountId =
|
||||
typeof contact.account === "object"
|
||||
? contact.account?.id
|
||||
: contact.account;
|
||||
|
||||
// In Payload's Lexical editor, a simple paragraph can be represented like this if we want to bypass rich text parsing for now,
|
||||
// or we can assign the raw HTML strings if the custom component parser supports it.
|
||||
// To strictly pass TypeScript for a default Lexical configuration:
|
||||
const lexContent = {
|
||||
root: {
|
||||
type: "root",
|
||||
format: "" as const,
|
||||
indent: 0,
|
||||
version: 1,
|
||||
children: [
|
||||
{
|
||||
type: "paragraph",
|
||||
format: "" as const,
|
||||
indent: 0,
|
||||
version: 1,
|
||||
children: [
|
||||
{
|
||||
type: "text",
|
||||
detail: 0,
|
||||
format: 0,
|
||||
mode: "normal",
|
||||
style: "",
|
||||
text: data.html || data.text || "No content provided",
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
direction: "ltr" as const,
|
||||
},
|
||||
};
|
||||
|
||||
await payload.create({
|
||||
collection: "crm-interactions",
|
||||
data: {
|
||||
type: "email",
|
||||
direction: "inbound",
|
||||
date: new Date(data.date).toISOString(),
|
||||
subject: data.subject || "No Subject",
|
||||
contact: contact.id,
|
||||
account: accountId,
|
||||
content: lexContent,
|
||||
},
|
||||
});
|
||||
|
||||
return Response.json({ message: "Email logged successfully" });
|
||||
} catch (error) {
|
||||
console.error("Stalwart Webhook Error:", error);
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
0
apps/web/src/payload/endpoints/projectsEndpoint.ts
Normal file
214
apps/web/src/payload/views/GanttChart/GanttChart.css
Normal file
@@ -0,0 +1,214 @@
|
||||
/* ─── Gantt Widget (embedded in Projects list) ─── */
|
||||
|
||||
.gantt-widget {
|
||||
border: 1px solid var(--theme-elevation-150);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
overflow: hidden;
|
||||
background: var(--theme-elevation-50);
|
||||
}
|
||||
|
||||
.gantt-widget__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: var(--theme-elevation-100);
|
||||
border-bottom: 1px solid var(--theme-elevation-150);
|
||||
}
|
||||
|
||||
.gantt-widget__header:hover {
|
||||
background: var(--theme-elevation-150);
|
||||
}
|
||||
|
||||
.gantt-widget__title {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.gantt-widget__count {
|
||||
font-weight: 400;
|
||||
font-size: 0.75rem;
|
||||
color: var(--theme-elevation-500);
|
||||
background: var(--theme-elevation-200);
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
.gantt-widget__toggle {
|
||||
font-size: 1rem;
|
||||
color: var(--theme-elevation-400);
|
||||
}
|
||||
|
||||
.gantt-widget__body {
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gantt-widget__empty {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--theme-elevation-400);
|
||||
text-align: center;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
/* ─── Timeline header (months) ─── */
|
||||
|
||||
.gantt-timeline__header {
|
||||
position: relative;
|
||||
height: 24px;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid var(--theme-elevation-150);
|
||||
}
|
||||
|
||||
.gantt-timeline__month {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--theme-elevation-400);
|
||||
text-transform: uppercase;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ─── Today line ─── */
|
||||
|
||||
.gantt-timeline__today {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
bottom: 16px;
|
||||
width: 2px;
|
||||
background: var(--theme-error-500);
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gantt-timeline__today-label {
|
||||
position: absolute;
|
||||
top: -18px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
color: var(--theme-error-500);
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ─── Rows ─── */
|
||||
|
||||
.gantt-timeline__rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gantt-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
border-bottom: 1px solid var(--theme-elevation-100);
|
||||
}
|
||||
|
||||
.gantt-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.gantt-row--project {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.gantt-row--milestone {
|
||||
font-size: 0.8rem;
|
||||
color: var(--theme-elevation-600);
|
||||
}
|
||||
|
||||
/* ─── Labels ─── */
|
||||
|
||||
.gantt-row__label {
|
||||
min-width: 180px;
|
||||
max-width: 220px;
|
||||
padding-right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gantt-row__label--indent {
|
||||
padding-left: 20px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.gantt-row__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gantt-row__priority {
|
||||
font-size: 0.6rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gantt-row__link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.gantt-row__link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ─── Bars ─── */
|
||||
|
||||
.gantt-row__bar-area {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.gantt-bar {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-radius: 4px;
|
||||
height: 16px;
|
||||
min-width: 4px;
|
||||
}
|
||||
|
||||
.gantt-bar--project {
|
||||
height: 20px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.gantt-bar--milestone {
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.gantt-bar__label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
white-space: nowrap;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
pointer-events: none;
|
||||
}
|
||||
255
apps/web/src/payload/views/GanttChart/index.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import "./GanttChart.css";
|
||||
|
||||
type Milestone = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "todo" | "in_progress" | "done";
|
||||
priority: "low" | "medium" | "high";
|
||||
startDate?: string;
|
||||
targetDate?: string;
|
||||
};
|
||||
|
||||
type Project = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: "draft" | "in_progress" | "review" | "completed";
|
||||
startDate?: string;
|
||||
targetDate?: string;
|
||||
milestones?: Milestone[];
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
draft: "#94a3b8",
|
||||
in_progress: "#3b82f6",
|
||||
review: "#f59e0b",
|
||||
completed: "#22c55e",
|
||||
todo: "#94a3b8",
|
||||
done: "#22c55e",
|
||||
};
|
||||
|
||||
const PRIORITY_LABELS: Record<string, string> = {
|
||||
low: "▽",
|
||||
medium: "◆",
|
||||
high: "▲",
|
||||
};
|
||||
|
||||
export const GanttChartView: React.FC = () => {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/projects?limit=100&depth=0");
|
||||
if (!res.ok) return;
|
||||
const json = await res.json();
|
||||
setProjects(json.docs || []);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
// Calculate timeline bounds
|
||||
const allDates: number[] = [];
|
||||
projects.forEach((p) => {
|
||||
if (p.startDate) allDates.push(new Date(p.startDate).getTime());
|
||||
if (p.targetDate) allDates.push(new Date(p.targetDate).getTime());
|
||||
p.milestones?.forEach((m) => {
|
||||
if (m.startDate) allDates.push(new Date(m.startDate).getTime());
|
||||
if (m.targetDate) allDates.push(new Date(m.targetDate).getTime());
|
||||
});
|
||||
});
|
||||
|
||||
const minDate = allDates.length > 0 ? Math.min(...allDates) : Date.now();
|
||||
const maxDate =
|
||||
allDates.length > 0 ? Math.max(...allDates) : Date.now() + 86400000 * 90;
|
||||
const totalSpan = Math.max(maxDate - minDate, 86400000); // at least 1 day
|
||||
|
||||
const getBarStyle = (start?: string, end?: string) => {
|
||||
if (!start && !end) return null;
|
||||
const s = start ? new Date(start).getTime() : minDate;
|
||||
const e = end ? new Date(end).getTime() : maxDate;
|
||||
const left = ((s - minDate) / totalSpan) * 100;
|
||||
const width = Math.max(((e - s) / totalSpan) * 100, 1);
|
||||
return { left: `${left}%`, width: `${width}%` };
|
||||
};
|
||||
|
||||
const formatDate = (d?: string) => {
|
||||
if (!d) return "";
|
||||
return new Date(d).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
});
|
||||
};
|
||||
|
||||
// Generate month markers
|
||||
const monthMarkers: { label: string; left: number }[] = [];
|
||||
if (allDates.length > 0) {
|
||||
const startMonth = new Date(minDate);
|
||||
startMonth.setDate(1);
|
||||
const endMonth = new Date(maxDate);
|
||||
const cursor = new Date(startMonth);
|
||||
while (cursor <= endMonth) {
|
||||
const pos = ((cursor.getTime() - minDate) / totalSpan) * 100;
|
||||
if (pos >= 0 && pos <= 100) {
|
||||
monthMarkers.push({
|
||||
label: cursor.toLocaleDateString("de-DE", {
|
||||
month: "short",
|
||||
year: "2-digit",
|
||||
}),
|
||||
left: pos,
|
||||
});
|
||||
}
|
||||
cursor.setMonth(cursor.getMonth() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const hasDates = allDates.length >= 2;
|
||||
|
||||
if (loading) return null;
|
||||
if (projects.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="gantt-widget">
|
||||
<div
|
||||
className="gantt-widget__header"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
>
|
||||
<span className="gantt-widget__title">
|
||||
📊 Timeline
|
||||
<span className="gantt-widget__count">
|
||||
{projects.length} Projects
|
||||
</span>
|
||||
</span>
|
||||
<span className="gantt-widget__toggle">{collapsed ? "▸" : "▾"}</span>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="gantt-widget__body">
|
||||
{!hasDates ? (
|
||||
<p className="gantt-widget__empty">
|
||||
Add start and target dates to your projects to see the timeline.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Month markers */}
|
||||
<div className="gantt-timeline__header">
|
||||
{monthMarkers.map((m, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="gantt-timeline__month"
|
||||
style={{ left: `${m.left}%` }}
|
||||
>
|
||||
{m.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Today marker */}
|
||||
{(() => {
|
||||
const todayPos = ((Date.now() - minDate) / totalSpan) * 100;
|
||||
if (todayPos >= 0 && todayPos <= 100) {
|
||||
return (
|
||||
<div
|
||||
className="gantt-timeline__today"
|
||||
style={{ left: `${todayPos}%` }}
|
||||
>
|
||||
<span className="gantt-timeline__today-label">Today</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{/* Project rows */}
|
||||
<div className="gantt-timeline__rows">
|
||||
{projects.map((project) => (
|
||||
<React.Fragment key={project.id}>
|
||||
<div className="gantt-row gantt-row--project">
|
||||
<div className="gantt-row__label">
|
||||
<span
|
||||
className="gantt-row__dot"
|
||||
style={{
|
||||
backgroundColor: STATUS_COLORS[project.status],
|
||||
}}
|
||||
/>
|
||||
<a
|
||||
href={`/admin/collections/projects/${project.id}`}
|
||||
className="gantt-row__link"
|
||||
>
|
||||
{project.title}
|
||||
</a>
|
||||
</div>
|
||||
<div className="gantt-row__bar-area">
|
||||
{(() => {
|
||||
const style = getBarStyle(
|
||||
project.startDate,
|
||||
project.targetDate,
|
||||
);
|
||||
if (!style) return null;
|
||||
return (
|
||||
<div
|
||||
className="gantt-bar gantt-bar--project"
|
||||
style={{
|
||||
...style,
|
||||
backgroundColor: STATUS_COLORS[project.status],
|
||||
}}
|
||||
>
|
||||
<span className="gantt-bar__label">
|
||||
{formatDate(project.startDate)} —{" "}
|
||||
{formatDate(project.targetDate)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Milestone rows */}
|
||||
{project.milestones?.map((m, i) => {
|
||||
const barStyle = getBarStyle(m.startDate, m.targetDate);
|
||||
return (
|
||||
<div
|
||||
key={m.id || i}
|
||||
className="gantt-row gantt-row--milestone"
|
||||
>
|
||||
<div className="gantt-row__label gantt-row__label--indent">
|
||||
<span
|
||||
className="gantt-row__priority"
|
||||
title={m.priority}
|
||||
>
|
||||
{PRIORITY_LABELS[m.priority]}
|
||||
</span>
|
||||
{m.name}
|
||||
</div>
|
||||
<div className="gantt-row__bar-area">
|
||||
{barStyle && (
|
||||
<div
|
||||
className="gantt-bar gantt-bar--milestone"
|
||||
style={{
|
||||
...barStyle,
|
||||
backgroundColor: STATUS_COLORS[m.status],
|
||||
opacity: m.status === "done" ? 0.5 : 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -15,6 +15,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
restart: unless-stopped
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
@@ -22,15 +23,17 @@ services:
|
||||
- apps_node_modules:/app/apps/web/node_modules
|
||||
- ../at-mintel:/at-mintel
|
||||
- pnpm_store:/pnpm # Cache pnpm store
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- NEXT_TELEMETRY_DISABLED=1
|
||||
- CI=true
|
||||
# - CI=true
|
||||
- NPM_TOKEN=${NPM_TOKEN:-}
|
||||
- DATABASE_URI=postgres://${postgres_DB_USER:-directus}:${postgres_DB_PASSWORD:-directus}@postgres-db:5432/${postgres_DB_NAME:-directus}
|
||||
- DATABASE_URI=postgres://${postgres_DB_USER:-payload}:${postgres_DB_PASSWORD:-payload}@postgres-db:5432/${postgres_DB_NAME:-payload}
|
||||
- PAYLOAD_SECRET=dev-secret
|
||||
command: >
|
||||
sh -c "pnpm install && pnpm --filter @mintel/web dev"
|
||||
sh -c "pnpm install --no-frozen-lockfile && pnpm --filter @mintel/web dev"
|
||||
|
||||
networks:
|
||||
- default
|
||||
@@ -50,11 +53,11 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
POSTGRES_DB: ${postgres_DB_NAME:-directus}
|
||||
POSTGRES_USER: ${postgres_DB_USER:-directus}
|
||||
POSTGRES_PASSWORD: ${postgres_DB_PASSWORD:-directus}
|
||||
POSTGRES_DB: ${postgres_DB_NAME:-payload}
|
||||
POSTGRES_USER: ${postgres_DB_USER:-payload}
|
||||
POSTGRES_PASSWORD: ${postgres_DB_PASSWORD:-payload}
|
||||
volumes:
|
||||
- directus-db-data:/var/lib/postgresql/data
|
||||
- payload-db-data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "54321:5432"
|
||||
|
||||
@@ -65,7 +68,7 @@ networks:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
directus-db-data:
|
||||
payload-db-data:
|
||||
node_modules:
|
||||
apps_node_modules:
|
||||
pnpm_store:
|
||||
|
||||
@@ -10,51 +10,51 @@ services:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# HTTP ⇒ HTTPS redirect
|
||||
- 'traefik.http.routers.mintel-me-web.rule=${TRAEFIK_HOST_RULE:-Host("${TRAEFIK_HOST:-mintel.localhost}")}'
|
||||
- "traefik.http.routers.mintel-me-web.entrypoints=web"
|
||||
# - "traefik.http.routers.mintel-me-web.middlewares=redirect-https"
|
||||
- 'traefik.http.routers.${PROJECT_NAME}-web.rule=${TRAEFIK_HOST_RULE:-Host("${TRAEFIK_HOST:-mintel.localhost}")}'
|
||||
- "traefik.http.routers.${PROJECT_NAME}-web.entrypoints=web"
|
||||
# - "traefik.http.routers.${PROJECT_NAME}-web.middlewares=redirect-https"
|
||||
# HTTPS router (Standard)
|
||||
- 'traefik.http.routers.mintel-me.rule=${TRAEFIK_HOST_RULE:-Host("${TRAEFIK_HOST:-mintel.localhost}")}'
|
||||
- "traefik.http.routers.mintel-me.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||
- "traefik.http.routers.mintel-me.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||
- "traefik.http.routers.mintel-me.tls=${TRAEFIK_TLS:-false}"
|
||||
- "traefik.http.routers.mintel-me.service=mintel-me-app-svc"
|
||||
- "traefik.http.routers.mintel-me.middlewares=${AUTH_MIDDLEWARE:-mintel-me-ratelimit,mintel-me-forward}"
|
||||
- "traefik.http.services.mintel-me-app-svc.loadbalancer.server.port=3000"
|
||||
- 'traefik.http.routers.${PROJECT_NAME}.rule=${TRAEFIK_HOST_RULE:-Host("${TRAEFIK_HOST:-mintel.localhost}")}'
|
||||
- "traefik.http.routers.${PROJECT_NAME}.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||
- "traefik.http.routers.${PROJECT_NAME}.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||
- "traefik.http.routers.${PROJECT_NAME}.tls=${TRAEFIK_TLS:-false}"
|
||||
- "traefik.http.routers.${PROJECT_NAME}.service=${PROJECT_NAME}-app-svc"
|
||||
- "traefik.http.routers.${PROJECT_NAME}.middlewares=${AUTH_MIDDLEWARE:-${PROJECT_NAME}-ratelimit,${PROJECT_NAME}-forward}"
|
||||
- "traefik.http.services.${PROJECT_NAME}-app-svc.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=infra"
|
||||
- "caddy=${TRAEFIK_HOST:-mintel.localhost}"
|
||||
- "caddy.reverse_proxy={{upstreams 3000}}"
|
||||
|
||||
# Public Router (Whitelist for OG Images, Sitemaps, Health)
|
||||
- 'traefik.http.routers.mintel-me-public.rule=(${TRAEFIK_HOST_RULE:-Host("${TRAEFIK_HOST:-mintel.localhost}")}) && (PathPrefix("/health") || PathPrefix("/sitemap.xml") || PathPrefix("/robots.txt") || PathPrefix("/manifest.webmanifest") || PathPrefix("/api/og") || PathRegexp(".*opengraph-image.*") || PathRegexp(".*sitemap.*"))'
|
||||
- "traefik.http.routers.mintel-me-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||
- "traefik.http.routers.mintel-me-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||
- "traefik.http.routers.mintel-me-public.tls=${TRAEFIK_TLS:-false}"
|
||||
- "traefik.http.routers.mintel-me-public.service=mintel-me-app-svc"
|
||||
- "traefik.http.routers.mintel-me-public.middlewares=${AUTH_MIDDLEWARE_UNPROTECTED:-mintel-me-ratelimit,mintel-me-forward}"
|
||||
- "traefik.http.routers.mintel-me-public.priority=2000"
|
||||
- 'traefik.http.routers.${PROJECT_NAME}-public.rule=(${TRAEFIK_HOST_RULE:-Host("${TRAEFIK_HOST:-mintel.localhost}")}) && (PathPrefix("/health") || PathPrefix("/api/health") || PathPrefix("/sitemap.xml") || PathPrefix("/robots.txt") || PathPrefix("/manifest.webmanifest") || PathPrefix("/api/og") || PathRegexp(".*opengraph-image.*") || PathRegexp(".*sitemap.*"))'
|
||||
- "traefik.http.routers.${PROJECT_NAME}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||
- "traefik.http.routers.${PROJECT_NAME}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||
- "traefik.http.routers.${PROJECT_NAME}-public.tls=${TRAEFIK_TLS:-false}"
|
||||
- "traefik.http.routers.${PROJECT_NAME}-public.service=${PROJECT_NAME}-app-svc"
|
||||
- "traefik.http.routers.${PROJECT_NAME}-public.middlewares=${AUTH_MIDDLEWARE_UNPROTECTED:-${PROJECT_NAME}-ratelimit,${PROJECT_NAME}-forward}"
|
||||
- "traefik.http.routers.${PROJECT_NAME}-public.priority=2000"
|
||||
|
||||
# Middlewares
|
||||
- "traefik.http.middlewares.mintel-me-ratelimit.ratelimit.average=100"
|
||||
- "traefik.http.middlewares.mintel-me-ratelimit.ratelimit.burst=50"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME}-ratelimit.ratelimit.average=100"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME}-ratelimit.ratelimit.burst=50"
|
||||
|
||||
# Gatekeeper Router (Path-based)
|
||||
- 'traefik.http.routers.mintel-me-gatekeeper.rule=(Host("${TRAEFIK_HOST:-mintel.localhost}") && PathPrefix("/gatekeeper"))'
|
||||
- "traefik.http.routers.mintel-me-gatekeeper.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||
- "traefik.http.routers.mintel-me-gatekeeper.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||
- "traefik.http.routers.mintel-me-gatekeeper.tls=${TRAEFIK_TLS:-false}"
|
||||
- "traefik.http.routers.mintel-me-gatekeeper.service=mintel-me-gatekeeper-svc"
|
||||
- 'traefik.http.routers.${PROJECT_NAME}-gatekeeper.rule=(Host("${TRAEFIK_HOST:-mintel.localhost}") && PathPrefix("/gatekeeper"))'
|
||||
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.tls=${TRAEFIK_TLS:-false}"
|
||||
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.service=${PROJECT_NAME}-gatekeeper-svc"
|
||||
|
||||
- "traefik.http.middlewares.mintel-me-auth.forwardauth.address=http://mintel-me-gatekeeper:3000/gatekeeper/api/verify"
|
||||
- "traefik.http.middlewares.mintel-me-auth.forwardauth.trustForwardHeader=true"
|
||||
- "traefik.http.middlewares.mintel-me-auth.forwardauth.authRequestHeaders=X-Forwarded-Host,X-Forwarded-Proto,X-Forwarded-For,Cookie"
|
||||
- "traefik.http.middlewares.mintel-me-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.address=http://${PROJECT_NAME}-gatekeeper:3000/gatekeeper/api/verify"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.trustForwardHeader=true"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.authRequestHeaders=X-Forwarded-Host,X-Forwarded-Proto,X-Forwarded-For,Cookie"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
||||
|
||||
# Forwarded Headers
|
||||
- "traefik.http.middlewares.mintel-me-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||
- "traefik.http.middlewares.mintel-me-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
||||
|
||||
mintel-me-gatekeeper:
|
||||
gatekeeper:
|
||||
profiles: ["gatekeeper"]
|
||||
image: registry.infra.mintel.me/mintel/gatekeeper:v1.7.12
|
||||
container_name: ${PROJECT_NAME:-mintel-me}-gatekeeper
|
||||
@@ -62,7 +62,7 @@ services:
|
||||
networks:
|
||||
infra:
|
||||
aliases:
|
||||
- mintel-me-gatekeeper
|
||||
- ${PROJECT_NAME}-gatekeeper
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
environment:
|
||||
@@ -74,7 +74,7 @@ services:
|
||||
GATEKEEPER_PASSWORD: ${GATEKEEPER_PASSWORD:-mintel}
|
||||
NEXT_PUBLIC_BASE_URL: ${GATEKEEPER_ORIGIN}
|
||||
labels:
|
||||
- "traefik.http.services.mintel-me-gatekeeper-svc.loadbalancer.server.port=3000"
|
||||
- "traefik.http.services.${PROJECT_NAME}-gatekeeper-svc.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=infra"
|
||||
- "caddy=gatekeeper.${TRAEFIK_HOST:-mintel.localhost}"
|
||||
- "caddy.reverse_proxy={{upstreams 3000}}"
|
||||
@@ -87,11 +87,11 @@ services:
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
environment:
|
||||
POSTGRES_DB: ${postgres_DB_NAME:-directus}
|
||||
POSTGRES_USER: ${postgres_DB_USER:-directus}
|
||||
POSTGRES_PASSWORD: ${postgres_DB_PASSWORD:-directus}
|
||||
POSTGRES_DB: ${postgres_DB_NAME:-payload}
|
||||
POSTGRES_USER: ${postgres_DB_USER:-payload}
|
||||
POSTGRES_PASSWORD: ${postgres_DB_PASSWORD:-payload}
|
||||
volumes:
|
||||
- directus-db-data:/var/lib/postgresql/data
|
||||
- payload-db-data:/var/lib/postgresql/data
|
||||
|
||||
networks:
|
||||
default:
|
||||
@@ -101,6 +101,6 @@ networks:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
directus-db-data:
|
||||
payload-db-data:
|
||||
external: true
|
||||
name: mintel-me_directus-db-data
|
||||
name: mintel-me_payload-db-data
|
||||
|
||||
18
package.json
@@ -4,7 +4,7 @@
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.18.3",
|
||||
"scripts": {
|
||||
"dev": "COMPOSE_PROJECT_NAME=mintel-me docker-compose -f docker-compose.dev.yml up -d postgres-db proxy && NODE_ENV=development pnpm --filter @mintel/web dev:native",
|
||||
"dev": "bash -c 'trap \"COMPOSE_PROJECT_NAME=mintel-me docker-compose -f docker-compose.dev.yml down\" EXIT INT TERM; docker network create infra 2>/dev/null || true && COMPOSE_PROJECT_NAME=mintel-me docker-compose -f docker-compose.dev.yml down && COMPOSE_PROJECT_NAME=mintel-me docker-compose -f docker-compose.dev.yml up app postgres-db --remove-orphans'",
|
||||
"dev:docker": "docker network create infra 2>/dev/null || true && echo \"\\n🚀 Dockerized Environment Starting...\\n\\n📱 App: http://mintel.localhost\\n🚦 Caddy Proxy: http://localhost:80\\n\" && COMPOSE_PROJECT_NAME=mintel-me docker-compose -f docker-compose.dev.yml up app postgres-db",
|
||||
"dev:clean": "pnpm dev:stop && rm -rf apps/web/.next apps/web/node_modules && pnpm install && pnpm dev",
|
||||
"dev:stop": "COMPOSE_PROJECT_NAME=mintel-me docker-compose -f docker-compose.dev.yml down",
|
||||
@@ -15,17 +15,18 @@
|
||||
"test": "pnpm -r test",
|
||||
"lint:yaml": "node scripts/lint-yaml.js",
|
||||
"optimize-blog": "tsx --env-file=.env apps/web/scripts/optimize-blog-post.ts",
|
||||
"db:backup": "bash apps/web/scripts/backup-db.sh",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@eslint/js": "^10.0.0",
|
||||
"@mintel/cli": "^1.8.21",
|
||||
"@mintel/eslint-config": "^1.8.21",
|
||||
"@mintel/husky-config": "^1.8.21",
|
||||
"@mintel/next-config": "^1.8.21",
|
||||
"@mintel/next-utils": "^1.8.21",
|
||||
"@mintel/tsconfig": "^1.8.21",
|
||||
"@mintel/cli": "^1.9.0",
|
||||
"@mintel/eslint-config": "^1.9.0",
|
||||
"@mintel/husky-config": "^1.9.0",
|
||||
"@mintel/next-config": "^1.9.0",
|
||||
"@mintel/next-utils": "^1.9.0",
|
||||
"@mintel/tsconfig": "^1.9.0",
|
||||
"@next/eslint-plugin-next": "^16.1.6",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "^2.1.0",
|
||||
@@ -59,6 +60,7 @@
|
||||
"dependencies": {
|
||||
"@eslint/compat": "^2.0.2",
|
||||
"@mintel/acquisition": "link:../at-mintel/packages/acquisition-library",
|
||||
"tsx": "^4.21.0"
|
||||
"tsx": "^4.21.0",
|
||||
"turbo": "^2.8.10"
|
||||
}
|
||||
}
|
||||
|
||||
2451
pnpm-lock.yaml
generated
23
turbo.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"globalDependencies": [
|
||||
"pnpm-lock.yaml",
|
||||
".gitea/workflows/ci.yml",
|
||||
".gitea/workflows/deploy.yml"
|
||||
],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": [".next/**", "dist/**"]
|
||||
},
|
||||
"lint": {
|
||||
"outputs": []
|
||||
},
|
||||
"typecheck": {
|
||||
"outputs": []
|
||||
},
|
||||
"test": {
|
||||
"outputs": []
|
||||
}
|
||||
}
|
||||
}
|
||||