diff --git a/package.json b/package.json index 04ffbd2..941fc00 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "prepare": "test -d .git && (ln -sf ../../scripts/detect-secrets.sh .git/hooks/pre-commit 2>/dev/null || true) || true" }, "dependencies": { + "@anthropic-ai/sdk": "^0.71.2", "@payloadcms/db-postgres": "3.69.0", "@payloadcms/next": "3.69.0", "@payloadcms/plugin-form-builder": "3.69.0", @@ -40,6 +41,7 @@ "bullmq": "^5.65.1", "cross-env": "^7.0.3", "dotenv": "16.4.7", + "googleapis": "^170.0.0", "ioredis": "^5.8.2", "next": "15.5.9", "node-cron": "^4.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8def93..f469618 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@anthropic-ai/sdk': + specifier: ^0.71.2 + version: 0.71.2 '@payloadcms/db-postgres': specifier: 3.69.0 version: 3.69.0(payload@3.69.0(graphql@16.12.0)(typescript@5.9.3)) @@ -47,6 +50,9 @@ importers: dotenv: specifier: 16.4.7 version: 16.4.7 + googleapis: + specifier: ^170.0.0 + version: 170.0.0 ioredis: specifier: ^5.8.2 version: 5.8.2 @@ -132,6 +138,15 @@ importers: packages: + '@anthropic-ai/sdk@0.71.2': + resolution: {integrity: sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@apidevtools/json-schema-ref-parser@11.9.3': resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==} engines: {node: '>= 16'} @@ -1009,6 +1024,10 @@ packages: '@ioredis/commands@1.4.0': resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==} + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1303,6 +1322,10 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@playwright/test@1.57.0': resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==} engines: {node: '>=18'} @@ -1925,6 +1948,10 @@ packages: ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} @@ -2017,10 +2044,16 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.8.31: resolution: {integrity: sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==} hasBin: true + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -2049,6 +2082,9 @@ packages: bson-objectid@2.0.4: resolution: {integrity: sha512-vgnKAUzcDoa+AeyYwXCoHyF2q6u/8H46dxu5JN+4/TZeq/Dlinn0K6GvxsCLb3LHUJl0m/TLiEK31kUwtgocMQ==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -2192,6 +2228,10 @@ packages: damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -2391,12 +2431,21 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + electron-to-chromium@1.5.260: resolution: {integrity: sha512-ov8rBoOBhVawpzdre+Cmz4FB+y66Eqrk6Gwqd8NGxuhv99GQ8XqMAr351KEkOt7gukXWDg6gJWEMKgL2RLMPtA==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} @@ -2600,6 +2649,9 @@ packages: resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} engines: {node: '>=12.0.0'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-copy@3.0.2: resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==} @@ -2638,6 +2690,10 @@ packages: picomatch: optional: true + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -2671,6 +2727,14 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2691,6 +2755,14 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + gaxios@7.1.3: + resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -2733,6 +2805,10 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + hasBin: true + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -2744,6 +2820,22 @@ packages: globrex@0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + google-auth-library@10.5.0: + resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==} + engines: {node: '>=18'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + + googleapis-common@8.0.1: + resolution: {integrity: sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==} + engines: {node: '>=18.0.0'} + + googleapis@170.0.0: + resolution: {integrity: sha512-UJz71WZ3ubMr4NhkEU+CFTS0CMrrXq+ltrFnAQo8Llf9M3cy0AIfKLyFQdUJyhqIpJ4jPW4SRCcBBntrLQ72/A==} + engines: {node: '>=18'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -2767,6 +2859,10 @@ packages: resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + gtoken@8.0.0: + resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} + engines: {node: '>=18'} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -2931,6 +3027,10 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-generator-function@1.1.2: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} @@ -3026,6 +3126,9 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jose@5.9.6: resolution: {integrity: sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==} @@ -3057,12 +3160,19 @@ packages: engines: {node: '>=6'} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-to-typescript@15.0.3: resolution: {integrity: sha512-iOKdzTUWEVM4nlxpFudFsWyUiu/Jakkga4OZPEt7CGoSEsAsUgdOZqR6pcgx2STBek9Gm4hcarJpXSzIvZ/hKA==} engines: {node: '>=16.0.0'} @@ -3098,6 +3208,12 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -3290,6 +3406,10 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + monaco-editor@0.55.1: resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} @@ -3348,6 +3468,15 @@ packages: resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==} engines: {node: '>=6.0.0'} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-gyp-build-optional-packages@5.2.2: resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} hasBin: true @@ -3433,6 +3562,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3458,6 +3590,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -3646,6 +3782,10 @@ packages: resolution: {integrity: sha512-D8NAthKSD7SGn748v+GLaaO6k08Mvpoqroa35PqIQC4gtUa8/Pb/k+r0m0NnGBVbHDP1gKZ2nVywqfMisRhV5A==} engines: {node: '>=18'} + qs@6.14.1: + resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} + engines: {node: '>=0.6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -3754,6 +3894,10 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + rollup@4.53.3: resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -3769,6 +3913,9 @@ packages: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -3860,6 +4007,10 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + simple-wcswidth@1.1.2: resolution: {integrity: sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==} @@ -3917,6 +4068,14 @@ packages: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -3947,6 +4106,10 @@ packages: stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + strip-ansi@7.1.2: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} @@ -4053,6 +4216,9 @@ packages: truncate-utf8-bytes@1.0.2: resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-api-utils@2.1.0: resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} engines: {node: '>=18.12'} @@ -4160,6 +4326,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url-template@2.0.8: + resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==} + use-context-selector@2.0.0: resolution: {integrity: sha512-owfuSmUNd3eNp3J9CdDl0kMgfidV+MkDvHPpvthN5ThqM+ibMccNE0k+Iq7TWC6JPFvGZqanqiGCuQx6DyV24g==} peerDependencies: @@ -4282,6 +4451,10 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -4328,6 +4501,14 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} @@ -4395,6 +4576,10 @@ packages: snapshots: + '@anthropic-ai/sdk@0.71.2': + dependencies: + json-schema-to-ts: 3.1.1 + '@apidevtools/json-schema-ref-parser@11.9.3': dependencies: '@jsdevtools/ono': 7.1.3 @@ -5418,6 +5603,15 @@ snapshots: '@ioredis/commands@1.4.0': {} + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5928,6 +6122,9 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@pkgjs/parseargs@0.11.0': + optional: true + '@playwright/test@1.57.0': dependencies: playwright: 1.57.0 @@ -6637,6 +6834,8 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} ansi-styles@4.3.0: @@ -6751,8 +6950,12 @@ snapshots: balanced-match@1.0.2: {} + base64-js@1.5.1: {} + baseline-browser-mapping@2.8.31: {} + bignumber.js@9.3.1: {} + binary-extensions@2.3.0: {} body-scroll-lock@4.0.0-beta.0: {} @@ -6782,6 +6985,8 @@ snapshots: bson-objectid@2.0.4: {} + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} bullmq@5.65.1: @@ -6925,6 +7130,8 @@ snapshots: damerau-levenshtein@1.0.8: {} + data-uri-to-buffer@4.0.1: {} + data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -7040,10 +7247,18 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + electron-to-chromium@1.5.260: {} emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} end-of-stream@1.4.5: @@ -7234,8 +7449,8 @@ snapshots: '@typescript-eslint/parser': 8.49.0(eslint@9.39.2)(typescript@5.9.3) eslint: 9.39.2 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2))(eslint@9.39.2) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2))(eslint@9.39.2))(eslint@9.39.2) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2) eslint-plugin-react: 7.37.5(eslint@9.39.2) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.2) @@ -7254,7 +7469,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2))(eslint@9.39.2): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -7265,22 +7480,22 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2))(eslint@9.39.2))(eslint@9.39.2) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.2)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2))(eslint@9.39.2))(eslint@9.39.2): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.2)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.49.0(eslint@9.39.2)(typescript@5.9.3) eslint: 9.39.2 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2))(eslint@9.39.2) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2))(eslint@9.39.2))(eslint@9.39.2): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -7291,7 +7506,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.2)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2))(eslint@9.39.2))(eslint@9.39.2) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.2)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -7433,6 +7648,8 @@ snapshots: expect-type@1.2.2: {} + extend@3.0.2: {} + fast-copy@3.0.2: {} fast-deep-equal@3.1.3: {} @@ -7465,6 +7682,11 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -7501,6 +7723,15 @@ snapshots: dependencies: is-callable: 1.2.7 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + fsevents@2.3.2: optional: true @@ -7520,6 +7751,23 @@ snapshots: functions-have-names@1.2.3: {} + gaxios@7.1.3: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + rimraf: 5.0.10 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.3 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} @@ -7568,6 +7816,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + globals@14.0.0: {} globalthis@1.0.4: @@ -7577,6 +7834,37 @@ snapshots: globrex@0.1.2: {} + google-auth-library@10.5.0: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.3 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + gtoken: 8.0.0 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + + googleapis-common@8.0.1: + dependencies: + extend: 3.0.2 + gaxios: 7.1.3 + google-auth-library: 10.5.0 + qs: 6.14.1 + url-template: 2.0.8 + transitivePeerDependencies: + - supports-color + + googleapis@170.0.0: + dependencies: + google-auth-library: 10.5.0 + googleapis-common: 8.0.1 + transitivePeerDependencies: + - supports-color + gopd@1.2.0: {} graphql-http@1.22.4(graphql@16.12.0): @@ -7594,6 +7882,13 @@ snapshots: graphql@16.12.0: {} + gtoken@8.0.0: + dependencies: + gaxios: 7.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -7761,6 +8056,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-fullwidth-code-point@3.0.0: {} + is-generator-function@1.1.2: dependencies: call-bound: 1.0.4 @@ -7863,6 +8160,12 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jose@5.9.6: {} joycon@3.1.1: {} @@ -7904,10 +8207,19 @@ snapshots: jsesc@3.1.0: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + json-buffer@3.0.1: {} json-parse-even-better-errors@2.3.1: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.28.4 + ts-algebra: 2.0.0 + json-schema-to-typescript@15.0.3: dependencies: '@apidevtools/json-schema-ref-parser': 11.9.3 @@ -7947,6 +8259,17 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -8265,6 +8588,8 @@ snapshots: minimist@1.2.8: {} + minipass@7.1.2: {} + monaco-editor@0.55.1: dependencies: dompurify: 3.2.7 @@ -8325,6 +8650,14 @@ snapshots: node-cron@4.2.1: {} + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + node-gyp-build-optional-packages@5.2.2: dependencies: detect-libc: 2.1.2 @@ -8417,6 +8750,8 @@ snapshots: dependencies: p-limit: 3.1.0 + package-json-from-dist@1.0.1: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -8448,6 +8783,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + path-to-regexp@6.3.0: {} path-type@4.0.0: {} @@ -8669,6 +9009,10 @@ snapshots: qs-esm@7.0.2: {} + qs@6.14.1: + dependencies: + side-channel: 1.1.0 + queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} @@ -8786,6 +9130,10 @@ snapshots: reusify@1.1.0: {} + rimraf@5.0.10: + dependencies: + glob: 10.5.0 + rollup@4.53.3: dependencies: '@types/estree': 1.0.8 @@ -8828,6 +9176,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.2.1: {} + safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -8958,6 +9308,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@4.1.0: {} + simple-wcswidth@1.1.2: {} sisteransi@1.0.5: {} @@ -9001,6 +9353,18 @@ snapshots: streamsearch@1.1.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -9062,6 +9426,10 @@ snapshots: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + strip-ansi@7.1.2: dependencies: ansi-regex: 6.2.2 @@ -9151,6 +9519,8 @@ snapshots: dependencies: utf8-byte-length: 1.0.5 + ts-algebra@2.0.0: {} + ts-api-utils@2.1.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -9295,6 +9665,8 @@ snapshots: dependencies: punycode: 2.3.1 + url-template@2.0.8: {} + use-context-selector@2.0.0(react@19.2.3)(scheduler@0.25.0): dependencies: react: 19.2.3 @@ -9394,6 +9766,8 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + web-streams-polyfill@3.3.3: {} + webidl-conversions@7.0.0: {} whatwg-encoding@3.1.1: @@ -9459,6 +9833,18 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 diff --git a/src/app/(payload)/api/community/reply/route.ts b/src/app/(payload)/api/community/reply/route.ts new file mode 100644 index 0000000..807f589 --- /dev/null +++ b/src/app/(payload)/api/community/reply/route.ts @@ -0,0 +1,144 @@ +// src/app/(payload)/api/community/reply/route.ts + +import { NextRequest, NextResponse } from 'next/server' +import { getPayload } from 'payload' +import config from '@payload-config' +import { YouTubeClient } from '@/lib/integrations/youtube/YouTubeClient' + +export async function POST(req: NextRequest) { + try { + const payload = await getPayload({ config }) + + // Auth prüfen + const { user } = await payload.auth({ headers: req.headers }) + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Berechtigungen prüfen + const userWithRoles = user as { + id: number + isSuperAdmin?: boolean + youtubeRole?: string + communityRole?: string + } + const hasAccess = + userWithRoles.isSuperAdmin || + userWithRoles.youtubeRole === 'manager' || + userWithRoles.youtubeRole === 'creator' || + userWithRoles.communityRole === 'manager' || + userWithRoles.communityRole === 'moderator' + + if (!hasAccess) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const body = await req.json() + const { interactionId, replyText, templateId } = body + + if (!interactionId || !replyText) { + return NextResponse.json( + { error: 'interactionId and replyText required' }, + { status: 400 } + ) + } + + // 1. Interaction laden + const interaction = await payload.findByID({ + collection: 'community-interactions', + id: interactionId, + depth: 2, + }) + + if (!interaction) { + return NextResponse.json( + { error: 'Interaction not found' }, + { status: 404 } + ) + } + + // 2. Platform prüfen + const platform = interaction.platform as { slug?: string } + if (platform?.slug !== 'youtube') { + return NextResponse.json( + { error: 'Nur YouTube wird derzeit unterstützt' }, + { status: 400 } + ) + } + + // 3. YouTube Client initialisieren + const account = interaction.socialAccount as { + credentials?: { + accessToken?: string + refreshToken?: string + } + } + + if (!account?.credentials?.accessToken || !account?.credentials?.refreshToken) { + return NextResponse.json( + { error: 'Keine gültigen API-Credentials' }, + { status: 400 } + ) + } + + const youtubeClient = new YouTubeClient( + { + clientId: process.env.YOUTUBE_CLIENT_ID!, + clientSecret: process.env.YOUTUBE_CLIENT_SECRET!, + accessToken: account.credentials.accessToken, + refreshToken: account.credentials.refreshToken, + }, + payload + ) + + // 4. Reply senden + const replyId = await youtubeClient.replyToComment( + interaction.externalId as string, + replyText + ) + + // 5. Interaction aktualisieren + await payload.update({ + collection: 'community-interactions', + id: interactionId, + data: { + status: 'replied', + response: { + text: replyText, + usedTemplate: templateId || null, + sentAt: new Date().toISOString(), + sentBy: userWithRoles.id, + externalReplyId: replyId, + }, + }, + }) + + // 6. Template Usage Counter erhöhen + if (templateId) { + const template = await payload.findByID({ + collection: 'community-templates', + id: templateId, + }) + if (template) { + await payload.update({ + collection: 'community-templates', + id: templateId, + data: { + usageCount: ((template as { usageCount?: number }).usageCount || 0) + 1, + }, + }) + } + } + + return NextResponse.json({ + success: true, + replyId, + }) + } catch (error: unknown) { + console.error('Reply error:', error) + const message = error instanceof Error ? error.message : 'Unknown error' + return NextResponse.json({ error: message }, { status: 500 }) + } +} + +export const dynamic = 'force-dynamic' diff --git a/src/app/(payload)/api/community/sync-comments/route.ts b/src/app/(payload)/api/community/sync-comments/route.ts new file mode 100644 index 0000000..c76bbdd --- /dev/null +++ b/src/app/(payload)/api/community/sync-comments/route.ts @@ -0,0 +1,61 @@ +// src/app/(payload)/api/community/sync-comments/route.ts + +import { NextRequest, NextResponse } from 'next/server' +import { getPayload } from 'payload' +import config from '@payload-config' +import { CommentsSyncService } from '@/lib/integrations/youtube/CommentsSyncService' + +export async function POST(req: NextRequest) { + try { + const payload = await getPayload({ config }) + + // Auth prüfen + const { user } = await payload.auth({ headers: req.headers }) + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Berechtigungen prüfen + const userWithRoles = user as { + isSuperAdmin?: boolean + youtubeRole?: string + communityRole?: string + } + const hasAccess = + userWithRoles.isSuperAdmin || + userWithRoles.youtubeRole === 'manager' || + userWithRoles.communityRole === 'manager' || + userWithRoles.communityRole === 'moderator' + + if (!hasAccess) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const body = await req.json() + const { socialAccountId, sinceDate, maxComments, analyzeWithAI } = body + + if (!socialAccountId) { + return NextResponse.json( + { error: 'socialAccountId required' }, + { status: 400 } + ) + } + + const syncService = new CommentsSyncService(payload) + + const result = await syncService.syncComments({ + socialAccountId, + sinceDate: sinceDate ? new Date(sinceDate) : undefined, + maxComments: maxComments || 100, + analyzeWithAI: analyzeWithAI ?? true, + }) + + return NextResponse.json(result) + } catch (error: unknown) { + console.error('Sync error:', error) + const message = error instanceof Error ? error.message : 'Unknown error' + return NextResponse.json({ error: message }, { status: 500 }) + } +} + +export const dynamic = 'force-dynamic' diff --git a/src/collections/CommunityInteractions.ts b/src/collections/CommunityInteractions.ts new file mode 100644 index 0000000..63ab3d6 --- /dev/null +++ b/src/collections/CommunityInteractions.ts @@ -0,0 +1,552 @@ +// src/collections/CommunityInteractions.ts + +import type { CollectionConfig } from 'payload' +import { + isCommunityModeratorOrAbove, + isCommunityManager, + canAccessAssignedInteractions, +} from '../lib/communityAccess' + +/** + * CommunityInteractions Collection + * + * Speichert alle Social Media Interaktionen (Kommentare, DMs, Mentions). + * Teil des Community Management Systems. + */ +export const CommunityInteractions: CollectionConfig = { + slug: 'community-interactions', + labels: { + singular: 'Interaction', + plural: 'Interactions', + }, + admin: { + group: 'Community', + defaultColumns: ['platform', 'type', 'author.name', 'status', 'priority', 'createdAt'], + listSearchableFields: ['author.name', 'author.handle', 'message'], + pagination: { + defaultLimit: 50, + }, + }, + access: { + read: canAccessAssignedInteractions, + create: isCommunityModeratorOrAbove, + update: canAccessAssignedInteractions, + delete: isCommunityManager, + }, + fields: [ + // === SOURCE === + { + type: 'row', + fields: [ + { + name: 'platform', + type: 'relationship', + relationTo: 'social-platforms', + required: true, + label: 'Plattform', + admin: { width: '33%' }, + }, + { + name: 'socialAccount', + type: 'relationship', + relationTo: 'social-accounts', + required: true, + label: 'Account', + admin: { width: '33%' }, + }, + { + name: 'linkedContent', + type: 'relationship', + relationTo: 'youtube-content', + label: 'Verknüpfter Content', + admin: { width: '33%' }, + }, + ], + }, + + // === INTERACTION TYPE === + { + type: 'row', + fields: [ + { + name: 'type', + type: 'select', + required: true, + label: 'Typ', + options: [ + { label: 'Kommentar', value: 'comment' }, + { label: 'Antwort', value: 'reply' }, + { label: 'Direktnachricht', value: 'dm' }, + { label: 'Erwähnung', value: 'mention' }, + { label: 'Bewertung', value: 'review' }, + { label: 'Frage', value: 'question' }, + ], + admin: { width: '50%' }, + }, + { + name: 'externalId', + type: 'text', + label: 'External ID', + required: true, + unique: true, + index: true, + admin: { + width: '50%', + description: 'YouTube Comment ID, etc.', + }, + }, + ], + }, + + // === PARENT (für Threads) === + { + name: 'parentInteraction', + type: 'relationship', + relationTo: 'community-interactions', + label: 'Parent (bei Replies)', + admin: { + condition: (data) => data?.type === 'reply', + }, + }, + + // === AUTHOR INFO === + { + name: 'author', + type: 'group', + label: 'Author', + fields: [ + { + type: 'row', + fields: [ + { + name: 'name', + type: 'text', + label: 'Name', + admin: { width: '50%' }, + }, + { + name: 'handle', + type: 'text', + label: 'Handle', + admin: { width: '50%' }, + }, + ], + }, + { + type: 'row', + fields: [ + { + name: 'profileUrl', + type: 'text', + label: 'Profile URL', + admin: { width: '50%' }, + }, + { + name: 'avatarUrl', + type: 'text', + label: 'Avatar URL', + admin: { width: '50%' }, + }, + ], + }, + { + type: 'row', + fields: [ + { + name: 'isVerified', + type: 'checkbox', + label: 'Verifiziert', + admin: { width: '25%' }, + }, + { + name: 'isSubscriber', + type: 'checkbox', + label: 'Subscriber/Follower', + admin: { width: '25%' }, + }, + { + name: 'isMember', + type: 'checkbox', + label: 'Channel Member', + admin: { width: '25%' }, + }, + { + name: 'subscriberCount', + type: 'number', + label: 'Ihre Subscriber', + admin: { width: '25%' }, + }, + ], + }, + ], + }, + + // === MESSAGE CONTENT === + { + name: 'message', + type: 'textarea', + label: 'Nachricht', + required: true, + admin: { + rows: 4, + }, + }, + { + name: 'messageHtml', + type: 'textarea', + label: 'Original HTML', + admin: { + rows: 2, + description: 'Falls Plattform HTML liefert', + }, + }, + { + name: 'attachments', + type: 'array', + label: 'Attachments', + fields: [ + { + type: 'row', + fields: [ + { + name: 'type', + type: 'select', + label: 'Typ', + options: [ + { label: 'Bild', value: 'image' }, + { label: 'Video', value: 'video' }, + { label: 'Link', value: 'link' }, + { label: 'Sticker', value: 'sticker' }, + ], + admin: { width: '30%' }, + }, + { + name: 'url', + type: 'text', + label: 'URL', + admin: { width: '70%' }, + }, + ], + }, + ], + }, + { + name: 'publishedAt', + type: 'date', + label: 'Veröffentlicht am', + required: true, + admin: { + date: { + pickerAppearance: 'dayAndTime', + }, + }, + }, + + // === AI ANALYSIS === + { + name: 'analysis', + type: 'group', + label: 'AI Analyse', + admin: { + description: 'Automatisch via Claude API', + }, + fields: [ + { + type: 'row', + fields: [ + { + name: 'sentiment', + type: 'select', + label: 'Sentiment', + options: [ + { label: 'Positiv', value: 'positive' }, + { label: 'Neutral', value: 'neutral' }, + { label: 'Negativ', value: 'negative' }, + { label: 'Frage', value: 'question' }, + { label: 'Dankbarkeit', value: 'gratitude' }, + { label: 'Frustration', value: 'frustration' }, + ], + admin: { width: '33%' }, + }, + { + name: 'sentimentScore', + type: 'number', + label: 'Score (-1 bis 1)', + min: -1, + max: 1, + admin: { width: '33%' }, + }, + { + name: 'confidence', + type: 'number', + label: 'Confidence %', + min: 0, + max: 100, + admin: { width: '33%' }, + }, + ], + }, + { + name: 'topics', + type: 'array', + label: 'Erkannte Themen', + fields: [ + { + name: 'topic', + type: 'text', + label: 'Thema', + }, + ], + }, + { + name: 'language', + type: 'text', + label: 'Sprache', + admin: { + placeholder: 'de', + }, + }, + { + name: 'suggestedTemplate', + type: 'relationship', + relationTo: 'community-templates', + label: 'Vorgeschlagenes Template', + }, + { + name: 'suggestedReply', + type: 'textarea', + label: 'AI-generierter Antwortvorschlag', + }, + { + name: 'analyzedAt', + type: 'date', + label: 'Analysiert am', + }, + ], + }, + + // === FLAGS === + { + name: 'flags', + type: 'group', + label: 'Flags', + fields: [ + { + type: 'row', + fields: [ + { + name: 'isMedicalQuestion', + type: 'checkbox', + label: 'Medizinische Frage', + admin: { + width: '25%', + description: 'Erfordert ärztliche Review', + }, + }, + { + name: 'requiresEscalation', + type: 'checkbox', + label: 'Eskalation nötig', + admin: { width: '25%' }, + }, + { + name: 'isSpam', + type: 'checkbox', + label: 'Spam', + admin: { width: '25%' }, + }, + { + name: 'isFromInfluencer', + type: 'checkbox', + label: 'Influencer', + admin: { + width: '25%', + description: '>10k Follower', + }, + }, + ], + }, + ], + }, + + // === WORKFLOW === + { + name: 'status', + type: 'select', + required: true, + defaultValue: 'new', + index: true, + label: 'Status', + options: [ + { label: 'Neu', value: 'new' }, + { label: 'In Review', value: 'in_review' }, + { label: 'Warten auf Info', value: 'waiting' }, + { label: 'Beantwortet', value: 'replied' }, + { label: 'Erledigt', value: 'resolved' }, + { label: 'Archiviert', value: 'archived' }, + { label: 'Spam', value: 'spam' }, + ], + admin: { + position: 'sidebar', + }, + }, + { + name: 'priority', + type: 'select', + required: true, + defaultValue: 'normal', + index: true, + label: 'Priorität', + options: [ + { label: 'Urgent', value: 'urgent' }, + { label: 'Hoch', value: 'high' }, + { label: 'Normal', value: 'normal' }, + { label: 'Niedrig', value: 'low' }, + ], + admin: { + position: 'sidebar', + }, + }, + { + name: 'assignedTo', + type: 'relationship', + relationTo: 'users', + label: 'Zugewiesen an', + admin: { + position: 'sidebar', + }, + }, + { + name: 'responseDeadline', + type: 'date', + label: 'Antwort-Deadline', + admin: { + position: 'sidebar', + date: { + pickerAppearance: 'dayAndTime', + }, + }, + }, + + // === OUR RESPONSE === + { + name: 'response', + type: 'group', + label: 'Unsere Antwort', + fields: [ + { + name: 'text', + type: 'textarea', + label: 'Antwort-Text', + admin: { rows: 4 }, + }, + { + name: 'usedTemplate', + type: 'relationship', + relationTo: 'community-templates', + label: 'Verwendetes Template', + }, + { + name: 'sentAt', + type: 'date', + label: 'Gesendet am', + }, + { + name: 'sentBy', + type: 'relationship', + relationTo: 'users', + label: 'Gesendet von', + }, + { + name: 'externalReplyId', + type: 'text', + label: 'Reply ID (extern)', + }, + ], + }, + + // === ENGAGEMENT (Platform-spezifisch) === + { + name: 'engagement', + type: 'group', + label: 'Engagement', + admin: { + description: 'Wird beim Sync aktualisiert', + }, + fields: [ + { + type: 'row', + fields: [ + { + name: 'likes', + type: 'number', + label: 'Likes', + admin: { width: '25%' }, + }, + { + name: 'replies', + type: 'number', + label: 'Replies', + admin: { width: '25%' }, + }, + { + name: 'isHearted', + type: 'checkbox', + label: 'Creator Heart', + admin: { width: '25%' }, + }, + { + name: 'isPinned', + type: 'checkbox', + label: 'Angepinnt', + admin: { width: '25%' }, + }, + ], + }, + ], + }, + + // === INTERNAL NOTES === + { + name: 'internalNotes', + type: 'textarea', + label: 'Interne Notizen', + admin: { + rows: 2, + description: 'Nur für Team sichtbar', + }, + }, + ], + + // === HOOKS === + hooks: { + beforeChange: [ + // Auto-set priority based on flags + async ({ data, operation }) => { + if (!data) return data + if (operation === 'create' || !data.priority) { + if (data?.flags?.isMedicalQuestion) { + data.priority = 'high' + } + if (data?.flags?.requiresEscalation) { + data.priority = 'urgent' + } + if (data?.flags?.isFromInfluencer) { + data.priority = data.priority === 'urgent' ? 'urgent' : 'high' + } + } + return data + }, + ], + afterChange: [ + // Send notification for urgent items + async ({ doc, operation }) => { + if (operation === 'create' && doc.priority === 'urgent') { + // TODO: Notification Logic via YtNotifications + console.log(`🚨 Urgent interaction: ${doc.id}`) + } + }, + ], + }, + timestamps: true, +} diff --git a/src/collections/CommunityRules.ts b/src/collections/CommunityRules.ts new file mode 100644 index 0000000..99dde2b --- /dev/null +++ b/src/collections/CommunityRules.ts @@ -0,0 +1,262 @@ +// src/collections/CommunityRules.ts + +import type { CollectionConfig } from 'payload' +import { isCommunityManager, hasCommunityAccess } from '../lib/communityAccess' + +/** + * CommunityRules Collection + * + * Auto-Regeln für Community-Interaktionen basierend auf Keywords, Sentiment etc. + * Teil des Community Management Systems. + */ +export const CommunityRules: CollectionConfig = { + slug: 'community-rules', + labels: { + singular: 'Community Rule', + plural: 'Community Rules', + }, + admin: { + group: 'Community', + useAsTitle: 'name', + defaultColumns: ['name', 'trigger.type', 'isActive', 'priority'], + }, + access: { + read: hasCommunityAccess, + create: isCommunityManager, + update: isCommunityManager, + delete: isCommunityManager, + }, + fields: [ + { + type: 'row', + fields: [ + { + name: 'name', + type: 'text', + required: true, + label: 'Name', + admin: { width: '50%' }, + }, + { + name: 'priority', + type: 'number', + required: true, + defaultValue: 100, + label: 'Priorität', + admin: { + width: '25%', + description: 'Niedrigere Zahl = höhere Priorität', + }, + }, + { + name: 'isActive', + type: 'checkbox', + label: 'Aktiv', + defaultValue: true, + admin: { width: '25%' }, + }, + ], + }, + { + name: 'description', + type: 'textarea', + label: 'Beschreibung', + admin: { rows: 2 }, + }, + + // Scope + { + type: 'row', + fields: [ + { + name: 'channel', + type: 'relationship', + relationTo: 'youtube-channels', + label: 'Kanal (optional)', + admin: { + width: '50%', + description: 'Leer = alle Kanäle', + }, + }, + { + name: 'platforms', + type: 'relationship', + relationTo: 'social-platforms', + hasMany: true, + label: 'Plattformen', + admin: { + width: '50%', + description: 'Leer = alle Plattformen', + }, + }, + ], + }, + + // Trigger + { + name: 'trigger', + type: 'group', + label: 'Trigger', + fields: [ + { + name: 'type', + type: 'select', + required: true, + label: 'Trigger-Typ', + options: [ + { label: 'Keyword Match', value: 'keyword' }, + { label: 'Sentiment', value: 'sentiment' }, + { label: 'Frage erkannt', value: 'question_detected' }, + { label: 'Medizinisch erkannt', value: 'medical_detected' }, + { label: 'Influencer', value: 'influencer' }, + { label: 'Alle neuen', value: 'all_new' }, + { label: 'Enthält Link', value: 'contains_link' }, + { label: 'Enthält Email', value: 'contains_email' }, + ], + }, + { + name: 'keywords', + type: 'array', + label: 'Keywords', + admin: { + condition: (data, siblingData) => siblingData?.type === 'keyword', + }, + fields: [ + { + name: 'keyword', + type: 'text', + required: true, + label: 'Keyword', + }, + { + name: 'matchType', + type: 'select', + label: 'Match-Typ', + options: [ + { label: 'Enthält', value: 'contains' }, + { label: 'Exakt', value: 'exact' }, + { label: 'Regex', value: 'regex' }, + ], + defaultValue: 'contains', + }, + ], + }, + { + name: 'sentimentValues', + type: 'select', + hasMany: true, + label: 'Sentiment-Werte', + options: [ + { label: 'Positiv', value: 'positive' }, + { label: 'Negativ', value: 'negative' }, + { label: 'Neutral', value: 'neutral' }, + { label: 'Frage', value: 'question' }, + ], + admin: { + condition: (data, siblingData) => siblingData?.type === 'sentiment', + }, + }, + { + name: 'influencerMinFollowers', + type: 'number', + label: 'Min. Follower', + defaultValue: 10000, + admin: { + condition: (data, siblingData) => siblingData?.type === 'influencer', + }, + }, + ], + }, + + // Actions + { + name: 'actions', + type: 'array', + label: 'Aktionen', + required: true, + minRows: 1, + fields: [ + { + type: 'row', + fields: [ + { + name: 'action', + type: 'select', + required: true, + label: 'Aktion', + options: [ + { label: 'Priorität setzen', value: 'set_priority' }, + { label: 'Zuweisen', value: 'assign_to' }, + { label: 'Flag setzen', value: 'set_flag' }, + { label: 'Template vorschlagen', value: 'suggest_template' }, + { label: 'Notification senden', value: 'send_notification' }, + { label: 'Medical Flag', value: 'flag_medical' }, + { label: 'Eskalieren', value: 'escalate' }, + { label: 'Als Spam markieren', value: 'mark_spam' }, + { label: 'Deadline setzen', value: 'set_deadline' }, + ], + admin: { width: '40%' }, + }, + { + name: 'value', + type: 'text', + label: 'Wert', + admin: { + width: '40%', + description: 'Priority: urgent/high/normal/low, Deadline: Stunden', + }, + }, + { + name: 'targetUser', + type: 'relationship', + relationTo: 'users', + label: 'User', + admin: { + width: '20%', + condition: (data, siblingData) => + ['assign_to', 'send_notification'].includes(siblingData?.action || ''), + }, + }, + ], + }, + { + name: 'targetTemplate', + type: 'relationship', + relationTo: 'community-templates', + label: 'Template', + admin: { + condition: (data, siblingData) => siblingData?.action === 'suggest_template', + }, + }, + ], + }, + + // Stats + { + name: 'stats', + type: 'group', + label: 'Statistiken', + fields: [ + { + type: 'row', + fields: [ + { + name: 'timesTriggered', + type: 'number', + label: 'Ausgelöst', + defaultValue: 0, + admin: { width: '50%', readOnly: true }, + }, + { + name: 'lastTriggeredAt', + type: 'date', + label: 'Zuletzt ausgelöst', + admin: { width: '50%', readOnly: true }, + }, + ], + }, + ], + }, + ], + timestamps: true, +} diff --git a/src/collections/CommunityTemplates.ts b/src/collections/CommunityTemplates.ts new file mode 100644 index 0000000..4e58e8f --- /dev/null +++ b/src/collections/CommunityTemplates.ts @@ -0,0 +1,214 @@ +// src/collections/CommunityTemplates.ts + +import type { CollectionConfig } from 'payload' +import { + isCommunityManager, + isCommunityModeratorOrAbove, + hasCommunityAccess, +} from '../lib/communityAccess' + +/** + * CommunityTemplates Collection + * + * Antwort-Vorlagen für Community-Interaktionen mit Variablen. + * Teil des Community Management Systems. + */ +export const CommunityTemplates: CollectionConfig = { + slug: 'community-templates', + labels: { + singular: 'Response Template', + plural: 'Response Templates', + }, + admin: { + group: 'Community', + useAsTitle: 'name', + defaultColumns: ['name', 'category', 'channel', 'usageCount'], + }, + access: { + read: hasCommunityAccess, + create: isCommunityModeratorOrAbove, + update: isCommunityModeratorOrAbove, + delete: isCommunityManager, + }, + fields: [ + { + type: 'row', + fields: [ + { + name: 'name', + type: 'text', + required: true, + localized: true, + label: 'Name', + admin: { width: '50%' }, + }, + { + name: 'category', + type: 'select', + required: true, + label: 'Kategorie', + options: [ + { label: 'Danke', value: 'thank_you' }, + { label: 'Frage beantworten', value: 'question_answer' }, + { label: 'Hotline-Verweis', value: 'redirect_hotline' }, + { label: 'Medizinischer Disclaimer', value: 'medical_disclaimer' }, + { label: 'Produkt-Info', value: 'product_info' }, + { label: 'Content-Verweis', value: 'content_reference' }, + { label: 'Follow-up', value: 'follow_up' }, + { label: 'Negatives Feedback', value: 'negative_feedback' }, + { label: 'Spam-Antwort', value: 'spam_response' }, + { label: 'Begrüßung', value: 'welcome' }, + ], + admin: { width: '50%' }, + }, + ], + }, + { + type: 'row', + fields: [ + { + name: 'channel', + type: 'relationship', + relationTo: 'youtube-channels', + label: 'Kanal (optional)', + admin: { + width: '50%', + description: 'Leer = für alle Kanäle', + }, + }, + { + name: 'platforms', + type: 'relationship', + relationTo: 'social-platforms', + hasMany: true, + label: 'Plattformen', + admin: { + width: '50%', + description: 'Leer = für alle Plattformen', + }, + }, + ], + }, + + // Template Text mit Variablen + { + name: 'template', + type: 'textarea', + required: true, + localized: true, + label: 'Template Text', + admin: { + rows: 6, + description: + 'Variablen: {{author_name}}, {{video_title}}, {{channel_name}}, {{hotline_number}}', + }, + }, + + // Verfügbare Variablen + { + name: 'variables', + type: 'array', + label: 'Verfügbare Variablen', + admin: { + description: 'Dokumentation der Variablen in diesem Template', + }, + fields: [ + { + type: 'row', + fields: [ + { + name: 'variable', + type: 'text', + required: true, + label: 'Variable', + admin: { + width: '30%', + placeholder: '{{author_name}}', + }, + }, + { + name: 'description', + type: 'text', + label: 'Beschreibung', + admin: { + width: '50%', + placeholder: 'Name des Kommentar-Autors', + }, + }, + { + name: 'defaultValue', + type: 'text', + label: 'Fallback', + admin: { + width: '20%', + }, + }, + ], + }, + ], + }, + + // Auto-Suggest Keywords + { + name: 'autoSuggestKeywords', + type: 'array', + label: 'Auto-Suggest Keywords', + admin: { + description: 'Bei diesen Keywords wird das Template vorgeschlagen', + }, + fields: [ + { + name: 'keyword', + type: 'text', + required: true, + label: 'Keyword', + }, + ], + }, + + // Flags + { + type: 'row', + fields: [ + { + name: 'requiresReview', + type: 'checkbox', + label: 'Review erforderlich', + admin: { + width: '33%', + description: 'Für medizinische Antworten', + }, + }, + { + name: 'isActive', + type: 'checkbox', + label: 'Aktiv', + defaultValue: true, + admin: { width: '33%' }, + }, + { + name: 'usageCount', + type: 'number', + label: 'Verwendungen', + defaultValue: 0, + admin: { + width: '33%', + readOnly: true, + }, + }, + ], + }, + + // Beispiel-Output + { + name: 'exampleOutput', + type: 'textarea', + label: 'Beispiel-Output', + admin: { + rows: 3, + description: 'So sieht die Antwort mit ausgefüllten Variablen aus', + }, + }, + ], + timestamps: true, +} diff --git a/src/collections/SocialAccounts.ts b/src/collections/SocialAccounts.ts new file mode 100644 index 0000000..efd2401 --- /dev/null +++ b/src/collections/SocialAccounts.ts @@ -0,0 +1,216 @@ +// src/collections/SocialAccounts.ts + +import type { CollectionConfig } from 'payload' +import { isCommunityManager, hasCommunityAccess } from '../lib/communityAccess' + +/** + * SocialAccounts Collection + * + * Verknüpft Social Media Accounts mit YouTube-Kanälen und speichert API-Credentials. + * Teil des Community Management Systems. + */ +export const SocialAccounts: CollectionConfig = { + slug: 'social-accounts', + labels: { + singular: 'Social Account', + plural: 'Social Accounts', + }, + admin: { + group: 'Community', + useAsTitle: 'displayName', + defaultColumns: ['displayName', 'platform', 'linkedChannel', 'isActive'], + }, + access: { + read: hasCommunityAccess, + create: isCommunityManager, + update: isCommunityManager, + delete: isCommunityManager, + }, + fields: [ + { + type: 'row', + fields: [ + { + name: 'platform', + type: 'relationship', + relationTo: 'social-platforms', + required: true, + label: 'Plattform', + admin: { width: '50%' }, + }, + { + name: 'linkedChannel', + type: 'relationship', + relationTo: 'youtube-channels', + label: 'Verknüpfter YouTube-Kanal', + admin: { + width: '50%', + description: 'Für Zuordnung zu Brand/Kanal', + }, + }, + ], + }, + { + type: 'row', + fields: [ + { + name: 'displayName', + type: 'text', + required: true, + label: 'Anzeigename', + admin: { + width: '50%', + placeholder: 'BlogWoman YouTube', + }, + }, + { + name: 'accountHandle', + type: 'text', + label: 'Handle / Username', + admin: { + width: '50%', + placeholder: '@blogwoman', + }, + }, + ], + }, + { + type: 'row', + fields: [ + { + name: 'externalId', + type: 'text', + label: 'Platform Account ID', + admin: { + width: '50%', + description: 'YouTube Channel ID, LinkedIn URN, etc.', + }, + }, + { + name: 'accountUrl', + type: 'text', + label: 'Account URL', + admin: { width: '50%' }, + }, + ], + }, + { + name: 'isActive', + type: 'checkbox', + label: 'Aktiv', + defaultValue: true, + admin: { + position: 'sidebar', + }, + }, + + // OAuth Credentials (verschlüsselt speichern!) + { + name: 'credentials', + type: 'group', + label: 'API Credentials', + admin: { + description: 'Sensible Daten – nur für Super-Admins sichtbar', + condition: (data, siblingData, { user }) => + Boolean((user as any)?.isSuperAdmin || (user as any)?.is_super_admin), + }, + fields: [ + { + name: 'accessToken', + type: 'text', + label: 'Access Token', + admin: { + description: 'OAuth Access Token', + }, + }, + { + name: 'refreshToken', + type: 'text', + label: 'Refresh Token', + }, + { + name: 'tokenExpiresAt', + type: 'date', + label: 'Token Ablauf', + }, + { + name: 'apiKey', + type: 'text', + label: 'API Key', + admin: { + description: 'Für API-Key basierte Auth', + }, + }, + ], + }, + + // Stats (periodisch aktualisiert) + { + name: 'stats', + type: 'group', + label: 'Account Stats', + admin: { + position: 'sidebar', + }, + fields: [ + { + name: 'followers', + type: 'number', + label: 'Followers/Subscribers', + admin: { readOnly: true }, + }, + { + name: 'totalPosts', + type: 'number', + label: 'Total Posts/Videos', + admin: { readOnly: true }, + }, + { + name: 'lastSyncedAt', + type: 'date', + label: 'Letzter Sync', + admin: { readOnly: true }, + }, + ], + }, + + // Sync Settings + { + name: 'syncSettings', + type: 'group', + label: 'Sync Settings', + fields: [ + { + name: 'autoSyncEnabled', + type: 'checkbox', + label: 'Auto-Sync aktiviert', + defaultValue: true, + }, + { + name: 'syncIntervalMinutes', + type: 'number', + label: 'Sync-Intervall (Minuten)', + defaultValue: 15, + min: 5, + max: 1440, + }, + { + name: 'syncComments', + type: 'checkbox', + label: 'Kommentare synchronisieren', + defaultValue: true, + }, + { + name: 'syncDMs', + type: 'checkbox', + label: 'DMs synchronisieren', + defaultValue: false, + admin: { + description: 'Nicht alle Plattformen unterstützen DM-API', + }, + }, + ], + }, + ], + timestamps: true, +} diff --git a/src/collections/SocialPlatforms.ts b/src/collections/SocialPlatforms.ts new file mode 100644 index 0000000..896f35b --- /dev/null +++ b/src/collections/SocialPlatforms.ts @@ -0,0 +1,231 @@ +// src/collections/SocialPlatforms.ts + +import type { CollectionConfig } from 'payload' +import { isCommunityManager, hasCommunityAccess } from '../lib/communityAccess' + +/** + * SocialPlatforms Collection + * + * Definiert unterstützte Social Media Plattformen mit API-Konfiguration. + * Teil des Community Management Systems. + */ +export const SocialPlatforms: CollectionConfig = { + slug: 'social-platforms', + labels: { + singular: 'Social Platform', + plural: 'Social Platforms', + }, + admin: { + group: 'Community', + useAsTitle: 'name', + defaultColumns: ['name', 'slug', 'isActive', 'apiStatus'], + }, + access: { + read: hasCommunityAccess, + create: isCommunityManager, + update: isCommunityManager, + delete: isCommunityManager, + }, + fields: [ + { + type: 'row', + fields: [ + { + name: 'name', + type: 'text', + required: true, + label: 'Name', + admin: { width: '50%' }, + }, + { + name: 'slug', + type: 'text', + required: true, + unique: true, + label: 'Slug', + admin: { width: '50%' }, + }, + ], + }, + { + type: 'row', + fields: [ + { + name: 'icon', + type: 'text', + label: 'Icon (Emoji)', + admin: { + width: '25%', + placeholder: '📺', + }, + }, + { + name: 'color', + type: 'text', + label: 'Brand Color', + admin: { + width: '25%', + placeholder: '#FF0000', + }, + }, + { + name: 'isActive', + type: 'checkbox', + label: 'Aktiv', + defaultValue: true, + admin: { width: '25%' }, + }, + { + name: 'apiStatus', + type: 'select', + label: 'API Status', + options: [ + { label: 'Verbunden', value: 'connected' }, + { label: 'Eingeschränkt', value: 'limited' }, + { label: 'Nicht verbunden', value: 'disconnected' }, + { label: 'In Entwicklung', value: 'development' }, + ], + defaultValue: 'disconnected', + admin: { width: '25%' }, + }, + ], + }, + + // API Configuration + { + name: 'apiConfig', + type: 'group', + label: 'API Konfiguration', + admin: { + condition: (data) => data?.isActive, + }, + fields: [ + { + name: 'apiType', + type: 'select', + label: 'API Type', + options: [ + { label: 'YouTube Data API v3', value: 'youtube_v3' }, + { label: 'LinkedIn API', value: 'linkedin' }, + { label: 'Instagram Graph API', value: 'instagram_graph' }, + { label: 'Facebook Graph API', value: 'facebook_graph' }, + { label: 'Custom/Webhook', value: 'custom' }, + ], + }, + { + name: 'baseUrl', + type: 'text', + label: 'Base URL', + admin: { + placeholder: 'https://www.googleapis.com/youtube/v3', + }, + }, + { + name: 'authType', + type: 'select', + label: 'Auth Type', + options: [ + { label: 'OAuth 2.0', value: 'oauth2' }, + { label: 'API Key', value: 'api_key' }, + { label: 'Bearer Token', value: 'bearer' }, + ], + }, + { + name: 'scopes', + type: 'array', + label: 'OAuth Scopes', + admin: { + condition: (data, siblingData) => siblingData?.authType === 'oauth2', + }, + fields: [{ name: 'scope', type: 'text', label: 'Scope' }], + }, + ], + }, + + // Interaction Types für diese Plattform + { + name: 'interactionTypes', + type: 'array', + label: 'Interaction Types', + fields: [ + { + type: 'row', + fields: [ + { + name: 'type', + type: 'text', + required: true, + label: 'Type', + admin: { + width: '30%', + placeholder: 'comment', + }, + }, + { + name: 'label', + type: 'text', + required: true, + label: 'Label', + admin: { + width: '30%', + placeholder: 'Kommentar', + }, + }, + { + name: 'icon', + type: 'text', + label: 'Icon', + admin: { + width: '20%', + placeholder: '💬', + }, + }, + { + name: 'canReply', + type: 'checkbox', + label: 'Reply möglich', + defaultValue: true, + admin: { width: '20%' }, + }, + ], + }, + ], + }, + + // Rate Limits + { + name: 'rateLimits', + type: 'group', + label: 'Rate Limits', + fields: [ + { + type: 'row', + fields: [ + { + name: 'requestsPerMinute', + type: 'number', + label: 'Requests/Minute', + admin: { width: '33%' }, + }, + { + name: 'requestsPerDay', + type: 'number', + label: 'Requests/Tag', + admin: { width: '33%' }, + }, + { + name: 'quotaUnitsPerDay', + type: 'number', + label: 'Quota Units/Tag', + admin: { + width: '33%', + description: 'YouTube: 10.000/Tag', + }, + }, + ], + }, + ], + }, + ], + timestamps: true, +} diff --git a/src/collections/Users.ts b/src/collections/Users.ts index 3d05a4d..da87c15 100644 --- a/src/collections/Users.ts +++ b/src/collections/Users.ts @@ -90,5 +90,22 @@ export const Users: CollectionConfig = { condition: (data) => data?.youtubeRole && data.youtubeRole !== 'none', }, }, + // Community Management Felder + { + name: 'communityRole', + type: 'select', + label: 'Community-Rolle', + defaultValue: 'none', + options: [ + { label: 'Kein Zugriff', value: 'none' }, + { label: 'Viewer (nur Lesen)', value: 'viewer' }, + { label: 'Moderator', value: 'moderator' }, + { label: 'Manager (Vollzugriff)', value: 'manager' }, + ], + admin: { + position: 'sidebar', + description: 'Zugriff auf Community Management Features', + }, + }, ], } diff --git a/src/hooks/auditUserChanges.ts b/src/hooks/auditUserChanges.ts index 7d9e7d6..18a9559 100644 --- a/src/hooks/auditUserChanges.ts +++ b/src/hooks/auditUserChanges.ts @@ -40,7 +40,8 @@ export const auditUserAfterChange: CollectionAfterChangeHook = async ({ return sanitized } - await logUserChange( + // Fire-and-forget: Nicht auf Audit-Log warten, um Save nicht zu blockieren + logUserChange( req.payload, doc.id, operation, @@ -51,7 +52,9 @@ export const auditUserAfterChange: CollectionAfterChangeHook = async ({ newValue: sanitizeDoc(doc), }, req, - ) + ).catch((error) => { + console.error('[AuditHook] Error logging user change:', error) + }) return doc } @@ -70,7 +73,8 @@ export const auditUserAfterDelete: CollectionAfterDeleteHook = async ({ doc, req delete sanitizedDoc.salt delete sanitizedDoc.password - await logUserChange( + // Fire-and-forget: Nicht auf Audit-Log warten + logUserChange( req.payload, doc.id, 'delete', @@ -80,7 +84,9 @@ export const auditUserAfterDelete: CollectionAfterDeleteHook = async ({ doc, req previousValue: sanitizedDoc, }, req, - ) + ).catch((error) => { + console.error('[AuditHook] Error logging user delete:', error) + }) return doc } diff --git a/src/lib/communityAccess.ts b/src/lib/communityAccess.ts new file mode 100644 index 0000000..5c03942 --- /dev/null +++ b/src/lib/communityAccess.ts @@ -0,0 +1,84 @@ +// src/lib/communityAccess.ts + +import type { Access } from 'payload' + +interface UserWithRoles { + id: number + isSuperAdmin?: boolean + is_super_admin?: boolean + youtubeRole?: 'none' | 'viewer' | 'editor' | 'producer' | 'creator' | 'manager' + youtube_role?: 'none' | 'viewer' | 'editor' | 'producer' | 'creator' | 'manager' + communityRole?: 'none' | 'viewer' | 'moderator' | 'manager' + community_role?: 'none' | 'viewer' | 'moderator' | 'manager' +} + +const checkIsSuperAdmin = (user: UserWithRoles | null): boolean => { + if (!user) return false + return Boolean(user.isSuperAdmin || user.is_super_admin) +} + +const getCommunityRole = (user: UserWithRoles | null): string | undefined => { + if (!user) return undefined + return user.communityRole || user.community_role +} + +const getYouTubeRole = (user: UserWithRoles | null): string | undefined => { + if (!user) return undefined + return user.youtubeRole || user.youtube_role +} + +/** + * Prüft ob User Community-Manager oder Super-Admin ist + */ +export const isCommunityManager: Access = ({ req }) => { + const user = req.user as UserWithRoles | null + if (!user) return false + if (checkIsSuperAdmin(user)) return true + // YouTube-Manager haben auch Community-Zugriff + if (getYouTubeRole(user) === 'manager') return true + return getCommunityRole(user) === 'manager' +} + +/** + * Prüft ob User mindestens Moderator-Rechte hat + */ +export const isCommunityModeratorOrAbove: Access = ({ req }) => { + const user = req.user as UserWithRoles | null + if (!user) return false + if (checkIsSuperAdmin(user)) return true + if (['manager', 'creator'].includes(getYouTubeRole(user) || '')) return true + return ['moderator', 'manager'].includes(getCommunityRole(user) || '') +} + +/** + * Prüft ob User Zugriff auf Community-Features hat (mindestens Viewer) + */ +export const hasCommunityAccess: Access = ({ req }) => { + const user = req.user as UserWithRoles | null + if (!user) return false + if (checkIsSuperAdmin(user)) return true + // YouTube-Zugriff impliziert Community-Lesezugriff + const ytRole = getYouTubeRole(user) + if (ytRole && ytRole !== 'none') return true + const commRole = getCommunityRole(user) + return commRole !== 'none' && commRole !== undefined +} + +/** + * Zugriff auf zugewiesene Interactions + */ +export const canAccessAssignedInteractions: Access = ({ req }) => { + const user = req.user as UserWithRoles | null + if (!user) return false + if (checkIsSuperAdmin(user)) return true + if (getYouTubeRole(user) === 'manager') return true + if (getCommunityRole(user) === 'manager') return true + + // Für andere Rollen: Nur zugewiesene Interactions + return { + or: [ + { assignedTo: { equals: user.id } }, + { 'response.sentBy': { equals: user.id } }, + ], + } +} diff --git a/src/lib/integrations/claude/ClaudeAnalysisService.ts b/src/lib/integrations/claude/ClaudeAnalysisService.ts new file mode 100644 index 0000000..e6d3728 --- /dev/null +++ b/src/lib/integrations/claude/ClaudeAnalysisService.ts @@ -0,0 +1,167 @@ +// src/lib/integrations/claude/ClaudeAnalysisService.ts + +import Anthropic from '@anthropic-ai/sdk' + +interface CommentAnalysis { + sentiment: 'positive' | 'neutral' | 'negative' | 'question' | 'gratitude' | 'frustration' + sentimentScore: number + confidence: number + topics: string[] + language: string + isMedicalQuestion: boolean + requiresEscalation: boolean + isSpam: boolean + suggestedReply?: string +} + +interface ReplyContext { + videoTitle: string + channelName: string + isBusinessChannel: boolean + template?: string +} + +export class ClaudeAnalysisService { + private client: Anthropic + + constructor() { + this.client = new Anthropic({ + apiKey: process.env.ANTHROPIC_API_KEY, + }) + } + + /** + * Kommentar analysieren + */ + async analyzeComment(message: string): Promise { + const systemPrompt = `Du bist ein Analyse-Assistent für YouTube-Kommentare eines deutschen Healthcare-Unternehmens (Medizinische Zweitmeinung) und eines Lifestyle-Kanals. + +Analysiere den Kommentar und gib ein JSON-Objekt zurück mit: +- sentiment: "positive", "neutral", "negative", "question", "gratitude", "frustration" +- sentimentScore: Zahl von -1 (sehr negativ) bis 1 (sehr positiv) +- confidence: Konfidenz der Analyse 0-100 +- topics: Array von erkannten Themen (max 3) +- language: ISO-639-1 Sprachcode (z.B. "de", "en") +- isMedicalQuestion: true wenn es um medizinische Fragen/Gesundheit geht +- requiresEscalation: true wenn dringend/kritisch/negativ oder Beschwerden +- isSpam: true wenn Spam/Werbung/Bot +- suggestedReply: Kurzer Antwortvorschlag auf Deutsch (optional, nur wenn sinnvoll) + +WICHTIG für isMedicalQuestion: +- Fragen zu Intensivmedizin, Diagnosen, Behandlungen, Medikamenten = true +- Fragen zu Angehörigen von Patienten = true +- Allgemeine Lifestyle-Fragen (Mode, Zeitmanagement) = false + +Antworte NUR mit dem JSON-Objekt, kein anderer Text.` + + try { + const response = await this.client.messages.create({ + model: 'claude-3-haiku-20240307', + max_tokens: 500, + system: systemPrompt, + messages: [ + { + role: 'user', + content: `Analysiere diesen Kommentar:\n\n"${message}"`, + }, + ], + }) + + const content = response.content[0] + if (content.type !== 'text') { + throw new Error('Unexpected response type') + } + + const analysis = JSON.parse(content.text) as CommentAnalysis + return analysis + } catch (error) { + console.error('Claude analysis error:', error) + + // Fallback bei Fehler + return { + sentiment: 'neutral', + sentimentScore: 0, + confidence: 0, + topics: [], + language: 'de', + isMedicalQuestion: false, + requiresEscalation: false, + isSpam: false, + } + } + } + + /** + * Antwort-Vorschlag generieren + */ + async generateReply(comment: string, context: ReplyContext): Promise { + const systemPrompt = `Du bist ein Community-Manager für ${context.channelName}. +${ + context.isBusinessChannel + ? 'Dies ist ein Healthcare-Kanal für medizinische Zweitmeinungen. Antworten müssen professionell sein und dürfen keine medizinischen Ratschläge geben. Bei medizinischen Fragen immer auf die Hotline verweisen.' + : 'Dies ist ein Lifestyle-Kanal für berufstätige Mütter. Antworten sollten warm, persönlich und hilfreich sein.' +} + +Erstelle eine passende, kurze Antwort auf den Kommentar. +- Maximal 2-3 Sätze +- Persönlich und authentisch +- Auf Deutsch +${context.template ? `\nVerwende dieses Template als Basis:\n${context.template}` : ''} + +Antworte NUR mit dem Antworttext, kein anderer Text.` + + try { + const response = await this.client.messages.create({ + model: 'claude-3-haiku-20240307', + max_tokens: 200, + system: systemPrompt, + messages: [ + { + role: 'user', + content: `Video: "${context.videoTitle}"\n\nKommentar: "${comment}"\n\nErstelle eine Antwort:`, + }, + ], + }) + + const content = response.content[0] + if (content.type !== 'text') { + throw new Error('Unexpected response type') + } + + return content.text.trim() + } catch (error) { + console.error('Claude reply generation error:', error) + throw error + } + } + + /** + * Batch-Analyse mehrerer Kommentare + */ + async analyzeCommentsBatch( + messages: string[] + ): Promise> { + const results = new Map() + + // Parallele Verarbeitung mit Rate-Limiting + const batchSize = 5 + for (let i = 0; i < messages.length; i += batchSize) { + const batch = messages.slice(i, i + batchSize) + const analyses = await Promise.all( + batch.map((msg) => this.analyzeComment(msg)) + ) + batch.forEach((msg, idx) => { + results.set(msg, analyses[idx]) + }) + + // Kurze Pause zwischen Batches + if (i + batchSize < messages.length) { + await new Promise((resolve) => setTimeout(resolve, 500)) + } + } + + return results + } +} + +export type { CommentAnalysis, ReplyContext } diff --git a/src/lib/integrations/youtube/CommentsSyncService.ts b/src/lib/integrations/youtube/CommentsSyncService.ts new file mode 100644 index 0000000..4aa009b --- /dev/null +++ b/src/lib/integrations/youtube/CommentsSyncService.ts @@ -0,0 +1,254 @@ +// src/lib/integrations/youtube/CommentsSyncService.ts + +import type { Payload } from 'payload' +import { YouTubeClient, type CommentThread } from './YouTubeClient' +import { ClaudeAnalysisService } from '../claude/ClaudeAnalysisService' + +interface SyncOptions { + socialAccountId: number + sinceDate?: Date + maxComments?: number + analyzeWithAI?: boolean +} + +interface SyncResult { + success: boolean + newComments: number + updatedComments: number + errors: string[] + syncedAt: Date +} + +export class CommentsSyncService { + private payload: Payload + private claudeService: ClaudeAnalysisService + + constructor(payload: Payload) { + this.payload = payload + this.claudeService = new ClaudeAnalysisService() + } + + /** + * Kommentare für einen Social Account synchronisieren + */ + async syncComments(options: SyncOptions): Promise { + const { + socialAccountId, + sinceDate, + maxComments = 100, + analyzeWithAI = true, + } = options + + const result: SyncResult = { + success: false, + newComments: 0, + updatedComments: 0, + errors: [], + syncedAt: new Date(), + } + + try { + // 1. Social Account laden + const account = await this.payload.findByID({ + collection: 'social-accounts', + id: socialAccountId, + depth: 2, + }) + + if (!account) { + result.errors.push('Social Account nicht gefunden') + return result + } + + // 2. Platform prüfen + const platform = account.platform as { slug?: string } + if (platform?.slug !== 'youtube') { + result.errors.push('Nur YouTube wird derzeit unterstützt') + return result + } + + // 3. YouTube Client initialisieren + const credentials = account.credentials as { + accessToken?: string + refreshToken?: string + } + + if (!credentials?.accessToken || !credentials?.refreshToken) { + result.errors.push('Keine gültigen API-Credentials') + return result + } + + const youtubeClient = new YouTubeClient( + { + clientId: process.env.YOUTUBE_CLIENT_ID!, + clientSecret: process.env.YOUTUBE_CLIENT_SECRET!, + accessToken: credentials.accessToken, + refreshToken: credentials.refreshToken, + }, + this.payload + ) + + // 4. Kommentare abrufen + const channelId = account.externalId as string + const comments = await youtubeClient.getChannelComments( + channelId, + sinceDate, + maxComments + ) + + // 5. Kommentare verarbeiten + for (const comment of comments) { + try { + const processResult = await this.processComment( + comment, + account, + analyzeWithAI + ) + if (processResult.isNew) { + result.newComments++ + } else { + result.updatedComments++ + } + } catch (error) { + result.errors.push(`Fehler bei Kommentar ${comment.id}: ${error}`) + } + } + + // 6. Account-Stats aktualisieren + await this.payload.update({ + collection: 'social-accounts', + id: socialAccountId, + data: { + stats: { + lastSyncedAt: new Date().toISOString(), + }, + }, + }) + + result.success = true + } catch (error) { + result.errors.push(`Sync-Fehler: ${error}`) + } + + return result + } + + /** + * Einzelnen Kommentar verarbeiten + */ + private async processComment( + comment: CommentThread, + account: any, + analyzeWithAI: boolean + ): Promise<{ isNew: boolean }> { + const snippet = comment.snippet.topLevelComment.snippet + + // Prüfen ob Kommentar bereits existiert + const existing = await this.payload.find({ + collection: 'community-interactions', + where: { + externalId: { equals: comment.id }, + }, + limit: 1, + }) + + const isNew = existing.totalDocs === 0 + + // AI-Analyse durchführen + let analysis = null + if (analyzeWithAI) { + analysis = await this.claudeService.analyzeComment(snippet.textOriginal) + } + + // Verknüpften Content finden (falls Video-ID vorhanden) + let linkedContentId = null + if (comment.snippet.videoId) { + const content = await this.payload.find({ + collection: 'youtube-content', + where: { + youtubeVideoId: { equals: comment.snippet.videoId }, + }, + limit: 1, + }) + if (content.docs[0]) { + linkedContentId = content.docs[0].id + } + } + + // Platform-ID holen + const platform = await this.payload.find({ + collection: 'social-platforms', + where: { + slug: { equals: 'youtube' }, + }, + limit: 1, + }) + const platformId = platform.docs[0]?.id + + const interactionData = { + platform: platformId, + socialAccount: account.id, + linkedContent: linkedContentId, + type: 'comment' as const, + externalId: comment.id, + author: { + name: snippet.authorDisplayName, + handle: snippet.authorChannelId?.value, + profileUrl: snippet.authorChannelUrl, + avatarUrl: snippet.authorProfileImageUrl, + isVerified: false, + isSubscriber: false, + isMember: false, + }, + message: snippet.textOriginal, + messageHtml: snippet.textDisplay, + publishedAt: new Date(snippet.publishedAt).toISOString(), + engagement: { + likes: snippet.likeCount || 0, + replies: comment.snippet.totalReplyCount || 0, + isHearted: false, + isPinned: false, + }, + ...(analysis && { + analysis: { + sentiment: analysis.sentiment, + sentimentScore: analysis.sentimentScore, + confidence: analysis.confidence, + topics: analysis.topics.map((t) => ({ topic: t })), + language: analysis.language, + suggestedReply: analysis.suggestedReply, + analyzedAt: new Date().toISOString(), + }, + flags: { + isMedicalQuestion: analysis.isMedicalQuestion, + requiresEscalation: analysis.requiresEscalation, + isSpam: analysis.isSpam, + isFromInfluencer: false, + }, + }), + } + + if (isNew) { + await this.payload.create({ + collection: 'community-interactions', + data: interactionData, + }) + } else { + await this.payload.update({ + collection: 'community-interactions', + id: existing.docs[0].id, + data: { + ...interactionData, + // Behalte existierende Workflow-Daten + status: existing.docs[0].status, + priority: existing.docs[0].priority, + assignedTo: existing.docs[0].assignedTo, + }, + }) + } + + return { isNew } + } +} + +export type { SyncOptions, SyncResult } diff --git a/src/lib/integrations/youtube/YouTubeClient.ts b/src/lib/integrations/youtube/YouTubeClient.ts new file mode 100644 index 0000000..c1b74b9 --- /dev/null +++ b/src/lib/integrations/youtube/YouTubeClient.ts @@ -0,0 +1,231 @@ +// src/lib/integrations/youtube/YouTubeClient.ts + +import { google, youtube_v3 } from 'googleapis' +import type { Payload } from 'payload' + +interface YouTubeCredentials { + clientId: string + clientSecret: string + accessToken: string + refreshToken: string +} + +interface CommentThread { + id: string + snippet: { + videoId: string + topLevelComment: { + id: string + snippet: { + textDisplay: string + textOriginal: string + authorDisplayName: string + authorProfileImageUrl: string + authorChannelUrl: string + authorChannelId: { value: string } + likeCount: number + publishedAt: string + updatedAt: string + } + } + totalReplyCount: number + } +} + +export class YouTubeClient { + private youtube: youtube_v3.Youtube + private oauth2Client: ReturnType + private payload: Payload + + constructor(credentials: YouTubeCredentials, payload: Payload) { + this.payload = payload + + this.oauth2Client = new google.auth.OAuth2( + credentials.clientId, + credentials.clientSecret, + process.env.YOUTUBE_REDIRECT_URI + ) + + this.oauth2Client.setCredentials({ + access_token: credentials.accessToken, + refresh_token: credentials.refreshToken, + }) + + this.youtube = google.youtube({ + version: 'v3', + auth: this.oauth2Client, + }) + } + + /** + * Alle Kommentar-Threads eines Videos abrufen + */ + async getVideoComments( + videoId: string, + pageToken?: string, + maxResults: number = 100 + ): Promise<{ + comments: CommentThread[] + nextPageToken?: string + }> { + try { + const response = await this.youtube.commentThreads.list({ + part: ['snippet', 'replies'], + videoId: videoId, + maxResults: maxResults, + pageToken: pageToken, + order: 'time', + textFormat: 'plainText', + }) + + return { + comments: response.data.items as CommentThread[], + nextPageToken: response.data.nextPageToken || undefined, + } + } catch (error) { + console.error('Error fetching comments:', error) + throw error + } + } + + /** + * Alle Kommentare eines Kanals abrufen (alle Videos) + */ + async getChannelComments( + channelId: string, + publishedAfter?: Date, + maxResults: number = 100 + ): Promise { + try { + const params: youtube_v3.Params$Resource$Commentthreads$List = { + part: ['snippet', 'replies'], + allThreadsRelatedToChannelId: channelId, + maxResults: maxResults, + order: 'time', + textFormat: 'plainText', + } + + const response = await this.youtube.commentThreads.list(params) + return response.data.items as CommentThread[] + } catch (error) { + console.error('Error fetching channel comments:', error) + throw error + } + } + + /** + * Auf einen Kommentar antworten + */ + async replyToComment(parentCommentId: string, text: string): Promise { + try { + const response = await this.youtube.comments.insert({ + part: ['snippet'], + requestBody: { + snippet: { + parentId: parentCommentId, + textOriginal: text, + }, + }, + }) + + return response.data.id! + } catch (error) { + console.error('Error replying to comment:', error) + throw error + } + } + + /** + * Kommentar als Spam markieren + */ + async markAsSpam(commentId: string): Promise { + try { + await this.youtube.comments.setModerationStatus({ + id: [commentId], + moderationStatus: 'rejected', + }) + } catch (error) { + console.error('Error marking as spam:', error) + throw error + } + } + + /** + * Kommentar löschen + */ + async deleteComment(commentId: string): Promise { + try { + await this.youtube.comments.delete({ + id: commentId, + }) + } catch (error) { + console.error('Error deleting comment:', error) + throw error + } + } + + /** + * Heart/Like auf einen Kommentar setzen + */ + async heartComment(commentId: string): Promise { + try { + await this.youtube.comments.setModerationStatus({ + id: [commentId], + moderationStatus: 'published', + }) + // Note: YouTube API doesn't directly support "hearting" via API + // This would require additional implementation + } catch (error) { + console.error('Error hearting comment:', error) + throw error + } + } + + /** + * Access Token erneuern + */ + async refreshAccessToken(): Promise<{ + accessToken: string + expiresAt: Date + }> { + try { + const { credentials } = await this.oauth2Client.refreshAccessToken() + + return { + accessToken: credentials.access_token!, + expiresAt: new Date(credentials.expiry_date!), + } + } catch (error) { + console.error('Error refreshing token:', error) + throw error + } + } + + /** + * Kanal-Statistiken abrufen + */ + async getChannelStats(channelId: string): Promise<{ + subscriberCount: number + videoCount: number + viewCount: number + }> { + try { + const response = await this.youtube.channels.list({ + part: ['statistics'], + id: [channelId], + }) + + const stats = response.data.items?.[0]?.statistics + return { + subscriberCount: parseInt(stats?.subscriberCount || '0', 10), + videoCount: parseInt(stats?.videoCount || '0', 10), + viewCount: parseInt(stats?.viewCount || '0', 10), + } + } catch (error) { + console.error('Error fetching channel stats:', error) + throw error + } + } +} + +export type { YouTubeCredentials, CommentThread } diff --git a/src/migrations/20260113_180000_add_community_phase1.ts b/src/migrations/20260113_180000_add_community_phase1.ts new file mode 100644 index 0000000..b5e4259 --- /dev/null +++ b/src/migrations/20260113_180000_add_community_phase1.ts @@ -0,0 +1,444 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' + +/** + * Migration: Community Phase 1 + * + * Creates all Community Management collections: + * - social_platforms + * - social_accounts + * - community_templates (before interactions, as it's referenced) + * - community_interactions + * - community_rules + * + * Also adds communityRole to users table. + */ +export async function up({ db }: MigrateUpArgs): Promise { + // Step 1: Add user column + await db.execute(sql` + ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "community_role" varchar DEFAULT 'none'; + `) + + // Step 2: Create social_platforms (no dependencies) + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "social_platforms" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar NOT NULL, + "slug" varchar NOT NULL UNIQUE, + "icon" varchar, + "color" varchar, + "is_active" boolean DEFAULT true, + "api_status" varchar DEFAULT 'disconnected', + "api_config_api_type" varchar, + "api_config_base_url" varchar, + "api_config_auth_type" varchar, + "rate_limits_requests_per_minute" numeric, + "rate_limits_requests_per_day" numeric, + "rate_limits_quota_units_per_day" numeric, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL + ); + + CREATE INDEX IF NOT EXISTS "social_platforms_slug_idx" ON "social_platforms" USING btree ("slug"); + CREATE INDEX IF NOT EXISTS "social_platforms_is_active_idx" ON "social_platforms" USING btree ("is_active"); + CREATE INDEX IF NOT EXISTS "social_platforms_updated_at_idx" ON "social_platforms" USING btree ("updated_at"); + CREATE INDEX IF NOT EXISTS "social_platforms_created_at_idx" ON "social_platforms" USING btree ("created_at"); + `) + + // Step 3: Create social_platforms array tables + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "social_platforms_api_config_scopes" ( + "id" serial PRIMARY KEY NOT NULL, + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL REFERENCES social_platforms(id) ON DELETE CASCADE, + "scope" varchar + ); + + CREATE INDEX IF NOT EXISTS "social_platforms_api_config_scopes_order_idx" ON "social_platforms_api_config_scopes" USING btree ("_order"); + CREATE INDEX IF NOT EXISTS "social_platforms_api_config_scopes_parent_idx" ON "social_platforms_api_config_scopes" USING btree ("_parent_id"); + + CREATE TABLE IF NOT EXISTS "social_platforms_interaction_types" ( + "id" serial PRIMARY KEY NOT NULL, + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL REFERENCES social_platforms(id) ON DELETE CASCADE, + "type" varchar NOT NULL, + "label" varchar NOT NULL, + "icon" varchar, + "can_reply" boolean DEFAULT true + ); + + CREATE INDEX IF NOT EXISTS "social_platforms_interaction_types_order_idx" ON "social_platforms_interaction_types" USING btree ("_order"); + CREATE INDEX IF NOT EXISTS "social_platforms_interaction_types_parent_idx" ON "social_platforms_interaction_types" USING btree ("_parent_id"); + `) + + // Step 4: Create social_accounts (depends on social_platforms, youtube_channels) + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "social_accounts" ( + "id" serial PRIMARY KEY NOT NULL, + "platform_id" integer REFERENCES social_platforms(id) ON DELETE SET NULL, + "linked_channel_id" integer REFERENCES youtube_channels(id) ON DELETE SET NULL, + "display_name" varchar NOT NULL, + "account_handle" varchar, + "external_id" varchar, + "account_url" varchar, + "is_active" boolean DEFAULT true, + "credentials_access_token" varchar, + "credentials_refresh_token" varchar, + "credentials_token_expires_at" timestamp(3) with time zone, + "credentials_api_key" varchar, + "stats_followers" numeric, + "stats_total_posts" numeric, + "stats_last_synced_at" timestamp(3) with time zone, + "sync_settings_auto_sync_enabled" boolean DEFAULT true, + "sync_settings_sync_interval_minutes" numeric DEFAULT 15, + "sync_settings_sync_comments" boolean DEFAULT true, + "sync_settings_sync_d_ms" boolean DEFAULT false, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL + ); + + CREATE INDEX IF NOT EXISTS "social_accounts_platform_idx" ON "social_accounts" USING btree ("platform_id"); + CREATE INDEX IF NOT EXISTS "social_accounts_linked_channel_idx" ON "social_accounts" USING btree ("linked_channel_id"); + CREATE INDEX IF NOT EXISTS "social_accounts_is_active_idx" ON "social_accounts" USING btree ("is_active"); + CREATE INDEX IF NOT EXISTS "social_accounts_updated_at_idx" ON "social_accounts" USING btree ("updated_at"); + CREATE INDEX IF NOT EXISTS "social_accounts_created_at_idx" ON "social_accounts" USING btree ("created_at"); + `) + + // Step 5: Create community_templates FIRST (before interactions, as it's referenced) + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "community_templates" ( + "id" serial PRIMARY KEY NOT NULL, + "category" varchar NOT NULL, + "channel_id" integer REFERENCES youtube_channels(id) ON DELETE SET NULL, + "requires_review" boolean DEFAULT false, + "is_active" boolean DEFAULT true, + "usage_count" numeric DEFAULT 0, + "example_output" text, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL + ); + + CREATE INDEX IF NOT EXISTS "community_templates_category_idx" ON "community_templates" USING btree ("category"); + CREATE INDEX IF NOT EXISTS "community_templates_channel_idx" ON "community_templates" USING btree ("channel_id"); + CREATE INDEX IF NOT EXISTS "community_templates_is_active_idx" ON "community_templates" USING btree ("is_active"); + CREATE INDEX IF NOT EXISTS "community_templates_updated_at_idx" ON "community_templates" USING btree ("updated_at"); + CREATE INDEX IF NOT EXISTS "community_templates_created_at_idx" ON "community_templates" USING btree ("created_at"); + `) + + // Step 6: Create community_templates related tables + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "community_templates_locales" ( + "name" varchar NOT NULL, + "template" text NOT NULL, + "id" serial PRIMARY KEY NOT NULL, + "_locale" varchar NOT NULL, + "_parent_id" integer NOT NULL REFERENCES community_templates(id) ON DELETE CASCADE, + CONSTRAINT "community_templates_locales_locale_parent_id_unique" UNIQUE("_locale", "_parent_id") + ); + + CREATE INDEX IF NOT EXISTS "community_templates_locales_locale_idx" ON "community_templates_locales" USING btree ("_locale"); + CREATE INDEX IF NOT EXISTS "community_templates_locales_parent_idx" ON "community_templates_locales" USING btree ("_parent_id"); + + CREATE TABLE IF NOT EXISTS "community_templates_variables" ( + "id" serial PRIMARY KEY NOT NULL, + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL REFERENCES community_templates(id) ON DELETE CASCADE, + "variable" varchar NOT NULL, + "description" varchar, + "default_value" varchar + ); + + CREATE INDEX IF NOT EXISTS "community_templates_variables_order_idx" ON "community_templates_variables" USING btree ("_order"); + CREATE INDEX IF NOT EXISTS "community_templates_variables_parent_idx" ON "community_templates_variables" USING btree ("_parent_id"); + + CREATE TABLE IF NOT EXISTS "community_templates_auto_suggest_keywords" ( + "id" serial PRIMARY KEY NOT NULL, + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL REFERENCES community_templates(id) ON DELETE CASCADE, + "keyword" varchar NOT NULL + ); + + CREATE INDEX IF NOT EXISTS "community_templates_auto_suggest_keywords_order_idx" ON "community_templates_auto_suggest_keywords" USING btree ("_order"); + CREATE INDEX IF NOT EXISTS "community_templates_auto_suggest_keywords_parent_idx" ON "community_templates_auto_suggest_keywords" USING btree ("_parent_id"); + + CREATE TABLE IF NOT EXISTS "community_templates_rels" ( + "id" serial PRIMARY KEY NOT NULL, + "order" integer, + "parent_id" integer NOT NULL REFERENCES community_templates(id) ON DELETE CASCADE, + "path" varchar NOT NULL, + "social_platforms_id" integer REFERENCES social_platforms(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS "community_templates_rels_order_idx" ON "community_templates_rels" USING btree ("order"); + CREATE INDEX IF NOT EXISTS "community_templates_rels_parent_idx" ON "community_templates_rels" USING btree ("parent_id"); + CREATE INDEX IF NOT EXISTS "community_templates_rels_path_idx" ON "community_templates_rels" USING btree ("path"); + CREATE INDEX IF NOT EXISTS "community_templates_rels_social_platforms_idx" ON "community_templates_rels" USING btree ("social_platforms_id"); + `) + + // Step 7: Create community_interactions (now community_templates exists) + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "community_interactions" ( + "id" serial PRIMARY KEY NOT NULL, + "platform_id" integer REFERENCES social_platforms(id) ON DELETE SET NULL, + "social_account_id" integer REFERENCES social_accounts(id) ON DELETE SET NULL, + "linked_content_id" integer REFERENCES youtube_content(id) ON DELETE SET NULL, + "type" varchar NOT NULL, + "external_id" varchar NOT NULL UNIQUE, + "parent_interaction_id" integer, + "author_name" varchar, + "author_handle" varchar, + "author_profile_url" varchar, + "author_avatar_url" varchar, + "author_is_verified" boolean DEFAULT false, + "author_is_subscriber" boolean DEFAULT false, + "author_is_member" boolean DEFAULT false, + "author_subscriber_count" numeric, + "message" text NOT NULL, + "message_html" text, + "published_at" timestamp(3) with time zone NOT NULL, + "analysis_sentiment" varchar, + "analysis_sentiment_score" numeric, + "analysis_confidence" numeric, + "analysis_language" varchar, + "analysis_suggested_template_id" integer REFERENCES community_templates(id) ON DELETE SET NULL, + "analysis_suggested_reply" text, + "analysis_analyzed_at" timestamp(3) with time zone, + "flags_is_medical_question" boolean DEFAULT false, + "flags_requires_escalation" boolean DEFAULT false, + "flags_is_spam" boolean DEFAULT false, + "flags_is_from_influencer" boolean DEFAULT false, + "status" varchar DEFAULT 'new' NOT NULL, + "priority" varchar DEFAULT 'normal' NOT NULL, + "assigned_to_id" integer REFERENCES users(id) ON DELETE SET NULL, + "response_deadline" timestamp(3) with time zone, + "response_text" text, + "response_used_template_id" integer REFERENCES community_templates(id) ON DELETE SET NULL, + "response_sent_at" timestamp(3) with time zone, + "response_sent_by_id" integer REFERENCES users(id) ON DELETE SET NULL, + "response_external_reply_id" varchar, + "engagement_likes" numeric DEFAULT 0, + "engagement_replies" numeric DEFAULT 0, + "engagement_is_hearted" boolean DEFAULT false, + "engagement_is_pinned" boolean DEFAULT false, + "internal_notes" text, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL + ); + `) + + // Step 8: Add self-reference for parent_interaction after table exists + await db.execute(sql` + ALTER TABLE "community_interactions" + ADD CONSTRAINT "community_interactions_parent_fk" + FOREIGN KEY ("parent_interaction_id") REFERENCES community_interactions(id) ON DELETE SET NULL; + `) + + // Step 9: Create indexes for community_interactions + await db.execute(sql` + CREATE INDEX IF NOT EXISTS "community_interactions_platform_idx" ON "community_interactions" USING btree ("platform_id"); + CREATE INDEX IF NOT EXISTS "community_interactions_social_account_idx" ON "community_interactions" USING btree ("social_account_id"); + CREATE INDEX IF NOT EXISTS "community_interactions_linked_content_idx" ON "community_interactions" USING btree ("linked_content_id"); + CREATE INDEX IF NOT EXISTS "community_interactions_external_id_idx" ON "community_interactions" USING btree ("external_id"); + CREATE INDEX IF NOT EXISTS "community_interactions_parent_idx" ON "community_interactions" USING btree ("parent_interaction_id"); + CREATE INDEX IF NOT EXISTS "community_interactions_status_idx" ON "community_interactions" USING btree ("status"); + CREATE INDEX IF NOT EXISTS "community_interactions_priority_idx" ON "community_interactions" USING btree ("priority"); + CREATE INDEX IF NOT EXISTS "community_interactions_assigned_to_idx" ON "community_interactions" USING btree ("assigned_to_id"); + CREATE INDEX IF NOT EXISTS "community_interactions_updated_at_idx" ON "community_interactions" USING btree ("updated_at"); + CREATE INDEX IF NOT EXISTS "community_interactions_created_at_idx" ON "community_interactions" USING btree ("created_at"); + `) + + // Step 10: Create community_interactions array tables + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "community_interactions_attachments" ( + "id" serial PRIMARY KEY NOT NULL, + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL REFERENCES community_interactions(id) ON DELETE CASCADE, + "type" varchar, + "url" varchar + ); + + CREATE INDEX IF NOT EXISTS "community_interactions_attachments_order_idx" ON "community_interactions_attachments" USING btree ("_order"); + CREATE INDEX IF NOT EXISTS "community_interactions_attachments_parent_idx" ON "community_interactions_attachments" USING btree ("_parent_id"); + + CREATE TABLE IF NOT EXISTS "community_interactions_analysis_topics" ( + "id" serial PRIMARY KEY NOT NULL, + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL REFERENCES community_interactions(id) ON DELETE CASCADE, + "topic" varchar + ); + + CREATE INDEX IF NOT EXISTS "community_interactions_analysis_topics_order_idx" ON "community_interactions_analysis_topics" USING btree ("_order"); + CREATE INDEX IF NOT EXISTS "community_interactions_analysis_topics_parent_idx" ON "community_interactions_analysis_topics" USING btree ("_parent_id"); + `) + + // Step 11: Create community_rules + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "community_rules" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar NOT NULL, + "priority" numeric DEFAULT 100 NOT NULL, + "is_active" boolean DEFAULT true, + "description" text, + "channel_id" integer REFERENCES youtube_channels(id) ON DELETE SET NULL, + "trigger_type" varchar NOT NULL, + "trigger_influencer_min_followers" numeric DEFAULT 10000, + "stats_times_triggered" numeric DEFAULT 0, + "stats_last_triggered_at" timestamp(3) with time zone, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL + ); + + CREATE INDEX IF NOT EXISTS "community_rules_name_idx" ON "community_rules" USING btree ("name"); + CREATE INDEX IF NOT EXISTS "community_rules_priority_idx" ON "community_rules" USING btree ("priority"); + CREATE INDEX IF NOT EXISTS "community_rules_is_active_idx" ON "community_rules" USING btree ("is_active"); + CREATE INDEX IF NOT EXISTS "community_rules_channel_idx" ON "community_rules" USING btree ("channel_id"); + CREATE INDEX IF NOT EXISTS "community_rules_updated_at_idx" ON "community_rules" USING btree ("updated_at"); + CREATE INDEX IF NOT EXISTS "community_rules_created_at_idx" ON "community_rules" USING btree ("created_at"); + `) + + // Step 12: Create community_rules array tables + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "community_rules_trigger_keywords" ( + "id" serial PRIMARY KEY NOT NULL, + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL REFERENCES community_rules(id) ON DELETE CASCADE, + "keyword" varchar NOT NULL, + "match_type" varchar DEFAULT 'contains' + ); + + CREATE INDEX IF NOT EXISTS "community_rules_trigger_keywords_order_idx" ON "community_rules_trigger_keywords" USING btree ("_order"); + CREATE INDEX IF NOT EXISTS "community_rules_trigger_keywords_parent_idx" ON "community_rules_trigger_keywords" USING btree ("_parent_id"); + + CREATE TABLE IF NOT EXISTS "community_rules_actions" ( + "id" serial PRIMARY KEY NOT NULL, + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL REFERENCES community_rules(id) ON DELETE CASCADE, + "action" varchar NOT NULL, + "value" varchar, + "target_user_id" integer REFERENCES users(id) ON DELETE SET NULL, + "target_template_id" integer REFERENCES community_templates(id) ON DELETE SET NULL + ); + + CREATE INDEX IF NOT EXISTS "community_rules_actions_order_idx" ON "community_rules_actions" USING btree ("_order"); + CREATE INDEX IF NOT EXISTS "community_rules_actions_parent_idx" ON "community_rules_actions" USING btree ("_parent_id"); + CREATE INDEX IF NOT EXISTS "community_rules_actions_target_user_idx" ON "community_rules_actions" USING btree ("target_user_id"); + CREATE INDEX IF NOT EXISTS "community_rules_actions_target_template_idx" ON "community_rules_actions" USING btree ("target_template_id"); + + CREATE TABLE IF NOT EXISTS "community_rules_rels" ( + "id" serial PRIMARY KEY NOT NULL, + "order" integer, + "parent_id" integer NOT NULL REFERENCES community_rules(id) ON DELETE CASCADE, + "path" varchar NOT NULL, + "social_platforms_id" integer REFERENCES social_platforms(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS "community_rules_rels_order_idx" ON "community_rules_rels" USING btree ("order"); + CREATE INDEX IF NOT EXISTS "community_rules_rels_parent_idx" ON "community_rules_rels" USING btree ("parent_id"); + CREATE INDEX IF NOT EXISTS "community_rules_rels_path_idx" ON "community_rules_rels" USING btree ("path"); + CREATE INDEX IF NOT EXISTS "community_rules_rels_social_platforms_idx" ON "community_rules_rels" USING btree ("social_platforms_id"); + + CREATE TABLE IF NOT EXISTS "community_rules_trigger_sentiment_values" ( + "id" serial PRIMARY KEY NOT NULL, + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL REFERENCES community_rules(id) ON DELETE CASCADE, + "value" varchar + ); + + CREATE INDEX IF NOT EXISTS "community_rules_trigger_sentiment_values_order_idx" ON "community_rules_trigger_sentiment_values" USING btree ("_order"); + CREATE INDEX IF NOT EXISTS "community_rules_trigger_sentiment_values_parent_idx" ON "community_rules_trigger_sentiment_values" USING btree ("_parent_id"); + `) + + // Step 13: Update system table + await db.execute(sql` + ALTER TABLE "payload_locked_documents_rels" + ADD COLUMN IF NOT EXISTS "social_platforms_id" integer REFERENCES social_platforms(id) ON DELETE CASCADE; + + ALTER TABLE "payload_locked_documents_rels" + ADD COLUMN IF NOT EXISTS "social_accounts_id" integer REFERENCES social_accounts(id) ON DELETE CASCADE; + + ALTER TABLE "payload_locked_documents_rels" + ADD COLUMN IF NOT EXISTS "community_interactions_id" integer REFERENCES community_interactions(id) ON DELETE CASCADE; + + ALTER TABLE "payload_locked_documents_rels" + ADD COLUMN IF NOT EXISTS "community_templates_id" integer REFERENCES community_templates(id) ON DELETE CASCADE; + + ALTER TABLE "payload_locked_documents_rels" + ADD COLUMN IF NOT EXISTS "community_rules_id" integer REFERENCES community_rules(id) ON DELETE CASCADE; + + CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_social_platforms_idx" + ON "payload_locked_documents_rels" USING btree ("social_platforms_id"); + + CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_social_accounts_idx" + ON "payload_locked_documents_rels" USING btree ("social_accounts_id"); + + CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_community_interactions_idx" + ON "payload_locked_documents_rels" USING btree ("community_interactions_id"); + + CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_community_templates_idx" + ON "payload_locked_documents_rels" USING btree ("community_templates_id"); + + CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_community_rules_idx" + ON "payload_locked_documents_rels" USING btree ("community_rules_id"); + `) + + // Step 14: Seed YouTube Platform + await db.execute(sql` + INSERT INTO "social_platforms" ("name", "slug", "icon", "color", "is_active", "api_status", "api_config_api_type", "api_config_base_url", "api_config_auth_type", "rate_limits_quota_units_per_day") + VALUES ('YouTube', 'youtube', '📺', '#FF0000', true, 'development', 'youtube_v3', 'https://www.googleapis.com/youtube/v3', 'oauth2', 10000) + ON CONFLICT (slug) DO NOTHING; + `) +} + +export async function down({ db }: MigrateDownArgs): Promise { + // Drop in reverse order of creation + await db.execute(sql` + DROP INDEX IF EXISTS "payload_locked_documents_rels_community_rules_idx"; + DROP INDEX IF EXISTS "payload_locked_documents_rels_community_templates_idx"; + DROP INDEX IF EXISTS "payload_locked_documents_rels_community_interactions_idx"; + DROP INDEX IF EXISTS "payload_locked_documents_rels_social_accounts_idx"; + DROP INDEX IF EXISTS "payload_locked_documents_rels_social_platforms_idx"; + + ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "community_rules_id"; + ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "community_templates_id"; + ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "community_interactions_id"; + ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "social_accounts_id"; + ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "social_platforms_id"; + `) + + await db.execute(sql` + DROP TABLE IF EXISTS "community_rules_trigger_sentiment_values"; + DROP TABLE IF EXISTS "community_rules_rels"; + DROP TABLE IF EXISTS "community_rules_actions"; + DROP TABLE IF EXISTS "community_rules_trigger_keywords"; + DROP TABLE IF EXISTS "community_rules"; + `) + + await db.execute(sql` + DROP TABLE IF EXISTS "community_interactions_analysis_topics"; + DROP TABLE IF EXISTS "community_interactions_attachments"; + ALTER TABLE "community_interactions" DROP CONSTRAINT IF EXISTS "community_interactions_parent_fk"; + DROP TABLE IF EXISTS "community_interactions"; + `) + + await db.execute(sql` + DROP TABLE IF EXISTS "community_templates_rels"; + DROP TABLE IF EXISTS "community_templates_auto_suggest_keywords"; + DROP TABLE IF EXISTS "community_templates_variables"; + DROP TABLE IF EXISTS "community_templates_locales"; + DROP TABLE IF EXISTS "community_templates"; + `) + + await db.execute(sql` + DROP TABLE IF EXISTS "social_accounts"; + `) + + await db.execute(sql` + DROP TABLE IF EXISTS "social_platforms_interaction_types"; + DROP TABLE IF EXISTS "social_platforms_api_config_scopes"; + DROP TABLE IF EXISTS "social_platforms"; + `) + + await db.execute(sql` + ALTER TABLE "users" DROP COLUMN IF EXISTS "community_role"; + `) +} diff --git a/src/migrations/20260114_200000_fix_community_role_enum.ts b/src/migrations/20260114_200000_fix_community_role_enum.ts new file mode 100644 index 0000000..6ae9b5b --- /dev/null +++ b/src/migrations/20260114_200000_fix_community_role_enum.ts @@ -0,0 +1,63 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' + +/** + * Migration: Fix Community Role Enum + * + * The previous migration created community_role as varchar, + * but Payload 3.x expects an enum type for select fields. + */ +export async function up({ db }: MigrateUpArgs): Promise { + // Step 1: Create the enum type + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "enum_users_community_role" AS ENUM ('none', 'viewer', 'moderator', 'manager'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `) + + // Step 2: Drop the default first + await db.execute(sql` + ALTER TABLE "users" + ALTER COLUMN "community_role" DROP DEFAULT; + `) + + // Step 3: Alter the column to use the enum type + await db.execute(sql` + ALTER TABLE "users" + ALTER COLUMN "community_role" TYPE "enum_users_community_role" + USING "community_role"::"enum_users_community_role"; + `) + + // Step 4: Set the new default value with proper type + await db.execute(sql` + ALTER TABLE "users" + ALTER COLUMN "community_role" SET DEFAULT 'none'::"enum_users_community_role"; + `) +} + +export async function down({ db }: MigrateDownArgs): Promise { + // Step 1: Drop default + await db.execute(sql` + ALTER TABLE "users" + ALTER COLUMN "community_role" DROP DEFAULT; + `) + + // Step 2: Revert to varchar + await db.execute(sql` + ALTER TABLE "users" + ALTER COLUMN "community_role" TYPE varchar + USING "community_role"::varchar; + `) + + // Step 3: Set varchar default + await db.execute(sql` + ALTER TABLE "users" + ALTER COLUMN "community_role" SET DEFAULT 'none'; + `) + + // Step 4: Drop enum type + await db.execute(sql` + DROP TYPE IF EXISTS "enum_users_community_role"; + `) +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 7ee6443..d17c689 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -31,6 +31,8 @@ import * as migration_20260109_030000_add_blogwoman_block_tables from './2026010 import * as migration_20260112_150000_add_youtube_operations_hub from './20260112_150000_add_youtube_operations_hub'; import * as migration_20260112_220000_add_youtube_ops_v2 from './20260112_220000_add_youtube_ops_v2'; import * as migration_20260113_140000_create_yt_series from './20260113_140000_create_yt_series'; +import * as migration_20260113_180000_add_community_phase1 from './20260113_180000_add_community_phase1'; +import * as migration_20260114_200000_fix_community_role_enum from './20260114_200000_fix_community_role_enum'; export const migrations = [ { @@ -198,4 +200,14 @@ export const migrations = [ down: migration_20260113_140000_create_yt_series.down, name: '20260113_140000_create_yt_series' }, + { + up: migration_20260113_180000_add_community_phase1.up, + down: migration_20260113_180000_add_community_phase1.down, + name: '20260113_180000_add_community_phase1' + }, + { + up: migration_20260114_200000_fix_community_role_enum.up, + down: migration_20260114_200000_fix_community_role_enum.down, + name: '20260114_200000_fix_community_role_enum' + }, ]; diff --git a/src/payload-types.ts b/src/payload-types.ts index f460746..6a05676 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -108,6 +108,12 @@ export interface Config { 'yt-monthly-goals': YtMonthlyGoal; 'yt-script-templates': YtScriptTemplate; 'yt-checklist-templates': YtChecklistTemplate; + 'yt-series': YtSery; + 'social-platforms': SocialPlatform; + 'social-accounts': SocialAccount; + 'community-interactions': CommunityInteraction; + 'community-templates': CommunityTemplate; + 'community-rules': CommunityRule; 'cookie-configurations': CookieConfiguration; 'cookie-inventory': CookieInventory; 'consent-logs': ConsentLog; @@ -167,6 +173,12 @@ export interface Config { 'yt-monthly-goals': YtMonthlyGoalsSelect | YtMonthlyGoalsSelect; 'yt-script-templates': YtScriptTemplatesSelect | YtScriptTemplatesSelect; 'yt-checklist-templates': YtChecklistTemplatesSelect | YtChecklistTemplatesSelect; + 'yt-series': YtSeriesSelect | YtSeriesSelect; + 'social-platforms': SocialPlatformsSelect | SocialPlatformsSelect; + 'social-accounts': SocialAccountsSelect | SocialAccountsSelect; + 'community-interactions': CommunityInteractionsSelect | CommunityInteractionsSelect; + 'community-templates': CommunityTemplatesSelect | CommunityTemplatesSelect; + 'community-rules': CommunityRulesSelect | CommunityRulesSelect; 'cookie-configurations': CookieConfigurationsSelect | CookieConfigurationsSelect; 'cookie-inventory': CookieInventorySelect | CookieInventorySelect; 'consent-logs': ConsentLogsSelect | ConsentLogsSelect; @@ -238,6 +250,10 @@ export interface User { * Zugewiesene YouTube-Kanäle */ youtubeChannels?: (number | YoutubeChannel)[] | null; + /** + * Zugriff auf Community Management Features + */ + communityRole?: ('none' | 'viewer' | 'moderator' | 'manager') | null; tenants?: | { tenant: number | Tenant; @@ -298,22 +314,6 @@ export interface YoutubeChannel { logo?: (number | null) | Media; thumbnailTemplate?: (number | null) | Media; }; - /** - * Wiederkehrende Formate wie "GRFI", "Investment-Piece", etc. - */ - contentSeries?: - | { - name: string; - slug: string; - description?: string | null; - /** - * Farbe für UI - */ - color?: string | null; - isActive?: boolean | null; - id?: string | null; - }[] - | null; publishingSchedule?: { defaultDays?: ('monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday')[] | null; /** @@ -6221,9 +6221,9 @@ export interface YoutubeContent { slug: string; channel: number | YoutubeChannel; /** - * Slug der Serie aus dem Kanal (z.B. "grfi", "investment-piece") + * Content-Serie für dieses Video */ - contentSeries?: string | null; + series?: (number | null) | YtSery; format: 'short' | 'longform' | 'premiere'; status: | 'idea' @@ -6282,49 +6282,6 @@ export interface YoutubeContent { productionDate?: string | null; targetDuration?: string | null; bRollNotes?: string | null; - /** - * Structured video script with sections - */ - script?: - | { - sectionType: 'hook' | 'intro_ident' | 'context' | 'content_part' | 'summary' | 'cta' | 'outro' | 'disclaimer'; - duration?: string | null; - sectionTitle?: string | null; - spokenText: { - 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; - }; - bRollInstructions?: - | { - instruction: string; - timestamp?: string | null; - id?: string | null; - }[] - | null; - textOverlays?: - | { - text: string; - style?: ('standard' | 'highlight' | 'quote' | 'statistic' | 'list') | null; - id?: string | null; - }[] - | null; - visualNotes?: string | null; - id?: string | null; - blockName?: string | null; - blockType: 'script-section'; - }[] - | null; publishTime?: string | null; thumbnailText?: string | null; ctaType?: ('link_in_bio' | 'newsletter' | 'longform_link' | 'custom') | null; @@ -6440,6 +6397,58 @@ export interface YoutubeContent { updatedAt: string; createdAt: string; } +/** + * Content-Serien für YouTube-Kanäle + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "yt-series". + */ +export interface YtSery { + id: number; + name: string; + /** + * URL-freundlicher Name (eindeutig pro Kanal) + */ + slug: string; + channel: number | YoutubeChannel; + description?: string | null; + /** + * Serien-Logo (transparent PNG empfohlen) + */ + logo?: (number | null) | Media; + /** + * Hauptbild für die Serie (16:9 empfohlen) + */ + coverImage?: (number | null) | Media; + /** + * Hex-Farbcode + */ + brandColor?: string | null; + /** + * Sekundäre Farbe + */ + accentColor?: string | null; + /** + * Die Playlist-ID aus YouTube (z.B. PLxxxxxx) + */ + youtubePlaylistId?: string | null; + /** + * Vollständiger Link zur YouTube-Playlist + */ + youtubePlaylistUrl?: string | null; + format?: ('short' | 'longform' | 'mixed') | null; + publishingFrequency?: string | null; + /** + * Inaktive Serien werden nicht angezeigt + */ + isActive?: boolean | null; + /** + * Niedrigere Zahlen werden zuerst angezeigt + */ + order?: number | null; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "yt-batches". @@ -6447,42 +6456,16 @@ export interface YoutubeContent { export interface YtBatch { id: number; name: string; - channel: number | YoutubeChannel; - productionPeriod: { - start: string; - end: string; - shootDays?: - | { - date: string; - location?: ('home' | 'office' | 'mobile' | 'external') | null; - duration?: ('2h' | '4h' | '8h') | null; - notes?: string | null; - id?: string | null; - }[] - | null; - }; - targets: { - shortsTarget: number; - longformsTarget: number; + channel?: (number | null) | YoutubeChannel; + status?: ('planning' | 'production' | 'published') | null; + productionPeriodStart?: string | null; + productionPeriodEnd?: string | null; + targets?: { + shortsTarget?: number | null; + longformsTarget?: number | null; totalTarget?: number | null; - /** - * Days between production and publish - */ bufferDays?: number | null; }; - /** - * Which series are produced in this batch - */ - seriesDistribution?: - | { - series: string; - shortsCount?: number | null; - longformsCount?: number | null; - priority?: ('high' | 'normal' | 'low') | null; - id?: string | null; - }[] - | null; - status: 'planning' | 'scripting' | 'production' | 'editing' | 'review' | 'ready' | 'published'; progress?: { shortsCompleted?: number | null; longformsCompleted?: number | null; @@ -6493,7 +6476,6 @@ export interface YtBatch { editor?: (number | null) | User; reviewer?: (number | null) | User; }; - notes?: string | null; updatedAt: string; createdAt: string; } @@ -6640,50 +6622,7 @@ export interface YtScriptTemplate { channel: number | YoutubeChannel; series: string; format: 'short' | 'longform'; - /** - * Usage notes for this template - */ description?: string | null; - templateSections?: - | { - sectionType: 'hook' | 'intro_ident' | 'context' | 'content_part' | 'summary' | 'cta' | 'outro' | 'disclaimer'; - duration?: string | null; - sectionTitle?: string | null; - spokenText: { - 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; - }; - bRollInstructions?: - | { - instruction: string; - timestamp?: string | null; - id?: string | null; - }[] - | null; - textOverlays?: - | { - text: string; - style?: ('standard' | 'highlight' | 'quote' | 'statistic' | 'list') | null; - id?: string | null; - }[] - | null; - visualNotes?: string | null; - id?: string | null; - blockName?: string | null; - blockType: 'script-section'; - }[] - | null; updatedAt: string; createdAt: string; } @@ -6722,6 +6661,325 @@ export interface YtChecklistTemplate { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "social-platforms". + */ +export interface SocialPlatform { + id: number; + name: string; + slug: string; + icon?: string | null; + color?: string | null; + isActive?: boolean | null; + apiStatus?: ('connected' | 'limited' | 'disconnected' | 'development') | null; + apiConfig?: { + apiType?: ('youtube_v3' | 'linkedin' | 'instagram_graph' | 'facebook_graph' | 'custom') | null; + baseUrl?: string | null; + authType?: ('oauth2' | 'api_key' | 'bearer') | null; + scopes?: + | { + scope?: string | null; + id?: string | null; + }[] + | null; + }; + interactionTypes?: + | { + type: string; + label: string; + icon?: string | null; + canReply?: boolean | null; + id?: string | null; + }[] + | null; + rateLimits?: { + requestsPerMinute?: number | null; + requestsPerDay?: number | null; + /** + * YouTube: 10.000/Tag + */ + quotaUnitsPerDay?: number | null; + }; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "social-accounts". + */ +export interface SocialAccount { + id: number; + platform: number | SocialPlatform; + /** + * Für Zuordnung zu Brand/Kanal + */ + linkedChannel?: (number | null) | YoutubeChannel; + displayName: string; + accountHandle?: string | null; + /** + * YouTube Channel ID, LinkedIn URN, etc. + */ + externalId?: string | null; + accountUrl?: string | null; + isActive?: boolean | null; + /** + * Sensible Daten – nur für Super-Admins sichtbar + */ + credentials?: { + /** + * OAuth Access Token + */ + accessToken?: string | null; + refreshToken?: string | null; + tokenExpiresAt?: string | null; + /** + * Für API-Key basierte Auth + */ + apiKey?: string | null; + }; + stats?: { + followers?: number | null; + totalPosts?: number | null; + lastSyncedAt?: string | null; + }; + syncSettings?: { + autoSyncEnabled?: boolean | null; + syncIntervalMinutes?: number | null; + syncComments?: boolean | null; + /** + * Nicht alle Plattformen unterstützen DM-API + */ + syncDMs?: boolean | null; + }; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "community-interactions". + */ +export interface CommunityInteraction { + id: number; + platform: number | SocialPlatform; + socialAccount: number | SocialAccount; + linkedContent?: (number | null) | YoutubeContent; + type: 'comment' | 'reply' | 'dm' | 'mention' | 'review' | 'question'; + /** + * YouTube Comment ID, etc. + */ + externalId: string; + parentInteraction?: (number | null) | CommunityInteraction; + author?: { + name?: string | null; + handle?: string | null; + profileUrl?: string | null; + avatarUrl?: string | null; + isVerified?: boolean | null; + isSubscriber?: boolean | null; + isMember?: boolean | null; + subscriberCount?: number | null; + }; + message: string; + /** + * Falls Plattform HTML liefert + */ + messageHtml?: string | null; + attachments?: + | { + type?: ('image' | 'video' | 'link' | 'sticker') | null; + url?: string | null; + id?: string | null; + }[] + | null; + publishedAt: string; + /** + * Automatisch via Claude API + */ + analysis?: { + sentiment?: ('positive' | 'neutral' | 'negative' | 'question' | 'gratitude' | 'frustration') | null; + sentimentScore?: number | null; + confidence?: number | null; + topics?: + | { + topic?: string | null; + id?: string | null; + }[] + | null; + language?: string | null; + suggestedTemplate?: (number | null) | CommunityTemplate; + suggestedReply?: string | null; + analyzedAt?: string | null; + }; + flags?: { + /** + * Erfordert ärztliche Review + */ + isMedicalQuestion?: boolean | null; + requiresEscalation?: boolean | null; + isSpam?: boolean | null; + /** + * >10k Follower + */ + isFromInfluencer?: boolean | null; + }; + status: 'new' | 'in_review' | 'waiting' | 'replied' | 'resolved' | 'archived' | 'spam'; + priority: 'urgent' | 'high' | 'normal' | 'low'; + assignedTo?: (number | null) | User; + responseDeadline?: string | null; + response?: { + text?: string | null; + usedTemplate?: (number | null) | CommunityTemplate; + sentAt?: string | null; + sentBy?: (number | null) | User; + externalReplyId?: string | null; + }; + /** + * Wird beim Sync aktualisiert + */ + engagement?: { + likes?: number | null; + replies?: number | null; + isHearted?: boolean | null; + isPinned?: boolean | null; + }; + /** + * Nur für Team sichtbar + */ + internalNotes?: string | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "community-templates". + */ +export interface CommunityTemplate { + id: number; + name: string; + category: + | 'thank_you' + | 'question_answer' + | 'redirect_hotline' + | 'medical_disclaimer' + | 'product_info' + | 'content_reference' + | 'follow_up' + | 'negative_feedback' + | 'spam_response' + | 'welcome'; + /** + * Leer = für alle Kanäle + */ + channel?: (number | null) | YoutubeChannel; + /** + * Leer = für alle Plattformen + */ + platforms?: (number | SocialPlatform)[] | null; + /** + * Variablen: {{author_name}}, {{video_title}}, {{channel_name}}, {{hotline_number}} + */ + template: string; + /** + * Dokumentation der Variablen in diesem Template + */ + variables?: + | { + variable: string; + description?: string | null; + defaultValue?: string | null; + id?: string | null; + }[] + | null; + /** + * Bei diesen Keywords wird das Template vorgeschlagen + */ + autoSuggestKeywords?: + | { + keyword: string; + id?: string | null; + }[] + | null; + /** + * Für medizinische Antworten + */ + requiresReview?: boolean | null; + isActive?: boolean | null; + usageCount?: number | null; + /** + * So sieht die Antwort mit ausgefüllten Variablen aus + */ + exampleOutput?: string | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "community-rules". + */ +export interface CommunityRule { + id: number; + name: string; + /** + * Niedrigere Zahl = höhere Priorität + */ + priority: number; + isActive?: boolean | null; + description?: string | null; + /** + * Leer = alle Kanäle + */ + channel?: (number | null) | YoutubeChannel; + /** + * Leer = alle Plattformen + */ + platforms?: (number | SocialPlatform)[] | null; + trigger: { + type: + | 'keyword' + | 'sentiment' + | 'question_detected' + | 'medical_detected' + | 'influencer' + | 'all_new' + | 'contains_link' + | 'contains_email'; + keywords?: + | { + keyword: string; + matchType?: ('contains' | 'exact' | 'regex') | null; + id?: string | null; + }[] + | null; + sentimentValues?: ('positive' | 'negative' | 'neutral' | 'question')[] | null; + influencerMinFollowers?: number | null; + }; + actions: { + action: + | 'set_priority' + | 'assign_to' + | 'set_flag' + | 'suggest_template' + | 'send_notification' + | 'flag_medical' + | 'escalate' + | 'mark_spam' + | 'set_deadline'; + /** + * Priority: urgent/high/normal/low, Deadline: Stunden + */ + value?: string | null; + targetUser?: (number | null) | User; + targetTemplate?: (number | null) | CommunityTemplate; + id?: string | null; + }[]; + stats?: { + timesTriggered?: number | null; + lastTriggeredAt?: string | null; + }; + updatedAt: string; + createdAt: string; +} /** * Cookie-Banner Konfiguration pro Tenant * @@ -7415,6 +7673,30 @@ export interface PayloadLockedDocument { relationTo: 'yt-checklist-templates'; value: number | YtChecklistTemplate; } | null) + | ({ + relationTo: 'yt-series'; + value: number | YtSery; + } | null) + | ({ + relationTo: 'social-platforms'; + value: number | SocialPlatform; + } | null) + | ({ + relationTo: 'social-accounts'; + value: number | SocialAccount; + } | null) + | ({ + relationTo: 'community-interactions'; + value: number | CommunityInteraction; + } | null) + | ({ + relationTo: 'community-templates'; + value: number | CommunityTemplate; + } | null) + | ({ + relationTo: 'community-rules'; + value: number | CommunityRule; + } | null) | ({ relationTo: 'cookie-configurations'; value: number | CookieConfiguration; @@ -7509,6 +7791,7 @@ export interface UsersSelect { isSuperAdmin?: T; youtubeRole?: T; youtubeChannels?: T; + communityRole?: T; tenants?: | T | { @@ -11106,16 +11389,6 @@ export interface YoutubeChannelsSelect { logo?: T; thumbnailTemplate?: T; }; - contentSeries?: - | T - | { - name?: T; - slug?: T; - description?: T; - color?: T; - isActive?: T; - id?: T; - }; publishingSchedule?: | T | { @@ -11143,7 +11416,7 @@ export interface YoutubeContentSelect { title?: T; slug?: T; channel?: T; - contentSeries?: T; + series?: T; format?: T; status?: T; priority?: T; @@ -11171,35 +11444,6 @@ export interface YoutubeContentSelect { productionDate?: T; targetDuration?: T; bRollNotes?: T; - script?: - | T - | { - 'script-section'?: - | T - | { - sectionType?: T; - duration?: T; - sectionTitle?: T; - spokenText?: T; - bRollInstructions?: - | T - | { - instruction?: T; - timestamp?: T; - id?: T; - }; - textOverlays?: - | T - | { - text?: T; - style?: T; - id?: T; - }; - visualNotes?: T; - id?: T; - blockName?: T; - }; - }; publishTime?: T; thumbnailText?: T; ctaType?: T; @@ -11368,21 +11612,9 @@ export interface YtNotificationsSelect { export interface YtBatchesSelect { name?: T; channel?: T; - productionPeriod?: - | T - | { - start?: T; - end?: T; - shootDays?: - | T - | { - date?: T; - location?: T; - duration?: T; - notes?: T; - id?: T; - }; - }; + status?: T; + productionPeriodStart?: T; + productionPeriodEnd?: T; targets?: | T | { @@ -11391,16 +11623,6 @@ export interface YtBatchesSelect { totalTarget?: T; bufferDays?: T; }; - seriesDistribution?: - | T - | { - series?: T; - shortsCount?: T; - longformsCount?: T; - priority?: T; - id?: T; - }; - status?: T; progress?: | T | { @@ -11415,7 +11637,6 @@ export interface YtBatchesSelect { editor?: T; reviewer?: T; }; - notes?: T; updatedAt?: T; createdAt?: T; } @@ -11482,35 +11703,6 @@ export interface YtScriptTemplatesSelect { series?: T; format?: T; description?: T; - templateSections?: - | T - | { - 'script-section'?: - | T - | { - sectionType?: T; - duration?: T; - sectionTitle?: T; - spokenText?: T; - bRollInstructions?: - | T - | { - instruction?: T; - timestamp?: T; - id?: T; - }; - textOverlays?: - | T - | { - text?: T; - style?: T; - id?: T; - }; - visualNotes?: T; - id?: T; - blockName?: T; - }; - }; updatedAt?: T; createdAt?: T; } @@ -11539,6 +11731,266 @@ export interface YtChecklistTemplatesSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "yt-series_select". + */ +export interface YtSeriesSelect { + name?: T; + slug?: T; + channel?: T; + description?: T; + logo?: T; + coverImage?: T; + brandColor?: T; + accentColor?: T; + youtubePlaylistId?: T; + youtubePlaylistUrl?: T; + format?: T; + publishingFrequency?: T; + isActive?: T; + order?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "social-platforms_select". + */ +export interface SocialPlatformsSelect { + name?: T; + slug?: T; + icon?: T; + color?: T; + isActive?: T; + apiStatus?: T; + apiConfig?: + | T + | { + apiType?: T; + baseUrl?: T; + authType?: T; + scopes?: + | T + | { + scope?: T; + id?: T; + }; + }; + interactionTypes?: + | T + | { + type?: T; + label?: T; + icon?: T; + canReply?: T; + id?: T; + }; + rateLimits?: + | T + | { + requestsPerMinute?: T; + requestsPerDay?: T; + quotaUnitsPerDay?: T; + }; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "social-accounts_select". + */ +export interface SocialAccountsSelect { + platform?: T; + linkedChannel?: T; + displayName?: T; + accountHandle?: T; + externalId?: T; + accountUrl?: T; + isActive?: T; + credentials?: + | T + | { + accessToken?: T; + refreshToken?: T; + tokenExpiresAt?: T; + apiKey?: T; + }; + stats?: + | T + | { + followers?: T; + totalPosts?: T; + lastSyncedAt?: T; + }; + syncSettings?: + | T + | { + autoSyncEnabled?: T; + syncIntervalMinutes?: T; + syncComments?: T; + syncDMs?: T; + }; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "community-interactions_select". + */ +export interface CommunityInteractionsSelect { + platform?: T; + socialAccount?: T; + linkedContent?: T; + type?: T; + externalId?: T; + parentInteraction?: T; + author?: + | T + | { + name?: T; + handle?: T; + profileUrl?: T; + avatarUrl?: T; + isVerified?: T; + isSubscriber?: T; + isMember?: T; + subscriberCount?: T; + }; + message?: T; + messageHtml?: T; + attachments?: + | T + | { + type?: T; + url?: T; + id?: T; + }; + publishedAt?: T; + analysis?: + | T + | { + sentiment?: T; + sentimentScore?: T; + confidence?: T; + topics?: + | T + | { + topic?: T; + id?: T; + }; + language?: T; + suggestedTemplate?: T; + suggestedReply?: T; + analyzedAt?: T; + }; + flags?: + | T + | { + isMedicalQuestion?: T; + requiresEscalation?: T; + isSpam?: T; + isFromInfluencer?: T; + }; + status?: T; + priority?: T; + assignedTo?: T; + responseDeadline?: T; + response?: + | T + | { + text?: T; + usedTemplate?: T; + sentAt?: T; + sentBy?: T; + externalReplyId?: T; + }; + engagement?: + | T + | { + likes?: T; + replies?: T; + isHearted?: T; + isPinned?: T; + }; + internalNotes?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "community-templates_select". + */ +export interface CommunityTemplatesSelect { + name?: T; + category?: T; + channel?: T; + platforms?: T; + template?: T; + variables?: + | T + | { + variable?: T; + description?: T; + defaultValue?: T; + id?: T; + }; + autoSuggestKeywords?: + | T + | { + keyword?: T; + id?: T; + }; + requiresReview?: T; + isActive?: T; + usageCount?: T; + exampleOutput?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "community-rules_select". + */ +export interface CommunityRulesSelect { + name?: T; + priority?: T; + isActive?: T; + description?: T; + channel?: T; + platforms?: T; + trigger?: + | T + | { + type?: T; + keywords?: + | T + | { + keyword?: T; + matchType?: T; + id?: T; + }; + sentimentValues?: T; + influencerMinFollowers?: T; + }; + actions?: + | T + | { + action?: T; + value?: T; + targetUser?: T; + targetTemplate?: T; + id?: T; + }; + stats?: + | T + | { + timesTriggered?: T; + lastTriggeredAt?: T; + }; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "cookie-configurations_select". diff --git a/src/payload.config.ts b/src/payload.config.ts index af5b3b5..05974ce 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -80,6 +80,13 @@ import { YtScriptTemplates } from './collections/YtScriptTemplates' import { YtChecklistTemplates } from './collections/YtChecklistTemplates' import { YtSeries } from './collections/YtSeries' +// Community Management Collections +import { SocialPlatforms } from './collections/SocialPlatforms' +import { SocialAccounts } from './collections/SocialAccounts' +import { CommunityInteractions } from './collections/CommunityInteractions' +import { CommunityTemplates } from './collections/CommunityTemplates' +import { CommunityRules } from './collections/CommunityRules' + // Debug: Minimal test collection - DISABLED (nur für Tests) // import { TestMinimal } from './collections/TestMinimal' @@ -230,6 +237,12 @@ export default buildConfig({ YtScriptTemplates, YtChecklistTemplates, YtSeries, + // Community Management + SocialPlatforms, + SocialAccounts, + CommunityInteractions, + CommunityTemplates, + CommunityRules, // Debug: Minimal test collection - DISABLED // TestMinimal, // Consent Management