├── VERSION ├── pint.json ├── src ├── Exceptions │ ├── TokenException.php │ ├── UserException.php │ ├── NoClientException.php │ ├── InvalidQueryException.php │ ├── UnableToSaveException.php │ ├── ModelNotFoundException.php │ ├── ModelReadonlyException.php │ └── InvalidRelationshipException.php ├── Support │ ├── Collection.php │ ├── Relations │ │ ├── ChildOf.php │ │ ├── HasMany.php │ │ ├── Relation.php │ │ └── BelongsTo.php │ ├── Builder.php │ └── Model.php ├── Status.php ├── Priority.php ├── Project.php ├── TaskTemplate.php ├── User.php ├── Http │ ├── Controllers │ │ └── ClickUpController.php │ └── Middleware │ │ └── Filter.php ├── Concerns │ ├── HasClient.php │ └── HasClickUp.php ├── Tag.php ├── Interval.php ├── Field.php ├── Member.php ├── Share.php ├── Checklist.php ├── Time.php ├── Providers │ ├── ClientServiceProvider.php │ └── ServiceProvider.php ├── Item.php ├── Result.php ├── Folder.php ├── Webhook.php ├── Comment.php ├── Space.php ├── Goal.php ├── Team.php ├── View.php ├── TaskList.php ├── Api │ └── Client.php └── Task.php ├── routes └── web.php ├── .editorconfig ├── database └── migrations │ └── 2019_09_23_194855_add_click_up_token_to_users_table.php ├── config └── clickup.php ├── composer.json └── README.md /VERSION: -------------------------------------------------------------------------------- 1 | 1.4.0 2 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel", 3 | "rules": { 4 | "no_superfluous_phpdoc_tags": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/Exceptions/TokenException.php: -------------------------------------------------------------------------------- 1 | name('clickup.sso.redirect_url'); 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 4 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.yml] 15 | indent_style = space 16 | indent_size = 2 17 | -------------------------------------------------------------------------------- /src/Support/Relations/ChildOf.php: -------------------------------------------------------------------------------- 1 | getChild(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Status.php: -------------------------------------------------------------------------------- 1 | 'float', 24 | ]; 25 | 26 | /** 27 | * Is the model readonly? 28 | */ 29 | protected bool $readonlyModel = true; 30 | } 31 | -------------------------------------------------------------------------------- /src/Priority.php: -------------------------------------------------------------------------------- 1 | 'integer', 24 | 'orderindex' => 'float', 25 | ]; 26 | 27 | /** 28 | * Is the model readonly? 29 | */ 30 | protected bool $readonlyModel = true; 31 | } 32 | -------------------------------------------------------------------------------- /src/Project.php: -------------------------------------------------------------------------------- 1 | 'integer', 24 | 'hidden' => 'boolean', 25 | 'access' => 'boolean', 26 | ]; 27 | 28 | /** 29 | * Is the model readonly? 30 | */ 31 | protected bool $readonlyModel = true; 32 | } 33 | -------------------------------------------------------------------------------- /src/TaskTemplate.php: -------------------------------------------------------------------------------- 1 | 'string', 22 | ]; 23 | 24 | /** 25 | * Path to API endpoint. 26 | */ 27 | protected string $path = '/taskTemplate'; 28 | 29 | /** 30 | * Some of the responses have the collections under a property 31 | */ 32 | protected ?string $responseCollectionKey = 'templates'; 33 | } 34 | -------------------------------------------------------------------------------- /src/Support/Relations/HasMany.php: -------------------------------------------------------------------------------- 1 | getBuilder() 27 | ->get(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/User.php: -------------------------------------------------------------------------------- 1 | 'boolean', 29 | 'id' => 'integer', 30 | 'week_start_day' => 'integer', 31 | ]; 32 | 33 | /** 34 | * Path to API endpoint. 35 | */ 36 | protected string $path = '/user'; 37 | 38 | /** 39 | * Is the model readonly? 40 | */ 41 | protected bool $readonlyModel = true; 42 | } 43 | -------------------------------------------------------------------------------- /src/Http/Controllers/ClickUpController.php: -------------------------------------------------------------------------------- 1 | clickup_token = $clickup->oauthRequestTokenUsingCode((string) $request->get('code')); 27 | 28 | $user->save(); 29 | 30 | return $redirector->intended(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Concerns/HasClient.php: -------------------------------------------------------------------------------- 1 | client) && $this->parentModel) { 25 | $this->client = $this->parentModel->getClient(); 26 | } 27 | 28 | if (isset($this->client)) { 29 | return $this->client; 30 | } 31 | 32 | throw new NoClientException(); 33 | } 34 | 35 | /** 36 | * Set the client instance 37 | */ 38 | public function setClient(?Client $client): self 39 | { 40 | $this->client = $client; 41 | 42 | return $this; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /database/migrations/2019_09_23_194855_add_click_up_token_to_users_table.php: -------------------------------------------------------------------------------- 1 | $table->string('clickup_token', 1024) 26 | ->after('password') 27 | ->nullable() 28 | ); 29 | } 30 | 31 | /** 32 | * Reverse the migrations. 33 | * 34 | * @return void 35 | */ 36 | public function down() 37 | { 38 | Schema::table( 39 | 'users', 40 | fn (Blueprint $table): Fluent => $table->dropColumn('clickup_token') 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Http/Middleware/Filter.php: -------------------------------------------------------------------------------- 1 | user()->clickup_token) { 32 | // Set intended route, so that after linking account, user is put where they were going 33 | $this->redirector->setIntendedUrl($request->path()); 34 | 35 | return $this->redirector->to( 36 | $this->clickup->oauthUri((string) $this->url_generator->route('clickup.sso.redirect_url', $request->user())) 37 | ); 38 | } 39 | 40 | return $next($request); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Tag.php: -------------------------------------------------------------------------------- 1 | childOf(Space::class); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /config/clickup.php: -------------------------------------------------------------------------------- 1 | [ 14 | 'id' => env('CLICKUP_CLIENT_ID'), 15 | 16 | 'secret' => env('CLICKUP_CLIENT_SECRET'), 17 | 18 | 'url' => env('CLICKUP_OAUTH_URL', 'https://app.clickup.com/api'), 19 | ], 20 | 21 | /* 22 | |-------------------------------------------------------------------------- 23 | | Route 24 | |-------------------------------------------------------------------------- 25 | | 26 | | Setting for the routing 27 | | 28 | */ 29 | 'route' => [ 30 | 'enabled' => true, 31 | 32 | 'middleware' => ['web'], 33 | 34 | 'sso' => 'clickup/sso', 35 | ], 36 | 37 | /* 38 | |-------------------------------------------------------------------------- 39 | | ClickUp URL 40 | |-------------------------------------------------------------------------- 41 | | 42 | | The URL to the ClickUp server 43 | | 44 | */ 45 | 'url' => env('CLICKUP_URL', 'https://api.clickup.com/api/v2'), 46 | ]; 47 | -------------------------------------------------------------------------------- /src/Interval.php: -------------------------------------------------------------------------------- 1 | 'datetime:Uv', 32 | 'end' => 'datetime:Uv', 33 | 'id' => 'string', 34 | 'start' => 'datetime:Uv', 35 | 'time' => 'integer', 36 | ]; 37 | 38 | // TODO: Figure out how to setup relation to task 39 | /** 40 | * Child of Task 41 | * 42 | * @throws InvalidRelationshipException 43 | * @throws ModelNotFoundException 44 | * @throws NoClientException 45 | */ 46 | /*public function task(): ChildOf 47 | { 48 | return $this->childOf(Task::class); 49 | }*/ 50 | } 51 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spinen/laravel-clickup", 3 | "description": "SPINEN's Laravel Package for ClickUp.", 4 | "keywords": [ 5 | "clickup", 6 | "client", 7 | "laravel", 8 | "spinen" 9 | ], 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Jimmy Puckett", 14 | "email": "jimmy.puckett@spinen.com" 15 | }, 16 | { 17 | "name": "Stephen Finney", 18 | "email": "stephen.finney@spinen.com" 19 | } 20 | ], 21 | "require": { 22 | "php": "^8.0.2", 23 | "ext-json": "*", 24 | "guzzlehttp/guzzle": "^7.0", 25 | "laravel/framework": "^9.19|^10|^11", 26 | "nesbot/carbon": "^2.62.1|^3" 27 | }, 28 | "require-dev": { 29 | "laravel/pint": "^1.4", 30 | "mockery/mockery": "^1.5.1", 31 | "phpunit/phpunit": "^9.5.8", 32 | "psy/psysh": "^0.11", 33 | "scrutinizer/ocular": "^1.9", 34 | "squizlabs/php_codesniffer": "^3.7" 35 | }, 36 | "autoload": { 37 | "psr-4": { 38 | "Spinen\\ClickUp\\": "src/" 39 | } 40 | }, 41 | "autoload-dev": { 42 | "psr-4": { 43 | "Spinen\\ClickUp\\": "tests/" 44 | } 45 | }, 46 | "extra": { 47 | "laravel": { 48 | "providers": [ 49 | "Spinen\\ClickUp\\Providers\\ClientServiceProvider", 50 | "Spinen\\ClickUp\\Providers\\ServiceProvider" 51 | ] 52 | } 53 | }, 54 | "config": { 55 | "sort-packages": true 56 | }, 57 | "minimum-stability": "dev", 58 | "prefer-stable": true 59 | } 60 | -------------------------------------------------------------------------------- /src/Field.php: -------------------------------------------------------------------------------- 1 | 'datetime:Uv', 32 | 'hide_from_guest' => 'boolean', 33 | 'id' => 'string', 34 | ]; 35 | 36 | /** 37 | * Is resource nested behind parentModel 38 | * 39 | * Several of the endpoints are nested behind another model for relationship, but then to 40 | * interact with the specific model, then are not nested. This property will know when to 41 | * keep the specific model nested. 42 | */ 43 | protected bool $nested = true; 44 | 45 | /** 46 | * Path to API endpoint. 47 | */ 48 | protected string $path = '/field'; 49 | 50 | /** 51 | * Child of TaskList 52 | * 53 | * @throws InvalidRelationshipException 54 | * @throws ModelNotFoundException 55 | * @throws NoClientException 56 | */ 57 | public function list(): ChildOf 58 | { 59 | return $this->childOf(TaskList::class); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Member.php: -------------------------------------------------------------------------------- 1 | 'integer', 33 | 'role' => 'integer', 34 | ]; 35 | 36 | /** 37 | * Path to API endpoint. 38 | */ 39 | protected string $path = '/member'; 40 | 41 | /** 42 | * Optional Child of TaskList 43 | * 44 | * @throws InvalidRelationshipException 45 | * @throws ModelNotFoundException 46 | * @throws NoClientException 47 | */ 48 | public function list(): ?ChildOf 49 | { 50 | return is_a($this->parentModel, TaskList::class) ? $this->childOf(TaskList::class) : null; 51 | } 52 | 53 | /** 54 | * Optional Child of Task 55 | * 56 | * @throws InvalidRelationshipException 57 | * @throws ModelNotFoundException 58 | * @throws NoClientException 59 | */ 60 | public function task(): ?ChildOf 61 | { 62 | return is_a($this->parentModel, Task::class) ? $this->childOf(Task::class) : null; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Share.php: -------------------------------------------------------------------------------- 1 | givenMany(Folder::class, $folders); 35 | } 36 | 37 | /** 38 | * Accessor for Lists. 39 | * 40 | * @throws NoClientException 41 | */ 42 | public function getListsAttribute(?array $lists): Collection 43 | { 44 | return $this->givenMany(TaskList::class, $lists); 45 | } 46 | 47 | /** 48 | * Accessor for Tasks. 49 | * 50 | * @throws NoClientException 51 | */ 52 | public function getTasksAttribute(?array $tasks): Collection 53 | { 54 | return $this->givenMany(Task::class, $tasks); 55 | } 56 | 57 | /** 58 | * Child of Team 59 | * 60 | * @throws InvalidRelationshipException 61 | * @throws ModelNotFoundException 62 | * @throws NoClientException 63 | */ 64 | public function team(): ChildOf 65 | { 66 | return $this->childOf(Team::class); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Checklist.php: -------------------------------------------------------------------------------- 1 | 'datetime:Uv', 35 | 'id' => 'string', 36 | 'orderindex' => 'float', 37 | 'resolved' => 'boolean', 38 | 'task_id' => 'string', 39 | 'unresolved' => 'boolean', 40 | ]; 41 | 42 | /** 43 | * Path to API endpoint. 44 | */ 45 | protected string $path = '/checklist'; 46 | 47 | /** 48 | * Accessor for Items. 49 | * 50 | * @throws NoClientException 51 | */ 52 | public function getItemsAttribute(array $items): Collection 53 | { 54 | return $this->givenMany(Item::class, $items); 55 | } 56 | 57 | /** 58 | * Child of Task 59 | * 60 | * @throws InvalidRelationshipException 61 | * @throws ModelNotFoundException 62 | * @throws NoClientException 63 | */ 64 | public function task(): ChildOf 65 | { 66 | return $this->childOf(Task::class); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Time.php: -------------------------------------------------------------------------------- 1 | 'integer', 29 | ]; 30 | 31 | /** 32 | * Path to API endpoint. 33 | */ 34 | protected string $path = '/time'; 35 | 36 | /** 37 | * Some of the responses have the data under a property 38 | */ 39 | protected ?string $responseKey = 'data'; 40 | 41 | /** 42 | * Accessor for Intervals. 43 | * 44 | * @throws NoClientException 45 | */ 46 | public function getIntervalsAttribute(?array $intervals): Collection 47 | { 48 | return $this->givenMany(Interval::class, $intervals); 49 | } 50 | 51 | /** 52 | * Accessor for User. 53 | * 54 | * @throws NoClientException 55 | */ 56 | public function getUserAttribute(?array $user): Member 57 | { 58 | return $this->givenOne(Member::class, $user); 59 | } 60 | 61 | /** 62 | * Child of Task 63 | * 64 | * @throws InvalidRelationshipException 65 | * @throws ModelNotFoundException 66 | * @throws NoClientException 67 | */ 68 | public function task(): ChildOf 69 | { 70 | return $this->childOf(Task::class); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Providers/ClientServiceProvider.php: -------------------------------------------------------------------------------- 1 | registerClient(); 38 | 39 | $this->app->alias(ClickUp::class, 'ClickUp'); 40 | } 41 | 42 | /** 43 | * Get the services provided by the provider. 44 | * 45 | * @return array 46 | */ 47 | public function provides() 48 | { 49 | return [ 50 | Builder::class, 51 | ClickUp::class, 52 | ]; 53 | } 54 | 55 | /** 56 | * Register the client 57 | * 58 | * If the ClickUp id or roles are null, then assume sensible values via the API 59 | */ 60 | protected function registerClient(): void 61 | { 62 | $this->app->bind( 63 | Builder::class, 64 | fn (Application $app): Builder => new Builder($app->make(ClickUp::class)) 65 | ); 66 | 67 | $this->app->bind( 68 | ClickUp::class, 69 | fn (Application $app): ClickUp => new ClickUp(Config::get('clickup'), $app->make(Guzzle::class)) 70 | ); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Support/Relations/Relation.php: -------------------------------------------------------------------------------- 1 | related = $builder->getModel(); 36 | } 37 | 38 | /** 39 | * Handle dynamic method calls to the relationship. 40 | */ 41 | public function __call(string $method, array $parameters) 42 | { 43 | if (static::hasMacro($method)) { 44 | return $this->macroCall($method, $parameters); 45 | } 46 | 47 | $result = $this->forwardCallTo($this->getBuilder(), $method, $parameters); 48 | 49 | if ($result === $this->getBuilder()) { 50 | return $this; 51 | } 52 | 53 | return $result; 54 | } 55 | 56 | /** 57 | * Get the Builder instance 58 | */ 59 | public function getBuilder(): Builder 60 | { 61 | return $this->builder; 62 | } 63 | 64 | /** 65 | * Get the parent Model instance 66 | */ 67 | public function getParent(): Model 68 | { 69 | return $this->parent; 70 | } 71 | 72 | /** 73 | * Get the related Model instance 74 | */ 75 | public function getRelated(): Model 76 | { 77 | return $this->related; 78 | } 79 | 80 | /** 81 | * Get the results of the relationship. 82 | */ 83 | abstract public function getResults(); 84 | } 85 | -------------------------------------------------------------------------------- /src/Support/Relations/BelongsTo.php: -------------------------------------------------------------------------------- 1 | whereId($this->getForeignKey()), $this->getChild()); 32 | } 33 | 34 | /** 35 | * Get the child Model 36 | */ 37 | public function getChild(): Model 38 | { 39 | return $this->child; 40 | } 41 | 42 | /** 43 | * Get the foreign key's name 44 | */ 45 | public function getForeignKey(): int|string|null 46 | { 47 | return $this->getChild()->{$this->getForeignKeyName()}; 48 | } 49 | 50 | /** 51 | * Get the name of the foreign key's name 52 | */ 53 | public function getForeignKeyName(): string 54 | { 55 | return $this->foreignKey; 56 | } 57 | 58 | /** 59 | * Get the results of the relationship. 60 | * 61 | * @throws GuzzleException 62 | * @throws InvalidRelationshipException 63 | * @throws NoClientException 64 | * @throws TokenException 65 | */ 66 | public function getResults(): ?Model 67 | { 68 | if (! $this->getForeignKey()) { 69 | return null; 70 | } 71 | 72 | return $this->getBuilder() 73 | ->get() 74 | ->first(); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Item.php: -------------------------------------------------------------------------------- 1 | 'datetime:Uv', 32 | 'id' => 'string', 33 | 'orderindex' => 'float', 34 | 'resolved' => 'boolean', 35 | 'unresolved' => 'boolean', 36 | ]; 37 | 38 | /** 39 | * Is resource nested behind parentModel 40 | * 41 | * Several of the endpoints are nested behind another model for relationship, but then to 42 | * interact with the specific model, then are not nested. This property will know when to 43 | * keep the specific model nested. 44 | */ 45 | protected bool $nested = true; 46 | 47 | /** 48 | * Path to API endpoint. 49 | */ 50 | protected string $path = '/checklist_item'; 51 | 52 | /** 53 | * Accessor for Assignee. 54 | * 55 | * @throws NoClientException 56 | */ 57 | public function getAssigneeAttribute(?array $assignee): Member 58 | { 59 | return $this->givenOne(Member::class, $assignee); 60 | } 61 | 62 | /** 63 | * Accessor for Children. 64 | * 65 | * @throws NoClientException 66 | */ 67 | public function getChildrenAttribute(?array $children): Collection 68 | { 69 | return $this->givenMany(Item::class, $children); 70 | } 71 | 72 | /** 73 | * Accessor for Parent. 74 | */ 75 | // TODO: Figure out how to make this relationship work 76 | /*public function getParentAttribute(string $parent): Item 77 | { 78 | return $this->parentModel; 79 | }*/ 80 | } 81 | -------------------------------------------------------------------------------- /src/Result.php: -------------------------------------------------------------------------------- 1 | 'boolean', 41 | 'creator' => 'integer', 42 | 'date_created' => 'datetime:Uv', 43 | 'goal_pretty_id' => 'integer', 44 | 'id' => 'string', 45 | 'percent_completed' => 'float', 46 | ]; 47 | 48 | /** 49 | * Is resource nested behind parentModel 50 | * 51 | * Several of the endpoints are nested behind another model for relationship, but then to 52 | * interact with the specific model, then are not nested. This property will know when to 53 | * keep the specific model nested. 54 | */ 55 | protected bool $nested = true; 56 | 57 | /** 58 | * Path to API endpoint. 59 | */ 60 | protected string $path = '/key_result'; 61 | 62 | /** 63 | * Accessor for Owners. 64 | * 65 | * @throws NoClientException 66 | */ 67 | public function getOwnersAttribute(?array $owners): Collection 68 | { 69 | return $this->givenMany(Member::class, $owners); 70 | } 71 | 72 | /** 73 | * Child of Goal 74 | * 75 | * @throws InvalidRelationshipException 76 | * @throws ModelNotFoundException 77 | * @throws NoClientException 78 | */ 79 | public function goal(): ChildOf 80 | { 81 | return $this->childOf(Goal::class); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Folder.php: -------------------------------------------------------------------------------- 1 | 'boolean', 37 | 'hidden' => 'boolean', 38 | 'id' => 'integer', 39 | 'orderindex' => 'float', 40 | 'override_statuses' => 'boolean', 41 | 'task_count' => 'integer', 42 | ]; 43 | 44 | /** 45 | * Path to API endpoint. 46 | */ 47 | protected string $path = '/folder'; 48 | 49 | /** 50 | * Accessor for Statuses. 51 | * 52 | * @throws NoClientException 53 | */ 54 | public function getStatusesAttribute(?array $statuses): Collection 55 | { 56 | return $this->givenMany(Status::class, $statuses); 57 | } 58 | 59 | /** 60 | * Accessor for Lists. 61 | * 62 | * @throws NoClientException 63 | */ 64 | public function getListsAttribute(?array $lists): Collection 65 | { 66 | return $this->givenMany(TaskList::class, $lists); 67 | } 68 | 69 | /** 70 | * Child of Space 71 | * 72 | * @throws InvalidRelationshipException 73 | * @throws ModelNotFoundException 74 | * @throws NoClientException 75 | */ 76 | public function space(): ChildOf 77 | { 78 | return $this->childOf(Space::class); 79 | } 80 | 81 | /** 82 | * HasMany Views 83 | * 84 | * @throws InvalidRelationshipException 85 | * @throws ModelNotFoundException 86 | * @throws NoClientException 87 | */ 88 | public function views(): HasMany 89 | { 90 | return $this->hasMany(View::class); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Providers/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | registerMiddleware(); 23 | 24 | $this->registerPublishes(); 25 | 26 | $this->registerRoutes(); 27 | } 28 | 29 | /** 30 | * Register services. 31 | * 32 | * @return void 33 | */ 34 | public function register() 35 | { 36 | $this->mergeConfigFrom(__DIR__.'/../../config/clickup.php', 'clickup'); 37 | } 38 | 39 | /** 40 | * Register the middleware 41 | * 42 | * If a route needs to have the QuickBooks client, then make sure that the user has linked their account. 43 | */ 44 | public function registerMiddleware() 45 | { 46 | $this->app->router->aliasMiddleware('clickup', Filter::class); 47 | } 48 | 49 | /** 50 | * There are several resources that get published 51 | * 52 | * Only worry about telling the application about them if running in the console. 53 | */ 54 | protected function registerPublishes() 55 | { 56 | if ($this->app->runningInConsole()) { 57 | $this->loadMigrationsFrom(__DIR__.'/../../database/migrations'); 58 | 59 | $this->publishes( 60 | [ 61 | __DIR__.'/../../config/clickup.php' => config_path('clickup.php'), 62 | ], 63 | 'clickup-config' 64 | ); 65 | 66 | $this->publishes( 67 | [ 68 | __DIR__.'/../../database/migrations' => database_path('migrations'), 69 | ], 70 | 'clickup-migrations' 71 | ); 72 | } 73 | } 74 | 75 | /** 76 | * Register the routes needed for the OAuth flow 77 | */ 78 | protected function registerRoutes() 79 | { 80 | if (Config::get('clickup.route.enabled')) { 81 | Route::group( 82 | [ 83 | 'namespace' => 'Spinen\ClickUp\Http\Controllers', 84 | 'middleware' => Config::get('clickup.route.middleware', ['web']), 85 | ], 86 | fn () => $this->loadRoutesFrom(realpath(__DIR__.'/../../routes/web.php')) 87 | ); 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Concerns/HasClickUp.php: -------------------------------------------------------------------------------- 1 | builder)) { 33 | $this->builder = Container::getInstance() 34 | ->make(Builder::class) 35 | ->setClient( 36 | Container::getInstance() 37 | ->make(ClickUp::class) 38 | ->setToken($this->clickup_token) 39 | ); 40 | } 41 | 42 | return $this->builder; 43 | } 44 | 45 | /** 46 | * Accessor for ClickUp Client. 47 | * 48 | * @throws BindingResolutionException 49 | * @throws NoClientException 50 | */ 51 | public function getClickupAttribute(): ClickUp 52 | { 53 | return $this->clickup() 54 | ->getClient(); 55 | } 56 | 57 | /** 58 | * Accessor for ClickUpToken. 59 | * 60 | * @throws BindingResolutionException 61 | */ 62 | public function getClickupTokenAttribute(): ?string 63 | { 64 | if (! is_null($this->attributes['clickup_token'])) { 65 | return Crypt::decryptString($this->attributes['clickup_token']); 66 | } 67 | 68 | return null; 69 | } 70 | 71 | /** 72 | * Make sure that the clickup_token is fillable & protected 73 | */ 74 | public function initializeHasClickUp(): void 75 | { 76 | $this->fillable[] = 'clickup_token'; 77 | $this->hidden[] = 'clickup'; 78 | $this->hidden[] = 'clickup_token'; 79 | } 80 | 81 | /** 82 | * Mutator for ClickUpToken. 83 | * 84 | * @throws BindingResolutionException 85 | */ 86 | public function setClickupTokenAttribute(?string $clickup_token): void 87 | { 88 | // If setting the password & already have a client, then empty the client to use new password in client 89 | if (! is_null($this->builder)) { 90 | $this->builder = null; 91 | } 92 | 93 | $this->attributes['clickup_token'] = is_null($clickup_token) 94 | ? null 95 | : Crypt::encryptString($clickup_token); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Webhook.php: -------------------------------------------------------------------------------- 1 | 'integer', 37 | 'id' => 'string', 38 | 'list_id' => 'integer', 39 | 'space_id' => 'integer', 40 | 'team_id' => 'integer', 41 | 'userid' => 'integer', 42 | ]; 43 | 44 | /** 45 | * Path to API endpoint. 46 | */ 47 | protected string $path = '/webhook'; 48 | 49 | /** 50 | * Belongs to Folder 51 | * 52 | * @throws InvalidRelationshipException 53 | * @throws ModelNotFoundException 54 | * @throws NoClientException 55 | */ 56 | public function folder(): BelongsTo 57 | { 58 | return $this->belongsTo(Folder::class); 59 | } 60 | 61 | /** 62 | * Belongs to TaskList 63 | * 64 | * @throws InvalidRelationshipException 65 | * @throws ModelNotFoundException 66 | * @throws NoClientException 67 | */ 68 | public function list(): BelongsTo 69 | { 70 | return $this->belongsTo(TaskList::class); 71 | } 72 | 73 | /** 74 | * Belongs to Space 75 | * 76 | * @throws InvalidRelationshipException 77 | * @throws ModelNotFoundException 78 | * @throws NoClientException 79 | */ 80 | public function space(): BelongsTo 81 | { 82 | return $this->belongsTo(Space::class); 83 | } 84 | 85 | /** 86 | * Child of Team 87 | * 88 | * @throws InvalidRelationshipException 89 | * @throws ModelNotFoundException 90 | * @throws NoClientException 91 | */ 92 | public function team(): ChildOf 93 | { 94 | return $this->childOf(Team::class); 95 | } 96 | 97 | /** 98 | * Belongs to Member 99 | * 100 | * @throws InvalidRelationshipException 101 | * @throws ModelNotFoundException 102 | * @throws NoClientException 103 | */ 104 | public function user(): BelongsTo 105 | { 106 | return $this->belongsTo(Member::class, 'userid'); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/Comment.php: -------------------------------------------------------------------------------- 1 | 'datetime:Uv', 38 | 'id' => 'integer', 39 | 'resolved' => 'boolean', 40 | ]; 41 | 42 | /** 43 | * Path to API endpoint. 44 | */ 45 | protected string $path = '/comment'; 46 | 47 | /** 48 | * Accessor for Assignee. 49 | * 50 | * @throws NoClientException 51 | */ 52 | public function getAssigneeAttribute(?array $assignee): Member 53 | { 54 | return $this->givenOne(Member::class, $assignee); 55 | } 56 | 57 | /** 58 | * Accessor for AssignedBy. 59 | * 60 | * @throws NoClientException 61 | */ 62 | public function getAssignedByAttribute(?array $assigned_by): Member 63 | { 64 | return $this->givenOne(Member::class, $assigned_by); 65 | } 66 | 67 | /** 68 | * Accessor for User. 69 | * 70 | * @throws NoClientException 71 | */ 72 | public function getUserAttribute(?array $user): Member 73 | { 74 | return $this->givenOne(Member::class, $user); 75 | } 76 | 77 | /** 78 | * Optional Child of TaskList 79 | * 80 | * @throws InvalidRelationshipException 81 | * @throws ModelNotFoundException 82 | * @throws NoClientException 83 | */ 84 | public function list(): ?ChildOf 85 | { 86 | return is_a($this->parentModel, TaskList::class) ? $this->childOf(TaskList::class) : null; 87 | } 88 | 89 | /** 90 | * Child of Task 91 | * 92 | * @throws InvalidRelationshipException 93 | * @throws ModelNotFoundException 94 | * @throws NoClientException 95 | */ 96 | public function task(): ?ChildOf 97 | { 98 | return is_a($this->parentModel, Task::class) ? $this->childOf(Task::class) : null; 99 | } 100 | 101 | /** 102 | * Child of View 103 | * 104 | * @throws InvalidRelationshipException 105 | * @throws ModelNotFoundException 106 | * @throws NoClientException 107 | */ 108 | public function view(): ?ChildOf 109 | { 110 | return is_a($this->parentModel, View::class) ? $this->childOf(View::class) : null; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/Space.php: -------------------------------------------------------------------------------- 1 | 'boolean', 40 | 'id' => 'integer', 41 | 'multiple_assignees' => 'boolean', 42 | 'private' => 'boolean', 43 | ]; 44 | 45 | /** 46 | * Path to API endpoint. 47 | */ 48 | protected string $path = '/space'; 49 | 50 | /** 51 | * Accessor for Members. 52 | * 53 | * @throws NoClientException 54 | */ 55 | public function getMembersAttribute(?array $members): Collection 56 | { 57 | return $this->givenMany(Member::class, $members, true); 58 | } 59 | 60 | /** 61 | * Accessor for Statuses. 62 | * 63 | * @throws NoClientException 64 | */ 65 | public function getStatusesAttribute(?array $statuses): Collection 66 | { 67 | return $this->givenMany(Status::class, $statuses); 68 | } 69 | 70 | /** 71 | * Has many Folders 72 | * 73 | * @throws InvalidRelationshipException 74 | * @throws ModelNotFoundException 75 | * @throws NoClientException 76 | */ 77 | public function folders(): HasMany 78 | { 79 | return $this->hasMany(Folder::class); 80 | } 81 | 82 | /** 83 | * Has many TaskLists 84 | * 85 | * @throws InvalidRelationshipException 86 | * @throws ModelNotFoundException 87 | * @throws NoClientException 88 | */ 89 | public function lists(): HasMany 90 | { 91 | return $this->hasMany(TaskList::class); 92 | } 93 | 94 | /** 95 | * Has many Tags 96 | * 97 | * @throws InvalidRelationshipException 98 | * @throws ModelNotFoundException 99 | * @throws NoClientException 100 | */ 101 | public function tags(): HasMany 102 | { 103 | return $this->hasMany(Tag::class); 104 | } 105 | 106 | /** 107 | * Child of Team 108 | * 109 | * @throws InvalidRelationshipException 110 | * @throws ModelNotFoundException 111 | * @throws NoClientException 112 | */ 113 | public function team(): ChildOf 114 | { 115 | return $this->childOf(Team::class); 116 | } 117 | 118 | /** 119 | * Has many Views 120 | * 121 | * @throws InvalidRelationshipException 122 | * @throws ModelNotFoundException 123 | * @throws NoClientException 124 | */ 125 | public function views(): HasMany 126 | { 127 | return $this->hasMany(View::class); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/Goal.php: -------------------------------------------------------------------------------- 1 | 'boolean', 53 | 'creator' => 'integer', 54 | 'date_created' => 'datetime:Uv', 55 | 'due_date' => 'datetime:Uv', 56 | 'folder_id' => 'integer', 57 | 'id' => 'string', 58 | 'key_result_count' => 'integer', 59 | 'last_update' => 'datetime:Uv', 60 | 'multiple_owners' => 'boolean', 61 | 'owner' => 'integer', 62 | 'percent_completed' => 'float', 63 | 'pinned' => 'boolean', 64 | 'pretty_id' => 'integer', 65 | 'private' => 'boolean', 66 | 'start_date' => 'datetime:Uv', 67 | 'team_id' => 'integer', 68 | ]; 69 | 70 | // TODO: Setup creator & owner as a "BelongsTo" (need API resource to look up a Member) 71 | 72 | /** 73 | * Path to API endpoint. 74 | */ 75 | protected string $path = '/goal'; 76 | 77 | /** 78 | * Accessor for Members. 79 | * 80 | * @throws NoClientException 81 | */ 82 | public function getMembersAttribute(?array $members): Collection 83 | { 84 | return $this->givenMany(Member::class, $members, true); 85 | } 86 | 87 | /** 88 | * Accessor for Owners. 89 | * 90 | * @throws NoClientException 91 | */ 92 | public function getOwnersAttribute(?array $owners): Collection 93 | { 94 | return $this->givenMany(Member::class, $owners); 95 | } 96 | 97 | /** 98 | * Belongs to Folder 99 | * 100 | * @throws InvalidRelationshipException 101 | * @throws ModelNotFoundException 102 | * @throws NoClientException 103 | */ 104 | public function folder(): BelongsTo 105 | { 106 | return $this->belongsTo(Folder::class); 107 | } 108 | 109 | /** 110 | * Child of Team 111 | * 112 | * @throws InvalidRelationshipException 113 | * @throws ModelNotFoundException 114 | * @throws NoClientException 115 | */ 116 | public function team(): ChildOf 117 | { 118 | return $this->childOf(Team::class); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/Team.php: -------------------------------------------------------------------------------- 1 | 'integer', 37 | ]; 38 | 39 | /** 40 | * Path to API endpoint. 41 | */ 42 | protected string $path = '/team'; 43 | 44 | /** 45 | * Has many Goals 46 | * 47 | * @throws InvalidRelationshipException 48 | * @throws ModelNotFoundException 49 | * @throws NoClientException 50 | */ 51 | public function goals(): HasMany 52 | { 53 | return $this->hasMany(Goal::class); 54 | } 55 | 56 | /** 57 | * Accessor for Members. 58 | * 59 | * @throws NoClientException 60 | */ 61 | public function getMembersAttribute(?array $members): Collection 62 | { 63 | return $this->givenMany(Member::class, $members, true); 64 | } 65 | 66 | /** 67 | * Has many Shares 68 | * 69 | * @throws InvalidRelationshipException 70 | * @throws ModelNotFoundException 71 | * @throws NoClientException 72 | */ 73 | public function shares(): HasMany 74 | { 75 | return $this->hasMany(Share::class); 76 | } 77 | 78 | /** 79 | * Has many Spaces 80 | * 81 | * @throws InvalidRelationshipException 82 | * @throws ModelNotFoundException 83 | * @throws NoClientException 84 | */ 85 | public function spaces(): HasMany 86 | { 87 | return $this->hasMany(Space::class); 88 | } 89 | 90 | /** 91 | * Has many Tasks 92 | * 93 | * @throws InvalidRelationshipException 94 | * @throws ModelNotFoundException 95 | * @throws NoClientException 96 | */ 97 | public function tasks(): HasMany 98 | { 99 | return $this->hasMany(Task::class); 100 | } 101 | 102 | /** 103 | * Has many TaskTemplates 104 | * 105 | * @throws InvalidRelationshipException 106 | * @throws ModelNotFoundException 107 | * @throws NoClientException 108 | */ 109 | public function taskTemplates(): HasMany 110 | { 111 | return $this->hasMany(TaskTemplate::class); 112 | } 113 | 114 | /** 115 | * Has many Views 116 | * 117 | * @throws InvalidRelationshipException 118 | * @throws ModelNotFoundException 119 | * @throws NoClientException 120 | */ 121 | public function views(): HasMany 122 | { 123 | return $this->hasMany(View::class); 124 | } 125 | 126 | /** 127 | * Has many Webhooks 128 | * 129 | * @throws InvalidRelationshipException 130 | * @throws ModelNotFoundException 131 | * @throws NoClientException 132 | */ 133 | public function webhooks(): HasMany 134 | { 135 | return $this->hasMany(Webhook::class); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/View.php: -------------------------------------------------------------------------------- 1 | 'datetime:Uv', 51 | 'date_protected' => 'integer', 52 | 'id' => 'string', 53 | 'orderindex' => 'float', 54 | 'protected' => 'boolean', 55 | ]; 56 | 57 | /** 58 | * Path to API endpoint. 59 | */ 60 | protected string $path = '/view'; 61 | 62 | /** 63 | * Has many Comments 64 | * 65 | * @throws InvalidRelationshipException 66 | * @throws ModelNotFoundException 67 | * @throws NoClientException 68 | */ 69 | public function comments(): HasMany 70 | { 71 | if ($this->type !== 'conversation') { 72 | throw new InvalidRelationshipException( 73 | sprintf('The view is of type [%s], but must be of type [conversation] to have comments.', $this->type) 74 | ); 75 | } 76 | 77 | return $this->hasMany(Comment::class); 78 | } 79 | 80 | /** 81 | * Optional Child of Folder 82 | * 83 | * @throws InvalidRelationshipException 84 | * @throws ModelNotFoundException 85 | * @throws NoClientException 86 | */ 87 | public function folder(): ?ChildOf 88 | { 89 | return is_a($this->parentModel, Folder::class) ? $this->childOf(Folder::class) : null; 90 | } 91 | 92 | /** 93 | * Accessor for ProtectedBy. 94 | * 95 | * @throws NoClientException 96 | */ 97 | public function getProtectedByAttribute(?array $protected_by): Member 98 | { 99 | return $this->givenOne(Member::class, $protected_by); 100 | } 101 | 102 | /** 103 | * Optional Child of TaskList 104 | * 105 | * @throws InvalidRelationshipException 106 | * @throws ModelNotFoundException 107 | * @throws NoClientException 108 | */ 109 | public function list(): ?ChildOf 110 | { 111 | return is_a($this->parentModel, TaskList::class) ? $this->childOf(TaskList::class) : null; 112 | } 113 | 114 | /** 115 | * Optional Child of Space 116 | * 117 | * @throws InvalidRelationshipException 118 | * @throws ModelNotFoundException 119 | * @throws NoClientException 120 | */ 121 | public function space(): ?ChildOf 122 | { 123 | return is_a($this->parentModel, Space::class) ? $this->childOf(Space::class) : null; 124 | } 125 | 126 | /** 127 | * HasMany Tasks 128 | * 129 | * @throws InvalidRelationshipException 130 | * @throws ModelNotFoundException 131 | * @throws NoClientException 132 | */ 133 | public function tasks(): HasMany 134 | { 135 | if (in_array($this->type, ['conversation', 'doc', 'embed'])) { 136 | throw new InvalidRelationshipException( 137 | sprintf('The view is of type [%s], but must be of on of the task types to have tasks.', $this->type) 138 | ); 139 | } 140 | 141 | return $this->hasMany(Task::class); 142 | } 143 | 144 | /** 145 | * Optional Child of Team 146 | * 147 | * @throws InvalidRelationshipException 148 | * @throws ModelNotFoundException 149 | * @throws NoClientException 150 | */ 151 | public function team(): ?ChildOf 152 | { 153 | return is_a($this->parentModel, Team::class) ? $this->childOf(Team::class) : null; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/TaskList.php: -------------------------------------------------------------------------------- 1 | 'boolean', 50 | 'due_date' => 'datetime:Uv', 51 | 'due_date_time' => 'boolean', 52 | 'id' => 'integer', 53 | 'orderindex' => 'float', 54 | 'override_statuses' => 'boolean', 55 | 'start_date' => 'datetime:Uv', 56 | 'start_date_time' => 'boolean', 57 | 'task_count' => 'integer', 58 | ]; 59 | 60 | /** 61 | * Path to API endpoint. 62 | */ 63 | protected string $path = '/list'; 64 | 65 | /** 66 | * Some of the responses have the collections under a property 67 | */ 68 | protected ?string $responseCollectionKey = 'lists'; 69 | 70 | /** 71 | * Some of the responses have the data under a property 72 | */ 73 | protected ?string $responseKey = 'list'; 74 | 75 | /** 76 | * Has many Comments 77 | * 78 | * @throws InvalidRelationshipException 79 | * @throws ModelNotFoundException 80 | * @throws NoClientException 81 | */ 82 | public function comments(): HasMany 83 | { 84 | return $this->hasMany(Comment::class); 85 | } 86 | 87 | /** 88 | * Has many Fields 89 | * 90 | * @throws InvalidRelationshipException 91 | * @throws ModelNotFoundException 92 | * @throws NoClientException 93 | */ 94 | public function fields(): HasMany 95 | { 96 | return $this->hasMany(Field::class); 97 | } 98 | 99 | /** 100 | * Optional Child of Folder 101 | * 102 | * @throws InvalidRelationshipException 103 | * @throws ModelNotFoundException 104 | * @throws NoClientException 105 | */ 106 | public function folder(): ?ChildOf 107 | { 108 | return is_a($this->parentModel, Folder::class) ? $this->childOf(Folder::class) : null; 109 | } 110 | 111 | /** 112 | * Accessor for Assignee. 113 | * 114 | * @throws NoClientException 115 | */ 116 | public function getAssigneeAttribute(?array $assignee): Member 117 | { 118 | return $this->givenOne(Member::class, $assignee); 119 | } 120 | 121 | /** 122 | * Accessor for Priority. 123 | * 124 | * @throws NoClientException 125 | */ 126 | public function getPriorityAttribute(?array $priority): Priority 127 | { 128 | return $this->givenOne(Priority::class, $priority); 129 | } 130 | 131 | /** 132 | * Accessor for Status. 133 | * 134 | * @throws NoClientException 135 | */ 136 | public function getStatusAttribute(?array $status): Status 137 | { 138 | return $this->givenOne(Status::class, $status); 139 | } 140 | 141 | /** 142 | * Accessor for Statuses. 143 | * 144 | * @throws NoClientException 145 | */ 146 | public function getStatusesAttribute(?array $statuses): Collection 147 | { 148 | return $this->givenMany(Status::class, $statuses); 149 | } 150 | 151 | /** 152 | * Has many Members 153 | * 154 | * @throws InvalidRelationshipException 155 | * @throws ModelNotFoundException 156 | * @throws NoClientException 157 | */ 158 | public function members(): HasMany 159 | { 160 | return $this->hasMany(Member::class); 161 | } 162 | 163 | /** 164 | * Optional Child of Space 165 | * 166 | * @throws InvalidRelationshipException 167 | * @throws ModelNotFoundException 168 | * @throws NoClientException 169 | */ 170 | public function space(): ?ChildOf 171 | { 172 | return is_a($this->parentModel, Space::class) ? $this->childOf(Space::class) : null; 173 | } 174 | 175 | /** 176 | * Has many Tasks 177 | * 178 | * @throws InvalidRelationshipException 179 | * @throws ModelNotFoundException 180 | * @throws NoClientException 181 | */ 182 | public function tasks(): HasMany 183 | { 184 | return $this->hasMany(Task::class); 185 | } 186 | 187 | /** 188 | * Has many TaskTemplates 189 | * 190 | * @throws InvalidRelationshipException 191 | * @throws ModelNotFoundException 192 | * @throws NoClientException 193 | */ 194 | public function taskTemplates(): HasMany 195 | { 196 | return $this->hasMany(TaskTemplate::class); 197 | } 198 | 199 | /** 200 | * Has many Views 201 | * 202 | * @throws InvalidRelationshipException 203 | * @throws ModelNotFoundException 204 | * @throws NoClientException 205 | */ 206 | public function views(): HasMany 207 | { 208 | return $this->hasMany(View::class); 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/Api/Client.php: -------------------------------------------------------------------------------- 1 | setConfigs($configs); 36 | $this->guzzle = $guzzle; 37 | $this->setToken($token); 38 | } 39 | 40 | /** 41 | * Shortcut to 'DELETE' request 42 | * 43 | * @throws GuzzleException 44 | * @throws TokenException 45 | */ 46 | public function delete(string $path): ?array 47 | { 48 | return $this->request($path, [], 'DELETE'); 49 | } 50 | 51 | /** 52 | * Shortcut to 'GET' request 53 | * 54 | * @throws GuzzleException 55 | * @throws TokenException 56 | */ 57 | public function get(string $path): ?array 58 | { 59 | return $this->request($path, [], 'GET'); 60 | } 61 | 62 | /** 63 | * Convert OAuth code to token for user 64 | * 65 | * @throws GuzzleException 66 | */ 67 | public function oauthRequestTokenUsingCode(string $code): string 68 | { 69 | $path = 'oauth/token?'.http_build_query( 70 | [ 71 | 'client_id' => $this->configs['oauth']['id'], 72 | 'client_secret' => $this->configs['oauth']['secret'], 73 | 'code' => $code, 74 | ] 75 | ); 76 | 77 | try { 78 | return json_decode( 79 | $this->guzzle->request( 80 | 'POST', 81 | $this->uri($path), 82 | [ 83 | 'headers' => [ 84 | 'Content-Type' => 'application/json', 85 | ], 86 | ] 87 | ) 88 | ->getBody() 89 | ->getContents(), 90 | true 91 | )['access_token']; 92 | } catch (GuzzleException $e) { 93 | // TODO: Figure out what to do with this error 94 | // TODO: Consider returning [] for 401's? 95 | 96 | throw $e; 97 | } 98 | } 99 | 100 | /** 101 | * Build the uri to redirect the user to start the OAuth process 102 | */ 103 | public function oauthUri(string $url): string 104 | { 105 | return $this->uri( 106 | '?'.http_build_query( 107 | [ 108 | 'client_id' => $this->configs['oauth']['id'], 109 | 'redirect_uri' => $url, 110 | ] 111 | ), 112 | $this->configs['oauth']['url'] 113 | ); 114 | } 115 | 116 | /** 117 | * Shortcut to 'POST' request 118 | * 119 | * @throws GuzzleException 120 | * @throws TokenException 121 | */ 122 | public function post(string $path, array $data): ?array 123 | { 124 | return $this->request($path, $data, 'POST'); 125 | } 126 | 127 | /** 128 | * Shortcut to 'PUT' request 129 | * 130 | * @throws GuzzleException 131 | * @throws TokenException 132 | */ 133 | public function put(string $path, array $data): ?array 134 | { 135 | return $this->request($path, $data, 'PUT'); 136 | } 137 | 138 | /** 139 | * Make an API call to ClickUp 140 | * 141 | * @throws GuzzleException 142 | * @throws TokenException 143 | */ 144 | public function request(?string $path, ?array $data = [], ?string $method = 'GET'): ?array 145 | { 146 | if (! $this->token) { 147 | throw new TokenException('Must set token before making a request'); 148 | } 149 | 150 | try { 151 | return json_decode( 152 | $this->guzzle->request( 153 | $method, 154 | $this->uri($path), 155 | [ 156 | 'headers' => [ 157 | 'Authorization' => $this->token, 158 | 'Content-Type' => 'application/json', 159 | ], 160 | 'body' => empty($data) ? null : json_encode($data), 161 | ] 162 | ) 163 | ->getBody() 164 | ->getContents(), 165 | true 166 | ); 167 | } catch (GuzzleException $e) { 168 | // TODO: Figure out what to do with this error 169 | // TODO: Consider returning [] for 401's? 170 | 171 | throw $e; 172 | } 173 | } 174 | 175 | /** 176 | * Set the configs 177 | */ 178 | public function setConfigs(array $configs): self 179 | { 180 | // Replace empty strings with nulls in config values 181 | $this->configs = array_map(fn ($v) => $v === '' ? null : $v, $configs); 182 | 183 | return $this; 184 | } 185 | 186 | /** 187 | * Set the token 188 | */ 189 | public function setToken(?string $token): self 190 | { 191 | $this->token = $token; 192 | 193 | return $this; 194 | } 195 | 196 | /** 197 | * URL to ClickUp 198 | * 199 | * If path is passed in, then append it to the end. By default, it will use the url 200 | * in the configs, but if a url is passed in as a second parameter then it is used. 201 | * If no url is found it will use the hard-coded v2 ClickUp API URL. 202 | */ 203 | public function uri(?string $path = null, ?string $url = null): string 204 | { 205 | $path = ltrim($path ?? '/', '/'); 206 | 207 | return rtrim($url ?? $this->configs['url'] ?? 'https://api.clickup.com/api/v2', '/') 208 | .($path ? (Str::startsWith($path, '?') ? null : '/').$path : '/'); 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/Task.php: -------------------------------------------------------------------------------- 1 | 'boolean', 60 | 'date_closed' => 'datetime:Uv', 61 | 'date_created' => 'datetime:Uv', 62 | 'date_updated' => 'datetime:Uv', 63 | 'due_date' => 'datetime:Uv', 64 | 'id' => 'string', 65 | 'orderindex' => 'float', 66 | 'start_date' => 'datetime:Uv', 67 | 'team_id' => 'integer', 68 | 'time_estimate' => 'integer', 69 | 'time_spent' => 'integer', 70 | ]; 71 | 72 | /** 73 | * Path to API endpoint. 74 | */ 75 | protected string $path = '/task'; 76 | 77 | /** 78 | * Has many Comments 79 | * 80 | * @throws InvalidRelationshipException 81 | * @throws ModelNotFoundException 82 | * @throws NoClientException 83 | */ 84 | public function comments(): HasMany 85 | { 86 | return $this->hasMany(Comment::class); 87 | } 88 | 89 | /** 90 | * Accessor for Assignees. 91 | * 92 | * @throws NoClientException 93 | */ 94 | public function getAssigneesAttribute(?array $assignees): Collection 95 | { 96 | return $this->givenMany(Member::class, $assignees); 97 | } 98 | 99 | /** 100 | * Accessor for Checklists. 101 | * 102 | * @throws NoClientException 103 | */ 104 | public function getChecklistsAttribute(?array $checklists): Collection 105 | { 106 | return $this->givenMany(Checklist::class, $checklists); 107 | } 108 | 109 | /** 110 | * Accessor for Creator. 111 | * 112 | * @throws NoClientException 113 | */ 114 | public function getCreatorAttribute(?array $creator): Member 115 | { 116 | return $this->givenOne(Member::class, $creator); 117 | } 118 | 119 | /** 120 | * Accessor for CustomFields. 121 | * 122 | * @throws NoClientException 123 | */ 124 | public function getCustomFieldsAttribute(?array $custom_fields): Collection 125 | { 126 | return $this->givenMany(Field::class, $custom_fields); 127 | } 128 | 129 | /** 130 | * Accessor for Folder. 131 | * 132 | * @throws NoClientException 133 | */ 134 | public function getFolderAttribute(?array $folder): Folder 135 | { 136 | return $this->givenOne(Folder::class, $folder); 137 | } 138 | 139 | /** 140 | * Accessor for Parent. 141 | * 142 | * @throws NoClientException 143 | */ 144 | // TODO: Figure out how to make this relationship work 145 | /*public function getParentAttribute(?array $parent): Task 146 | { 147 | return $this->parentModel; 148 | }*/ 149 | 150 | /** 151 | * Accessor for Priority. 152 | * 153 | * @throws NoClientException 154 | */ 155 | public function getPriorityAttribute(?array $priority): Priority 156 | { 157 | return $this->givenOne(Priority::class, $priority); 158 | } 159 | 160 | /** 161 | * Accessor for Project. 162 | * 163 | * @throws NoClientException 164 | */ 165 | public function getProjectAttribute(?array $project): Project 166 | { 167 | // TODO: This is not documented. I think it is a hold over from v1? 168 | return $this->givenOne(Project::class, $project); 169 | } 170 | 171 | /** 172 | * Accessor for Space. 173 | * 174 | * @throws NoClientException 175 | */ 176 | public function getSpaceAttribute(?array $space): Space 177 | { 178 | // TODO: Look into making this a relationship 179 | return $this->givenOne(Space::class, $space); 180 | } 181 | 182 | /** 183 | * Accessor for Status. 184 | * 185 | * @throws NoClientException 186 | */ 187 | public function getStatusAttribute(?array $status): Status 188 | { 189 | return $this->givenOne(Status::class, $status); 190 | } 191 | 192 | /** 193 | * Accessor for Tags. 194 | * 195 | * @throws NoClientException 196 | */ 197 | public function getTagsAttribute(?array $tags): Collection 198 | { 199 | return $this->givenMany(Tag::class, $tags); 200 | } 201 | 202 | /** 203 | * Optional Child of TaskList 204 | * 205 | * @throws InvalidRelationshipException 206 | * @throws ModelNotFoundException 207 | * @throws NoClientException 208 | */ 209 | public function list(): ?ChildOf 210 | { 211 | return is_a($this->parentModel, TaskList::class) ? $this->childOf(TaskList::class) : null; 212 | } 213 | 214 | /** 215 | * Has many Members 216 | * 217 | * @throws InvalidRelationshipException 218 | * @throws ModelNotFoundException 219 | * @throws NoClientException 220 | */ 221 | public function members(): HasMany 222 | { 223 | return $this->hasMany(Member::class); 224 | } 225 | 226 | /** 227 | * Optional Child of Team 228 | * 229 | * @return ChildOf 230 | * 231 | * @throws InvalidRelationshipException 232 | * @throws ModelNotFoundException 233 | * @throws NoClientException 234 | */ 235 | public function team(): ?ChildOf 236 | { 237 | return is_a($this->parentModel, Team::class) ? $this->childOf(Team::class) : null; 238 | } 239 | 240 | /** 241 | * Has many Times 242 | * 243 | * @throws InvalidRelationshipException 244 | * @throws ModelNotFoundException 245 | * @throws NoClientException 246 | */ 247 | public function times(): HasMany 248 | { 249 | return $this->hasMany(Time::class); 250 | } 251 | 252 | /** 253 | * Belongs to View 254 | * 255 | * @throws InvalidRelationshipException 256 | * @throws ModelNotFoundException 257 | * @throws NoClientException 258 | */ 259 | public function view(): BelongsTo 260 | { 261 | return $this->belongsTo(View::class); 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /src/Support/Builder.php: -------------------------------------------------------------------------------- 1 | Space::class, 59 | 'tasks' => Task::class, 60 | 'teams' => Team::class, 61 | 'workspaces' => Team::class, 62 | ]; 63 | 64 | /** 65 | * Properties to filter the response 66 | */ 67 | protected array $wheres = []; 68 | 69 | /** 70 | * Magic method to make builders for root models 71 | * 72 | * @throws BadMethodCallException 73 | * @throws ModelNotFoundException 74 | * @throws NoClientException 75 | */ 76 | public function __call(string $name, array $arguments) 77 | { 78 | if (! isset($this->parentModel) && array_key_exists($name, $this->rootModels)) { 79 | return $this->newInstanceForModel($this->rootModels[$name]); 80 | } 81 | 82 | throw new BadMethodCallException(sprintf('Call to undefined method [%s]', $name)); 83 | } 84 | 85 | /** 86 | * Magic method to make builders appears as properties 87 | * 88 | * @throws GuzzleException 89 | * @throws InvalidRelationshipException 90 | * @throws ModelNotFoundException 91 | * @throws NoClientException 92 | * @throws TokenException 93 | */ 94 | public function __get(string $name): Collection|Model|null 95 | { 96 | if ($name === 'user') { 97 | return $this->newInstanceForModel(User::class) 98 | ->get() 99 | ->first(); 100 | } 101 | 102 | // Only return builders as properties, when not a child 103 | if (! $this->parentModel && array_key_exists($name, $this->rootModels)) { 104 | return $this->{$name}() 105 | ->get(); 106 | } 107 | 108 | return null; 109 | } 110 | 111 | /** 112 | * Create instance of class and save via API 113 | * 114 | * @throws InvalidRelationshipException 115 | */ 116 | public function create(array $attributes): Model 117 | { 118 | return tap( 119 | $this->make($attributes), 120 | fn (Model $model): bool => $model->save() 121 | ); 122 | } 123 | 124 | /** 125 | * Get Collection of class instances that match query 126 | * 127 | * @throws GuzzleException 128 | * @throws InvalidRelationshipException 129 | * @throws NoClientException 130 | * @throws TokenException 131 | */ 132 | public function get(array|string $properties = ['*']): Collection|Model 133 | { 134 | $properties = Arr::wrap($properties); 135 | 136 | // Call API to get the response 137 | $response = $this->getClient() 138 | ->request($this->getPath()); 139 | 140 | // Peel off the key if exist 141 | $response = $this->peelWrapperPropertyIfNeeded(Arr::wrap($response)); 142 | 143 | // Convert to a collection of filtered objects casted to the class 144 | return (new Collection((array_values($response) === $response) ? $response : [$response]))->map( 145 | // Cast to class with only the requested, properties 146 | fn ($items) => $this->getModel() 147 | ->newFromBuilder( 148 | $properties === ['*'] 149 | ? (array) $items 150 | : collect($items) 151 | ->only($properties) 152 | ->toArray() 153 | ) 154 | ->setClient($this->getClient()) 155 | ); 156 | } 157 | 158 | /** 159 | * Get the model instance being queried. 160 | * 161 | * @throws InvalidRelationshipException 162 | */ 163 | public function getModel(): Model 164 | { 165 | if (! isset($this->class)) { 166 | throw new InvalidRelationshipException(); 167 | } 168 | 169 | if (! isset($this->model)) { 170 | $this->model = (new $this->class([], $this->parentModel))->setClient($this->client); 171 | } 172 | 173 | return $this->model; 174 | } 175 | 176 | /** 177 | * Get the path for the resource with the where filters 178 | * 179 | * @throws InvalidRelationshipException 180 | */ 181 | public function getPath(?string $extra = null): ?string 182 | { 183 | return $this->getModel() 184 | ->getPath($extra, $this->wheres); 185 | } 186 | 187 | /** 188 | * Find specific instance of class 189 | * 190 | * @throws GuzzleException 191 | * @throws InvalidRelationshipException 192 | * @throws NoClientException 193 | * @throws TokenException 194 | */ 195 | public function find(int|string $id, array|string $properties = ['*']): Model 196 | { 197 | return $this->where($this->getModel()->getKeyName(), $id) 198 | ->get($properties) 199 | ->first(); 200 | } 201 | 202 | /** 203 | * New up a class instance, but not saved 204 | * 205 | * @throws InvalidRelationshipException 206 | */ 207 | public function make(?array $attributes = []): Model 208 | { 209 | // TODO: Make sure that the model supports "creating" 210 | return $this->getModel() 211 | ->newInstance($attributes); 212 | } 213 | 214 | /** 215 | * Create new Builder instance 216 | * 217 | * @throws ModelNotFoundException 218 | * @throws NoClientException 219 | */ 220 | public function newInstance(): self 221 | { 222 | return isset($this->class) 223 | ? (new static()) 224 | ->setClass($this->class) 225 | ->setClient($this->getClient()) 226 | ->setParent($this->parentModel) 227 | : (new static()) 228 | ->setClient($this->getClient()) 229 | ->setParent($this->parentModel); 230 | } 231 | 232 | /** 233 | * Create new Builder instance for a specific model 234 | * 235 | * @throws ModelNotFoundException 236 | * @throws NoClientException 237 | */ 238 | public function newInstanceForModel(string $model): self 239 | { 240 | return $this->newInstance() 241 | ->setClass($model); 242 | } 243 | 244 | /** 245 | * Peel of the wrapping property if it exist. 246 | * 247 | * @throws InvalidRelationshipException 248 | */ 249 | protected function peelWrapperPropertyIfNeeded(array $properties): array 250 | { 251 | // Check for single response 252 | if (array_key_exists( 253 | $this->getModel() 254 | ->getResponseKey(), 255 | $properties 256 | )) { 257 | return $properties[$this->getModel() 258 | ->getResponseKey()]; 259 | } 260 | 261 | // Check for collection of responses 262 | if (array_key_exists( 263 | $this->getModel() 264 | ->getResponseCollectionKey(), 265 | $properties 266 | )) { 267 | return $properties[$this->getModel() 268 | ->getResponseCollectionKey()]; 269 | } 270 | 271 | return $properties; 272 | } 273 | 274 | /** 275 | * Set the class to cast the response 276 | * 277 | * @throws ModelNotFoundException 278 | */ 279 | public function setClass(string $class): self 280 | { 281 | if (! class_exists($class)) { 282 | throw new ModelNotFoundException(sprintf('The model [%s] not found.', $class)); 283 | } 284 | 285 | $this->class = $class; 286 | 287 | return $this; 288 | } 289 | 290 | /** 291 | * Set the parent model 292 | */ 293 | public function setParent(?Model $parent): self 294 | { 295 | $this->parentModel = $parent; 296 | 297 | return $this; 298 | } 299 | 300 | /** 301 | * Add property to filter the collection 302 | * 303 | * @throws InvalidRelationshipException 304 | */ 305 | public function where(string $property, $value = true): self 306 | { 307 | $value = is_a($value, LaravelCollection::class) ? $value->toArray() : $value; 308 | 309 | // If looking for a specific model, then set the id 310 | if ($property === $this->getModel()->getKeyName()) { 311 | $this->getModel()->{$property} = $value; 312 | 313 | return $this; 314 | } 315 | 316 | $this->wheres[$property] = $value; 317 | 318 | return $this; 319 | } 320 | 321 | /** 322 | * Shortcut to where property id 323 | * 324 | * @throws InvalidRelationshipException 325 | */ 326 | public function whereId(int|string|null $id): self 327 | { 328 | return $this->where($this->getModel()->getKeyName(), $id); 329 | } 330 | 331 | /** 332 | * Shortcut to where property is false 333 | * 334 | * @throws InvalidRelationshipException 335 | */ 336 | public function whereNot(string $property): self 337 | { 338 | return $this->where($property, false); 339 | } 340 | } 341 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SPINEN's Laravel ClickUp 2 | 3 | [![Latest Stable Version](https://poser.pugx.org/spinen/laravel-clickup/v/stable)](https://packagist.org/packages/spinen/laravel-clickup) 4 | [![Latest Unstable Version](https://poser.pugx.org/spinen/laravel-clickup/v/unstable)](https://packagist.org/packages/spinen/laravel-clickup) 5 | [![Total Downloads](https://poser.pugx.org/spinen/laravel-clickup/downloads)](https://packagist.org/packages/spinen/laravel-clickup) 6 | [![License](https://poser.pugx.org/spinen/laravel-clickup/license)](https://packagist.org/packages/spinen/laravel-clickup) 7 | 8 | PHP package to interface with [ClickUp](https://clickup.com). We strongly encourage you to review [ClickUp's API docs](https://clickup.com/api) to get a feel for what this package can do, as we are just wrapping their API. 9 | 10 | We solely use [Laravel](https://www.laravel.com) for our applications, so this package is written with Laravel in mind. We have tried to make it work outside of Laravel. If there is a request from the community to split this package into 2 parts, then we will consider doing that work. 11 | 12 | ## Build Status 13 | 14 | | Branch | Status | Coverage | Code Quality | 15 | | ------ | :----: | :------: | :----------: | 16 | | Develop | [![Build Status](https://github.com/spinen/laravel-clickup/workflows/CI/badge.svg?branch=develop)](https://github.com/spinen/laravel-clickup/workflows/CI/badge.svg?branch=develop) | [![Code Coverage](https://scrutinizer-ci.com/g/spinen/laravel-clickup/badges/coverage.png?b=develop)](https://scrutinizer-ci.com/g/spinen/laravel-clickup/?branch=develop) | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/spinen/laravel-clickup/badges/quality-score.png?b=develop)](https://scrutinizer-ci.com/g/spinen/laravel-clickup/?branch=develop) | 17 | | Master | [![Build Status](https://github.com/spinen/laravel-clickup/workflows/CI/badge.svg?branch=master)](https://github.com/spinen/laravel-clickup/workflows/CI/badge.svg?branch=master) | [![Code Coverage](https://scrutinizer-ci.com/g/spinen/laravel-clickup/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/spinen/laravel-clickup/?branch=master) | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/spinen/laravel-clickup/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/spinen/laravel-clickup/?branch=master) | 18 | 19 | ## Table of Contents 20 | * [Installation](#installation) 21 | * [Laravel Setup](#laravel-setup) 22 | * [Configuration](#configuration) 23 | * [Optional Keys](#optional-keys) 24 | * [Generic PHP Setup](#generic-php-setup) 25 | * [Examples](#examples) 26 | * [Authentication](#authentication) 27 | * [OAuth](#oauth) 28 | * [Personal Token](#personal-token) 29 | * [Usage](#usage) 30 | * [Supported Actions](#supported-actions) 31 | * [Using the Client](#using-the-client) 32 | * [Getting the Client object](#getting-the-client-object) 33 | * [Models](#models) 34 | * [Relationships](#relationships) 35 | * [Advanced filtering using "where"](#advanced-filtering-using-where) 36 | * [More Examples](#more-examples) 37 | * [Known Issues](#known-issues) 38 | 39 | ## Installation 40 | 41 | Install ClickUp PHP Package via Composer: 42 | 43 | ```bash 44 | $ composer require spinen/laravel-clickup 45 | ``` 46 | 47 | The package uses the [auto registration feature](https://laravel.com/docs/master/packages#package-discovery) of Laravel. 48 | 49 | ## Laravel Setup 50 | 51 | ### Configuration 52 | 53 | 1. You will need to make your `User` object implement includes the `Spinen\ClickUp\Concerns\HasClickUp` trait which will allow it to access the Client as an attribute like this: `$user->clickup` 54 | 55 | ```php 56 | 78 | CLICKUP_CLIENT_SECRET= 79 | CLICKUP_OAUTH_URL= 80 | CLICKUP_URL= 81 | ``` 82 | 83 | 3. _[Optional]_ Publish config & migration 84 | 85 | #### Config 86 | A configuration file named ```clickup.php``` can be published to ```config/``` by running... 87 | 88 | ```bash 89 | php artisan vendor:publish --tag=clickup-config 90 | ``` 91 | 92 | #### Migration 93 | Migrations files can be published by running... 94 | 95 | ```bash 96 | php artisan vendor:publish --tag=clickup-migrations 97 | ``` 98 | 99 | You'll need the migration to set the ClickUp API token on your `User` model. 100 | 101 | ## Generic PHP Setup 102 | 103 | ### Examples 104 | 105 | To get a `Spinen\ClickUp\Api\Client` instance... 106 | 107 | ```bash 108 | $ psysh 109 | Psy Shell v0.9.9 (PHP 7.3.11 — cli) by Justin Hileman 110 | >>> $configs = [ 111 | "oauth" => [ 112 | "id" => "", // if using OAuth 113 | "secret" => "", // If using OAuth 114 | "url" => "https://app.clickup.com/api", 115 | ], 116 | "route" => [ 117 | "enabled" => true, 118 | "middleware" => [ 119 | "web", 120 | ], 121 | "sso" => "clickup/sso", 122 | ], 123 | "url" => "https://api.clickup.com/api/v2", 124 | ]; 125 | >>> $guzzle = new GuzzleHttp\Client(); 126 | => GuzzleHttp\Client {#2379} 127 | >>> $clickup = new Spinen\ClickUp\Api\Client($configs, $guzzle) // Optionally, pass the token as 3rd parameter 128 | => Spinen\ClickUp\Api\Client {#2363} 129 | >>> $clickup->setToken('') // Skip if passed in via constructor 130 | => Spinen\ClickUp\Api\Client {#2363} 131 | ``` 132 | 133 | The `$clickup` instance will work exactly like all of the examples below, so if you are not using Laravel, then you can use the package once you bootstrap the client. 134 | 135 | 136 | ## Authentication 137 | 138 | ClickUp has 2 ways to authenticate when making API calls... 1) OAuth token or 2) Personal Token. Either method uses a token that is saved to the `clickup_token` property on the `User` model. 139 | 140 | ### OAuth 141 | 142 | There is a middleware named `clickup` that you can apply to any route that verifies that the user has a `clickup_token`, and if the user does not, then it redirects the user to ClickUp's OAuth page with the `client_id` where the user selects the team(s) to link with your application. Upon selecting the team(s), the user is redirected to `/clickup/sso/?code=` where the system converts the `code` to a token & saves it to the user. Upon saving the `clickup_token`, the user is redirected to the initial page that was protected by the middleware. 143 | 144 | > NOTE: You will need to have the `auth` middleware on the routes as the `User` is needed to see if there is a `clickup_token`. 145 | 146 | If you do not want to use the `clickup` middleware to start the OAuth flow, then you can use the `oauthUri` on the `Client` to generate the link for the user... 147 | 148 | ```bash 149 | $ php artisan tinker 150 | Psy Shell v0.9.9 (PHP 7.3.11 — cli) by Justin Hileman 151 | >>> $clickup = app(Spinen\ClickUp\Api\Client::class) 152 | => Spinen\ClickUp\Api\Client {#3035} 153 | >>> $clickup->oauthUri(route('clickup.sso.redirect_url', )) 154 | => "https://app.clickup.com/api?client_id=&redirect_uri=https%3A%2F%2F2Fclickup%2Fsso%2F" 155 | >>> 156 | ``` 157 | 158 | > NOTE: At this time, there is not a way to remove a token that has been invalidated, so you will need to delete the `clickup_token` on the user to restart the flow. 159 | 160 | ### Personal Token 161 | 162 | If you do not want to use the OAuth flow, then you can allow the user to provide you a personal token that you can save on the `User`. 163 | 164 | ```bash 165 | $ php artisan tinker 166 | Psy Shell v0.9.9 (PHP 7.3.11 — cli) by Justin Hileman 167 | >>> $user = App\User::find(1) 168 | => App\User {#3040 169 | id: 1, 170 | first_name: "Bob", 171 | last_name: "Tester", 172 | email: "bob.tester@example.com", 173 | email_verified_at: null, 174 | created_at: "2019-11-15 19:49:01", 175 | updated_at: "2019-11-15 19:49:01", 176 | logged_in_at: "2019-11-15 19:49:01", 177 | deleted_at: null, 178 | } 179 | >>> $user->clickup_token = ''; 180 | => "" 181 | >>> $user->save() 182 | => true 183 | ``` 184 | 185 | ## Usage 186 | 187 | ### Supported Actions 188 | 189 | The primary class is `Spinen\ClickUp\Client`. It gets constructed with 3 parameters... 190 | 191 | * `array $configs` - Configuration properties. See the `clickup.php` file in the `./config` directory for a documented list of options. 192 | 193 | * `Guzzle $guzzle` - Instance of `GuzzleHttp\Client` 194 | 195 | * `Token $token` - _[Optional]_ String of the user's token 196 | 197 | Once you new up a `Client` instance, you have the following methods... 198 | 199 | * `delete($path)` - Shortcut to the `request()` method with 'DELETE' as the last parameter 200 | 201 | * `get($path)` - Shortcut to the `request()` method with 'GET' as the last parameter 202 | 203 | * `oauthRequestTokenUsingCode($code)` - Request a token from the OAuth code 204 | 205 | * `oauthUri($url)` - Build the URI to the OAuth page with the redirect_url set to `$url` 206 | 207 | * `post($path, array $data)` - Shortcut to the `request()` method with 'POST' as the last parameter 208 | 209 | * `put($path, array $data)` - Shortcut to the `request()` method with 'PUT' as the last parameter 210 | 211 | * `request($path, $data = [], $method = 'GET')` - Make an [API call to ClickUp](https://clickup.com/api) to `$path` with the `$data` using the JWT for the logged in user. 212 | 213 | * `setConfigs(array $configs)` - Allow overriding the `$configs` on the `Client` instance. 214 | 215 | * `setToken($token)` - Set the token for the ClickUp API 216 | 217 | * `uri($path = null, $url = null)` - Generate a full uri for the path to the ClickUp API. 218 | 219 | ### Using the Client 220 | 221 | The Client is meant to emulate [Laravel's models with Eloquent](https://laravel.com/docs/master/eloquent#retrieving-models). When working with ClickUp resources, you can access properties and relationships [just like you would in Laravel](https://laravel.com/docs/master/eloquent-relationships#querying-relations). 222 | 223 | #### Getting the Client object 224 | 225 | By running the migration included in this package, your `User` class will have a `clickup_token` column on it. When you set the user's token, it is encrypted in your database with [Laravel's encryption methods](https://laravel.com/docs/master/encryption#using-the-encrypter). After setting the ClickUp API token, you can access the Client object through `$user->clickup`. 226 | 227 | ```php 228 | $ php artisan tinker 229 | Psy Shell v0.9.9 (PHP 7.2.19 — cli) by Justin Hileman 230 | >>> $user = User::find(1); 231 | => App\User {#3631 232 | id: 1, 233 | first_name: "Bob", 234 | last_name: "Tester", 235 | email: "bob.tester@example.com", 236 | email_verified_at: null, 237 | created_at: "2019-11-15 19:49:01", 238 | updated_at: "2019-11-15 19:49:01", 239 | logged_in_at: "2019-11-15 19:49:01", 240 | deleted_at: null, 241 | } 242 | >>> // NOTE: Must have a clickup_token via one of the 2 ways in the Authentication section 243 | >>> $user->clickup; 244 | => Spinen\ClickUp\Api\Client {#3635} 245 | ``` 246 | 247 | #### Models 248 | 249 | The API responses are cast into models with the properties cast into the types as defined in the [ClickUp API documentation](https://clickup.com/api). You can review the models in the `src/` folder. There is a property named `casts` on each model that instructs the Client on how to cast the properties from the API response. If the `casts` property is empty, then the properties are not defined in the API docs, so an array is returned. 250 | 251 | ```php 252 | >>> $team = $user->clickUp()->teams->first(); 253 | => Spinen\ClickUp\Team {#3646 254 | +exists: true, 255 | +incrementing: false, 256 | +parentModel: null, 257 | +timestamps: false, 258 | } 259 | >>> $team->toArray(); // Calling toArray() is allowed just like in Laravel 260 | => [ 261 | "id" => <7 digit ClickUp ID>, 262 | "name" => "SPINEN", 263 | "color" => "#2980B9", 264 | "avatar" => , 265 | "members" => [ 266 | [ 267 | // Keeps going 268 | ``` 269 | 270 | #### Relationships 271 | 272 | Some of the responses have links to the related resources. If a property has a relationship, you can call it as a method and the additional calls are automatically made & returned. The value is stored in place of the original data, so once it is loaded it is cached. 273 | 274 | ```php 275 | $folder = $team->spaces->first()->folders->first(); 276 | => Spinen\ClickUp\Folder {#3632 277 | +exists: true, 278 | +incrementing: false, 279 | +parentModel: Spinen\ClickUp\Space {#3658 280 | +exists: true, 281 | +incrementing: false, 282 | +parentModel: Spinen\ClickUp\Team {#3645 283 | +exists: true, 284 | +incrementing: false, 285 | +parentModel: null, 286 | +timestamps: false, 287 | }, 288 | +timestamps: false, 289 | }, 290 | +timestamps: false, 291 | } 292 | >>> $folder->lists->count(); 293 | => 5 294 | >>> $folder->lists->first()->name; 295 | => "Test Folder" 296 | ``` 297 | 298 | You may also call these relationships as attributes, and the Client will return a `Collection` for you (just like Eloquent). 299 | 300 | ```php 301 | >>> $folder->lists; 302 | => Spinen\ClickUp\Support\Collection {#3650 303 | all: [ 304 | Spinen\ClickUp\TaskList {#3636 305 | +exists: true, 306 | +incrementing: false, 307 | +parentModel: Spinen\ClickUp\Space {#3658 308 | +exists: true, 309 | +incrementing: false, 310 | +parentModel: Spinen\ClickUp\Team {#3645 311 | +exists: true, 312 | +incrementing: false, 313 | +parentModel: null, 314 | +timestamps: false, 315 | }, 316 | +timestamps: false, 317 | }, 318 | +timestamps: false, 319 | }, 320 | // Keeps going 321 | ``` 322 | 323 | #### Advanced filtering using "where" 324 | 325 | You can do advanced filters by using `where` on the models 326 | 327 | ```php 328 | >>> $team->tasks()->where('space_ids', ['space_id_1', 'space_id_2'])->where('assignees', ['assignee1', 'assignee2'])->get()->count(); 329 | => 100 330 | // If there are more than 100 results, they will be paginated. Pass in another parameter to get another page: 331 | >>> $team->tasks()->where....->where('page', 2)->get(); 332 | ``` 333 | 334 | > NOTE: The API has a page size of `100` records, so to get to the next page you use the `where` method... ```->where('page', 3)``` 335 | 336 | ### More Examples 337 | 338 | ```php 339 | >>> $team = $user->clickUp()->teams->first(); 340 | => Spinen\ClickUp\Team {#3646 341 | +exists: true, 342 | +incrementing: false, 343 | +parentModel: null, 344 | +timestamps: false, 345 | } 346 | >>> $first_space = $team->spaces->first(); 347 | => Spinen\ClickUp\Space {#3695 348 | +exists: true, 349 | +incrementing: false, 350 | +parentModel: Spinen\ClickUp\Team {#3646 351 | +exists: true, 352 | +incrementing: false, 353 | +parentModel: null, 354 | +timestamps: false, 355 | }, 356 | +timestamps: false, 357 | } 358 | >>> $folder = $first_space->folders->first()->toArray(); 359 | => [ 360 | "id" => <7 digit ClickUp ID>, 361 | "name" => "Test folder", 362 | "orderindex" => 3.0, 363 | "override_statuses" => true, 364 | "hidden" => false, 365 | "task_count" => 79, 366 | "archived" => false, 367 | "lists" => [ 368 | // Keeps going 369 | ``` 370 | 371 | ## Known Issues 372 | 373 | // TODO: Document known issues as we find them 374 | -------------------------------------------------------------------------------- /src/Support/Model.php: -------------------------------------------------------------------------------- 1 | dateFormat = 'Uv'; 125 | // None of these models will use timestamps, but need the date casting 126 | $this->timestamps = false; 127 | 128 | $this->syncOriginal(); 129 | 130 | $this->fill($attributes); 131 | $this->parentModel = $parentModel; 132 | } 133 | 134 | /** 135 | * Dynamically retrieve attributes on the model. 136 | */ 137 | public function __get(string $key) 138 | { 139 | return $this->getAttribute($key); 140 | } 141 | 142 | /** 143 | * Determine if an attribute or relation exists on the model. 144 | */ 145 | public function __isset(string $key): bool 146 | { 147 | return $this->offsetExists($key); 148 | } 149 | 150 | /** 151 | * Dynamically set attributes on the model. 152 | * 153 | * @param string $key 154 | * @return void 155 | * 156 | * @throws ModelReadonlyException 157 | */ 158 | public function __set($key, $value) 159 | { 160 | if ($this->readonlyModel) { 161 | throw new ModelReadonlyException(); 162 | } 163 | 164 | $this->setAttribute($key, $value); 165 | } 166 | 167 | /** 168 | * Convert the model to its string representation. 169 | * 170 | * @return string 171 | */ 172 | public function __toString() 173 | { 174 | return $this->toJson(); 175 | } 176 | 177 | /** 178 | * Unset an attribute on the model. 179 | * 180 | * @param string $key 181 | * @return void 182 | */ 183 | public function __unset($key) 184 | { 185 | $this->offsetUnset($key); 186 | } 187 | 188 | /** 189 | * Return a timestamp as DateTime object. 190 | * 191 | * @return Carbon 192 | */ 193 | protected function asDateTime($value) 194 | { 195 | if (is_numeric($value) && $this->timestampsInMilliseconds) { 196 | return Date::createFromTimestampMs($value); 197 | } 198 | 199 | return $this->originalAsDateTime($value); 200 | } 201 | 202 | /** 203 | * Assume foreign key 204 | * 205 | * @param string $related 206 | */ 207 | protected function assumeForeignKey($related): string 208 | { 209 | return Str::snake((new $related())->getResponseKey()).'_id'; 210 | } 211 | 212 | /** 213 | * Relationship that makes the model belongs to another model 214 | * 215 | * @param string $related 216 | * @param string|null $foreignKey 217 | * 218 | * @throws InvalidRelationshipException 219 | * @throws ModelNotFoundException 220 | * @throws NoClientException 221 | */ 222 | public function belongsTo($related, $foreignKey = null): BelongsTo 223 | { 224 | $foreignKey = $foreignKey ?? $this->assumeForeignKey($related); 225 | 226 | $builder = (new Builder())->setClass($related) 227 | ->setClient($this->getClient()); 228 | 229 | return new BelongsTo($builder, $this, $foreignKey); 230 | } 231 | 232 | /** 233 | * Relationship that makes the model child to another model 234 | * 235 | * @param string $related 236 | * @param string|null $foreignKey 237 | * 238 | * @throws InvalidRelationshipException 239 | * @throws ModelNotFoundException 240 | * @throws NoClientException 241 | */ 242 | public function childOf($related, $foreignKey = null): ChildOf 243 | { 244 | $foreignKey = $foreignKey ?? $this->assumeForeignKey($related); 245 | 246 | $builder = (new Builder())->setClass($related) 247 | ->setClient($this->getClient()) 248 | ->setParent($this); 249 | 250 | return new ChildOf($builder, $this, $foreignKey); 251 | } 252 | 253 | /** 254 | * Delete the model from ClickUp 255 | * 256 | * @throws NoClientException 257 | * @throws TokenException 258 | */ 259 | public function delete(): bool 260 | { 261 | // TODO: Make sure that the model supports being deleted 262 | if ($this->readonlyModel) { 263 | return false; 264 | } 265 | 266 | try { 267 | $this->getClient() 268 | ->delete($this->getPath()); 269 | 270 | return true; 271 | } catch (GuzzleException $e) { 272 | // TODO: Do something with the error 273 | 274 | return false; 275 | } 276 | } 277 | 278 | /** 279 | * Fill the model with the supplied properties 280 | */ 281 | public function fill(?array $attributes = []): self 282 | { 283 | foreach ((array) $attributes as $attribute => $value) { 284 | $this->setAttribute($attribute, $value); 285 | } 286 | 287 | return $this; 288 | } 289 | 290 | /** 291 | * Get the value indicating whether the IDs are incrementing. 292 | */ 293 | public function getIncrementing(): bool 294 | { 295 | return $this->incrementing; 296 | } 297 | 298 | /** 299 | * Get the value of the model's primary key. 300 | */ 301 | public function getKey() 302 | { 303 | return $this->getAttribute($this->getKeyName()); 304 | } 305 | 306 | /** 307 | * Get the primary key for the model. 308 | */ 309 | public function getKeyName(): string 310 | { 311 | return $this->primaryKey; 312 | } 313 | 314 | /** 315 | * Get the auto-incrementing key type. 316 | */ 317 | public function getKeyType(): string 318 | { 319 | return $this->keyType; 320 | } 321 | 322 | /** 323 | * Build API path 324 | * 325 | * Put anything on the end of the URI that is passed in 326 | * 327 | * @param string|null $extra 328 | * @param array|null $query 329 | * @return string 330 | */ 331 | public function getPath($extra = null, array $query = []): ?string 332 | { 333 | // Start with path to resource without "/" on end 334 | $path = rtrim($this->path, '/'); 335 | 336 | // If have an id, then put it on the end 337 | if ($this->getKey()) { 338 | $path .= '/'.$this->getKey(); 339 | } 340 | 341 | // Stick any extra things on the end 342 | if (! is_null($extra)) { 343 | $path .= '/'.ltrim($extra, '/'); 344 | } 345 | 346 | // Convert query to querystring format and put on the end 347 | if (! empty($query)) { 348 | $path .= '?'.http_build_query($query); 349 | } 350 | 351 | // If there is a parentModel & not have an id (unless for nested), then prepend parentModel 352 | if (! is_null($this->parentModel) && (! $this->getKey() || $this->isNested())) { 353 | return $this->parentModel->getPath($path); 354 | } 355 | 356 | return $path; 357 | } 358 | 359 | /** 360 | * Get a relationship value from a method. 361 | * 362 | * @param string $method 363 | * 364 | * @throws LogicException 365 | */ 366 | public function getRelationshipFromMethod($method) 367 | { 368 | $relation = $this->{$method}(); 369 | 370 | if (! $relation instanceof Relation) { 371 | $exception_message = is_null($relation) 372 | ? '%s::%s must return a relationship instance, but "null" was returned. Was the "return" keyword used?' 373 | : '%s::%s must return a relationship instance.'; 374 | 375 | throw new LogicException( 376 | sprintf($exception_message, static::class, $method) 377 | ); 378 | } 379 | 380 | return tap( 381 | $relation->getResults(), 382 | function ($results) use ($method) { 383 | $this->setRelation($method, $results); 384 | } 385 | ); 386 | } 387 | 388 | /** 389 | * Name of the wrapping key when response is a collection 390 | * 391 | * If none provided, assume plural version responseKey 392 | */ 393 | public function getResponseCollectionKey(): ?string 394 | { 395 | return $this->responseCollectionKey ?? Str::plural($this->getResponseKey()); 396 | } 397 | 398 | /** 399 | * Name of the wrapping key of response 400 | * 401 | * If none provided, assume camelCase of class name 402 | */ 403 | public function getResponseKey(): ?string 404 | { 405 | return $this->responseKey ?? Str::camel(class_basename(static::class)); 406 | } 407 | 408 | /** 409 | * Many of the results include collection of related data, so cast it 410 | * 411 | * @param string $related 412 | * @param array $given 413 | * @param bool $reset Some of the values are nested under a property, so peel it off 414 | * 415 | * @throws NoClientException 416 | */ 417 | public function givenMany($related, $given, $reset = false): Collection 418 | { 419 | /** @var Model $model */ 420 | $model = (new $related([], $this->parentModel))->setClient($this->getClient()); 421 | 422 | return (new Collection($given))->map( 423 | function ($attributes) use ($model, $reset) { 424 | return $model->newFromBuilder($reset ? reset($attributes) : $attributes); 425 | } 426 | ); 427 | } 428 | 429 | /** 430 | * Many of the results include related data, so cast it to object 431 | * 432 | * @param string $related 433 | * @param array $attributes 434 | * @param bool $reset Some of the values are nested under a property, so peel it off 435 | * 436 | * @throws NoClientException 437 | */ 438 | public function givenOne($related, $attributes, $reset = false): Model 439 | { 440 | return (new $related([], $this->parentModel))->setClient($this->getClient()) 441 | ->newFromBuilder($reset ? reset($attributes) : $attributes); 442 | } 443 | 444 | /** 445 | * Relationship that makes the model have a collection of another model 446 | * 447 | * @param string $related 448 | * 449 | * @throws InvalidRelationshipException 450 | * @throws ModelNotFoundException 451 | * @throws NoClientException 452 | */ 453 | public function hasMany($related): HasMany 454 | { 455 | $builder = (new Builder())->setClass($related) 456 | ->setClient($this->getClient()) 457 | ->setParent($this); 458 | 459 | return new HasMany($builder, $this); 460 | } 461 | 462 | /** 463 | * Is endpoint nested behind another endpoint 464 | */ 465 | public function isNested(): bool 466 | { 467 | return $this->nested ?? false; 468 | } 469 | 470 | /** 471 | * Convert the object into something JSON serializable. 472 | */ 473 | public function jsonSerialize(): array 474 | { 475 | return $this->toArray(); 476 | } 477 | 478 | /** 479 | * Create a new model instance that is existing. 480 | * 481 | * @param array $attributes 482 | * @return static 483 | */ 484 | public function newFromBuilder($attributes = []): self 485 | { 486 | $model = $this->newInstance([], true); 487 | 488 | $model->setRawAttributes((array) $attributes, true); 489 | 490 | return $model; 491 | } 492 | 493 | /** 494 | * Create a new instance of the given model. 495 | * 496 | * Provides a convenient way for us to generate fresh model instances of this current model. 497 | * It is particularly useful during the hydration of new objects via the builder. 498 | * 499 | * @param bool $exists 500 | * @return static 501 | */ 502 | public function newInstance(array $attributes = [], $exists = false): self 503 | { 504 | $model = (new static($attributes, $this->parentModel))->setClient($this->client); 505 | 506 | $model->exists = $exists; 507 | 508 | return $model; 509 | } 510 | 511 | /** 512 | * Determine if accessing missing attributes is disabled. 513 | * 514 | * 515 | * @return bool 516 | */ 517 | public static function preventsAccessingMissingAttributes() 518 | { 519 | // NOTE: Needed for HasAttributes, just return false 520 | return false; 521 | } 522 | 523 | /** 524 | * Determine if the given attribute exists. 525 | */ 526 | public function offsetExists($offset): bool 527 | { 528 | return ! is_null($this->getAttribute($offset)); 529 | } 530 | 531 | /** 532 | * Get the value for a given offset. 533 | */ 534 | public function offsetGet($offset): mixed 535 | { 536 | return $this->getAttribute($offset); 537 | } 538 | 539 | /** 540 | * Set the value for a given offset. 541 | * 542 | * 543 | * @throws ModelReadonlyException 544 | */ 545 | public function offsetSet($offset, $value): void 546 | { 547 | if ($this->readonlyModel) { 548 | throw new ModelReadonlyException(); 549 | } 550 | 551 | $this->setAttribute($offset, $value); 552 | } 553 | 554 | /** 555 | * Unset the value for a given offset. 556 | */ 557 | public function offsetUnset($offset): void 558 | { 559 | unset($this->attributes[$offset], $this->relations[$offset]); 560 | } 561 | 562 | /** 563 | * Determine if the given relation is loaded. 564 | * 565 | * @param string $key 566 | */ 567 | public function relationLoaded($key): bool 568 | { 569 | return array_key_exists($key, $this->relations); 570 | } 571 | 572 | /** 573 | * Laravel allows the resolver to be set at runtime, so we just return null 574 | * 575 | * @param string $class 576 | * @param string $key 577 | * @return null 578 | */ 579 | public function relationResolver($class, $key) 580 | { 581 | return null; 582 | } 583 | 584 | /** 585 | * Save the model in ClickUp 586 | * 587 | * @throws NoClientException 588 | * @throws TokenException 589 | */ 590 | public function save(): bool 591 | { 592 | // TODO: Make sure that the model supports being saved 593 | if ($this->readonlyModel) { 594 | return false; 595 | } 596 | 597 | try { 598 | if (! $this->isDirty()) { 599 | return true; 600 | } 601 | 602 | if ($this->exists) { 603 | // TODO: If we get null from the PUT, throw/handle exception 604 | $response = $this->getClient() 605 | ->put($this->getPath(), $this->getDirty()); 606 | 607 | // Record the changes 608 | $this->syncChanges(); 609 | 610 | // Reset the model with the results as we get back the full model 611 | $this->setRawAttributes($response, true); 612 | 613 | return true; 614 | } 615 | 616 | $response = $this->getClient() 617 | ->post($this->getPath(), $this->toArray()); 618 | 619 | $this->exists = true; 620 | 621 | // Reset the model with the results as we get back the full model 622 | $this->setRawAttributes($response, true); 623 | 624 | return true; 625 | } catch (GuzzleException $e) { 626 | // TODO: Do something with the error 627 | 628 | return false; 629 | } 630 | } 631 | 632 | /** 633 | * Save the model in ClickUp, but raise error if fail 634 | * 635 | * @throws NoClientException 636 | * @throws TokenException 637 | * @throws UnableToSaveException 638 | */ 639 | public function saveOrFail(): bool 640 | { 641 | if (! $this->save()) { 642 | throw new UnableToSaveException(); 643 | } 644 | 645 | return true; 646 | } 647 | 648 | /** 649 | * Set the readonly 650 | * 651 | * @param bool $readonly 652 | * @return $this 653 | */ 654 | public function setReadonly($readonly = true): self 655 | { 656 | $this->readonlyModel = $readonly; 657 | 658 | return $this; 659 | } 660 | 661 | /** 662 | * Set the given relationship on the model. 663 | * 664 | * @param string $relation 665 | * @return $this 666 | */ 667 | public function setRelation($relation, $value): self 668 | { 669 | $this->relations[$relation] = $value; 670 | 671 | return $this; 672 | } 673 | 674 | /** 675 | * Convert the model instance to an array. 676 | */ 677 | public function toArray(): array 678 | { 679 | return array_merge($this->attributesToArray(), $this->relationsToArray()); 680 | } 681 | 682 | /** 683 | * Convert the model instance to JSON. 684 | * 685 | * @param int $options 686 | * 687 | * @throws JsonEncodingException 688 | */ 689 | public function toJson($options = 0): string 690 | { 691 | $json = json_encode($this->jsonSerialize(), $options); 692 | 693 | // @codeCoverageIgnoreStart 694 | if (JSON_ERROR_NONE !== json_last_error()) { 695 | throw JsonEncodingException::forModel($this, json_last_error_msg()); 696 | } 697 | // @codeCoverageIgnoreEnd 698 | 699 | return $json; 700 | } 701 | } 702 | --------------------------------------------------------------------------------