├── bin └── composer ├── src ├── tile.png ├── favicon.ico ├── tile-wide.png ├── apple-touch-icon.png ├── model │ ├── EntityModel.png │ ├── member.php │ ├── vote.php │ ├── session.php │ └── poll.php ├── templates │ ├── removal.html │ ├── add_source.html │ ├── 404.html │ ├── cookie_notice.php │ ├── default_source.html │ ├── templates.php │ ├── member.php │ ├── join.php │ ├── list.html │ ├── github_source.html │ ├── gitlab_source.html │ ├── jira_source.html │ ├── master.php │ └── home.php ├── fonts │ ├── glyphicons-halflings-regular.eot │ ├── glyphicons-halflings-regular.ttf │ ├── glyphicons-halflings-regular.woff │ └── glyphicons-halflings-regular.woff2 ├── robots.txt ├── humans.txt ├── controllers │ ├── card-frequency.php │ ├── statistics │ │ ├── PollCount.php │ │ ├── README.md │ │ ├── EstimationTime.php │ │ ├── AverageAttempts.php │ │ ├── generate-plugin.sh │ │ ├── DiscussionTime.php │ │ └── EstimationProphet.php │ ├── response.php │ ├── statistic.php │ ├── user-vote.php │ ├── jira-controller.php │ ├── statistics-controller.php │ ├── session-evaluation.php │ ├── session-controller.php │ └── poll-controller.php ├── browserconfig.xml ├── js │ ├── npm.js │ ├── github-plugin.js │ ├── gitlab-plugin.js │ ├── jira-plugin.js │ ├── J2M.js │ ├── modernizr-2.8.3.min.js │ └── main.js ├── crossdomain.xml ├── .htaccess ├── api.php ├── sample-config.php ├── index.php ├── bootstrap.php └── css │ ├── main.css │ ├── scrumonline.css │ └── normalize.css ├── .gitignore ├── cli-config.php ├── .vscode └── launch.json ├── composer.json ├── doc ├── Deployment.md ├── Deployment-Nginx.md ├── Cookies.md ├── Deployment-Apache.md ├── Jira.md ├── Developer-Documentation.md ├── Poker-Tutorial.md └── swagger.yaml ├── README.md └── LICENSE.md /bin/composer: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Toxantron/scrumonline/HEAD/bin/composer -------------------------------------------------------------------------------- /src/tile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Toxantron/scrumonline/HEAD/src/tile.png -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Toxantron/scrumonline/HEAD/src/favicon.ico -------------------------------------------------------------------------------- /src/tile-wide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Toxantron/scrumonline/HEAD/src/tile-wide.png -------------------------------------------------------------------------------- /src/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Toxantron/scrumonline/HEAD/src/apple-touch-icon.png -------------------------------------------------------------------------------- /src/model/EntityModel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Toxantron/scrumonline/HEAD/src/model/EntityModel.png -------------------------------------------------------------------------------- /src/templates/removal.html: -------------------------------------------------------------------------------- 1 |
2 |

You were removed from the session!

3 |
-------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | node_modules/ 3 | src/config.php 4 | *.lock 5 | !composer.lock 6 | proxies/ 7 | .vagrant/ 8 | mysql_db 9 | -------------------------------------------------------------------------------- /src/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Toxantron/scrumonline/HEAD/src/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /src/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Toxantron/scrumonline/HEAD/src/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /src/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Toxantron/scrumonline/HEAD/src/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /src/fonts/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Toxantron/scrumonline/HEAD/src/fonts/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /src/robots.txt: -------------------------------------------------------------------------------- 1 | # www.robotstxt.org/ 2 | 3 | # Per default forbid crawling to avoid google penalities of self hosted sites 4 | User-agent: * 5 | Disallow: / 6 | -------------------------------------------------------------------------------- /cli-config.php: -------------------------------------------------------------------------------- 1 | Would like to contribute a ticketing system? Check out the github repo.

