Rename plugins to automations and fix scheduler/payment bugs

Major changes:
- Rename "plugins" to "automations" throughout codebase
- Move automation system to dedicated app (scheduling/automations/)
- Add new automation marketplace, creation, and management pages

Bug fixes:
- Fix payment endpoint 500 errors (use has_feature() instead of attribute)
- Fix scheduler showing "0 AM" instead of "12 AM"
- Fix scheduler business hours double-inversion display issue
- Fix scheduler scroll-to-current-time when switching views
- Fix week view centering on wrong day (use Sunday-based indexing)
- Fix capacity widget overflow with many resources
- Fix Recharts minWidth/minHeight console warnings

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-16 11:56:01 -05:00
parent c333010620
commit cfb626b595
62 changed files with 4914 additions and 2413 deletions

View File

@@ -57,6 +57,7 @@
"jsdom": "^27.2.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.17",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.2.4",
"vitest": "^4.0.15"
@@ -4074,6 +4075,19 @@
"node": ">= 0.4"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -5970,6 +5984,16 @@
"node": ">=4"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/rollup": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
@@ -6285,6 +6309,525 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz",
"integrity": "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-arm": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.1.tgz",
"integrity": "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.1.tgz",
"integrity": "sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.1.tgz",
"integrity": "sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.1.tgz",
"integrity": "sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/darwin-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.1.tgz",
"integrity": "sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.1.tgz",
"integrity": "sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.1.tgz",
"integrity": "sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-arm": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.1.tgz",
"integrity": "sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.1.tgz",
"integrity": "sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-ia32": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.1.tgz",
"integrity": "sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-loong64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.1.tgz",
"integrity": "sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.1.tgz",
"integrity": "sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.1.tgz",
"integrity": "sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.1.tgz",
"integrity": "sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-s390x": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.1.tgz",
"integrity": "sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.1.tgz",
"integrity": "sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.1.tgz",
"integrity": "sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.1.tgz",
"integrity": "sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.1.tgz",
"integrity": "sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.1.tgz",
"integrity": "sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.1.tgz",
"integrity": "sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/sunos-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.1.tgz",
"integrity": "sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.1.tgz",
"integrity": "sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-ia32": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.1.tgz",
"integrity": "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.1.tgz",
"integrity": "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/esbuild": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz",
"integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.1",
"@esbuild/android-arm": "0.27.1",
"@esbuild/android-arm64": "0.27.1",
"@esbuild/android-x64": "0.27.1",
"@esbuild/darwin-arm64": "0.27.1",
"@esbuild/darwin-x64": "0.27.1",
"@esbuild/freebsd-arm64": "0.27.1",
"@esbuild/freebsd-x64": "0.27.1",
"@esbuild/linux-arm": "0.27.1",
"@esbuild/linux-arm64": "0.27.1",
"@esbuild/linux-ia32": "0.27.1",
"@esbuild/linux-loong64": "0.27.1",
"@esbuild/linux-mips64el": "0.27.1",
"@esbuild/linux-ppc64": "0.27.1",
"@esbuild/linux-riscv64": "0.27.1",
"@esbuild/linux-s390x": "0.27.1",
"@esbuild/linux-x64": "0.27.1",
"@esbuild/netbsd-arm64": "0.27.1",
"@esbuild/netbsd-x64": "0.27.1",
"@esbuild/openbsd-arm64": "0.27.1",
"@esbuild/openbsd-x64": "0.27.1",
"@esbuild/openharmony-arm64": "0.27.1",
"@esbuild/sunos-x64": "0.27.1",
"@esbuild/win32-arm64": "0.27.1",
"@esbuild/win32-ia32": "0.27.1",
"@esbuild/win32-x64": "0.27.1"
}
},
"node_modules/tsx/node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@@ -53,6 +53,7 @@
"jsdom": "^27.2.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.17",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.2.4",
"vitest": "^4.0.15"
@@ -68,6 +69,12 @@
"test:watch": "vitest --watch",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed"
"test:e2e:headed": "playwright test --headed",
"crawl": "tsx scripts/crawl-site.ts",
"crawl:public": "tsx scripts/crawl-site.ts --public",
"crawl:platform": "tsx scripts/crawl-site.ts --platform",
"crawl:all": "tsx scripts/crawl-site.ts --all",
"crawl:verbose": "tsx scripts/crawl-site.ts --all --verbose",
"test:crawl": "playwright test site-crawler.spec.ts"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,293 @@
#!/usr/bin/env tsx
/**
* Site Crawler Script
*
* Crawls the SmoothSchedule site looking for:
* - Broken links
* - Console errors
* - Network failures
* - Page errors
*
* Usage:
* npx ts-node scripts/crawl-site.ts [options]
*
* Options:
* --public Crawl public marketing site only
* --platform Crawl platform dashboard (requires login)
* --tenant Crawl tenant dashboard (requires login)
* --all Crawl all areas (default)
* --max-pages=N Maximum pages to crawl per area (default: 50)
* --verbose Show detailed logging
* --screenshots Save screenshots on errors
*
* Examples:
* npx ts-node scripts/crawl-site.ts --public
* npx ts-node scripts/crawl-site.ts --all --max-pages=100
* npx ts-node scripts/crawl-site.ts --platform --verbose
*/
import { chromium, Browser, BrowserContext, Page } from 'playwright';
import {
SiteCrawler,
CrawlerOptions,
CrawlReport,
formatReport,
loginAsUser,
TEST_USERS,
UserCredentials,
} from '../tests/e2e/utils/crawler';
import * as fs from 'fs';
import * as path from 'path';
interface CrawlArea {
name: string;
startUrl: string;
requiresAuth: boolean;
user?: UserCredentials;
}
const CRAWL_AREAS: Record<string, CrawlArea> = {
public: {
name: 'Public Site',
startUrl: 'http://lvh.me:5173',
requiresAuth: false,
},
platform: {
name: 'Platform Dashboard',
startUrl: 'http://platform.lvh.me:5173/platform/login',
requiresAuth: true,
user: TEST_USERS.platformSuperuser,
},
tenant: {
name: 'Tenant Dashboard (Owner)',
startUrl: 'http://demo.lvh.me:5173/login',
requiresAuth: true,
user: TEST_USERS.businessOwner,
},
tenantStaff: {
name: 'Tenant Dashboard (Staff)',
startUrl: 'http://demo.lvh.me:5173/login',
requiresAuth: true,
user: TEST_USERS.businessStaff,
},
customer: {
name: 'Customer Booking Portal (Public)',
startUrl: 'http://demo.lvh.me:5173/book',
requiresAuth: false, // Public booking page
},
customerPortal: {
name: 'Customer Portal (Logged In)',
startUrl: 'http://demo.lvh.me:5173/login',
requiresAuth: true,
user: TEST_USERS.customer,
},
};
function parseArgs(): {
areas: string[];
maxPages: number;
verbose: boolean;
screenshots: boolean;
} {
const args = process.argv.slice(2);
const result = {
areas: [] as string[],
maxPages: 0, // 0 = unlimited
verbose: false,
screenshots: false,
};
for (const arg of args) {
if (arg === '--public') result.areas.push('public');
else if (arg === '--platform') result.areas.push('platform');
else if (arg === '--tenant') result.areas.push('tenant');
else if (arg === '--tenant-staff') result.areas.push('tenantStaff');
else if (arg === '--customer') result.areas.push('customer');
else if (arg === '--customer-portal') result.areas.push('customerPortal');
else if (arg === '--all') result.areas = Object.keys(CRAWL_AREAS);
else if (arg.startsWith('--max-pages=')) result.maxPages = parseInt(arg.split('=')[1], 10);
else if (arg === '--verbose') result.verbose = true;
else if (arg === '--screenshots') result.screenshots = true;
else if (arg === '--help' || arg === '-h') {
console.log(`
Site Crawler - Find broken links and errors
Usage: npm run crawl -- [options]
Options:
--public Crawl public marketing site only
--platform Crawl platform dashboard (requires login)
--tenant Crawl tenant dashboard as Business Owner
--tenant-staff Crawl tenant dashboard as Staff Member
--customer Crawl customer booking portal (public)
--all Crawl all areas
--max-pages=N Maximum pages to crawl per area (default: 50)
--verbose Show detailed logging
--screenshots Save screenshots on errors
Examples:
npm run crawl -- --public
npm run crawl -- --tenant --max-pages=50
npm run crawl -- --all --verbose
`);
process.exit(0);
}
}
// Default to public only if no areas specified
if (result.areas.length === 0) {
result.areas = ['public'];
}
// Filter out areas that don't exist in CRAWL_AREAS
result.areas = result.areas.filter(area => CRAWL_AREAS[area]);
return result;
}
async function crawlArea(
browser: Browser,
area: CrawlArea,
options: CrawlerOptions
): Promise<CrawlReport> {
console.log(`\n${'═'.repeat(60)}`);
console.log(` Crawling: ${area.name}`);
console.log(` URL: ${area.startUrl}`);
console.log(`${'═'.repeat(60)}`);
const context = await browser.newContext({
viewport: { width: 1280, height: 720 },
ignoreHTTPSErrors: true,
});
const page = await context.newPage();
try {
// Login if required
let crawlStartUrl = area.startUrl;
if (area.requiresAuth && area.user) {
const loggedIn = await loginAsUser(page, area.user);
if (!loggedIn) {
console.error(`Failed to login for ${area.name}. Skipping.`);
return {
startTime: new Date(),
endTime: new Date(),
totalPages: 0,
totalErrors: 1,
results: [],
summary: {
consoleErrors: 0,
networkErrors: 0,
brokenLinks: 0,
pageErrors: 1,
},
};
}
// After login, start from the current URL (typically dashboard)
crawlStartUrl = page.url();
}
// Create crawler and run
const crawler = new SiteCrawler(page, context, options);
const report = await crawler.crawl(crawlStartUrl);
return report;
} finally {
await context.close();
}
}
function saveReport(reports: Map<string, CrawlReport>): string {
// Ensure output directory exists
const outputDir = path.join(process.cwd(), 'test-results');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = path.join(outputDir, `crawl-report-${timestamp}.json`);
const reportData = {
timestamp: new Date().toISOString(),
areas: Object.fromEntries(reports),
};
fs.writeFileSync(filename, JSON.stringify(reportData, null, 2));
return filename;
}
async function main() {
const { areas, maxPages, verbose, screenshots } = parseArgs();
console.log('\n🕷 SmoothSchedule Site Crawler');
console.log('─'.repeat(40));
console.log(`Areas to crawl: ${areas.join(', ')}`);
console.log(`Max pages per area: ${maxPages}`);
console.log(`Verbose: ${verbose}`);
console.log(`Screenshots: ${screenshots}`);
const browser = await chromium.launch({
headless: true,
});
const options: CrawlerOptions = {
maxPages,
verbose,
screenshotOnError: screenshots,
screenshotDir: 'test-results/crawler-screenshots',
timeout: 30000,
waitForNetworkIdle: true,
};
// Ensure screenshot directory exists if enabled
if (screenshots && !fs.existsSync(options.screenshotDir!)) {
fs.mkdirSync(options.screenshotDir!, { recursive: true });
}
const reports = new Map<string, CrawlReport>();
let totalErrors = 0;
try {
for (const areaKey of areas) {
const area = CRAWL_AREAS[areaKey];
if (!area) continue;
const report = await crawlArea(browser, area, options);
reports.set(areaKey, report);
totalErrors += report.totalErrors;
// Print report for this area
console.log(formatReport(report));
}
// Save combined report
const reportFile = saveReport(reports);
console.log(`\n📄 Full report saved to: ${reportFile}`);
// Final summary
console.log('\n' + '═'.repeat(60));
console.log(' FINAL SUMMARY');
console.log('═'.repeat(60));
let totalPages = 0;
for (const [areaKey, report] of reports) {
const area = CRAWL_AREAS[areaKey];
const icon = report.totalErrors === 0 ? '✅' : '❌';
console.log(`${icon} ${area.name}: ${report.totalPages} pages, ${report.totalErrors} errors`);
totalPages += report.totalPages;
}
console.log('─'.repeat(60));
console.log(` Total: ${totalPages} pages crawled, ${totalErrors} errors found`);
console.log('═'.repeat(60) + '\n');
// Exit with error code if errors found
process.exit(totalErrors > 0 ? 1 : 0);
} finally {
await browser.close();
}
}
main().catch(error => {
console.error('Crawler failed:', error);
process.exit(1);
});

View File

