Compare commits
57 Commits
8907963d57
...
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 |
@@ -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 }}
|
||||
@@ -254,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"
|
||||
|
||||
@@ -262,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
|
||||
@@ -279,40 +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 Object Storage
|
||||
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
|
||||
@@ -321,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
|
||||
@@ -333,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:
|
||||
@@ -391,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
|
||||
|
||||
5
.gitignore
vendored
@@ -51,3 +51,8 @@ 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 }) {
|
||||
|
||||
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,17 @@ 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: [
|
||||
{
|
||||
@@ -37,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://payload:payload@127.0.0.1:54321/payload 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,14 +21,20 @@
|
||||
"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",
|
||||
@@ -69,6 +75,7 @@
|
||||
"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",
|
||||
@@ -86,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",
|
||||
@@ -99,12 +107,12 @@
|
||||
"@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",
|
||||
|
||||
@@ -13,53 +13,53 @@
|
||||
* via the `definition` "supportedTimezones".
|
||||
*/
|
||||
export type SupportedTimezones =
|
||||
| 'Pacific/Midway'
|
||||
| 'Pacific/Niue'
|
||||
| 'Pacific/Honolulu'
|
||||
| 'Pacific/Rarotonga'
|
||||
| 'America/Anchorage'
|
||||
| 'Pacific/Gambier'
|
||||
| 'America/Los_Angeles'
|
||||
| 'America/Tijuana'
|
||||
| 'America/Denver'
|
||||
| 'America/Phoenix'
|
||||
| 'America/Chicago'
|
||||
| 'America/Guatemala'
|
||||
| 'America/New_York'
|
||||
| 'America/Bogota'
|
||||
| 'America/Caracas'
|
||||
| 'America/Santiago'
|
||||
| 'America/Buenos_Aires'
|
||||
| 'America/Sao_Paulo'
|
||||
| 'Atlantic/South_Georgia'
|
||||
| 'Atlantic/Azores'
|
||||
| 'Atlantic/Cape_Verde'
|
||||
| 'Europe/London'
|
||||
| 'Europe/Berlin'
|
||||
| 'Africa/Lagos'
|
||||
| 'Europe/Athens'
|
||||
| 'Africa/Cairo'
|
||||
| 'Europe/Moscow'
|
||||
| 'Asia/Riyadh'
|
||||
| 'Asia/Dubai'
|
||||
| 'Asia/Baku'
|
||||
| 'Asia/Karachi'
|
||||
| 'Asia/Tashkent'
|
||||
| 'Asia/Calcutta'
|
||||
| 'Asia/Dhaka'
|
||||
| 'Asia/Almaty'
|
||||
| 'Asia/Jakarta'
|
||||
| 'Asia/Bangkok'
|
||||
| 'Asia/Shanghai'
|
||||
| 'Asia/Singapore'
|
||||
| 'Asia/Tokyo'
|
||||
| 'Asia/Seoul'
|
||||
| 'Australia/Brisbane'
|
||||
| 'Australia/Sydney'
|
||||
| 'Pacific/Guam'
|
||||
| 'Pacific/Noumea'
|
||||
| 'Pacific/Auckland'
|
||||
| 'Pacific/Fiji';
|
||||
| "Pacific/Midway"
|
||||
| "Pacific/Niue"
|
||||
| "Pacific/Honolulu"
|
||||
| "Pacific/Rarotonga"
|
||||
| "America/Anchorage"
|
||||
| "Pacific/Gambier"
|
||||
| "America/Los_Angeles"
|
||||
| "America/Tijuana"
|
||||
| "America/Denver"
|
||||
| "America/Phoenix"
|
||||
| "America/Chicago"
|
||||
| "America/Guatemala"
|
||||
| "America/New_York"
|
||||
| "America/Bogota"
|
||||
| "America/Caracas"
|
||||
| "America/Santiago"
|
||||
| "America/Buenos_Aires"
|
||||
| "America/Sao_Paulo"
|
||||
| "Atlantic/South_Georgia"
|
||||
| "Atlantic/Azores"
|
||||
| "Atlantic/Cape_Verde"
|
||||
| "Europe/London"
|
||||
| "Europe/Berlin"
|
||||
| "Africa/Lagos"
|
||||
| "Europe/Athens"
|
||||
| "Africa/Cairo"
|
||||
| "Europe/Moscow"
|
||||
| "Asia/Riyadh"
|
||||
| "Asia/Dubai"
|
||||
| "Asia/Baku"
|
||||
| "Asia/Karachi"
|
||||
| "Asia/Tashkent"
|
||||
| "Asia/Calcutta"
|
||||
| "Asia/Dhaka"
|
||||
| "Asia/Almaty"
|
||||
| "Asia/Jakarta"
|
||||
| "Asia/Bangkok"
|
||||
| "Asia/Shanghai"
|
||||
| "Asia/Singapore"
|
||||
| "Asia/Tokyo"
|
||||
| "Asia/Seoul"
|
||||
| "Australia/Brisbane"
|
||||
| "Australia/Sydney"
|
||||
| "Pacific/Guam"
|
||||
| "Pacific/Noumea"
|
||||
| "Pacific/Auckland"
|
||||
| "Pacific/Fiji";
|
||||
|
||||
export interface Config {
|
||||
auth: {
|
||||
@@ -72,34 +72,65 @@ export interface Config {
|
||||
posts: Post;
|
||||
inquiries: Inquiry;
|
||||
redirects: Redirect;
|
||||
'context-files': ContextFile;
|
||||
'payload-kv': PayloadKv;
|
||||
'payload-locked-documents': PayloadLockedDocument;
|
||||
'payload-preferences': PayloadPreference;
|
||||
'payload-migrations': PayloadMigration;
|
||||
"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: {
|
||||
"crm-accounts": {
|
||||
topics: "crm-topics";
|
||||
contacts: "crm-contacts";
|
||||
interactions: "crm-interactions";
|
||||
projects: "projects";
|
||||
};
|
||||
"crm-contacts": {
|
||||
interactions: "crm-interactions";
|
||||
};
|
||||
"crm-topics": {
|
||||
interactions: "crm-interactions";
|
||||
};
|
||||
};
|
||||
collectionsJoins: {};
|
||||
collectionsSelect: {
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
media: MediaSelect<false> | MediaSelect<true>;
|
||||
posts: PostsSelect<false> | PostsSelect<true>;
|
||||
inquiries: InquiriesSelect<false> | InquiriesSelect<true>;
|
||||
redirects: RedirectsSelect<false> | RedirectsSelect<true>;
|
||||
'context-files': ContextFilesSelect<false> | ContextFilesSelect<true>;
|
||||
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<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>
|
||||
| PayloadLockedDocumentsSelect<true>;
|
||||
"payload-preferences":
|
||||
| PayloadPreferencesSelect<false>
|
||||
| PayloadPreferencesSelect<true>;
|
||||
"payload-migrations":
|
||||
| PayloadMigrationsSelect<false>
|
||||
| PayloadMigrationsSelect<true>;
|
||||
};
|
||||
db: {
|
||||
defaultIDType: number;
|
||||
};
|
||||
fallbackLocale: null;
|
||||
globals: {
|
||||
'ai-settings': AiSetting;
|
||||
"ai-settings": AiSetting;
|
||||
};
|
||||
globalsSelect: {
|
||||
'ai-settings': AiSettingsSelect<false> | AiSettingsSelect<true>;
|
||||
"ai-settings": AiSettingsSelect<false> | AiSettingsSelect<true>;
|
||||
};
|
||||
locale: null;
|
||||
user: User;
|
||||
@@ -149,7 +180,7 @@ export interface User {
|
||||
}[]
|
||||
| null;
|
||||
password?: string | null;
|
||||
collection: 'users';
|
||||
collection: "users";
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
@@ -158,6 +189,7 @@ export interface User {
|
||||
export interface Media {
|
||||
id: number;
|
||||
alt: string;
|
||||
prefix?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
url?: string | null;
|
||||
@@ -228,8 +260,8 @@ export interface Post {
|
||||
version: number;
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
direction: ('ltr' | 'rtl') | null;
|
||||
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
|
||||
direction: ("ltr" | "rtl") | null;
|
||||
format: "left" | "start" | "center" | "right" | "end" | "justify" | "";
|
||||
indent: number;
|
||||
version: number;
|
||||
};
|
||||
@@ -237,7 +269,7 @@ export interface Post {
|
||||
} | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
_status?: ('draft' | 'published') | null;
|
||||
_status?: ("draft" | "published") | null;
|
||||
}
|
||||
/**
|
||||
* Contact form leads and inquiries.
|
||||
@@ -247,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;
|
||||
@@ -302,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".
|
||||
@@ -327,32 +618,52 @@ export interface PayloadLockedDocument {
|
||||
id: number;
|
||||
document?:
|
||||
| ({
|
||||
relationTo: 'users';
|
||||
relationTo: "users";
|
||||
value: number | User;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'media';
|
||||
relationTo: "media";
|
||||
value: number | Media;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'posts';
|
||||
relationTo: "posts";
|
||||
value: number | Post;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'inquiries';
|
||||
relationTo: "inquiries";
|
||||
value: number | Inquiry;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'redirects';
|
||||
relationTo: "redirects";
|
||||
value: number | Redirect;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'context-files';
|
||||
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: {
|
||||
relationTo: 'users';
|
||||
relationTo: "users";
|
||||
value: number | User;
|
||||
};
|
||||
updatedAt: string;
|
||||
@@ -365,7 +676,7 @@ export interface PayloadLockedDocument {
|
||||
export interface PayloadPreference {
|
||||
id: number;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
relationTo: "users";
|
||||
value: number | User;
|
||||
};
|
||||
key?: string | null;
|
||||
@@ -420,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;
|
||||
@@ -492,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;
|
||||
@@ -522,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".
|
||||
@@ -603,7 +1010,6 @@ export interface Auth {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
declare module 'payload' {
|
||||
declare module "payload" {
|
||||
export interface GeneratedTypes extends Config {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,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";
|
||||
|
||||
@@ -28,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,
|
||||
@@ -68,24 +84,31 @@ export default buildConfig({
|
||||
plugins: [
|
||||
...(process.env.S3_ENDPOINT
|
||||
? [
|
||||
s3Storage({
|
||||
collections: {
|
||||
media: {
|
||||
prefix: `${process.env.S3_PREFIX || "mintel-me"}/media`,
|
||||
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 || "",
|
||||
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,
|
||||
},
|
||||
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 |
@@ -98,7 +98,7 @@ async function main() {
|
||||
crawlDir,
|
||||
});
|
||||
|
||||
const engine = new PdfEngine();
|
||||
const engine = new PdfEngine() as any;
|
||||
|
||||
const headerIcon = path.join(
|
||||
monorepoRoot,
|
||||
|
||||
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]*)$/);
|
||||
|
||||
@@ -2,121 +2,129 @@ 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 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*\[(.*?)\]/);
|
||||
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, "")) : []
|
||||
};
|
||||
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.");
|
||||
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");
|
||||
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);
|
||||
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:-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
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
20527
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": []
|
||||
}
|
||||
}
|
||||
}
|
||||