2 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Listen for XDebug", 6 | "type": "php", 7 | "request": "launch", 8 | "port": 9000 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": { 3 | "doctrine/orm": "^2.7.3", 4 | "symfony/yaml": "2.*", 5 | "guzzlehttp/guzzle": "^6.2" 6 | }, 7 | "autoload": { 8 | "psr-0": {"": "src/"} 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/templates/404.html: -------------------------------------------------------------------------------- 1 |

Page Not Found

2 |

Sorry, but the page you were trying to view does not exist. This can also happen when you tried 3 | to access a private session directly. Please use the Sessions 4 | view to enter the password and access the session. 5 |

6 | -------------------------------------------------------------------------------- /src/humans.txt: -------------------------------------------------------------------------------- 1 | # humanstxt.org/ 2 | # The humans responsible & technology colophon 3 | 4 | # TEAM 5 | 6 | -- -- 7 | 8 | # THANKS 9 | 10 | 11 | 12 | # TECHNOLOGY COLOPHON 13 | 14 | CSS3, HTML5 15 | Apache Server Configs, jQuery, Modernizr, Normalize.css 16 | -------------------------------------------------------------------------------- /src/controllers/card-frequency.php: -------------------------------------------------------------------------------- 1 | value = $value; 8 | $this->count = 0; 9 | } 10 | 11 | // Value of the card 12 | public $value; 13 | 14 | // Number of votes in poll 15 | public $count; 16 | } -------------------------------------------------------------------------------- /src/controllers/statistics/PollCount.php: -------------------------------------------------------------------------------- 1 | getPolls()->count(); 15 | } 16 | } 17 | 18 | return new PollCount(); 19 | -------------------------------------------------------------------------------- /src/controllers/statistics/README.md: -------------------------------------------------------------------------------- 1 | # Scrum Poker Statistics 2 | This directory contains statistics of the session. Each statistic is implemented by a dedicated implementation of the `IStatistic` interface. 3 | Create new plugins by navigating to the directory and running: 4 | 5 | ```sh 6 | $ bash generate-plugin.sh PluginName 7 | ``` 8 | 9 | Make sure to provide a meaningful comment on top of the class, because the application links directly to the source file from the UI. 10 | -------------------------------------------------------------------------------- /src/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/controllers/response.php: -------------------------------------------------------------------------------- 1 | success = $value; 11 | } 12 | } 13 | 14 | // Wrapper class for a numeric server response 15 | class NumericResponse 16 | { 17 | // Value of the numeric response 18 | public $value; 19 | 20 | function __construct($value = 0) 21 | { 22 | $this->value = $value; 23 | } 24 | } -------------------------------------------------------------------------------- /src/js/npm.js: -------------------------------------------------------------------------------- 1 | // This file is autogenerated via the `commonjs` Grunt task. You can require() this file in a CommonJS environment. 2 | require('../../js/transition.js') 3 | require('../../js/alert.js') 4 | require('../../js/button.js') 5 | require('../../js/carousel.js') 6 | require('../../js/collapse.js') 7 | require('../../js/dropdown.js') 8 | require('../../js/modal.js') 9 | require('../../js/tooltip.js') 10 | require('../../js/popover.js') 11 | require('../../js/scrollspy.js') 12 | require('../../js/tab.js') 13 | require('../../js/affix.js') -------------------------------------------------------------------------------- /src/controllers/statistic.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/templates/cookie_notice.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/controllers/statistics/EstimationTime.php: -------------------------------------------------------------------------------- 1 | getPolls() as $poll) { 18 | $times[] = $poll->getEndTime()->diff($poll->getStartTime())->s; 19 | } 20 | return array_sum($times) / count($times); 21 | } 22 | } 23 | 24 | return new EstimationTime(); 25 | -------------------------------------------------------------------------------- /src/crossdomain.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 15 | 16 | -------------------------------------------------------------------------------- /src/templates/default_source.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 |
6 |
7 | 8 | 9 |
10 | 11 | 12 |
13 | -------------------------------------------------------------------------------- /src/.htaccess: -------------------------------------------------------------------------------- 1 | # Use mod_rewrite to direct calls to api.php 2 | RewriteEngine on 3 | RewriteBase / 4 | RewriteRule ^\.htaccess$ - [F] 5 | 6 | # Rule that includes session and member id 7 | RewriteRule api\/(\w+)\/(\w+)\/(\d+)\/(\d+) api.php?c=$1&m=$2&id=$3&mid=$4 [QSA] 8 | # Rule that includes a session or member id, mostly used for HTTP GET 9 | RewriteRule api\/(\w+)\/(\w+)\/(\d+) api.php?c=$1&m=$2&id=$3 [QSA] 10 | # Standard rule for controller and method - applies to most queries 11 | RewriteRule api\/(\w+)\/(\w+) api.php?c=$1&m=$2 [QSA] 12 | 13 | # Rewrite all other calls to index.php 14 | RewriteCond %{REQUEST_FILENAME} !-f 15 | RewriteCond %{REQUEST_FILENAME} !-d 16 | RewriteCond %{REQUEST_FILENAME} !-l 17 | RewriteRule .* index.php [L] 18 | -------------------------------------------------------------------------------- /src/api.php: -------------------------------------------------------------------------------- 1 | $method($_GET["id"], $_GET["mid"]); 15 | // Pass only id 16 | else if (isset($_GET["id"])) 17 | $result = $controller->$method($_GET["id"]); 18 | // Pass no arguments 19 | else 20 | $result = $controller->$method(); 21 | 22 | // Return reponse as JSON if this method has a return value 23 | if (isset($result) && $result != null) 24 | echo json_encode($result); 25 | -------------------------------------------------------------------------------- /src/controllers/statistics/AverageAttempts.php: -------------------------------------------------------------------------------- 1 | getPolls() as $poll) { 17 | $id = $poll->getTopic(); 18 | if (isset($attempts[$id])) { 19 | $attempts[$id]++; 20 | } else { 21 | $attempts[$id] = 1; 22 | } 23 | } 24 | 25 | // Calculate average 26 | $total = 0; 27 | foreach($attempts as $pollAttempts) { 28 | $total += $pollAttempts; 29 | } 30 | return $total / sizeof($attempts); 31 | } 32 | } 33 | 34 | return new AverageAttempts(); 35 | -------------------------------------------------------------------------------- /src/controllers/statistics/generate-plugin.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | name=$1 3 | file=$1".php" 4 | echo "Generating plugin $name to $file" 5 | 6 | # Frame and docs 7 | echo "> $file 8 | echo "/*" >> $file 9 | echo " * TODO: Documentation for $name" >> $file 10 | echo " */" >> $file 11 | 12 | # Class header 13 | echo "class $name implements IStatistic" >> $file 14 | echo "{" >> $file 15 | 16 | # getType method 17 | echo " public function getType()" >> $file 18 | echo " {" >> $file 19 | echo " return \"numeric\"; // Define type" >> $file 20 | echo " }" >> $file 21 | echo "" >> $file 22 | 23 | # evaluate method 24 | echo " public function evaluate(\$session)" >> $file 25 | echo " {" >> $file 26 | echo " return 0;" >> $file 27 | echo " }" >> $file 28 | 29 | # Footer and return instance 30 | echo "}" >> $file 31 | echo "" >> $file 32 | echo "return new $name();" >> $file 33 | -------------------------------------------------------------------------------- /src/controllers/user-vote.php: -------------------------------------------------------------------------------- 1 | placed = false; 7 | $this->value = 'N/A'; 8 | $this->active = false; 9 | } 10 | 11 | // Create vote object from query object 12 | public static function fromQuery($cardSet, $entity) 13 | { 14 | $vote = new UserVote(); 15 | $vote->id = $entity['id']; 16 | $vote->name = $entity['name']; 17 | if($entity['value'] !== null) 18 | { 19 | $vote->placed = true; 20 | $vote->value = $cardSet[$entity['value']]; 21 | $vote->active = $entity['highlighted']; 22 | } 23 | 24 | return $vote; 25 | } 26 | 27 | // Id of the member 28 | public $id; 29 | 30 | // Name of the user 31 | public $name; 32 | 33 | // Flag if value was set allready 34 | public $placed; 35 | 36 | // Value of the vote 37 | public $value; 38 | 39 | // Member must explain his vote 40 | public $active; 41 | 42 | // Flag if the requesting user has the rights to delete this user (and its vote) 43 | public $canDelete; 44 | } -------------------------------------------------------------------------------- /src/controllers/jira-controller.php: -------------------------------------------------------------------------------- 1 | request('GET', $jiraUrl, [ 23 | 'auth' => [$parameters['username'], $parameters['password']] 24 | ]); 25 | $response = json_decode($res->getBody()->getContents(), true); 26 | return $response; 27 | } 28 | } 29 | 30 | return new JiraController($entityManager); 31 | -------------------------------------------------------------------------------- /src/controllers/statistics/DiscussionTime.php: -------------------------------------------------------------------------------- 1 | getPolls() as $poll) { 19 | if (!isset($last) || $last->getTopic() !== $poll->getTopic()) { 20 | // If this is the first poll or a new topic, simply continue 21 | $last = $poll; 22 | continue; 23 | } 24 | 25 | // Calculate the time difference from this poll to the previous one 26 | $times[] = $poll->getStartTime()->diff($last->getStartTime())->s; 27 | $last = $poll; 28 | } 29 | 30 | // No times -> no average 31 | if (count($times) == 0) 32 | return 0; 33 | 34 | // Calculate average 35 | return array_sum($times) / count($times); 36 | } 37 | } 38 | 39 | return new DiscussionTime(); 40 | -------------------------------------------------------------------------------- /src/model/member.php: -------------------------------------------------------------------------------- 1 | id; 23 | } 24 | 25 | // Getter and setter for name field 26 | public function getName() 27 | { 28 | return $this->name; 29 | } 30 | public function setName($name) 31 | { 32 | $this->name = $name; 33 | } 34 | 35 | // Getter and setter for session field 36 | public function getSession() 37 | { 38 | return $this->session; 39 | } 40 | public function setSession($session) 41 | { 42 | $this->session = $session; 43 | $session->getMembers()->add($this); 44 | } 45 | 46 | // Getter for votes association 47 | public function getVotes() 48 | { 49 | return $this->votes; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/controllers/statistics-controller.php: -------------------------------------------------------------------------------- 1 | getSession($id); 23 | 24 | // Reading a private sessions statistic requires the token 25 | if (!$this->verifyToken($session, null, true)) 26 | return; 27 | 28 | // Evaluation 29 | $statistics = []; 30 | foreach ($this->loadPlugins() as $key => $plugin) { 31 | $statistic = new Statistic(); 32 | $statistic->name = $key; 33 | $statistic->type = $plugin->getType(); 34 | $statistic->value = $plugin->evaluate($session); 35 | 36 | $statistics[] = $statistic; 37 | } 38 | 39 | return $statistics; 40 | } 41 | } 42 | 43 | return new StatisticsController($entityManager); 44 | ?> -------------------------------------------------------------------------------- /src/js/github-plugin.js: -------------------------------------------------------------------------------- 1 | /*globals scrum */ 2 | 3 | // Add a plugin for github integration 4 | scrum.sources.push({ 5 | // Fixed properties and methods 6 | name: "Github", 7 | position: 3, 8 | view: "templates/github_source.html", 9 | feedback: false, 10 | // Feedback call for completed poll 11 | completed: function(result) { 12 | }, 13 | 14 | // Custom properties and methods 15 | loaded: false, 16 | user: '', 17 | repo: '', 18 | issues: [], 19 | issue: {}, 20 | 21 | // Private repo 22 | isPrivate: false, 23 | password: '', 24 | 25 | // Load issues from github 26 | load: function() { 27 | var self = this; 28 | 29 | var headers = {}; 30 | if(self.isPrivate) { 31 | var auth = window.btoa(self.user + ':' + self.password); 32 | headers.Authorization = 'Basic ' + auth; 33 | } 34 | 35 | this.parent.$http 36 | .get('https://api.github.com/repos/' + this.repo + '/issues', { headers: headers }) 37 | .then(function (response) { 38 | // Convert markdown to HTML 39 | var converter = new showdown.Converter(); 40 | response.data.forEach(function(issue) { 41 | issue.body = converter.makeHtml(issue.body); 42 | }); 43 | self.issues = response.data; 44 | self.issue = self.issues[0]; 45 | self.loaded = true; 46 | }); 47 | } 48 | }); 49 | -------------------------------------------------------------------------------- /src/templates/templates.php: -------------------------------------------------------------------------------- 1 | name = $name; 20 | $this->path = $path; 21 | 22 | if($navigationTag != null) 23 | { 24 | $this->isNavigation = true; 25 | $this->navigationTag = $navigationTag; 26 | $this->link = "/" . lcfirst($navigationTag); 27 | } 28 | } 29 | 30 | public function render() 31 | { 32 | ?> 33 | 36 | = 7.1 10 | - MySQL, MySQL-PDO 11 | 12 | # Webservers 13 | - [Nginx Deployment](Deployment-Nginx.md) 14 | - [Apache Deployment](Deployment-Apache.md) 15 | 16 | # Configuration 17 | The repository provides a sample config (config-sample.php). 18 | Rename the file to `config.php` and make your changes. 19 | 20 | ```` 21 | $ cp src/sample-config.php src/config.php 22 | ```` 23 | 24 | # Database 25 | 26 | First you have to change the connection settings in your [`config.php`](Deployment.md). 27 | The database engine can create the schema itself. So just execute the following commands: 28 | 29 | ````bash 30 | $ php bin/composer install 31 | $ ./vendor/bin/doctrine orm:generate-proxies 32 | $ ./vendor/bin/doctrine orm:schema-tool:create 33 | ```` 34 | 35 | # Schema script 36 | 37 | If you do not have CLI access to the target server you can generate a schema script instead. Just append `--dump-sql` to the last command. I recommend writing them to a file. 38 | 39 | ```bash 40 | $ php bin/composer install 41 | $ ./vendor/bin/doctrine orm:generate-proxies 42 | $ ./vendor/bin/doctrine orm:schema-tool:create --dump-sql >> schema.sql 43 | -------------------------------------------------------------------------------- /src/js/gitlab-plugin.js: -------------------------------------------------------------------------------- 1 | /*globals scrum */ 2 | 3 | // Add a plugin for github integration 4 | scrum.sources.push({ 5 | // Fixed properties and methods 6 | name: "Gitlab", 7 | position: 4, 8 | view: "templates/gitlab_source.html", 9 | feedback: false, 10 | // Feedback call for completed poll 11 | completed: function(result) { 12 | }, 13 | 14 | // Custom properties and methods 15 | loaded: false, 16 | server: 'https://gitlab.com/', 17 | repo: '', 18 | issues: [], 19 | issue: {}, 20 | 21 | // Private repo 22 | isPrivate: false, 23 | token: '', 24 | 25 | // Load issues from github 26 | load: function() { 27 | var self = this; 28 | 29 | var headers = {}; 30 | if(self.isPrivate) { 31 | headers['Private-Token'] = this.token; 32 | } 33 | 34 | // Build access URL. Gitlab is very picky about that! 35 | var encodedRepo = encodeURIComponent(this.repo); 36 | var uri = this.server; 37 | if(uri.substr(-1) !== '/') 38 | uri += '/'; 39 | uri += 'api/v4/projects/' + encodedRepo + '/issues'; 40 | this.parent.$http 41 | .get(uri, { headers: headers }) 42 | .then(function (response) { 43 | // Convert markdown to HTML 44 | var converter = new showdown.Converter(); 45 | response.data.forEach(function(issue) { 46 | issue.description = converter.makeHtml(issue.description); 47 | }); 48 | self.issues = response.data; 49 | self.issue = self.issues[0]; 50 | self.loaded = true; 51 | }); 52 | } 53 | }); 54 | -------------------------------------------------------------------------------- /doc/Deployment-Nginx.md: -------------------------------------------------------------------------------- 1 | # Nginx Deployment 2 | 3 | ## Requirements 4 | The app requires a couple of packages you need to install on your system. Those are: 5 | - Nginx >=1.6 6 | - PHP >= 7.1 7 | - MySQL, MySQL-PDO 8 | 9 | ## PHP Configuration 10 | 11 | Your PHP intance should contain the openssl module to load dependencies with the composer. 12 | For the database connection php mysql is necessary. 13 | 14 | *php.ini* 15 | ```` 16 | extension=php_openssl.dll 17 | extension=php_pdo_mysql.dll 18 | ```` 19 | 20 | ## Nginx Configuration 21 | The nginx configuration is really default. Add the virtual host to your section. 22 | If you are using php fastcgi, dont forget to change the port or add a unix socket. 23 | 24 | As nginx will ignore apaches .htacces and it is [also bad](https://www.nginx.com/resources/wiki/start/topics/examples/likeapache-htaccess/), we add the needed rewrite rules to the servers configuration. 25 | 26 | ```` 27 | server { 28 | listen 80; 29 | server_name scrum.local; 30 | 31 | root /var/www/scrumonline/src; 32 | 33 | rewrite /api\/(\w+)\/(\w+)\/(\d+)\/(\d+) /api.php?c=$1&m=$2&id=$3&mid=$4; 34 | 35 | rewrite /api\/(\w+)\/(\w+)\/(\d+) /api.php?c=$1&m=$2&id=$3; 36 | 37 | rewrite /api\/(\w+)\/(\w+) /api.php?c=$1&m=$2; 38 | 39 | location / { 40 | index index.html index.htm index.php; 41 | } 42 | 43 | location ~ \.php$ { 44 | fastcgi_pass 127.0.0.1:9123; #sample port 45 | fastcgi_index index.php; 46 | fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; 47 | include fastcgi_params; 48 | } 49 | } 50 | ```` 51 | -------------------------------------------------------------------------------- /doc/Cookies.md: -------------------------------------------------------------------------------- 1 | # Cookies 2 | The scrumpoker application uses two types of cookies. One is the *scrum_member_name* and the other are session tokens. Both types and their usage are explained below. 3 | 4 | ## Scrum Member Name 5 | This cookie is set and updated everytime a member enters a name to join a session. It is used to prefill the _Name_ box the next time the user open the application. It is only a convenience feature and not necesarry for the overall functionality. 6 | 7 | ## Session Tokens 8 | Unlike the member name cookie these cookies are necessary for the app to work. Session tokens are assigned per session and have the key _session-token-{id}_. For private sessions the token is generated from the sessions name and its password, thereby it remains valid if a new session with the same name and password is created. The token for public sessions is generated from random bytes upon creation and returned to the creator. 9 | 10 | For private sessions every member needs the token to join and perform operations. They receive the token either through qualified invite or by supplying the password during the join-process. A qualified invite is a Join-URL that includes the token as query parameter like `"?token=XXX"`. 11 | 12 | In public sessions the token is returned to the creator and can be passed onto members through qualified invite. In that case the member gains full privilidge, for example to remove other members. If someones joins a public session **without** the token, a member-specific token is generated. That token limits the user to view the current topic, place a vote or remove itself. It does not grant privilidge to start a poll or remove other members. -------------------------------------------------------------------------------- /src/templates/member.php: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 |
8 |
9 |

10 |
11 |
12 | 13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | 22 |
23 |
24 |
25 |
26 |
27 | 28 |
29 |
30 |
31 |
32 |
33 | 34 |
35 |

How to:

36 |

37 | Votes can only be placed during an active poll.That means as long as the master has not started a poll or all votes have been placed, you can not vote! 38 | When you select a card it is highlighted in red, meaning that you vote is processed by the server. If it was placed successfully the card is highlighted 39 | green as feedback. Until everyone has placed their vote you can still change it. When the last person votes the poll is closed. 40 |

41 |
42 | -------------------------------------------------------------------------------- /src/model/vote.php: -------------------------------------------------------------------------------- 1 | id; 26 | } 27 | 28 | // Getter and setter for value field 29 | public function getValue() 30 | { 31 | return $this->value; 32 | } 33 | public function setValue($value) 34 | { 35 | $this->value = $value; 36 | } 37 | 38 | // Getter and setter for start time 39 | public function getHighlighted() 40 | { 41 | return $this->highlighted; 42 | } 43 | public function setHighlighted($highlighted) 44 | { 45 | $this->highlighted = $highlighted; 46 | } 47 | 48 | // Getter and setter for poll association 49 | public function getPoll() 50 | { 51 | return $this->poll; 52 | } 53 | public function setPoll($poll) 54 | { 55 | $this->poll = $poll; 56 | $poll->getVotes()->add($this); 57 | } 58 | 59 | // Getter and setter for value field 60 | public function getMember() 61 | { 62 | return $this->member; 63 | } 64 | public function setMember($member) 65 | { 66 | $this->member = $member; 67 | $member->getVotes()->add($this); 68 | } 69 | } -------------------------------------------------------------------------------- /src/templates/join.php: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
Join session
5 |
6 |
7 |
8 | 9 |
10 | 11 | 12 |
13 |
14 |
15 | 16 |
'"> 17 | 18 | 19 |
20 |
21 |
22 | 23 |
24 | 25 | 26 |
27 |
28 | 29 |
30 |
31 |
32 |
33 |
34 | -------------------------------------------------------------------------------- /src/js/jira-plugin.js: -------------------------------------------------------------------------------- 1 | /*globals scrum */ 2 | 3 | // Add a plugin to load tickets from local JIRA server 4 | scrum.sources.push({ 5 | // Fixed properties and methods 6 | name: "JIRA", 7 | position: 3, 8 | view: "templates/jira_source.html", 9 | feedback: false, 10 | jql: 'issuetype=story and status=backlog', 11 | disable_jira_fields : false, 12 | // Feedback call for completed poll 13 | completed: function(result) { 14 | }, 15 | 16 | // Custom properties and methods 17 | loaded: false, 18 | issues: [], 19 | issue: {}, 20 | 21 | load: function() { 22 | var self = this; 23 | 24 | var queryParameters = $.param({ 25 | base_url: this.base_url, 26 | username: this.username, 27 | password: this.password, 28 | project: this.project, 29 | jql: this.jql 30 | }); 31 | 32 | this.parent.$http({ 33 | url: '/api/jira/getIssues', 34 | method: 'POST', 35 | data: queryParameters, 36 | headers: {'Content-Type': 'application/x-www-form-urlencoded'} 37 | }) 38 | .then(function (response) { 39 | var data = response.data; 40 | 41 | if (!data || !data.issues) { 42 | self.error = 'Can\'t load Jira issues, check configuration'; 43 | } else { 44 | var converter = new showdown.Converter(); 45 | // Convert JIRA format to Markdown and then to HTML 46 | response.data.issues.forEach(function(issue) { 47 | var markdown = J2M.toM(issue.fields.description || ''); 48 | issue.fields.description = converter.makeHtml(markdown); 49 | }); 50 | self.issues = response.data.issues; 51 | self.issue = self.issues[0]; 52 | self.loaded = true; 53 | } 54 | }); 55 | }, 56 | reload: function() { 57 | this.loaded = false; 58 | } 59 | }); 60 | -------------------------------------------------------------------------------- /src/templates/list.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | Name 5 | Members 6 | Options 7 |
8 |
9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 |
20 |
21 | 22 | 23 | 24 |
25 | 26 | 27 |
28 |
29 |
30 |
31 |
32 |
33 | -------------------------------------------------------------------------------- /src/controllers/statistics/EstimationProphet.php: -------------------------------------------------------------------------------- 1 | getTopic() != $poll->getTopic()) { 22 | $firstPoll = $poll; 23 | continue; 24 | } 25 | 26 | // Now only look at consensus 27 | if (!$poll->getConsensus()) 28 | continue; 29 | 30 | // Compare first estimation and final results 31 | $result = $poll->getResult(); 32 | foreach ($firstPoll->getVotes() as $vote) { 33 | if ($vote->getValue() == $result) { 34 | // Award point to the member who was right 35 | $members[$vote->getMember()->getName()]++; 36 | } 37 | } 38 | } 39 | } 40 | 41 | public function evaluate($session) 42 | { 43 | // Create dictionary of member and points 44 | $members = []; 45 | foreach ($session->getMembers() as $member) { 46 | $members[$member->getName()] = 0; 47 | } 48 | 49 | // Loop over polls and compare first estimation with consensus 50 | $this->calculatePoints($members, $session->getPolls()); 51 | 52 | // Find member who was right most often 53 | $maxPoints = 0; 54 | $prophet = null; 55 | foreach ($members as $member => $points) { 56 | if ($points <= $maxPoints) 57 | continue; 58 | 59 | $prophet = $member; 60 | $maxPoints = $points; 61 | } 62 | 63 | return $prophet; 64 | } 65 | } 66 | 67 | return new EstimationProphet(); 68 | -------------------------------------------------------------------------------- /doc/Deployment-Apache.md: -------------------------------------------------------------------------------- 1 | # Apache Deployment 2 | 3 | ## Requirements 4 | The app requires a couple of packages you need to install on your system. Those are: 5 | * Apache >= 2.2 6 | * mod_rewrite 7 | * PHP >= 7.1 8 | * MySQL, MySQL-PDO 9 | 10 | You need to install and enable `mod_rewrite`. You can do this by following [this SO answer](http://stackoverflow.com/a/5758551/6082960) or simply run the commands (as root): 11 | 12 | ```sh 13 | a2enmod rewrite 14 | service apache2 restart 15 | ``` 16 | 17 | ## Configuration 18 | Configure Apache to run the application from the _src_ directory and make sure to ether `AllowOverride All` or move the rewrite rule from _.htaccess_ to the VirtualHost config. 19 | 20 | With _.htaccess_: 21 | 22 | ```ini 23 | 24 | DocumentRoot /var/www/scrumonline/src 25 | 26 | 27 | AllowOverride All 28 | 29 | 30 | ErrorLog ${APACHE_LOG_DIR}/error.log 31 | CustomLog ${APACHE_LOG_DIR}/access.log combined 32 | 33 | ``` 34 | 35 | Without _.htaccess_: 36 | 37 | ```ini 38 | 39 | DocumentRoot /var/www/scrumonline/src 40 | 41 | RewriteEngine on 42 | 43 | # Rule that includes session and member id 44 | RewriteRule ^/api/(\w+)/(\w+)/(\d+)/(\d+) /api.php?c=$1&m=$2&id=$3&mid=$4 [QSA] 45 | # Rule that includes the session id, mostly used for HTTP GET 46 | RewriteRule ^/api/(\w+)/(\w+)/(\d+) /api.php?c=$1&m=$2&id=$3 [QSA] 47 | # Standard rule for controller and method - applies to most queries 48 | RewriteRule ^/api/(\w+)/(\w+) /api.php?c=$1&m=$2 [QSA] 49 | 50 | # The angular HTML mode rewrite needs to be in the directory tag because of reasons... 51 | # http://tltech.com/info/rewriterule-in-htaccess-vs-httpd-conf/ 52 | 53 | # Rewrite all other calls to index.php 54 | RewriteCond %{REQUEST_FILENAME} !-f 55 | RewriteCond %{REQUEST_FILENAME} !-d 56 | RewriteCond %{REQUEST_FILENAME} !-l 57 | RewriteRule . index.php [L] 58 | 59 | 60 | ErrorLog ${APACHE_LOG_DIR}/error.log 61 | CustomLog ${APACHE_LOG_DIR}/access.log combined 62 | 63 | ``` 64 | -------------------------------------------------------------------------------- /doc/Jira.md: -------------------------------------------------------------------------------- 1 | # Jira integration 2 | 3 | It is possible to import stuff from Jira. Below you can find simple tutorial, how to do it. 4 | 5 | # 0. Enable Jira Integration 6 | 7 | Valid only if you have your standalone copy of the tool! 8 | 9 | * open `src/config.php` 10 | * change `$plugins` value to contain `Jira`, for example: 11 | ```php 12 | $plugins = [ 13 | 'GitHub', 14 | 'Jira', 15 | ]; 16 | ``` 17 | 18 | # 1. Start planning 19 | 20 | Start session as a Scrum Master. Then in the menu you should see `Jira` option. Click on it. 21 | 22 | # 2. Fill in your JIRA information 23 | 24 | To import issues from Jira, you have to pass some configuration information: 25 | 26 | * `JIRA base url` - your JIRA installation address 27 | * `Project` - JIRA project, from which you want to import issues. Use shortname. 28 | * `Username` - your JIRA username 29 | * `Password` - your JIRA password 30 | * `JQL` - (optional) if you want to select with extra criterias, feel free to change this value. By default it contains `issuetype=story and status=backlog`, which means that only stories from backlog will be imported. 31 | 32 | Imported issues are sorted by priority. 33 | 34 | # 3. Load issues 35 | 36 | Just click on `Load issues` button. That's it! 37 | 38 | # 4. (additional) Reload issues 39 | 40 | During planning you might want to add some new issues to JIRA. Feel free to do it! Later you can click on `Reload` button on the top of issues list. Config form will appear again, then click `Load issues` and all new stuff should be visible now! 41 | 42 | # 5. (additional) Hardcode your JIRA configuration 43 | 44 | Valid only if you have your standalone copy of the tool! 45 | 46 | It might be boring to type all the credentials over and over again. You can hardcode it in your application config. 47 | 48 | * open `src/config.php` 49 | * find `$jiraConfiguration` and fill in with values 50 | * if you can not find it, just copy the code below, paste to `src/config.php` and fill in with your values: 51 | ```php 52 | $jiraConfiguration = [ 53 | 'base_url' => '', 54 | 'username' => '', 55 | 'password' => '', 56 | 'project' => '', 57 | 'jql' => '', 58 | ]; 59 | ``` 60 | 61 | Even if you hardcode your JIRA configuration, you can still override i.e. project during planning. Just fill in project name in `Project` field - it will override value from `$jiraConfiguration` variable. 62 | -------------------------------------------------------------------------------- /src/sample-config.php: -------------------------------------------------------------------------------- 1 | 'scrum_online', 5 | 'user' => 'root', 6 | 'password' => 'passwd', 7 | 'host' => 'localhost', 8 | 'driver' => 'pdo_mysql', 9 | ); 10 | 11 | // This is used to create the join link 12 | $host = "https://localhost"; 13 | 14 | $cardSets = [ 15 | // Standard fibonaci like series of values 16 | ['1', '2', '3', '5', '8', '13', '20', '40', '100'], 17 | // Special card set with '?' for unclear stories 18 | ['1', '2', '3', '5', '8', '13', '20', '40', '?'], 19 | // Powers of two used by other teams 20 | ['0' ,'1', '2', '4', '8', '16', '32', '64'], 21 | // Card set used to estimate hours as different fractions and multiples of a working day 22 | ['1', '2', '4', '8', '12', '16', '24', '32', '40'], 23 | // Demonstration of the coffee cup card 24 | ['☕', '1', '2', '3', '5', '8', '13', '20', '?'], 25 | // T-shirt Size 26 | ['XXS','XS', 'S', 'M', 'L', 'XL', 'XXL', '?'], 27 | // Fibonacci number 28 | ['1', '2', '3', '5', '8', '13', '21', '34', '55', '89', '144', '☕', '?'], 29 | // Fibonaci series including 0.5 30 | ['0.5', '1', '2', '3', '5', '8', '13', '20', '40', '100'], 31 | // Canadian Cash format 32 | ['1', '2', '5', '10', '20', '50', '100'], 33 | // Standard fibonacci with shrug 34 | ['1', '2', '3', '5', '8', '13', '&#F937;'], 35 | // Standard fibonaci like series of values with 0, 1/2, infinity, and shrug 36 | ['0', '1/2', '1', '2', '3', '5', '8', '13', '20', '40', '100', '∞', '&#F937;'], 37 | // Decimal values representing number of dev weeks of effort 38 | ['0.1', '0.2', '0.5', '1', '2', '?'] 39 | ]; 40 | 41 | // Src tree for documentation linking from page 42 | $src = "https://github.com/Toxantron/scrumonline/tree/master"; 43 | 44 | // Active ticketing plugins of the page 45 | $plugins = [ 46 | // Plugin to load issues from github 47 | 'GitHub', 48 | // Plugin to load issues from JIRA 49 | 'JIRA', 50 | // Plugin to load issues from Gitlab 51 | 'Gitlab' 52 | ]; 53 | 54 | // Configuration for the server side JIRA controller 55 | $jiraConfiguration = [ 56 | 'base_url' => '', 57 | 'username' => '', 58 | 'password' => '', 59 | 'project' => '', 60 | 'jql' => '', 61 | ]; 62 | 63 | //Configuration for Enable/Disable style elements 64 | $layout_switch = [ 65 | 'enable_fork_banner' => true 66 | ]; 67 | -------------------------------------------------------------------------------- /src/controllers/session-evaluation.php: -------------------------------------------------------------------------------- 1 | getVotes()->count(); 12 | 13 | if ($count <= 0 || $count != $session->getMembers()->count() && !$force) 14 | return false; 15 | 16 | foreach($currentPoll->getVotes() as $vote) 17 | { 18 | $sum += $vote->getValue(); 19 | } 20 | $currentPoll->setResult($sum / $count); 21 | $currentPoll->setEndTime(new DateTime()); 22 | 23 | return true; 24 | } 25 | 26 | // Highlight highest and lowest estimate 27 | public static function highlightVotes($session, $currentPoll, $cardSet) 28 | { 29 | include __DIR__ . "/card-frequency.php"; 30 | 31 | $votes = $currentPoll->getVotes(); 32 | // Frequency for each card 33 | $frequencies = []; 34 | foreach($cardSet as $key=>$card) 35 | { 36 | $frequencies[$key] = new CardFrequency($key); 37 | } 38 | 39 | // Count absolute frequence 40 | foreach($votes as $vote) 41 | { 42 | $frequencies[$vote->getValue()]->count++; 43 | } 44 | 45 | // Determine most common vote 46 | foreach($frequencies as $frequency) 47 | { 48 | if(!isset($mostCommon) || $mostCommon->count < $frequency->count) 49 | $mostCommon = $frequency; 50 | } 51 | 52 | $min = 0; $max = 0; 53 | // Iterate over frequencies and find lowest 54 | foreach($frequencies as $frequency) 55 | { 56 | $min = self::selectLimits($votes, $frequency, $mostCommon); 57 | if($min != 0) 58 | break; 59 | } 60 | // Iterate over frequencies and find highest 61 | foreach(array_reverse($frequencies) as $frequency) 62 | { 63 | $max = self::selectLimits($votes, $frequency, $mostCommon); 64 | if($max != 0) 65 | break; 66 | } 67 | 68 | $currentPoll->setConsensus($min == -1 && $max == -1); 69 | } 70 | 71 | // Select highest or lowest estimates - depends on direction of loop 72 | private static function selectLimits($votes, $frequency, $mostCommon) 73 | { 74 | // No card at all 75 | if($frequency->count == 0) 76 | return 0; 77 | // This is the most common, no lowest found 78 | if($frequency == $mostCommon) 79 | return -1; 80 | 81 | foreach($votes as $vote) 82 | { 83 | if($vote->getValue() == $frequency->value) 84 | $vote->setHighlighted(true); 85 | } 86 | return 1; 87 | } 88 | } -------------------------------------------------------------------------------- /doc/Developer-Documentation.md: -------------------------------------------------------------------------------- 1 | # REST API 2 | Scrumonline offers a JSON REST API for developers of apps or to extend the project. The API is specified in [swagger.yaml](swagger.yaml) using the OpenAPI format. You can generate source code from it or simply upload the file to [the online editor](http://editor.swagger.io/) to generate the full documentation. 3 | 4 | # Ticketing plugin 5 | On the client side the app can be extended with plugins for ticketing systems. A ticketing plugin consists of two components: 6 | 1. JS model 7 | 2. HTML template 8 | 9 | ## JS model 10 | The plugins JS model must be an object with some standard attributes and functions. It must be added to the source array of the scrum global. Each plugin should be placed in a separate file _js/-plugin.js_. You need to include it in the _config.php_ to activate it. 11 | 12 | The following properties and methods are mandatory, the rest is up to you. You have access to your object within the template by binding to __master.current.__ like in any other angular application. The same applies for modules. Once you fetched the topic from the ticketing system, you must call __this.parent.startPoll(topic)__. 13 | 14 | ````js 15 | scrum.sources.push({ 16 | /* Standard members for each plugin */ 17 | name: "", 18 | position: 2, 19 | view: "templates/_source.html", 20 | feedback: false, // Flag that the plugin wants feedback when the poll was completed 21 | // Feedback call for completed poll 22 | completed: function(result) { 23 | var value = result; 24 | this.feedback = false; 25 | } 26 | 27 | /* Plugin specific code */ 28 | // Load issue from ticketing system 29 | loadIssue: function() { 30 | this.feedback = true; 31 | /* Some ticketing code */ 32 | this.parent.$http.get(url).then(function(response) { 33 | var story = response.story; 34 | this.parent.startPoll(story.topic); 35 | }); 36 | }, 37 | }); 38 | ```` 39 | 40 | ## HTML Template 41 | Each plugin can define its UI with a partial HTML snippet that is loaded with ngInclude when the tab is selected. The __current__ object points to your JS model. It might look like this: 42 | 43 | ````html 44 |
45 |