@@ -75,7 +75,7 @@ const Tickets = React.lazy(() => import('./pages/Tickets')); // Import Tickets p
const HelpGuide = React.lazy(() => import('./pages/HelpGuide')); // Import Platform Guide page
const HelpTicketing = React.lazy(() => import('./pages/HelpTicketing')); // Import Help page for ticketing
const HelpApiDocs = React.lazy(() => import('./pages/HelpApiDocs')); // Import API documentation page
const HelpPluginDocs = React.lazy(() => import('./pages/help/HelpPluginDocs')); // Import Plugin documentation page
const HelpAutomationDocs = React.lazy(() => import('./pages/help/HelpAutomationDocs')); // Import Automation documentation page
const HelpEmailSettings = React.lazy(() => import('./pages/HelpEmailSettings')); // Import Email settings help page
// Import new help pages
@@ -90,7 +90,7 @@ const HelpTimeBlocks = React.lazy(() => import('./pages/HelpTimeBlocks'));
const HelpMessages = React.lazy(() => import('./pages/help/HelpMessages'));
const HelpPayments = React.lazy(() => import('./pages/help/HelpPayments'));
const HelpContracts = React.lazy(() => import('./pages/help/HelpContracts'));
const HelpPlugins = React.lazy(() => import('./pages/help/HelpPlugins'));
const HelpAutomations = React.lazy(() => import('./pages/help/HelpAutomations'));
const HelpSettingsGeneral = React.lazy(() => import('./pages/help/HelpSettingsGeneral'));
const HelpSettingsResourceTypes = React.lazy(() => import('./pages/help/HelpSettingsResourceTypes'));
const HelpSettingsBooking = React.lazy(() => import('./pages/help/HelpSettingsBooking'));
@@ -104,10 +104,10 @@ const HelpSettingsQuota = React.lazy(() => import('./pages/help/HelpSettingsQuot
const HelpComprehensive = React.lazy(() => import('./pages/help/HelpComprehensive'));
const StaffHelp = React.lazy(() => import('./pages/help/StaffHelp'));
const PlatformSupport = React.lazy(() => import('./pages/PlatformSupport')); // Import Platform Support page (for businesses to contact SmoothSchedule)
const PluginMarketplace = React.lazy(() => import('./pages/PluginMarketplace')); // Import Plugin Marketplace page
const MyPlugins = React.lazy(() => import('./pages/MyPlugins')); // Import My Plugins page
const CreatePlugin = React.lazy(() => import('./pages/CreatePlugin')); // Import Create Plugin page
const Tasks = React.lazy(() => import('./pages/Tasks')); // Import Tasks page for scheduled plugin executions
const AutomationMarketplace = React.lazy(() => import('./pages/AutomationMarketplace')); // Import Automation Marketplace page
const MyAutomations = React.lazy(() => import('./pages/MyAutomations')); // Import My Automations page
const CreateAutomation = React.lazy(() => import('./pages/CreateAutomation')); // Import Create Automation page
const Tasks = React.lazy(() => import('./pages/Tasks')); // Import Tasks page for scheduled automation executions
const SystemEmailTemplates = React.lazy(() => import('./pages/settings/SystemEmailTemplates')); // System email templates (Puck-based)
const Contracts = React.lazy(() => import('./pages/Contracts')); // Import Contracts page
const ContractTemplates = React.lazy(() => import('./pages/ContractTemplates')); // Import Contract Templates page
@@ -518,7 +518,7 @@ const AppContent: React.FC = () => {
<Route path="/help/guide" element={<HelpGuide />} />
<Route path="/help/ticketing" element={<HelpTicketing />} />
<Route path="/help/api" element={<HelpApiDocs />} />
<Route path="/help/plugins" element={<HelpPluginDocs />} />
<Route path="/help/automations" element={<HelpAutomationDocs />} />
<Route path="/help/email" element={<HelpEmailSettings />} />
{user.role === 'superuser' && (
<>
@@ -738,7 +738,7 @@ const AppContent: React.FC = () => {
<Route path="/dashboard/help/guide" element={<HelpGuide />} />
<Route path="/dashboard/help/ticketing" element={<HelpTicketing />} />
<Route path="/dashboard/help/api" element={<HelpApiDocs />} />
<Route path="/dashboard/help/plugins/docs" element={<HelpPluginDocs />} />
<Route path="/dashboard/help/automations/docs" element={<HelpAutomationDocs />} />
<Route path="/dashboard/help/email" element={<HelpEmailSettings />} />
{/* New help pages */}
<Route path="/dashboard/help/dashboard" element={<HelpDashboard />} />
@@ -752,7 +752,7 @@ const AppContent: React.FC = () => {
<Route path="/dashboard/help/messages" element={<HelpMessages />} />
<Route path="/dashboard/help/payments" element={<HelpPayments />} />
<Route path="/dashboard/help/contracts" element={<HelpContracts />} />
<Route path="/dashboard/help/plugins" element={<HelpPlugins />} />
<Route path="/dashboard/help/automations" element={<HelpAutomations />} />
<Route path="/dashboard/help/settings/general" element={<HelpSettingsGeneral />} />
<Route path="/dashboard/help/settings/resource-types" element={<HelpSettingsResourceTypes />} />
<Route path="/dashboard/help/settings/booking" element={<HelpSettingsBooking />} />
@@ -764,30 +764,30 @@ const AppContent: React.FC = () => {
<Route path="/dashboard/help/settings/billing" element={<HelpSettingsBilling />} />
<Route path="/dashboard/help/settings/quota" element={<HelpSettingsQuota />} />
<Route
path="/dashboard/plugins/marketplace"
path="/dashboard/automations/marketplace"
element={
hasAccess(['owner', 'manager']) ? (
<PluginMarketplace />
<AutomationMarketplace />
) : (
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/dashboard/plugins/my-plugins"
path="/dashboard/automations/my-automations"
element={
hasAccess(['owner', 'manager']) ? (
<MyPlugins />
<MyAutomations />
) : (
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/dashboard/plugins/create"
path="/dashboard/automations/create"
element={
hasAccess(['owner', 'manager']) ? (
<CreatePlugin />
<CreateAutomation />
) : (
<Navigate to="/dashboard" />
)

View File

@@ -12,7 +12,7 @@ import {
} from '../constants/schedulePresets';
import { ErrorMessage } from './ui';
interface PluginInstallation {
interface AutomationInstallation {
id: string;
template: number;
template_name: string;
@@ -42,7 +42,7 @@ type TaskType = 'scheduled' | 'event';
const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSuccess }) => {
const queryClient = useQueryClient();
const [step, setStep] = useState(1);
const [selectedPlugin, setSelectedPlugin] = useState<PluginInstallation | null>(null);
const [selectedAutomation, setSelectedAutomation] = useState<AutomationInstallation | null>(null);
const [taskName, setTaskName] = useState('');
const [description, setDescription] = useState('');
@@ -68,20 +68,20 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
// Fetch available plugins
const { data: plugins = [], isLoading: pluginsLoading } = useQuery<PluginInstallation[]>({
queryKey: ['plugin-installations'],
// Fetch available automations
const { data: automations = [], isLoading: automationsLoading } = useQuery<AutomationInstallation[]>({
queryKey: ['automation-installations'],
queryFn: async () => {
const { data } = await axios.get('/plugin-installations/');
// Filter out plugins that already have scheduled tasks
return data.filter((p: PluginInstallation) => !p.scheduled_task);
const { data } = await axios.get('/automation-installations/');
// Filter out automations that already have scheduled tasks
return data.filter((p: AutomationInstallation) => !p.scheduled_task);
},
enabled: isOpen,
});
const handleClose = () => {
setStep(1);
setSelectedPlugin(null);
setSelectedAutomation(null);
setTaskName('');
setDescription('');
setError('');
@@ -97,9 +97,9 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
onClose();
};
const handlePluginSelect = (plugin: PluginInstallation) => {
setSelectedPlugin(plugin);
setTaskName(`${plugin.template_name} - Scheduled Task`);
const handleAutomationSelect = (automation: AutomationInstallation) => {
setSelectedAutomation(automation);
setTaskName(`${automation.template_name} - Scheduled Task`);
setStep(2);
};
@@ -117,33 +117,33 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
const showOffset = !['on_complete', 'on_cancel'].includes(selectedTrigger);
const handleSubmit = async () => {
if (!selectedPlugin) return;
if (!selectedAutomation) return;
setIsSubmitting(true);
setError('');
try {
if (taskType === 'event') {
// Create global event plugin (applies to all events)
// Create global event automation (applies to all events)
const payload = {
plugin_installation: selectedPlugin.id,
automation_installation: selectedAutomation.id,
trigger: selectedTrigger,
offset_minutes: selectedOffset,
is_active: true,
apply_to_existing: applyToExisting,
};
await axios.post('/global-event-plugins/', payload);
queryClient.invalidateQueries({ queryKey: ['global-event-plugins'] });
toast.success(applyToExisting ? 'Plugin attached to all events' : 'Plugin will apply to future events');
await axios.post('/global-event-automations/', payload);
queryClient.invalidateQueries({ queryKey: ['global-event-automations'] });
toast.success(applyToExisting ? 'Automation attached to all events' : 'Automation will apply to future events');
} else {
// Create scheduled task
let payload: any = {
name: taskName,
description,
plugin_name: selectedPlugin.template_slug,
automation_name: selectedAutomation.template_slug,
status: 'ACTIVE',
plugin_config: selectedPlugin.config_values || {},
automation_config: selectedAutomation.config_values || {},
};
if (scheduleMode === 'onetime') {
@@ -202,7 +202,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${step >= 1 ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-500'}`}>
1
</div>
<span className="font-medium">Select Plugin</span>
<span className="font-medium">Select Automation</span>
</div>
<div className="w-16 h-0.5 bg-gray-200 dark:bg-gray-700" />
<div className={`flex items-center gap-2 ${step >= 2 ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400'}`}>
@@ -216,36 +216,36 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
{/* Content */}
<div className="p-6">
{/* Step 1: Select Plugin */}
{/* Step 1: Select Automation */}
{step === 1 && (
<div>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Select a plugin to schedule for automatic execution.
Select an automation to schedule for automatic execution.
</p>
{pluginsLoading ? (
{automationsLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : plugins.length === 0 ? (
) : automations.length === 0 ? (
<div className="text-center py-12 bg-gray-50 dark:bg-gray-900 rounded-lg">
<Zap className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-600 dark:text-gray-400 mb-2">No available plugins</p>
<p className="text-gray-600 dark:text-gray-400 mb-2">No available automations</p>
<p className="text-sm text-gray-500 dark:text-gray-500">
Install plugins from the Marketplace first, or all your plugins are already scheduled.
Install automations from the Marketplace first, or all your automations are already scheduled.
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-3 max-h-96 overflow-y-auto">
{plugins.map((plugin) => (
{automations.map((automation) => (
<button
key={plugin.id}
onClick={() => handlePluginSelect(plugin)}
key={automation.id}
onClick={() => handleAutomationSelect(automation)}
className="text-left p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-blue-500 dark:hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
>
<div className="flex items-start gap-3">
{plugin.logo_url ? (
<img src={plugin.logo_url} alt="" className="w-10 h-10 rounded" />
{automation.logo_url ? (
<img src={automation.logo_url} alt="" className="w-10 h-10 rounded" />
) : (
<div className="w-10 h-10 rounded bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<Zap className="w-5 h-5 text-blue-600 dark:text-blue-400" />
@@ -253,10 +253,10 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
)}
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white mb-1">
{plugin.template_name}
{automation.template_name}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{plugin.template_description}
{automation.template_description}
</p>
</div>
</div>
@@ -268,18 +268,18 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
)}
{/* Step 2: Configure Schedule */}
{step === 2 && selectedPlugin && (
{step === 2 && selectedAutomation && (
<div className="space-y-6">
{/* Selected Plugin */}
{/* Selected Automation */}
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="flex items-center gap-3">
<Zap className="w-5 h-5 text-blue-600 dark:text-blue-400" />
<div>
<p className="text-sm text-blue-800 dark:text-blue-200 font-medium">
Selected Plugin
Selected Automation
</p>
<p className="text-blue-900 dark:text-blue-100">
{selectedPlugin.template_name}
{selectedAutomation.template_name}
</p>
</div>
</div>
@@ -288,7 +288,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
{/* Task Type Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
How should this plugin run?
How should this automation run?
</label>
<div className="grid grid-cols-2 gap-3">
<button
@@ -519,7 +519,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
{/* Info about timing updates */}
<div className="p-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
<p className="text-xs text-gray-600 dark:text-gray-400">
If an event is rescheduled, the plugin timing will automatically update.
If an event is rescheduled, the automation timing will automatically update.
</p>
</div>

View File

@@ -7,8 +7,8 @@ interface ScheduledTask {
id: string;
name: string;
description: string;
plugin_name: string;
plugin_display_name: string;
automation_name: string;
automation_display_name: string;
schedule_type: 'ONE_TIME' | 'INTERVAL' | 'CRON';
cron_expression?: string;
interval_minutes?: number;
@@ -17,7 +17,7 @@ interface ScheduledTask {
last_run_at?: string;
status: 'ACTIVE' | 'PAUSED' | 'DISABLED';
last_run_status?: string;
plugin_config: Record<string, any>;
automation_config: Record<string, any>;
created_at: string;
updated_at: string;
}
@@ -198,16 +198,16 @@ const EditTaskModal: React.FC<EditTaskModalProps> = ({ task, isOpen, onClose, on
{/* Content */}
<div className="p-6 space-y-6">
{/* Plugin Info (read-only) */}
{/* Automation Info (read-only) */}
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="flex items-center gap-3">
<Zap className="w-5 h-5 text-blue-600 dark:text-blue-400" />
<div>
<p className="text-sm text-blue-800 dark:text-blue-200 font-medium">
Plugin
Automation
</p>
<p className="text-blue-900 dark:text-blue-100">
{task.plugin_display_name}
{task.automation_display_name}
</p>
</div>
</div>

View File

@@ -4,7 +4,7 @@ import axios from '../api/client';
import { Zap, Plus, Trash2, Clock, CheckCircle2, XCircle, ChevronDown, Power } from 'lucide-react';
import toast from 'react-hot-toast';
interface PluginInstallation {
interface AutomationInstallation {
id: string;
template_name: string;
template_description: string;
@@ -12,14 +12,14 @@ interface PluginInstallation {
logo_url?: string;
}
interface EventPlugin {
interface EventAutomation {
id: string;
event: number;
plugin_installation: number;
plugin_name: string;
plugin_description: string;
plugin_category: string;
plugin_logo_url?: string;
automation_installation: number;
automation_name: string;
automation_description: string;
automation_category: string;
automation_logo_url?: string;
trigger: string;
trigger_display: string;
offset_minutes: number;
@@ -64,41 +64,41 @@ const OFFSET_PRESETS: OffsetPreset[] = [
const EventAutomations: React.FC<EventAutomationsProps> = ({ eventId, compact = false }) => {
const queryClient = useQueryClient();
const [showAddForm, setShowAddForm] = useState(false);
const [selectedPlugin, setSelectedPlugin] = useState<string>('');
const [selectedAutomation, setSelectedAutomation] = useState<string>('');
const [selectedTrigger, setSelectedTrigger] = useState<string>('at_start');
const [selectedOffset, setSelectedOffset] = useState<number>(0);
// Fetch installed plugins
const { data: plugins = [] } = useQuery<PluginInstallation[]>({
queryKey: ['plugin-installations'],
// Fetch installed automations
const { data: automations = [] } = useQuery<AutomationInstallation[]>({
queryKey: ['automation-installations'],
queryFn: async () => {
const { data } = await axios.get('/plugin-installations/');
const { data } = await axios.get('/automation-installations/');
return data;
},
});
// Fetch event plugins
const { data: eventPlugins = [], isLoading } = useQuery<EventPlugin[]>({
queryKey: ['event-plugins', eventId],
// Fetch event automations
const { data: eventAutomations = [], isLoading } = useQuery<EventAutomation[]>({
queryKey: ['event-automations', eventId],
queryFn: async () => {
const { data } = await axios.get(`/event-plugins/?event_id=${eventId}`);
const { data } = await axios.get(`/event-automations/?event_id=${eventId}`);
return data;
},
enabled: !!eventId,
});
// Add plugin mutation
// Add automation mutation
const addMutation = useMutation({
mutationFn: async (data: { plugin_installation: string; trigger: string; offset_minutes: number }) => {
return axios.post('/event-plugins/', {
mutationFn: async (data: { automation_installation: string; trigger: string; offset_minutes: number }) => {
return axios.post('/event-automations/', {
event: eventId,
...data,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-plugins', eventId] });
queryClient.invalidateQueries({ queryKey: ['event-automations', eventId] });
setShowAddForm(false);
setSelectedPlugin('');
setSelectedAutomation('');
setSelectedTrigger('at_start');
setSelectedOffset(0);
toast.success('Automation added');
@@ -110,29 +110,29 @@ const EventAutomations: React.FC<EventAutomationsProps> = ({ eventId, compact =
// Toggle mutation
const toggleMutation = useMutation({
mutationFn: async (pluginId: string) => {
return axios.post(`/event-plugins/${pluginId}/toggle/`);
mutationFn: async (automationId: string) => {
return axios.post(`/event-automations/${automationId}/toggle/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-plugins', eventId] });
queryClient.invalidateQueries({ queryKey: ['event-automations', eventId] });
},
});
// Delete mutation
const deleteMutation = useMutation({
mutationFn: async (pluginId: string) => {
return axios.delete(`/event-plugins/${pluginId}/`);
mutationFn: async (automationId: string) => {
return axios.delete(`/event-automations/${automationId}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-plugins', eventId] });
queryClient.invalidateQueries({ queryKey: ['event-automations', eventId] });
toast.success('Automation removed');
},
});
const handleAdd = () => {
if (!selectedPlugin) return;
if (!selectedAutomation) return;
addMutation.mutate({
plugin_installation: selectedPlugin,
automation_installation: selectedAutomation,
trigger: selectedTrigger,
offset_minutes: selectedOffset,
});
@@ -157,13 +157,13 @@ const EventAutomations: React.FC<EventAutomationsProps> = ({ eventId, compact =
<span className="text-sm font-medium text-gray-900 dark:text-white">
Automations
</span>
{eventPlugins.length > 0 && (
{eventAutomations.length > 0 && (
<span className="px-1.5 py-0.5 text-xs bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded">
{eventPlugins.length}
{eventAutomations.length}
</span>
)}
</div>
{!showAddForm && plugins.length > 0 && (
{!showAddForm && automations.length > 0 && (
<button
onClick={() => setShowAddForm(true)}
className="flex items-center gap-1 px-2 py-1 text-xs text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded transition-colors"
@@ -175,9 +175,9 @@ const EventAutomations: React.FC<EventAutomationsProps> = ({ eventId, compact =
</div>
{/* Existing automations */}
{eventPlugins.length > 0 && (
{eventAutomations.length > 0 && (
<div className="space-y-2">
{eventPlugins.map((ep) => (
{eventAutomations.map((ep) => (
<div
key={ep.id}
className={`flex items-center justify-between p-2 rounded-lg border ${
@@ -192,7 +192,7 @@ const EventAutomations: React.FC<EventAutomationsProps> = ({ eventId, compact =
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{ep.plugin_name}
{ep.automation_name}
</p>
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
<Clock className="w-3 h-3" />
@@ -229,20 +229,20 @@ const EventAutomations: React.FC<EventAutomationsProps> = ({ eventId, compact =
{/* Add automation form */}
{showAddForm && (
<div className="p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800 space-y-3">
{/* Plugin selector */}
{/* Automation selector */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Plugin
Automation
</label>
<select
value={selectedPlugin}
onChange={(e) => setSelectedPlugin(e.target.value)}
value={selectedAutomation}
onChange={(e) => setSelectedAutomation(e.target.value)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
>
<option value="">Select a plugin...</option>
{plugins.map((p) => (
<option key={p.id} value={p.id}>
{p.template_name}
<option value="">Select an automation...</option>
{automations.map((a) => (
<option key={a.id} value={a.id}>
{a.template_name}
</option>
))}
</select>
@@ -302,7 +302,7 @@ const EventAutomations: React.FC<EventAutomationsProps> = ({ eventId, compact =
)}
{/* Preview */}
{selectedPlugin && (
{selectedAutomation && (
<div className="text-xs text-purple-800 dark:text-purple-200 bg-purple-100 dark:bg-purple-900/40 px-2 py-1.5 rounded">
Will run: <strong>
{selectedTrigger === 'on_complete' ? 'When completed' :
@@ -328,7 +328,7 @@ const EventAutomations: React.FC<EventAutomationsProps> = ({ eventId, compact =
</button>
<button
onClick={handleAdd}
disabled={!selectedPlugin || addMutation.isPending}
disabled={!selectedAutomation || addMutation.isPending}
className="px-3 py-1.5 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{addMutation.isPending ? 'Adding...' : 'Add Automation'}
@@ -338,9 +338,9 @@ const EventAutomations: React.FC<EventAutomationsProps> = ({ eventId, compact =
)}
{/* Empty state */}
{eventPlugins.length === 0 && !showAddForm && (
{eventAutomations.length === 0 && !showAddForm && (
<div className="text-center py-3 text-gray-500 dark:text-gray-400">
{plugins.length > 0 ? (
{automations.length > 0 ? (
<button
onClick={() => setShowAddForm(true)}
className="text-xs text-purple-600 dark:text-purple-400 hover:underline"
@@ -349,7 +349,7 @@ const EventAutomations: React.FC<EventAutomationsProps> = ({ eventId, compact =
</button>
) : (
<p className="text-xs">
Install plugins from the Marketplace to use automations
Install automations from the Marketplace to use them
</p>
)}
</div>

View File

@@ -27,10 +27,10 @@ const routeToHelpPath: Record<string, string> = {
'/payments': '/help/payments',
'/contracts': '/help/contracts',
'/contracts/templates': '/help/contracts',
'/plugins': '/help/plugins',
'/plugins/marketplace': '/help/plugins',
'/plugins/my-plugins': '/help/plugins',
'/plugins/create': '/help/plugins/docs',
'/automations': '/help/automations',
'/automations/marketplace': '/help/automations',
'/automations/my-automations': '/help/automations',
'/automations/create': '/help/automations/docs',
'/settings': '/help/settings/general',
'/settings/general': '/help/settings/general',
'/settings/resource-types': '/help/settings/resource-types',

View File

@@ -130,7 +130,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
icon={Clock}
label={t('nav.tasks', 'Tasks')}
isCollapsed={isCollapsed}
locked={!canUse('plugins') || !canUse('tasks')}
locked={!canUse('automations') || !canUse('tasks')}
badgeElement={<UnfinishedBadge />}
/>
)}
@@ -257,15 +257,15 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
</SidebarSection>
)}
{/* Extend Section - Plugins */}
{/* Extend Section - Automations */}
{canViewAdminPages && (
<SidebarSection title={t('nav.sections.extend', 'Extend')} isCollapsed={isCollapsed}>
<SidebarItem
to="/dashboard/plugins/my-plugins"
to="/dashboard/automations/my-automations"
icon={Plug}
label={t('nav.plugins', 'Plugins')}
label={t('nav.automations', 'Automations')}
isCollapsed={isCollapsed}
locked={!canUse('plugins')}
locked={!canUse('automations')}
badgeElement={<UnfinishedBadge />}
/>
</SidebarSection>

View File

@@ -108,7 +108,7 @@ const CapacityWidget: React.FC<CapacityWidgetProps> = ({
<p className="text-sm">{t('dashboard.noResourcesConfigured')}</p>
</div>
) : (
<div className="flex-1 grid grid-cols-2 gap-2 auto-rows-min">
<div className="flex-1 grid grid-cols-2 gap-2 auto-rows-min overflow-y-auto">
{capacityData.resources.map((resource) => (
<div
key={resource.id}

View File

@@ -59,7 +59,7 @@ const ChartWidget: React.FC<ChartWidgetProps> = ({
</h3>
<div className="flex-1 min-h-0">
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer width="100%" height="100%" minWidth={100} minHeight={100}>
{type === 'bar' ? (
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#374151" strokeOpacity={0.2} />

View File

@@ -58,7 +58,7 @@ const CustomerBreakdownWidget: React.FC<CustomerBreakdownWidgetProps> = ({
<div className="flex-1 flex items-center gap-3 min-h-0">
{/* Pie Chart */}
<div className="w-20 h-20 flex-shrink-0">
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer width="100%" height="100%" minWidth={60} minHeight={60}>
<PieChart>
<Pie
data={breakdownData.chartData}

View File

@@ -4,7 +4,7 @@ import { Mail, Calendar, Bell, ArrowRight, Zap, CheckCircle2, Code, LayoutGrid }
import { useTranslation } from 'react-i18next';
import CodeBlock from './CodeBlock';
const PluginShowcase: React.FC = () => {
const AutomationShowcase: React.FC = () => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState(0);
const [viewMode, setViewMode] = useState<'marketplace' | 'code'>('marketplace');
@@ -174,7 +174,7 @@ const PluginShowcase: React.FC = () => {
// Code View
<CodeBlock
code={examples[activeTab].code}
filename={`${examples[activeTab].id}_plugin.py`}
filename={`${examples[activeTab].id}_automation.py`}
/>
)}
@@ -194,4 +194,4 @@ const PluginShowcase: React.FC = () => {
);
};
export default PluginShowcase;
export default AutomationShowcase;

View File

@@ -122,11 +122,11 @@
"platformGuide": "Platform Guide",
"ticketingHelp": "Ticketing System",
"apiDocs": "API Docs",
"pluginDocs": "Plugin Docs",
"automationDocs": "Automation Docs",
"contactSupport": "Contact Support",
"plugins": "Plugins",
"pluginMarketplace": "Marketplace",
"myPlugins": "My Plugins",
"automations": "Automations",
"automationMarketplace": "Marketplace",
"myAutomations": "My Automations",
"expandSidebar": "Expand sidebar",
"collapseSidebar": "Collapse sidebar",
"smoothSchedule": "Smooth Schedule",
@@ -1481,10 +1481,10 @@
"customization": "Customization",
"customDomains": "Custom Domains",
"whiteLabelling": "White Labelling",
"pluginsAutomation": "Plugins & Automation",
"usePlugins": "Use Plugins",
"automationsSection": "Automations",
"useAutomations": "Use Automations",
"scheduledTasks": "Scheduled Tasks",
"createPlugins": "Create Plugins",
"createAutomations": "Create Automations",
"advancedFeatures": "Advanced Features",
"apiAccess": "API Access",
"webhooks": "Webhooks",
@@ -2178,14 +2178,14 @@
"plugins": {
"badge": "Limitless Automation",
"headline": "Choose from our Marketplace, or build your own.",
"subheadline": "Browse hundreds of pre-built plugins to automate your workflows instantly. Need something custom? Developers can write Python scripts to extend the platform endlessly.",
"subheadline": "Browse hundreds of pre-built automations to streamline your workflows instantly. Need something custom? Developers can write Python scripts to extend the platform endlessly.",
"viewToggle": {
"marketplace": "Marketplace",
"developer": "Developer"
},
"marketplaceCard": {
"author": "by SmoothSchedule Team",
"installButton": "Install Plugin",
"installButton": "Install Automation",
"usedBy": "Used by 1,200+ businesses"
},
"cta": "Explore the Marketplace",
@@ -2231,7 +2231,7 @@
},
"automationEngine": {
"title": "Automation Engine",
"description": "Install plugins from our marketplace or build your own to automate tasks."
"description": "Install automations from our marketplace or build your own to automate tasks."
},
"multiTenant": {
"title": "Enterprise Security",
@@ -2278,6 +2278,241 @@
"company": "FitNation"
}
}
},
"privacyPolicy": {
"title": "Privacy Policy",
"lastUpdated": "Last updated: December 1, 2025",
"section1": {
"title": "1. Introduction",
"content": "Welcome to SmoothSchedule. We respect your privacy and are committed to protecting your personal data. This Privacy Policy explains how we collect, use, disclose, and safeguard your information when you use our scheduling platform and services."
},
"section2": {
"title": "2. Information We Collect",
"subsection1": {
"title": "2.1 Information You Provide",
"intro": "We collect information you directly provide to us, including:",
"items": [
"Account information (name, email, password, phone number)",
"Business information (business name, subdomain, industry)",
"Payment information (processed securely through third-party payment processors)",
"Customer data you input into the platform (appointments, resources, services)",
"Communications with our support team"
]
},
"subsection2": {
"title": "2.2 Automatically Collected Information",
"intro": "When you use our Service, we automatically collect:",
"items": [
"Log data (IP address, browser type, device information, operating system)",
"Usage data (pages visited, features used, time spent on platform)",
"Cookie data (session cookies, preference cookies)",
"Performance and error data for service improvement"
]
}
},
"section3": {
"title": "3. How We Use Your Information",
"intro": "We use the collected information for:",
"items": [
"Providing and maintaining the Service",
"Processing your transactions and managing subscriptions",
"Sending you service updates, security alerts, and administrative messages",
"Responding to your inquiries and providing customer support",
"Improving and optimizing our Service",
"Detecting and preventing fraud and security issues",
"Complying with legal obligations",
"Sending marketing communications (with your consent)"
]
},
"section4": {
"title": "4. Data Sharing and Disclosure",
"subsection1": {
"title": "4.1 We Share Data With:",
"items": [
"<strong>Service Providers:</strong> Third-party vendors who help us provide the Service (hosting, payment processing, analytics)",
"<strong>Business Transfers:</strong> In connection with any merger, sale, or acquisition of all or part of our company",
"<strong>Legal Requirements:</strong> When required by law, court order, or legal process",
"<strong>Protection of Rights:</strong> To protect our rights, property, or safety, or that of our users"
]
},
"subsection2": {
"title": "4.2 We Do NOT:",
"items": [
"Sell your personal data to third parties",
"Share your data for third-party marketing without consent",
"Access your customer data except for support or technical purposes"
]
}
},
"section5": {
"title": "5. Data Security",
"intro": "We implement industry-standard security measures to protect your data:",
"items": [
"Encryption of data in transit (TLS/SSL)",
"Encryption of sensitive data at rest",
"Regular security audits and vulnerability assessments",
"Access controls and authentication mechanisms",
"Regular backups and disaster recovery procedures"
],
"disclaimer": "However, no method of transmission over the Internet is 100% secure. While we strive to protect your data, we cannot guarantee absolute security."
},
"section6": {
"title": "6. Data Retention",
"content": "We retain your personal data for as long as necessary to provide the Service and fulfill the purposes described in this policy. When you cancel your account, we retain your data for 30 days to allow for account reactivation. After this period, your personal data may be anonymized and aggregated for internal analytics and service improvement purposes. Anonymized data cannot be used to identify you personally and cannot be retrieved or attributed to any person or account. We may also retain certain data if required for legal or legitimate business purposes."
},
"section7": {
"title": "7. Your Rights and Choices",
"intro": "Depending on your location, you may have the following rights:",
"items": [
"<strong>Access:</strong> Request a copy of your personal data",
"<strong>Correction:</strong> Update or correct inaccurate data",
"<strong>Deletion:</strong> Request deletion of your personal data",
"<strong>Portability:</strong> Receive your data in a portable format",
"<strong>Objection:</strong> Object to certain data processing activities",
"<strong>Restriction:</strong> Request restriction of data processing",
"<strong>Withdraw Consent:</strong> Withdraw previously given consent"
],
"contact": "To exercise these rights, please contact us at privacy@smoothschedule.com."
},
"section8": {
"title": "8. Cookies and Tracking",
"intro": "We use cookies and similar tracking technologies to:",
"items": [
"Maintain your session and keep you logged in",
"Remember your preferences and settings",
"Analyze usage patterns and improve our Service",
"Provide personalized content and features"
],
"disclaimer": "You can control cookies through your browser settings, but disabling cookies may affect your ability to use certain features of the Service."
},
"section9": {
"title": "9. Third-Party Services",
"content": "Our Service may contain links to third-party websites or integrate with third-party services (OAuth providers, payment processors). We are not responsible for the privacy practices of these third parties. We encourage you to review their privacy policies before providing any personal information."
},
"section10": {
"title": "10. Children's Privacy",
"content": "Our Service is not intended for children under 13 years of age. We do not knowingly collect personal information from children under 13. If you believe we have collected data from a child under 13, please contact us immediately so we can delete it."
},
"section11": {
"title": "11. International Data Transfers",
"content": "Your information may be transferred to and processed in countries other than your country of residence. These countries may have different data protection laws. We ensure appropriate safeguards are in place to protect your data in accordance with this Privacy Policy."
},
"section12": {
"title": "12. California Privacy Rights",
"content": "If you are a California resident, you have additional rights under the California Consumer Privacy Act (CCPA), including the right to know what personal information we collect, the right to delete your information, and the right to opt-out of the sale of your information (which we do not do)."
},
"section13": {
"title": "13. GDPR Compliance",
"content": "If you are in the European Economic Area (EEA), we process your personal data based on legal grounds such as consent, contract performance, legal obligations, or legitimate interests. You have rights under the General Data Protection Regulation (GDPR) including the right to lodge a complaint with a supervisory authority."
},
"section14": {
"title": "14. Changes to This Privacy Policy",
"content": "We may update this Privacy Policy from time to time. We will notify you of material changes by posting the new policy on this page and updating the \"Last updated\" date. We encourage you to review this Privacy Policy periodically."
},
"section15": {
"title": "15. Contact Us",
"intro": "If you have any questions about this Privacy Policy or our data practices, please contact us:",
"emailLabel": "Email:",
"email": "privacy@smoothschedule.com",
"dpoLabel": "Data Protection Officer:",
"dpo": "dpo@smoothschedule.com",
"websiteLabel": "Website:",
"website": "https://smoothschedule.com/contact"
}
},
"termsOfService": {
"title": "Terms of Service",
"lastUpdated": "Last updated: December 1, 2025",
"sections": {
"acceptanceOfTerms": {
"title": "1. Acceptance of Terms",
"content": "By accessing and using SmoothSchedule (\"the Service\"), you accept and agree to be bound by the terms and provision of this agreement. If you do not agree to these Terms of Service, please do not use the Service."
},
"descriptionOfService": {
"title": "2. Description of Service",
"content": "SmoothSchedule is a scheduling platform that enables businesses to manage appointments, resources, services, and customer interactions. The Service is provided on a subscription basis with various pricing tiers."
},
"userAccounts": {
"title": "3. User Accounts",
"intro": "To use the Service, you must:",
"requirements": {
"accurate": "Create an account with accurate and complete information",
"security": "Maintain the security of your account credentials",
"notify": "Notify us immediately of any unauthorized access",
"responsible": "Be responsible for all activities under your account"
}
},
"acceptableUse": {
"title": "4. Acceptable Use",
"intro": "You agree not to use the Service to:",
"prohibitions": {
"laws": "Violate any applicable laws or regulations",
"ip": "Infringe on intellectual property rights",
"malicious": "Transmit malicious code or interfere with the Service",
"unauthorized": "Attempt to gain unauthorized access to any part of the Service",
"fraudulent": "Use the Service for any fraudulent or illegal purpose"
}
},
"subscriptionsAndPayments": {
"title": "5. Subscriptions and Payments",
"intro": "Subscription terms:",
"terms": {
"billing": "Subscriptions are billed in advance on a recurring basis",
"cancel": "You may cancel your subscription at any time",
"refunds": "No refunds are provided for partial subscription periods",
"pricing": "We reserve the right to change pricing with 30 days notice",
"failed": "Failed payments may result in service suspension"
}
},
"trialPeriod": {
"title": "6. Trial Period",
"content": "We may offer a free trial period. At the end of the trial, your subscription will automatically convert to a paid plan unless you cancel. Trial terms may vary and are subject to change."
},
"dataAndPrivacy": {
"title": "7. Data and Privacy",
"content": "Your use of the Service is also governed by our Privacy Policy. We collect, use, and protect your data as described in that policy. You retain ownership of all data you input into the Service."
},
"serviceAvailability": {
"title": "8. Service Availability",
"content": "While we strive for 99.9% uptime, we do not guarantee uninterrupted access to the Service. We may perform maintenance, updates, or modifications that temporarily affect availability. We are not liable for any downtime or service interruptions."
},
"intellectualProperty": {
"title": "9. Intellectual Property",
"content": "The Service, including all content, features, and functionality, is owned by SmoothSchedule and protected by copyright, trademark, and other intellectual property laws. You may not copy, modify, distribute, or create derivative works without our express written consent."
},
"warrantyDisclaimer": {
"title": "10. Warranty Disclaimer",
"content": "THE SERVICE IS PROVIDED \"AS IS\" AND \"AS AVAILABLE\" WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR IMPLIED. WE DISCLAIM ALL WARRANTIES, INCLUDING MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT."
},
"limitationOfLiability": {
"title": "11. Limitation of Liability",
"content": "IN NO EVENT SHALL SMOOTHSCHEDULE BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS OR REVENUES. OUR TOTAL LIABILITY SHALL NOT EXCEED THE AMOUNT YOU PAID US IN THE TWELVE MONTHS PRECEDING THE CLAIM."
},
"indemnification": {
"title": "12. Indemnification",
"content": "You agree to indemnify and hold harmless SmoothSchedule and its officers, directors, employees, and agents from any claims, damages, losses, or expenses arising from your use of the Service or violation of these Terms."
},
"termination": {
"title": "10. Termination",
"content": "We may suspend or terminate your access to the Service at any time for any reason, including violation of these Terms. Upon termination, your right to use the Service ceases immediately. Provisions that by their nature should survive termination will remain in effect."
},
"governingLaw": {
"title": "14. Governing Law",
"content": "These Terms shall be governed by and construed in accordance with the laws of the State of Delaware, without regard to its conflict of law provisions. Any disputes shall be resolved in the courts of Delaware."
},
"changesToTerms": {
"title": "15. Changes to Terms",
"content": "We may update these Terms from time to time. We will notify you of material changes by posting the new Terms on this page and updating the \"Last updated\" date. Your continued use of the Service after changes constitutes acceptance of the new Terms."
},
"contactUs": {
"title": "16. Contact Us",
"intro": "If you have any questions about these Terms of Service, please contact us:",
"emailLabel": "Email:",
"email": "legal@smoothschedule.com",
"websiteLabel": "Website:",
"website": "https://smoothschedule.com/contact"
}
}
}
},
"trial": {
@@ -2487,241 +2722,6 @@
"paymentProcessing": "Payment processing",
"prioritySupport": "Priority support"
}
},
"privacyPolicy": {
"title": "Privacy Policy",
"lastUpdated": "Last updated: December 1, 2025",
"section1": {
"title": "1. Introduction",
"content": "Welcome to SmoothSchedule. We respect your privacy and are committed to protecting your personal data. This Privacy Policy explains how we collect, use, disclose, and safeguard your information when you use our scheduling platform and services."
},
"section2": {
"title": "2. Information We Collect",
"subsection1": {
"title": "2.1 Information You Provide",
"intro": "We collect information you directly provide to us, including:",
"items": [
"Account information (name, email, password, phone number)",
"Business information (business name, subdomain, industry)",
"Payment information (processed securely through third-party payment processors)",
"Customer data you input into the platform (appointments, resources, services)",
"Communications with our support team"
]
},
"subsection2": {
"title": "2.2 Automatically Collected Information",
"intro": "When you use our Service, we automatically collect:",
"items": [
"Log data (IP address, browser type, device information, operating system)",
"Usage data (pages visited, features used, time spent on platform)",
"Cookie data (session cookies, preference cookies)",
"Performance and error data for service improvement"
]
}
},
"section3": {
"title": "3. How We Use Your Information",
"intro": "We use the collected information for:",
"items": [
"Providing and maintaining the Service",
"Processing your transactions and managing subscriptions",
"Sending you service updates, security alerts, and administrative messages",
"Responding to your inquiries and providing customer support",
"Improving and optimizing our Service",
"Detecting and preventing fraud and security issues",
"Complying with legal obligations",
"Sending marketing communications (with your consent)"
]
},
"section4": {
"title": "4. Data Sharing and Disclosure",
"subsection1": {
"title": "4.1 We Share Data With:",
"items": [
"<strong>Service Providers:</strong> Third-party vendors who help us provide the Service (hosting, payment processing, analytics)",
"<strong>Business Transfers:</strong> In connection with any merger, sale, or acquisition of all or part of our company",
"<strong>Legal Requirements:</strong> When required by law, court order, or legal process",
"<strong>Protection of Rights:</strong> To protect our rights, property, or safety, or that of our users"
]
},
"subsection2": {
"title": "4.2 We Do NOT:",
"items": [
"Sell your personal data to third parties",
"Share your data for third-party marketing without consent",
"Access your customer data except for support or technical purposes"
]
}
},
"section5": {
"title": "5. Data Security",
"intro": "We implement industry-standard security measures to protect your data:",
"items": [
"Encryption of data in transit (TLS/SSL)",
"Encryption of sensitive data at rest",
"Regular security audits and vulnerability assessments",
"Access controls and authentication mechanisms",
"Regular backups and disaster recovery procedures"
],
"disclaimer": "However, no method of transmission over the Internet is 100% secure. While we strive to protect your data, we cannot guarantee absolute security."
},
"section6": {
"title": "6. Data Retention",
"content": "We retain your personal data for as long as necessary to provide the Service and fulfill the purposes described in this policy. When you cancel your account, we retain your data for 30 days to allow for account reactivation. After this period, your personal data may be anonymized and aggregated for internal analytics and service improvement purposes. Anonymized data cannot be used to identify you personally and cannot be retrieved or attributed to any person or account. We may also retain certain data if required for legal or legitimate business purposes."
},
"section7": {
"title": "7. Your Rights and Choices",
"intro": "Depending on your location, you may have the following rights:",
"items": [
"<strong>Access:</strong> Request a copy of your personal data",
"<strong>Correction:</strong> Update or correct inaccurate data",
"<strong>Deletion:</strong> Request deletion of your personal data",
"<strong>Portability:</strong> Receive your data in a portable format",
"<strong>Objection:</strong> Object to certain data processing activities",
"<strong>Restriction:</strong> Request restriction of data processing",
"<strong>Withdraw Consent:</strong> Withdraw previously given consent"
],
"contact": "To exercise these rights, please contact us at privacy@smoothschedule.com."
},
"section8": {
"title": "8. Cookies and Tracking",
"intro": "We use cookies and similar tracking technologies to:",
"items": [
"Maintain your session and keep you logged in",
"Remember your preferences and settings",
"Analyze usage patterns and improve our Service",
"Provide personalized content and features"
],
"disclaimer": "You can control cookies through your browser settings, but disabling cookies may affect your ability to use certain features of the Service."
},
"section9": {
"title": "9. Third-Party Services",
"content": "Our Service may contain links to third-party websites or integrate with third-party services (OAuth providers, payment processors). We are not responsible for the privacy practices of these third parties. We encourage you to review their privacy policies before providing any personal information."
},
"section10": {
"title": "10. Children's Privacy",
"content": "Our Service is not intended for children under 13 years of age. We do not knowingly collect personal information from children under 13. If you believe we have collected data from a child under 13, please contact us immediately so we can delete it."
},
"section11": {
"title": "11. International Data Transfers",
"content": "Your information may be transferred to and processed in countries other than your country of residence. These countries may have different data protection laws. We ensure appropriate safeguards are in place to protect your data in accordance with this Privacy Policy."
},
"section12": {
"title": "12. California Privacy Rights",
"content": "If you are a California resident, you have additional rights under the California Consumer Privacy Act (CCPA), including the right to know what personal information we collect, the right to delete your information, and the right to opt-out of the sale of your information (which we do not do)."
},
"section13": {
"title": "13. GDPR Compliance",
"content": "If you are in the European Economic Area (EEA), we process your personal data based on legal grounds such as consent, contract performance, legal obligations, or legitimate interests. You have rights under the General Data Protection Regulation (GDPR) including the right to lodge a complaint with a supervisory authority."
},
"section14": {
"title": "14. Changes to This Privacy Policy",
"content": "We may update this Privacy Policy from time to time. We will notify you of material changes by posting the new policy on this page and updating the \"Last updated\" date. We encourage you to review this Privacy Policy periodically."
},
"section15": {
"title": "15. Contact Us",
"intro": "If you have any questions about this Privacy Policy or our data practices, please contact us:",
"emailLabel": "Email:",
"email": "privacy@smoothschedule.com",
"dpoLabel": "Data Protection Officer:",
"dpo": "dpo@smoothschedule.com",
"websiteLabel": "Website:",
"website": "https://smoothschedule.com/contact"
}
},
"termsOfService": {
"title": "Terms of Service",
"lastUpdated": "Last updated: December 1, 2025",
"sections": {
"acceptanceOfTerms": {
"title": "1. Acceptance of Terms",
"content": "By accessing and using SmoothSchedule (\"the Service\"), you accept and agree to be bound by the terms and provision of this agreement. If you do not agree to these Terms of Service, please do not use the Service."
},
"descriptionOfService": {
"title": "2. Description of Service",
"content": "SmoothSchedule is a scheduling platform that enables businesses to manage appointments, resources, services, and customer interactions. The Service is provided on a subscription basis with various pricing tiers."
},
"userAccounts": {
"title": "3. User Accounts",
"intro": "To use the Service, you must:",
"requirements": {
"accurate": "Create an account with accurate and complete information",
"security": "Maintain the security of your account credentials",
"notify": "Notify us immediately of any unauthorized access",
"responsible": "Be responsible for all activities under your account"
}
},
"acceptableUse": {
"title": "4. Acceptable Use",
"intro": "You agree not to use the Service to:",
"prohibitions": {
"laws": "Violate any applicable laws or regulations",
"ip": "Infringe on intellectual property rights",
"malicious": "Transmit malicious code or interfere with the Service",
"unauthorized": "Attempt to gain unauthorized access to any part of the Service",
"fraudulent": "Use the Service for any fraudulent or illegal purpose"
}
},
"subscriptionsAndPayments": {
"title": "5. Subscriptions and Payments",
"intro": "Subscription terms:",
"terms": {
"billing": "Subscriptions are billed in advance on a recurring basis",
"cancel": "You may cancel your subscription at any time",
"refunds": "No refunds are provided for partial subscription periods",
"pricing": "We reserve the right to change pricing with 30 days notice",
"failed": "Failed payments may result in service suspension"
}
},
"trialPeriod": {
"title": "6. Trial Period",
"content": "We may offer a free trial period. At the end of the trial, your subscription will automatically convert to a paid plan unless you cancel. Trial terms may vary and are subject to change."
},
"dataAndPrivacy": {
"title": "7. Data and Privacy",
"content": "Your use of the Service is also governed by our Privacy Policy. We collect, use, and protect your data as described in that policy. You retain ownership of all data you input into the Service."
},
"serviceAvailability": {
"title": "8. Service Availability",
"content": "While we strive for 99.9% uptime, we do not guarantee uninterrupted access to the Service. We may perform maintenance, updates, or modifications that temporarily affect availability. We are not liable for any downtime or service interruptions."
},
"intellectualProperty": {
"title": "9. Intellectual Property",
"content": "The Service, including all software, designs, text, graphics, and other content, is owned by SmoothSchedule and protected by copyright, trademark, and other intellectual property laws. You may not copy, modify, distribute, or create derivative works without our express written permission."
},
"termination": {
"title": "10. Termination",
"content": "We may terminate or suspend your account and access to the Service at any time, with or without cause, with or without notice. Upon termination, your right to use the Service will immediately cease. We will retain your data for 30 days after termination, after which it may be permanently deleted."
},
"limitationOfLiability": {
"title": "11. Limitation of Liability",
"content": "To the maximum extent permitted by law, SmoothSchedule shall not be liable for any indirect, incidental, special, consequential, or punitive damages, or any loss of profits or revenues, whether incurred directly or indirectly, or any loss of data, use, goodwill, or other intangible losses resulting from your use of the Service."
},
"warrantyDisclaimer": {
"title": "12. Warranty Disclaimer",
"content": "The Service is provided \"as is\" and \"as available\" without warranties of any kind, either express or implied, including but not limited to implied warranties of merchantability, fitness for a particular purpose, or non-infringement."
},
"indemnification": {
"title": "13. Indemnification",
"content": "You agree to indemnify and hold harmless SmoothSchedule, its officers, directors, employees, and agents from any claims, damages, losses, liabilities, and expenses (including legal fees) arising from your use of the Service or violation of these Terms."
},
"changesToTerms": {
"title": "14. Changes to Terms",
"content": "We reserve the right to modify these Terms at any time. We will notify you of material changes via email or through the Service. Your continued use of the Service after such changes constitutes acceptance of the new Terms."
},
"governingLaw": {
"title": "15. Governing Law",
"content": "These Terms shall be governed by and construed in accordance with the laws of the jurisdiction in which SmoothSchedule is registered, without regard to its conflict of law provisions."
},
"contactUs": {
"title": "16. Contact Us",
"intro": "If you have any questions about these Terms of Service, please contact us at:",
"email": "Email:",
"emailAddress": "legal@smoothschedule.com",
"website": "Website:",
"websiteUrl": "https://smoothschedule.com/contact"
}
}
}
},
"timeBlocks": {
@@ -3121,9 +3121,9 @@
"timeBlocksDocumentationDesc": "Complete guide to creating, managing, and visualizing time blocks"
},
"plugins": {
"title": "Plugins",
"description": "Plugins extend SmoothSchedule with custom automation and integrations. Browse the marketplace for pre-built plugins or create your own using our scripting language.",
"whatPluginsCanDo": "What Plugins Can Do",
"title": "Automations",
"description": "Automations extend SmoothSchedule with custom workflows and integrations. Browse the marketplace for pre-built automations or create your own using our scripting language.",
"whatPluginsCanDo": "What Automations Can Do",
"sendEmailsCapability": "Send Emails:",
"sendEmailsDesc": "Automated reminders, confirmations, and follow-ups",
"webhooksCapability": "Webhooks:",
@@ -3132,20 +3132,20 @@
"reportsDesc": "Generate and email business reports on a schedule",
"cleanupCapability": "Cleanup:",
"cleanupDesc": "Automatically archive old data or manage records",
"pluginTypes": "Plugin Types",
"marketplacePlugins": "Marketplace Plugins",
"marketplacePluginsDesc": "Pre-built plugins available to install immediately. Browse, install, and configure with a few clicks.",
"customPlugins": "Custom Plugins",
"customPluginsDesc": "Create your own plugins using our scripting language. Full control over logic and triggers.",
"pluginTypes": "Automation Types",
"marketplacePlugins": "Marketplace Automations",
"marketplacePluginsDesc": "Pre-built automations available to install immediately. Browse, install, and configure with a few clicks.",
"customPlugins": "Custom Automations",
"customPluginsDesc": "Create your own automations using our scripting language. Full control over logic and triggers.",
"triggers": "Triggers",
"triggersDesc": "Plugins can be triggered in various ways:",
"triggersDesc": "Automations can be triggered in various ways:",
"beforeEventTrigger": "Before Event",
"atStartTrigger": "At Start",
"afterEndTrigger": "After End",
"onStatusChangeTrigger": "On Status Change",
"learnMore": "Learn More",
"pluginDocumentation": "Plugin Documentation",
"pluginDocumentationDesc": "Complete guide to creating and using plugins, including API reference and examples"
"pluginDocumentation": "Automation Documentation",
"pluginDocumentationDesc": "Complete guide to creating and using automations, including API reference and examples"
},
"contracts": {
"title": "Contracts",

View File

@@ -28,12 +28,12 @@ import {
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import api from '../api/client';
import { PluginTemplate, PluginCategory } from '../types';
import { AutomationTemplate, AutomationCategory } from '../types';
import { usePlanFeatures } from '../hooks/usePlanFeatures';
import { LockedSection } from '../components/UpgradePrompt';
// Category icon mapping
const categoryIcons: Record<PluginCategory, React.ReactNode> = {
const categoryIcons: Record<AutomationCategory, React.ReactNode> = {
EMAIL: <Mail className="h-4 w-4" />,
REPORTS: <BarChart3 className="h-4 w-4" />,
CUSTOMER: <Users className="h-4 w-4" />,
@@ -44,7 +44,7 @@ const categoryIcons: Record<PluginCategory, React.ReactNode> = {
};
// Category colors
const categoryColors: Record<PluginCategory, string> = {
const categoryColors: Record<AutomationCategory, string> = {
EMAIL: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
REPORTS: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
CUSTOMER: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
@@ -80,29 +80,29 @@ const detectPlatformOnlyCode = (code: string): { hasPlatformCode: boolean; warni
};
};
const PluginMarketplace: React.FC = () => {
const AutomationMarketplace: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<PluginCategory | 'ALL'>('ALL');
const [selectedPlugin, setSelectedPlugin] = useState<PluginTemplate | null>(null);
const [selectedCategory, setSelectedCategory] = useState<AutomationCategory | 'ALL'>('ALL');
const [selectedAutomation, setSelectedAutomation] = useState<AutomationTemplate | null>(null);
const [showDetailsModal, setShowDetailsModal] = useState(false);
const [showCode, setShowCode] = useState(false);
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
const [showWhatsNextModal, setShowWhatsNextModal] = useState(false);
const [installedPluginId, setInstalledPluginId] = useState<string | null>(null);
const [installedAutomationId, setInstalledAutomationId] = useState<string | null>(null);
// Check plan permissions
const { canUse, isLoading: permissionsLoading } = usePlanFeatures();
const hasPluginsFeature = canUse('plugins');
const isLocked = !hasPluginsFeature;
const hasAutomationsFeature = canUse('automations');
const isLocked = !hasAutomationsFeature;
// Fetch marketplace plugins - only when user has the feature
const { data: plugins = [], isLoading, error } = useQuery<PluginTemplate[]>({
queryKey: ['plugin-templates', 'marketplace'],
// Fetch marketplace automations - only when user has the feature
const { data: automations = [], isLoading, error } = useQuery<AutomationTemplate[]>({
queryKey: ['automation-templates', 'marketplace'],
queryFn: async () => {
const { data } = await api.get('/plugin-templates/?view=marketplace');
const { data } = await api.get('/automation-templates/?view=marketplace');
return data.map((p: any) => ({
id: String(p.id),
name: p.name,
@@ -118,49 +118,49 @@ const PluginMarketplace: React.FC = () => {
createdAt: p.created_at,
updatedAt: p.updated_at,
logoUrl: p.logo_url,
pluginCode: p.plugin_code,
automationCode: p.automation_code,
}));
},
// Don't fetch if user doesn't have the plugins feature
enabled: hasPluginsFeature && !permissionsLoading,
// Don't fetch if user doesn't have the automations feature
enabled: hasAutomationsFeature && !permissionsLoading,
});
// Fetch installed plugins to check which are already installed
const { data: installedPlugins = [] } = useQuery<{ template: number }[]>({
queryKey: ['plugin-installations'],
// Fetch installed automations to check which are already installed
const { data: installedAutomations = [] } = useQuery<{ template: number }[]>({
queryKey: ['automation-installations'],
queryFn: async () => {
const { data } = await api.get('/plugin-installations/');
const { data } = await api.get('/automation-installations/');
return data;
},
// Don't fetch if user doesn't have the plugins feature
enabled: hasPluginsFeature && !permissionsLoading,
// Don't fetch if user doesn't have the automations feature
enabled: hasAutomationsFeature && !permissionsLoading,
});
// Create a set of installed template IDs for quick lookup
const installedTemplateIds = useMemo(() => {
return new Set(installedPlugins.map(p => String(p.template)));
}, [installedPlugins]);
return new Set(installedAutomations.map(p => String(p.template)));
}, [installedAutomations]);
// Install plugin mutation
// Install automation mutation
const installMutation = useMutation({
mutationFn: async (templateId: string) => {
const { data } = await api.post('/plugin-installations/', {
const { data } = await api.post('/automation-installations/', {
template: templateId,
});
return data;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['plugin-installations'] });
queryClient.invalidateQueries({ queryKey: ['automation-installations'] });
setShowDetailsModal(false);
// Show What's Next modal
setInstalledPluginId(data.id);
setInstalledAutomationId(data.id);
setShowWhatsNextModal(true);
},
});
// Filter plugins
const filteredPlugins = useMemo(() => {
let result = plugins;
// Filter automations
const filteredAutomations = useMemo(() => {
let result = automations;
// Filter by category
if (selectedCategory !== 'ALL') {
@@ -178,42 +178,42 @@ const PluginMarketplace: React.FC = () => {
}
return result;
}, [plugins, selectedCategory, searchQuery]);
}, [automations, selectedCategory, searchQuery]);
// Sort: Featured first, then by rating, then by install count
const sortedPlugins = useMemo(() => {
return [...filteredPlugins].sort((a, b) => {
const sortedAutomations = useMemo(() => {
return [...filteredAutomations].sort((a, b) => {
if (a.isFeatured && !b.isFeatured) return -1;
if (!a.isFeatured && b.isFeatured) return 1;
if (a.rating !== b.rating) return b.rating - a.rating;
return b.installCount - a.installCount;
});
}, [filteredPlugins]);
}, [filteredAutomations]);
const handleInstall = async (plugin: PluginTemplate) => {
setSelectedPlugin(plugin);
const handleInstall = async (automation: AutomationTemplate) => {
setSelectedAutomation(automation);
setShowDetailsModal(true);
setShowCode(false);
// Fetch full plugin details including plugin_code
// Fetch full automation details including automation_code
setIsLoadingDetails(true);
try {
const { data } = await api.get(`/plugin-templates/${plugin.id}/`);
setSelectedPlugin({
...plugin,
pluginCode: data.plugin_code,
const { data } = await api.get(`/automation-templates/${automation.id}/`);
setSelectedAutomation({
...automation,
automationCode: data.automation_code,
logoUrl: data.logo_url,
});
} catch (error) {
console.error('Failed to fetch plugin details:', error);
console.error('Failed to fetch automation details:', error);
} finally {
setIsLoadingDetails(false);
}
};
const confirmInstall = () => {
if (selectedPlugin) {
installMutation.mutate(selectedPlugin.id);
if (selectedAutomation) {
installMutation.mutate(selectedAutomation.id);
}
};
@@ -246,17 +246,17 @@ const PluginMarketplace: React.FC = () => {
const effectivelyLocked = isLocked || is403Error;
return (
<LockedSection feature="plugins" isLocked={effectivelyLocked} variant="overlay">
<LockedSection feature="automations" isLocked={effectivelyLocked} variant="overlay">
<div className="p-8 space-y-6 max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<ShoppingBag className="h-7 w-7 text-brand-600" />
{t('plugins.marketplace', 'Plugin Marketplace')}
{t('automations.marketplace', 'Automation Marketplace')}
</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">
{t('plugins.marketplaceDescription', 'Extend your business with powerful plugins')}
{t('automations.marketplaceDescription', 'Extend your business with powerful automations')}
</p>
</div>
</div>
@@ -271,7 +271,7 @@ const PluginMarketplace: React.FC = () => {
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('plugins.searchPlugins', 'Search plugins...')}
placeholder={t('automations.searchAutomations', 'Search automations...')}
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
@@ -281,17 +281,17 @@ const PluginMarketplace: React.FC = () => {
<Filter className="h-5 w-5 text-gray-400" />
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value as PluginCategory | 'ALL')}
onChange={(e) => setSelectedCategory(e.target.value as AutomationCategory | 'ALL')}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
>
<option value="ALL">{t('plugins.allCategories', 'All Categories')}</option>
<option value="EMAIL">{t('plugins.categoryEmail', 'Email')}</option>
<option value="REPORTS">{t('plugins.categoryReports', 'Reports')}</option>
<option value="CUSTOMER">{t('plugins.categoryCustomer', 'Customer')}</option>
<option value="BOOKING">{t('plugins.categoryBooking', 'Booking')}</option>
<option value="INTEGRATION">{t('plugins.categoryIntegration', 'Integration')}</option>
<option value="AUTOMATION">{t('plugins.categoryAutomation', 'Automation')}</option>
<option value="OTHER">{t('plugins.categoryOther', 'Other')}</option>
<option value="ALL">{t('automations.allCategories', 'All Categories')}</option>
<option value="EMAIL">{t('automations.categoryEmail', 'Email')}</option>
<option value="REPORTS">{t('automations.categoryReports', 'Reports')}</option>
<option value="CUSTOMER">{t('automations.categoryCustomer', 'Customer')}</option>
<option value="BOOKING">{t('automations.categoryBooking', 'Booking')}</option>
<option value="INTEGRATION">{t('automations.categoryIntegration', 'Integration')}</option>
<option value="AUTOMATION">{t('automations.categoryAutomation', 'Automation')}</option>
<option value="OTHER">{t('automations.categoryOther', 'Other')}</option>
</select>
</div>
</div>
@@ -300,7 +300,7 @@ const PluginMarketplace: React.FC = () => {
{(searchQuery || selectedCategory !== 'ALL') && (
<div className="flex items-center gap-2 mt-4">
<span className="text-sm text-gray-500 dark:text-gray-400">
{t('plugins.showingResults', 'Showing')} {sortedPlugins.length} {t('plugins.results', 'results')}
{t('automations.showingResults', 'Showing')} {sortedAutomations.length} {t('automations.results', 'results')}
</span>
{(searchQuery || selectedCategory !== 'ALL') && (
<button
@@ -317,31 +317,31 @@ const PluginMarketplace: React.FC = () => {
)}
</div>
{/* Plugin Grid */}
{sortedPlugins.length === 0 ? (
{/* Automation Grid */}
{sortedAutomations.length === 0 ? (
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<ShoppingBag className="h-12 w-12 mx-auto text-gray-400 mb-4" />
<p className="text-gray-500 dark:text-gray-400">
{searchQuery || selectedCategory !== 'ALL'
? t('plugins.noPluginsFound', 'No plugins found matching your criteria')
: t('plugins.noPluginsAvailable', 'No plugins available yet')}
? t('automations.noAutomationsFound', 'No automations found matching your criteria')
: t('automations.noAutomationsAvailable', 'No automations available yet')}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{sortedPlugins.map((plugin) => (
{sortedAutomations.map((automation) => (
<div
key={plugin.id}
key={automation.id}
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow overflow-hidden flex flex-col"
>
{/* Plugin Card Header */}
{/* Automation Card Header */}
<div className="p-6 flex-1">
<div className="flex items-start gap-3 mb-3">
{/* Logo */}
{plugin.logoUrl ? (
{automation.logoUrl ? (
<img
src={plugin.logoUrl}
alt={`${plugin.name} logo`}
src={automation.logoUrl}
alt={`${automation.name} logo`}
className="w-12 h-12 rounded-lg object-cover flex-shrink-0"
/>
) : (
@@ -353,24 +353,24 @@ const PluginMarketplace: React.FC = () => {
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2 flex-wrap">
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium ${categoryColors[plugin.category]}`}>
{categoryIcons[plugin.category]}
{plugin.category}
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium ${categoryColors[automation.category]}`}>
{categoryIcons[automation.category]}
{automation.category}
</span>
{plugin.isFeatured && (
{automation.isFeatured && (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
<Zap className="h-3 w-3" />
Featured
</span>
)}
{installedTemplateIds.has(plugin.id) && (
{installedTemplateIds.has(automation.id) && (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
<CheckCircle className="h-3 w-3" />
Installed
</span>
)}
</div>
{plugin.isVerified && (
{automation.isVerified && (
<CheckCircle className="h-5 w-5 text-blue-500 flex-shrink-0" title="Verified" />
)}
</div>
@@ -378,39 +378,39 @@ const PluginMarketplace: React.FC = () => {
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
{plugin.name}
{automation.name}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">
{plugin.description}
{automation.description}
</p>
{/* Stats */}
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
<div className="flex items-center gap-1">
<Star className="h-4 w-4 text-amber-500 fill-amber-500" />
<span className="font-medium">{plugin.rating.toFixed(1)}</span>
<span className="text-xs">({plugin.ratingCount})</span>
<span className="font-medium">{automation.rating.toFixed(1)}</span>
<span className="text-xs">({automation.ratingCount})</span>
</div>
<div className="flex items-center gap-1">
<Download className="h-4 w-4" />
<span>{plugin.installCount.toLocaleString()}</span>
<span>{automation.installCount.toLocaleString()}</span>
</div>
</div>
<div className="mt-3 text-xs text-gray-400 dark:text-gray-500">
by {plugin.author} v{plugin.version}
by {automation.author} v{automation.version}
</div>
</div>
{/* Plugin Card Actions */}
{/* Automation Card Actions */}
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-100 dark:border-gray-700">
<button
onClick={() => handleInstall(plugin)}
onClick={() => handleInstall(automation)}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium text-sm"
>
<Eye className="h-4 w-4" />
{t('plugins.view', 'View')}
{t('automations.view', 'View')}
</button>
</div>
</div>
@@ -418,18 +418,18 @@ const PluginMarketplace: React.FC = () => {
</div>
)}
{/* Plugin Details Modal */}
{showDetailsModal && selectedPlugin && (
{/* Automation Details Modal */}
{showDetailsModal && selectedAutomation && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Modal Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div className="flex items-center gap-3">
{/* Logo in modal header */}
{selectedPlugin.logoUrl ? (
{selectedAutomation.logoUrl ? (
<img
src={selectedPlugin.logoUrl}
alt={`${selectedPlugin.name} logo`}
src={selectedAutomation.logoUrl}
alt={`${selectedAutomation.name} logo`}
className="w-10 h-10 rounded-lg object-cover flex-shrink-0"
/>
) : (
@@ -439,9 +439,9 @@ const PluginMarketplace: React.FC = () => {
)}
<div className="flex items-center gap-2">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">
{selectedPlugin.name}
{selectedAutomation.name}
</h3>
{selectedPlugin.isVerified && (
{selectedAutomation.isVerified && (
<CheckCircle className="h-5 w-5 text-blue-500" title="Verified" />
)}
</div>
@@ -449,7 +449,7 @@ const PluginMarketplace: React.FC = () => {
<button
onClick={() => {
setShowDetailsModal(false);
setSelectedPlugin(null);
setSelectedAutomation(null);
setShowCode(false);
}}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
@@ -467,11 +467,11 @@ const PluginMarketplace: React.FC = () => {
) : (
<>
<div className="flex items-center gap-2">
<span className={`inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium ${categoryColors[selectedPlugin.category]}`}>
{categoryIcons[selectedPlugin.category]}
{selectedPlugin.category}
<span className={`inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium ${categoryColors[selectedAutomation.category]}`}>
{categoryIcons[selectedAutomation.category]}
{selectedAutomation.category}
</span>
{selectedPlugin.isFeatured && (
{selectedAutomation.isFeatured && (
<span className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
<Zap className="h-4 w-4" />
Featured
@@ -481,25 +481,25 @@ const PluginMarketplace: React.FC = () => {
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
{t('plugins.description', 'Description')}
{t('automations.description', 'Description')}
</h4>
<p className="text-gray-600 dark:text-gray-400">
{selectedPlugin.description}
{selectedAutomation.description}
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
{t('plugins.version', 'Version')}
{t('automations.version', 'Version')}
</h4>
<p className="text-gray-600 dark:text-gray-400">{selectedPlugin.version}</p>
<p className="text-gray-600 dark:text-gray-400">{selectedAutomation.version}</p>
</div>
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
{t('plugins.author', 'Author')}
{t('automations.author', 'Author')}
</h4>
<p className="text-gray-600 dark:text-gray-400">{selectedPlugin.author}</p>
<p className="text-gray-600 dark:text-gray-400">{selectedAutomation.author}</p>
</div>
</div>
@@ -507,29 +507,29 @@ const PluginMarketplace: React.FC = () => {
<div className="flex items-center gap-2">
<Star className="h-5 w-5 text-amber-500 fill-amber-500" />
<span className="font-semibold text-gray-900 dark:text-white">
{selectedPlugin.rating.toFixed(1)}
{selectedAutomation.rating.toFixed(1)}
</span>
<span className="text-gray-500 dark:text-gray-400">
({selectedPlugin.ratingCount} {t('plugins.ratings', 'ratings')})
({selectedAutomation.ratingCount} {t('automations.ratings', 'ratings')})
</span>
</div>
<div className="flex items-center gap-2">
<Download className="h-5 w-5 text-gray-400" />
<span className="text-gray-600 dark:text-gray-400">
{selectedPlugin.installCount.toLocaleString()} {t('plugins.installs', 'installs')}
{selectedAutomation.installCount.toLocaleString()} {t('automations.installs', 'installs')}
</span>
</div>
</div>
{/* Code Viewer Section */}
{selectedPlugin.pluginCode && (
{selectedAutomation.automationCode && (
<div className="space-y-3">
<button
onClick={() => setShowCode(!showCode)}
className="flex items-center justify-between w-full px-4 py-3 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
<span className="font-semibold text-gray-900 dark:text-white">
{t('plugins.viewCode', 'View Code')}
{t('automations.viewCode', 'View Code')}
</span>
<ChevronDown
className={`h-5 w-5 text-gray-600 dark:text-gray-400 transition-transform ${showCode ? 'rotate-180' : ''}`}
@@ -540,14 +540,14 @@ const PluginMarketplace: React.FC = () => {
<div className="space-y-3">
{/* Platform-only code warnings */}
{(() => {
const { hasPlatformCode, warnings } = detectPlatformOnlyCode(selectedPlugin.pluginCode || '');
const { hasPlatformCode, warnings } = detectPlatformOnlyCode(selectedAutomation.automationCode || '');
return hasPlatformCode ? (
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h5 className="font-semibold text-amber-900 dark:text-amber-200 mb-2">
{t('plugins.platformOnlyWarning', 'Platform-Only Features Detected')}
{t('automations.platformOnlyWarning', 'Platform-Only Features Detected')}
</h5>
<ul className="space-y-1 text-sm text-amber-800 dark:text-amber-300">
{warnings.map((warning, idx) => (
@@ -576,7 +576,7 @@ const PluginMarketplace: React.FC = () => {
}}
showLineNumbers
>
{selectedPlugin.pluginCode}
{selectedAutomation.automationCode}
</SyntaxHighlighter>
</div>
</div>
@@ -592,25 +592,25 @@ const PluginMarketplace: React.FC = () => {
<button
onClick={() => {
setShowDetailsModal(false);
setSelectedPlugin(null);
setSelectedAutomation(null);
setShowCode(false);
}}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors font-medium"
>
{installedTemplateIds.has(selectedPlugin.id) ? t('common.close', 'Close') : t('common.cancel', 'Cancel')}
{installedTemplateIds.has(selectedAutomation.id) ? t('common.close', 'Close') : t('common.cancel', 'Cancel')}
</button>
{installedTemplateIds.has(selectedPlugin.id) ? (
{installedTemplateIds.has(selectedAutomation.id) ? (
<button
onClick={() => {
setShowDetailsModal(false);
setSelectedPlugin(null);
setSelectedAutomation(null);
setShowCode(false);
navigate('/dashboard/plugins/my-plugins');
navigate('/dashboard/automations/my-automations');
}}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium"
>
<CheckCircle className="h-4 w-4" />
{t('plugins.viewInstalled', 'View in My Plugins')}
{t('automations.viewInstalled', 'View in My Automations')}
</button>
) : (
<button
@@ -621,12 +621,12 @@ const PluginMarketplace: React.FC = () => {
{installMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
{t('plugins.installing', 'Installing...')}
{t('automations.installing', 'Installing...')}
</>
) : (
<>
<Download className="h-4 w-4" />
{t('plugins.install', 'Install')}
{t('automations.install', 'Install')}
</>
)}
</button>
@@ -637,7 +637,7 @@ const PluginMarketplace: React.FC = () => {
)}
{/* What's Next Modal - shown after successful install */}
{showWhatsNextModal && selectedPlugin && (
{showWhatsNextModal && selectedAutomation && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-white dark:bg-gray-800 rounded-xl max-w-lg w-full shadow-2xl overflow-hidden">
{/* Success Header */}
@@ -646,10 +646,10 @@ const PluginMarketplace: React.FC = () => {
<CheckCircle className="w-10 h-10 text-white" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">
Plugin Installed!
Automation Installed!
</h2>
<p className="text-green-100">
{selectedPlugin.name} is ready to use
{selectedAutomation.name} is ready to use
</p>
</div>
@@ -663,7 +663,7 @@ const PluginMarketplace: React.FC = () => {
<button
onClick={() => {
setShowWhatsNextModal(false);
setSelectedPlugin(null);
setSelectedAutomation(null);
navigate('/dashboard/tasks');
}}
className="w-full flex items-center gap-4 p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-blue-500 dark:hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors text-left group"
@@ -686,8 +686,8 @@ const PluginMarketplace: React.FC = () => {
<button
onClick={() => {
setShowWhatsNextModal(false);
setSelectedPlugin(null);
navigate('/dashboard/plugins/my-plugins');
setSelectedAutomation(null);
navigate('/dashboard/automations/my-automations');
}}
className="w-full flex items-center gap-4 p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-purple-500 dark:hover:border-purple-500 hover:bg-purple-50 dark:hover:bg-purple-900/20 transition-colors text-left group"
>
@@ -699,7 +699,7 @@ const PluginMarketplace: React.FC = () => {
Configure settings
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Set up plugin options and customize behavior
Set up automation options and customize behavior
</p>
</div>
<ArrowRight className="w-5 h-5 text-gray-400 group-hover:text-purple-600 dark:group-hover:text-purple-400 transition-colors" />
@@ -709,7 +709,7 @@ const PluginMarketplace: React.FC = () => {
<button
onClick={() => {
setShowWhatsNextModal(false);
setSelectedPlugin(null);
setSelectedAutomation(null);
}}
className="w-full p-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors text-center"
>
@@ -724,4 +724,4 @@ const PluginMarketplace: React.FC = () => {
);
};
export default PluginMarketplace;
export default AutomationMarketplace;

View File

@@ -1,7 +1,7 @@
/**
* Create Plugin Page
* Create Automation Page
*
* Allows businesses to create custom plugins with code editor,
* Allows businesses to create custom automations with code editor,
* category selection, and visibility options.
*/
@@ -31,11 +31,11 @@ import {
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import api from '../api/client';
import { PluginCategory } from '../types';
import { AutomationCategory } from '../types';
import { usePlanFeatures } from '../hooks/usePlanFeatures';
// Category icon mapping
const categoryIcons: Record<PluginCategory, React.ReactNode> = {
const categoryIcons: Record<AutomationCategory, React.ReactNode> = {
EMAIL: <Mail className="h-4 w-4" />,
REPORTS: <BarChart3 className="h-4 w-4" />,
CUSTOMER: <Users className="h-4 w-4" />,
@@ -46,21 +46,21 @@ const categoryIcons: Record<PluginCategory, React.ReactNode> = {
};
// Category descriptions
const categoryDescriptions: Record<PluginCategory, string> = {
const categoryDescriptions: Record<AutomationCategory, string> = {
EMAIL: 'Email notifications and automated messaging',
REPORTS: 'Analytics, reports, and data exports',
CUSTOMER: 'Customer engagement and retention',
BOOKING: 'Scheduling and booking automation',
INTEGRATION: 'Third-party service integrations',
AUTOMATION: 'General business automation',
OTHER: 'Miscellaneous plugins',
OTHER: 'Miscellaneous automations',
};
// Default plugin code template
const DEFAULT_PLUGIN_CODE = `# My Custom Plugin
// Default automation code template
const DEFAULT_AUTOMATION_CODE = `# My Custom Automation
#
# This plugin runs on a schedule and can interact with your business data.
# Use template variables to make your plugin configurable.
# This automation runs on a schedule and can interact with your business data.
# Use template variables to make your automation configurable.
#
# Available template variables:
# {{PROMPT:variable_name:default_value:description}}
@@ -95,14 +95,14 @@ interface FormData {
name: string;
shortDescription: string;
description: string;
category: PluginCategory;
pluginCode: string;
category: AutomationCategory;
automationCode: string;
version: string;
logoUrl: string;
visibility: 'PRIVATE' | 'PUBLIC';
}
const CreatePlugin: React.FC = () => {
const CreateAutomation: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -113,7 +113,7 @@ const CreatePlugin: React.FC = () => {
shortDescription: '',
description: '',
category: 'AUTOMATION',
pluginCode: DEFAULT_PLUGIN_CODE,
automationCode: DEFAULT_AUTOMATION_CODE,
version: '1.0.0',
logoUrl: '',
visibility: 'PRIVATE',
@@ -160,11 +160,11 @@ const CreatePlugin: React.FC = () => {
// Update extracted variables when code changes
const handleCodeChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newCode = e.target.value;
setFormData(prev => ({ ...prev, pluginCode: newCode }));
setFormData(prev => ({ ...prev, automationCode: newCode }));
setExtractedVariables(extractVariables(newCode));
};
// Create plugin mutation
// Create automation mutation
const createMutation = useMutation({
mutationFn: async (data: FormData) => {
const payload = {
@@ -172,17 +172,17 @@ const CreatePlugin: React.FC = () => {
short_description: data.shortDescription,
description: data.description,
category: data.category,
plugin_code: data.pluginCode,
automation_code: data.automationCode,
version: data.version,
logo_url: data.logoUrl || undefined,
visibility: data.visibility,
};
const response = await api.post('/plugin-templates/', payload);
const response = await api.post('/automation-templates/', payload);
return response.data;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['plugin-templates'] });
navigate('/dashboard/plugins/my-plugins');
queryClient.invalidateQueries({ queryKey: ['automation-templates'] });
navigate('/dashboard/automations/my-automations');
},
});
@@ -191,25 +191,25 @@ const CreatePlugin: React.FC = () => {
createMutation.mutate(formData);
};
// Check if user can create plugins
const canCreatePlugins = canUse('can_create_plugins');
// Check if user can create automations
const canCreateAutomations = canUse('can_create_automations');
if (!canCreatePlugins) {
if (!canCreateAutomations) {
return (
<div className="p-8 max-w-4xl mx-auto">
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-8 text-center">
<AlertTriangle className="h-16 w-16 mx-auto text-amber-500 mb-4" />
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
{t('plugins.upgradeRequired', 'Upgrade Required')}
{t('automations.upgradeRequired', 'Upgrade Required')}
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
{t('plugins.upgradeToCreate', 'Plugin creation is available on higher-tier plans. Upgrade your subscription to create custom plugins.')}
{t('automations.upgradeToCreate', 'Automation creation is available on higher-tier plans. Upgrade your subscription to create custom automations.')}
</p>
<button
onClick={() => navigate('/dashboard/settings/billing')}
className="px-6 py-3 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium"
>
{t('plugins.viewPlans', 'View Plans')}
{t('automations.viewPlans', 'View Plans')}
</button>
</div>
</div>
@@ -221,7 +221,7 @@ const CreatePlugin: React.FC = () => {
{/* Header */}
<div className="mb-8">
<button
onClick={() => navigate('/dashboard/plugins/my-plugins')}
onClick={() => navigate('/dashboard/automations/my-automations')}
className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white mb-4 transition-colors"
>
<ArrowLeft size={20} />
@@ -231,19 +231,19 @@ const CreatePlugin: React.FC = () => {
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
<Code className="text-brand-500" />
{t('plugins.createPlugin', 'Create Custom Plugin')}
{t('automations.createAutomation', 'Create Custom Automation')}
</h1>
<p className="text-gray-500 dark:text-gray-400 mt-1">
{t('plugins.createPluginDescription', 'Build a custom automation for your business')}
{t('automations.createAutomationDescription', 'Build a custom automation for your business')}
</p>
</div>
<a
href="/help/plugins"
href="/help/automations"
target="_blank"
className="flex items-center gap-2 text-brand-600 dark:text-brand-400 hover:underline"
>
<HelpCircle size={18} />
{t('plugins.viewDocs', 'View Documentation')}
{t('automations.viewDocs', 'View Documentation')}
</a>
</div>
</div>
@@ -252,16 +252,16 @@ const CreatePlugin: React.FC = () => {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Basic Info */}
<div className="lg:col-span-1 space-y-6">
{/* Plugin Name */}
{/* Automation Name */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{t('plugins.basicInfo', 'Basic Information')}
{t('automations.basicInfo', 'Basic Information')}
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('plugins.pluginName', 'Plugin Name')} *
{t('automations.automationName', 'Automation Name')} *
</label>
<input
type="text"
@@ -275,7 +275,7 @@ const CreatePlugin: React.FC = () => {
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('plugins.shortDescription', 'Short Description')} *
{t('automations.shortDescription', 'Short Description')} *
</label>
<input
type="text"
@@ -291,24 +291,24 @@ const CreatePlugin: React.FC = () => {
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('plugins.description', 'Full Description')}
{t('automations.description', 'Full Description')}
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
rows={4}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 resize-none"
placeholder="Detailed description of what this plugin does..."
placeholder="Detailed description of what this automation does..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('plugins.category', 'Category')} *
{t('automations.category', 'Category')} *
</label>
<select
value={formData.category}
onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value as PluginCategory }))}
onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value as AutomationCategory }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500"
>
{Object.entries(categoryDescriptions).map(([key, desc]) => (
@@ -321,7 +321,7 @@ const CreatePlugin: React.FC = () => {
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('plugins.version', 'Version')}
{t('automations.version', 'Version')}
</label>
<input
type="text"
@@ -335,7 +335,7 @@ const CreatePlugin: React.FC = () => {
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<Image size={16} className="inline mr-1" />
{t('plugins.logoUrl', 'Logo URL')} ({t('common.optional', 'optional')})
{t('automations.logoUrl', 'Logo URL')} ({t('common.optional', 'optional')})
</label>
<input
type="url"
@@ -351,7 +351,7 @@ const CreatePlugin: React.FC = () => {
{/* Visibility */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{t('plugins.visibility', 'Visibility')}
{t('automations.visibility', 'Visibility')}
</h3>
<div className="space-y-3">
@@ -368,11 +368,11 @@ const CreatePlugin: React.FC = () => {
<div className="flex items-center gap-2">
<EyeOff size={16} className="text-gray-500" />
<span className="font-medium text-gray-900 dark:text-white">
{t('plugins.private', 'Private')}
{t('automations.private', 'Private')}
</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('plugins.privateDescription', 'Only you can see and use this plugin')}
{t('automations.privateDescription', 'Only you can see and use this automation')}
</p>
</div>
</label>
@@ -390,11 +390,11 @@ const CreatePlugin: React.FC = () => {
<div className="flex items-center gap-2">
<Eye size={16} className="text-green-500" />
<span className="font-medium text-gray-900 dark:text-white">
{t('plugins.public', 'Public (Marketplace)')}
{t('automations.public', 'Public (Marketplace)')}
</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('plugins.publicDescription', 'Submit for review to be listed in the marketplace')}
{t('automations.publicDescription', 'Submit for review to be listed in the marketplace')}
</p>
</div>
</label>
@@ -405,7 +405,7 @@ const CreatePlugin: React.FC = () => {
<div className="flex items-start gap-2">
<Info size={16} className="text-blue-500 mt-0.5 flex-shrink-0" />
<p className="text-sm text-blue-800 dark:text-blue-200">
{t('plugins.publicNote', 'Public plugins require approval before appearing in the marketplace. Our team will review your code for security and quality.')}
{t('automations.publicNote', 'Public automations require approval before appearing in the marketplace. Our team will review your code for security and quality.')}
</p>
</div>
</div>
@@ -416,7 +416,7 @@ const CreatePlugin: React.FC = () => {
{extractedVariables.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{t('plugins.templateVariables', 'Detected Variables')}
{t('automations.templateVariables', 'Detected Variables')}
</h3>
<div className="space-y-2">
{extractedVariables.map((v, idx) => (
@@ -451,7 +451,7 @@ const CreatePlugin: React.FC = () => {
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Code size={20} className="text-brand-500" />
{t('plugins.pluginCode', 'Plugin Code')}
{t('automations.automationCode', 'Automation Code')}
</h3>
<button
type="button"
@@ -459,7 +459,7 @@ const CreatePlugin: React.FC = () => {
className="flex items-center gap-2 text-sm text-brand-600 dark:text-brand-400 hover:underline"
>
{showPreview ? <EyeOff size={16} /> : <Eye size={16} />}
{showPreview ? t('plugins.hidePreview', 'Hide Preview') : t('plugins.showPreview', 'Show Preview')}
{showPreview ? t('automations.hidePreview', 'Hide Preview') : t('automations.showPreview', 'Show Preview')}
</button>
</div>
@@ -475,16 +475,16 @@ const CreatePlugin: React.FC = () => {
}}
showLineNumbers
>
{formData.pluginCode}
{formData.automationCode}
</SyntaxHighlighter>
</div>
) : (
<textarea
value={formData.pluginCode}
value={formData.automationCode}
onChange={handleCodeChange}
rows={25}
className="w-full px-4 py-4 bg-[#1e1e1e] text-gray-100 font-mono text-sm focus:outline-none resize-none"
placeholder="# Write your plugin code here..."
placeholder="# Write your automation code here..."
spellCheck={false}
/>
)}
@@ -493,7 +493,7 @@ const CreatePlugin: React.FC = () => {
{/* Quick Reference */}
<div className="bg-gradient-to-r from-brand-50 to-indigo-50 dark:from-brand-900/20 dark:to-indigo-900/20 rounded-xl border border-brand-200 dark:border-brand-800 p-6">
<h4 className="font-semibold text-gray-900 dark:text-white mb-3">
{t('plugins.quickReference', 'Quick Reference')}
{t('automations.quickReference', 'Quick Reference')}
</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div>
@@ -525,7 +525,7 @@ const CreatePlugin: React.FC = () => {
<div className="flex items-center gap-2">
<AlertTriangle className="text-red-500" size={20} />
<p className="text-red-800 dark:text-red-200">
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to create plugin'}
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to create automation'}
</p>
</div>
</div>
@@ -535,7 +535,7 @@ const CreatePlugin: React.FC = () => {
<div className="flex items-center justify-end gap-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={() => navigate('/dashboard/plugins/my-plugins')}
onClick={() => navigate('/dashboard/automations/my-automations')}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
{t('common.cancel', 'Cancel')}
@@ -548,14 +548,14 @@ const CreatePlugin: React.FC = () => {
{createMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
{t('plugins.creating', 'Creating...')}
{t('automations.creating', 'Creating...')}
</>
) : (
<>
<Save size={18} />
{formData.visibility === 'PUBLIC'
? t('plugins.createAndSubmit', 'Create & Submit for Review')
: t('plugins.createPlugin', 'Create Plugin')}
? t('automations.createAndSubmit', 'Create & Submit for Review')
: t('automations.createAutomation', 'Create Automation')}
</>
)}
</button>
@@ -565,4 +565,4 @@ const CreatePlugin: React.FC = () => {
);
};
export default CreatePlugin;
export default CreateAutomation;

View File

@@ -27,13 +27,13 @@ import {
} from 'lucide-react';
import { Link } from 'react-router-dom';
import api from '../api/client';
import { PluginInstallation, PluginCategory } from '../types';
import { AutomationInstallation, AutomationCategory } from '../types';
import EmailTemplateSelector from '../components/EmailTemplateSelector';
import { usePlanFeatures } from '../hooks/usePlanFeatures';
import { LockedSection } from '../components/UpgradePrompt';
// Category icon mapping
const categoryIcons: Record<PluginCategory, React.ReactNode> = {
const categoryIcons: Record<AutomationCategory, React.ReactNode> = {
EMAIL: <Mail className="h-4 w-4" />,
REPORTS: <BarChart3 className="h-4 w-4" />,
CUSTOMER: <Users className="h-4 w-4" />,
@@ -44,7 +44,7 @@ const categoryIcons: Record<PluginCategory, React.ReactNode> = {
};
// Category colors
const categoryColors: Record<PluginCategory, string> = {
const categoryColors: Record<AutomationCategory, string> = {
EMAIL: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
REPORTS: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
CUSTOMER: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
@@ -54,11 +54,11 @@ const categoryColors: Record<PluginCategory, string> = {
OTHER: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
};
const MyPlugins: React.FC = () => {
const MyAutomations: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [selectedPlugin, setSelectedPlugin] = useState<PluginInstallation | null>(null);
const [selectedAutomation, setSelectedAutomation] = useState<AutomationInstallation | null>(null);
const [showUninstallModal, setShowUninstallModal] = useState(false);
const [showRatingModal, setShowRatingModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
@@ -68,124 +68,124 @@ const MyPlugins: React.FC = () => {
// Check plan permissions
const { canUse, isLoading: permissionsLoading } = usePlanFeatures();
const hasPluginsFeature = canUse('plugins');
const canCreatePlugins = canUse('can_create_plugins');
const isLocked = !hasPluginsFeature;
const hasAutomationsFeature = canUse('automations');
const canCreateAutomations = canUse('can_create_automations');
const isLocked = !hasAutomationsFeature;
// Fetch installed plugins - only when user has the feature
const { data: plugins = [], isLoading, error } = useQuery<PluginInstallation[]>({
queryKey: ['plugin-installations'],
// Fetch installed automations - only when user has the feature
const { data: automations = [], isLoading, error } = useQuery<AutomationInstallation[]>({
queryKey: ['automation-installations'],
queryFn: async () => {
const { data } = await api.get('/plugin-installations/');
return data.map((p: any) => ({
id: String(p.id),
template: String(p.template),
templateName: p.template_name || p.templateName,
templateDescription: p.template_description || p.templateDescription,
category: p.category,
version: p.version,
authorName: p.author_name || p.authorName,
logoUrl: p.logo_url || p.logoUrl,
templateVariables: p.template_variables || p.templateVariables || {},
configValues: p.config_values || p.configValues || {},
isActive: p.is_active !== undefined ? p.is_active : p.isActive,
installedAt: p.installed_at || p.installedAt,
hasUpdate: p.has_update !== undefined ? p.has_update : p.hasUpdate || false,
rating: p.rating,
review: p.review,
const { data } = await api.get('/automation-installations/');
return data.map((a: any) => ({
id: String(a.id),
template: String(a.template),
templateName: a.template_name || a.templateName,
templateDescription: a.template_description || a.templateDescription,
category: a.category,
version: a.version,
authorName: a.author_name || a.authorName,
logoUrl: a.logo_url || a.logoUrl,
templateVariables: a.template_variables || a.templateVariables || {},
configValues: a.config_values || a.configValues || {},
isActive: a.is_active !== undefined ? a.is_active : a.isActive,
installedAt: a.installed_at || a.installedAt,
hasUpdate: a.has_update !== undefined ? a.has_update : a.hasUpdate || false,
rating: a.rating,
review: a.review,
}));
},
// Don't fetch if user doesn't have the plugins feature
enabled: hasPluginsFeature && !permissionsLoading,
// Don't fetch if user doesn't have the automations feature
enabled: hasAutomationsFeature && !permissionsLoading,
});
// Uninstall plugin mutation
// Uninstall automation mutation
const uninstallMutation = useMutation({
mutationFn: async (pluginId: string) => {
await api.delete(`/plugin-installations/${pluginId}/`);
mutationFn: async (automationId: string) => {
await api.delete(`/automation-installations/${automationId}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['plugin-installations'] });
queryClient.invalidateQueries({ queryKey: ['automation-installations'] });
setShowUninstallModal(false);
setSelectedPlugin(null);
setSelectedAutomation(null);
},
});
// Rate plugin mutation
// Rate automation mutation
const rateMutation = useMutation({
mutationFn: async ({ pluginId, rating, review }: { pluginId: string; rating: number; review: string }) => {
const { data } = await api.post(`/plugin-installations/${pluginId}/rate/`, {
mutationFn: async ({ automationId, rating, review }: { automationId: string; rating: number; review: string }) => {
const { data } = await api.post(`/automation-installations/${automationId}/rate/`, {
rating,
review,
});
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['plugin-installations'] });
queryClient.invalidateQueries({ queryKey: ['automation-installations'] });
setShowRatingModal(false);
setSelectedPlugin(null);
setSelectedAutomation(null);
setRating(0);
setReview('');
},
});
// Update plugin mutation
// Update automation mutation
const updateMutation = useMutation({
mutationFn: async (pluginId: string) => {
const { data } = await api.post(`/plugin-installations/${pluginId}/update/`);
mutationFn: async (automationId: string) => {
const { data } = await api.post(`/automation-installations/${automationId}/update/`);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['plugin-installations'] });
queryClient.invalidateQueries({ queryKey: ['automation-installations'] });
},
});
// Edit config mutation
const editConfigMutation = useMutation({
mutationFn: async ({ pluginId, configValues }: { pluginId: string; configValues: Record<string, any> }) => {
const { data } = await api.patch(`/plugin-installations/${pluginId}/`, {
mutationFn: async ({ automationId, configValues }: { automationId: string; configValues: Record<string, any> }) => {
const { data } = await api.patch(`/automation-installations/${automationId}/`, {
config_values: configValues,
});
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['plugin-installations'] });
queryClient.invalidateQueries({ queryKey: ['automation-installations'] });
setShowEditModal(false);
setSelectedPlugin(null);
setSelectedAutomation(null);
setConfigValues({});
},
});
const handleUninstall = (plugin: PluginInstallation) => {
setSelectedPlugin(plugin);
const handleUninstall = (automation: AutomationInstallation) => {
setSelectedAutomation(automation);
setShowUninstallModal(true);
};
const confirmUninstall = () => {
if (selectedPlugin) {
uninstallMutation.mutate(selectedPlugin.id);
if (selectedAutomation) {
uninstallMutation.mutate(selectedAutomation.id);
}
};
const handleRating = (plugin: PluginInstallation) => {
setSelectedPlugin(plugin);
setRating(plugin.rating || 0);
setReview(plugin.review || '');
const handleRating = (automation: AutomationInstallation) => {
setSelectedAutomation(automation);
setRating(automation.rating || 0);
setReview(automation.review || '');
setShowRatingModal(true);
};
const submitRating = () => {
if (selectedPlugin && rating > 0) {
if (selectedAutomation && rating > 0) {
rateMutation.mutate({
pluginId: selectedPlugin.id,
automationId: selectedAutomation.id,
rating,
review,
});
}
};
const handleUpdate = (plugin: PluginInstallation) => {
updateMutation.mutate(plugin.id);
const handleUpdate = (automation: AutomationInstallation) => {
updateMutation.mutate(automation.id);
};
const unescapeString = (str: string): string => {
@@ -198,12 +198,12 @@ const MyPlugins: React.FC = () => {
.replace(/\\\\/g, '\\');
};
const handleEdit = (plugin: PluginInstallation) => {
setSelectedPlugin(plugin);
const handleEdit = (automation: AutomationInstallation) => {
setSelectedAutomation(automation);
// Convert escape sequences to actual characters for display
const displayValues = { ...plugin.configValues || {} };
if (plugin.templateVariables) {
Object.entries(plugin.templateVariables).forEach(([key, variable]: [string, any]) => {
const displayValues = { ...automation.configValues || {} };
if (automation.templateVariables) {
Object.entries(automation.templateVariables).forEach(([key, variable]: [string, any]) => {
if (displayValues[key]) {
displayValues[key] = unescapeString(displayValues[key]);
}
@@ -224,18 +224,18 @@ const MyPlugins: React.FC = () => {
};
const submitConfigEdit = () => {
if (selectedPlugin) {
if (selectedAutomation) {
// Convert actual characters back to escape sequences for storage
const storageValues = { ...configValues };
if (selectedPlugin.templateVariables) {
Object.entries(selectedPlugin.templateVariables).forEach(([key, variable]: [string, any]) => {
if (selectedAutomation.templateVariables) {
Object.entries(selectedAutomation.templateVariables).forEach(([key, variable]: [string, any]) => {
if (storageValues[key]) {
storageValues[key] = escapeString(storageValues[key]);
}
});
}
editConfigMutation.mutate({
pluginId: selectedPlugin.id,
automationId: selectedAutomation.id,
configValues: storageValues,
});
}
@@ -270,59 +270,59 @@ const MyPlugins: React.FC = () => {
const effectivelyLocked = isLocked || is403Error;
return (
<LockedSection feature="plugins" isLocked={effectivelyLocked} variant="overlay">
<LockedSection feature="automations" isLocked={effectivelyLocked} variant="overlay">
<div className="p-8 space-y-6 max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<Package className="h-7 w-7 text-brand-600" />
{t('plugins.myPlugins', 'My Plugins')}
{t('automations.myAutomations', 'My Automations')}
</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">
{t('plugins.myPluginsDescription', 'Manage your installed plugins')}
{t('automations.myAutomationsDescription', 'Manage your installed automations')}
</p>
</div>
<button
onClick={() => navigate('/dashboard/plugins/marketplace')}
onClick={() => navigate('/dashboard/automations/marketplace')}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium"
>
<Plus className="h-5 w-5" />
{t('plugins.browseMarketplace', 'Browse Marketplace')}
{t('automations.browseMarketplace', 'Browse Marketplace')}
</button>
</div>
{/* Plugin List */}
{plugins.length === 0 ? (
{/* Automation List */}
{automations.length === 0 ? (
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<Package className="h-12 w-12 mx-auto text-gray-400 mb-4" />
<p className="text-gray-500 dark:text-gray-400 mb-4">
{t('plugins.noPluginsInstalled', 'No plugins installed yet')}
{t('automations.noAutomationsInstalled', 'No automations installed yet')}
</p>
<button
onClick={() => navigate('/dashboard/plugins/marketplace')}
onClick={() => navigate('/dashboard/automations/marketplace')}
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium"
>
<Plus className="h-4 w-4" />
{t('plugins.browseMarketplace', 'Browse Marketplace')}
{t('automations.browseMarketplace', 'Browse Marketplace')}
</button>
</div>
) : (
<div className="space-y-4">
{plugins.map((plugin) => (
{automations.map((item) => (
<div
key={plugin.id}
key={item.id}
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow overflow-hidden"
>
<div className="p-6">
<div className="flex items-start justify-between">
{/* Plugin Info */}
{/* Automation Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2">
{plugin.logoUrl && (
{item.logoUrl && (
<img
src={plugin.logoUrl}
alt={`${plugin.templateName} logo`}
src={item.logoUrl}
alt={`${item.templateName} logo`}
className="w-10 h-10 rounded-lg object-cover flex-shrink-0"
onError={(e) => {
// Hide image if it fails to load
@@ -331,13 +331,13 @@ const MyPlugins: React.FC = () => {
/>
)}
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{plugin.templateName}
{item.templateName}
</h3>
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium ${categoryColors[plugin.category]}`}>
{categoryIcons[plugin.category]}
{plugin.category}
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium ${categoryColors[item.category]}`}>
{categoryIcons[item.category]}
{item.category}
</span>
{plugin.hasUpdate && (
{item.hasUpdate && (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
<RefreshCw className="h-3 w-3" />
Update Available
@@ -346,53 +346,53 @@ const MyPlugins: React.FC = () => {
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
{plugin.templateDescription}
{item.templateDescription}
</p>
<div className="flex items-center gap-6 text-sm text-gray-500 dark:text-gray-400">
{plugin.authorName && (
{item.authorName && (
<div className="flex items-center gap-1">
<span className="font-medium">
{t('plugins.author', 'Author')}:
{t('automations.author', 'Author')}:
</span>
<span>{plugin.authorName}</span>
<span>{item.authorName}</span>
</div>
)}
<div className="flex items-center gap-1">
<span className="font-medium">
{t('plugins.version', 'Version')}:
{t('automations.version', 'Version')}:
</span>
<span>{plugin.version}</span>
<span>{item.version}</span>
</div>
<div className="flex items-center gap-1">
<span className="font-medium">
{t('plugins.installedOn', 'Installed on')}:
{t('automations.installedOn', 'Installed on')}:
</span>
<span>
{new Date(plugin.installedAt).toLocaleDateString()}
{new Date(item.installedAt).toLocaleDateString()}
</span>
</div>
<div className="flex items-center gap-1">
{plugin.isActive ? (
{item.isActive ? (
<>
<CheckCircle className="h-4 w-4 text-green-500" />
<span className="text-green-600 dark:text-green-400 font-medium">
{t('plugins.active', 'Active')}
{t('automations.active', 'Active')}
</span>
</>
) : (
<>
<XCircle className="h-4 w-4 text-gray-400" />
<span className="text-gray-500 dark:text-gray-400">
{t('plugins.inactive', 'Inactive')}
{t('automations.inactive', 'Inactive')}
</span>
</>
)}
</div>
{plugin.rating && (
{item.rating && (
<div className="flex items-center gap-1">
<Star className="h-4 w-4 text-amber-500 fill-amber-500" />
<span className="font-medium">{plugin.rating}/5</span>
<span className="font-medium">{item.rating}/5</span>
</div>
)}
</div>
@@ -402,58 +402,58 @@ const MyPlugins: React.FC = () => {
<div className="flex items-center gap-2 ml-4">
{/* Configure button */}
<button
onClick={() => handleEdit(plugin)}
onClick={() => handleEdit(item)}
className="flex items-center gap-2 px-3 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors text-sm font-medium"
title={t('plugins.configure', 'Configure')}
title={t('automations.configure', 'Configure')}
>
<Settings className="h-4 w-4" />
{t('plugins.configure', 'Configure')}
{t('automations.configure', 'Configure')}
</button>
{/* Schedule button - only if not already scheduled */}
{!plugin.scheduledTaskId && (
{!item.scheduledTaskId && (
<button
onClick={() => navigate('/dashboard/tasks')}
className="flex items-center gap-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
title={t('plugins.schedule', 'Schedule')}
title={t('automations.schedule', 'Schedule')}
>
<Clock className="h-4 w-4" />
{t('plugins.schedule', 'Schedule')}
{t('automations.schedule', 'Schedule')}
</button>
)}
{/* Already scheduled indicator */}
{plugin.scheduledTaskId && (
{item.scheduledTaskId && (
<span className="flex items-center gap-1.5 px-3 py-2 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-lg text-sm font-medium">
<Clock className="h-4 w-4" />
{t('plugins.scheduled', 'Scheduled')}
{t('automations.scheduled', 'Scheduled')}
</span>
)}
{plugin.hasUpdate && (
{item.hasUpdate && (
<button
onClick={() => handleUpdate(plugin)}
onClick={() => handleUpdate(item)}
disabled={updateMutation.isPending}
className="flex items-center gap-2 px-3 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium"
title={t('plugins.update', 'Update')}
title={t('automations.update', 'Update')}
>
{updateMutation.isPending ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
) : (
<RefreshCw className="h-4 w-4" />
)}
{t('plugins.update', 'Update')}
{t('automations.update', 'Update')}
</button>
)}
<button
onClick={() => handleRating(plugin)}
onClick={() => handleRating(item)}
className="flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-sm font-medium"
title={plugin.rating ? t('plugins.editRating', 'Edit Rating') : t('plugins.rate', 'Rate')}
title={item.rating ? t('automations.editRating', 'Edit Rating') : t('automations.rate', 'Rate')}
>
<Star className={`h-4 w-4 ${plugin.rating ? 'fill-amber-500 text-amber-500' : ''}`} />
{plugin.rating ? t('plugins.editRating', 'Edit Rating') : t('plugins.rate', 'Rate')}
<Star className={`h-4 w-4 ${item.rating ? 'fill-amber-500 text-amber-500' : ''}`} />
{item.rating ? t('automations.editRating', 'Edit Rating') : t('automations.rate', 'Rate')}
</button>
<button
onClick={() => handleUninstall(plugin)}
onClick={() => handleUninstall(item)}
className="p-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
title={t('plugins.uninstall', 'Uninstall')}
title={t('automations.uninstall', 'Uninstall')}
>
<Trash2 className="h-5 w-5" />
</button>
@@ -467,17 +467,17 @@ const MyPlugins: React.FC = () => {
{/* Info Box */}
<div className={`rounded-xl p-6 border ${
canCreatePlugins
canCreateAutomations
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
: 'bg-amber-50 dark:bg-amber-900/20 border-amber-300 dark:border-amber-700'
}`}>
<div className="flex items-start gap-4">
<div className={`shrink-0 p-2 rounded-lg ${
canCreatePlugins
canCreateAutomations
? 'bg-blue-100 dark:bg-blue-900/40'
: 'bg-gradient-to-br from-amber-400 to-orange-500'
}`}>
{canCreatePlugins ? (
{canCreateAutomations ? (
<Package className="h-6 w-6 text-blue-600 dark:text-blue-400" />
) : (
<Crown className="h-6 w-6 text-white" />
@@ -486,13 +486,13 @@ const MyPlugins: React.FC = () => {
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h3 className={`text-lg font-semibold ${
canCreatePlugins
canCreateAutomations
? 'text-blue-900 dark:text-blue-100'
: 'text-gray-900 dark:text-gray-100'
}`}>
{t('plugins.needCustomPlugin', 'Need a custom plugin?')}
{t('automations.needCustomAutomation', 'Need a custom automation?')}
</h3>
{!canCreatePlugins && (
{!canCreateAutomations && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
<Lock className="h-3 w-3" />
{t('common.upgradeRequired', 'Upgrade Required')}
@@ -500,22 +500,22 @@ const MyPlugins: React.FC = () => {
)}
</div>
<p className={`mb-4 ${
canCreatePlugins
canCreateAutomations
? 'text-blue-800 dark:text-blue-200'
: 'text-gray-600 dark:text-gray-400'
}`}>
{canCreatePlugins
? t('plugins.customPluginDescription', 'Create your own custom plugins to extend your business functionality with specific features tailored to your needs.')
: t('plugins.customPluginUpgradeDescription', 'Custom plugins allow you to create automated workflows tailored to your business needs. Upgrade your plan to unlock this feature.')
{canCreateAutomations
? t('automations.customAutomationDescription', 'Create your own custom automations to extend your business functionality with specific features tailored to your needs.')
: t('automations.customAutomationUpgradeDescription', 'Custom automations allow you to create automated workflows tailored to your business needs. Upgrade your plan to unlock this feature.')
}
</p>
{canCreatePlugins ? (
{canCreateAutomations ? (
<button
onClick={() => navigate('/dashboard/plugins/create')}
onClick={() => navigate('/dashboard/automations/create')}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
<Plus className="h-4 w-4" />
{t('plugins.createCustomPlugin', 'Create Custom Plugin')}
{t('automations.createCustomAutomation', 'Create Custom Automation')}
</button>
) : (
<Link
@@ -532,7 +532,7 @@ const MyPlugins: React.FC = () => {
</div>
{/* Uninstall Confirmation Modal */}
{showUninstallModal && selectedPlugin && (
{showUninstallModal && selectedAutomation && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full overflow-hidden">
{/* Modal Header */}
@@ -542,13 +542,13 @@ const MyPlugins: React.FC = () => {
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('plugins.confirmUninstall', 'Confirm Uninstall')}
{t('automations.confirmUninstall', 'Confirm Uninstall')}
</h3>
</div>
<button
onClick={() => {
setShowUninstallModal(false);
setSelectedPlugin(null);
setSelectedAutomation(null);
}}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
@@ -559,10 +559,10 @@ const MyPlugins: React.FC = () => {
{/* Modal Body */}
<div className="p-6">
<p className="text-gray-600 dark:text-gray-400 mb-4">
{t('plugins.uninstallWarning', 'Are you sure you want to uninstall')} <span className="font-semibold text-gray-900 dark:text-white">{selectedPlugin.templateName}</span>?
{t('automations.uninstallWarning', 'Are you sure you want to uninstall')} <span className="font-semibold text-gray-900 dark:text-white">{selectedAutomation.templateName}</span>?
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('plugins.uninstallNote', 'This action cannot be undone. Your plugin data and settings will be removed.')}
{t('automations.uninstallNote', 'This action cannot be undone. Your automation data and settings will be removed.')}
</p>
</div>
@@ -571,7 +571,7 @@ const MyPlugins: React.FC = () => {
<button
onClick={() => {
setShowUninstallModal(false);
setSelectedPlugin(null);
setSelectedAutomation(null);
}}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors font-medium"
>
@@ -585,12 +585,12 @@ const MyPlugins: React.FC = () => {
{uninstallMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
{t('plugins.uninstalling', 'Uninstalling...')}
{t('automations.uninstalling', 'Uninstalling...')}
</>
) : (
<>
<Trash2 className="h-4 w-4" />
{t('plugins.uninstall', 'Uninstall')}
{t('automations.uninstall', 'Uninstall')}
</>
)}
</button>
@@ -600,18 +600,18 @@ const MyPlugins: React.FC = () => {
)}
{/* Rating Modal */}
{showRatingModal && selectedPlugin && (
{showRatingModal && selectedAutomation && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full overflow-hidden">
{/* Modal Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('plugins.ratePlugin', 'Rate Plugin')}
{t('automations.rateAutomation', 'Rate Automation')}
</h3>
<button
onClick={() => {
setShowRatingModal(false);
setSelectedPlugin(null);
setSelectedAutomation(null);
setRating(0);
setReview('');
}}
@@ -625,17 +625,17 @@ const MyPlugins: React.FC = () => {
<div className="p-6 space-y-4">
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-2">
{selectedPlugin.templateName}
{selectedAutomation.templateName}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('plugins.rateDescription', 'Share your experience with this plugin')}
{t('automations.rateDescription', 'Share your experience with this automation')}
</p>
</div>
{/* Star Rating */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('plugins.yourRating', 'Your Rating')} *
{t('automations.yourRating', 'Your Rating')} *
</label>
<div className="flex items-center gap-2">
{[1, 2, 3, 4, 5].map((star) => (
@@ -660,14 +660,14 @@ const MyPlugins: React.FC = () => {
{/* Review */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('plugins.review', 'Review')} ({t('plugins.optional', 'optional')})
{t('automations.review', 'Review')} ({t('automations.optional', 'optional')})
</label>
<textarea
value={review}
onChange={(e) => setReview(e.target.value)}
rows={4}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 resize-none"
placeholder={t('plugins.reviewPlaceholder', 'Share your thoughts about this plugin...')}
placeholder={t('automations.reviewPlaceholder', 'Share your thoughts about this automation...')}
/>
</div>
</div>
@@ -677,7 +677,7 @@ const MyPlugins: React.FC = () => {
<button
onClick={() => {
setShowRatingModal(false);
setSelectedPlugin(null);
setSelectedAutomation(null);
setRating(0);
setReview('');
}}
@@ -693,12 +693,12 @@ const MyPlugins: React.FC = () => {
{rateMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
{t('plugins.submitting', 'Submitting...')}
{t('automations.submitting', 'Submitting...')}
</>
) : (
<>
<Star className="h-4 w-4" />
{t('plugins.submitRating', 'Submit Rating')}
{t('automations.submitRating', 'Submit Rating')}
</>
)}
</button>
@@ -708,18 +708,18 @@ const MyPlugins: React.FC = () => {
)}
{/* Edit Config Modal */}
{showEditModal && selectedPlugin && (
{showEditModal && selectedAutomation && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-2xl w-full overflow-hidden max-h-[80vh] flex flex-col">
{/* Modal Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('plugins.editConfig', 'Edit Plugin Configuration')}
{t('automations.editConfig', 'Edit Automation Configuration')}
</h3>
<button
onClick={() => {
setShowEditModal(false);
setSelectedPlugin(null);
setSelectedAutomation(null);
setConfigValues({});
}}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
@@ -732,17 +732,17 @@ const MyPlugins: React.FC = () => {
<div className="p-6 overflow-y-auto flex-1">
<div className="mb-4">
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-2">
{selectedPlugin.templateName}
{selectedAutomation.templateName}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
{selectedPlugin.templateDescription}
{selectedAutomation.templateDescription}
</p>
</div>
{/* Config Fields */}
{selectedPlugin.templateVariables && Object.keys(selectedPlugin.templateVariables).length > 0 ? (
{selectedAutomation.templateVariables && Object.keys(selectedAutomation.templateVariables).length > 0 ? (
<div className="space-y-4">
{Object.entries(selectedPlugin.templateVariables).map(([key, variable]: [string, any]) => (
{Object.entries(selectedAutomation.templateVariables).map(([key, variable]: [string, any]) => (
<div key={key}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{variable.description || key}
@@ -783,7 +783,7 @@ const MyPlugins: React.FC = () => {
</div>
) : (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
{t('plugins.noConfigOptions', 'This plugin has no configuration options')}
{t('automations.noConfigOptions', 'This automation has no configuration options')}
</div>
)}
</div>
@@ -793,7 +793,7 @@ const MyPlugins: React.FC = () => {
<button
onClick={() => {
setShowEditModal(false);
setSelectedPlugin(null);
setSelectedAutomation(null);
setConfigValues({});
}}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors font-medium"
@@ -808,7 +808,7 @@ const MyPlugins: React.FC = () => {
{editConfigMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
{t('plugins.saving', 'Saving...')}
{t('automations.saving', 'Saving...')}
</>
) : (
<>
@@ -826,4 +826,4 @@ const MyPlugins: React.FC = () => {
);
};
export default MyPlugins;
export default MyAutomations;

View File

@@ -270,9 +270,10 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
const hasActiveFilters = filterStatuses.size < DEFAULT_STATUSES.length || filterResources.size > 0 || filterServices.size > 0;
// Scroll to current time on mount (centered in view)
// Scroll to current time on mount and when view mode changes (centered in view)
useEffect(() => {
if (!scrollContainerRef.current) return;
if (viewMode === 'month') return; // Month view doesn't have horizontal scrolling
const now = new Date();
const today = new Date(viewDate);
@@ -282,20 +283,39 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
// Only scroll if today is in the current view
if (viewMode === 'day' && nowDay.getTime() !== today.getTime()) return;
if (viewMode === 'week') {
// Check if today falls within the current week view (week starts on Sunday)
const weekStart = new Date(today);
weekStart.setDate(today.getDate() - today.getDay()); // Sunday
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6); // Saturday
if (nowDay < weekStart || nowDay > weekEnd) return;
}
const container = scrollContainerRef.current;
const containerWidth = container.clientWidth;
// Calculate day width (same formula as used elsewhere in component)
const calculatedDayWidth = (END_HOUR - START_HOUR) * 60 * (PIXELS_PER_MINUTE * zoomLevel);
// Calculate current time offset in pixels
const startOfDay = new Date(now);
startOfDay.setHours(START_HOUR, 0, 0, 0);
const minutesSinceStart = (now.getTime() - startOfDay.getTime()) / (1000 * 60);
const currentTimeOffset = minutesSinceStart * PIXELS_PER_MINUTE * zoomLevel;
// For week view, add the day offset
let dayOffset = 0;
if (viewMode === 'week') {
// Week starts on Sunday (day 0), so dayOfWeek directly maps to position
const dayOfWeek = now.getDay();
dayOffset = dayOfWeek * calculatedDayWidth;
}
// Scroll so current time is centered
const scrollPosition = currentTimeOffset - (containerWidth / 2);
const scrollPosition = dayOffset + currentTimeOffset - (containerWidth / 2);
container.scrollLeft = Math.max(0, scrollPosition);
}, []);
}, [viewMode, zoomLevel, viewDate]);
const addToHistory = (action: HistoryAction) => {
// Remove any history after current index (when doing new action after undo)
@@ -1905,7 +1925,7 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
className="flex-shrink-0 border-r border-gray-200 dark:border-gray-700 px-2 py-2 text-xs font-medium text-gray-400 select-none"
style={{ width: 60 * (PIXELS_PER_MINUTE * zoomLevel) }}
>
{hour > 12 ? `${hour - 12} PM` : `${hour} ${hour === 12 ? 'PM' : 'AM'}`}
{hour === 0 || hour === 24 ? '12 AM' : hour === 12 ? '12 PM' : hour > 12 ? `${hour - 12} PM` : `${hour} AM`}
</div>
))}
</div>

View File

@@ -506,7 +506,7 @@ const StaffDashboard: React.FC<StaffDashboardProps> = ({ user }) => {
{t('staffDashboard.yourWeeklyOverview', 'Your Weekly Schedule')}
</h2>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer width="100%" height="100%" minWidth={200} minHeight={150}>
<BarChart data={weeklyChartData}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#374151" strokeOpacity={0.2} />
<XAxis

View File

@@ -29,8 +29,8 @@ interface ScheduledTask {
id: string;
name: string;
description: string;
plugin_name: string;
plugin_display_name: string;
automation_name: string;
automation_display_name: string;
schedule_type: 'ONE_TIME' | 'INTERVAL' | 'CRON';
cron_expression?: string;
interval_minutes?: number;
@@ -39,12 +39,12 @@ interface ScheduledTask {
last_run_at?: string;
status: 'ACTIVE' | 'PAUSED' | 'DISABLED';
last_run_status?: string;
plugin_config: Record<string, any>;
automation_config: Record<string, any>;
created_at: string;
updated_at: string;
}
interface PluginInstallation {
interface AutomationInstallation {
id: string;
template: number;
template_name: string;
@@ -62,13 +62,13 @@ interface PluginInstallation {
has_update: boolean;
}
interface GlobalEventPlugin {
interface GlobalEventAutomation {
id: string;
plugin_installation: number;
plugin_name: string;
plugin_description: string;
plugin_category: string;
plugin_logo_url?: string;
automation_installation: number;
automation_name: string;
automation_description: string;
automation_category: string;
automation_logo_url?: string;
trigger: string;
trigger_display: string;
offset_minutes: number;
@@ -87,7 +87,7 @@ type UnifiedTask = {
data: ScheduledTask;
} | {
type: 'event';
data: GlobalEventPlugin;
data: GlobalEventAutomation;
};
const Tasks: React.FC = () => {
@@ -95,13 +95,13 @@ const Tasks: React.FC = () => {
const queryClient = useQueryClient();
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingTask, setEditingTask] = useState<ScheduledTask | null>(null);
const [editingEventAutomation, setEditingEventAutomation] = useState<GlobalEventPlugin | null>(null);
const [editingEventAutomation, setEditingEventAutomation] = useState<GlobalEventAutomation | null>(null);
// Check plan permissions - tasks requires both plugins AND tasks features
// Check plan permissions - tasks requires both automations AND tasks features
const { canUse, isLoading: permissionsLoading } = usePlanFeatures();
const hasPluginsFeature = canUse('plugins');
const hasAutomationsFeature = canUse('automations');
const hasTasksFeature = canUse('tasks');
const isLocked = !hasPluginsFeature || !hasTasksFeature;
const isLocked = !hasAutomationsFeature || !hasTasksFeature;
// Fetch scheduled tasks - only when user has the feature
const { data: scheduledTasks = [], isLoading: tasksLoading, error: tasksError } = useQuery<ScheduledTask[]>({
@@ -111,18 +111,18 @@ const Tasks: React.FC = () => {
return data;
},
// Don't fetch if user doesn't have the required features
enabled: hasPluginsFeature && hasTasksFeature && !permissionsLoading,
enabled: hasAutomationsFeature && hasTasksFeature && !permissionsLoading,
});
// Fetch global event plugins (event automations) - only when user has the feature
const { data: eventAutomations = [], isLoading: automationsLoading, error: automationsError } = useQuery<GlobalEventPlugin[]>({
queryKey: ['global-event-plugins'],
// Fetch global event automations - only when user has the feature
const { data: eventAutomations = [], isLoading: automationsLoading, error: automationsError } = useQuery<GlobalEventAutomation[]>({
queryKey: ['global-event-automations'],
queryFn: async () => {
const { data } = await axios.get('/global-event-plugins/');
const { data } = await axios.get('/global-event-automations/');
return data;
},
// Don't fetch if user doesn't have the required features
enabled: hasPluginsFeature && hasTasksFeature && !permissionsLoading,
enabled: hasAutomationsFeature && hasTasksFeature && !permissionsLoading,
});
// Check if any error is a 403 (plan restriction)
@@ -187,10 +187,10 @@ const Tasks: React.FC = () => {
// Delete event automation
const deleteEventAutomationMutation = useMutation({
mutationFn: async (automationId: string) => {
await axios.delete(`/global-event-plugins/${automationId}/`);
await axios.delete(`/global-event-automations/${automationId}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['global-event-plugins'] });
queryClient.invalidateQueries({ queryKey: ['global-event-automations'] });
toast.success('Event automation deleted');
},
onError: (error: any) => {
@@ -201,10 +201,10 @@ const Tasks: React.FC = () => {
// Toggle event automation active status
const toggleEventAutomationMutation = useMutation({
mutationFn: async (automationId: string) => {
await axios.post(`/global-event-plugins/${automationId}/toggle/`);
await axios.post(`/global-event-automations/${automationId}/toggle/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['global-event-plugins'] });
queryClient.invalidateQueries({ queryKey: ['global-event-automations'] });
toast.success('Automation updated');
},
onError: (error: any) => {
@@ -214,11 +214,11 @@ const Tasks: React.FC = () => {
// Update event automation
const updateEventAutomationMutation = useMutation({
mutationFn: async ({ id, data }: { id: string; data: Partial<GlobalEventPlugin> }) => {
await axios.patch(`/global-event-plugins/${id}/`, data);
mutationFn: async ({ id, data }: { id: string; data: Partial<GlobalEventAutomation> }) => {
await axios.patch(`/global-event-automations/${id}/`, data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['global-event-plugins'] });
queryClient.invalidateQueries({ queryKey: ['global-event-automations'] });
toast.success('Automation updated');
setEditingEventAutomation(null);
},
@@ -281,7 +281,7 @@ const Tasks: React.FC = () => {
{t('Tasks')}
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Schedule and manage automated plugin executions
Schedule and manage automated executions
</p>
</div>
<button
@@ -402,7 +402,7 @@ const Tasks: React.FC = () => {
<div className="flex flex-wrap items-center gap-4 text-sm">
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
<Zap className="w-4 h-4" />
<span>{task.plugin_display_name}</span>
<span>{task.automation_display_name}</span>
</div>
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
@@ -494,7 +494,7 @@ const Tasks: React.FC = () => {
Event Automation
</span>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{automation.plugin_name}
{automation.automation_name}
</h3>
{automation.is_active ? (
<CheckCircle2 className="w-5 h-5 text-green-600 dark:text-green-400" />
@@ -503,9 +503,9 @@ const Tasks: React.FC = () => {
)}
</div>
{automation.plugin_description && (
{automation.automation_description && (
<p className="text-gray-600 dark:text-gray-400 text-sm mb-3">
{automation.plugin_description}
{automation.automation_description}
</p>
)}
@@ -580,7 +580,7 @@ const Tasks: React.FC = () => {
onClose={() => setShowCreateModal(false)}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ['scheduled-tasks'] });
queryClient.invalidateQueries({ queryKey: ['global-event-plugins'] });
queryClient.invalidateQueries({ queryKey: ['global-event-automations'] });
}}
/>
@@ -612,10 +612,10 @@ const Tasks: React.FC = () => {
// Inline Edit Event Automation Modal component
interface EditEventAutomationModalProps {
automation: GlobalEventPlugin;
automation: GlobalEventAutomation;
isOpen: boolean;
onClose: () => void;
onSave: (data: Partial<GlobalEventPlugin>) => void;
onSave: (data: Partial<GlobalEventAutomation>) => void;
isLoading: boolean;
}
@@ -693,16 +693,16 @@ const EditEventAutomationModal: React.FC<EditEventAutomationModalProps> = ({
{/* Content */}
<div className="p-6 space-y-6">
{/* Plugin info */}
{/* Automation info */}
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg">
<div className="flex items-center gap-3">
<Zap className="w-5 h-5 text-purple-600 dark:text-purple-400" />
<div>
<p className="text-sm text-purple-800 dark:text-purple-200 font-medium">
Plugin
Automation
</p>
<p className="text-purple-900 dark:text-purple-100">
{automation.plugin_name}
{automation.automation_name}
</p>
</div>
</div>

View File

@@ -11,7 +11,7 @@ import {
} from 'lucide-react';
import Hero from '../../components/marketing/Hero';
import FeatureCard from '../../components/marketing/FeatureCard';
import PluginShowcase from '../../components/marketing/PluginShowcase';
import AutomationShowcase from '../../components/marketing/AutomationShowcase';
import BenefitsSection from '../../components/marketing/BenefitsSection';
import TestimonialCard from '../../components/marketing/TestimonialCard';
import CTASection from '../../components/marketing/CTASection';
@@ -119,8 +119,8 @@ const HomePage: React.FC = () => {
</div>
</section>
{/* Plugin Showcase - NEW */}
<PluginShowcase />
{/* Automation Showcase */}
<AutomationShowcase />
{/* Benefits Section (Replaces Stats) */}
<BenefitsSection />

View File

@@ -1,6 +1,11 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
// Helper to safely get array translations
const getItems = (value: unknown): string[] => {
return Array.isArray(value) ? value : [];
};
const PrivacyPolicyPage: React.FC = () => {
const { t } = useTranslation();
@@ -35,7 +40,7 @@ const PrivacyPolicyPage: React.FC = () => {
{t('marketing.privacyPolicy.section2.subsection1.intro')}
</p>
<ul className="list-disc pl-6 text-gray-600 dark:text-gray-400 mb-6">
{(t('marketing.privacyPolicy.section2.subsection1.items', { returnObjects: true }) as string[]).map((item, index) => (
{getItems(t('marketing.privacyPolicy.section2.subsection1.items', { returnObjects: true })).map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
@@ -45,7 +50,7 @@ const PrivacyPolicyPage: React.FC = () => {
{t('marketing.privacyPolicy.section2.subsection2.intro')}
</p>
<ul className="list-disc pl-6 text-gray-600 dark:text-gray-400 mb-6">
{(t('marketing.privacyPolicy.section2.subsection2.items', { returnObjects: true }) as string[]).map((item, index) => (
{getItems(t('marketing.privacyPolicy.section2.subsection2.items', { returnObjects: true })).map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
@@ -55,7 +60,7 @@ const PrivacyPolicyPage: React.FC = () => {
{t('marketing.privacyPolicy.section3.intro')}
</p>
<ul className="list-disc pl-6 text-gray-600 dark:text-gray-400 mb-6">
{(t('marketing.privacyPolicy.section3.items', { returnObjects: true }) as string[]).map((item, index) => (
{getItems(t('marketing.privacyPolicy.section3.items', { returnObjects: true })).map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
@@ -64,14 +69,15 @@ const PrivacyPolicyPage: React.FC = () => {
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mt-6 mb-3">{t('marketing.privacyPolicy.section4.subsection1.title')}</h3>
<ul className="list-disc pl-6 text-gray-600 dark:text-gray-400 mb-6">
{(t('marketing.privacyPolicy.section4.subsection1.items', { returnObjects: true }) as string[]).map((item, index) => (
{getItems(t('marketing.privacyPolicy.section4.subsection1.items', { returnObjects: true })).map((item, index) => (
// Content from translation files (trusted source, not user input)
<li key={index} dangerouslySetInnerHTML={{ __html: item }} />
))}
</ul>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mt-6 mb-3">{t('marketing.privacyPolicy.section4.subsection2.title')}</h3>
<ul className="list-disc pl-6 text-gray-600 dark:text-gray-400 mb-6">
{(t('marketing.privacyPolicy.section4.subsection2.items', { returnObjects: true }) as string[]).map((item, index) => (
{getItems(t('marketing.privacyPolicy.section4.subsection2.items', { returnObjects: true })).map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
@@ -81,7 +87,7 @@ const PrivacyPolicyPage: React.FC = () => {
{t('marketing.privacyPolicy.section5.intro')}
</p>
<ul className="list-disc pl-6 text-gray-600 dark:text-gray-400 mb-6">
{(t('marketing.privacyPolicy.section5.items', { returnObjects: true }) as string[]).map((item, index) => (
{getItems(t('marketing.privacyPolicy.section5.items', { returnObjects: true })).map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
@@ -99,7 +105,8 @@ const PrivacyPolicyPage: React.FC = () => {
{t('marketing.privacyPolicy.section7.intro')}
</p>
<ul className="list-disc pl-6 text-gray-600 dark:text-gray-400 mb-6">
{(t('marketing.privacyPolicy.section7.items', { returnObjects: true }) as string[]).map((item, index) => (
{getItems(t('marketing.privacyPolicy.section7.items', { returnObjects: true })).map((item, index) => (
// Content from translation files (trusted source, not user input)
<li key={index} dangerouslySetInnerHTML={{ __html: item }} />
))}
</ul>
@@ -112,7 +119,7 @@ const PrivacyPolicyPage: React.FC = () => {
{t('marketing.privacyPolicy.section8.intro')}
</p>
<ul className="list-disc pl-6 text-gray-600 dark:text-gray-400 mb-6">
{(t('marketing.privacyPolicy.section8.items', { returnObjects: true }) as string[]).map((item, index) => (
{getItems(t('marketing.privacyPolicy.section8.items', { returnObjects: true })).map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>

View File

@@ -31,8 +31,8 @@ vi.mock('../../../components/marketing/FeatureCard', () => ({
),
}));
vi.mock('../../../components/marketing/PluginShowcase', () => ({
default: () => <div data-testid="plugin-showcase">Plugin Showcase Component</div>,
vi.mock('../../../components/marketing/AutomationShowcase', () => ({
default: () => <div data-testid="automation-showcase">Automation Showcase Component</div>,
}));
vi.mock('../../../components/marketing/BenefitsSection', () => ({
@@ -225,13 +225,13 @@ describe('HomePage', () => {
});
});
describe('Plugin Showcase Section', () => {
it('should render the plugin showcase section', () => {
describe('Automation Showcase Section', () => {
it('should render the automation showcase section', () => {
renderHomePage();
const pluginShowcase = screen.getByTestId('plugin-showcase');
expect(pluginShowcase).toBeInTheDocument();
expect(pluginShowcase).toHaveTextContent('Plugin Showcase Component');
const automationShowcase = screen.getByTestId('automation-showcase');
expect(automationShowcase).toBeInTheDocument();
expect(automationShowcase).toHaveTextContent('Automation Showcase Component');
});
});
@@ -341,13 +341,13 @@ describe('HomePage', () => {
renderHomePage();
const hero = screen.getByTestId('hero-section');
const pluginShowcase = screen.getByTestId('plugin-showcase');
const automationShowcase = screen.getByTestId('automation-showcase');
const benefits = screen.getByTestId('benefits-section');
const cta = screen.getByTestId('cta-section');
// All sections should be present
expect(hero).toBeInTheDocument();
expect(pluginShowcase).toBeInTheDocument();
expect(automationShowcase).toBeInTheDocument();
expect(benefits).toBeInTheDocument();
expect(cta).toBeInTheDocument();
});
@@ -541,10 +541,10 @@ describe('HomePage', () => {
expect(featureCards).toHaveLength(7);
});
it('should integrate PluginShowcase component', () => {
it('should integrate AutomationShowcase component', () => {
renderHomePage();
expect(screen.getByTestId('plugin-showcase')).toBeInTheDocument();
expect(screen.getByTestId('automation-showcase')).toBeInTheDocument();
});
it('should integrate BenefitsSection component', () => {

View File

@@ -64,7 +64,7 @@ const PlatformDashboard: React.FC = () => {
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">{t('platform.mrrGrowth')}</h3>
<div className="h-80 min-h-[320px]">
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer width="100%" height="100%" minWidth={200} minHeight={200}>
<AreaChart data={data}>
<defs>
<linearGradient id="colorMrr" x1="0" y1="0" x2="0" y2="1">

View File

@@ -438,19 +438,19 @@ export interface BusinessOAuthCredentialsResponse {
useCustomCredentials: boolean;
}
// --- Plugin Types ---
// --- Automation Types ---
export type PluginCategory = 'EMAIL' | 'REPORTS' | 'CUSTOMER' | 'BOOKING' | 'INTEGRATION' | 'AUTOMATION' | 'OTHER';
export type AutomationCategory = 'EMAIL' | 'REPORTS' | 'CUSTOMER' | 'BOOKING' | 'INTEGRATION' | 'AUTOMATION' | 'OTHER';
export interface PluginTemplate {
export interface AutomationTemplate {
id: string;
name: string;
description: string;
category: PluginCategory;
category: AutomationCategory;
version: string;
author: string;
logoUrl?: string;
pluginCode?: string;
automationCode?: string;
rating: number;
ratingCount: number;
installCount: number;
@@ -460,12 +460,12 @@ export interface PluginTemplate {
updatedAt: string;
}
export interface PluginInstallation {
export interface AutomationInstallation {
id: string;
template: string;
templateName: string;
templateDescription: string;
category: PluginCategory;
category: AutomationCategory;
version: string;
authorName?: string;
logoUrl?: string;

View File

@@ -1,7 +1,4 @@
{
"status": "failed",
"failedTests": [
"6f1a4b04e7ad1ff99f24-d68c404526a42bfecb67",
"6f1a4b04e7ad1ff99f24-6b3beabbc695cf50d356"
]
"status": "passed",
"failedTests": []
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -0,0 +1,232 @@
/**
* Site Crawler Tests
*
* Crawls the site looking for broken links, console errors, and network failures.
* Run with: npx playwright test site-crawler.spec.ts
*
* These tests are designed to be run in CI to catch issues before deployment.
*/
import { test, expect } from '@playwright/test';
import {
SiteCrawler,
formatReport,
loginAsUser,
TEST_USERS,
CrawlerOptions,
} from './utils/crawler';
// Increase timeout for crawl tests since they visit many pages
test.setTimeout(300000); // 5 minutes
const CRAWLER_OPTIONS: CrawlerOptions = {
maxPages: 30, // Limit for CI to keep tests reasonable
verbose: false,
screenshotOnError: true,
screenshotDir: 'test-results/crawler-screenshots',
timeout: 15000,
waitForNetworkIdle: true,
excludePatterns: [
/\.(pdf|zip|tar|gz|exe|dmg|pkg)$/i,
/^mailto:/i,
/^tel:/i,
/^javascript:/i,
/logout/i,
/sign-out/i,
// Exclude external links for faster tests
/^https?:\/\/(?!.*lvh\.me)/i,
],
};
test.describe('Site Crawler - Public Pages', () => {
test('should crawl public marketing site without errors', async ({ page, context }) => {
const crawler = new SiteCrawler(page, context, {
...CRAWLER_OPTIONS,
maxPages: 20, // Fewer pages for public site
});
const report = await crawler.crawl('http://lvh.me:5173');
// Print the report
console.log(formatReport(report));
// Assertions
expect(report.totalPages).toBeGreaterThan(0);
// Filter out minor warnings that aren't real issues
const criticalErrors = report.results.flatMap(r =>
r.errors.filter(e => {
// Ignore React DevTools suggestion
if (e.message.includes('React DevTools')) return false;
// Ignore favicon 404
if (e.message.includes('favicon.ico')) return false;
// Ignore hot module replacement messages
if (e.message.includes('hot-update') || e.message.includes('hmr')) return false;
return true;
})
);
// Fail if there are critical errors
if (criticalErrors.length > 0) {
console.error('\n❌ Critical errors found:');
criticalErrors.forEach(e => {
console.error(` - [${e.type}] ${e.url}: ${e.message}`);
});
}
expect(criticalErrors.length).toBe(0);
});
});
test.describe('Site Crawler - Platform Dashboard', () => {
test('should crawl platform dashboard without errors', async ({ page, context }) => {
// Login first
const user = TEST_USERS.platformSuperuser;
const loggedIn = await loginAsUser(page, user);
expect(loggedIn).toBe(true);
const crawler = new SiteCrawler(page, context, CRAWLER_OPTIONS);
const report = await crawler.crawl('http://platform.lvh.me:5173');
// Print the report
console.log(formatReport(report));
// Assertions
expect(report.totalPages).toBeGreaterThan(0);
// Filter out minor warnings
const criticalErrors = report.results.flatMap(r =>
r.errors.filter(e => {
if (e.message.includes('React DevTools')) return false;
if (e.message.includes('favicon.ico')) return false;
if (e.message.includes('hot-update') || e.message.includes('hmr')) return false;
// Ignore WebSocket connection issues in dev
if (e.message.includes('WebSocket')) return false;
return true;
})
);
if (criticalErrors.length > 0) {
console.error('\n❌ Critical errors found:');
criticalErrors.forEach(e => {
console.error(` - [${e.type}] ${e.url}: ${e.message}`);
});
}
expect(criticalErrors.length).toBe(0);
});
});
// Uncomment and configure when tenant test users are set up
// test.describe('Site Crawler - Tenant Dashboard', () => {
// test('should crawl tenant dashboard without errors', async ({ page, context }) => {
// const user = TEST_USERS.businessOwner;
// const loggedIn = await loginAsUser(page, user);
// expect(loggedIn).toBe(true);
//
// const crawler = new SiteCrawler(page, context, CRAWLER_OPTIONS);
// const report = await crawler.crawl('http://demo.lvh.me:5173');
//
// console.log(formatReport(report));
//
// expect(report.totalPages).toBeGreaterThan(0);
//
// const criticalErrors = report.results.flatMap(r =>
// r.errors.filter(e => {
// if (e.message.includes('React DevTools')) return false;
// if (e.message.includes('favicon.ico')) return false;
// if (e.message.includes('hot-update')) return false;
// if (e.message.includes('WebSocket')) return false;
// return true;
// })
// );
//
// expect(criticalErrors.length).toBe(0);
// });
// });
// test.describe('Site Crawler - Customer Portal', () => {
// test('should crawl customer booking portal without errors', async ({ page, context }) => {
// // Customer portal might not require auth for the booking pages
// const crawler = new SiteCrawler(page, context, {
// ...CRAWLER_OPTIONS,
// maxPages: 15,
// });
//
// const report = await crawler.crawl('http://demo.lvh.me:5173/book');
//
// console.log(formatReport(report));
//
// expect(report.totalPages).toBeGreaterThan(0);
//
// const criticalErrors = report.results.flatMap(r =>
// r.errors.filter(e => {
// if (e.message.includes('React DevTools')) return false;
// if (e.message.includes('favicon.ico')) return false;
// if (e.message.includes('hot-update')) return false;
// return true;
// })
// );
//
// expect(criticalErrors.length).toBe(0);
// });
// });
// Quick smoke test that just checks key pages load
test.describe('Site Crawler - Quick Smoke Test', () => {
const keyPages = [
{ name: 'Home', url: 'http://lvh.me:5173/' },
{ name: 'About', url: 'http://lvh.me:5173/about' },
{ name: 'Pricing', url: 'http://lvh.me:5173/pricing' },
{ name: 'Privacy', url: 'http://lvh.me:5173/privacy' },
{ name: 'Terms', url: 'http://lvh.me:5173/terms' },
{ name: 'Contact', url: 'http://lvh.me:5173/contact' },
];
for (const { name, url } of keyPages) {
test(`${name} page should load without errors`, async ({ page }) => {
const errors: string[] = [];
// Capture console errors
page.on('console', msg => {
if (msg.type() === 'error') {
const text = msg.text();
// Filter out non-critical
if (!text.includes('React DevTools') && !text.includes('favicon')) {
errors.push(`Console: ${text}`);
}
}
});
// Capture page errors
page.on('pageerror', error => {
errors.push(`Page error: ${error.message}`);
});
// Capture failed requests
page.on('requestfailed', request => {
const failedUrl = request.url();
if (!failedUrl.includes('favicon') && !failedUrl.includes('hot-update')) {
errors.push(`Request failed: ${failedUrl}`);
}
});
await page.goto(url, { waitUntil: 'networkidle' });
// Wait a bit for React to fully render
await page.waitForTimeout(1000);
// Check that page rendered something
const bodyContent = await page.locator('body').textContent();
expect(bodyContent?.length).toBeGreaterThan(100);
// Report and fail if errors
if (errors.length > 0) {
console.error(`\n❌ Errors on ${name} page (${url}):`);
errors.forEach(e => console.error(` - ${e}`));
}
expect(errors.length).toBe(0);
});
}
});

View File

@@ -0,0 +1,568 @@
/**
* Site Crawler Utility
* Crawls the site discovering links and capturing errors
*/
import { Page, BrowserContext } from '@playwright/test';
export interface CrawlError {
url: string;
type: 'console' | 'network' | 'broken-link' | 'page-error';
message: string;
details?: string;
timestamp: Date;
}
export interface CrawlResult {
url: string;
status: 'success' | 'error' | 'skipped';
title?: string;
errors: CrawlError[];
linksFound: string[];
duration: number;
}
export interface CrawlReport {
startTime: Date;
endTime: Date;
totalPages: number;
totalErrors: number;
results: CrawlResult[];
summary: {
consoleErrors: number;
networkErrors: number;
brokenLinks: number;
pageErrors: number;
};
}
export interface CrawlerOptions {
maxPages?: number;
timeout?: number;
excludePatterns?: RegExp[];
includeExternalLinks?: boolean;
waitForNetworkIdle?: boolean;
screenshotOnError?: boolean;
screenshotDir?: string;
verbose?: boolean;
}
const DEFAULT_OPTIONS: CrawlerOptions = {
maxPages: 0, // 0 = unlimited
timeout: 30000,
excludePatterns: [
/\.(pdf|zip|tar|gz|exe|dmg|pkg)$/i,
/^mailto:/i,
/^tel:/i,
/^javascript:/i,
/logout/i,
/sign-out/i,
],
includeExternalLinks: false,
waitForNetworkIdle: true,
screenshotOnError: false,
screenshotDir: 'test-results/crawler-screenshots',
verbose: false,
};
export class SiteCrawler {
private page: Page;
private context: BrowserContext;
private options: CrawlerOptions;
private visited: Set<string> = new Set();
private queue: string[] = [];
private results: CrawlResult[] = [];
private baseUrl: string = '';
private baseDomain: string = '';
constructor(page: Page, context: BrowserContext, options: Partial<CrawlerOptions> = {}) {
this.page = page;
this.context = context;
this.options = { ...DEFAULT_OPTIONS, ...options };
}
private log(message: string, ...args: unknown[]) {
if (this.options.verbose) {
console.log(`[Crawler] ${message}`, ...args);
}
}
private normalizeUrl(url: string): string {
try {
const parsed = new URL(url, this.baseUrl);
// Remove hash and trailing slash for comparison
parsed.hash = '';
let normalized = parsed.href;
if (normalized.endsWith('/') && normalized !== parsed.origin + '/') {
normalized = normalized.slice(0, -1);
}
return normalized;
} catch {
return url;
}
}
private isInternalUrl(url: string): boolean {
try {
const parsed = new URL(url, this.baseUrl);
// Check if it's on the same domain or a subdomain of lvh.me
return parsed.hostname.endsWith('lvh.me') ||
parsed.hostname === 'localhost' ||
parsed.hostname === '127.0.0.1';
} catch {
return false;
}
}
private shouldCrawl(url: string): boolean {
// Skip if already visited
if (this.visited.has(url)) {
return false;
}
// Skip if matches exclude patterns
for (const pattern of this.options.excludePatterns || []) {
if (pattern.test(url)) {
this.log(`Skipping excluded URL: ${url}`);
return false;
}
}
// Skip external links unless explicitly included
if (!this.options.includeExternalLinks && !this.isInternalUrl(url)) {
this.log(`Skipping external URL: ${url}`);
return false;
}
return true;
}
private async extractLinks(): Promise<string[]> {
const links = await this.page.evaluate(() => {
const anchors = document.querySelectorAll('a[href]');
const hrefs: string[] = [];
anchors.forEach(anchor => {
const href = anchor.getAttribute('href');
if (href) {
hrefs.push(href);
}
});
return hrefs;
});
// Normalize and filter links
const normalizedLinks: string[] = [];
for (const link of links) {
try {
const normalized = this.normalizeUrl(link);
if (this.shouldCrawl(normalized)) {
normalizedLinks.push(normalized);
}
} catch {
// Invalid URL, skip
}
}
return [...new Set(normalizedLinks)];
}
private async crawlPage(url: string): Promise<CrawlResult> {
const startTime = Date.now();
const errors: CrawlError[] = [];
const linksFound: string[] = [];
this.log(`Crawling: ${url}`);
// Set up error listeners
const consoleHandler = (msg: { type: () => string; text: () => string; location: () => { url: string; lineNumber: number } }) => {
const type = msg.type();
if (type === 'error' || type === 'warning') {
const text = msg.text();
// Filter out non-critical warnings
if (text.includes('width(-1) and height(-1) of chart')) return; // Recharts initial render warning
if (text.includes('WebSocket')) return; // WebSocket connection issues in dev
if (text.includes('Cross-Origin-Opener-Policy')) return; // COOP header in dev environment
if (text.includes('must use HTTPS') && text.includes('Stripe')) return; // Stripe dev mode warning
// Show all backend errors (403, 404, 500) for debugging
errors.push({
url,
type: 'console',
message: text,
details: `${type.toUpperCase()} at ${msg.location().url}:${msg.location().lineNumber}`,
timestamp: new Date(),
});
}
};
const pageErrorHandler = (error: Error) => {
errors.push({
url,
type: 'page-error',
message: error.message,
details: error.stack,
timestamp: new Date(),
});
};
const requestFailedHandler = (request: { url: () => string; failure: () => { errorText: string } | null }) => {
const failedUrl = request.url();
// Ignore some common non-critical failures
if (failedUrl.includes('favicon.ico') || failedUrl.includes('hot-update')) {
return;
}
// Ignore Stripe external requests (tracking/monitoring that gets cancelled)
if (failedUrl.includes('stripe.com') || failedUrl.includes('stripe.network')) {
return;
}
// Show all API failures for debugging
errors.push({
url,
type: 'network',
message: `Request failed: ${failedUrl}`,
details: request.failure()?.errorText || 'Unknown error',
timestamp: new Date(),
});
};
const responseHandler = (response: { url: () => string; status: () => number }) => {
const status = response.status();
const responseUrl = response.url();
// Track 4xx and 5xx responses (excluding some common benign ones)
if (status >= 400 && !responseUrl.includes('favicon.ico')) {
// Show all API errors (403, 404, 500) for debugging
errors.push({
url,
type: 'network',
message: `HTTP ${status}: ${responseUrl}`,
details: `Response status ${status}`,
timestamp: new Date(),
});
}
};
this.page.on('console', consoleHandler);
this.page.on('pageerror', pageErrorHandler);
this.page.on('requestfailed', requestFailedHandler);
this.page.on('response', responseHandler);
try {
// Navigate to the page
const response = await this.page.goto(url, {
timeout: this.options.timeout,
waitUntil: this.options.waitForNetworkIdle ? 'networkidle' : 'domcontentloaded',
});
if (!response) {
errors.push({
url,
type: 'network',
message: 'No response received',
timestamp: new Date(),
});
return {
url,
status: 'error',
errors,
linksFound,
duration: Date.now() - startTime,
};
}
const status = response.status();
if (status >= 400) {
errors.push({
url,
type: 'broken-link',
message: `HTTP ${status}`,
timestamp: new Date(),
});
}
// Wait a bit for React to render and any async operations
await this.page.waitForTimeout(500);
// Get page title
const title = await this.page.title();
// Extract links
const links = await this.extractLinks();
linksFound.push(...links);
// Add new links to queue
for (const link of links) {
if (!this.visited.has(link) && !this.queue.includes(link)) {
this.queue.push(link);
}
}
// Screenshot on error if enabled
if (errors.length > 0 && this.options.screenshotOnError) {
const filename = url.replace(/[^a-zA-Z0-9]/g, '_').substring(0, 100);
await this.page.screenshot({
path: `${this.options.screenshotDir}/${filename}.png`,
fullPage: true,
});
}
return {
url,
status: errors.length > 0 ? 'error' : 'success',
title,
errors,
linksFound,
duration: Date.now() - startTime,
};
} catch (error) {
errors.push({
url,
type: 'page-error',
message: error instanceof Error ? error.message : String(error),
timestamp: new Date(),
});
return {
url,
status: 'error',
errors,
linksFound,
duration: Date.now() - startTime,
};
} finally {
// Remove listeners
this.page.off('console', consoleHandler);
this.page.off('pageerror', pageErrorHandler);
this.page.off('requestfailed', requestFailedHandler);
this.page.off('response', responseHandler);
}
}
async crawl(startUrl: string): Promise<CrawlReport> {
const startTime = new Date();
this.baseUrl = startUrl;
this.baseDomain = new URL(startUrl).hostname;
this.queue = [this.normalizeUrl(startUrl)];
this.visited.clear();
this.results = [];
console.log(`\n🕷 Starting crawl from: ${startUrl}`);
console.log(` Max pages: ${this.options.maxPages || 'unlimited'}`);
console.log('');
const maxPages = this.options.maxPages || 0; // 0 = unlimited
while (this.queue.length > 0 && (maxPages === 0 || this.results.length < maxPages)) {
const url = this.queue.shift()!;
if (this.visited.has(url)) {
continue;
}
this.visited.add(url);
const result = await this.crawlPage(url);
this.results.push(result);
// Progress indicator
const errorCount = result.errors.length;
const statusIcon = result.status === 'success' ? '✓' : '✗';
const errorInfo = errorCount > 0 ? ` (${errorCount} error${errorCount > 1 ? 's' : ''})` : '';
const maxDisplay = maxPages === 0 ? '∞' : maxPages;
console.log(` ${statusIcon} [${this.results.length}/${maxDisplay}] ${url}${errorInfo}`);
}
const endTime = new Date();
// Calculate summary
const summary = {
consoleErrors: 0,
networkErrors: 0,
brokenLinks: 0,
pageErrors: 0,
};
for (const result of this.results) {
for (const error of result.errors) {
switch (error.type) {
case 'console':
summary.consoleErrors++;
break;
case 'network':
summary.networkErrors++;
break;
case 'broken-link':
summary.brokenLinks++;
break;
case 'page-error':
summary.pageErrors++;
break;
}
}
}
return {
startTime,
endTime,
totalPages: this.results.length,
totalErrors: summary.consoleErrors + summary.networkErrors + summary.brokenLinks + summary.pageErrors,
results: this.results,
summary,
};
}
}
export function formatReport(report: CrawlReport): string {
const duration = (report.endTime.getTime() - report.startTime.getTime()) / 1000;
let output = '\n';
output += '═'.repeat(60) + '\n';
output += ' CRAWL REPORT\n';
output += '═'.repeat(60) + '\n\n';
output += `📊 Summary\n`;
output += ` Pages crawled: ${report.totalPages}\n`;
output += ` Total errors: ${report.totalErrors}\n`;
output += ` Duration: ${duration.toFixed(1)}s\n\n`;
output += `📋 Error Breakdown\n`;
output += ` Console errors: ${report.summary.consoleErrors}\n`;
output += ` Network errors: ${report.summary.networkErrors}\n`;
output += ` Broken links: ${report.summary.brokenLinks}\n`;
output += ` Page errors: ${report.summary.pageErrors}\n\n`;
// List pages with errors
const pagesWithErrors = report.results.filter(r => r.errors.length > 0);
if (pagesWithErrors.length > 0) {
output += '─'.repeat(60) + '\n';
output += ' ERROR DETAILS\n';
output += '─'.repeat(60) + '\n\n';
for (const result of pagesWithErrors) {
output += `🔗 ${result.url}\n`;
output += ` Title: ${result.title || 'N/A'}\n`;
for (const error of result.errors) {
const icon = error.type === 'console' ? '⚠️' :
error.type === 'network' ? '🌐' :
error.type === 'broken-link' ? '🔴' : '💥';
output += ` ${icon} [${error.type.toUpperCase()}] ${error.message}\n`;
if (error.details) {
output += ` Details: ${error.details.substring(0, 200)}\n`;
}
}
output += '\n';
}
} else {
output += '✅ No errors found!\n\n';
}
output += '═'.repeat(60) + '\n';
return output;
}
// Authentication helpers for different user types
export interface UserCredentials {
username: string;
password: string;
loginUrl: string;
description: string;
}
export const TEST_USERS: Record<string, UserCredentials> = {
platformSuperuser: {
username: 'poduck@gmail.com',
password: 'starry12',
loginUrl: 'http://platform.lvh.me:5173/platform/login',
description: 'Platform Superuser',
},
businessOwner: {
username: 'owner@demo.com',
password: 'password123',
loginUrl: 'http://demo.lvh.me:5173/login',
description: 'Business Owner',
},
businessManager: {
username: 'manager@demo.com',
password: 'password123',
loginUrl: 'http://demo.lvh.me:5173/login',
description: 'Business Manager',
},
businessStaff: {
username: 'staff@demo.com',
password: 'password123',
loginUrl: 'http://demo.lvh.me:5173/login',
description: 'Staff Member',
},
customer: {
username: 'customer@demo.com',
password: 'password123',
loginUrl: 'http://demo.lvh.me:5173/login',
description: 'Customer',
},
};
export async function loginAsUser(page: Page, user: UserCredentials): Promise<boolean> {
console.log(`\n🔐 Logging in as ${user.description}...`);
console.log(` Login URL: ${user.loginUrl}`);
try {
await page.goto(user.loginUrl);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
// Check if already logged in (dashboard visible)
const isDashboard = page.url().includes('/dashboard') ||
await page.getByRole('heading', { name: /dashboard/i }).isVisible().catch(() => false);
if (isDashboard) {
console.log(` ✓ Already logged in. Current URL: ${page.url()}`);
return true;
}
// Try quick login buttons first (dev mode)
const quickLoginButton = page.getByRole('button', { name: new RegExp(user.description, 'i') });
const hasQuickLogin = await quickLoginButton.isVisible().catch(() => false);
if (hasQuickLogin) {
console.log(` Using quick login button for ${user.description}...`);
await quickLoginButton.click();
} else {
// Fall back to form login
let emailInput = page.locator('#email');
let passwordInput = page.locator('#password');
const formFound = await emailInput.waitFor({ timeout: 10000 }).then(() => true).catch(() => false);
if (!formFound) {
emailInput = page.getByPlaceholder(/enter your email/i);
passwordInput = page.getByPlaceholder(/password/i);
await emailInput.waitFor({ timeout: 5000 });
}
await emailInput.fill(user.username);
await passwordInput.fill(user.password);
await page.getByRole('button', { name: /^sign in$/i }).click();
}
// Wait for navigation after login
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000);
// Verify we're logged in (not on login page anymore)
const currentUrl = page.url();
const isLoggedIn = !currentUrl.includes('/login') &&
!currentUrl.endsWith(':5173/') &&
!currentUrl.endsWith(':5173');
if (isLoggedIn) {
console.log(` ✓ Logged in successfully. Current URL: ${currentUrl}`);
} else {
console.log(` ✗ Login may have failed. Current URL: ${currentUrl}`);
}
return isLoggedIn;
} catch (error) {
console.error(` ✗ Login failed:`, error);
return false;
}
}

View File

@@ -66,6 +66,7 @@ TENANT_APPS = [
# Scheduling Domain (tenant-isolated)
'smoothschedule.scheduling.schedule', # Resource scheduling with configurable concurrency
'smoothschedule.scheduling.automations', # Automation system for scheduled tasks
'smoothschedule.scheduling.contracts', # Contract/e-signature system
# Communication Domain (tenant-isolated)

View File

@@ -83,6 +83,8 @@ urlpatterns += [
path("", include("smoothschedule.platform.tenant_sites.urls")),
# Schedule API (internal)
path("", include("smoothschedule.scheduling.schedule.urls")),
# Automations API
path("", include("smoothschedule.scheduling.automations.urls")),
# Analytics API
path("", include("smoothschedule.scheduling.analytics.urls")),
# Payments API

View File

@@ -100,12 +100,14 @@ class PaymentConfigStatusView(TenantRequiredAPIView, APIView):
stripe_configured = True
# Check if tier/subscription allows payments
tier_allows_payments = tenant.can_accept_payments
tier_allows_payments = tenant.has_feature('can_accept_payments')
# Get plan name from billing subscription
# Get plan name from billing subscription (with null-safe access)
plan_name = None
if hasattr(tenant, 'billing_subscription') and tenant.billing_subscription:
plan_name = tenant.billing_subscription.plan_version.plan.code
billing_sub = tenant.billing_subscription
if billing_sub.plan_version and billing_sub.plan_version.plan:
plan_name = billing_sub.plan_version.plan.code
return Response({
'payment_mode': tenant.payment_mode,
@@ -171,7 +173,6 @@ class SubscriptionPlansView(APIView):
'name': plan.name,
'description': plan.description,
'plan_type': plan.plan_type,
'business_tier': plan.business_tier,
'price_monthly': float(plan.price_monthly) if plan.price_monthly else None,
'price_yearly': float(plan.price_yearly) if plan.price_yearly else None,
'features': plan.features or [],
@@ -184,11 +185,14 @@ class SubscriptionPlansView(APIView):
'stripe_price_id': plan.stripe_price_id,
}
# Determine current plan info from billing subscription
# Determine current plan info from billing subscription (with null-safe access)
current_plan_name = 'free'
if hasattr(tenant, 'billing_subscription') and tenant.billing_subscription:
current_plan_name = tenant.billing_subscription.plan_version.plan.code
current_plan = base_plans.filter(business_tier=current_plan_name).first()
billing_sub = tenant.billing_subscription
if billing_sub.plan_version and billing_sub.plan_version.plan:
current_plan_name = billing_sub.plan_version.plan.code
# Try to find matching subscription plan by name (case-insensitive)
current_plan = base_plans.filter(name__iexact=current_plan_name).first()
return Response({
'current_plan_name': current_plan_name,

View File

@@ -365,51 +365,52 @@ class UserTenantFilteredMixin(SandboxFilteredQuerySetMixin):
# Feature Permission Mixins
# ==============================================================================
class PluginFeatureRequiredMixin:
class AutomationFeatureRequiredMixin:
"""
Mixin that checks plugin permission before allowing access.
Mixin that checks automation permission before allowing access.
Raises PermissionDenied if tenant doesn't have 'can_use_plugins' feature.
Raises PermissionDenied if tenant doesn't have 'can_use_automations' feature.
Usage:
class PluginTemplateViewSet(PluginFeatureRequiredMixin, ModelViewSet):
class AutomationTemplateViewSet(AutomationFeatureRequiredMixin, ModelViewSet):
# ...
"""
plugin_feature_key = 'can_use_plugins'
plugin_feature_error = (
"Your current plan does not include Plugin access. "
"Please upgrade your subscription to use plugins."
automation_feature_key = 'can_use_automations'
automation_feature_error = (
"Your current plan does not include Automation access. "
"Please upgrade your subscription to use automations."
)
def check_plugin_permission(self):
"""Check if tenant has plugin permission."""
def check_automation_permission(self):
"""Check if tenant has automation permission."""
tenant = getattr(self.request, 'tenant', None)
if tenant and not tenant.has_feature(self.plugin_feature_key):
raise PermissionDenied(self.plugin_feature_error)
if tenant:
if not tenant.has_feature(self.automation_feature_key):
raise PermissionDenied(self.automation_feature_error)
def list(self, request, *args, **kwargs):
self.check_plugin_permission()
self.check_automation_permission()
return super().list(request, *args, **kwargs)
def retrieve(self, request, *args, **kwargs):
self.check_plugin_permission()
self.check_automation_permission()
return super().retrieve(request, *args, **kwargs)
def create(self, request, *args, **kwargs):
self.check_plugin_permission()
self.check_automation_permission()
return super().create(request, *args, **kwargs)
class TaskFeatureRequiredMixin(PluginFeatureRequiredMixin):
class TaskFeatureRequiredMixin(AutomationFeatureRequiredMixin):
"""
Mixin that checks both plugin and task permissions.
Mixin that checks both automation and task permissions.
Requires both 'can_use_plugins' AND 'can_use_tasks' features.
Requires both 'can_use_automations' AND 'can_use_tasks' features.
"""
def check_plugin_permission(self):
"""Check both plugin and task permissions."""
super().check_plugin_permission()
def check_automation_permission(self):
"""Check both automation and task permissions."""
super().check_automation_permission()
tenant = getattr(self.request, 'tenant', None)
if tenant and not tenant.has_feature('can_use_tasks'):

View File

@@ -14,7 +14,7 @@ from smoothschedule.identity.core.mixins import (
TenantFilteredQuerySetMixin,
SandboxFilteredQuerySetMixin,
UserTenantFilteredMixin,
PluginFeatureRequiredMixin,
AutomationFeatureRequiredMixin,
TaskFeatureRequiredMixin,
StandardResponseMixin,
TenantAPIView,
@@ -849,35 +849,35 @@ class TestUserTenantFilteredMixin:
# ==============================================================================
# PluginFeatureRequiredMixin Tests
# AutomationFeatureRequiredMixin Tests
# ==============================================================================
class TestPluginFeatureRequiredMixin:
"""Test the PluginFeatureRequiredMixin class."""
class TestAutomationFeatureRequiredMixin:
"""Test the AutomationFeatureRequiredMixin class."""
def test_allows_access_when_tenant_has_plugin_feature(self):
viewset = PluginFeatureRequiredMixin()
def test_allows_access_when_tenant_has_automation_feature(self):
viewset = AutomationFeatureRequiredMixin()
viewset.request = Mock()
viewset.request.tenant = Mock()
viewset.request.tenant.has_feature.return_value = True
# Should not raise
viewset.check_plugin_permission()
viewset.request.tenant.has_feature.assert_called_once_with('can_use_plugins')
viewset.check_automation_permission()
viewset.request.tenant.has_feature.assert_called_once_with('can_use_automations')
def test_denies_access_when_tenant_lacks_plugin_feature(self):
viewset = PluginFeatureRequiredMixin()
def test_denies_access_when_tenant_lacks_automation_feature(self):
viewset = AutomationFeatureRequiredMixin()
viewset.request = Mock()
viewset.request.tenant = Mock()
viewset.request.tenant.has_feature.return_value = False
with pytest.raises(PermissionDenied) as exc_info:
viewset.check_plugin_permission()
viewset.check_automation_permission()
assert 'Plugin access' in str(exc_info.value)
assert 'Automation access' in str(exc_info.value)
def test_list_checks_plugin_permission(self):
viewset = PluginFeatureRequiredMixin()
def test_list_checks_automation_permission(self):
viewset = AutomationFeatureRequiredMixin()
viewset.request = Mock()
viewset.request.tenant = Mock()
viewset.request.tenant.has_feature.return_value = False
@@ -885,8 +885,8 @@ class TestPluginFeatureRequiredMixin:
with pytest.raises(PermissionDenied):
viewset.list(viewset.request)
def test_retrieve_checks_plugin_permission(self):
viewset = PluginFeatureRequiredMixin()
def test_retrieve_checks_automation_permission(self):
viewset = AutomationFeatureRequiredMixin()
viewset.request = Mock()
viewset.request.tenant = Mock()
viewset.request.tenant.has_feature.return_value = False
@@ -894,8 +894,8 @@ class TestPluginFeatureRequiredMixin:
with pytest.raises(PermissionDenied):
viewset.retrieve(viewset.request)
def test_create_checks_plugin_permission(self):
viewset = PluginFeatureRequiredMixin()
def test_create_checks_automation_permission(self):
viewset = AutomationFeatureRequiredMixin()
viewset.request = Mock()
viewset.request.tenant = Mock()
viewset.request.tenant.has_feature.return_value = False
@@ -918,35 +918,35 @@ class TestTaskFeatureRequiredMixin:
viewset.request.tenant.has_feature.return_value = True
# Should not raise
viewset.check_plugin_permission()
viewset.check_automation_permission()
# Should be called twice: once for plugins, once for tasks
# Should be called twice: once for automations, once for tasks
assert viewset.request.tenant.has_feature.call_count == 2
def test_denies_access_when_tenant_lacks_plugin_feature(self):
def test_denies_access_when_tenant_lacks_automation_feature(self):
viewset = TaskFeatureRequiredMixin()
viewset.request = Mock()
viewset.request.tenant = Mock()
viewset.request.tenant.has_feature.return_value = False
with pytest.raises(PermissionDenied) as exc_info:
viewset.check_plugin_permission()
viewset.check_automation_permission()
assert 'Plugin access' in str(exc_info.value)
assert 'Automation access' in str(exc_info.value)
def test_denies_access_when_tenant_lacks_task_feature(self):
viewset = TaskFeatureRequiredMixin()
viewset.request = Mock()
viewset.request.tenant = Mock()
# Return True for plugins, False for tasks
# Return True for automations, False for tasks
def has_feature_side_effect(key):
return key == 'can_use_plugins'
return key == 'can_use_automations'
viewset.request.tenant.has_feature.side_effect = has_feature_side_effect
with pytest.raises(PermissionDenied) as exc_info:
viewset.check_plugin_permission()
viewset.check_automation_permission()
assert 'Scheduled Tasks' in str(exc_info.value)

View File

@@ -0,0 +1 @@
default_app_config = 'smoothschedule.scheduling.automations.apps.AutomationsConfig'

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Admin registrations will be added after models are defined

View File

@@ -0,0 +1,12 @@
from django.apps import AppConfig
class AutomationsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'smoothschedule.scheduling.automations'
label = 'automations'
verbose_name = 'Automations'
def ready(self):
# Import signals to register them
from . import signals # noqa: F401

View File

@@ -1,7 +1,7 @@
"""
Built-in plugins for common automated tasks.
Built-in automations for common automated tasks.
These plugins are registered automatically and available out-of-the-box.
These automations are registered automatically and available out-of-the-box.
"""
from typing import Any, Dict
@@ -12,13 +12,13 @@ from django.db.models import Count, Q
from datetime import timedelta
import logging
from .plugins import BasePlugin, register_plugin, PluginExecutionError
from .registry import BaseAutomation, register_automation, AutomationExecutionError
logger = logging.getLogger(__name__)
@register_plugin
class SendEmailPlugin(BasePlugin):
@register_automation
class SendEmailAutomation(BaseAutomation):
"""Send a custom email to specified recipients"""
name = "send_email"
@@ -56,7 +56,7 @@ class SendEmailPlugin(BasePlugin):
from_email = self.config.get('from_email', settings.DEFAULT_FROM_EMAIL)
if not recipients:
raise PluginExecutionError("No recipients specified")
raise AutomationExecutionError("No recipients specified")
try:
send_mail(
@@ -72,11 +72,11 @@ class SendEmailPlugin(BasePlugin):
'data': {'recipient_count': len(recipients)},
}
except Exception as e:
raise PluginExecutionError(f"Failed to send email: {e}")
raise AutomationExecutionError(f"Failed to send email: {e}")
@register_plugin
class CleanupOldEventsPlugin(BasePlugin):
@register_automation
class CleanupOldEventsAutomation(BaseAutomation):
"""Clean up old completed or canceled events"""
name = "cleanup_old_events"
@@ -106,7 +106,7 @@ class CleanupOldEventsPlugin(BasePlugin):
}
def execute(self, context: Dict[str, Any]) -> Dict[str, Any]:
from .models import Event
from smoothschedule.scheduling.schedule.models import Event
days_old = self.config.get('days_old', 90)
statuses = self.config.get('statuses', ['COMPLETED', 'CANCELED'])
@@ -138,8 +138,8 @@ class CleanupOldEventsPlugin(BasePlugin):
}
@register_plugin
class DailyReportPlugin(BasePlugin):
@register_automation
class DailyReportAutomation(BaseAutomation):
"""Generate and send a daily business report"""
name = "daily_report"
@@ -168,13 +168,13 @@ class DailyReportPlugin(BasePlugin):
}
def execute(self, context: Dict[str, Any]) -> Dict[str, Any]:
from .models import Event
from smoothschedule.scheduling.schedule.models import Event
business = context.get('business')
recipients = self.config.get('recipients', [])
if not recipients:
raise PluginExecutionError("No recipients specified")
raise AutomationExecutionError("No recipients specified")
# Get today's date range
today = timezone.now().date()
@@ -238,11 +238,11 @@ class DailyReportPlugin(BasePlugin):
'data': {'recipient_count': len(recipients)},
}
except Exception as e:
raise PluginExecutionError(f"Failed to send report: {e}")
raise AutomationExecutionError(f"Failed to send report: {e}")
@register_plugin
class AppointmentReminderPlugin(BasePlugin):
@register_automation
class AppointmentReminderAutomation(BaseAutomation):
"""Send reminder emails/SMS for upcoming appointments"""
name = "appointment_reminder"
@@ -272,7 +272,7 @@ class AppointmentReminderPlugin(BasePlugin):
}
def execute(self, context: Dict[str, Any]) -> Dict[str, Any]:
from .models import Event
from smoothschedule.scheduling.schedule.models import Event
from smoothschedule.platform.admin.tasks import send_appointment_reminder_email
hours_before = self.config.get('hours_before', 24)
@@ -326,8 +326,8 @@ class AppointmentReminderPlugin(BasePlugin):
}
@register_plugin
class BackupDatabasePlugin(BasePlugin):
@register_automation
class BackupDatabaseAutomation(BaseAutomation):
"""Create a database backup"""
name = "backup_database"
@@ -366,8 +366,8 @@ class BackupDatabasePlugin(BasePlugin):
}
@register_plugin
class WebhookPlugin(BasePlugin):
@register_automation
class WebhookAutomation(BaseAutomation):
"""Call an external webhook URL"""
name = "webhook"
@@ -409,7 +409,7 @@ class WebhookPlugin(BasePlugin):
payload = self.config.get('payload', {})
if not url:
raise PluginExecutionError("Webhook URL is required")
raise AutomationExecutionError("Webhook URL is required")
try:
response = requests.request(
@@ -430,4 +430,4 @@ class WebhookPlugin(BasePlugin):
},
}
except requests.RequestException as e:
raise PluginExecutionError(f"Webhook request failed: {e}")
raise AutomationExecutionError(f"Webhook request failed: {e}")

View File

@@ -1,5 +1,5 @@
"""
Custom Script Plugin
Custom Script Automation
Allows customers to write their own automation logic using a safe,
sandboxed Python environment with access to their business data.
@@ -9,14 +9,14 @@ from typing import Any, Dict
from django.utils import timezone
import logging
from .plugins import BasePlugin, register_plugin, PluginExecutionError
from .safe_scripting import SafeScriptEngine, SafeScriptAPI, ScriptExecutionError
from .registry import BaseAutomation, register_automation, AutomationExecutionError
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptEngine, SafeScriptAPI, ScriptExecutionError
logger = logging.getLogger(__name__)
@register_plugin
class CustomScriptPlugin(BasePlugin):
@register_automation
class CustomScriptAutomation(BaseAutomation):
"""
Execute custom customer-written scripts safely.
@@ -52,7 +52,7 @@ class CustomScriptPlugin(BasePlugin):
script = self.config.get('script')
if not script:
raise PluginExecutionError("No script provided")
raise AutomationExecutionError("No script provided")
# Create safe API for customer
api = SafeScriptAPI(
@@ -99,11 +99,11 @@ class CustomScriptPlugin(BasePlugin):
except Exception as e:
logger.error(f"Script execution failed: {e}", exc_info=True)
raise PluginExecutionError(f"Script execution failed: {e}")
raise AutomationExecutionError(f"Script execution failed: {e}")
@register_plugin
class ScriptTemplatePlugin(BasePlugin):
@register_automation
class ScriptTemplateAutomation(BaseAutomation):
"""
Pre-built script templates that customers can customize.
@@ -284,7 +284,7 @@ result = {{'exported_count': len(appointments)}}
template_name = self.config.get('template')
if template_name not in self.TEMPLATES:
raise PluginExecutionError(f"Unknown template: {template_name}")
raise AutomationExecutionError(f"Unknown template: {template_name}")
template = self.TEMPLATES[template_name]
parameters = self.config.get('parameters', {})
@@ -292,7 +292,7 @@ result = {{'exported_count': len(appointments)}}
# Validate required parameters
for param in template['parameters']:
if param not in parameters:
raise PluginExecutionError(
raise AutomationExecutionError(
f"Missing required parameter '{param}' for template '{template_name}'"
)
@@ -300,7 +300,7 @@ result = {{'exported_count': len(appointments)}}
try:
script = template['script'].format(**parameters)
except KeyError as e:
raise PluginExecutionError(f"Template parameter error: {e}")
raise AutomationExecutionError(f"Template parameter error: {e}")
# Create safe API
api = SafeScriptAPI(
@@ -337,4 +337,4 @@ result = {{'exported_count': len(appointments)}}
except Exception as e:
logger.error(f"Template execution failed: {e}", exc_info=True)
raise PluginExecutionError(f"Template execution failed: {e}")
raise AutomationExecutionError(f"Template execution failed: {e}")

View File

@@ -0,0 +1,36 @@
"""
Models for the automations app.
This module re-exports the automation models from the schedule app with new names.
The canonical model definitions remain in schedule/models.py for backwards compatibility.
Automations (formerly plugins) are Python-based automated tasks that can be:
- Attached to calendar events (EventAutomation)
- Run globally on all events (GlobalEventAutomation)
- Installed from templates (AutomationTemplate, AutomationInstallation)
New code should use the names from this module:
- AutomationTemplate (was PluginTemplate)
- AutomationInstallation (was PluginInstallation)
- EventAutomation (was EventPlugin)
- GlobalEventAutomation (was GlobalEventPlugin)
- WhitelistedURL (same name, moved to automations domain)
"""
# Re-export models from schedule with new names
from smoothschedule.scheduling.schedule.models import (
PluginTemplate as AutomationTemplate,
PluginInstallation as AutomationInstallation,
EventPlugin as EventAutomation,
GlobalEventPlugin as GlobalEventAutomation,
WhitelistedURL,
)
# Export all names
__all__ = [
'AutomationTemplate',
'AutomationInstallation',
'EventAutomation',
'GlobalEventAutomation',
'WhitelistedURL',
]

View File

@@ -0,0 +1,239 @@
"""
Automation system for automated tasks.
Automations are Python classes that define automated tasks that can be scheduled
and executed without requiring resource allocation.
Example automation:
class SendWeeklyReportAutomation(BaseAutomation):
name = "send_weekly_report"
display_name = "Send Weekly Report"
description = "Emails a weekly business report to managers"
def execute(self, context):
# Automation implementation
return {"success": True, "message": "Report sent"}
"""
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
from django.utils import timezone
import logging
logger = logging.getLogger(__name__)
class AutomationExecutionError(Exception):
"""Raised when an automation fails to execute"""
pass
class BaseAutomation(ABC):
"""
Base class for all scheduler automations.
Subclass this to create custom automated tasks.
"""
# Automation metadata (override in subclasses)
name: str = "" # Unique identifier (snake_case)
display_name: str = "" # Human-readable name
description: str = "" # What this automation does
category: str = "general" # Automation category for organization
# Configuration schema (override if automation accepts config)
config_schema: Dict[str, Any] = {}
def __init__(self, config: Optional[Dict[str, Any]] = None):
"""
Initialize automation with configuration.
Args:
config: Automation-specific configuration dictionary
"""
self.config = config or {}
self.validate_config()
def validate_config(self) -> None:
"""
Validate automation configuration.
Override to add custom validation logic.
Raises:
ValueError: If configuration is invalid
"""
if self.config_schema:
for key, schema in self.config_schema.items():
if schema.get('required', False) and key not in self.config:
raise ValueError(f"Required config key '{key}' missing for automation '{self.name}'")
@abstractmethod
def execute(self, context: Dict[str, Any]) -> Dict[str, Any]:
"""
Execute the automation's main task.
Args:
context: Execution context containing:
- business: Current business/tenant instance
- scheduled_task: ScheduledTask instance that triggered this
- execution_time: When this execution started
- user: User who created the scheduled task (if applicable)
Returns:
Dictionary with execution results:
- success: bool - Whether execution succeeded
- message: str - Human-readable result message
- data: dict - Any additional data
Raises:
AutomationExecutionError: If execution fails
"""
pass
def can_execute(self, context: Dict[str, Any]) -> tuple[bool, Optional[str]]:
"""
Check if automation can execute in current context.
Override to add pre-execution checks.
Args:
context: Execution context
Returns:
Tuple of (can_execute: bool, reason: Optional[str])
"""
return True, None
def on_success(self, result: Dict[str, Any]) -> None:
"""
Called after successful execution.
Override for post-execution logic.
"""
pass
def on_failure(self, error: Exception) -> None:
"""
Called after failed execution.
Override for error handling logic.
"""
logger.error(f"Automation {self.name} failed: {error}", exc_info=True)
def get_next_run_time(self, last_run: Optional[timezone.datetime]) -> Optional[timezone.datetime]:
"""
Calculate next run time based on automation logic.
Override for custom scheduling logic.
Args:
last_run: Last execution time (None if never run)
Returns:
Next scheduled run time, or None to use schedule's default logic
"""
return None
def __str__(self) -> str:
return f"{self.display_name} ({self.name})"
class AutomationRegistry:
"""
Registry for managing available automations.
"""
def __init__(self):
self._automations: Dict[str, type[BaseAutomation]] = {}
def register(self, automation_class: type[BaseAutomation]) -> None:
"""
Register an automation class.
Args:
automation_class: Automation class to register
Raises:
ValueError: If automation name is missing or already registered
"""
if not automation_class.name:
raise ValueError(f"Automation class {automation_class.__name__} must define a 'name' attribute")
if automation_class.name in self._automations:
raise ValueError(f"Automation '{automation_class.name}' is already registered")
self._automations[automation_class.name] = automation_class
logger.info(f"Registered automation: {automation_class.name}")
def unregister(self, automation_name: str) -> None:
"""Unregister an automation by name"""
if automation_name in self._automations:
del self._automations[automation_name]
logger.info(f"Unregistered automation: {automation_name}")
def get(self, automation_name: str) -> Optional[type[BaseAutomation]]:
"""Get automation class by name"""
return self._automations.get(automation_name)
def get_instance(self, automation_name: str, config: Optional[Dict[str, Any]] = None) -> Optional[BaseAutomation]:
"""
Get automation instance by name with configuration.
Args:
automation_name: Name of automation to instantiate
config: Configuration dictionary
Returns:
Automation instance or None if not found
"""
automation_class = self.get(automation_name)
if automation_class:
return automation_class(config=config)
return None
def list_all(self) -> List[Dict[str, Any]]:
"""
List all registered automations with metadata.
Returns:
List of automation metadata dictionaries
"""
return [
{
'name': automation_class.name,
'display_name': automation_class.display_name,
'description': automation_class.description,
'category': automation_class.category,
'config_schema': automation_class.config_schema,
}
for automation_class in self._automations.values()
]
def list_by_category(self) -> Dict[str, List[Dict[str, Any]]]:
"""
List automations grouped by category.
Returns:
Dictionary mapping category names to automation lists
"""
categories: Dict[str, List[Dict[str, Any]]] = {}
for automation_info in self.list_all():
category = automation_info['category']
if category not in categories:
categories[category] = []
categories[category].append(automation_info)
return categories
# Global automation registry
registry = AutomationRegistry()
def register_automation(automation_class: type[BaseAutomation]) -> type[BaseAutomation]:
"""
Decorator to register an automation class.
Usage:
@register_automation
class MyAutomation(BaseAutomation):
name = "my_automation"
...
"""
registry.register(automation_class)
return automation_class

View File

@@ -0,0 +1,368 @@
"""
Serializers for the automations app.
Provides API serialization for:
- AutomationInfo (registry metadata)
- AutomationTemplate (marketplace templates)
- AutomationInstallation (installed automations)
- EventAutomation (automations attached to events)
- GlobalEventAutomation (rules for auto-attaching to all events)
Note: Field names in the serializers match the underlying model field names
(plugin_code, plugin_installation, etc.) for backwards compatibility.
"""
from rest_framework import serializers
from .models import (
AutomationTemplate,
AutomationInstallation,
EventAutomation,
GlobalEventAutomation,
)
class AutomationInfoSerializer(serializers.Serializer):
"""Serializer for automation metadata from registry"""
name = serializers.CharField()
display_name = serializers.CharField()
description = serializers.CharField()
category = serializers.CharField()
config_schema = serializers.DictField()
class AutomationTemplateSerializer(serializers.ModelSerializer):
"""Serializer for AutomationTemplate model (alias for PluginTemplate)"""
author_name = serializers.CharField(read_only=True)
approved_by_name = serializers.SerializerMethodField()
can_publish = serializers.SerializerMethodField()
validation_errors = serializers.SerializerMethodField()
class Meta:
model = AutomationTemplate
fields = [
'id', 'name', 'slug', 'description', 'short_description',
'plugin_code', 'plugin_code_hash', 'template_variables', 'default_config',
'visibility', 'category', 'tags',
'author', 'author_name', 'version', 'license_type', 'logo_url',
'is_approved', 'approved_by', 'approved_by_name', 'approved_at', 'rejection_reason',
'install_count', 'rating_average', 'rating_count',
'created_at', 'updated_at', 'published_at',
'can_publish', 'validation_errors',
]
read_only_fields = [
'id', 'slug', 'plugin_code_hash', 'template_variables',
'author', 'author_name', 'is_approved', 'approved_by', 'approved_by_name',
'approved_at', 'rejection_reason', 'install_count', 'rating_average',
'rating_count', 'created_at', 'updated_at', 'published_at',
]
def get_approved_by_name(self, obj):
"""Get name of user who approved the automation"""
if obj.approved_by:
return obj.approved_by.get_full_name() or obj.approved_by.username
return None
def get_can_publish(self, obj):
"""Check if automation can be published to marketplace"""
return obj.can_be_published()
def get_validation_errors(self, obj):
"""Get validation errors for publishing"""
from smoothschedule.scheduling.schedule.safe_scripting import validate_plugin_whitelist
validation = validate_plugin_whitelist(obj.plugin_code)
if not validation['valid']:
return validation['errors']
return []
def create(self, validated_data):
"""Set author from request user"""
request = self.context.get('request')
if request and hasattr(request, 'user'):
validated_data['author'] = request.user
return super().create(validated_data)
def validate_plugin_code(self, value):
"""Validate plugin code and extract template variables"""
if not value or not value.strip():
raise serializers.ValidationError("Automation code cannot be empty")
# Extract template variables
from smoothschedule.scheduling.schedule.template_parser import TemplateVariableParser
try:
template_vars = TemplateVariableParser.extract_variables(value)
except Exception as e:
raise serializers.ValidationError(f"Failed to parse template variables: {str(e)}")
return value
class AutomationTemplateListSerializer(serializers.ModelSerializer):
"""Lightweight serializer for automation template listing"""
author_name = serializers.CharField(read_only=True)
class Meta:
model = AutomationTemplate
fields = [
'id', 'name', 'slug', 'short_description', 'description',
'visibility', 'category', 'tags',
'author_name', 'version', 'license_type', 'logo_url', 'is_approved',
'install_count', 'rating_average', 'rating_count',
'created_at', 'updated_at', 'published_at',
]
read_only_fields = fields # All fields are read-only for list view
class AutomationInstallationSerializer(serializers.ModelSerializer):
"""Serializer for AutomationInstallation model (alias for PluginInstallation)"""
template_name = serializers.CharField(source='template.name', read_only=True)
template_slug = serializers.CharField(source='template.slug', read_only=True)
template_description = serializers.CharField(source='template.description', read_only=True)
category = serializers.CharField(source='template.category', read_only=True)
version = serializers.CharField(source='template.version', read_only=True)
author_name = serializers.CharField(source='template.author_name', read_only=True)
logo_url = serializers.CharField(source='template.logo_url', read_only=True)
template_variables = serializers.JSONField(source='template.template_variables', read_only=True)
scheduled_task_name = serializers.CharField(source='scheduled_task.name', read_only=True)
installed_by_name = serializers.SerializerMethodField()
has_update = serializers.SerializerMethodField()
class Meta:
model = AutomationInstallation
fields = [
'id', 'template', 'template_name', 'template_slug', 'template_description',
'category', 'version', 'author_name', 'logo_url', 'template_variables',
'scheduled_task', 'scheduled_task_name',
'installed_by', 'installed_by_name', 'installed_at',
'config_values', 'template_version_hash',
'rating', 'review', 'reviewed_at',
'has_update',
]
read_only_fields = [
'id', 'installed_by', 'installed_by_name', 'installed_at',
'template_version_hash', 'reviewed_at',
]
def get_installed_by_name(self, obj):
"""Get name of user who installed the automation"""
if obj.installed_by:
return obj.installed_by.get_full_name() or obj.installed_by.username
return None
def get_has_update(self, obj):
"""Check if template has been updated"""
return obj.has_update_available()
def create(self, validated_data):
"""
Create automation installation.
Installation makes the automation available in "My Automations".
Scheduling is optional and done separately.
"""
request = self.context.get('request')
template = validated_data.get('template')
# Set installed_by from request user
if request and hasattr(request, 'user') and request.user.is_authenticated:
validated_data['installed_by'] = request.user
# Store template version hash for update detection
if template:
import hashlib
validated_data['template_version_hash'] = hashlib.sha256(
template.plugin_code.encode('utf-8')
).hexdigest()
# Don't require scheduled_task on creation
# It can be added later when user schedules the automation
validated_data.pop('scheduled_task', None)
return super().create(validated_data)
class EventAutomationSerializer(serializers.ModelSerializer):
"""
Serializer for EventAutomation - attaching automations to calendar events.
Provides a visual-friendly representation of when automations run:
- trigger: 'before_start', 'at_start', 'after_start', 'after_end', 'on_complete', 'on_cancel'
- offset_minutes: 0, 5, 10, 15, 30, 60 (for time-based triggers)
"""
automation_name = serializers.CharField(source='plugin_installation.template.name', read_only=True)
automation_description = serializers.CharField(source='plugin_installation.template.short_description', read_only=True)
automation_category = serializers.CharField(source='plugin_installation.template.category', read_only=True)
automation_logo_url = serializers.CharField(source='plugin_installation.template.logo_url', read_only=True)
trigger_display = serializers.CharField(source='get_trigger_display', read_only=True)
execution_time = serializers.SerializerMethodField()
timing_description = serializers.SerializerMethodField()
class Meta:
model = EventAutomation
fields = [
'id',
'event',
'plugin_installation', # Field name matches model
'automation_name',
'automation_description',
'automation_category',
'automation_logo_url',
'trigger',
'trigger_display',
'offset_minutes',
'timing_description',
'execution_time',
'is_active',
'execution_order',
'created_at',
]
read_only_fields = ['id', 'created_at']
def get_execution_time(self, obj):
"""Get the calculated execution time"""
exec_time = obj.get_execution_time()
return exec_time.isoformat() if exec_time else None
def get_timing_description(self, obj):
"""
Generate a human-readable description of when the automation runs.
Examples: "At start", "10 minutes before start", "30 minutes after end"
"""
trigger = obj.trigger
offset = obj.offset_minutes
if trigger == EventAutomation.Trigger.BEFORE_START:
if offset == 0:
return "At start"
return f"{offset} min before start"
elif trigger == EventAutomation.Trigger.AT_START:
if offset == 0:
return "At start"
return f"{offset} min after start"
elif trigger == EventAutomation.Trigger.AFTER_START:
if offset == 0:
return "At start"
return f"{offset} min after start"
elif trigger == EventAutomation.Trigger.AFTER_END:
if offset == 0:
return "At end"
return f"{offset} min after end"
elif trigger == EventAutomation.Trigger.ON_COMPLETE:
return "When completed"
elif trigger == EventAutomation.Trigger.ON_CANCEL:
return "When canceled"
return "Unknown"
def validate(self, attrs):
"""Validate that offset makes sense for the trigger type"""
trigger = attrs.get('trigger', EventAutomation.Trigger.AT_START)
offset = attrs.get('offset_minutes', 0)
# Event-driven triggers don't use offset
if trigger in [EventAutomation.Trigger.ON_COMPLETE, EventAutomation.Trigger.ON_CANCEL]:
if offset != 0:
attrs['offset_minutes'] = 0 # Auto-correct instead of error
return attrs
class GlobalEventAutomationSerializer(serializers.ModelSerializer):
"""
Serializer for GlobalEventAutomation - rules for auto-attaching automations to ALL events.
When created, automatically applies to:
1. All existing events
2. All future events as they are created
"""
automation_name = serializers.CharField(source='plugin_installation.template.name', read_only=True)
automation_description = serializers.CharField(source='plugin_installation.template.short_description', read_only=True)
automation_category = serializers.CharField(source='plugin_installation.template.category', read_only=True)
automation_logo_url = serializers.CharField(source='plugin_installation.template.logo_url', read_only=True)
trigger_display = serializers.CharField(source='get_trigger_display', read_only=True)
timing_description = serializers.SerializerMethodField()
events_count = serializers.SerializerMethodField()
class Meta:
model = GlobalEventAutomation
fields = [
'id',
'plugin_installation', # Field name matches model
'automation_name',
'automation_description',
'automation_category',
'automation_logo_url',
'trigger',
'trigger_display',
'offset_minutes',
'timing_description',
'is_active',
'apply_to_existing',
'execution_order',
'events_count',
'created_at',
'updated_at',
'created_by',
]
read_only_fields = ['id', 'created_at', 'updated_at', 'created_by']
def get_timing_description(self, obj):
"""Generate a human-readable description of when the automation runs."""
trigger = obj.trigger
offset = obj.offset_minutes
if trigger == 'before_start':
if offset == 0:
return "At start"
return f"{offset} min before start"
elif trigger == 'at_start':
if offset == 0:
return "At start"
return f"{offset} min after start"
elif trigger == 'after_start':
if offset == 0:
return "At start"
return f"{offset} min after start"
elif trigger == 'after_end':
if offset == 0:
return "At end"
return f"{offset} min after end"
elif trigger == 'on_complete':
return "When completed"
elif trigger == 'on_cancel':
return "When canceled"
return "Unknown"
def get_events_count(self, obj):
"""Get the count of events this rule applies to."""
return EventAutomation.objects.filter(
plugin_installation=obj.plugin_installation,
trigger=obj.trigger,
offset_minutes=obj.offset_minutes,
).count()
def validate(self, attrs):
"""Validate the global event automation configuration."""
trigger = attrs.get('trigger', 'at_start')
offset = attrs.get('offset_minutes', 0)
# Event-driven triggers don't use offset
if trigger in ['on_complete', 'on_cancel']:
if offset != 0:
attrs['offset_minutes'] = 0
return attrs
def create(self, validated_data):
"""Create the global rule and apply to existing events."""
# Set the created_by from request context
request = self.context.get('request')
if request and hasattr(request, 'user'):
validated_data['created_by'] = request.user
return super().create(validated_data)

View File

@@ -0,0 +1,15 @@
"""
Signals for the automations app.
Handles auto-attaching automations to events when GlobalEventAutomation rules are defined.
"""
from django.db.models.signals import post_save
from django.dispatch import receiver
import logging
logger = logging.getLogger(__name__)
# Signal handlers will be added after models are defined
# This file is imported by apps.py to register signals

View File

@@ -0,0 +1,585 @@
"""
Unit tests for scheduling/automations/registry.py
Tests automation system base classes and registry.
"""
from unittest.mock import Mock, patch
import pytest
class TestAutomationExecutionError:
"""Tests for AutomationExecutionError exception."""
def test_is_exception_class(self):
"""Should be an Exception subclass."""
from smoothschedule.scheduling.automations.registry import AutomationExecutionError
assert issubclass(AutomationExecutionError, Exception)
def test_can_be_raised_and_caught(self):
"""Should be raisable with a message."""
from smoothschedule.scheduling.automations.registry import AutomationExecutionError
with pytest.raises(AutomationExecutionError) as exc_info:
raise AutomationExecutionError("Automation failed")
assert str(exc_info.value) == "Automation failed"
class TestBaseAutomation:
"""Tests for BaseAutomation abstract class."""
def test_class_exists(self):
"""Should have BaseAutomation class."""
from smoothschedule.scheduling.automations.registry import BaseAutomation
assert BaseAutomation is not None
def test_has_required_attributes(self):
"""Should define required class attributes."""
from smoothschedule.scheduling.automations.registry import BaseAutomation
assert hasattr(BaseAutomation, 'name')
assert hasattr(BaseAutomation, 'display_name')
assert hasattr(BaseAutomation, 'description')
assert hasattr(BaseAutomation, 'category')
assert hasattr(BaseAutomation, 'config_schema')
def test_init_stores_config(self):
"""Should store config on initialization."""
from smoothschedule.scheduling.automations.registry import BaseAutomation
# Create concrete implementation for testing
class TestAutomation(BaseAutomation):
name = "test_automation"
display_name = "Test Automation"
def execute(self, context):
return {"success": True}
automation = TestAutomation(config={"key": "value"})
assert automation.config == {"key": "value"}
def test_init_defaults_to_empty_config(self):
"""Should default to empty config when None provided."""
from smoothschedule.scheduling.automations.registry import BaseAutomation
class TestAutomation(BaseAutomation):
name = "test_automation"
display_name = "Test Automation"
def execute(self, context):
return {"success": True}
automation = TestAutomation(config=None)
assert automation.config == {}
def test_validate_config_checks_required_keys(self):
"""Should raise ValueError for missing required config keys."""
from smoothschedule.scheduling.automations.registry import BaseAutomation
class TestAutomation(BaseAutomation):
name = "test_automation"
display_name = "Test Automation"
config_schema = {
"api_key": {"required": True},
}
def execute(self, context):
return {"success": True}
with pytest.raises(ValueError) as exc_info:
TestAutomation(config={})
assert "Required config key 'api_key' missing" in str(exc_info.value)
def test_validate_config_allows_optional_keys(self):
"""Should not raise for missing optional config keys."""
from smoothschedule.scheduling.automations.registry import BaseAutomation
class TestAutomation(BaseAutomation):
name = "test_automation"
display_name = "Test Automation"
config_schema = {
"optional_key": {"required": False},
}
def execute(self, context):
return {"success": True}
# Should not raise
automation = TestAutomation(config={})
assert automation.config == {}
def test_validate_config_passes_with_required_keys_present(self):
"""Should not raise when required keys are provided."""
from smoothschedule.scheduling.automations.registry import BaseAutomation
class TestAutomation(BaseAutomation):
name = "test_automation"
display_name = "Test Automation"
config_schema = {
"api_key": {"required": True},
}
def execute(self, context):
return {"success": True}
# Should not raise
automation = TestAutomation(config={"api_key": "secret"})
assert automation.config["api_key"] == "secret"
def test_can_execute_returns_true_by_default(self):
"""Should return (True, None) by default."""
from smoothschedule.scheduling.automations.registry import BaseAutomation
class TestAutomation(BaseAutomation):
name = "test_automation"
display_name = "Test Automation"
def execute(self, context):
return {"success": True}
automation = TestAutomation()
can_exec, reason = automation.can_execute({})
assert can_exec is True
assert reason is None
def test_on_success_does_nothing_by_default(self):
"""Should not raise on success callback."""
from smoothschedule.scheduling.automations.registry import BaseAutomation
class TestAutomation(BaseAutomation):
name = "test_automation"
display_name = "Test Automation"
def execute(self, context):
return {"success": True}
automation = TestAutomation()
# Should not raise
automation.on_success({"success": True})
def test_on_failure_logs_error(self):
"""Should log error on failure."""
from smoothschedule.scheduling.automations.registry import BaseAutomation
class TestAutomation(BaseAutomation):
name = "test_automation"
display_name = "Test Automation"
def execute(self, context):
return {"success": True}
automation = TestAutomation()
with patch('smoothschedule.scheduling.automations.registry.logger') as mock_logger:
automation.on_failure(Exception("Something failed"))
mock_logger.error.assert_called()
def test_get_next_run_time_returns_none_by_default(self):
"""Should return None for next run time by default."""
from smoothschedule.scheduling.automations.registry import BaseAutomation
class TestAutomation(BaseAutomation):
name = "test_automation"
display_name = "Test Automation"
def execute(self, context):
return {"success": True}
automation = TestAutomation()
result = automation.get_next_run_time(None)
assert result is None
def test_str_representation(self):
"""Should return display_name and name."""
from smoothschedule.scheduling.automations.registry import BaseAutomation
class TestAutomation(BaseAutomation):
name = "test_automation"
display_name = "Test Automation"
def execute(self, context):
return {"success": True}
automation = TestAutomation()
result = str(automation)
assert "Test Automation" in result
assert "test_automation" in result
class TestAutomationRegistry:
"""Tests for AutomationRegistry class."""
def test_init_creates_empty_automations_dict(self):
"""Should start with empty automations."""
from smoothschedule.scheduling.automations.registry import AutomationRegistry
registry = AutomationRegistry()
assert len(registry._automations) == 0
def test_register_adds_automation(self):
"""Should register an automation class."""
from smoothschedule.scheduling.automations.registry import AutomationRegistry, BaseAutomation
class TestAutomation(BaseAutomation):
name = "test_automation"
display_name = "Test Automation"
def execute(self, context):
return {"success": True}
registry = AutomationRegistry()
registry.register(TestAutomation)
assert "test_automation" in registry._automations
assert registry._automations["test_automation"] is TestAutomation
def test_register_raises_for_no_name(self):
"""Should raise ValueError if automation has no name."""
from smoothschedule.scheduling.automations.registry import AutomationRegistry, BaseAutomation
class NoNameAutomation(BaseAutomation):
name = "" # Empty name
display_name = "No Name"
def execute(self, context):
return {"success": True}
registry = AutomationRegistry()
with pytest.raises(ValueError) as exc_info:
registry.register(NoNameAutomation)
assert "must define a 'name' attribute" in str(exc_info.value)
def test_register_raises_for_duplicate_name(self):
"""Should raise ValueError for duplicate automation names."""
from smoothschedule.scheduling.automations.registry import AutomationRegistry, BaseAutomation
class Automation1(BaseAutomation):
name = "duplicate_name"
display_name = "Automation 1"
def execute(self, context):
return {"success": True}
class Automation2(BaseAutomation):
name = "duplicate_name" # Same name
display_name = "Automation 2"
def execute(self, context):
return {"success": True}
registry = AutomationRegistry()
registry.register(Automation1)
with pytest.raises(ValueError) as exc_info:
registry.register(Automation2)
assert "already registered" in str(exc_info.value)
def test_unregister_removes_automation(self):
"""Should unregister an automation by name."""
from smoothschedule.scheduling.automations.registry import AutomationRegistry, BaseAutomation
class TestAutomation(BaseAutomation):
name = "test_automation"
display_name = "Test Automation"
def execute(self, context):
return {"success": True}
registry = AutomationRegistry()
registry.register(TestAutomation)
assert "test_automation" in registry._automations
registry.unregister("test_automation")
assert "test_automation" not in registry._automations
def test_unregister_does_nothing_for_unknown_automation(self):
"""Should not raise when unregistering unknown automation."""
from smoothschedule.scheduling.automations.registry import AutomationRegistry
registry = AutomationRegistry()
# Should not raise
registry.unregister("nonexistent_automation")
def test_get_returns_automation_class(self):
"""Should return automation class by name."""
from smoothschedule.scheduling.automations.registry import AutomationRegistry, BaseAutomation
class TestAutomation(BaseAutomation):
name = "test_automation"
display_name = "Test Automation"
def execute(self, context):
return {"success": True}
registry = AutomationRegistry()
registry.register(TestAutomation)
result = registry.get("test_automation")
assert result is TestAutomation
def test_get_returns_none_for_unknown(self):
"""Should return None for unknown automation name."""
from smoothschedule.scheduling.automations.registry import AutomationRegistry
registry = AutomationRegistry()
result = registry.get("nonexistent")
assert result is None
def test_get_instance_returns_automation_instance(self):
"""Should return configured automation instance."""
from smoothschedule.scheduling.automations.registry import AutomationRegistry, BaseAutomation
class TestAutomation(BaseAutomation):
name = "test_automation"
display_name = "Test Automation"
def execute(self, context):
return {"success": True}
registry = AutomationRegistry()
registry.register(TestAutomation)
instance = registry.get_instance("test_automation", config={"key": "value"})
assert isinstance(instance, TestAutomation)
assert instance.config == {"key": "value"}
def test_get_instance_returns_none_for_unknown(self):
"""Should return None for unknown automation name."""
from smoothschedule.scheduling.automations.registry import AutomationRegistry
registry = AutomationRegistry()
result = registry.get_instance("nonexistent")
assert result is None
def test_list_all_returns_automation_metadata(self):
"""Should return list of automation metadata."""
from smoothschedule.scheduling.automations.registry import AutomationRegistry, BaseAutomation
class TestAutomation(BaseAutomation):
name = "test_automation"
display_name = "Test Automation"
description = "A test automation"
category = "testing"
def execute(self, context):
return {"success": True}
registry = AutomationRegistry()
registry.register(TestAutomation)
result = registry.list_all()
assert len(result) == 1
assert result[0]['name'] == 'test_automation'
assert result[0]['display_name'] == 'Test Automation'
assert result[0]['description'] == 'A test automation'
assert result[0]['category'] == 'testing'
def test_list_all_returns_empty_list_when_no_automations(self):
"""Should return empty list when no automations registered."""
from smoothschedule.scheduling.automations.registry import AutomationRegistry
registry = AutomationRegistry()
result = registry.list_all()
assert result == []
def test_list_by_category_groups_automations(self):
"""Should group automations by category."""
from smoothschedule.scheduling.automations.registry import AutomationRegistry, BaseAutomation
class Automation1(BaseAutomation):
name = "automation1"
display_name = "Automation 1"
category = "category_a"
def execute(self, context):
return {"success": True}
class Automation2(BaseAutomation):
name = "automation2"
display_name = "Automation 2"
category = "category_b"
def execute(self, context):
return {"success": True}
class Automation3(BaseAutomation):
name = "automation3"
display_name = "Automation 3"
category = "category_a"
def execute(self, context):
return {"success": True}
registry = AutomationRegistry()
registry.register(Automation1)
registry.register(Automation2)
registry.register(Automation3)
result = registry.list_by_category()
assert 'category_a' in result
assert 'category_b' in result
assert len(result['category_a']) == 2
assert len(result['category_b']) == 1
class TestGlobalRegistry:
"""Tests for global automation registry."""
def test_registry_is_automation_registry_instance(self):
"""Should have a global registry instance."""
from smoothschedule.scheduling.automations.registry import registry, AutomationRegistry
assert isinstance(registry, AutomationRegistry)
class TestRegisterAutomationDecorator:
"""Tests for register_automation decorator."""
def test_decorator_registers_automation(self):
"""Should register automation when used as decorator."""
from smoothschedule.scheduling.automations.registry import AutomationRegistry, BaseAutomation
registry = AutomationRegistry()
# Create a decorator that uses this specific registry
def register_automation(automation_class):
registry.register(automation_class)
return automation_class
@register_automation
class DecoratedAutomation(BaseAutomation):
name = "decorated_automation"
display_name = "Decorated Automation"
def execute(self, context):
return {"success": True}
assert "decorated_automation" in registry._automations
def test_decorator_returns_same_class(self):
"""Should return the same class after decoration."""
from smoothschedule.scheduling.automations.registry import AutomationRegistry, BaseAutomation
registry = AutomationRegistry()
def register_automation(automation_class):
registry.register(automation_class)
return automation_class
@register_automation
class DecoratedAutomation(BaseAutomation):
name = "decorated_automation2"
display_name = "Decorated Automation 2"
def execute(self, context):
return {"success": True}
assert DecoratedAutomation.name == "decorated_automation2"
class TestAutomationExecution:
"""Tests for automation execution functionality."""
def test_execute_abstract_method(self):
"""Should have execute as abstract method."""
from smoothschedule.scheduling.automations.registry import BaseAutomation
from abc import ABC
# BaseAutomation should be abstract
assert issubclass(BaseAutomation, ABC)
def test_concrete_automation_can_execute(self):
"""Should allow concrete automation execution."""
from smoothschedule.scheduling.automations.registry import BaseAutomation
class ConcreteAutomation(BaseAutomation):
name = "concrete_automation"
display_name = "Concrete Automation"
def execute(self, context):
return {
"success": True,
"data": context.get("input", "no input")
}
automation = ConcreteAutomation()
result = automation.execute({"input": "test data"})
assert result["success"] is True
assert result["data"] == "test data"
def test_automation_with_custom_can_execute(self):
"""Should allow overriding can_execute."""
from smoothschedule.scheduling.automations.registry import BaseAutomation
class ConditionalAutomation(BaseAutomation):
name = "conditional_automation"
display_name = "Conditional Automation"
def can_execute(self, context):
if not context.get("has_permission"):
return False, "Missing permission"
return True, None
def execute(self, context):
return {"success": True}
automation = ConditionalAutomation()
# Without permission
can_exec, reason = automation.can_execute({})
assert can_exec is False
assert reason == "Missing permission"
# With permission
can_exec, reason = automation.can_execute({"has_permission": True})
assert can_exec is True
assert reason is None
def test_automation_with_custom_next_run_time(self):
"""Should allow overriding get_next_run_time."""
from smoothschedule.scheduling.automations.registry import BaseAutomation
from django.utils import timezone
from datetime import timedelta
class ScheduledAutomation(BaseAutomation):
name = "scheduled_automation"
display_name = "Scheduled Automation"
def get_next_run_time(self, last_run):
if last_run is None:
return timezone.now()
return last_run + timedelta(hours=1)
def execute(self, context):
return {"success": True}
automation = ScheduledAutomation()
# First run (no last_run)
next_run = automation.get_next_run_time(None)
assert next_run is not None
# Subsequent run
now = timezone.now()
next_run = automation.get_next_run_time(now)
assert next_run == now + timedelta(hours=1)

View File

@@ -0,0 +1,39 @@
"""
URL Configuration for the automations app.
Routes:
- /automations/ - List available automations from registry
- /automation-templates/ - CRUD for automation templates
- /automation-installations/ - Manage installed automations
- /event-automations/ - Attach automations to events
- /global-event-automations/ - Global automation rules
"""
from rest_framework.routers import DefaultRouter
from .views import (
AutomationViewSet,
AutomationTemplateViewSet,
AutomationInstallationViewSet,
EventAutomationViewSet,
GlobalEventAutomationViewSet,
)
router = DefaultRouter()
# Registry-based automations (built-in + custom)
router.register(r'automations', AutomationViewSet, basename='automation')
# Automation templates (marketplace)
router.register(r'automation-templates', AutomationTemplateViewSet, basename='automationtemplate')
# Installed automations
router.register(r'automation-installations', AutomationInstallationViewSet, basename='automationinstallation')
# Event-attached automations
router.register(r'event-automations', EventAutomationViewSet, basename='eventautomation')
# Global event automation rules
router.register(r'global-event-automations', GlobalEventAutomationViewSet, basename='globaleventautomation')
urlpatterns = router.urls

View File

@@ -0,0 +1,826 @@
"""
ViewSets for the automations app.
Provides API endpoints for:
- AutomationViewSet: List available automations from registry
- AutomationTemplateViewSet: CRUD for automation templates
- AutomationInstallationViewSet: Manage installed automations
- EventAutomationViewSet: Attach automations to events
- GlobalEventAutomationViewSet: Global automation rules
"""
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from django.core.exceptions import ValidationError as DjangoValidationError
from .models import (
AutomationTemplate,
AutomationInstallation,
EventAutomation,
GlobalEventAutomation,
)
from .serializers import (
AutomationInfoSerializer,
AutomationTemplateSerializer,
AutomationTemplateListSerializer,
AutomationInstallationSerializer,
EventAutomationSerializer,
GlobalEventAutomationSerializer,
)
from smoothschedule.scheduling.schedule.models import ScheduledTask
class AutomationViewSet(viewsets.ViewSet):
"""
API endpoint for listing available automations from the registry.
Features:
- List all registered automations
- Get automation details
- List automations by category
"""
permission_classes = [AllowAny] # TODO: Change to IsAuthenticated for production
def list(self, request):
"""List all available automations"""
from .registry import registry
automations = registry.list_all()
serializer = AutomationInfoSerializer(automations, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'])
def by_category(self, request):
"""List automations grouped by category"""
from .registry import registry
automations_by_category = registry.list_by_category()
return Response(automations_by_category)
def retrieve(self, request, pk=None):
"""Get details for a specific automation"""
from .registry import registry
automation_class = registry.get(pk)
if not automation_class:
return Response(
{'error': f"Automation '{pk}' not found"},
status=status.HTTP_404_NOT_FOUND
)
automation_info = {
'name': automation_class.name,
'display_name': automation_class.display_name,
'description': automation_class.description,
'category': automation_class.category,
'config_schema': automation_class.config_schema,
}
serializer = AutomationInfoSerializer(automation_info)
return Response(serializer.data)
class AutomationTemplateViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing automation templates.
Features:
- List all automation templates (filtered by visibility)
- Create new automation templates
- Update existing templates
- Delete templates
- Publish to marketplace
- Unpublish from marketplace
- Install a template as a ScheduledTask
- Request approval (for marketplace publishing)
- Approve/reject templates (platform admins only)
Permissions:
- Marketplace view: Always accessible (for discovery)
- My Automations view: Requires can_use_automations feature
- Install action: Requires can_use_automations feature
- Create: Requires can_use_automations AND can_create_automations features
"""
queryset = AutomationTemplate.objects.all()
serializer_class = AutomationTemplateSerializer
permission_classes = [AllowAny] # TODO: Change to IsAuthenticated for production
ordering = ['-created_at']
filterset_fields = ['visibility', 'category', 'is_approved']
search_fields = ['name', 'short_description', 'description', 'tags']
def _has_automations_permission(self):
"""Check if tenant has permission to use automations."""
tenant = getattr(self.request, 'tenant', None)
if tenant:
# Check for new feature name, fall back to old name for compatibility
return tenant.has_feature('can_use_automations') or tenant.has_feature('can_use_plugins')
return True # Allow if no tenant context
def get_queryset(self):
"""
Filter templates based on user permissions.
- Marketplace view: Only approved PUBLIC templates (always accessible)
- My Automations: User's own templates (requires can_use_automations)
- Platform admins: All templates
"""
queryset = super().get_queryset()
view_mode = self.request.query_params.get('view', 'marketplace')
if view_mode == 'marketplace':
# Public marketplace - platform official + approved public templates
# Always accessible for discovery/marketing purposes
from django.db.models import Q
queryset = queryset.filter(
Q(visibility=AutomationTemplate.Visibility.PLATFORM) |
Q(visibility=AutomationTemplate.Visibility.PUBLIC, is_approved=True)
)
elif view_mode == 'my_automations' or view_mode == 'my_plugins':
# User's own templates - requires automation permission
if not self._has_automations_permission():
queryset = queryset.none()
elif self.request.user.is_authenticated:
queryset = queryset.filter(author=self.request.user)
else:
queryset = queryset.none()
elif view_mode == 'platform':
# Platform official automations - always accessible for discovery
queryset = queryset.filter(visibility=AutomationTemplate.Visibility.PLATFORM)
# else: all templates (for platform admins)
# Filter by category if provided
category = self.request.query_params.get('category')
if category:
queryset = queryset.filter(category=category)
# Filter by search query
search = self.request.query_params.get('search')
if search:
from django.db.models import Q
queryset = queryset.filter(
Q(name__icontains=search) |
Q(short_description__icontains=search) |
Q(description__icontains=search) |
Q(tags__icontains=search)
)
return queryset
def get_serializer_class(self):
"""Use lightweight serializer for list view"""
if self.action == 'list':
return AutomationTemplateListSerializer
return AutomationTemplateSerializer
def perform_create(self, serializer):
"""Set author and extract template variables on create"""
from smoothschedule.scheduling.schedule.template_parser import TemplateVariableParser
from rest_framework.exceptions import PermissionDenied
# Check permission to use automations first
tenant = getattr(self.request, 'tenant', None)
if tenant and not (tenant.has_feature('can_use_automations') or tenant.has_feature('can_use_plugins')):
raise PermissionDenied(
"Your current plan does not include Automation access. "
"Please upgrade your subscription to use automations."
)
# Check permission to create automations
if tenant and not (tenant.has_feature('can_create_automations') or tenant.has_feature('can_create_plugins')):
raise PermissionDenied(
"Your current plan does not include Automation Creation. "
"Please upgrade your subscription to create custom automations."
)
plugin_code = serializer.validated_data.get('plugin_code', '')
template_vars = TemplateVariableParser.extract_variables(plugin_code)
# Convert to dict format expected by model
template_vars_dict = {var['name']: var for var in template_vars}
serializer.save(
author=self.request.user if self.request.user.is_authenticated else None,
template_variables=template_vars_dict
)
@action(detail=True, methods=['post'])
def publish(self, request, pk=None):
"""Publish template to marketplace (requires approval)"""
template = self.get_object()
# Check ownership
if template.author != request.user:
return Response(
{'error': 'You can only publish your own templates'},
status=status.HTTP_403_FORBIDDEN
)
# Check if approved
if not template.is_approved:
return Response(
{'error': 'Template must be approved before publishing to marketplace'},
status=status.HTTP_400_BAD_REQUEST
)
# Publish
try:
template.publish_to_marketplace(request.user)
return Response({
'message': 'Template published to marketplace successfully',
'slug': template.slug
})
except DjangoValidationError as e:
return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=['post'])
def unpublish(self, request, pk=None):
"""Unpublish template from marketplace"""
template = self.get_object()
# Check ownership
if template.author != request.user:
return Response(
{'error': 'You can only unpublish your own templates'},
status=status.HTTP_403_FORBIDDEN
)
template.unpublish_from_marketplace()
return Response({
'message': 'Template unpublished from marketplace successfully'
})
@action(detail=True, methods=['post'])
def install(self, request, pk=None):
"""
Install an automation template as a ScheduledTask.
Expects:
{
"name": "Task Name",
"description": "Task Description",
"config_values": {"variable1": "value1", ...},
"schedule_type": "CRON",
"cron_expression": "0 0 * * *"
}
"""
# Check permission to use automations
tenant = getattr(request, 'tenant', None)
if tenant and not (tenant.has_feature('can_use_automations') or tenant.has_feature('can_use_plugins')):
return Response(
{'error': 'Your current plan does not include Automation access. Please upgrade your subscription to install automations.'},
status=status.HTTP_403_FORBIDDEN
)
template = self.get_object()
# Check if template is accessible
if template.visibility == AutomationTemplate.Visibility.PRIVATE:
if not request.user.is_authenticated or template.author != request.user:
return Response(
{'error': 'This template is private'},
status=status.HTTP_403_FORBIDDEN
)
elif template.visibility == AutomationTemplate.Visibility.PUBLIC:
if not template.is_approved:
return Response(
{'error': 'This template has not been approved'},
status=status.HTTP_400_BAD_REQUEST
)
# Create ScheduledTask from template
from smoothschedule.scheduling.schedule.template_parser import TemplateVariableParser
name = request.data.get('name')
description = request.data.get('description', '')
config_values = request.data.get('config_values', {})
schedule_type = request.data.get('schedule_type')
cron_expression = request.data.get('cron_expression')
interval_minutes = request.data.get('interval_minutes')
run_at = request.data.get('run_at')
if not name:
return Response(
{'error': 'name is required'},
status=status.HTTP_400_BAD_REQUEST
)
# Compile template with config values
try:
compiled_code = TemplateVariableParser.compile_template(
template.plugin_code,
config_values,
context={} # TODO: Add business context
)
except ValueError as e:
return Response(
{'error': f'Configuration error: {str(e)}'},
status=status.HTTP_400_BAD_REQUEST
)
# Create ScheduledTask
scheduled_task = ScheduledTask.objects.create(
name=name,
description=description,
plugin_name='custom_script', # Use custom script automation
plugin_code=compiled_code,
plugin_config={},
schedule_type=schedule_type,
cron_expression=cron_expression,
interval_minutes=interval_minutes,
run_at=run_at,
status=ScheduledTask.Status.ACTIVE,
created_by=request.user if request.user.is_authenticated else None
)
# Create AutomationInstallation record
installation = AutomationInstallation.objects.create(
template=template,
scheduled_task=scheduled_task,
installed_by=request.user if request.user.is_authenticated else None,
config_values=config_values,
template_version_hash=template.plugin_code_hash
)
# Increment install count
template.install_count += 1
template.save(update_fields=['install_count'])
return Response({
'message': 'Automation installed successfully',
'scheduled_task_id': scheduled_task.id,
'installation_id': installation.id
}, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['post'])
def request_approval(self, request, pk=None):
"""Request approval for marketplace publishing"""
template = self.get_object()
# Check ownership
if template.author != request.user:
return Response(
{'error': 'You can only request approval for your own templates'},
status=status.HTTP_403_FORBIDDEN
)
# Check if already approved or pending
if template.is_approved:
return Response(
{'error': 'Template is already approved'},
status=status.HTTP_400_BAD_REQUEST
)
# Validate automation code
validation = template.can_be_published()
if not validation:
from smoothschedule.scheduling.schedule.safe_scripting import validate_plugin_whitelist
errors = validate_plugin_whitelist(template.plugin_code)
return Response(
{'error': 'Template has validation errors', 'errors': errors['errors']},
status=status.HTTP_400_BAD_REQUEST
)
# TODO: Notify platform admins about approval request
# For now, just return success
return Response({
'message': 'Approval requested successfully. A platform administrator will review your automation.',
'template_id': template.id
})
@action(detail=True, methods=['post'])
def approve(self, request, pk=None):
"""Approve template for marketplace (platform admins only)"""
# TODO: Add permission check for platform admins
# if not request.user.has_perm('can_approve_automations'):
# return Response({'error': 'Permission denied'}, status=status.HTTP_403_FORBIDDEN)
template = self.get_object()
if template.is_approved:
return Response(
{'error': 'Template is already approved'},
status=status.HTTP_400_BAD_REQUEST
)
# Validate automation code
from smoothschedule.scheduling.schedule.safe_scripting import validate_plugin_whitelist
validation = validate_plugin_whitelist(template.plugin_code, scheduled_task=None)
if not validation['valid']:
return Response(
{'error': 'Template has validation errors', 'errors': validation['errors']},
status=status.HTTP_400_BAD_REQUEST
)
# Approve
from django.utils import timezone
template.is_approved = True
template.approved_by = request.user if request.user.is_authenticated else None
template.approved_at = timezone.now()
template.rejection_reason = ''
template.save()
return Response({
'message': 'Template approved successfully',
'template_id': template.id
})
@action(detail=True, methods=['post'])
def reject(self, request, pk=None):
"""Reject template for marketplace (platform admins only)"""
# TODO: Add permission check for platform admins
# if not request.user.has_perm('can_approve_automations'):
# return Response({'error': 'Permission denied'}, status=status.HTTP_403_FORBIDDEN)
template = self.get_object()
reason = request.data.get('reason', 'No reason provided')
template.is_approved = False
template.rejection_reason = reason
template.save()
return Response({
'message': 'Template rejected',
'reason': reason
})
class AutomationInstallationViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing automation installations.
Features:
- List user's installed automations
- View installation details
- Update installation (update to latest version)
- Uninstall automation
- Rate and review automation
Permissions:
- Requires can_use_automations feature for all operations
"""
queryset = AutomationInstallation.objects.select_related('template', 'scheduled_task').all()
serializer_class = AutomationInstallationSerializer
permission_classes = [AllowAny] # TODO: Change to IsAuthenticated for production
ordering = ['-installed_at']
def _check_automations_permission(self):
"""Check if tenant has permission to access automation installations."""
from rest_framework.exceptions import PermissionDenied
tenant = getattr(self.request, 'tenant', None)
if tenant and not (tenant.has_feature('can_use_automations') or tenant.has_feature('can_use_plugins')):
raise PermissionDenied(
"Your current plan does not include Automation access. "
"Please upgrade your subscription to use automations."
)
def list(self, request, *args, **kwargs):
"""List automation installations with permission check."""
self._check_automations_permission()
return super().list(request, *args, **kwargs)
def retrieve(self, request, *args, **kwargs):
"""Retrieve an automation installation with permission check."""
self._check_automations_permission()
return super().retrieve(request, *args, **kwargs)
def get_queryset(self):
"""Return installations for current user/tenant"""
queryset = super().get_queryset()
# TODO: Filter by tenant when multi-tenancy is fully enabled
# if self.request.user.is_authenticated and self.request.user.tenant:
# queryset = queryset.filter(scheduled_task__tenant=self.request.user.tenant)
return queryset
def perform_create(self, serializer):
"""Check permission to use automations before installing"""
from rest_framework.exceptions import PermissionDenied
# Check permission to use automations
tenant = getattr(self.request, 'tenant', None)
if tenant and not (tenant.has_feature('can_use_automations') or tenant.has_feature('can_use_plugins')):
raise PermissionDenied(
"Your current plan does not include Automation access. "
"Please upgrade your subscription to use automations."
)
serializer.save()
@action(detail=True, methods=['post'])
def update_to_latest(self, request, pk=None):
"""Update installed automation to latest template version"""
installation = self.get_object()
if not installation.has_update_available():
return Response(
{'error': 'No update available'},
status=status.HTTP_400_BAD_REQUEST
)
try:
installation.update_to_latest()
return Response({
'message': 'Automation updated successfully',
'new_version_hash': installation.template_version_hash
})
except DjangoValidationError as e:
return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=['post'])
def rate(self, request, pk=None):
"""Rate an installed automation"""
installation = self.get_object()
rating = request.data.get('rating')
review = request.data.get('review', '')
if not rating or not isinstance(rating, int) or rating < 1 or rating > 5:
return Response(
{'error': 'Rating must be an integer between 1 and 5'},
status=status.HTTP_400_BAD_REQUEST
)
# Update installation
from django.utils import timezone
installation.rating = rating
installation.review = review
installation.reviewed_at = timezone.now()
installation.save()
# Update template average rating
if installation.template:
template = installation.template
ratings = AutomationInstallation.objects.filter(
template=template,
rating__isnull=False
).values_list('rating', flat=True)
if ratings:
from decimal import Decimal
template.rating_average = Decimal(sum(ratings)) / Decimal(len(ratings))
template.rating_count = len(ratings)
template.save(update_fields=['rating_average', 'rating_count'])
return Response({
'message': 'Rating submitted successfully',
'rating': rating
})
def destroy(self, request, *args, **kwargs):
"""Uninstall automation (delete ScheduledTask and Installation)"""
installation = self.get_object()
# Delete the scheduled task (this will cascade delete the installation)
if installation.scheduled_task:
installation.scheduled_task.delete()
else:
# If scheduled task was already deleted, just delete the installation
installation.delete()
return Response({
'message': 'Automation uninstalled successfully'
}, status=status.HTTP_204_NO_CONTENT)
class EventAutomationViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing automations attached to calendar events.
This allows users to attach installed automations to events with configurable
timing triggers (before start, at start, after end, on complete, etc.)
Endpoints:
- GET /api/event-automations/?event_id=X - List automations for an event
- POST /api/event-automations/ - Attach automation to event
- PATCH /api/event-automations/{id}/ - Update timing/trigger
- DELETE /api/event-automations/{id}/ - Remove automation from event
- POST /api/event-automations/{id}/toggle/ - Enable/disable automation
"""
queryset = EventAutomation.objects.select_related(
'event',
'automation_installation',
'automation_installation__template'
).all()
serializer_class = EventAutomationSerializer
permission_classes = [AllowAny] # TODO: Change to IsAuthenticated
def get_queryset(self):
"""Filter by event if specified"""
queryset = super().get_queryset()
event_id = self.request.query_params.get('event_id')
if event_id:
queryset = queryset.filter(event_id=event_id)
return queryset.order_by('execution_order', 'created_at')
def perform_create(self, serializer):
"""Check permission to use automations before attaching to event"""
from rest_framework.exceptions import PermissionDenied
tenant = getattr(self.request, 'tenant', None)
if tenant and not (tenant.has_feature('can_use_automations') or tenant.has_feature('can_use_plugins')):
raise PermissionDenied(
"Your current plan does not include Automation access. "
"Please upgrade your subscription to use automations."
)
serializer.save()
def list(self, request):
"""
List event automations.
Query params:
- event_id: Filter by event (required for listing)
"""
event_id = request.query_params.get('event_id')
if not event_id:
return Response({
'error': 'event_id query parameter is required'
}, status=status.HTTP_400_BAD_REQUEST)
queryset = self.get_queryset()
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@action(detail=True, methods=['post'])
def toggle(self, request, pk=None):
"""Toggle is_active status of an event automation"""
event_automation = self.get_object()
event_automation.is_active = not event_automation.is_active
event_automation.save(update_fields=['is_active'])
serializer = self.get_serializer(event_automation)
return Response(serializer.data)
@action(detail=False, methods=['get'])
def triggers(self, request):
"""
Get available trigger options for the UI.
Returns trigger choices with human-readable labels and
common offset presets.
"""
return Response({
'triggers': [
{'value': choice[0], 'label': choice[1]}
for choice in EventAutomation.Trigger.choices
],
'offset_presets': [
{'value': 0, 'label': 'Immediately'},
{'value': 5, 'label': '5 minutes'},
{'value': 10, 'label': '10 minutes'},
{'value': 15, 'label': '15 minutes'},
{'value': 30, 'label': '30 minutes'},
{'value': 60, 'label': '1 hour'},
{'value': 120, 'label': '2 hours'},
{'value': 1440, 'label': '1 day'},
],
'timing_groups': [
{
'label': 'Before Event',
'triggers': ['before_start'],
'supports_offset': True,
},
{
'label': 'During Event',
'triggers': ['at_start', 'after_start'],
'supports_offset': True,
},
{
'label': 'After Event',
'triggers': ['after_end'],
'supports_offset': True,
},
{
'label': 'Status Changes',
'triggers': ['on_complete', 'on_cancel'],
'supports_offset': False,
},
]
})
class GlobalEventAutomationViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing global event automation rules.
Global event automations automatically attach to ALL events - both existing
events and new events as they are created.
Use this for automation rules that should apply across the board, such as:
- Sending confirmation emails for all appointments
- Logging all event completions
- Running cleanup after every event
Endpoints:
- GET /api/global-event-automations/ - List all global rules
- POST /api/global-event-automations/ - Create rule (auto-applies to existing events)
- GET /api/global-event-automations/{id}/ - Get rule details
- PATCH /api/global-event-automations/{id}/ - Update rule
- DELETE /api/global-event-automations/{id}/ - Delete rule
- POST /api/global-event-automations/{id}/toggle/ - Enable/disable rule
- POST /api/global-event-automations/{id}/reapply/ - Reapply to all events
"""
queryset = GlobalEventAutomation.objects.select_related(
'automation_installation',
'automation_installation__template',
'created_by'
).all()
serializer_class = GlobalEventAutomationSerializer
permission_classes = [AllowAny] # TODO: Change to IsAuthenticated
def get_queryset(self):
"""Optionally filter by active status"""
queryset = super().get_queryset()
is_active = self.request.query_params.get('is_active')
if is_active is not None:
queryset = queryset.filter(is_active=is_active.lower() == 'true')
return queryset.order_by('execution_order', 'created_at')
def perform_create(self, serializer):
"""Check permission to use automations and set created_by on creation"""
from rest_framework.exceptions import PermissionDenied
tenant = getattr(self.request, 'tenant', None)
if tenant and not (tenant.has_feature('can_use_automations') or tenant.has_feature('can_use_plugins')):
raise PermissionDenied(
"Your current plan does not include Automation access. "
"Please upgrade your subscription to use automations."
)
user = self.request.user if self.request.user.is_authenticated else None
serializer.save(created_by=user)
@action(detail=True, methods=['post'])
def toggle(self, request, pk=None):
"""Toggle is_active status of a global event automation rule"""
global_automation = self.get_object()
global_automation.is_active = not global_automation.is_active
global_automation.save(update_fields=['is_active', 'updated_at'])
serializer = self.get_serializer(global_automation)
return Response(serializer.data)
@action(detail=True, methods=['post'])
def reapply(self, request, pk=None):
"""
Reapply this global rule to all events.
Useful if:
- Events were created while the rule was inactive
- Automation attachments were manually removed
"""
global_automation = self.get_object()
if not global_automation.is_active:
return Response({
'error': 'Cannot reapply inactive rule. Enable it first.'
}, status=status.HTTP_400_BAD_REQUEST)
count = global_automation.apply_to_all_events()
return Response({
'message': f'Applied to {count} events',
'events_affected': count
})
@action(detail=False, methods=['get'])
def triggers(self, request):
"""
Get available trigger options for the UI.
Returns trigger choices with human-readable labels and
common offset presets (same as EventAutomation).
"""
return Response({
'triggers': [
{'value': choice[0], 'label': choice[1]}
for choice in EventAutomation.Trigger.choices
],
'offset_presets': [
{'value': 0, 'label': 'Immediately'},
{'value': 5, 'label': '5 minutes'},
{'value': 10, 'label': '10 minutes'},
{'value': 15, 'label': '15 minutes'},
{'value': 30, 'label': '30 minutes'},
{'value': 60, 'label': '1 hour'},
],
})
# Backwards compatibility aliases (deprecated, will be removed in future)
PluginViewSet = AutomationViewSet
PluginTemplateViewSet = AutomationTemplateViewSet
PluginInstallationViewSet = AutomationInstallationViewSet
EventPluginViewSet = EventAutomationViewSet
GlobalEventPluginViewSet = GlobalEventAutomationViewSet

View File

@@ -1,526 +0,0 @@
"""
Example: Smart Client Re-engagement Automation
This demonstrates a real-world automation that businesses would love:
Automatically win back customers who haven't booked in a while.
"""
from typing import Any, Dict
from django.utils import timezone
from django.core.mail import send_mail
from django.conf import settings
from django.db.models import Max, Count, Q
from datetime import timedelta
import logging
from .plugins import BasePlugin, register_plugin, PluginExecutionError
logger = logging.getLogger(__name__)
@register_plugin
class ClientReengagementPlugin(BasePlugin):
"""
Automatically re-engage customers who haven't booked recently.
This plugin:
1. Finds customers who haven't booked in X days
2. Sends personalized re-engagement emails
3. Offers optional discount codes
4. Tracks engagement metrics
"""
name = "client_reengagement"
display_name = "Client Re-engagement Campaign"
description = "Automatically reach out to customers who haven't booked recently with personalized offers"
category = "marketing"
config_schema = {
'days_inactive': {
'type': 'integer',
'required': False,
'default': 60,
'description': 'Target customers who haven\'t booked in this many days (default: 60)',
},
'email_subject': {
'type': 'string',
'required': False,
'default': 'We Miss You! Come Back Soon',
'description': 'Email subject line',
},
'email_message': {
'type': 'text',
'required': False,
'default': '''Hi {customer_name},
We noticed it's been a while since your last visit on {last_visit_date}. We'd love to see you again!
As a valued customer, we're offering you {discount}% off your next appointment.
Book now and use code: {promo_code}
Looking forward to seeing you soon!
{business_name}''',
'description': 'Email message template (variables: customer_name, last_visit_date, business_name, discount, promo_code)',
},
'discount_percentage': {
'type': 'integer',
'required': False,
'default': 15,
'description': 'Discount percentage to offer (default: 15%)',
},
'promo_code_prefix': {
'type': 'string',
'required': False,
'default': 'COMEBACK',
'description': 'Prefix for generated promo codes (default: COMEBACK)',
},
'max_customers_per_run': {
'type': 'integer',
'required': False,
'default': 50,
'description': 'Maximum customers to contact per execution (prevents spam)',
},
'exclude_already_contacted': {
'type': 'boolean',
'required': False,
'default': True,
'description': 'Skip customers who were already contacted by this campaign',
},
'minimum_past_visits': {
'type': 'integer',
'required': False,
'default': 1,
'description': 'Only target customers with at least this many past visits',
},
}
def execute(self, context: Dict[str, Any]) -> Dict[str, Any]:
"""Execute the re-engagement campaign"""
from .models import Event
from smoothschedule.identity.users.models import User
business = context.get('business')
if not business:
raise PluginExecutionError("No business context available")
# Get configuration
days_inactive = self.config.get('days_inactive', 60)
max_customers = self.config.get('max_customers_per_run', 50)
min_visits = self.config.get('minimum_past_visits', 1)
# Calculate cutoff date
cutoff_date = timezone.now() - timedelta(days=days_inactive)
# Find inactive customers
inactive_customers = self._find_inactive_customers(
cutoff_date=cutoff_date,
min_visits=min_visits,
max_count=max_customers
)
if not inactive_customers:
return {
'success': True,
'message': 'No inactive customers found',
'data': {
'customers_contacted': 0,
'emails_sent': 0,
}
}
# Send re-engagement emails
results = {
'customers_contacted': 0,
'emails_sent': 0,
'emails_failed': 0,
'customers': [],
}
for customer_data in inactive_customers:
try:
success = self._send_reengagement_email(
customer_data=customer_data,
business=business
)
if success:
results['emails_sent'] += 1
results['customers_contacted'] += 1
results['customers'].append({
'email': customer_data['email'],
'last_visit': customer_data['last_visit'].isoformat(),
'days_since_visit': customer_data['days_since_visit'],
})
else:
results['emails_failed'] += 1
except Exception as e:
logger.error(f"Failed to send re-engagement email to {customer_data['email']}: {e}")
results['emails_failed'] += 1
return {
'success': True,
'message': f"Contacted {results['customers_contacted']} inactive customers",
'data': results
}
def _find_inactive_customers(self, cutoff_date, min_visits, max_count):
"""Find customers who haven't booked recently"""
from .models import Event, Participant
from smoothschedule.identity.users.models import User
from django.contrib.contenttypes.models import ContentType
# Get customer content type
customer_ct = ContentType.objects.get_for_model(User)
# Find customers with their last visit date
# This query finds all customers who participated in events
customer_participants = Participant.objects.filter(
role=Participant.Role.CUSTOMER,
content_type=customer_ct,
).values('object_id').annotate(
last_event_date=Max('event__end_time'),
total_visits=Count('event', filter=Q(event__status__in=['COMPLETED', 'PAID']))
).filter(
last_event_date__lt=cutoff_date, # Last visit before cutoff
total_visits__gte=min_visits, # Minimum number of visits
).order_by('last_event_date')[:max_count]
# Get customer details
inactive_customers = []
for participant_data in customer_participants:
try:
customer = User.objects.get(id=participant_data['object_id'])
# Skip if no email
if not customer.email:
continue
days_since_visit = (timezone.now() - participant_data['last_event_date']).days
inactive_customers.append({
'id': customer.id,
'email': customer.email,
'name': customer.get_full_name() or customer.username,
'last_visit': participant_data['last_event_date'],
'days_since_visit': days_since_visit,
'total_visits': participant_data['total_visits'],
})
except User.DoesNotExist:
continue
return inactive_customers
def _send_reengagement_email(self, customer_data, business):
"""Send personalized re-engagement email to a customer"""
# Generate promo code
promo_prefix = self.config.get('promo_code_prefix', 'COMEBACK')
promo_code = f"{promo_prefix}{customer_data['id']}"
# Format email message
email_template = self.config.get('email_message', self.config_schema['email_message']['default'])
email_body = email_template.format(
customer_name=customer_data['name'],
last_visit_date=customer_data['last_visit'].strftime('%B %d, %Y'),
business_name=business.name if business else 'Our Business',
discount=self.config.get('discount_percentage', 15),
promo_code=promo_code,
)
subject = self.config.get('email_subject', 'We Miss You! Come Back Soon')
try:
send_mail(
subject=subject,
message=email_body,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[customer_data['email']],
fail_silently=False,
)
logger.info(f"Sent re-engagement email to {customer_data['email']} (promo: {promo_code})")
return True
except Exception as e:
logger.error(f"Failed to send email to {customer_data['email']}: {e}")
return False
@register_plugin
class LowShowRateAlertPlugin(BasePlugin):
"""
Alert business owners when no-show rate is unusually high.
Helps businesses identify issues early and take corrective action.
"""
name = "low_show_rate_alert"
display_name = "No-Show Rate Alert"
description = "Alert when customer no-show rate exceeds threshold"
category = "monitoring"
config_schema = {
'threshold_percentage': {
'type': 'integer',
'required': False,
'default': 20,
'description': 'Alert if no-show rate exceeds this percentage (default: 20%)',
},
'lookback_days': {
'type': 'integer',
'required': False,
'default': 7,
'description': 'Analyze appointments from the last N days (default: 7)',
},
'alert_emails': {
'type': 'list',
'required': True,
'description': 'Email addresses to notify',
},
'min_appointments': {
'type': 'integer',
'required': False,
'default': 10,
'description': 'Minimum appointments needed before alerting (avoid false positives)',
},
}
def execute(self, context: Dict[str, Any]) -> Dict[str, Any]:
"""Check no-show rate and alert if too high"""
from .models import Event
lookback_days = self.config.get('lookback_days', 7)
threshold = self.config.get('threshold_percentage', 20)
min_appointments = self.config.get('min_appointments', 10)
# Calculate date range
start_date = timezone.now() - timedelta(days=lookback_days)
# Get appointment statistics
total_appointments = Event.objects.filter(
start_time__gte=start_date,
start_time__lt=timezone.now(),
).count()
if total_appointments < min_appointments:
return {
'success': True,
'message': f'Not enough data ({total_appointments} appointments, need {min_appointments})',
'data': {'appointments': total_appointments}
}
canceled_count = Event.objects.filter(
start_time__gte=start_date,
start_time__lt=timezone.now(),
status='CANCELED',
).count()
no_show_rate = (canceled_count / total_appointments) * 100
if no_show_rate >= threshold:
# Send alert
business = context.get('business')
self._send_alert(
business=business,
no_show_rate=no_show_rate,
canceled_count=canceled_count,
total_appointments=total_appointments,
lookback_days=lookback_days
)
return {
'success': True,
'message': f'ALERT: No-show rate is {no_show_rate:.1f}% (threshold: {threshold}%)',
'data': {
'no_show_rate': round(no_show_rate, 2),
'canceled': canceled_count,
'total': total_appointments,
'alert_sent': True,
}
}
else:
return {
'success': True,
'message': f'No-show rate is healthy: {no_show_rate:.1f}%',
'data': {
'no_show_rate': round(no_show_rate, 2),
'canceled': canceled_count,
'total': total_appointments,
'alert_sent': False,
}
}
def _send_alert(self, business, no_show_rate, canceled_count, total_appointments, lookback_days):
"""Send alert email to business owners"""
alert_emails = self.config.get('alert_emails', [])
subject = f"⚠️ High No-Show Rate Alert - {business.name if business else 'Your Business'}"
message = f"""
Alert: Your no-show rate is unusually high!
No-Show Rate: {no_show_rate:.1f}%
Period: Last {lookback_days} days
Canceled Appointments: {canceled_count} out of {total_appointments}
Recommended Actions:
1. Review your confirmation process
2. Send appointment reminders 24 hours before
3. Implement a cancellation policy
4. Follow up with customers who no-showed
This is an automated alert from your scheduling system.
"""
try:
send_mail(
subject=subject,
message=message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=alert_emails,
fail_silently=False,
)
logger.info(f"Sent no-show alert to {len(alert_emails)} recipient(s)")
except Exception as e:
logger.error(f"Failed to send no-show alert: {e}")
@register_plugin
class PeakHoursAnalyzerPlugin(BasePlugin):
"""
Analyze booking patterns to identify peak hours and make staffing recommendations.
"""
name = "peak_hours_analyzer"
display_name = "Peak Hours Analyzer"
description = "Analyze booking patterns and recommend optimal staffing"
category = "analytics"
config_schema = {
'analysis_days': {
'type': 'integer',
'required': False,
'default': 30,
'description': 'Analyze data from the last N days (default: 30)',
},
'report_emails': {
'type': 'list',
'required': True,
'description': 'Email addresses to receive the analysis report',
},
'group_by': {
'type': 'choice',
'choices': ['hour', 'day_of_week', 'both'],
'required': False,
'default': 'both',
'description': 'How to group the analysis',
},
}
def execute(self, context: Dict[str, Any]) -> Dict[str, Any]:
"""Analyze booking patterns and send report"""
from .models import Event
from collections import defaultdict
analysis_days = self.config.get('analysis_days', 30)
start_date = timezone.now() - timedelta(days=analysis_days)
# Get all appointments in the period
events = Event.objects.filter(
start_time__gte=start_date,
status__in=['COMPLETED', 'PAID', 'SCHEDULED']
).values_list('start_time', flat=True)
# Analyze by hour
hourly_counts = defaultdict(int)
weekday_counts = defaultdict(int)
for event_time in events:
hour = event_time.hour
weekday = event_time.strftime('%A')
hourly_counts[hour] += 1
weekday_counts[weekday] += 1
# Find peak hours
peak_hours = sorted(hourly_counts.items(), key=lambda x: x[1], reverse=True)[:5]
peak_days = sorted(weekday_counts.items(), key=lambda x: x[1], reverse=True)[:3]
# Generate report
report = self._generate_report(
peak_hours=peak_hours,
peak_days=peak_days,
total_appointments=len(events),
analysis_days=analysis_days,
business=context.get('business')
)
# Send report
self._send_report(report)
return {
'success': True,
'message': 'Peak hours analysis completed',
'data': {
'peak_hours': [f"{h}:00" for h, _ in peak_hours],
'peak_days': [d for d, _ in peak_days],
'total_analyzed': len(events),
}
}
def _generate_report(self, peak_hours, peak_days, total_appointments, analysis_days, business):
"""Generate human-readable report"""
report = f"""
Peak Hours Analysis Report
Business: {business.name if business else 'Your Business'}
Period: Last {analysis_days} days
Total Appointments: {total_appointments}
TOP 5 BUSIEST HOURS:
"""
for hour, count in peak_hours:
percentage = (count / total_appointments * 100) if total_appointments > 0 else 0
time_label = f"{hour}:00 - {hour+1}:00"
report += f" {time_label}: {count} appointments ({percentage:.1f}%)\n"
report += f"\nTOP 3 BUSIEST DAYS:\n"
for day, count in peak_days:
percentage = (count / total_appointments * 100) if total_appointments > 0 else 0
report += f" {day}: {count} appointments ({percentage:.1f}%)\n"
report += f"""
RECOMMENDATIONS:
• Ensure adequate staffing during peak hours
• Consider offering promotions during slower periods
• Use this data to optimize your schedule
• Review this analysis monthly to spot trends
This is an automated report from your scheduling system.
"""
return report
def _send_report(self, report):
"""Send the analysis report via email"""
recipients = self.config.get('report_emails', [])
try:
send_mail(
subject="📊 Peak Hours Analysis Report",
message=report,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=recipients,
fail_silently=False,
)
logger.info(f"Sent peak hours report to {len(recipients)} recipient(s)")
except Exception as e:
logger.error(f"Failed to send peak hours report: {e}")

View File

@@ -931,7 +931,7 @@ class ScheduledTask(models.Model):
def get_plugin_instance(self):
"""Get configured plugin instance for this task"""
from .plugins import registry
from smoothschedule.scheduling.automations.registry import registry
return registry.get_instance(self.plugin_name, self.plugin_config)
def update_next_run_time(self):

View File

@@ -1,239 +0,0 @@
"""
Plugin system for automated tasks.
Plugins are Python classes that define automated tasks that can be scheduled
and executed without requiring resource allocation.
Example plugin:
class SendWeeklyReportPlugin(BasePlugin):
name = "send_weekly_report"
display_name = "Send Weekly Report"
description = "Emails a weekly business report to managers"
def execute(self, context):
# Plugin implementation
return {"success": True, "message": "Report sent"}
"""
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
from django.utils import timezone
import logging
logger = logging.getLogger(__name__)
class PluginExecutionError(Exception):
"""Raised when a plugin fails to execute"""
pass
class BasePlugin(ABC):
"""
Base class for all scheduler plugins.
Subclass this to create custom automated tasks.
"""
# Plugin metadata (override in subclasses)
name: str = "" # Unique identifier (snake_case)
display_name: str = "" # Human-readable name
description: str = "" # What this plugin does
category: str = "general" # Plugin category for organization
# Configuration schema (override if plugin accepts config)
config_schema: Dict[str, Any] = {}
def __init__(self, config: Optional[Dict[str, Any]] = None):
"""
Initialize plugin with configuration.
Args:
config: Plugin-specific configuration dictionary
"""
self.config = config or {}
self.validate_config()
def validate_config(self) -> None:
"""
Validate plugin configuration.
Override to add custom validation logic.
Raises:
ValueError: If configuration is invalid
"""
if self.config_schema:
for key, schema in self.config_schema.items():
if schema.get('required', False) and key not in self.config:
raise ValueError(f"Required config key '{key}' missing for plugin '{self.name}'")
@abstractmethod
def execute(self, context: Dict[str, Any]) -> Dict[str, Any]:
"""
Execute the plugin's main task.
Args:
context: Execution context containing:
- business: Current business/tenant instance
- scheduled_task: ScheduledTask instance that triggered this
- execution_time: When this execution started
- user: User who created the scheduled task (if applicable)
Returns:
Dictionary with execution results:
- success: bool - Whether execution succeeded
- message: str - Human-readable result message
- data: dict - Any additional data
Raises:
PluginExecutionError: If execution fails
"""
pass
def can_execute(self, context: Dict[str, Any]) -> tuple[bool, Optional[str]]:
"""
Check if plugin can execute in current context.
Override to add pre-execution checks.
Args:
context: Execution context
Returns:
Tuple of (can_execute: bool, reason: Optional[str])
"""
return True, None
def on_success(self, result: Dict[str, Any]) -> None:
"""
Called after successful execution.
Override for post-execution logic.
"""
pass
def on_failure(self, error: Exception) -> None:
"""
Called after failed execution.
Override for error handling logic.
"""
logger.error(f"Plugin {self.name} failed: {error}", exc_info=True)
def get_next_run_time(self, last_run: Optional[timezone.datetime]) -> Optional[timezone.datetime]:
"""
Calculate next run time based on plugin logic.
Override for custom scheduling logic.
Args:
last_run: Last execution time (None if never run)
Returns:
Next scheduled run time, or None to use schedule's default logic
"""
return None
def __str__(self) -> str:
return f"{self.display_name} ({self.name})"
class PluginRegistry:
"""
Registry for managing available plugins.
"""
def __init__(self):
self._plugins: Dict[str, type[BasePlugin]] = {}
def register(self, plugin_class: type[BasePlugin]) -> None:
"""
Register a plugin class.
Args:
plugin_class: Plugin class to register
Raises:
ValueError: If plugin name is missing or already registered
"""
if not plugin_class.name:
raise ValueError(f"Plugin class {plugin_class.__name__} must define a 'name' attribute")
if plugin_class.name in self._plugins:
raise ValueError(f"Plugin '{plugin_class.name}' is already registered")
self._plugins[plugin_class.name] = plugin_class
logger.info(f"Registered plugin: {plugin_class.name}")
def unregister(self, plugin_name: str) -> None:
"""Unregister a plugin by name"""
if plugin_name in self._plugins:
del self._plugins[plugin_name]
logger.info(f"Unregistered plugin: {plugin_name}")
def get(self, plugin_name: str) -> Optional[type[BasePlugin]]:
"""Get plugin class by name"""
return self._plugins.get(plugin_name)
def get_instance(self, plugin_name: str, config: Optional[Dict[str, Any]] = None) -> Optional[BasePlugin]:
"""
Get plugin instance by name with configuration.
Args:
plugin_name: Name of plugin to instantiate
config: Configuration dictionary
Returns:
Plugin instance or None if not found
"""
plugin_class = self.get(plugin_name)
if plugin_class:
return plugin_class(config=config)
return None
def list_all(self) -> List[Dict[str, Any]]:
"""
List all registered plugins with metadata.
Returns:
List of plugin metadata dictionaries
"""
return [
{
'name': plugin_class.name,
'display_name': plugin_class.display_name,
'description': plugin_class.description,
'category': plugin_class.category,
'config_schema': plugin_class.config_schema,
}
for plugin_class in self._plugins.values()
]
def list_by_category(self) -> Dict[str, List[Dict[str, Any]]]:
"""
List plugins grouped by category.
Returns:
Dictionary mapping category names to plugin lists
"""
categories: Dict[str, List[Dict[str, Any]]] = {}
for plugin_info in self.list_all():
category = plugin_info['category']
if category not in categories:
categories[category] = []
categories[category].append(plugin_info)
return categories
# Global plugin registry
registry = PluginRegistry()
def register_plugin(plugin_class: type[BasePlugin]) -> type[BasePlugin]:
"""
Decorator to register a plugin class.
Usage:
@register_plugin
class MyPlugin(BasePlugin):
name = "my_plugin"
...
"""
registry.register(plugin_class)
return plugin_class

View File

@@ -844,7 +844,7 @@ class ScheduledTaskSerializer(serializers.ModelSerializer):
def get_plugin_display_name(self, obj):
"""Get display name of the plugin"""
from .plugins import registry
from smoothschedule.scheduling.automations.registry import registry
plugin_class = registry.get(obj.plugin_name)
if plugin_class:
return plugin_class.display_name
@@ -876,7 +876,7 @@ class ScheduledTaskSerializer(serializers.ModelSerializer):
def validate_plugin_name(self, value):
"""Validate that the plugin exists"""
from .plugins import registry
from smoothschedule.scheduling.automations.registry import registry
if not registry.get(value):
raise serializers.ValidationError(f"Plugin '{value}' not found")
return value

View File

@@ -23,7 +23,7 @@ def execute_scheduled_task(self, scheduled_task_id: int):
dict: Execution result
"""
from .models import ScheduledTask, TaskExecutionLog
from .plugins import PluginExecutionError
from smoothschedule.scheduling.automations.registry import AutomationExecutionError as PluginExecutionError
from django.contrib.contenttypes.models import ContentType
try:
@@ -232,7 +232,7 @@ def execute_event_plugin(self, event_plugin_id: int, event_id: int = None):
dict: Execution result
"""
from .models import EventPlugin, Event
from .plugins import PluginExecutionError
from smoothschedule.scheduling.automations.registry import AutomationExecutionError as PluginExecutionError
start_time = time.time()

View File

@@ -1,585 +0,0 @@
"""
Unit tests for scheduling/schedule/plugins.py
Tests plugin system base classes and registry.
"""
from unittest.mock import Mock, patch
import pytest
class TestPluginExecutionError:
"""Tests for PluginExecutionError exception."""
def test_is_exception_class(self):
"""Should be an Exception subclass."""
from smoothschedule.scheduling.schedule.plugins import PluginExecutionError
assert issubclass(PluginExecutionError, Exception)
def test_can_be_raised_and_caught(self):
"""Should be raisable with a message."""
from smoothschedule.scheduling.schedule.plugins import PluginExecutionError
with pytest.raises(PluginExecutionError) as exc_info:
raise PluginExecutionError("Plugin failed")
assert str(exc_info.value) == "Plugin failed"
class TestBasePlugin:
"""Tests for BasePlugin abstract class."""
def test_class_exists(self):
"""Should have BasePlugin class."""
from smoothschedule.scheduling.schedule.plugins import BasePlugin
assert BasePlugin is not None
def test_has_required_attributes(self):
"""Should define required class attributes."""
from smoothschedule.scheduling.schedule.plugins import BasePlugin
assert hasattr(BasePlugin, 'name')
assert hasattr(BasePlugin, 'display_name')
assert hasattr(BasePlugin, 'description')
assert hasattr(BasePlugin, 'category')
assert hasattr(BasePlugin, 'config_schema')
def test_init_stores_config(self):
"""Should store config on initialization."""
from smoothschedule.scheduling.schedule.plugins import BasePlugin
# Create concrete implementation for testing
class TestPlugin(BasePlugin):
name = "test_plugin"
display_name = "Test Plugin"
def execute(self, context):
return {"success": True}
plugin = TestPlugin(config={"key": "value"})
assert plugin.config == {"key": "value"}
def test_init_defaults_to_empty_config(self):
"""Should default to empty config when None provided."""
from smoothschedule.scheduling.schedule.plugins import BasePlugin
class TestPlugin(BasePlugin):
name = "test_plugin"
display_name = "Test Plugin"
def execute(self, context):
return {"success": True}
plugin = TestPlugin(config=None)
assert plugin.config == {}
def test_validate_config_checks_required_keys(self):
"""Should raise ValueError for missing required config keys."""
from smoothschedule.scheduling.schedule.plugins import BasePlugin
class TestPlugin(BasePlugin):
name = "test_plugin"
display_name = "Test Plugin"
config_schema = {
"api_key": {"required": True},
}
def execute(self, context):
return {"success": True}
with pytest.raises(ValueError) as exc_info:
TestPlugin(config={})
assert "Required config key 'api_key' missing" in str(exc_info.value)
def test_validate_config_allows_optional_keys(self):
"""Should not raise for missing optional config keys."""
from smoothschedule.scheduling.schedule.plugins import BasePlugin
class TestPlugin(BasePlugin):
name = "test_plugin"
display_name = "Test Plugin"
config_schema = {
"optional_key": {"required": False},
}
def execute(self, context):
return {"success": True}
# Should not raise
plugin = TestPlugin(config={})
assert plugin.config == {}
def test_validate_config_passes_with_required_keys_present(self):
"""Should not raise when required keys are provided."""
from smoothschedule.scheduling.schedule.plugins import BasePlugin
class TestPlugin(BasePlugin):
name = "test_plugin"
display_name = "Test Plugin"
config_schema = {
"api_key": {"required": True},
}
def execute(self, context):
return {"success": True}
# Should not raise
plugin = TestPlugin(config={"api_key": "secret"})
assert plugin.config["api_key"] == "secret"
def test_can_execute_returns_true_by_default(self):
"""Should return (True, None) by default."""
from smoothschedule.scheduling.schedule.plugins import BasePlugin
class TestPlugin(BasePlugin):
name = "test_plugin"
display_name = "Test Plugin"
def execute(self, context):
return {"success": True}
plugin = TestPlugin()
can_exec, reason = plugin.can_execute({})
assert can_exec is True
assert reason is None
def test_on_success_does_nothing_by_default(self):
"""Should not raise on success callback."""
from smoothschedule.scheduling.schedule.plugins import BasePlugin
class TestPlugin(BasePlugin):
name = "test_plugin"
display_name = "Test Plugin"
def execute(self, context):
return {"success": True}
plugin = TestPlugin()
# Should not raise
plugin.on_success({"success": True})
def test_on_failure_logs_error(self):
"""Should log error on failure."""
from smoothschedule.scheduling.schedule.plugins import BasePlugin
class TestPlugin(BasePlugin):
name = "test_plugin"
display_name = "Test Plugin"
def execute(self, context):
return {"success": True}
plugin = TestPlugin()
with patch('smoothschedule.scheduling.schedule.plugins.logger') as mock_logger:
plugin.on_failure(Exception("Something failed"))
mock_logger.error.assert_called()
def test_get_next_run_time_returns_none_by_default(self):
"""Should return None for next run time by default."""
from smoothschedule.scheduling.schedule.plugins import BasePlugin
class TestPlugin(BasePlugin):
name = "test_plugin"
display_name = "Test Plugin"
def execute(self, context):
return {"success": True}
plugin = TestPlugin()
result = plugin.get_next_run_time(None)
assert result is None
def test_str_representation(self):
"""Should return display_name and name."""
from smoothschedule.scheduling.schedule.plugins import BasePlugin
class TestPlugin(BasePlugin):
name = "test_plugin"
display_name = "Test Plugin"
def execute(self, context):
return {"success": True}
plugin = TestPlugin()
result = str(plugin)
assert "Test Plugin" in result
assert "test_plugin" in result
class TestPluginRegistry:
"""Tests for PluginRegistry class."""
def test_init_creates_empty_plugins_dict(self):
"""Should start with empty plugins."""
from smoothschedule.scheduling.schedule.plugins import PluginRegistry
registry = PluginRegistry()
assert len(registry._plugins) == 0
def test_register_adds_plugin(self):
"""Should register a plugin class."""
from smoothschedule.scheduling.schedule.plugins import PluginRegistry, BasePlugin
class TestPlugin(BasePlugin):
name = "test_plugin"
display_name = "Test Plugin"
def execute(self, context):
return {"success": True}
registry = PluginRegistry()
registry.register(TestPlugin)
assert "test_plugin" in registry._plugins
assert registry._plugins["test_plugin"] is TestPlugin
def test_register_raises_for_no_name(self):
"""Should raise ValueError if plugin has no name."""
from smoothschedule.scheduling.schedule.plugins import PluginRegistry, BasePlugin
class NoNamePlugin(BasePlugin):
name = "" # Empty name
display_name = "No Name"
def execute(self, context):
return {"success": True}
registry = PluginRegistry()
with pytest.raises(ValueError) as exc_info:
registry.register(NoNamePlugin)
assert "must define a 'name' attribute" in str(exc_info.value)
def test_register_raises_for_duplicate_name(self):
"""Should raise ValueError for duplicate plugin names."""
from smoothschedule.scheduling.schedule.plugins import PluginRegistry, BasePlugin
class Plugin1(BasePlugin):
name = "duplicate_name"
display_name = "Plugin 1"
def execute(self, context):
return {"success": True}
class Plugin2(BasePlugin):
name = "duplicate_name" # Same name
display_name = "Plugin 2"
def execute(self, context):
return {"success": True}
registry = PluginRegistry()
registry.register(Plugin1)
with pytest.raises(ValueError) as exc_info:
registry.register(Plugin2)
assert "already registered" in str(exc_info.value)
def test_unregister_removes_plugin(self):
"""Should unregister a plugin by name."""
from smoothschedule.scheduling.schedule.plugins import PluginRegistry, BasePlugin
class TestPlugin(BasePlugin):
name = "test_plugin"
display_name = "Test Plugin"
def execute(self, context):
return {"success": True}
registry = PluginRegistry()
registry.register(TestPlugin)
assert "test_plugin" in registry._plugins
registry.unregister("test_plugin")
assert "test_plugin" not in registry._plugins
def test_unregister_does_nothing_for_unknown_plugin(self):
"""Should not raise when unregistering unknown plugin."""
from smoothschedule.scheduling.schedule.plugins import PluginRegistry
registry = PluginRegistry()
# Should not raise
registry.unregister("nonexistent_plugin")
def test_get_returns_plugin_class(self):
"""Should return plugin class by name."""
from smoothschedule.scheduling.schedule.plugins import PluginRegistry, BasePlugin
class TestPlugin(BasePlugin):
name = "test_plugin"
display_name = "Test Plugin"
def execute(self, context):
return {"success": True}
registry = PluginRegistry()
registry.register(TestPlugin)
result = registry.get("test_plugin")
assert result is TestPlugin
def test_get_returns_none_for_unknown(self):
"""Should return None for unknown plugin name."""
from smoothschedule.scheduling.schedule.plugins import PluginRegistry
registry = PluginRegistry()
result = registry.get("nonexistent")
assert result is None
def test_get_instance_returns_plugin_instance(self):
"""Should return configured plugin instance."""
from smoothschedule.scheduling.schedule.plugins import PluginRegistry, BasePlugin
class TestPlugin(BasePlugin):
name = "test_plugin"
display_name = "Test Plugin"
def execute(self, context):
return {"success": True}
registry = PluginRegistry()
registry.register(TestPlugin)
instance = registry.get_instance("test_plugin", config={"key": "value"})
assert isinstance(instance, TestPlugin)
assert instance.config == {"key": "value"}
def test_get_instance_returns_none_for_unknown(self):
"""Should return None for unknown plugin name."""
from smoothschedule.scheduling.schedule.plugins import PluginRegistry
registry = PluginRegistry()
result = registry.get_instance("nonexistent")
assert result is None
def test_list_all_returns_plugin_metadata(self):
"""Should return list of plugin metadata."""
from smoothschedule.scheduling.schedule.plugins import PluginRegistry, BasePlugin
class TestPlugin(BasePlugin):
name = "test_plugin"
display_name = "Test Plugin"
description = "A test plugin"
category = "testing"
def execute(self, context):
return {"success": True}
registry = PluginRegistry()
registry.register(TestPlugin)
result = registry.list_all()
assert len(result) == 1
assert result[0]['name'] == 'test_plugin'
assert result[0]['display_name'] == 'Test Plugin'
assert result[0]['description'] == 'A test plugin'
assert result[0]['category'] == 'testing'
def test_list_all_returns_empty_list_when_no_plugins(self):
"""Should return empty list when no plugins registered."""
from smoothschedule.scheduling.schedule.plugins import PluginRegistry
registry = PluginRegistry()
result = registry.list_all()
assert result == []
def test_list_by_category_groups_plugins(self):
"""Should group plugins by category."""
from smoothschedule.scheduling.schedule.plugins import PluginRegistry, BasePlugin
class Plugin1(BasePlugin):
name = "plugin1"
display_name = "Plugin 1"
category = "category_a"
def execute(self, context):
return {"success": True}
class Plugin2(BasePlugin):
name = "plugin2"
display_name = "Plugin 2"
category = "category_b"
def execute(self, context):
return {"success": True}
class Plugin3(BasePlugin):
name = "plugin3"
display_name = "Plugin 3"
category = "category_a"
def execute(self, context):
return {"success": True}
registry = PluginRegistry()
registry.register(Plugin1)
registry.register(Plugin2)
registry.register(Plugin3)
result = registry.list_by_category()
assert 'category_a' in result
assert 'category_b' in result
assert len(result['category_a']) == 2
assert len(result['category_b']) == 1
class TestGlobalRegistry:
"""Tests for global plugin registry."""
def test_registry_is_plugin_registry_instance(self):
"""Should have a global registry instance."""
from smoothschedule.scheduling.schedule.plugins import registry, PluginRegistry
assert isinstance(registry, PluginRegistry)
class TestRegisterPluginDecorator:
"""Tests for register_plugin decorator."""
def test_decorator_registers_plugin(self):
"""Should register plugin when used as decorator."""
from smoothschedule.scheduling.schedule.plugins import PluginRegistry, BasePlugin
registry = PluginRegistry()
# Create a decorator that uses this specific registry
def register_plugin(plugin_class):
registry.register(plugin_class)
return plugin_class
@register_plugin
class DecoratedPlugin(BasePlugin):
name = "decorated_plugin"
display_name = "Decorated Plugin"
def execute(self, context):
return {"success": True}
assert "decorated_plugin" in registry._plugins
def test_decorator_returns_same_class(self):
"""Should return the same class after decoration."""
from smoothschedule.scheduling.schedule.plugins import PluginRegistry, BasePlugin
registry = PluginRegistry()
def register_plugin(plugin_class):
registry.register(plugin_class)
return plugin_class
@register_plugin
class DecoratedPlugin(BasePlugin):
name = "decorated_plugin2"
display_name = "Decorated Plugin 2"
def execute(self, context):
return {"success": True}
assert DecoratedPlugin.name == "decorated_plugin2"
class TestPluginExecution:
"""Tests for plugin execution functionality."""
def test_execute_abstract_method(self):
"""Should have execute as abstract method."""
from smoothschedule.scheduling.schedule.plugins import BasePlugin
from abc import ABC
# BasePlugin should be abstract
assert issubclass(BasePlugin, ABC)
def test_concrete_plugin_can_execute(self):
"""Should allow concrete plugin execution."""
from smoothschedule.scheduling.schedule.plugins import BasePlugin
class ConcretePlugin(BasePlugin):
name = "concrete_plugin"
display_name = "Concrete Plugin"
def execute(self, context):
return {
"success": True,
"data": context.get("input", "no input")
}
plugin = ConcretePlugin()
result = plugin.execute({"input": "test data"})
assert result["success"] is True
assert result["data"] == "test data"
def test_plugin_with_custom_can_execute(self):
"""Should allow overriding can_execute."""
from smoothschedule.scheduling.schedule.plugins import BasePlugin
class ConditionalPlugin(BasePlugin):
name = "conditional_plugin"
display_name = "Conditional Plugin"
def can_execute(self, context):
if not context.get("has_permission"):
return False, "Missing permission"
return True, None
def execute(self, context):
return {"success": True}
plugin = ConditionalPlugin()
# Without permission
can_exec, reason = plugin.can_execute({})
assert can_exec is False
assert reason == "Missing permission"
# With permission
can_exec, reason = plugin.can_execute({"has_permission": True})
assert can_exec is True
assert reason is None
def test_plugin_with_custom_next_run_time(self):
"""Should allow overriding get_next_run_time."""
from smoothschedule.scheduling.schedule.plugins import BasePlugin
from django.utils import timezone
from datetime import timedelta
class ScheduledPlugin(BasePlugin):
name = "scheduled_plugin"
display_name = "Scheduled Plugin"
def get_next_run_time(self, last_run):
if last_run is None:
return timezone.now()
return last_run + timedelta(hours=1)
def execute(self, context):
return {"success": True}
plugin = ScheduledPlugin()
# First run (no last_run)
next_run = plugin.get_next_run_time(None)
assert next_run is not None
# Subsequent run
now = timezone.now()
next_run = plugin.get_next_run_time(now)
assert next_run == now + timedelta(hours=1)

View File

@@ -1085,7 +1085,7 @@ class TestScheduledTaskSerializer:
assert name is None
@patch('smoothschedule.scheduling.schedule.plugins.registry')
@patch('smoothschedule.scheduling.automations.registry.registry')
def test_get_plugin_display_name_from_registry(self, mock_registry):
"""Test plugin_display_name returns display name from registry."""
from smoothschedule.scheduling.schedule.serializers import ScheduledTaskSerializer
@@ -1103,7 +1103,7 @@ class TestScheduledTaskSerializer:
assert name == "Backup Plugin"
mock_registry.get.assert_called_with("backup")
@patch('smoothschedule.scheduling.schedule.plugins.registry')
@patch('smoothschedule.scheduling.automations.registry.registry')
def test_get_plugin_display_name_falls_back_to_plugin_name(self, mock_registry):
"""Test plugin_display_name falls back when not in registry."""
from smoothschedule.scheduling.schedule.serializers import ScheduledTaskSerializer
@@ -1166,7 +1166,7 @@ class TestScheduledTaskSerializer:
assert 'run_at' in str(exc_info.value)
@patch('smoothschedule.scheduling.schedule.plugins.registry')
@patch('smoothschedule.scheduling.automations.registry.registry')
def test_validate_plugin_name_exists(self, mock_registry):
"""Test plugin_name validation passes for existing plugin."""
from smoothschedule.scheduling.schedule.serializers import ScheduledTaskSerializer
@@ -1178,7 +1178,7 @@ class TestScheduledTaskSerializer:
assert result == "backup"
@patch('smoothschedule.scheduling.schedule.plugins.registry')
@patch('smoothschedule.scheduling.automations.registry.registry')
def test_validate_plugin_name_rejects_unknown(self, mock_registry):
"""Test plugin_name validation fails for unknown plugin."""
from smoothschedule.scheduling.schedule.serializers import ScheduledTaskSerializer

View File

@@ -429,15 +429,15 @@ class TestSimpleAvailabilityCheck:
# Warnings are not returned in simple mode
class TestSendEmailPlugin:
"""Test SendEmailPlugin execution logic."""
class TestSendEmailAutomation:
"""Test SendEmailAutomation execution logic."""
@patch('smoothschedule.scheduling.schedule.builtin_plugins.send_mail')
@patch('smoothschedule.scheduling.schedule.builtin_plugins.settings')
@patch('smoothschedule.scheduling.automations.builtin.send_mail')
@patch('smoothschedule.scheduling.automations.builtin.settings')
def test_execute_sends_email_successfully(self, mock_settings, mock_send_mail):
"""Test that plugin sends email with correct parameters."""
# Arrange
from smoothschedule.scheduling.schedule.builtin_plugins import SendEmailPlugin
from smoothschedule.scheduling.automations.builtin import SendEmailAutomation
mock_settings.DEFAULT_FROM_EMAIL = 'noreply@example.com'
@@ -446,7 +446,7 @@ class TestSendEmailPlugin:
'subject': 'Test Subject',
'message': 'Test message body',
}
plugin = SendEmailPlugin(config=config)
plugin = SendEmailAutomation(config=config)
context = {}
# Act
@@ -464,12 +464,12 @@ class TestSendEmailPlugin:
assert result['message'] == 'Email sent to 2 recipient(s)'
assert result['data']['recipient_count'] == 2
@patch('smoothschedule.scheduling.schedule.builtin_plugins.send_mail')
@patch('smoothschedule.scheduling.schedule.builtin_plugins.settings')
@patch('smoothschedule.scheduling.automations.builtin.send_mail')
@patch('smoothschedule.scheduling.automations.builtin.settings')
def test_execute_uses_custom_from_email(self, mock_settings, mock_send_mail):
"""Test that custom from_email is used when provided."""
# Arrange
from smoothschedule.scheduling.schedule.builtin_plugins import SendEmailPlugin
from smoothschedule.scheduling.automations.builtin import SendEmailAutomation
mock_settings.DEFAULT_FROM_EMAIL = 'noreply@example.com'
@@ -479,7 +479,7 @@ class TestSendEmailPlugin:
'message': 'Body',
'from_email': 'custom@example.com',
}
plugin = SendEmailPlugin(config=config)
plugin = SendEmailAutomation(config=config)
# Act
result = plugin.execute({})
@@ -491,27 +491,27 @@ class TestSendEmailPlugin:
def test_execute_raises_error_when_no_recipients(self):
"""Test that plugin raises error when no recipients specified."""
# Arrange
from smoothschedule.scheduling.schedule.builtin_plugins import SendEmailPlugin
from smoothschedule.scheduling.schedule.plugins import PluginExecutionError
from smoothschedule.scheduling.automations.builtin import SendEmailAutomation
from smoothschedule.scheduling.automations.registry import AutomationExecutionError as PluginExecutionError
config = {
'recipients': [],
'subject': 'Test',
'message': 'Body',
}
plugin = SendEmailPlugin(config=config)
plugin = SendEmailAutomation(config=config)
# Act & Assert
with pytest.raises(PluginExecutionError) as exc_info:
plugin.execute({})
assert 'No recipients specified' in str(exc_info.value)
@patch('smoothschedule.scheduling.schedule.builtin_plugins.send_mail')
@patch('smoothschedule.scheduling.automations.builtin.send_mail')
def test_execute_raises_error_on_send_failure(self, mock_send_mail):
"""Test that plugin raises PluginExecutionError on send failure."""
# Arrange
from smoothschedule.scheduling.schedule.builtin_plugins import SendEmailPlugin
from smoothschedule.scheduling.schedule.plugins import PluginExecutionError
from smoothschedule.scheduling.automations.builtin import SendEmailAutomation
from smoothschedule.scheduling.automations.registry import AutomationExecutionError as PluginExecutionError
mock_send_mail.side_effect = Exception('SMTP error')
@@ -520,7 +520,7 @@ class TestSendEmailPlugin:
'subject': 'Test',
'message': 'Body',
}
plugin = SendEmailPlugin(config=config)
plugin = SendEmailAutomation(config=config)
# Act & Assert
with pytest.raises(PluginExecutionError) as exc_info:
@@ -528,13 +528,13 @@ class TestSendEmailPlugin:
assert 'Failed to send email' in str(exc_info.value)
class TestCleanupOldEventsPlugin:
"""Test CleanupOldEventsPlugin execution logic."""
class TestCleanupOldEventsAutomation:
"""Test CleanupOldEventsAutomation execution logic."""
def test_execute_counts_events_in_dry_run_mode(self):
"""Test that dry run mode only counts events without deleting."""
# Arrange
from smoothschedule.scheduling.schedule.builtin_plugins import CleanupOldEventsPlugin
from smoothschedule.scheduling.automations.builtin import CleanupOldEventsAutomation
now = datetime(2024, 1, 15, 12, 0, tzinfo=dt_timezone.utc)
@@ -543,13 +543,13 @@ class TestCleanupOldEventsPlugin:
'statuses': ['COMPLETED', 'CANCELED'],
'dry_run': True,
}
plugin = CleanupOldEventsPlugin(config=config)
plugin = CleanupOldEventsAutomation(config=config)
# Mock Event model
mock_event_query = Mock()
mock_event_query.count.return_value = 5
with patch('smoothschedule.scheduling.schedule.builtin_plugins.timezone') as mock_timezone:
with patch('smoothschedule.scheduling.automations.builtin.timezone') as mock_timezone:
mock_timezone.now.return_value = now
with patch('smoothschedule.scheduling.schedule.models.Event') as mock_event:
mock_event.objects.filter.return_value = mock_event_query
@@ -567,7 +567,7 @@ class TestCleanupOldEventsPlugin:
def test_execute_deletes_events_when_not_dry_run(self):
"""Test that events are deleted when not in dry run mode."""
# Arrange
from smoothschedule.scheduling.schedule.builtin_plugins import CleanupOldEventsPlugin
from smoothschedule.scheduling.automations.builtin import CleanupOldEventsAutomation
now = datetime(2024, 1, 15, 12, 0, tzinfo=dt_timezone.utc)
@@ -576,13 +576,13 @@ class TestCleanupOldEventsPlugin:
'statuses': ['COMPLETED'],
'dry_run': False,
}
plugin = CleanupOldEventsPlugin(config=config)
plugin = CleanupOldEventsAutomation(config=config)
# Mock Event model
mock_event_query = Mock()
mock_event_query.count.return_value = 3
with patch('smoothschedule.scheduling.schedule.builtin_plugins.timezone') as mock_timezone:
with patch('smoothschedule.scheduling.automations.builtin.timezone') as mock_timezone:
mock_timezone.now.return_value = now
with patch('smoothschedule.scheduling.schedule.models.Event') as mock_event:
mock_event.objects.filter.return_value = mock_event_query
@@ -600,17 +600,17 @@ class TestCleanupOldEventsPlugin:
def test_execute_uses_correct_cutoff_date(self):
"""Test that cutoff date is calculated correctly."""
# Arrange
from smoothschedule.scheduling.schedule.builtin_plugins import CleanupOldEventsPlugin
from smoothschedule.scheduling.automations.builtin import CleanupOldEventsAutomation
now = datetime(2024, 6, 15, 12, 0, tzinfo=dt_timezone.utc)
config = {'days_old': 90}
plugin = CleanupOldEventsPlugin(config=config)
plugin = CleanupOldEventsAutomation(config=config)
mock_event_query = Mock()
mock_event_query.count.return_value = 0
with patch('smoothschedule.scheduling.schedule.builtin_plugins.timezone') as mock_timezone:
with patch('smoothschedule.scheduling.automations.builtin.timezone') as mock_timezone:
mock_timezone.now.return_value = now
with patch('smoothschedule.scheduling.schedule.models.Event') as mock_event:
mock_event.objects.filter.return_value = mock_event_query
@@ -626,17 +626,17 @@ class TestCleanupOldEventsPlugin:
def test_execute_uses_default_values(self):
"""Test that default values are used when not specified."""
# Arrange
from smoothschedule.scheduling.schedule.builtin_plugins import CleanupOldEventsPlugin
from smoothschedule.scheduling.automations.builtin import CleanupOldEventsAutomation
now = datetime(2024, 1, 15, tzinfo=dt_timezone.utc)
config = {} # Empty config
plugin = CleanupOldEventsPlugin(config=config)
plugin = CleanupOldEventsAutomation(config=config)
mock_event_query = Mock()
mock_event_query.count.return_value = 0
with patch('smoothschedule.scheduling.schedule.builtin_plugins.timezone') as mock_timezone:
with patch('smoothschedule.scheduling.automations.builtin.timezone') as mock_timezone:
mock_timezone.now.return_value = now
with patch('smoothschedule.scheduling.schedule.models.Event') as mock_event:
mock_event.objects.filter.return_value = mock_event_query
@@ -650,16 +650,16 @@ class TestCleanupOldEventsPlugin:
assert result['data']['days_old'] == 90
class TestDailyReportPlugin:
"""Test DailyReportPlugin execution logic."""
class TestDailyReportAutomation:
"""Test DailyReportAutomation execution logic."""
@patch('smoothschedule.scheduling.schedule.builtin_plugins.send_mail')
@patch('smoothschedule.scheduling.schedule.builtin_plugins.timezone')
@patch('smoothschedule.scheduling.schedule.builtin_plugins.settings')
@patch('smoothschedule.scheduling.automations.builtin.send_mail')
@patch('smoothschedule.scheduling.automations.builtin.timezone')
@patch('smoothschedule.scheduling.automations.builtin.settings')
def test_execute_sends_report_with_all_sections(self, mock_settings, mock_timezone, mock_send_mail):
"""Test that plugin generates and sends complete daily report."""
# Arrange
from smoothschedule.scheduling.schedule.builtin_plugins import DailyReportPlugin
from smoothschedule.scheduling.automations.builtin import DailyReportAutomation
mock_settings.DEFAULT_FROM_EMAIL = 'reports@example.com'
@@ -678,7 +678,7 @@ class TestDailyReportPlugin:
mock_business.name = 'Test Business'
context = {'business': mock_business}
plugin = DailyReportPlugin(config=config)
plugin = DailyReportAutomation(config=config)
# Mock Event queries
with patch('smoothschedule.scheduling.schedule.models.Event') as mock_event:
@@ -717,24 +717,24 @@ class TestDailyReportPlugin:
def test_execute_raises_error_when_no_recipients(self):
"""Test that plugin raises error when no recipients specified."""
# Arrange
from smoothschedule.scheduling.schedule.builtin_plugins import DailyReportPlugin
from smoothschedule.scheduling.schedule.plugins import PluginExecutionError
from smoothschedule.scheduling.automations.builtin import DailyReportAutomation
from smoothschedule.scheduling.automations.registry import AutomationExecutionError as PluginExecutionError
config = {'recipients': []}
plugin = DailyReportPlugin(config=config)
plugin = DailyReportAutomation(config=config)
# Act & Assert
with pytest.raises(PluginExecutionError) as exc_info:
plugin.execute({})
assert 'No recipients specified' in str(exc_info.value)
@patch('smoothschedule.scheduling.schedule.builtin_plugins.send_mail')
@patch('smoothschedule.scheduling.schedule.builtin_plugins.timezone')
@patch('smoothschedule.scheduling.schedule.builtin_plugins.settings')
@patch('smoothschedule.scheduling.automations.builtin.send_mail')
@patch('smoothschedule.scheduling.automations.builtin.timezone')
@patch('smoothschedule.scheduling.automations.builtin.settings')
def test_execute_excludes_sections_based_on_config(self, mock_settings, mock_timezone, mock_send_mail):
"""Test that sections are excluded when config options are False."""
# Arrange
from smoothschedule.scheduling.schedule.builtin_plugins import DailyReportPlugin
from smoothschedule.scheduling.automations.builtin import DailyReportAutomation
mock_settings.DEFAULT_FROM_EMAIL = 'reports@example.com'
@@ -753,7 +753,7 @@ class TestDailyReportPlugin:
mock_business.name = 'Test Business'
context = {'business': mock_business}
plugin = DailyReportPlugin(config=config)
plugin = DailyReportAutomation(config=config)
# Act
result = plugin.execute(context)
@@ -765,20 +765,20 @@ class TestDailyReportPlugin:
assert "Yesterday's Summary" not in message
assert "Test Business" in message # Header still present
@patch('smoothschedule.scheduling.schedule.builtin_plugins.send_mail')
@patch('smoothschedule.scheduling.automations.builtin.send_mail')
def test_execute_raises_error_on_send_failure(self, mock_send_mail):
"""Test that plugin raises PluginExecutionError on send failure."""
# Arrange
from smoothschedule.scheduling.schedule.builtin_plugins import DailyReportPlugin
from smoothschedule.scheduling.schedule.plugins import PluginExecutionError
from smoothschedule.scheduling.automations.builtin import DailyReportAutomation
from smoothschedule.scheduling.automations.registry import AutomationExecutionError as PluginExecutionError
mock_send_mail.side_effect = Exception('SMTP error')
config = {'recipients': ['manager@example.com']}
plugin = DailyReportPlugin(config=config)
plugin = DailyReportAutomation(config=config)
context = {'business': Mock(name='Test')}
with patch('smoothschedule.scheduling.schedule.builtin_plugins.timezone'):
with patch('smoothschedule.scheduling.automations.builtin.timezone'):
with patch('smoothschedule.scheduling.schedule.models.Event'):
# Act & Assert
with pytest.raises(PluginExecutionError) as exc_info:
@@ -786,16 +786,16 @@ class TestDailyReportPlugin:
assert 'Failed to send report' in str(exc_info.value)
class TestAppointmentReminderPlugin:
"""Test AppointmentReminderPlugin execution logic."""
class TestAppointmentReminderAutomation:
"""Test AppointmentReminderAutomation execution logic."""
@patch('smoothschedule.platform.admin.tasks.send_appointment_reminder_email')
@patch('smoothschedule.scheduling.schedule.builtin_plugins.timezone')
@patch('smoothschedule.scheduling.schedule.builtin_plugins.logger')
@patch('smoothschedule.scheduling.automations.builtin.timezone')
@patch('smoothschedule.scheduling.automations.builtin.logger')
def test_execute_queues_email_reminders(self, mock_logger, mock_timezone, mock_task):
"""Test that plugin queues email reminders for upcoming appointments."""
# Arrange
from smoothschedule.scheduling.schedule.builtin_plugins import AppointmentReminderPlugin
from smoothschedule.scheduling.automations.builtin import AppointmentReminderAutomation
now = datetime(2024, 1, 15, 12, 0, tzinfo=dt_timezone.utc)
mock_timezone.now.return_value = now
@@ -804,7 +804,7 @@ class TestAppointmentReminderPlugin:
'hours_before': 24,
'method': 'email',
}
plugin = AppointmentReminderPlugin(config=config)
plugin = AppointmentReminderAutomation(config=config)
# Mock event with participants
mock_customer = Mock()
@@ -840,18 +840,18 @@ class TestAppointmentReminderPlugin:
assert result['data']['method'] == 'email'
@patch('smoothschedule.platform.admin.tasks.send_appointment_reminder_email')
@patch('smoothschedule.scheduling.schedule.builtin_plugins.timezone')
@patch('smoothschedule.scheduling.schedule.builtin_plugins.logger')
@patch('smoothschedule.scheduling.automations.builtin.timezone')
@patch('smoothschedule.scheduling.automations.builtin.logger')
def test_execute_handles_multiple_participants(self, mock_logger, mock_timezone, mock_task):
"""Test that reminders are sent to all participants."""
# Arrange
from smoothschedule.scheduling.schedule.builtin_plugins import AppointmentReminderPlugin
from smoothschedule.scheduling.automations.builtin import AppointmentReminderAutomation
now = datetime(2024, 1, 15, 12, 0, tzinfo=dt_timezone.utc)
mock_timezone.now.return_value = now
config = {'hours_before': 24, 'method': 'email'}
plugin = AppointmentReminderPlugin(config=config)
plugin = AppointmentReminderAutomation(config=config)
# Mock event with multiple participants
mock_customer1 = Mock()
@@ -884,17 +884,17 @@ class TestAppointmentReminderPlugin:
assert result['data']['reminders_queued'] == 2
@patch('smoothschedule.platform.admin.tasks.send_appointment_reminder_email')
@patch('smoothschedule.scheduling.schedule.builtin_plugins.timezone')
@patch('smoothschedule.scheduling.automations.builtin.timezone')
def test_execute_skips_participants_without_email(self, mock_timezone, mock_task):
"""Test that participants without email are skipped."""
# Arrange
from smoothschedule.scheduling.schedule.builtin_plugins import AppointmentReminderPlugin
from smoothschedule.scheduling.automations.builtin import AppointmentReminderAutomation
now = datetime(2024, 1, 15, 12, 0, tzinfo=dt_timezone.utc)
mock_timezone.now.return_value = now
config = {'hours_before': 24, 'method': 'email'}
plugin = AppointmentReminderPlugin(config=config)
plugin = AppointmentReminderAutomation(config=config)
# Mock participant with no customer
mock_participant1 = Mock()
@@ -925,18 +925,18 @@ class TestAppointmentReminderPlugin:
assert result['data']['reminders_queued'] == 0
@patch('smoothschedule.platform.admin.tasks.send_appointment_reminder_email')
@patch('smoothschedule.scheduling.schedule.builtin_plugins.timezone')
@patch('smoothschedule.scheduling.schedule.builtin_plugins.logger')
@patch('smoothschedule.scheduling.automations.builtin.timezone')
@patch('smoothschedule.scheduling.automations.builtin.logger')
def test_execute_logs_sms_intent_for_sms_method(self, mock_logger, mock_timezone, mock_task):
"""Test that SMS method logs intent (not yet implemented)."""
# Arrange
from smoothschedule.scheduling.schedule.builtin_plugins import AppointmentReminderPlugin
from smoothschedule.scheduling.automations.builtin import AppointmentReminderAutomation
now = datetime(2024, 1, 15, 12, 0, tzinfo=dt_timezone.utc)
mock_timezone.now.return_value = now
config = {'hours_before': 2, 'method': 'sms'}
plugin = AppointmentReminderPlugin(config=config)
plugin = AppointmentReminderAutomation(config=config)
mock_customer = Mock()
mock_customer.email = 'customer@example.com'
@@ -963,17 +963,17 @@ class TestAppointmentReminderPlugin:
assert result['data']['method'] == 'sms'
class TestBackupDatabasePlugin:
"""Test BackupDatabasePlugin execution logic."""
class TestBackupDatabaseAutomation:
"""Test BackupDatabaseAutomation execution logic."""
@patch('smoothschedule.scheduling.schedule.builtin_plugins.logger')
@patch('smoothschedule.scheduling.automations.builtin.logger')
def test_execute_returns_success_placeholder(self, mock_logger):
"""Test that plugin returns success (placeholder implementation)."""
# Arrange
from smoothschedule.scheduling.schedule.builtin_plugins import BackupDatabasePlugin
from smoothschedule.scheduling.automations.builtin import BackupDatabaseAutomation
config = {'compress': True}
plugin = BackupDatabasePlugin(config=config)
plugin = BackupDatabaseAutomation(config=config)
mock_business = Mock()
mock_business.name = 'Test Business'
@@ -991,13 +991,13 @@ class TestBackupDatabasePlugin:
def test_execute_with_custom_backup_location(self):
"""Test that custom backup location is accepted."""
# Arrange
from smoothschedule.scheduling.schedule.builtin_plugins import BackupDatabasePlugin
from smoothschedule.scheduling.automations.builtin import BackupDatabaseAutomation
config = {
'backup_location': '/custom/path',
'compress': False,
}
plugin = BackupDatabasePlugin(config=config)
plugin = BackupDatabaseAutomation(config=config)
context = {'business': Mock(name='Test')}
@@ -1008,13 +1008,13 @@ class TestBackupDatabasePlugin:
assert result['success'] is True
class TestWebhookPlugin:
"""Test WebhookPlugin execution logic."""
class TestWebhookAutomation:
"""Test WebhookAutomation execution logic."""
def test_execute_makes_post_request_successfully(self):
"""Test that plugin makes POST request with correct parameters."""
# Arrange
from smoothschedule.scheduling.schedule.builtin_plugins import WebhookPlugin
from smoothschedule.scheduling.automations.builtin import WebhookAutomation
import requests
mock_response = Mock()
@@ -1027,7 +1027,7 @@ class TestWebhookPlugin:
'headers': {'Authorization': 'Bearer token123'},
'payload': {'event': 'test', 'data': 'value'},
}
plugin = WebhookPlugin(config=config)
plugin = WebhookAutomation(config=config)
with patch('requests.request', return_value=mock_response) as mock_request:
# Act
@@ -1048,7 +1048,7 @@ class TestWebhookPlugin:
def test_execute_supports_different_http_methods(self):
"""Test that plugin supports GET, PUT, PATCH methods."""
# Arrange
from smoothschedule.scheduling.schedule.builtin_plugins import WebhookPlugin
from smoothschedule.scheduling.automations.builtin import WebhookAutomation
mock_response = Mock()
mock_response.status_code = 200
@@ -1060,7 +1060,7 @@ class TestWebhookPlugin:
'method': method,
'payload': {'key': 'value'},
}
plugin = WebhookPlugin(config=config)
plugin = WebhookAutomation(config=config)
with patch('requests.request', return_value=mock_response) as mock_request:
# Act
@@ -1073,14 +1073,14 @@ class TestWebhookPlugin:
def test_execute_uses_default_method_post(self):
"""Test that POST is used as default method."""
# Arrange
from smoothschedule.scheduling.schedule.builtin_plugins import WebhookPlugin
from smoothschedule.scheduling.automations.builtin import WebhookAutomation
mock_response = Mock()
mock_response.status_code = 200
mock_response.text = 'OK'
config = {'url': 'https://api.example.com/webhook'}
plugin = WebhookPlugin(config=config)
plugin = WebhookAutomation(config=config)
with patch('requests.request', return_value=mock_response) as mock_request:
# Act
@@ -1093,24 +1093,24 @@ class TestWebhookPlugin:
def test_execute_raises_error_when_no_url(self):
"""Test that plugin raises ValueError during init when URL is not provided."""
# Arrange
from smoothschedule.scheduling.schedule.builtin_plugins import WebhookPlugin
from smoothschedule.scheduling.automations.builtin import WebhookAutomation
config = {}
# Act & Assert - config validation happens during __init__
with pytest.raises(ValueError) as exc_info:
plugin = WebhookPlugin(config=config)
plugin = WebhookAutomation(config=config)
assert 'url' in str(exc_info.value).lower()
def test_execute_raises_error_on_request_failure(self):
"""Test that plugin raises PluginExecutionError on request failure."""
# Arrange
from smoothschedule.scheduling.schedule.builtin_plugins import WebhookPlugin
from smoothschedule.scheduling.schedule.plugins import PluginExecutionError
from smoothschedule.scheduling.automations.builtin import WebhookAutomation
from smoothschedule.scheduling.automations.registry import AutomationExecutionError as PluginExecutionError
import requests
config = {'url': 'https://api.example.com/webhook'}
plugin = WebhookPlugin(config=config)
plugin = WebhookAutomation(config=config)
with patch('requests.request', side_effect=requests.RequestException('Connection timeout')):
# Act & Assert
@@ -1121,8 +1121,8 @@ class TestWebhookPlugin:
def test_execute_raises_error_on_http_error_status(self):
"""Test that plugin raises error on HTTP error status codes."""
# Arrange
from smoothschedule.scheduling.schedule.builtin_plugins import WebhookPlugin
from smoothschedule.scheduling.schedule.plugins import PluginExecutionError
from smoothschedule.scheduling.automations.builtin import WebhookAutomation
from smoothschedule.scheduling.automations.registry import AutomationExecutionError as PluginExecutionError
import requests
mock_response = Mock()
@@ -1130,7 +1130,7 @@ class TestWebhookPlugin:
mock_response.raise_for_status.side_effect = requests.HTTPError('500 Server Error')
config = {'url': 'https://api.example.com/webhook'}
plugin = WebhookPlugin(config=config)
plugin = WebhookAutomation(config=config)
with patch('requests.request', return_value=mock_response):
# Act & Assert
@@ -1140,7 +1140,7 @@ class TestWebhookPlugin:
def test_execute_truncates_long_response(self):
"""Test that long responses are truncated to 500 chars."""
# Arrange
from smoothschedule.scheduling.schedule.builtin_plugins import WebhookPlugin
from smoothschedule.scheduling.automations.builtin import WebhookAutomation
long_response = 'x' * 1000 # 1000 character response
mock_response = Mock()
@@ -1148,7 +1148,7 @@ class TestWebhookPlugin:
mock_response.text = long_response
config = {'url': 'https://api.example.com/webhook'}
plugin = WebhookPlugin(config=config)
plugin = WebhookAutomation(config=config)
with patch('requests.request', return_value=mock_response):
# Act

View File

@@ -1045,7 +1045,7 @@ class TestPluginViewSetList:
viewset.format_kwarg = None
# Patch at source since it's a local import
with patch('smoothschedule.scheduling.schedule.plugins.registry') as mock_registry:
with patch('smoothschedule.scheduling.automations.registry.registry') as mock_registry:
mock_registry.list_all.return_value = []
with patch('smoothschedule.scheduling.schedule.serializers.PluginInfoSerializer') as mock_serializer:
@@ -1080,7 +1080,7 @@ class TestPluginViewSetRetrieve:
mock_plugin_class.config_schema = {}
# Patch at source since it's a local import
with patch('smoothschedule.scheduling.schedule.plugins.registry') as mock_registry:
with patch('smoothschedule.scheduling.automations.registry.registry') as mock_registry:
mock_registry.get.return_value = mock_plugin_class
with patch('smoothschedule.scheduling.schedule.serializers.PluginInfoSerializer') as mock_serializer:
@@ -1104,7 +1104,7 @@ class TestPluginViewSetRetrieve:
viewset.format_kwarg = None
# Patch at source since it's a local import
with patch('smoothschedule.scheduling.schedule.plugins.registry') as mock_registry:
with patch('smoothschedule.scheduling.automations.registry.registry') as mock_registry:
mock_registry.get.return_value = None
response = viewset.retrieve(request, pk='unknown')
@@ -1130,7 +1130,7 @@ class TestPluginViewSetByCategory:
viewset.format_kwarg = None
# Patch at source since it's a local import
with patch('smoothschedule.scheduling.schedule.plugins.registry') as mock_registry:
with patch('smoothschedule.scheduling.automations.registry.registry') as mock_registry:
mock_registry.list_by_category.return_value = {'automation': []}
response = viewset.by_category(request)

View File

@@ -9,9 +9,7 @@ from rest_framework.routers import DefaultRouter
from .views import (
ResourceViewSet, EventViewSet, ParticipantViewSet,
CustomerViewSet, ServiceViewSet, StaffViewSet, ResourceTypeViewSet,
ScheduledTaskViewSet, TaskExecutionLogViewSet, PluginViewSet,
PluginTemplateViewSet, PluginInstallationViewSet, EventPluginViewSet,
GlobalEventPluginViewSet,
ScheduledTaskViewSet, TaskExecutionLogViewSet,
HolidayViewSet, TimeBlockViewSet, LocationViewSet,
AlbumViewSet, MediaFileViewSet, StorageUsageView,
)
@@ -29,11 +27,6 @@ router.register(r'services', ServiceViewSet, basename='service')
router.register(r'staff', StaffViewSet, basename='staff')
router.register(r'scheduled-tasks', ScheduledTaskViewSet, basename='scheduledtask')
router.register(r'task-logs', TaskExecutionLogViewSet, basename='tasklog') # UNUSED_ENDPOINT: Logs accessed via scheduled-tasks/{id}/logs action
router.register(r'plugins', PluginViewSet, basename='plugin') # UNUSED_ENDPOINT: Frontend uses plugin-templates instead
router.register(r'plugin-templates', PluginTemplateViewSet, basename='plugintemplate')
router.register(r'plugin-installations', PluginInstallationViewSet, basename='plugininstallation')
router.register(r'event-plugins', EventPluginViewSet, basename='eventplugin')
router.register(r'global-event-plugins', GlobalEventPluginViewSet, basename='globaleventplugin')
router.register(r'export', ExportViewSet, basename='export')
router.register(r'holidays', HolidayViewSet, basename='holiday')
router.register(r'time-blocks', TimeBlockViewSet, basename='timeblock')

View File

@@ -32,7 +32,7 @@ from smoothschedule.identity.core.mixins import (
DenyStaffWritePermission,
DenyStaffAllAccessPermission,
DenyStaffListPermission,
PluginFeatureRequiredMixin,
AutomationFeatureRequiredMixin,
TaskFeatureRequiredMixin,
)
from smoothschedule.identity.users.models import User
@@ -975,7 +975,7 @@ class PluginViewSet(viewsets.ViewSet):
def list(self, request):
"""List all available plugins"""
from .plugins import registry
from smoothschedule.scheduling.automations.registry import registry
plugins = registry.list_all()
serializer = PluginInfoSerializer(plugins, many=True)
@@ -985,7 +985,7 @@ class PluginViewSet(viewsets.ViewSet):
@action(detail=False, methods=['get'])
def by_category(self, request):
"""List plugins grouped by category"""
from .plugins import registry
from smoothschedule.scheduling.automations.registry import registry
plugins_by_category = registry.list_by_category()
@@ -993,7 +993,7 @@ class PluginViewSet(viewsets.ViewSet):
def retrieve(self, request, pk=None):
"""Get details for a specific plugin"""
from .plugins import registry
from smoothschedule.scheduling.automations.registry import registry
plugin_class = registry.get(pk)
if not plugin_class:
@@ -2008,52 +2008,91 @@ class TimeBlockViewSet(viewsets.ModelViewSet):
'time_block_id': block.id,
})
# For BUSINESS_HOURS blocks, generate INVERSE blocked periods
# (times OUTSIDE business hours should be shown as blocked)
# For BUSINESS_HOURS blocks, handle two scenarios:
# 1. Blocks that already represent CLOSED periods (start_time=00:00 or end_time=23:59)
# - These are pre-inverted by the frontend, add them directly
# 2. Blocks that represent OPEN periods (e.g., 09:00 to 17:00)
# - These need to be inverted to show closed periods
from datetime import time as dt_time, timedelta
if business_hours_blocks:
# Separate pre-inverted blocks from open-time blocks
pre_inverted_blocks = []
open_time_blocks = []
for bh_block in business_hours_blocks:
if bh_block.start_time and bh_block.end_time:
# Check if this is a pre-inverted block (already represents closed time)
is_before_hours = bh_block.start_time == dt_time(0, 0)
is_after_hours = bh_block.end_time >= dt_time(23, 59) or bh_block.end_time == dt_time(0, 0)
if is_before_hours or is_after_hours:
pre_inverted_blocks.append(bh_block)
else:
open_time_blocks.append(bh_block)
elif bh_block.all_day:
# All-day blocks go to open_time_blocks for closed-day handling
open_time_blocks.append(bh_block)
# Process pre-inverted blocks - add them directly as blocked periods
current_date = start_date
while current_date <= end_date:
# Find business hours for this date
day_business_hours = None
for bh_block in business_hours_blocks:
for bh_block in pre_inverted_blocks:
if bh_block.blocks_date(current_date):
if not bh_block.all_day and bh_block.start_time and bh_block.end_time:
day_business_hours = (bh_block.start_time, bh_block.end_time, bh_block)
break
blocked_dates.append({
'date': current_date.isoformat(),
'block_type': bh_block.block_type,
'purpose': 'OUTSIDE_BUSINESS_HOURS',
'title': bh_block.title,
'resource_id': None,
'all_day': False,
'start_time': bh_block.start_time.isoformat() if bh_block.start_time else None,
'end_time': bh_block.end_time.isoformat() if bh_block.end_time else None,
'time_block_id': bh_block.id,
})
current_date += timedelta(days=1)
if day_business_hours:
bh_start, bh_end, bh_block = day_business_hours
# Add "before business hours" blocked period (midnight to business start)
if bh_start > dt_time(0, 0):
blocked_dates.append({
'date': current_date.isoformat(),
'block_type': 'HARD',
'purpose': 'OUTSIDE_BUSINESS_HOURS',
'title': 'Before Business Hours',
'resource_id': None,
'all_day': False,
'start_time': '00:00:00',
'end_time': bh_start.isoformat(),
'time_block_id': bh_block.id,
})
# Add "after business hours" blocked period (business end to midnight)
if bh_end < dt_time(23, 59):
blocked_dates.append({
'date': current_date.isoformat(),
'block_type': 'HARD',
'purpose': 'OUTSIDE_BUSINESS_HOURS',
'title': 'After Business Hours',
'resource_id': None,
'all_day': False,
'start_time': bh_end.isoformat(),
'end_time': '23:59:59',
'time_block_id': bh_block.id,
})
else:
# No business hours for this day - check if any BUSINESS_HOURS blocks exist
# If blocks exist but none match this day, business is CLOSED
if business_hours_blocks:
# Process open-time blocks - invert to show blocked periods
if open_time_blocks:
current_date = start_date
while current_date <= end_date:
# Find business hours for this date
day_business_hours = None
for bh_block in open_time_blocks:
if bh_block.blocks_date(current_date):
if not bh_block.all_day and bh_block.start_time and bh_block.end_time:
day_business_hours = (bh_block.start_time, bh_block.end_time, bh_block)
break
if day_business_hours:
bh_start, bh_end, bh_block = day_business_hours
# Add "before business hours" blocked period (midnight to business start)
if bh_start > dt_time(0, 0):
blocked_dates.append({
'date': current_date.isoformat(),
'block_type': 'HARD',
'purpose': 'OUTSIDE_BUSINESS_HOURS',
'title': 'Before Business Hours',
'resource_id': None,
'all_day': False,
'start_time': '00:00:00',
'end_time': bh_start.isoformat(),
'time_block_id': bh_block.id,
})
# Add "after business hours" blocked period (business end to midnight)
if bh_end < dt_time(23, 59):
blocked_dates.append({
'date': current_date.isoformat(),
'block_type': 'HARD',
'purpose': 'OUTSIDE_BUSINESS_HOURS',
'title': 'After Business Hours',
'resource_id': None,
'all_day': False,
'start_time': bh_end.isoformat(),
'end_time': '23:59:59',
'time_block_id': bh_block.id,
})
else:
# No business hours for this day from open_time_blocks
# Check if business is closed (only if we have open_time_blocks defining hours)
blocked_dates.append({
'date': current_date.isoformat(),
'block_type': 'HARD',
@@ -2063,10 +2102,30 @@ class TimeBlockViewSet(viewsets.ModelViewSet):
'all_day': True,
'start_time': None,
'end_time': None,
'time_block_id': business_hours_blocks[0].id,
'time_block_id': open_time_blocks[0].id,
})
current_date += timedelta(days=1)
current_date += timedelta(days=1)
elif pre_inverted_blocks:
# Only pre-inverted blocks exist - check for days with no blocks (business closed)
current_date = start_date
while current_date <= end_date:
has_blocks_for_day = any(
bh_block.blocks_date(current_date) for bh_block in pre_inverted_blocks
)
if not has_blocks_for_day:
blocked_dates.append({
'date': current_date.isoformat(),
'block_type': 'HARD',
'purpose': 'BUSINESS_CLOSED',
'title': 'Business Closed',
'resource_id': None,
'all_day': True,
'start_time': None,
'end_time': None,
'time_block_id': pre_inverted_blocks[0].id,
})
current_date += timedelta(days=1)
# Sort by date
blocked_dates.sort(key=lambda d: (d['date'], d['resource_id'] or 0))