feat(Community): add Community Management Phase 1

- Add 5 new collections: SocialPlatforms, SocialAccounts,
  CommunityInteractions, CommunityTemplates, CommunityRules
- Add communityRole field to Users collection
- Add YouTube API client for comment sync
- Add Claude AI service for sentiment analysis
- Add API endpoints: /api/community/sync-comments, /api/community/reply
- Add communityAccess.ts for role-based access control
- Add migrations for all new tables and community_role enum fix

Fix: Make audit hooks non-blocking to prevent user save timeout

Dependencies: @anthropic-ai/sdk, googleapis

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2026-01-14 16:13:03 +00:00
parent 3294fbb506
commit 358920f442
20 changed files with 4056 additions and 245 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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',
},
},
],
}

View file

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

View file

@ -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 } },
],
}
}

View file

@ -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<CommentAnalysis> {
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<string> {
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<Map<string, CommentAnalysis>> {
const results = new Map<string, CommentAnalysis>()
// 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 }

View file

@ -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<SyncResult> {
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 }

View file

@ -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<typeof google.auth.OAuth2>
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<CommentThread[]> {
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<string> {
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<void> {
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<void> {
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<void> {
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 }

View file

@ -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<void> {
// 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<void> {
// 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";
`)
}

View file

@ -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<void> {
// 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<void> {
// 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";
`)
}

View file

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

File diff suppressed because it is too large Load diff

View file

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