This feature requires CORS - either on server or client side! 46 | 47 | 48 | 49 |

50 |
51 |
52 |
53 | 54 | 55 |
56 |
57 | 58 | 59 |
60 | 61 |
62 | ```` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ScrumOnline 2 | 3 | [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/scrumonline/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 4 | 5 | PHP web app for planning poker. It includes a master view for the ScrumMaster and a simple responsive card view for the team. A deployed demo is available at http://www.scrumpoker.online 6 | 7 | ## Idea 8 | Everyone who ever managed a software project using scrum will have either heard of or played planning poker. It is a simple tool to determine a stories/features complexity. For the detailed process refer to https://en.wikipedia.org/wiki/Planning_poker. So far there are a couple of apps where you can select a card and display it on the screen - but none of them offer a network collaboration mode. Some of the existing web apps are nowhere near responsive or come with too overloaded UIs. I want to create a simple web app where the Scrum Master can start a named session and all team members can join the session. 9 | 10 | ## Deployment 11 | You can find a detailed deployment how-to in the [documentation](/doc/Deployment.md) or use the docker image over at [chrisns/scrumonline](https://github.com/chrisns/scrumonline). 12 | 13 | ## Setup 14 | The "deployment" is a general scrum meeting where the ScrumMaster has a laptop connected to a beamer while all team members have an internet connected device (phone, tablet, laptop, ... - smartwatch would be awesome). The meeting starts with the ScrumMaster creating a named session and all team members joining that session. The beamer should now show the list of joined members. 15 | 16 | ## Estimation workflow 17 | For every story the Scrum Master will than start a poll and each member of the session must select a card. As they select a card the main screen will show a card over their name, but without showing the number. Once everyone selected a card the main page (beamer) flips all the cards. According to planning poker it will than highlight the minimum and maximum estimation for colleagues to bring their arguments. A demonstration using the Redmine plugin is available [on youtube](https://www.youtube.com/watch?v=faRYrNz8MYw). 18 | 19 | ## Road Map 20 | * Include vote history of previous stories at the bottom of the master view 21 | * Statistics tab in navigation bar 22 | * Mobile apps with watch support. Imagine voting on Android Wear or Apple Watch. Wouldn't that be cool? :D 23 | 24 | ## Contribute 25 | If you want to contribute you can just clone the repository and follow the deployment instructions. Any changes must be commited to a fork and then merged by issuing a pull request. For information on the REST API or ticketing plugins please have a look at the [wiki documentation](https://github.com/Toxantron/scrumonline/blob/master/doc/). 26 | 27 | You can also use the [REST API](https://github.com/Toxantron/scrumonline/blob/master/doc/Developer-Documentation.md) to build a mobile for iOS or Android. In that case I am happy to link your app in the README and on the page. 28 | -------------------------------------------------------------------------------- /src/model/session.php: -------------------------------------------------------------------------------- 1 | id; 38 | } 39 | 40 | // Getter and setter for name field 41 | public function getName() 42 | { 43 | return $this->name; 44 | } 45 | public function setName($name) 46 | { 47 | $this->name = $name; 48 | } 49 | 50 | // Getter and setter for isPrivate field 51 | public function getIsPrivate() 52 | { 53 | return $this->isPrivate; 54 | } 55 | public function setIsPrivate($isPrivate) 56 | { 57 | $this->isPrivate = $isPrivate; 58 | } 59 | 60 | // Getter and setter for the token field 61 | public function getToken() 62 | { 63 | return $this->token; 64 | } 65 | public function setToken($token) 66 | { 67 | $this->token = $token; 68 | } 69 | 70 | // Getter and setter for cardSet field 71 | public function getCardSet() 72 | { 73 | return $this->cardSet; 74 | } 75 | public function setCardSet($cardSet) 76 | { 77 | $this->cardSet = $cardSet; 78 | } 79 | 80 | // Getter and setter for lastAction field 81 | public function getLastAction() 82 | { 83 | return $this->lastAction; 84 | } 85 | public function setLastAction($lastAction) 86 | { 87 | $this->lastAction = $lastAction; 88 | } 89 | 90 | // Getter and setter for members association 91 | public function getMembers() 92 | { 93 | return $this->members; 94 | } 95 | 96 | // Getter and setter for members association 97 | public function getPolls() 98 | { 99 | return $this->polls; 100 | } 101 | 102 | // Getter and setter for currentPoll field 103 | public function getCurrentPoll() 104 | { 105 | return $this->currentPoll; 106 | } 107 | public function setCurrentPoll($currentPoll) 108 | { 109 | $this->currentPoll = $currentPoll; 110 | } 111 | } -------------------------------------------------------------------------------- /src/templates/github_source.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
5 |
6 |
7 | 8 | 9 |
10 |
11 |
12 |
13 | 14 |
15 |
16 |
17 |
18 |
19 |
20 | 21 | 22 |
23 |
24 |
25 |
26 | 27 | 28 |
29 |
30 |
31 | 32 |
33 |
34 | 35 | 36 |
37 |
38 | 45 |
46 |
47 |
48 | {{ master.current.issue.user.login }} 49 |
50 |
51 |

#{{ master.current.issue.number }}: {{ master.current.issue.title }}

52 |
53 |
54 |
55 |

{{ label.name }}

56 |
57 | 58 |
59 |
60 | {{ master.current.issue.user.login }} created issue on {{ master.current.issue.created_at | date : "medium" }} 61 |
62 |
63 |
64 | 65 | 66 | 67 |
68 |
69 |
70 | -------------------------------------------------------------------------------- /src/templates/gitlab_source.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
5 |
6 |
7 | 8 | 9 |
10 |
11 |
12 |
13 | 14 | 15 |
16 |
17 |
18 |
19 |
20 |
21 | 22 |
23 |
24 |
25 |
26 | 27 | 28 |
29 |
30 |
31 | 32 |
33 |
34 | 35 | 36 |
37 |
38 | 45 |
46 |
47 |
48 | {{ master.current.issue.author.name }} 49 |
50 |
51 |

#{{ master.current.issue.iid }}: {{ master.current.issue.title }}

52 |
53 |
54 |
55 |

{{ label.name }}

56 |
57 | 58 |
59 |
60 | {{ master.current.issue.author.name }} created issue on {{ master.current.issue.created_at | date : "medium" }} 61 |
62 |
63 |
64 | 65 | 66 | 67 |
68 |
69 |
70 | -------------------------------------------------------------------------------- /src/model/poll.php: -------------------------------------------------------------------------------- 1 | startTime = new DateTime(); 11 | $this->endTime = new DateTime(); 12 | } 13 | 14 | /** @Id @Column(type="integer") @GeneratedValue **/ 15 | protected $id; 16 | 17 | /** @Column(type="string") **/ 18 | protected $topic; 19 | 20 | /** @Column(type="text") **/ 21 | protected $description; 22 | 23 | /** @Column(type="string") **/ 24 | protected $url; 25 | 26 | /** @Column(type="datetime") **/ 27 | protected $startTime; 28 | 29 | /** @Column(type="datetime") **/ 30 | protected $endTime; 31 | 32 | /** @Column(type="float") **/ 33 | protected $result = 0; 34 | 35 | /** @Column(type="boolean") **/ 36 | protected $consensus = false; 37 | 38 | /** @ManyToOne(targetEntity="Session", inversedBy="polls") **/ 39 | protected $session; 40 | 41 | /** @OneToMany(targetEntity="Vote", mappedBy="poll") **/ 42 | protected $votes; 43 | 44 | public function getId() 45 | { 46 | return $this->id; 47 | } 48 | 49 | // Getter and setter for topic field 50 | public function getTopic() 51 | { 52 | return $this->topic; 53 | } 54 | public function setTopic($topic) 55 | { 56 | $this->topic = $topic; 57 | } 58 | 59 | // Getter and setter for url field 60 | public function getUrl() 61 | { 62 | return $this->url; 63 | } 64 | public function setUrl($url) 65 | { 66 | $this->url = $url; 67 | } 68 | 69 | // Getter and setter for description field 70 | public function getDescription() 71 | { 72 | return $this->description; 73 | } 74 | public function setDescription($description) 75 | { 76 | $this->description = $description; 77 | } 78 | 79 | // Getter and setter for start time 80 | public function getStartTime() 81 | { 82 | return $this->startTime; 83 | } 84 | public function setStartTime($startTime) 85 | { 86 | $this->startTime = $startTime; 87 | } 88 | 89 | // Getter and setter for end time 90 | public function getEndTime() 91 | { 92 | return $this->endTime; 93 | } 94 | public function setEndTime($endTime) 95 | { 96 | $this->endTime = $endTime; 97 | } 98 | 99 | // Getter and setter for result field 100 | public function getResult() 101 | { 102 | return $this->result; 103 | } 104 | public function setResult($result) 105 | { 106 | $this->result = $result; 107 | } 108 | 109 | // Getter and setter for consensus field 110 | public function getConsensus() 111 | { 112 | return $this->consensus; 113 | } 114 | public function setConsensus($consensus) 115 | { 116 | $this->consensus = $consensus; 117 | } 118 | 119 | // Getter and setter for session field 120 | public function getSession() 121 | { 122 | return $this->session; 123 | } 124 | public function setSession($session) 125 | { 126 | $this->session = $session; 127 | $session->getPolls()->add($this); 128 | } 129 | 130 | // Getter and setter for votes association 131 | public function getVotes() 132 | { 133 | return $this->votes; 134 | } 135 | } -------------------------------------------------------------------------------- /src/templates/jira_source.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
5 |
{{master.current.error}}
6 |
7 | 8 |
9 |
10 |
11 | 12 | 13 |
14 |
15 |
16 |
17 | 18 | 19 |
20 |
21 |
22 | 23 |
24 |
25 |
26 | 27 | 28 |
29 |
30 |
31 |
32 | 33 | 34 |
35 |
36 |
37 |
38 |
39 |
40 | 41 | 42 |
43 |
44 |
45 | 46 |
47 |
48 | 49 | 50 |
51 |
52 |
53 | 54 |
55 |
56 |
57 | 64 |
65 |
66 |
67 | {{ master.current.issue.field.creator.displayName }} 68 |
69 |
70 |

{{ master.current.issue.key }}: {{ master.current.issue.fields.summary }}

71 |
72 |
73 | 74 |
75 |
76 | {{ master.current.issue.fields.creator.displayName }} created issue on {{ master.current.issue.fields.created | date : "medium" }} 77 |
78 |
79 |
80 | 81 | 82 | 83 |
84 |
85 |
86 | -------------------------------------------------------------------------------- /src/index.php: -------------------------------------------------------------------------------- 1 | $template) 11 | { 12 | if ($template->isNavigation) 13 | $navItems[$index] = $template; 14 | } 15 | ?> 16 | 17 | 18 | 19 | 20 | 21 | 22 | Online planning poker 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 44 | 45 | 46 | 47 | 52 | 53 | 54 | 74 | 75 | 76 |
77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | render(); 103 | } 104 | ?> 105 | 106 | 107 | -------------------------------------------------------------------------------- /src/bootstrap.php: -------------------------------------------------------------------------------- 1 | setAutoGenerateProxyClasses(AbstractProxyFactory::AUTOGENERATE_NEVER); 13 | $config->setProxyDir(__DIR__ . '/proxies'); 14 | 15 | // obtaining the entity manager 16 | $entityManager = EntityManager::create($conn, $config); 17 | 18 | // Load models 19 | require_once __DIR__ . "/model/member.php"; 20 | require_once __DIR__ . "/model/poll.php"; 21 | require_once __DIR__ . "/model/session.php"; 22 | require_once __DIR__ . "/model/vote.php"; 23 | 24 | /* 25 | * Base class for all controllers 26 | */ 27 | class ControllerBase 28 | { 29 | protected $entityManager; 30 | 31 | // Configured cards sets 32 | public $cardSets; 33 | 34 | function __construct($entityManager, $cardSets = []) 35 | { 36 | $this->entityManager = $entityManager; 37 | $this->cardSets = $cardSets; 38 | } 39 | 40 | // Get session by id 41 | protected function getSession($id) 42 | { 43 | $session = $this->entityManager->find("Session", $id); 44 | if($session == null) 45 | throw new Exception("Unknown session id!"); 46 | return $session; 47 | } 48 | 49 | // Get member by id 50 | protected function getMember($id) 51 | { 52 | $member = $this->entityManager->find("Member", $id); 53 | return $member; 54 | } 55 | 56 | // Get card set of the session 57 | protected function getCardSet($session) 58 | { 59 | return $this->cardSets[$session->getCardSet()]; 60 | } 61 | 62 | protected function jsonInput() 63 | { 64 | $post = file_get_contents('php://input'); 65 | $data = json_decode($post, true); 66 | 67 | return $data; 68 | } 69 | 70 | // Save only a single entity 71 | protected function save($entity) 72 | { 73 | $this->entityManager->persist($entity); 74 | $this->entityManager->flush(); 75 | } 76 | 77 | // Save an array of entities 78 | protected function saveAll(array $entities) 79 | { 80 | foreach($entities as $entity) 81 | { 82 | $this->entityManager->persist($entity); 83 | } 84 | $this->entityManager->flush(); 85 | } 86 | 87 | // Create a crypto hash for a secret using the name as salt 88 | protected function createHash($name, $password) 89 | { 90 | // Create a safe token from name, password and salt 91 | $token = crypt($name . $password, '$1$ScrumSalt'); 92 | $fragments = explode('$', $token); 93 | $hash = $fragments[sizeof($fragments) - 1]; 94 | // Bas64 contains weird characters, so use HEX instead 95 | $hash = bin2hex(base64_decode($hash)); 96 | return $hash; 97 | } 98 | 99 | // The cookie name of the token for a given session 100 | protected function tokenKey($id) 101 | { 102 | return 'session-token-' . $id; 103 | } 104 | 105 | // Make sure the caller has the necesarry token for the operation 106 | // $memberName indicates that a member token is sufficient for the operation 107 | // $privateOnly indicates that the token is only required for private sessions 108 | protected function verifyToken($session, $memberName = null, $privateOnly = false) 109 | { 110 | if ($this->tokenProvided($session, $memberName, $privateOnly)) 111 | return true; 112 | 113 | http_response_code(403); // Return HTTP 403 FORBIDDEN 114 | return false; 115 | } 116 | 117 | // Check if the token for this session was present in the request 118 | // Return true if it was and otherwise false 119 | protected function tokenProvided($session, $memberName = null, $privateOnly = false) 120 | { 121 | // Token only required for private sessions and this is a public one 122 | if ($privateOnly && $session->getIsPrivate() == false) 123 | return true; 124 | 125 | // Everything else requires a token 126 | $tokenKey = $this->tokenKey($session->getId()); 127 | if (!isset($_COOKIE[$tokenKey])) 128 | return false; 129 | 130 | // Verify token 131 | $token = $_COOKIE[$tokenKey]; 132 | if ($token == $session->getToken()) 133 | return true; 134 | 135 | // Alternatively compare membertoken if sufficient 136 | if ($memberName != null && $token == $this->createHash($memberName, $session->getToken())) 137 | return true; 138 | 139 | // Token required but not provided 140 | return false; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/templates/master.php: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 |
8 | 9 |
10 |
11 |

{{ master.id }} - {{ master.name }}

12 |
13 | 16 |
17 | 18 | 19 |
20 |
21 | 26 |
27 |
28 |
29 |
30 | 31 | 32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
?
40 |
41 |
42 |
43 |
44 |
45 |
46 | 47 |
48 |
49 |

50 |
51 |
52 |
53 |
54 | 55 | 56 |
57 | 69 | 70 | 71 |
72 |

Team

73 |
    74 | 75 |
  • {{$index + 1}}. {{member.name}}
  • 76 |
77 | 78 |
79 | 80 | 81 |
82 |
83 |
Statistics
84 |
85 |

Statistics will appear as soon as the first poll is concluded!

86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 102 | 103 | 104 | 105 | 106 | 111 | 112 | 113 | 114 |
EnabledNameValue
98 | 99 | {{ statistic.name }} 100 | 101 |
107 | 108 | Want more? 109 | 110 |
115 |
116 |
117 |
118 |
119 | -------------------------------------------------------------------------------- /src/templates/home.php: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 |
7 |

8 |

Scrum Online

9 | Welcome to my open source Planning Poker® web app. Use of this app is free of charge for everyone. As a scrum master just start a named session 10 | and invite your team to join you. It is recommended to display the scrum master view on the big screen (TV or projector) and let everyone else 11 | join via smartphone. To join a session just enter the id displayed in the heading of the scrum master view or use the QR-Code. 12 |

13 |
14 |
15 | 16 |
17 |

Create or join a session

18 | 19 | 20 |
21 |
22 |
Create session
23 |
24 |
25 |
26 | 27 |
28 | 29 | 30 |
31 |
32 |
33 | 34 | 45 |
46 |
47 | 48 |
49 |
50 | 51 |
52 | 53 | 54 |
55 |
56 | 57 |
58 |
59 |
60 |
61 | 62 | 63 |
64 |
65 |
Join session
66 |
67 |
68 |
69 | 70 |
71 | 72 | 73 |
74 |
75 |
76 | 77 |
'"> 78 | 79 | 80 |
81 |
82 |
83 | 84 |
85 | 86 | 87 |
88 |
89 | 90 |
91 |
92 |
93 |
94 | 95 |
96 | -------------------------------------------------------------------------------- /doc/Poker-Tutorial.md: -------------------------------------------------------------------------------- 1 | # Instructions 2 | This page has detailed instructions how to use the app. It is written for the perspective of the session owner, which will be the Scrum Master or Product Owner in most cases, but also includes instructions for the team members. 3 | 4 | ## Setup a Session 5 | The first step is to setup a new session using the Create Session from on the front page. Pick a name for your session. It does not have to be unique or special, it is mostly for the overview of running sessions and as a title for your session view. Next select a card set for your session from the drop-down box. Those are the card-sets that are most popular and some were contributed by other users. If you need a different card set just click the question mark next to the drop-down to navigate to the file and open a pull request on GitHub. 6 | 7 | After setting the name and card set for your session, you can choose to declare your session as private. Unlike public sessions they can only be observed if spectators provide the password. Private sessions also require joining members to either join through invite or session id and password. Once you completed the form press Create and you will be redirected to your new session. 8 | 9 | ## Invite Members 10 | After you created the session, it is time to invite members and start estimating stories. Your team members have three options to join the session: 11 | 12 | 1. **Session Id:** Members can join the session by entering the session id, displayed in the top right corner, on the main page. If you choose a private session you must also tell joining members the password for this joining process. 13 | 2. **Join URL:** In the bottom left of the session view, below the QR code is the URL to join the session. If you created the session the URL also contains the session token, which is necessary for authorization and access control. You can send this link to your team members to join the session. 14 | 3. **QR-Code:** The QR-Code equals the previously mentioned join ULR. It is only a convenience feature for teams who sit in the same room. Instead of typing the session id or copy&pasting the link your team members can use any QR-Code reader on their smartphones to quickly join the session. 15 | 16 | Independent from the method your team members pick, they all go through the Join Session form. After entering a member name, and in some cases the session password, they are redirected to the member view of your session. They can pick anything the want as a member name, it must however be unique within your session, otherwise it would not be possible to identify their votes later. In fact, it is not possible to have two members with the same name. Instead both members would then simly share the same view and overwrite each others vote in the process. 17 | 18 | The member view is optimized for mobile devices. It displays the title and description of the current poll at the top and all cards from the selected card set below. At the bottom of the page there is also a short explaination of the voting process. 19 | 20 | ## Load Stories (optional) 21 | Scrumpoker Online offers integrations for GitHub and JIRA, with more plugins under development. If you would like to use either one of those, select it from the tab control at the top and enter the necessary information to fetch issues from the server. Your credentials are not stored anywhere and only transmitted through an encrypted connection. If you are worried you can check for yourself on GitHub or follow the instructions to deploy the app on-premise. 22 | 23 | ## First Estimation 24 | To start the first estimation, enter topic and description of your feature or select an issue from the list, if you chose one the plugins in the previous step. As soon as you click Start, the poll begins and the stopwatch in the top right corner starts. Members of your team now see title and description of the current story on their devices and can start voting. 25 | 26 | Members of your team place votes by selecting on of the cards from their screen. The card is highlighted red to indicate the server is processing the vote. Once the vote was placed successfully the card is highlighted green. You will now see a card with a question mark (?) above that members name in your session view. Until the poll is completed and everyone has voted, members can still change their mind. They can retract their vote by pressing on the selected card again or simply select a different card. 27 | 28 | ## Poll Completed 29 | Once every member placed his vote, the poll is closed and the cards are flipped. At this time the stopwatch also stops and shows the overall estimation time. If the team directly reached a consensus, all cards are highlighted green to indicate a successful estimation. Otherwise the highest and lowest estimations are highlighted in red. The team members with the highlighted cards should now explain their decision. After all arguments were heared, you can simply restart the poll by clicking Start. This process is usually repeated until the team agres on a value. 30 | 31 | After you completed the first poll, the statistics are enabled. Statistics are shown in the table on the bottom left of the session view and are updated with every completed poll. You can enable and disable individual values depending on your interests. 32 | 33 | ## Wrapping up 34 | Once you are finished with the tasks for your next sprint or when the meeting is over, there is no need to close the session or "sign out". Simply close the window and go along with the rest of your day. If you estimate regularly in the same team constellation you can bookmark your session as well as each member login and return anytime. 35 | -------------------------------------------------------------------------------- /src/css/main.css: -------------------------------------------------------------------------------- 1 | /*! HTML5 Boilerplate v5.2.0 | MIT License | https://html5boilerplate.com/ */ 2 | 3 | /* 4 | * What follows is the result of much research on cross-browser styling. 5 | * Credit left inline and big thanks to Nicolas Gallagher, Jonathan Neal, 6 | * Kroc Camen, and the H5BP dev community and team. 7 | */ 8 | 9 | /* ========================================================================== 10 | Base styles: opinionated defaults 11 | ========================================================================== */ 12 | 13 | html { 14 | color: #222; 15 | font-size: 1em; 16 | line-height: 1.4; 17 | } 18 | 19 | /* 20 | * Remove text-shadow in selection highlight: 21 | * https://twitter.com/miketaylr/status/12228805301 22 | * 23 | * These selection rule sets have to be separate. 24 | * Customize the background color to match your design. 25 | */ 26 | 27 | ::-moz-selection { 28 | background: #b3d4fc; 29 | text-shadow: none; 30 | } 31 | 32 | ::selection { 33 | background: #b3d4fc; 34 | text-shadow: none; 35 | } 36 | 37 | /* 38 | * A better looking default horizontal rule 39 | */ 40 | 41 | hr { 42 | display: block; 43 | height: 1px; 44 | border: 0; 45 | border-top: 1px solid #ccc; 46 | margin: 1em 0; 47 | padding: 0; 48 | } 49 | 50 | /* 51 | * Remove the gap between audio, canvas, iframes, 52 | * images, videos and the bottom of their containers: 53 | * https://github.com/h5bp/html5-boilerplate/issues/440 54 | */ 55 | 56 | audio, 57 | canvas, 58 | iframe, 59 | img, 60 | svg, 61 | video { 62 | vertical-align: middle; 63 | } 64 | 65 | /* 66 | * Remove default fieldset styles. 67 | */ 68 | 69 | fieldset { 70 | border: 0; 71 | margin: 0; 72 | padding: 0; 73 | } 74 | 75 | /* 76 | * Allow only vertical resizing of textareas. 77 | */ 78 | 79 | textarea { 80 | resize: vertical; 81 | } 82 | 83 | /* ========================================================================== 84 | Browser Upgrade Prompt 85 | ========================================================================== */ 86 | 87 | .browserupgrade { 88 | margin: 0.2em 0; 89 | background: #ccc; 90 | color: #000; 91 | padding: 0.2em 0; 92 | } 93 | 94 | 95 | /* ========================================================================== 96 | Helper classes 97 | ========================================================================== */ 98 | 99 | /* 100 | * Hide visually and from screen readers: 101 | */ 102 | 103 | .hidden { 104 | display: none !important; 105 | } 106 | 107 | /* 108 | * Hide only visually, but have it available for screen readers: 109 | * http://snook.ca/archives/html_and_css/hiding-content-for-accessibility 110 | */ 111 | 112 | .visuallyhidden { 113 | border: 0; 114 | clip: rect(0 0 0 0); 115 | height: 1px; 116 | margin: -1px; 117 | overflow: hidden; 118 | padding: 0; 119 | position: absolute; 120 | width: 1px; 121 | } 122 | 123 | /* 124 | * Extends the .visuallyhidden class to allow the element 125 | * to be focusable when navigated to via the keyboard: 126 | * https://www.drupal.org/node/897638 127 | */ 128 | 129 | .visuallyhidden.focusable:active, 130 | .visuallyhidden.focusable:focus { 131 | clip: auto; 132 | height: auto; 133 | margin: 0; 134 | overflow: visible; 135 | position: static; 136 | width: auto; 137 | } 138 | 139 | /* 140 | * Hide visually and from screen readers, but maintain layout 141 | */ 142 | 143 | .invisible { 144 | visibility: hidden; 145 | } 146 | 147 | /* 148 | * Clearfix: contain floats 149 | * 150 | * For modern browsers 151 | * 1. The space content is one way to avoid an Opera bug when the 152 | * `contenteditable` attribute is included anywhere else in the document. 153 | * Otherwise it causes space to appear at the top and bottom of elements 154 | * that receive the `clearfix` class. 155 | * 2. The use of `table` rather than `block` is only necessary if using 156 | * `:before` to contain the top-margins of child elements. 157 | */ 158 | 159 | .clearfix:before, 160 | .clearfix:after { 161 | content: " "; /* 1 */ 162 | display: table; /* 2 */ 163 | } 164 | 165 | .clearfix:after { 166 | clear: both; 167 | } 168 | 169 | /* ========================================================================== 170 | EXAMPLE Media Queries for Responsive Design. 171 | These examples override the primary ('mobile first') styles. 172 | Modify as content requires. 173 | ========================================================================== */ 174 | 175 | @media only screen and (min-width: 35em) { 176 | /* Style adjustments for viewports that meet the condition */ 177 | } 178 | 179 | @media print, 180 | (-webkit-min-device-pixel-ratio: 1.25), 181 | (min-resolution: 1.25dppx), 182 | (min-resolution: 120dpi) { 183 | /* Style adjustments for high resolution devices */ 184 | } 185 | 186 | /* ========================================================================== 187 | Print styles. 188 | Inlined to avoid the additional HTTP request: 189 | http://www.phpied.com/delay-loading-your-print-css/ 190 | ========================================================================== */ 191 | 192 | @media print { 193 | *, 194 | *:before, 195 | *:after { 196 | background: transparent !important; 197 | color: #000 !important; /* Black prints faster: 198 | http://www.sanbeiji.com/archives/953 */ 199 | box-shadow: none !important; 200 | text-shadow: none !important; 201 | } 202 | 203 | a, 204 | a:visited { 205 | text-decoration: underline; 206 | } 207 | 208 | a[href]:after { 209 | content: " (" attr(href) ")"; 210 | } 211 | 212 | abbr[title]:after { 213 | content: " (" attr(title) ")"; 214 | } 215 | 216 | /* 217 | * Don't show links that are fragment identifiers, 218 | * or use the `javascript:` pseudo protocol 219 | */ 220 | 221 | a[href^="#"]:after, 222 | a[href^="javascript:"]:after { 223 | content: ""; 224 | } 225 | 226 | pre, 227 | blockquote { 228 | border: 1px solid #999; 229 | page-break-inside: avoid; 230 | } 231 | 232 | /* 233 | * Printing Tables: 234 | * http://css-discuss.incutio.com/wiki/Printing_Tables 235 | */ 236 | 237 | thead { 238 | display: table-header-group; 239 | } 240 | 241 | tr, 242 | img { 243 | page-break-inside: avoid; 244 | } 245 | 246 | img { 247 | max-width: 100% !important; 248 | } 249 | 250 | p, 251 | h2, 252 | h3 { 253 | orphans: 3; 254 | widows: 3; 255 | } 256 | 257 | h2, 258 | h3 { 259 | page-break-after: avoid; 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /src/js/J2M.js: -------------------------------------------------------------------------------- 1 | 2 | (function() { 3 | 4 | /** 5 | * Takes Jira markup and converts it to Markdown. 6 | * 7 | * https://jira.atlassian.com/secure/WikiRendererHelpAction.jspa?section=all 8 | * 9 | * @param {string} input - Jira markup text 10 | * @returns {string} - Markdown formatted text 11 | */ 12 | function toM(input) { 13 | 14 | input = input.replace(/^bq\.(.*)$/gm, function (match, content) { 15 | return '> ' + content + "\n"; 16 | }); 17 | 18 | input = input.replace(/([*_])(.*)\1/g, function (match,wrapper,content) { 19 | var to = (wrapper === '*') ? '**' : '*'; 20 | return to + content + to; 21 | }); 22 | 23 | // multi-level numbered list 24 | input = input.replace(/^((?:#|-|\+|\*)+) (.*)$/gm, function (match, level, content) { 25 | var len = 2; 26 | var prefix = '1.'; 27 | if (level.length > 1) { 28 | len = parseInt((level.length - 1) * 4) + 2; 29 | } 30 | 31 | // take the last character of the level to determine the replacement 32 | var prefix = level[level.length - 1]; 33 | if (prefix == '#') prefix = '1.'; 34 | 35 | return Array(len).join(" ") + prefix + ' ' + content; 36 | }); 37 | 38 | // headers, must be after numbered lists 39 | input = input.replace(/^h([0-6])\.(.*)$/gm, function (match,level,content) { 40 | return Array(parseInt(level) + 1).join('#') + content; 41 | }); 42 | 43 | input = input.replace(/\{\{([^}]+)\}\}/g, '`$1`'); 44 | input = input.replace(/\?\?((?:.[^?]|[^?].)+)\?\?/g, '$1'); 45 | input = input.replace(/\+([^+]*)\+/g, '$1'); 46 | input = input.replace(/\^([^^]*)\^/g, '$1'); 47 | input = input.replace(/~([^~]*)~/g, '$1'); 48 | input = input.replace(/-([^-]*)-/g, '-$1-'); 49 | 50 | input = input.replace(/\{code(:([a-z]+))?\}([^]*?)\{code\}/gm, '```$2$3```'); 51 | input = input.replace(/\{quote\}([^]*)\{quote\}/gm, function(match, content) { 52 | lines = content.split(/\r?\n/gm); 53 | 54 | for (var i = 0; i < lines.length; i++) { 55 | lines[i] = '> ' + lines[i]; 56 | } 57 | 58 | return lines.join("\n"); 59 | }); 60 | 61 | input = input.replace(/!([^\n\s]+)!/, '![]($1)'); 62 | input = input.replace(/\[([^|]+)\|(.+?)\]/g, '[$1]($2)'); 63 | input = input.replace(/\[(.+?)\]([^\(]+)/g, '<$1>$2'); 64 | 65 | input = input.replace(/{noformat}/g, '```'); 66 | input = input.replace(/{color:([^}]+)}([^]*?){color}/gm, '$2'); 67 | 68 | // Convert header rows of tables by splitting input on lines 69 | lines = input.split(/\r?\n/gm); 70 | lines_to_remove = [] 71 | for (var i = 0; i < lines.length; i++) { 72 | line_content = lines[i]; 73 | 74 | seperators = line_content.match(/\|\|/g); 75 | if (seperators != null) { 76 | lines[i] = lines[i].replace(/\|\|/g, "|"); 77 | console.log(seperators) 78 | 79 | // Add a new line to mark the header in Markdown, 80 | // we require that at least 3 -'s are between each | 81 | header_line = ""; 82 | for (var j = 0; j < seperators.length-1; j++) { 83 | header_line += "|---"; 84 | } 85 | 86 | header_line += "|"; 87 | 88 | lines.splice(i+1, 0, header_line); 89 | 90 | } 91 | } 92 | 93 | // Join the split lines back 94 | input = "" 95 | for (var i = 0; i < lines.length; i++) { 96 | input += lines[i] + "\n" 97 | } 98 | 99 | 100 | 101 | return input; 102 | }; 103 | 104 | /** 105 | * Takes Markdown and converts it to Jira formatted text 106 | * 107 | * @param {string} input 108 | * @returns {string} 109 | */ 110 | function toJ(input) { 111 | // remove sections that shouldn't be recursively processed 112 | var START = 'J2MBLOCKPLACEHOLDER'; 113 | var replacementsList = []; 114 | var counter = 0; 115 | 116 | input = input.replace(/`{3,}(\w+)?((?:\n|.)+?)`{3,}/g, function(match, synt, content) { 117 | var code = '{code'; 118 | 119 | if (synt) { 120 | code += ':' + synt; 121 | } 122 | 123 | code += '}' + content + '{code}'; 124 | var key = START + counter++ + '%%'; 125 | replacementsList.push({key: key, value: code}); 126 | return key; 127 | }); 128 | 129 | input = input.replace(/`([^`]+)`/g, function(match, content) { 130 | var code = '{{'+ content + '}}'; 131 | var key = START + counter++ + '%%'; 132 | replacementsList.push({key: key, value: code}); 133 | return key; 134 | }); 135 | 136 | input = input.replace(/`([^`]+)`/g, '{{$1}}'); 137 | 138 | input = input.replace(/^(.*?)\n([=-])+$/gm, function (match,content,level) { 139 | return 'h' + (level[0] === '=' ? 1 : 2) + '. ' + content; 140 | }); 141 | 142 | input = input.replace(/^([#]+)(.*?)$/gm, function (match,level,content) { 143 | return 'h' + level.length + '.' + content; 144 | }); 145 | 146 | input = input.replace(/([*_]+)(.*?)\1/g, function (match,wrapper,content) { 147 | var to = (wrapper.length === 1) ? '_' : '*'; 148 | return to + content + to; 149 | }); 150 | 151 | // multi-level bulleted list 152 | input = input.replace(/^(\s*)- (.*)$/gm, function (match,level,content) { 153 | var len = 2; 154 | if(level.length > 0) { 155 | len = parseInt(level.length/4.0) + 2; 156 | } 157 | return Array(len).join("-") + ' ' + content; 158 | }); 159 | 160 | // multi-level numbered list 161 | input = input.replace(/^(\s+)1. (.*)$/gm, function (match, level, content) { 162 | var len = 2; 163 | if (level.length > 1) { 164 | len = parseInt(level.length / 4) + 2; 165 | } 166 | return Array(len).join("#") + ' ' + content; 167 | }); 168 | 169 | var map = { 170 | cite: '??', 171 | del: '-', 172 | ins: '+', 173 | sup: '^', 174 | sub: '~' 175 | }; 176 | 177 | input = input.replace(new RegExp('<(' + Object.keys(map).join('|') + ')>(.*?)<\/\\1>', 'g'), function (match,from,content) { 178 | //console.log(from); 179 | var to = map[from]; 180 | return to + content + to; 181 | }); 182 | 183 | input = input.replace(/([^]*?)<\/span>/gm, '{color:$1}$2{color}'); 184 | 185 | input = input.replace(/~~(.*?)~~/g, '-$1-'); 186 | 187 | input = input.replace(/!\[[^\]]+\]\(([^)]+)\)/g, '!$1!'); 188 | input = input.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '[$1|$2]'); 189 | input = input.replace(/<([^>]+)>/g, '[$1]'); 190 | 191 | // restore extracted sections 192 | for(var i =0; i < replacementsList.length; i++){ 193 | var sub = replacementsList[i]; 194 | input = input.replace(sub["key"], sub["value"]); 195 | } 196 | 197 | // Convert header rows of tables by splitting input on lines 198 | lines = input.split(/\r?\n/gm); 199 | lines_to_remove = [] 200 | for (var i = 0; i < lines.length; i++) { 201 | line_content = lines[i]; 202 | 203 | if (line_content.match(/\|---/g) != null) { 204 | lines[i-1] = lines[i-1].replace(/\|/g, "||") 205 | lines.splice(i, 1) 206 | } 207 | } 208 | 209 | // Join the split lines back 210 | input = "" 211 | for (var i = 0; i < lines.length; i++) { 212 | input += lines[i] + "\n" 213 | } 214 | return input; 215 | }; 216 | 217 | 218 | /** 219 | * Exports object 220 | * @type {{toM: toM, toJ: toJ}} 221 | */ 222 | var J2M = { 223 | toM: toM, 224 | toJ: toJ 225 | }; 226 | 227 | // exporting that can be used in a browser and in node 228 | try { 229 | window.J2M = J2M; 230 | } catch (e) { 231 | // not a browser, we assume it is node 232 | module.exports = J2M; 233 | } 234 | })(); 235 | -------------------------------------------------------------------------------- /src/css/scrumonline.css: -------------------------------------------------------------------------------- 1 | /* ========================================================================== 2 | Scrum poker style 3 | ========================================================================== */ 4 | 5 | /* Container */ 6 | .container-fluid.main, .container-fluid.navigation { 7 | padding-left: 10px; 8 | padding-right: 10px; 9 | } 10 | 11 | .container-fluid.main { 12 | min-height: 800px; 13 | padding-top: 55px; 14 | } 15 | 16 | footer { 17 | font-size: 14px; 18 | text-align: center; 19 | } 20 | @media (min-width: 992px) { 21 | .container-fluid.main, .container-fluid.navigation { 22 | padding-left: 50px; 23 | padding-right: 50px; 24 | } 25 | 26 | .container-fluid.main { 27 | padding-top: 80px; 28 | } 29 | } 30 | @media (min-width: 1200px) { 31 | .container-fluid.main, .container-fluid.navigation { 32 | padding-left: 150px; 33 | padding-right: 150px; 34 | } 35 | } 36 | @media (min-width: 1500px) { 37 | .container-fluid.main, .container-fluid.navigation { 38 | padding-left: 220px; 39 | padding-right: 220px; 40 | } 41 | } 42 | 43 | div.row.topic { 44 | margin-bottom: 20px; 45 | } 46 | button.wipe { 47 | margin-top: 10.75px; 48 | } 49 | div.topic .form-control { 50 | width: 280px; 51 | } 52 | @media(min-width: 1200px) 53 | { 54 | div.topic .form-control { 55 | width: 500px; 56 | } 57 | } 58 | 59 | .selectable { 60 | cursor: pointer; 61 | } 62 | 63 | /* 64 | * Story style 65 | */ 66 | form.storysetter { 67 | margin-top: 10px; 68 | } 69 | 70 | /* 71 | * Ticketing 72 | */ 73 | div.ticketing { 74 | padding: 10px; 75 | border-width: 0px, 1px, 1px, 1px; 76 | } 77 | 78 | 79 | /* 80 | * Cards style 81 | */ 82 | div.card-container { 83 | margin-bottom: 20px; 84 | } 85 | 86 | div.card-container h2 { 87 | font-weight: bold; 88 | width: 100px; 89 | min-height: 70px; 90 | text-align: center; 91 | } 92 | @media(min-width: 992px){ 93 | div.card-container h2 { 94 | width: 130px; 95 | } 96 | } 97 | 98 | div.card { 99 | border: 2px solid #0F1593; 100 | border-radius: 15px; 101 | margin-left: auto; 102 | margin-right: auto; 103 | } 104 | div.card .inner { 105 | margin: 3px 3px 0; 106 | border-radius: 12px; 107 | background-image: linear-gradient(to bottom, #0F1593, #161ec9); 108 | background-image: -webkit-linear-gradient(top, #0F1593, #161ec9); 109 | text-align: center; 110 | } 111 | 112 | span.card-label { 113 | display: block; 114 | font-size: 2em; 115 | font-weight: bold; 116 | padding: 50px 0 50px; 117 | color: #FFFFFF; 118 | } 119 | @media (min-width: 992px) { 120 | span.card-label { 121 | padding: 75px 0 75px; 122 | } 123 | } 124 | 125 | div.card, div.card-flip { 126 | height: 150px; 127 | width: 100px; 128 | } 129 | @media (min-width: 992px) { 130 | div.card, div.card-flip { 131 | height: 200px; 132 | width: 130px; 133 | } 134 | } 135 | 136 | 137 | /* Card flip */ 138 | div.card-overview { 139 | perspective: 1000px; 140 | } 141 | div.card-overview:first-child { 142 | margin-left: 15px; 143 | } 144 | div.card-overview div.card-container { 145 | float: left; 146 | width: 33%; 147 | } 148 | @media(min-width: 768px) { 149 | div.card-overview { 150 | margin-right: -40px; 151 | } 152 | div.card-overview div.card-container { 153 | width: 160px; 154 | } 155 | } 156 | @media(min-width: 992px) { 157 | div.card-overview { 158 | margin-right: -60px; 159 | } 160 | div.card-overview div.card-container { 161 | width: 195px; 162 | } 163 | } 164 | @media(min-width: 1200px) { 165 | div.card-overview div.card-container { 166 | width: 210px; 167 | } 168 | } 169 | div.card.front, div.card.back { 170 | -webkit-backface-visibility: hidden; 171 | backface-visibility: hidden; 172 | position: absolute; 173 | top: 15px; 174 | left: 0; 175 | } 176 | div.deletable-card{ 177 | position: relative; 178 | height: 212px; 179 | width: 142px; 180 | } 181 | div.card.back { 182 | transform: rotateY(180deg); 183 | } 184 | div.card-flip{ 185 | transition: 1s; 186 | transform-style: preserve-3d; 187 | position: relative; 188 | } 189 | div.card-flip.flipped { 190 | transform: rotateY(180deg); 191 | } 192 | 193 | /* Selectable card */ 194 | div.card.active { 195 | box-shadow: 0px 0px 20px 5px #c80020; 196 | } 197 | div.card.confirmed { 198 | box-shadow: 0px 0px 20px 5px #7ec500; 199 | } 200 | button.vote { 201 | width: 100%; 202 | } 203 | div.delete-member { 204 | top: 3px; 205 | left: 85px; 206 | height: 25px; 207 | width: 25px; 208 | } 209 | 210 | div.leave { 211 | top: 10px; 212 | left: 5px; 213 | height: 40px; 214 | width: 40px; 215 | } 216 | 217 | div.remove { 218 | display: block; 219 | position: absolute; 220 | color: white; 221 | text-shadow: 0 1px rgba(0, 0, 0, 0.25); 222 | border: 1px solid; 223 | border-radius: 3px; 224 | box-shadow: inset 0 1px rgba(255, 255, 255, 0.3), 0 1px 1px rgba(0, 0, 0, 0.08); 225 | -webkit-box-shadow: inset 0 1px rgba(255, 255, 255, 0.3), 0 1px 1px rgba(0, 0, 0, 0.08); 226 | background: #fa623f; 227 | border-color: #fa5a35; 228 | background-image: linear-gradient(to bottom, #fc9f8a, #fa623f); 229 | background-image: -webkit-linear-gradient(top, #fc9f8a, #fa623f); 230 | } 231 | 232 | div.remove span { 233 | position: absolute; 234 | top: 50%; 235 | left: 50%; 236 | transform: translate(-50%, -50%); 237 | } 238 | 239 | div.delete-member:hover { 240 | /* color:??; color is missing here */ 241 | } 242 | 243 | @media (min-width: 992px) { 244 | div.delete-member { 245 | left: 115px; 246 | } 247 | } 248 | 249 | /* 250 | * Session style 251 | */ 252 | span.session-private { 253 | width: 10%; 254 | text-align: left; 255 | display: inline-block; 256 | } 257 | span.session-list { 258 | width: 27%; 259 | display: inline-block; 260 | } 261 | span.session-list.left { 262 | text-align: left; 263 | } 264 | span.session-list.center { 265 | text-align: center; 266 | } 267 | span.session-list.right { 268 | text-align: right; 269 | } 270 | 271 | /* 272 | * Optimize space usage on mobile with smaller member column 273 | */ 274 | @media (max-width: 425px) { 275 | span.session-private { 276 | width: 16px; 277 | } 278 | span.session-list.left { 279 | width: 30%; 280 | } 281 | span.session-list.right { 282 | width: 42%; 283 | } 284 | .list-group-item.slim { 285 | padding: 5px; 286 | } 287 | span.session-list.center { 288 | text-align: left; 289 | width: 15%; 290 | } 291 | } 292 | 293 | /* 294 | * Removal styling 295 | */ 296 | div.removal { 297 | margin-top: 150px; 298 | } 299 | 300 | /* 301 | * Plugin styles 302 | */ 303 | div.issue-list { 304 | max-height: 800px; 305 | overflow-y: scroll; 306 | } 307 | 308 | .github-fork-ribbon-wrapper { 309 | width: 150px; 310 | height: 150px; 311 | position: fixed; 312 | overflow: hidden; 313 | top: 0; 314 | z-index: 9999; 315 | pointer-events: none; 316 | right: 0; 317 | } 318 | 319 | .github-fork-ribbon-wrapper .github-fork-ribbon { 320 | position: absolute; 321 | padding: 2px 0; 322 | background-color: #333; 323 | background-image: linear-gradient(to bottom, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.15)); 324 | -webkit-box-shadow: 0 2px 3px 0 rgba(0, 0, 0, 0.5); 325 | -moz-box-shadow: 0 2px 3px 0 rgba(0, 0, 0, 0.5); 326 | box-shadow: 0 2px 3px 0 rgba(0, 0, 0, 0.5); 327 | z-index: 9999; 328 | pointer-events: auto; 329 | top: 42px; 330 | right: -43px; 331 | -webkit-transform: rotate(45deg); 332 | -moz-transform: rotate(45deg); 333 | -ms-transform: rotate(45deg); 334 | -o-transform: rotate(45deg); 335 | transform: rotate(45deg); 336 | } 337 | 338 | .github-fork-ribbon-wrapper .github-fork-ribbon a { 339 | font: 700 13px "Helvetica Neue", Helvetica, Arial, sans-serif; 340 | color: #fff; 341 | text-decoration: none; 342 | text-shadow: 0 -1px rgba(0, 0, 0, 0.5); 343 | text-align: center; 344 | width: 200px; 345 | line-height: 20px; 346 | display: inline-block; 347 | padding: 2px 0; 348 | border-width: 1px 0; 349 | border-style: dotted; 350 | border-color: rgba(255, 255, 255, 0.7); 351 | } -------------------------------------------------------------------------------- /src/controllers/session-controller.php: -------------------------------------------------------------------------------- 1 | entityManager->createQuery('SELECT s.id, s.name, s.isPrivate, s.token, count(m.id) memberCount FROM Session s LEFT JOIN s.members m WHERE s.lastAction > ?1 GROUP BY s.id'); 13 | $query->setParameter(1, new DateTime('-1 hour')); 14 | $sessions = $query->getArrayResult(); 15 | 16 | // Determine password requirement for each session 17 | foreach($sessions as &$session) { 18 | $tokenKey = $this->tokenKey($session["id"]); 19 | $session["requiresPassword"] = $session["isPrivate"] 20 | && (!isset($_COOKIE[$tokenKey]) || $_COOKIE[$tokenKey] !== $session["token"]); 21 | // Remove token from the response again 22 | unset($session["token"]); 23 | } 24 | 25 | return $sessions; 26 | } 27 | 28 | // Create session with name and private flag 29 | // URL: /api/session/create 30 | public function create() 31 | { 32 | $data = $this->jsonInput(); 33 | 34 | $session = new Session(); 35 | $session->setName($data["name"]); 36 | $session->setCardSet($data["cardSet"]); 37 | 38 | // Generate the access token and assign it to the session 39 | $private = $data["isPrivate"]; 40 | $session->setIsPrivate($private); 41 | if ($private) 42 | $token = $this->createHash($data["name"], $data["password"]); 43 | else 44 | $token = $this->createHash($data["name"], $this->randomKey()); 45 | $session->setToken($token); 46 | 47 | $session->setLastAction(new DateTime()); 48 | 49 | $this->save($session); 50 | 51 | $this->setCookie($session); 52 | 53 | return new NumericResponse($session->getId()); 54 | } 55 | 56 | // Generate a random key for the public session token 57 | private function randomKey() 58 | { 59 | if (PHP_MAJOR_VERSION >= 7) 60 | $bytes = random_bytes(8); 61 | else 62 | $bytes = openssl_random_pseudo_bytes(8); 63 | return bin2hex($bytes); 64 | } 65 | 66 | // Add or remove member 67 | // URL: /api/session/member/{id}/?{mid} 68 | public function member($sessionId, $memberId = 0) 69 | { 70 | $method = $_SERVER['REQUEST_METHOD']; 71 | if ($method == "PUT") 72 | { 73 | return $this->addMember($sessionId); 74 | } 75 | if ($method == "DELETE") 76 | { 77 | $this->removeMember($memberId); 78 | } 79 | } 80 | 81 | // Add a member with this name to the session 82 | private function addMember($id) 83 | { 84 | $data = $this->jsonInput(); 85 | $name = $data["name"]; 86 | 87 | $session = $this->getSession($id); 88 | $token = $session->getToken(); 89 | $tokenKey = $this->tokenKey($session->getId()); 90 | 91 | // Check for existing member 92 | foreach($session->getMembers() as $candidate) 93 | { 94 | if($candidate->getName() == $name) 95 | { 96 | $member = $candidate; 97 | break; 98 | } 99 | } 100 | 101 | // This blocks check different ways to be granted access 102 | if ($this->tokenProvided($session, $name)) { 103 | // The user already has the token so we do nothing and continue 104 | } else if(isset($_GET["token"]) && $_GET["token"] == $token) { 105 | // User supplied the token 106 | $this->setCookie($session); 107 | } else if(isset($data["password"]) && $token === $this->createHash($session->getName(), $data["password"])) { 108 | // Or the password 109 | $this->setCookie($session); 110 | } else if ($session->getIsPrivate() == false && !isset($member)) { 111 | // Tokens are only required to join private sessions, 112 | // but without the token the rights are restricted by a member-only token 113 | // unless this member already exists 114 | $memberToken = $this->createHash($name, $token); 115 | $this->setCookie($session, $memberToken); 116 | } else { 117 | // Return access forbidden otherwise 118 | http_response_code(403); // Return HTTP 403 FORBIDDEN 119 | return; 120 | } 121 | 122 | // Create new member 123 | if(!isset($member)) 124 | { 125 | $member = new Member(); 126 | $member->setName($name); 127 | $member->setSession($session); 128 | $session->setLastAction(new DateTime()); 129 | 130 | $this->saveAll([$member, $session]); 131 | } 132 | 133 | // Set name cookie for faster login next time 134 | setcookie('scrum_member_name', $name, time()+60*60*24*30, "/"); 135 | 136 | // Create response 137 | $result = new stdClass(); 138 | $result->sessionId = $id; 139 | $result->memberId = $member->getId(); 140 | return $result; 141 | } 142 | 143 | // Remove member from session 144 | private function removeMember($id) 145 | { 146 | // Get member and session 147 | $member = $this->getMember($id); 148 | $session = $member->getSession(); 149 | 150 | if (!$this->verifyToken($session, $member->getName())) 151 | return; 152 | 153 | // Get and remove member 154 | $this->entityManager->remove($member); 155 | $this->entityManager->flush(); 156 | 157 | // Reevaluate the current poll 158 | include __DIR__ . "/session-evaluation.php"; 159 | $poll = $session->getCurrentPoll(); 160 | if($poll !== null && SessionEvaluation::evaluatePoll($session, $poll)) 161 | { 162 | $cardSet = $this->getCardSet($session); 163 | SessionEvaluation::highlightVotes($session, $poll, $cardSet); 164 | } 165 | 166 | // Update session to trigger polling 167 | $session->setLastAction(new DateTime()); 168 | $this->save($session); 169 | 170 | $this->entityManager->flush(); 171 | } 172 | 173 | // Check if session is protected by password 174 | // This only returns true of the requesting user does not 175 | // have the token cookie 176 | // URL: /api/session/requiresPassword/{id} 177 | public function requiresPassword($id = 0) 178 | { 179 | $session = $this->getSession($id); 180 | $requires = $session->getIsPrivate() && !$this->tokenProvided($session); 181 | return new BoolResponse($requires); 182 | } 183 | 184 | // Check if member is still part of the session 185 | // URL: /api/session/membercheck/{id}/{mid} 186 | public function membercheck($sid, $mid) 187 | { 188 | try{ 189 | $session = $this->getSession($sid); 190 | } 191 | catch(Exception $e){ 192 | return new BoolResponse(); 193 | } 194 | foreach($session->getMembers() as $member) { 195 | if($member->getId() == $mid) { 196 | return new BoolResponse(true); 197 | } 198 | } 199 | return new BoolResponse(); 200 | } 201 | 202 | // Check given password for a session 203 | // URL: /api/session/check/{id} 204 | public function check($id) 205 | { 206 | $data = $this->jsonInput(); 207 | $session = $this->getSession($id); 208 | $result = $session->getToken() === $this->createHash($session->getName(), $data["password"]); 209 | 210 | // If the correct password was transmitted we grant the token as a reward 211 | if ($result) 212 | $this->setCookie($session); 213 | 214 | return new BoolResponse($result); 215 | } 216 | 217 | // Get the card set of this session 218 | // URL: /api/session/cardset/{id} 219 | public function cardset($id) 220 | { 221 | $session = $this->getSession($id); 222 | return $this->getCardSet($session); 223 | } 224 | 225 | // Get the card set of this session 226 | // URL: /api/session/cardsets 227 | public function cardsets() 228 | { 229 | return $this->cardSets; 230 | } 231 | 232 | // Wipe all data from the session 233 | // URL: /api/session/wipe/{id} 234 | public function wipe($id) 235 | { 236 | // Fetch session and verify token 237 | $session = $this->getSession($id); 238 | if (!$this->verifyToken($session)) 239 | return; 240 | // Clear and wipe polls 241 | $session->setCurrentPoll(null); 242 | foreach($session->getPolls() as $poll) 243 | $this->entityManager->remove($poll); 244 | // Wipe all members 245 | foreach($session->getMembers() as $member) 246 | $this->entityManager->remove($member); 247 | $this->entityManager->flush(); 248 | // Remove session object 249 | $this->entityManager->remove($session); 250 | $this->entityManager->flush(); 251 | } 252 | 253 | // Set the token cookie for this session 254 | // with additional parameters for expiration and path 255 | private function setCookie($session, $token = null) 256 | { 257 | $tokenKey = $this->tokenKey($session->getId()); 258 | 259 | if ($token == null) 260 | $token = $session->getToken(); 261 | 262 | setcookie($tokenKey, $token, time()+60*60*24*30, "/"); 263 | } 264 | } 265 | 266 | return new SessionController($entityManager, $cardSets); 267 | -------------------------------------------------------------------------------- /src/controllers/poll-controller.php: -------------------------------------------------------------------------------- 1 | getCardSet($session); 11 | return array_flip($cardSet)[$voteValue]; 12 | } 13 | 14 | // Get value of session and index 15 | private function getValue($session, $vote) 16 | { 17 | $cardSet = $this->getCardSet($session); 18 | $value = $cardSet[$vote->getValue()]; 19 | return intval($value); 20 | } 21 | 22 | // Check if the session as changed since the last polling call 23 | private function sessionUnchanged($session) 24 | { 25 | // Check if anything changed since the last polling call 26 | return isset($_GET['last']) && $_GET['last'] >= $session->getLastAction()->getTimestamp(); 27 | } 28 | 29 | // Place or delete a vote for the current poll 30 | // URL: /api/poll/vote/{id}/{mid} 31 | public function vote($sessionId, $memberId) 32 | { 33 | // Fetch entities 34 | $session = $this->getSession($sessionId); 35 | $member = $this->getMember($memberId); 36 | $currentPoll = $session->getCurrentPoll(); 37 | 38 | // Validate token before performing action 39 | if (!$this->verifyToken($session, $member->getName())) 40 | return; 41 | 42 | // Reject votes if poll is completed 43 | if($currentPoll == null || $currentPoll->getResult() > 0) 44 | throw new Exception("Can not modify non-existing or completed poll!"); 45 | 46 | $method = $_SERVER['REQUEST_METHOD']; 47 | if ($method == "POST") 48 | $this->placeVote($session, $currentPoll, $member); 49 | else if ($method == "DELETE") 50 | $this->deleteVote($session, $currentPoll, $member); 51 | } 52 | 53 | // Place a new vote 54 | private function placeVote($session, $currentPoll, $member) 55 | { 56 | include __DIR__ . "/session-evaluation.php"; 57 | 58 | // Find or create vote 59 | foreach($currentPoll->getVotes() as $vote) 60 | { 61 | if($vote->getMember() != $member) 62 | continue; 63 | 64 | $match = $vote; 65 | break; 66 | } 67 | 68 | // Create vote if not found 69 | if(!isset($match)) 70 | { 71 | $match = new Vote(); 72 | $match->setPoll($currentPoll); 73 | $match->setMember($member); 74 | } 75 | 76 | // Set value 77 | $voteValue = $data = $this->jsonInput()["vote"]; 78 | $voteIndex = $this->getIndex($session, $voteValue); 79 | $match->setValue($voteIndex); 80 | 81 | // Evaluate the poll 82 | if(SessionEvaluation::evaluatePoll($session, $currentPoll)) 83 | { 84 | $cardSet = $this->getCardSet($session); 85 | SessionEvaluation::highlightVotes($session, $currentPoll, $cardSet); 86 | } 87 | 88 | // Save all to db 89 | $session->setLastAction(new DateTime()); 90 | $this->saveAll([$session, $match, $currentPoll]); 91 | $this->saveAll($currentPoll->getVotes()->toArray()); 92 | } 93 | 94 | // Delete an already placed vote 95 | private function deleteVote($session, $currentPoll, $member) 96 | { 97 | // Find the vote of this member 98 | foreach($currentPoll->getVotes() as $vote) 99 | { 100 | if ($vote->getMember() == $member) 101 | { 102 | $match = $vote; 103 | break; 104 | } 105 | } 106 | 107 | if (!isset($match)) 108 | return; 109 | 110 | // Remove vote and update timestamp 111 | $this->entityManager->remove($match); 112 | $session->setLastAction(new DateTime()); 113 | $this->save($session); 114 | $this->entityManager->flush(); 115 | } 116 | 117 | // Wrap up current poll in reponse object 118 | // URL: /api/poll/current/{id} 119 | public function current($sessionId) 120 | { 121 | // Load the user-vote.php required for this 122 | include __DIR__ . "/user-vote.php"; 123 | 124 | // Create reponse object 125 | $response = new stdClass(); 126 | $session = $this->getSession($sessionId); 127 | 128 | // Check if anything changed since the last polling call 129 | if($this->sessionUnchanged($session)) 130 | { 131 | $response->unchanged = true; 132 | return $response; 133 | } 134 | 135 | // Validate token only to access the topic of private sessions 136 | if (!$this->verifyToken($session, null, true)) 137 | return; 138 | 139 | // Fill response object 140 | $response->name = $session->getName(); 141 | $response->timestamp = $session->getLastAction()->getTimestamp(); 142 | $response->votes = array(); 143 | // Include votes in response 144 | $currentPoll = $session->getCurrentPoll(); 145 | if ($currentPoll == null) 146 | { 147 | $response->topic = ""; 148 | $response->description = ""; 149 | $response->url = ""; 150 | $response->flipped = false; 151 | $response->consensus = false; 152 | } 153 | else 154 | { 155 | $response->topic = $currentPoll->getTopic(); 156 | $response->description = $currentPoll->getDescription(); 157 | $response->url = $currentPoll->getUrl(); 158 | $response->flipped = $currentPoll->getResult() >= 0; 159 | $response->consensus = $currentPoll->getConsensus(); 160 | 161 | $diff = $currentPoll->getEndTime()->diff($currentPoll->getStartTime()); 162 | $response->duration = $diff; 163 | } 164 | 165 | // Members votes 166 | $cardSet = $this->getCardSet($session); 167 | $query = $this->entityManager 168 | ->createQuery('SELECT m.id, m.name, v.value, v.highlighted FROM \Member m LEFT JOIN m.votes v WITH (v.member = m AND v.poll = ?1) WHERE m.session = ?2') 169 | ->setParameter(1, $currentPoll) 170 | ->setParameter(2, $session); 171 | $result = $query->getArrayResult(); 172 | foreach($result as $vote) 173 | { 174 | $userVote = UserVote::fromQuery($cardSet, $vote); 175 | $userVote->canDelete = $this->tokenProvided($session, $userVote->name); 176 | $response->votes[] = $userVote; 177 | } 178 | 179 | return $response; 180 | } 181 | 182 | // Get or set topic of the current poll 183 | public function topic($sessionId) 184 | { 185 | $session = $this->getSession($sessionId); 186 | 187 | $method = $_SERVER['REQUEST_METHOD']; 188 | if ($method == "POST") 189 | { 190 | $data = $this->jsonInput(); 191 | $this->startPoll($session, $data["topic"], $data["description"], $data["url"]); 192 | return null; 193 | } 194 | 195 | $result = new stdClass(); 196 | // Check if anything changed since the last polling call 197 | if($this->sessionUnchanged($session)) 198 | { 199 | $result->unchanged = true; 200 | return $result; 201 | } 202 | 203 | // Reading a sessions topic is only protected for private sessions 204 | if (!$this->verifyToken($session, null, true)) 205 | return; 206 | 207 | $currentPoll = $session->getCurrentPoll(); 208 | 209 | // Result object. Only votable until all votes received 210 | $result->timestamp = $session->getLastAction()->getTimestamp(); 211 | if ($currentPoll == null) 212 | { 213 | $result->topic = "No topic"; 214 | $result->description = ""; 215 | $result->url = ""; 216 | $result->votable = false; 217 | } 218 | else 219 | { 220 | $result->topic = $currentPoll->getTopic(); 221 | $result->description = $currentPoll->getDescription(); 222 | $result->url = $currentPoll->getUrl(); 223 | $result->votable = $currentPoll->getResult() < 0; 224 | } 225 | 226 | return $result; 227 | } 228 | 229 | // Start a new poll in the session 230 | private function startPoll($session, $topic, $description, $url) 231 | { 232 | // Only the sessions main token holder can start a poll 233 | if (!$this->verifyToken($session)) 234 | return; 235 | 236 | // Start new poll 237 | $poll = new Poll(); 238 | $poll->setTopic($topic); 239 | $poll->setDescription($description); 240 | $poll->setUrl($url); 241 | $poll->setSession($session); 242 | $poll->setResult(-1); 243 | 244 | // Update session 245 | $session->setLastAction(new DateTime()); 246 | $session->setCurrentPoll($poll); 247 | 248 | // Save changes 249 | $this->saveAll([$session, $poll]); 250 | } 251 | 252 | // Close current poll and ignore missing votes 253 | // URL: /api/poll/close/{id} 254 | public function close($sessionId) 255 | { 256 | $session = $this->getSession($sessionId); 257 | $currentPoll = $session->getCurrentPoll(); 258 | // No poll or already closed, nothing to do 259 | if ($currentPoll == null || $currentPoll->getResult() >= 0) 260 | return; 261 | 262 | // Load the SessionEvaluation required for this 263 | include __DIR__ . "/session-evaluation.php"; 264 | 265 | // Force poll evaluation 266 | if(SessionEvaluation::evaluatePoll($session, $currentPoll, true)) 267 | { 268 | $cardSet = $this->getCardSet($session); 269 | SessionEvaluation::highlightVotes($session, $currentPoll, $cardSet); 270 | } 271 | 272 | // Save all to db 273 | $session->setLastAction(new DateTime()); 274 | $this->saveAll([$session, $currentPoll]); 275 | $this->saveAll($currentPoll->getVotes()->toArray()); 276 | } 277 | } 278 | 279 | return new PollController($entityManager, $cardSets); 280 | -------------------------------------------------------------------------------- /src/css/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /** 4 | * 1. Set default font family to sans-serif. 5 | * 2. Prevent iOS and IE text size adjust after device orientation change, 6 | * without disabling user zoom. 7 | */ 8 | 9 | html { 10 | font-family: sans-serif; /* 1 */ 11 | -ms-text-size-adjust: 100%; /* 2 */ 12 | -webkit-text-size-adjust: 100%; /* 2 */ 13 | } 14 | 15 | /** 16 | * Remove default margin. 17 | */ 18 | 19 | body { 20 | margin: 0; 21 | } 22 | 23 | /* HTML5 display definitions 24 | ========================================================================== */ 25 | 26 | /** 27 | * Correct `block` display not defined for any HTML5 element in IE 8/9. 28 | * Correct `block` display not defined for `details` or `summary` in IE 10/11 29 | * and Firefox. 30 | * Correct `block` display not defined for `main` in IE 11. 31 | */ 32 | 33 | article, 34 | aside, 35 | details, 36 | figcaption, 37 | figure, 38 | footer, 39 | header, 40 | hgroup, 41 | main, 42 | menu, 43 | nav, 44 | section, 45 | summary { 46 | display: block; 47 | } 48 | 49 | /** 50 | * 1. Correct `inline-block` display not defined in IE 8/9. 51 | * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. 52 | */ 53 | 54 | audio, 55 | canvas, 56 | progress, 57 | video { 58 | display: inline-block; /* 1 */ 59 | vertical-align: baseline; /* 2 */ 60 | } 61 | 62 | /** 63 | * Prevent modern browsers from displaying `audio` without controls. 64 | * Remove excess height in iOS 5 devices. 65 | */ 66 | 67 | audio:not([controls]) { 68 | display: none; 69 | height: 0; 70 | } 71 | 72 | /** 73 | * Address `[hidden]` styling not present in IE 8/9/10. 74 | * Hide the `template` element in IE 8/9/10/11, Safari, and Firefox < 22. 75 | */ 76 | 77 | [hidden], 78 | template { 79 | display: none; 80 | } 81 | 82 | /* Links 83 | ========================================================================== */ 84 | 85 | /** 86 | * Remove the gray background color from active links in IE 10. 87 | */ 88 | 89 | a { 90 | background-color: transparent; 91 | } 92 | 93 | /** 94 | * Improve readability of focused elements when they are also in an 95 | * active/hover state. 96 | */ 97 | 98 | a:active, 99 | a:hover { 100 | outline: 0; 101 | } 102 | 103 | /* Text-level semantics 104 | ========================================================================== */ 105 | 106 | /** 107 | * Address styling not present in IE 8/9/10/11, Safari, and Chrome. 108 | */ 109 | 110 | abbr[title] { 111 | border-bottom: 1px dotted; 112 | } 113 | 114 | /** 115 | * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. 116 | */ 117 | 118 | b, 119 | strong { 120 | font-weight: bold; 121 | } 122 | 123 | /** 124 | * Address styling not present in Safari and Chrome. 125 | */ 126 | 127 | dfn { 128 | font-style: italic; 129 | } 130 | 131 | /** 132 | * Address variable `h1` font-size and margin within `section` and `article` 133 | * contexts in Firefox 4+, Safari, and Chrome. 134 | */ 135 | 136 | h1 { 137 | font-size: 2em; 138 | margin: 0.67em 0; 139 | } 140 | 141 | /** 142 | * Address styling not present in IE 8/9. 143 | */ 144 | 145 | mark { 146 | background: #ff0; 147 | color: #000; 148 | } 149 | 150 | /** 151 | * Address inconsistent and variable font size in all browsers. 152 | */ 153 | 154 | small { 155 | font-size: 80%; 156 | } 157 | 158 | /** 159 | * Prevent `sub` and `sup` affecting `line-height` in all browsers. 160 | */ 161 | 162 | sub, 163 | sup { 164 | font-size: 75%; 165 | line-height: 0; 166 | position: relative; 167 | vertical-align: baseline; 168 | } 169 | 170 | sup { 171 | top: -0.5em; 172 | } 173 | 174 | sub { 175 | bottom: -0.25em; 176 | } 177 | 178 | /* Embedded content 179 | ========================================================================== */ 180 | 181 | /** 182 | * Remove border when inside `a` element in IE 8/9/10. 183 | */ 184 | 185 | img { 186 | border: 0; 187 | } 188 | 189 | /** 190 | * Correct overflow not hidden in IE 9/10/11. 191 | */ 192 | 193 | svg:not(:root) { 194 | overflow: hidden; 195 | } 196 | 197 | /* Grouping content 198 | ========================================================================== */ 199 | 200 | /** 201 | * Address margin not present in IE 8/9 and Safari. 202 | */ 203 | 204 | figure { 205 | margin: 1em 40px; 206 | } 207 | 208 | /** 209 | * Address differences between Firefox and other browsers. 210 | */ 211 | 212 | hr { 213 | box-sizing: content-box; 214 | height: 0; 215 | } 216 | 217 | /** 218 | * Contain overflow in all browsers. 219 | */ 220 | 221 | pre { 222 | overflow: auto; 223 | } 224 | 225 | /** 226 | * Address odd `em`-unit font size rendering in all browsers. 227 | */ 228 | 229 | code, 230 | kbd, 231 | pre, 232 | samp { 233 | font-family: monospace, monospace; 234 | font-size: 1em; 235 | } 236 | 237 | /* Forms 238 | ========================================================================== */ 239 | 240 | /** 241 | * Known limitation: by default, Chrome and Safari on OS X allow very limited 242 | * styling of `select`, unless a `border` property is set. 243 | */ 244 | 245 | /** 246 | * 1. Correct color not being inherited. 247 | * Known issue: affects color of disabled elements. 248 | * 2. Correct font properties not being inherited. 249 | * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. 250 | */ 251 | 252 | button, 253 | input, 254 | optgroup, 255 | select, 256 | textarea { 257 | color: inherit; /* 1 */ 258 | font: inherit; /* 2 */ 259 | margin: 0; /* 3 */ 260 | } 261 | 262 | /** 263 | * Address `overflow` set to `hidden` in IE 8/9/10/11. 264 | */ 265 | 266 | button { 267 | overflow: visible; 268 | } 269 | 270 | /** 271 | * Address inconsistent `text-transform` inheritance for `button` and `select`. 272 | * All other form control elements do not inherit `text-transform` values. 273 | * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. 274 | * Correct `select` style inheritance in Firefox. 275 | */ 276 | 277 | button, 278 | select { 279 | text-transform: none; 280 | } 281 | 282 | /** 283 | * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` 284 | * and `video` controls. 285 | * 2. Correct inability to style clickable `input` types in iOS. 286 | * 3. Improve usability and consistency of cursor style between image-type 287 | * `input` and others. 288 | */ 289 | 290 | button, 291 | html input[type="button"], /* 1 */ 292 | input[type="reset"], 293 | input[type="submit"] { 294 | -webkit-appearance: button; /* 2 */ 295 | cursor: pointer; /* 3 */ 296 | } 297 | 298 | /** 299 | * Re-set default cursor for disabled elements. 300 | */ 301 | 302 | button[disabled], 303 | html input[disabled] { 304 | cursor: default; 305 | } 306 | 307 | /** 308 | * Remove inner padding and border in Firefox 4+. 309 | */ 310 | 311 | button::-moz-focus-inner, 312 | input::-moz-focus-inner { 313 | border: 0; 314 | padding: 0; 315 | } 316 | 317 | /** 318 | * Address Firefox 4+ setting `line-height` on `input` using `!important` in 319 | * the UA stylesheet. 320 | */ 321 | 322 | input { 323 | line-height: normal; 324 | } 325 | 326 | /** 327 | * It's recommended that you don't attempt to style these elements. 328 | * Firefox's implementation doesn't respect box-sizing, padding, or width. 329 | * 330 | * 1. Address box sizing set to `content-box` in IE 8/9/10. 331 | * 2. Remove excess padding in IE 8/9/10. 332 | */ 333 | 334 | input[type="checkbox"], 335 | input[type="radio"] { 336 | box-sizing: border-box; /* 1 */ 337 | padding: 0; /* 2 */ 338 | } 339 | 340 | /** 341 | * Fix the cursor style for Chrome's increment/decrement buttons. For certain 342 | * `font-size` values of the `input`, it causes the cursor style of the 343 | * decrement button to change from `default` to `text`. 344 | */ 345 | 346 | input[type="number"]::-webkit-inner-spin-button, 347 | input[type="number"]::-webkit-outer-spin-button { 348 | height: auto; 349 | } 350 | 351 | /** 352 | * 1. Address `appearance` set to `searchfield` in Safari and Chrome. 353 | * 2. Address `box-sizing` set to `border-box` in Safari and Chrome. 354 | */ 355 | 356 | input[type="search"] { 357 | -webkit-appearance: textfield; /* 1 */ 358 | box-sizing: content-box; /* 2 */ 359 | } 360 | 361 | /** 362 | * Remove inner padding and search cancel button in Safari and Chrome on OS X. 363 | * Safari (but not Chrome) clips the cancel button when the search input has 364 | * padding (and `textfield` appearance). 365 | */ 366 | 367 | input[type="search"]::-webkit-search-cancel-button, 368 | input[type="search"]::-webkit-search-decoration { 369 | -webkit-appearance: none; 370 | } 371 | 372 | /** 373 | * Define consistent border, margin, and padding. 374 | */ 375 | 376 | fieldset { 377 | border: 1px solid #c0c0c0; 378 | margin: 0 2px; 379 | padding: 0.35em 0.625em 0.75em; 380 | } 381 | 382 | /** 383 | * 1. Correct `color` not being inherited in IE 8/9/10/11. 384 | * 2. Remove padding so people aren't caught out if they zero out fieldsets. 385 | */ 386 | 387 | legend { 388 | border: 0; /* 1 */ 389 | padding: 0; /* 2 */ 390 | } 391 | 392 | /** 393 | * Remove default vertical scrollbar in IE 8/9/10/11. 394 | */ 395 | 396 | textarea { 397 | overflow: auto; 398 | } 399 | 400 | /** 401 | * Don't inherit the `font-weight` (applied by a rule above). 402 | * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. 403 | */ 404 | 405 | optgroup { 406 | font-weight: bold; 407 | } 408 | 409 | /* Tables 410 | ========================================================================== */ 411 | 412 | /** 413 | * Remove most spacing between table cells. 414 | */ 415 | 416 | table { 417 | border-collapse: collapse; 418 | border-spacing: 0; 419 | } 420 | 421 | td, 422 | th { 423 | padding: 0; 424 | } 425 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/js/modernizr-2.8.3.min.js: -------------------------------------------------------------------------------- 1 | /* Modernizr 2.8.3 (Custom Build) | MIT & BSD 2 | * Build: http://modernizr.com/download/#-fontface-backgroundsize-borderimage-borderradius-boxshadow-flexbox-hsla-multiplebgs-opacity-rgba-textshadow-cssanimations-csscolumns-generatedcontent-cssgradients-cssreflections-csstransforms-csstransforms3d-csstransitions-applicationcache-canvas-canvastext-draganddrop-hashchange-history-audio-video-indexeddb-input-inputtypes-localstorage-postmessage-sessionstorage-websockets-websqldatabase-webworkers-geolocation-inlinesvg-smil-svg-svgclippaths-touch-webgl-shiv-mq-cssclasses-addtest-prefixed-teststyles-testprop-testallprops-hasevent-prefixes-domprefixes-load 3 | */ 4 | ;window.Modernizr=function(a,b,c){function D(a){j.cssText=a}function E(a,b){return D(n.join(a+";")+(b||""))}function F(a,b){return typeof a===b}function G(a,b){return!!~(""+a).indexOf(b)}function H(a,b){for(var d in a){var e=a[d];if(!G(e,"-")&&j[e]!==c)return b=="pfx"?e:!0}return!1}function I(a,b,d){for(var e in a){var f=b[a[e]];if(f!==c)return d===!1?a[e]:F(f,"function")?f.bind(d||b):f}return!1}function J(a,b,c){var d=a.charAt(0).toUpperCase()+a.slice(1),e=(a+" "+p.join(d+" ")+d).split(" ");return F(b,"string")||F(b,"undefined")?H(e,b):(e=(a+" "+q.join(d+" ")+d).split(" "),I(e,b,c))}function K(){e.input=function(c){for(var d=0,e=c.length;d',a,""].join(""),l.id=h,(m?l:n).innerHTML+=f,n.appendChild(l),m||(n.style.background="",n.style.overflow="hidden",k=g.style.overflow,g.style.overflow="hidden",g.appendChild(n)),i=c(l,a),m?l.parentNode.removeChild(l):(n.parentNode.removeChild(n),g.style.overflow=k),!!i},z=function(b){var c=a.matchMedia||a.msMatchMedia;if(c)return c(b)&&c(b).matches||!1;var d;return y("@media "+b+" { #"+h+" { position: absolute; } }",function(b){d=(a.getComputedStyle?getComputedStyle(b,null):b.currentStyle)["position"]=="absolute"}),d},A=function(){function d(d,e){e=e||b.createElement(a[d]||"div"),d="on"+d;var f=d in e;return f||(e.setAttribute||(e=b.createElement("div")),e.setAttribute&&e.removeAttribute&&(e.setAttribute(d,""),f=F(e[d],"function"),F(e[d],"undefined")||(e[d]=c),e.removeAttribute(d))),e=null,f}var a={select:"input",change:"input",submit:"form",reset:"form",error:"img",load:"img",abort:"img"};return d}(),B={}.hasOwnProperty,C;!F(B,"undefined")&&!F(B.call,"undefined")?C=function(a,b){return B.call(a,b)}:C=function(a,b){return b in a&&F(a.constructor.prototype[b],"undefined")},Function.prototype.bind||(Function.prototype.bind=function(b){var c=this;if(typeof c!="function")throw new TypeError;var d=w.call(arguments,1),e=function(){if(this instanceof e){var a=function(){};a.prototype=c.prototype;var f=new a,g=c.apply(f,d.concat(w.call(arguments)));return Object(g)===g?g:f}return c.apply(b,d.concat(w.call(arguments)))};return e}),s.flexbox=function(){return J("flexWrap")},s.canvas=function(){var a=b.createElement("canvas");return!!a.getContext&&!!a.getContext("2d")},s.canvastext=function(){return!!e.canvas&&!!F(b.createElement("canvas").getContext("2d").fillText,"function")},s.webgl=function(){return!!a.WebGLRenderingContext},s.touch=function(){var c;return"ontouchstart"in a||a.DocumentTouch&&b instanceof DocumentTouch?c=!0:y(["@media (",n.join("touch-enabled),("),h,")","{#modernizr{top:9px;position:absolute}}"].join(""),function(a){c=a.offsetTop===9}),c},s.geolocation=function(){return"geolocation"in navigator},s.postmessage=function(){return!!a.postMessage},s.websqldatabase=function(){return!!a.openDatabase},s.indexedDB=function(){return!!J("indexedDB",a)},s.hashchange=function(){return A("hashchange",a)&&(b.documentMode===c||b.documentMode>7)},s.history=function(){return!!a.history&&!!history.pushState},s.draganddrop=function(){var a=b.createElement("div");return"draggable"in a||"ondragstart"in a&&"ondrop"in a},s.websockets=function(){return"WebSocket"in a||"MozWebSocket"in a},s.rgba=function(){return D("background-color:rgba(150,255,150,.5)"),G(j.backgroundColor,"rgba")},s.hsla=function(){return D("background-color:hsla(120,40%,100%,.5)"),G(j.backgroundColor,"rgba")||G(j.backgroundColor,"hsla")},s.multiplebgs=function(){return D("background:url(https://),url(https://),red url(https://)"),/(url\s*\(.*?){3}/.test(j.background)},s.backgroundsize=function(){return J("backgroundSize")},s.borderimage=function(){return J("borderImage")},s.borderradius=function(){return J("borderRadius")},s.boxshadow=function(){return J("boxShadow")},s.textshadow=function(){return b.createElement("div").style.textShadow===""},s.opacity=function(){return E("opacity:.55"),/^0.55$/.test(j.opacity)},s.cssanimations=function(){return J("animationName")},s.csscolumns=function(){return J("columnCount")},s.cssgradients=function(){var a="background-image:",b="gradient(linear,left top,right bottom,from(#9f9),to(white));",c="linear-gradient(left top,#9f9, white);";return D((a+"-webkit- ".split(" ").join(b+a)+n.join(c+a)).slice(0,-a.length)),G(j.backgroundImage,"gradient")},s.cssreflections=function(){return J("boxReflect")},s.csstransforms=function(){return!!J("transform")},s.csstransforms3d=function(){var a=!!J("perspective");return a&&"webkitPerspective"in g.style&&y("@media (transform-3d),(-webkit-transform-3d){#modernizr{left:9px;position:absolute;height:3px;}}",function(b,c){a=b.offsetLeft===9&&b.offsetHeight===3}),a},s.csstransitions=function(){return J("transition")},s.fontface=function(){var a;return y('@font-face {font-family:"font";src:url("https://")}',function(c,d){var e=b.getElementById("smodernizr"),f=e.sheet||e.styleSheet,g=f?f.cssRules&&f.cssRules[0]?f.cssRules[0].cssText:f.cssText||"":"";a=/src/i.test(g)&&g.indexOf(d.split(" ")[0])===0}),a},s.generatedcontent=function(){var a;return y(["#",h,"{font:0/0 a}#",h,':after{content:"',l,'";visibility:hidden;font:3px/1 a}'].join(""),function(b){a=b.offsetHeight>=3}),a},s.video=function(){var a=b.createElement("video"),c=!1;try{if(c=!!a.canPlayType)c=new Boolean(c),c.ogg=a.canPlayType('video/ogg; codecs="theora"').replace(/^no$/,""),c.h264=a.canPlayType('video/mp4; codecs="avc1.42E01E"').replace(/^no$/,""),c.webm=a.canPlayType('video/webm; codecs="vp8, vorbis"').replace(/^no$/,"")}catch(d){}return c},s.audio=function(){var a=b.createElement("audio"),c=!1;try{if(c=!!a.canPlayType)c=new Boolean(c),c.ogg=a.canPlayType('audio/ogg; codecs="vorbis"').replace(/^no$/,""),c.mp3=a.canPlayType("audio/mpeg;").replace(/^no$/,""),c.wav=a.canPlayType('audio/wav; codecs="1"').replace(/^no$/,""),c.m4a=(a.canPlayType("audio/x-m4a;")||a.canPlayType("audio/aac;")).replace(/^no$/,"")}catch(d){}return c},s.localstorage=function(){try{return localStorage.setItem(h,h),localStorage.removeItem(h),!0}catch(a){return!1}},s.sessionstorage=function(){try{return sessionStorage.setItem(h,h),sessionStorage.removeItem(h),!0}catch(a){return!1}},s.webworkers=function(){return!!a.Worker},s.applicationcache=function(){return!!a.applicationCache},s.svg=function(){return!!b.createElementNS&&!!b.createElementNS(r.svg,"svg").createSVGRect},s.inlinesvg=function(){var a=b.createElement("div");return a.innerHTML="",(a.firstChild&&a.firstChild.namespaceURI)==r.svg},s.smil=function(){return!!b.createElementNS&&/SVGAnimate/.test(m.call(b.createElementNS(r.svg,"animate")))},s.svgclippaths=function(){return!!b.createElementNS&&/SVGClipPath/.test(m.call(b.createElementNS(r.svg,"clipPath")))};for(var L in s)C(s,L)&&(x=L.toLowerCase(),e[x]=s[L](),v.push((e[x]?"":"no-")+x));return e.input||K(),e.addTest=function(a,b){if(typeof a=="object")for(var d in a)C(a,d)&&e.addTest(d,a[d]);else{a=a.toLowerCase();if(e[a]!==c)return e;b=typeof b=="function"?b():b,typeof f!="undefined"&&f&&(g.className+=" "+(b?"":"no-")+a),e[a]=b}return e},D(""),i=k=null,function(a,b){function l(a,b){var c=a.createElement("p"),d=a.getElementsByTagName("head")[0]||a.documentElement;return c.innerHTML="x",d.insertBefore(c.lastChild,d.firstChild)}function m(){var a=s.elements;return typeof a=="string"?a.split(" "):a}function n(a){var b=j[a[h]];return b||(b={},i++,a[h]=i,j[i]=b),b}function o(a,c,d){c||(c=b);if(k)return c.createElement(a);d||(d=n(c));var g;return d.cache[a]?g=d.cache[a].cloneNode():f.test(a)?g=(d.cache[a]=d.createElem(a)).cloneNode():g=d.createElem(a),g.canHaveChildren&&!e.test(a)&&!g.tagUrn?d.frag.appendChild(g):g}function p(a,c){a||(a=b);if(k)return a.createDocumentFragment();c=c||n(a);var d=c.frag.cloneNode(),e=0,f=m(),g=f.length;for(;e",g="hidden"in a,k=a.childNodes.length==1||function(){b.createElement("a");var a=b.createDocumentFragment();return typeof a.cloneNode=="undefined"||typeof a.createDocumentFragment=="undefined"||typeof a.createElement=="undefined"}()}catch(c){g=!0,k=!0}})();var s={elements:d.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output progress section summary template time video",version:c,shivCSS:d.shivCSS!==!1,supportsUnknownElements:k,shivMethods:d.shivMethods!==!1,type:"default",shivDocument:r,createElement:o,createDocumentFragment:p};a.html5=s,r(b)}(this,b),e._version=d,e._prefixes=n,e._domPrefixes=q,e._cssomPrefixes=p,e.mq=z,e.hasEvent=A,e.testProp=function(a){return H([a])},e.testAllProps=J,e.testStyles=y,e.prefixed=function(a,b,c){return b?J(a,b,c):J(a,"pfx")},g.className=g.className.replace(/(^|\s)no-js(\s|$)/,"$1$2")+(f?" js "+v.join(" "):""),e}(this,this.document),function(a,b,c){function d(a){return"[object Function]"==o.call(a)}function e(a){return"string"==typeof a}function f(){}function g(a){return!a||"loaded"==a||"complete"==a||"uninitialized"==a}function h(){var a=p.shift();q=1,a?a.t?m(function(){("c"==a.t?B.injectCss:B.injectJs)(a.s,0,a.a,a.x,a.e,1)},0):(a(),h()):q=0}function i(a,c,d,e,f,i,j){function k(b){if(!o&&g(l.readyState)&&(u.r=o=1,!q&&h(),l.onload=l.onreadystatechange=null,b)){"img"!=a&&m(function(){t.removeChild(l)},50);for(var d in y[c])y[c].hasOwnProperty(d)&&y[c][d].onload()}}var j=j||B.errorTimeout,l=b.createElement(a),o=0,r=0,u={t:d,s:c,e:f,a:i,x:j};1===y[c]&&(r=1,y[c]=[]),"object"==a?l.data=c:(l.src=c,l.type=a),l.width=l.height="0",l.onerror=l.onload=l.onreadystatechange=function(){k.call(this,r)},p.splice(e,0,u),"img"!=a&&(r||2===y[c]?(t.insertBefore(l,s?null:n),m(k,j)):y[c].push(l))}function j(a,b,c,d,f){return q=0,b=b||"j",e(a)?i("c"==b?v:u,a,b,this.i++,c,d,f):(p.splice(this.i++,0,a),1==p.length&&h()),this}function k(){var a=B;return a.loader={load:j,i:0},a}var l=b.documentElement,m=a.setTimeout,n=b.getElementsByTagName("script")[0],o={}.toString,p=[],q=0,r="MozAppearance"in l.style,s=r&&!!b.createRange().compareNode,t=s?l:n.parentNode,l=a.opera&&"[object Opera]"==o.call(a.opera),l=!!b.attachEvent&&!l,u=r?"object":l?"script":"img",v=l?"script":u,w=Array.isArray||function(a){return"[object Array]"==o.call(a)},x=[],y={},z={timeout:function(a,b){return b.length&&(a.timeout=b[0]),a}},A,B;B=function(a){function b(a){var a=a.split("!"),b=x.length,c=a.pop(),d=a.length,c={url:c,origUrl:c,prefixes:a},e,f,g;for(f=0;f- 4 | API for the scrumpoker online project. This is used by the web application 5 | as well as possible mobile appes 6 | version: 2.0.0 7 | title: Scrumpoker Online 8 | contact: 9 | email: info@scrumpoker.online 10 | license: 11 | name: Apache 2.0 12 | url: 'http://www.apache.org/licenses/LICENSE-2.0.html' 13 | host: www.scrumpoker.online 14 | basePath: /api 15 | tags: 16 | - name: session 17 | description: >- 18 | The session management to list or create sessions, add members and much 19 | more 20 | - name: poll 21 | description: 'Create polls, fetch the current topic and place a vote' 22 | - name: statistics 23 | description: Calculate statistics about the current poll 24 | - name: jira 25 | description: Load issues from a JIRA server 26 | schemes: 27 | - http 28 | paths: 29 | /session/active: 30 | get: 31 | tags: 32 | - session 33 | summary: Retrieve all sessions active within the last hour 34 | produces: 35 | - application/json 36 | responses: 37 | '200': 38 | description: successful operation 39 | schema: 40 | type: array 41 | items: 42 | $ref: '#/definitions/Session' 43 | /session/create: 44 | post: 45 | tags: 46 | - session 47 | summary: Create a new session 48 | consumes: 49 | - application/json 50 | produces: 51 | - application/json 52 | parameters: 53 | - in: body 54 | name: body 55 | description: Session object that shall be created 56 | required: true 57 | schema: 58 | $ref: '#/definitions/Session' 59 | responses: 60 | '200': 61 | description: Session created 62 | schema: 63 | $ref: '#/definitions/Session' 64 | '/session/member/{id}': 65 | put: 66 | tags: 67 | - session 68 | summary: Add member or join as existing meber 69 | consumes: 70 | - application/json 71 | parameters: 72 | - name: id 73 | in: path 74 | required: true 75 | type: integer 76 | format: int64 77 | description: Id of the session to add the member to 78 | - name: body 79 | in: body 80 | description: Member object with name of member 81 | required: true 82 | schema: 83 | $ref: '#/definitions/Member' 84 | responses: 85 | '200': 86 | description: Added member to session 87 | '/session/member/{id}/{mid}': 88 | delete: 89 | tags: 90 | - session 91 | summary: Remove member from session 92 | parameters: 93 | - name: id 94 | in: path 95 | required: true 96 | type: integer 97 | format: int64 98 | description: Id of the session to remove the member from 99 | - name: mid 100 | in: path 101 | required: true 102 | type: integer 103 | format: int64 104 | description: Id of the member that shall be removed 105 | responses: 106 | '200': 107 | description: Member removed 108 | '/session/requiresPassword/{id}': 109 | get: 110 | tags: 111 | - session 112 | summary: Check if session is protected by password and if it is necessary 113 | produces: 114 | - application/json 115 | parameters: 116 | - name: id 117 | in: path 118 | required: true 119 | type: integer 120 | format: int64 121 | description: Id of the session that is checked for password protection 122 | responses: 123 | '200': 124 | description: Boolean if session has a password and user does not have a sufficient token 125 | schema: 126 | $ref: '#/definitions/BoolResponse' 127 | '/session/membercheck/{id}/{mid}': 128 | get: 129 | tags: 130 | - session 131 | summary: Check if member is still part of the session 132 | produces: 133 | - application/json 134 | parameters: 135 | - name: id 136 | in: path 137 | required: true 138 | type: integer 139 | format: int64 140 | description: Id of the session 141 | - name: mid 142 | in: path 143 | required: true 144 | type: integer 145 | format: int64 146 | description: Id of the member 147 | responses: 148 | '200': 149 | description: Boolean if member is still part of the session 150 | schema: 151 | $ref: '#/definitions/BoolResponse' 152 | '/session/check/{id}': 153 | post: 154 | tags: 155 | - session 156 | summary: Check the password of a session 157 | produces: 158 | - application/json 159 | parameters: 160 | - name: id 161 | in: path 162 | required: true 163 | type: integer 164 | format: int64 165 | description: Id of the session 166 | - name: body 167 | in: body 168 | description: Password to compare to the sessions hash 169 | required: true 170 | schema: 171 | type: object 172 | properties: 173 | path: 174 | type: string 175 | responses: 176 | '200': 177 | description: Boolean if the password was correct 178 | schema: 179 | $ref: '#/definitions/BoolResponse' 180 | /session/cardsets: 181 | get: 182 | tags: 183 | - session 184 | summary: Get all configured cardsets 185 | produces: 186 | - application/json 187 | responses: 188 | '200': 189 | description: Arrays with the face values of each card 190 | schema: 191 | type: array 192 | items: 193 | type: array 194 | items: 195 | type: string 196 | '/session/cardset/{id}': 197 | get: 198 | tags: 199 | - session 200 | summary: Get the cardset of that session 201 | produces: 202 | - application/json 203 | parameters: 204 | - name: id 205 | in: path 206 | required: true 207 | type: integer 208 | format: int64 209 | description: Id of the session 210 | responses: 211 | '200': 212 | description: Arrays with the face values of each card 213 | schema: 214 | type: array 215 | items: 216 | type: string 217 | '/session/wipe/{id}': 218 | get: 219 | tags: 220 | - session 221 | summary: Wipe the session and all associated data 222 | produces: 223 | - application/json 224 | parameters: 225 | - name: id 226 | in: path 227 | required: true 228 | type: integer 229 | format: int64 230 | description: Id of the session 231 | responses: 232 | '200': 233 | description: Session was wiped 234 | '/poll/vote/{id}/{mid}': 235 | post: 236 | tags: 237 | - poll 238 | summary: Place a vote in the current poll 239 | consumes: 240 | - application/json 241 | parameters: 242 | - name: id 243 | in: path 244 | required: true 245 | type: integer 246 | format: int64 247 | description: Id of the session 248 | - name: mid 249 | in: path 250 | required: true 251 | type: integer 252 | format: int64 253 | description: Id of the member 254 | - in: body 255 | name: body 256 | description: Value of the vote 257 | required: true 258 | schema: 259 | type: object 260 | properties: 261 | vote: 262 | type: string 263 | responses: 264 | '200': 265 | description: Vote was placed 266 | '500': 267 | description: Failed to place vote 268 | delete: 269 | tags: 270 | - poll 271 | summary: Take back a vote as long as the poll was not closed 272 | consumes: 273 | - application/json 274 | parameters: 275 | - name: id 276 | in: path 277 | required: true 278 | type: integer 279 | format: int64 280 | description: Id of the session 281 | - name: mid 282 | in: path 283 | required: true 284 | type: integer 285 | format: int64 286 | description: Id of the member 287 | responses: 288 | '200': 289 | description: Vote was removed 290 | '500': 291 | description: Could not take back the vote anymore 292 | '/poll/current/{id}': 293 | get: 294 | tags: 295 | - poll 296 | summary: 'Get the current poll, its topic and votes' 297 | description: This endpoint is used by the Masterview to frequently update the UI 298 | produces: 299 | - application/json 300 | parameters: 301 | - name: id 302 | in: path 303 | required: true 304 | type: integer 305 | format: int64 306 | description: Id of the session 307 | - name: last 308 | in: query 309 | required: false 310 | type: integer 311 | format: int64 312 | description: Timestamp of last known modification 313 | responses: 314 | '200': 315 | description: Poll object with votes 316 | schema: 317 | type: array 318 | items: 319 | $ref: '#/definitions/PollResponse' 320 | '/poll/topic/{id}': 321 | get: 322 | tags: 323 | - poll 324 | summary: Get the topic of the current poll 325 | produces: 326 | - application/json 327 | parameters: 328 | - name: id 329 | in: path 330 | required: true 331 | type: integer 332 | format: int64 333 | description: Id of the session 334 | - name: last 335 | in: query 336 | required: false 337 | type: integer 338 | format: int64 339 | description: Timestamp of last known modification 340 | responses: 341 | '200': 342 | description: Topic object with timestamp and voting indicator 343 | schema: 344 | type: array 345 | items: 346 | $ref: '#/definitions/TopicResponse' 347 | post: 348 | tags: 349 | - poll 350 | summary: Set new topic and start poll 351 | consumes: 352 | - application/json 353 | parameters: 354 | - name: id 355 | in: path 356 | required: true 357 | type: integer 358 | format: int64 359 | description: Id of the session 360 | - in: body 361 | name: body 362 | description: The topic of the new poll 363 | required: true 364 | schema: 365 | type: object 366 | properties: 367 | topic: 368 | type: string 369 | responses: 370 | '200': 371 | description: Topic was set 372 | '500': 373 | description: Failed to start poll and set topic 374 | '/statistics/calculate/{id}': 375 | get: 376 | tags: 377 | - statistics 378 | summary: Calculate statistics for the session after a poll was completed 379 | produces: 380 | - application/json 381 | parameters: 382 | - name: id 383 | in: path 384 | required: true 385 | type: integer 386 | format: int64 387 | description: Id of the session 388 | responses: 389 | '200': 390 | description: Arrays with result of the statistics calculation 391 | schema: 392 | type: array 393 | items: 394 | $ref: '#/definitions/Statistic' 395 | /jira/getIssues: 396 | get: 397 | tags: 398 | - jira 399 | summary: 'Fetch issues of a project from JIRA ' 400 | consumes: 401 | - application/x-www-form-urlencoded 402 | produces: 403 | - application/json 404 | parameters: 405 | - name: base_url 406 | in: formData 407 | description: URL of the JIRA server 408 | required: true 409 | type: string 410 | - name: username 411 | in: formData 412 | description: User name for authenication 413 | required: true 414 | type: string 415 | - name: password 416 | in: formData 417 | description: Password for authenication 418 | required: true 419 | type: string 420 | - name: project 421 | in: formData 422 | description: The project of the issues 423 | required: true 424 | type: string 425 | - name: jql 426 | in: formData 427 | description: JIRA query string to filter the issues 428 | required: false 429 | type: string 430 | responses: 431 | '200': 432 | description: Arrays with result of the statistics calculation 433 | schema: 434 | type: array 435 | items: 436 | type: object 437 | properties: 438 | key: 439 | type: integer 440 | format: int64 441 | definitions: 442 | Session: 443 | type: object 444 | properties: 445 | id: 446 | type: integer 447 | format: int64 448 | name: 449 | type: string 450 | isPrivate: 451 | type: boolean 452 | requiresPassword: 453 | type: boolean 454 | description: >- 455 | Indicator if the requesting user needs a password to access this session. 456 | This is the case for private sessions if the user does not have the necessary token yet. 457 | password: 458 | type: string 459 | membercount: 460 | type: integer 461 | format: int32 462 | Member: 463 | type: object 464 | properties: 465 | id: 466 | type: integer 467 | format: int32 468 | name: 469 | type: string 470 | BoolResponse: 471 | type: object 472 | properties: 473 | success: 474 | type: boolean 475 | NumericResponse: 476 | type: object 477 | properties: 478 | value: 479 | type: integer 480 | format: int32 481 | Statistic: 482 | type: object 483 | properties: 484 | key: 485 | type: string 486 | description: Class and file name of the statistic 487 | type: 488 | type: string 489 | enum: 490 | - numeric 491 | - time 492 | - nominal 493 | value: 494 | type: string 495 | PollResponse: 496 | type: object 497 | properties: 498 | name: 499 | type: string 500 | description: Name of the sesion 501 | timestamp: 502 | type: integer 503 | description: >- 504 | Timestamp of last known modification. Can be used in queries for 505 | client-side caching 506 | topic: 507 | type: string 508 | description: Topic or feature name of the current poll 509 | flipped: 510 | type: boolean 511 | description: Indicator if the cards shall be flipped 512 | consensus: 513 | type: boolean 514 | description: Indicator that the team reached a consensus on the estimation 515 | votes: 516 | type: array 517 | items: 518 | $ref: '#/definitions/UserVote' 519 | unchanged: 520 | type: boolean 521 | description: Flag that the polls values have not changed since the last call 522 | UserVote: 523 | type: object 524 | properties: 525 | id: 526 | type: integer 527 | format: int64 528 | description: Id of the member who placed the vote 529 | name: 530 | type: string 531 | description: Name of the member who placed the vote 532 | placed: 533 | type: boolean 534 | description: Flag if the member already voted in the current poll 535 | value: 536 | type: string 537 | active: 538 | type: boolean 539 | description: >- 540 | Flag that the vote shall be highlighted because it is either the 541 | highest or lowest value 542 | canDelete: 543 | type: boolean 544 | description: Indicator if the master can remove this vote and its user 545 | TopicResponse: 546 | type: object 547 | properties: 548 | timestamp: 549 | type: integer 550 | description: >- 551 | Timestamp of last known modification. Can be used in queries for 552 | client-side caching 553 | votable: 554 | type: boolean 555 | description: Flag if poll is still open for voting 556 | unchanged: 557 | type: boolean 558 | description: Flag that the polls values have not changed since the last call 559 | -------------------------------------------------------------------------------- /src/js/main.js: -------------------------------------------------------------------------------- 1 | /*eslint-env browser, jquery*/ 2 | /*globals angular*/ 3 | 4 | var scrum = { 5 | // Ticketing sources 6 | sources: [ 7 | { 8 | name:'Default', 9 | position: 1, 10 | feedback: false, 11 | topic: '', 12 | description: '', 13 | view: 'default_source.html' 14 | }, 15 | { 16 | name: '+', 17 | position: 99, 18 | view: 'add_source.html', 19 | feedback: false 20 | }, 21 | ], 22 | 23 | // At peak times the number of polling clients exceeds the servers capacity. 24 | // To avoid error 503 and rather keep the page running this strategy adapts 25 | // the polling interval to the response behavior -> Few clients & fast polling 26 | // or many clients and slow polling 27 | pollingScale: (function () { 28 | // Scale goes from 1.0 to 5.0 and starts in the middle 29 | var min = 1.0, current = 2.5, max = 5.0; 30 | 31 | return { 32 | // Call was successful -> decrease scale slowly 33 | success: function() { 34 | if (current > min) 35 | current -= 0.05; 36 | }, 37 | // Call failed, so increase scale 4 times faster then decrease 38 | failed: function() { 39 | if (current < max) 40 | current += 0.2 41 | }, 42 | // Scale interval using the current scaling value 43 | scale: function(interval, callback) { 44 | interval *= current; 45 | 46 | // Quadruple polling interval while tab is out of focus 47 | if (!document.hasFocus()) 48 | interval *= 4; 49 | 50 | setTimeout(callback, interval); 51 | } 52 | }; 53 | })() 54 | }; 55 | 56 | // Define angular app 57 | scrum.app = angular.module('scrum-online', ['ngRoute', 'ngSanitize', 'ngCookies']); 58 | 59 | //------------------------------ 60 | // Configure routing 61 | // ----------------------------- 62 | scrum.app.config( 63 | function($locationProvider, $routeProvider) { 64 | // Use HTML5 mode for prettier routes 65 | $locationProvider.html5Mode(true); 66 | 67 | // Configure routing 68 | $routeProvider 69 | .when('/', { 70 | templateUrl: 'home.html' 71 | }) 72 | .when('/sessions', { 73 | templateUrl: 'list.html', 74 | controller: 'ListController', 75 | controllerAs: 'list' 76 | }) 77 | .when('/session/:id',{ 78 | templateUrl : 'master.html', 79 | controller: 'MasterController', 80 | controllerAs: 'master' 81 | }) 82 | .when('/join', { redirectTo: '/join/0' }) 83 | .when('/join/:id', { 84 | templateUrl : 'join.html', 85 | controller: 'JoinController', 86 | controllerAs: 'join' 87 | }) 88 | .when('/member/:sessionId/:memberId', { 89 | templateUrl : 'member.html', 90 | controller: 'MemberController', 91 | controllerAs: 'member' 92 | }) 93 | .when('/removal', { 94 | templateUrl: 'removal.html', 95 | }) 96 | .otherwise({ 97 | templateUrl: '404.html' 98 | }) 99 | ; 100 | }); 101 | 102 | //------------------------------ 103 | // Create controller 104 | //------------------------------ 105 | scrum.app.controller('CreateController', function CreateController($http, $location) { 106 | // Save reference and set current 107 | scrum.current = this; 108 | 109 | // Initialize properties 110 | this.name = ''; 111 | this.cardSets = []; 112 | for(var i=0; i thanks to https://stackoverflow.com/a/35890816/6082960 314 | self.stopwatchElapsed = new Date(stopwatchMs).toISOString().slice(14, 19); 315 | // Start next cycle 316 | self.stopwatch(); 317 | }, interval); 318 | }; 319 | 320 | // Starting a new poll 321 | this.startPoll = function (topic, description, url) { 322 | $http.post('/api/poll/topic/' + self.id, { topic: topic, description:description || '', url:url || '' }).then(function(response) { 323 | // Reset our GUI 324 | for(var index=0; index < self.votes.length; index++) 325 | { 326 | var vote = self.votes[index]; 327 | vote.placed = false; 328 | vote.active = false; 329 | } 330 | self.flipped = false; 331 | // Reset stopwatch 332 | stopwatchMs = 0; 333 | self.stopwatchElapsed = '00:00'; 334 | // Start the stopwatch 335 | self.stopwatch(); 336 | }); 337 | }; 338 | 339 | // Force stop a poll 340 | this.stopPoll = function () { 341 | $http.post('/api/poll/close/' + self.id); 342 | }; 343 | 344 | // Remove a member from the session 345 | this.remove = function (id) { 346 | $http.delete("/api/session/member/" + self.id + "/" + id); 347 | }; 348 | 349 | // Wipe the session and redirect 350 | this.wipe = function () { 351 | var confirmed = confirm("Do you want to delete the session and wipe all associated data?"); 352 | if (!confirmed) 353 | return; 354 | 355 | $http.delete('/api/session/wipe/' + self.id).then(function (response){ 356 | $location.url("/404.html"); // Redirect to 404 when we wiped the session 357 | }); 358 | } 359 | 360 | // Select a ticketing system 361 | this.selectSource = function(source) { 362 | // Give source a reference to the this and set as current 363 | source.parent = this; 364 | this.current = source; 365 | }; 366 | 367 | // Fetch statistics 368 | function fetchStatistics() { 369 | var query = "/api/statistics/calculate/" + self.id 370 | $http.get(query).then(function(response){ 371 | var result = response.data; 372 | 373 | if (self.statistics) { 374 | // Update values 375 | for (var i=0; i < result.length; i++) { 376 | var item = result[i]; 377 | // Find match 378 | for(var j=0; j < self.statistics.length; j++) { 379 | var statistic = self.statistics[j]; 380 | if(statistic.name == item.name) { 381 | statistic.value = item.value; 382 | break; 383 | } 384 | } 385 | } 386 | } else { 387 | // Initial set 388 | self.statistics = result; 389 | } 390 | }); 391 | } 392 | 393 | // Poll all votes from the server 394 | function pollVotes() { 395 | if (scrum.current !== self) 396 | return; 397 | 398 | $http.get("/api/poll/current/" + self.id + "?last=" + self.timestamp).then(function(response){ 399 | var result = response.data; 400 | 401 | // Session was not modified 402 | if (result.unchanged) { 403 | scrum.pollingScale.scale(300, pollVotes); 404 | return; 405 | } 406 | 407 | // Query statistics 408 | if (!self.flipped && result.flipped) { 409 | fetchStatistics(); 410 | } 411 | 412 | // Copy poll values 413 | self.name = result.name; 414 | self.timestamp = result.timestamp; 415 | self.votes = result.votes; 416 | self.flipped = result.flipped; 417 | self.consensus = result.consensus; 418 | 419 | // If the result has a topic, the team has started estimating 420 | if(result.topic !== '') { 421 | self.current.topic = result.topic; 422 | self.current.description = result.description; 423 | self.teamComplete = true; 424 | } 425 | 426 | // Forward result to ticketing system 427 | if (self.current.feedback && self.flipped && self.consensus) { 428 | self.current.completed(self.votes[0].value); 429 | } 430 | 431 | scrum.pollingScale.success(); 432 | scrum.pollingScale.scale(400, pollVotes); 433 | }, function(){ 434 | scrum.pollingScale.failed(); 435 | scrum.pollingScale.scale(400, pollVotes); 436 | }); 437 | } 438 | 439 | // Start the polling timer 440 | pollVotes(); 441 | }); 442 | 443 | // ------------------------------- 444 | // Card controller 445 | // ------------------------------- 446 | scrum.app.controller('MemberController', function MemberController ($http, $location, $routeParams) { 447 | // Set current 448 | scrum.current = this; 449 | 450 | // Init model 451 | this.id = $routeParams.sessionId; 452 | this.member = $routeParams.memberId; 453 | this.votable = false; 454 | this.leaving = false; 455 | this.topic = ''; 456 | this.description = ''; 457 | this.topicUrl = ''; 458 | this.cards = []; 459 | 460 | // Self reference for callbacks 461 | var self = this; 462 | 463 | // Reset the member UI 464 | this.reset = function () { 465 | for (var i=0; i < this.cards.length; i++) { 466 | this.cards[i].active = false; 467 | this.cards[i].confirmed = false; 468 | } 469 | }; 470 | 471 | // Leave the session 472 | this.leave = function () { 473 | this.leaving = true; 474 | $http.delete("/api/session/member/" + self.id + "/" + self.member).then(function (response) { 475 | $location.url("/"); 476 | }, function() { 477 | self.leaving = false; 478 | }); 479 | }; 480 | 481 | // Select a card and try to place a vote 482 | this.selectCard = function (card) { 483 | // If the user tapped the confirmed card again, remove the vote 484 | if (this.currentCard == card && this.currentCard.confirmed) { 485 | $http.delete('/api/poll/vote/' + this.id + '/' + this.member).then(function() { 486 | self.currentCard.confirmed = false; 487 | self.currentCard = null; 488 | }); 489 | return; 490 | } 491 | 492 | // Otherwise figure out what to do 493 | this.currentCard = card; 494 | card.active = true; 495 | 496 | $http.post('/api/poll/vote/' + this.id + "/" + this.member, { 497 | vote: card.value 498 | }).then(function (response) { 499 | self.reset(); 500 | card.confirmed = true; 501 | }); 502 | }; 503 | 504 | // Check if we are part of the session 505 | // callback: function (stillPresent : boolean) 506 | function selfCheck(callback) { 507 | $http.get("/api/session/membercheck/" + self.id + '/' + self.member).then(function(response){ 508 | var data = response.data; 509 | if (self.leaving) { 510 | return; 511 | } 512 | 513 | callback(data.success); 514 | }); 515 | } 516 | 517 | // Update current topic from server to activate voting 518 | function update() { 519 | if (scrum.current !== self) 520 | return; 521 | 522 | // Update topic 523 | $http.get("/api/poll/topic/" + self.id + "?last=" + self.timestamp).then(function(response){ 524 | var result = response.data; 525 | 526 | // Keep current state 527 | if (result.unchanged) { 528 | scrum.pollingScale.scale(500, update); 529 | return 530 | } 531 | 532 | self.timestamp = result.timestamp; 533 | 534 | // Voting was closed, get our peers votes 535 | if(self.votable && !result.votable) { 536 | } 537 | 538 | // Topic changed or poll was opened for voting again 539 | if(self.topic !== result.topic || (!self.votable && result.votable)) { 540 | self.reset(); 541 | self.topic = result.topic; 542 | self.description = result.description || ''; 543 | self.topicUrl = result.url || '#'; 544 | } 545 | 546 | self.votable = result.votable; 547 | 548 | scrum.pollingScale.success(); 549 | scrum.pollingScale.scale(500, update); 550 | }, function() { 551 | scrum.pollingScale.failed(); 552 | scrum.pollingScale.scale(500, update); 553 | }); 554 | 555 | // Check if we are still here 556 | selfCheck(function (stillPresent){ 557 | if(!stillPresent) { 558 | $location.url("/removal"); 559 | } 560 | }); 561 | }; 562 | 563 | // Get card set of our session 564 | function getCardSet() { 565 | $http.get("/api/session/cardset/" + self.id).then(function(response){ 566 | var cards = response.data; 567 | for(var i=0; i