├── client ├── src │ ├── assets │ │ ├── .gitkeep │ │ ├── i18n │ │ │ ├── notice.de.html │ │ │ ├── notice.es.html │ │ │ ├── notice.it.html │ │ │ ├── notice.en-US.html │ │ │ ├── changelog.de.html │ │ │ ├── changelog.en-US.html │ │ │ ├── changelog.it.html │ │ │ ├── changelog.es.html │ │ │ └── changelog.fr.html │ │ ├── icon.png │ │ ├── fonts │ │ │ ├── Linearicons-Free.eot │ │ │ ├── Linearicons-Free.ttf │ │ │ ├── Linearicons-Free.woff │ │ │ └── Linearicons-Free.woff2 │ │ ├── login-background-left.webp │ │ ├── login-background-right.webp │ │ └── arrow-down.svg │ ├── app │ │ ├── auth │ │ │ ├── oauth-landing │ │ │ │ ├── oauth-landing.component.scss │ │ │ │ ├── oauth-landing.component.html │ │ │ │ ├── oauth-landing.component.ts │ │ │ │ └── oauth-landing.component.spec.ts │ │ │ ├── login │ │ │ │ ├── login.component.html │ │ │ │ └── login.component.spec.ts │ │ │ ├── logged-in.interceptor.spec.ts │ │ │ ├── auth.guard.ts │ │ │ ├── logged-in.interceptor.ts │ │ │ └── auth.service.spec.ts │ │ ├── project-creation │ │ │ ├── task-edit │ │ │ │ ├── task-edit.component.scss │ │ │ │ ├── task-edit.component.html │ │ │ │ ├── task-edit.component.ts │ │ │ │ └── task-edit.component.spec.ts │ │ │ ├── task-draft-list │ │ │ │ ├── task-draft-list.component.scss │ │ │ │ ├── task-draft-list.component.html │ │ │ │ ├── task-draft-list.component.ts │ │ │ │ └── task-draft-list.component.spec.ts │ │ │ ├── project-import │ │ │ │ ├── project-import.component.scss │ │ │ │ ├── project-import.component.html │ │ │ │ └── project-import.component.ts │ │ │ ├── shape-upload │ │ │ │ ├── shape-upload.component.scss │ │ │ │ ├── shape-upload.component.html │ │ │ │ └── shape-upload.component.ts │ │ │ ├── shape-remote │ │ │ │ ├── shape-remote.component.scss │ │ │ │ ├── shape-remote.component.html │ │ │ │ └── shape-remote.component.ts │ │ │ ├── copy-project │ │ │ │ ├── copy-project.component.scss │ │ │ │ ├── copy-project.component.html │ │ │ │ └── copy-project.component.ts │ │ │ ├── project-properties │ │ │ │ ├── project-properties.component.scss │ │ │ │ ├── project-properties.component.ts │ │ │ │ ├── project-properties.component.spec.ts │ │ │ │ └── project-properties.component.html │ │ │ ├── project-properties.ts │ │ │ ├── shape-divide │ │ │ │ └── shape-divide.component.scss │ │ │ ├── drawing-toolbar │ │ │ │ ├── drawing-toolbar.component.html │ │ │ │ └── drawing-toolbar.component.scss │ │ │ ├── project-import.service.spec.ts │ │ │ └── project-creation │ │ │ │ └── project-creation.component.scss │ │ ├── common │ │ │ ├── entities │ │ │ │ ├── josm-data-source.ts │ │ │ │ ├── language.ts │ │ │ │ └── websocket-message.ts │ │ │ ├── components │ │ │ │ └── map │ │ │ │ │ ├── map.component.html │ │ │ │ │ └── map.component.scss │ │ │ ├── mock-router.ts │ │ │ ├── unsubscriber.ts │ │ │ ├── selected-language.guard.ts │ │ │ └── services │ │ │ │ ├── shortcut.service.spec.ts │ │ │ │ ├── websocket-client.service.spec.ts │ │ │ │ ├── loading.service.ts │ │ │ │ ├── process-point-color.service.spec.ts │ │ │ │ ├── process-point-color.service.ts │ │ │ │ ├── shortcut.service.ts │ │ │ │ ├── map-layer.service.spec.ts │ │ │ │ ├── loading.service.spec.ts │ │ │ │ └── notification.service.ts │ │ ├── task │ │ │ ├── task-map │ │ │ │ ├── task-map.component.html │ │ │ │ └── task-map.component.scss │ │ │ ├── task-title.pipe.ts │ │ │ ├── task-list │ │ │ │ ├── task-list.component.html │ │ │ │ └── task-list.component.scss │ │ │ ├── task-title.pipe.spec.ts │ │ │ ├── task.material.spec.ts │ │ │ ├── task-details │ │ │ │ ├── task-details.component.scss │ │ │ │ └── task-details.component.html │ │ │ └── task.material.ts │ │ ├── ui │ │ │ ├── footer │ │ │ │ ├── footer.component.html │ │ │ │ ├── footer.component.scss │ │ │ │ └── footer.component.ts │ │ │ ├── language-selection │ │ │ │ ├── language-selection.component.scss │ │ │ │ ├── language-selection.component.html │ │ │ │ ├── language-selection.component.ts │ │ │ │ └── language-selection.component.spec.ts │ │ │ ├── toolbar │ │ │ │ ├── toolbar.component.html │ │ │ │ ├── toolbar.component.ts │ │ │ │ ├── toolbar.component.scss │ │ │ │ └── toolbar.component.spec.ts │ │ │ ├── icon-button │ │ │ │ ├── icon-button.component.html │ │ │ │ ├── icon-button.component.scss │ │ │ │ └── icon-button.component.ts │ │ │ ├── zoom-control │ │ │ │ ├── zoom-control.component.html │ │ │ │ ├── zoom-control.component.scss │ │ │ │ ├── zoom-control.component.ts │ │ │ │ └── zoom-control.component.spec.ts │ │ │ ├── progress-bar │ │ │ │ ├── progress-bar.component.html │ │ │ │ ├── progress-bar.component.scss │ │ │ │ └── progress-bar.component.ts │ │ │ ├── tabs │ │ │ │ ├── tabs.component.html │ │ │ │ ├── tabs.component.spec.ts │ │ │ │ ├── tabs.component.ts │ │ │ │ └── tabs.component.scss │ │ │ ├── max-validator.directive.ts │ │ │ ├── min-validator.directive.ts │ │ │ ├── max-validator.directive.spec.ts │ │ │ ├── min-validator.directive.spec.ts │ │ │ └── notification │ │ │ │ ├── notification.component.html │ │ │ │ └── notification.component.ts │ │ ├── user │ │ │ ├── user-invitation │ │ │ │ ├── user-invitation.component.scss │ │ │ │ ├── user-invitation.component.html │ │ │ │ └── user-invitation.component.ts │ │ │ ├── user.material.ts │ │ │ ├── user-list │ │ │ │ ├── user-list.component.html │ │ │ │ ├── user-list.component.scss │ │ │ │ ├── user-list.component.ts │ │ │ │ └── user-list.component.spec.ts │ │ │ ├── current-user.service.spec.ts │ │ │ └── current-user.service.ts │ │ ├── config │ │ │ ├── config.ts │ │ │ ├── config.provider.ts │ │ │ ├── config.resolver.ts │ │ │ ├── config.provider.spec.ts │ │ │ └── config.resolver.spec.ts │ │ ├── app.component.html │ │ ├── app.component.scss │ │ ├── error-handler.ts │ │ ├── comments │ │ │ ├── comment.service.ts │ │ │ ├── comment.material.ts │ │ │ ├── comment │ │ │ │ ├── comment.component.html │ │ │ │ ├── comment.component.ts │ │ │ │ └── comment.component.spec.ts │ │ │ └── comment.service.spec.ts │ │ ├── project │ │ │ ├── project-settings │ │ │ │ └── project-settings.component.scss │ │ │ ├── project-list │ │ │ │ ├── project-list.component.scss │ │ │ │ └── project-list.component.html │ │ │ ├── project.resolver.ts │ │ │ └── all-projects.resolver.ts │ │ ├── dashboard │ │ │ ├── dashboard.component.scss │ │ │ ├── dashboard.component.html │ │ │ ├── dashboard.component.spec.ts │ │ │ └── dashboard.component.ts │ │ ├── app.component.spec.ts │ │ ├── app.component.ts │ │ └── app-routing.module.ts │ ├── favicon.ico │ ├── favicon.xcf │ ├── index.html │ ├── main.ts │ ├── colors.scss │ └── environments │ │ ├── environment.ts │ │ ├── environment.local.ts │ │ └── environment.prod.ts ├── .dockerignore ├── nginx.conf ├── .htaccess-stm-test ├── tsconfig.app.json ├── .vimrc ├── jest │ ├── worker-mock.ts │ └── setup-jest.ts ├── .editorconfig ├── tsconfig.spec.json ├── .browserslistrc ├── Dockerfile ├── jest.config.js ├── .gitignore ├── tsconfig.json ├── tslint-to-eslint-config.log └── package.json ├── screenshot.webp ├── server ├── .dockerignore ├── comment │ ├── api.go │ ├── entity.go │ ├── service.go │ └── service_test.go ├── database │ ├── scripts │ │ ├── 009_remove-task-ids-from-projects.sql │ │ ├── 002_project-description.sql │ │ ├── 000_init.sql │ │ ├── 010_add-project-creation-date.sql │ │ ├── 006_project-task-relation.sql │ │ ├── 013_josm-data-source.sql │ │ ├── 007_remove-stale-tasks.sql │ │ ├── 001_basic-tables.sql │ │ ├── 008_migrate-project-task-relation.sh │ │ ├── 011_comments.sql │ │ ├── common.sh │ │ ├── 003_string-to-array-migration.sql │ │ ├── 012_initial-comment-migration.sh │ │ └── 005_migrate-to-geo-json.sh │ ├── database.go │ └── init-db.sh ├── config │ ├── default.json │ ├── prod.json │ ├── local.json │ ├── root_test.go │ ├── api.go │ └── api_test.go ├── test │ └── test-config.json ├── oauth2 │ └── entity.go ├── task │ ├── api.go │ └── entity.go ├── export │ └── entity.go ├── util │ ├── random.go │ ├── logger.go │ └── util.go ├── Dockerfile ├── go.mod ├── project │ ├── api.go │ └── entity.go ├── api │ └── context.go ├── main.go └── run-tests.sh ├── doc ├── architecture │ ├── server-diagram.png │ └── server.puml ├── authentication │ └── authentication.png ├── operation │ ├── certbot.timer │ ├── stm-backup.timer │ ├── stm-backup.service │ ├── certbot.service │ ├── README.md │ └── docker.md └── README.md ├── .gitignore ├── create-backup.sh ├── README.ja.md └── docker-compose.yml /client/src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/i18n/notice.de.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/i18n/notice.es.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/i18n/notice.it.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/i18n/notice.en-US.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/app/auth/oauth-landing/oauth-landing.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/app/project-creation/task-edit/task-edit.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /screenshot.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/HEAD/screenshot.webp -------------------------------------------------------------------------------- /client/src/app/auth/oauth-landing/oauth-landing.component.html: -------------------------------------------------------------------------------- 1 |

Succesfully logged in :)

2 | -------------------------------------------------------------------------------- /client/src/app/common/entities/josm-data-source.ts: -------------------------------------------------------------------------------- 1 | export type JosmDataSource = 'OSM' | 'OVERPASS'; 2 | -------------------------------------------------------------------------------- /client/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/HEAD/client/src/favicon.ico -------------------------------------------------------------------------------- /client/src/favicon.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/HEAD/client/src/favicon.xcf -------------------------------------------------------------------------------- /client/src/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/HEAD/client/src/assets/icon.png -------------------------------------------------------------------------------- /server/.dockerignore: -------------------------------------------------------------------------------- 1 | docker-compose* 2 | .dockerignore 3 | .git 4 | .gitignore 5 | server 6 | server.debug 7 | 8 | -------------------------------------------------------------------------------- /client/.dockerignore: -------------------------------------------------------------------------------- 1 | docker-compose* 2 | .dockerignore 3 | .git 4 | .gitignore 5 | node_modules 6 | npm-debug.log 7 | 8 | -------------------------------------------------------------------------------- /client/src/app/task/task-map/task-map.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /doc/architecture/server-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/HEAD/doc/architecture/server-diagram.png -------------------------------------------------------------------------------- /doc/authentication/authentication.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/HEAD/doc/authentication/authentication.png -------------------------------------------------------------------------------- /server/comment/api.go: -------------------------------------------------------------------------------- 1 | package comment 2 | 3 | type DraftDto struct { 4 | Text string `json:"text"` // The text of the comment. 5 | } 6 | -------------------------------------------------------------------------------- /client/src/app/common/entities/language.ts: -------------------------------------------------------------------------------- 1 | export class Language { 2 | constructor(public code: string, public name: string) { 3 | } 4 | } 5 | -------------------------------------------------------------------------------- /client/src/app/ui/footer/footer.component.html: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /client/src/app/ui/language-selection/language-selection.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../../styles"; 2 | 3 | .selection { 4 | width: 120px; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/app/project-creation/task-draft-list/task-draft-list.component.scss: -------------------------------------------------------------------------------- 1 | @use '../../../styles'; 2 | 3 | :host { 4 | overflow-y: auto; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/assets/fonts/Linearicons-Free.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/HEAD/client/src/assets/fonts/Linearicons-Free.eot -------------------------------------------------------------------------------- /client/src/assets/fonts/Linearicons-Free.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/HEAD/client/src/assets/fonts/Linearicons-Free.ttf -------------------------------------------------------------------------------- /client/src/assets/fonts/Linearicons-Free.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/HEAD/client/src/assets/fonts/Linearicons-Free.woff -------------------------------------------------------------------------------- /client/src/assets/login-background-left.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/HEAD/client/src/assets/login-background-left.webp -------------------------------------------------------------------------------- /client/src/assets/login-background-right.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/HEAD/client/src/assets/login-background-right.webp -------------------------------------------------------------------------------- /client/src/app/ui/toolbar/toolbar.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 | -------------------------------------------------------------------------------- /client/src/assets/fonts/Linearicons-Free.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/HEAD/client/src/assets/fonts/Linearicons-Free.woff2 -------------------------------------------------------------------------------- /client/src/app/user/user-invitation/user-invitation.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../../styles"; 2 | 3 | #userInput { 4 | margin-right: styles.$space-base; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/app/project-creation/project-import/project-import.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../../styles"; 2 | 3 | .import-button { 4 | margin-top: styles.$space-base; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/app/project-creation/shape-upload/shape-upload.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../../styles"; 2 | 3 | .upload-button { 4 | margin-top: styles.$space-base; 5 | } 6 | -------------------------------------------------------------------------------- /doc/operation/certbot.timer: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Certbot renewal 3 | 4 | [Timer] 5 | OnCalendar=Mon *-*-* 00:00:00 6 | 7 | [Install] 8 | WantedBy=multi-user.target 9 | -------------------------------------------------------------------------------- /doc/operation/stm-backup.timer: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=STM database backup creation 3 | 4 | [Timer] 5 | OnCalendar=*-*-* 00:00:00 6 | 7 | [Install] 8 | WantedBy=multi-user.target 9 | -------------------------------------------------------------------------------- /client/src/app/task/task-map/task-map.component.scss: -------------------------------------------------------------------------------- 1 | .map { 2 | height: 100%; 3 | } 4 | 5 | .map-container { 6 | display: flex; 7 | flex-direction: column; 8 | flex-grow: 1; 9 | } 10 | -------------------------------------------------------------------------------- /client/src/app/user/user.material.ts: -------------------------------------------------------------------------------- 1 | export class User { 2 | constructor( 3 | public name: string, 4 | public uid: string, 5 | public hasName: boolean = true 6 | ) { 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /client/src/assets/i18n/changelog.de.html: -------------------------------------------------------------------------------- 1 |

Änderungen in 1.7.0:

2 | 3 | 7 | -------------------------------------------------------------------------------- /client/src/assets/i18n/changelog.en-US.html: -------------------------------------------------------------------------------- 1 |

Changes in 1.7.0:

2 | 3 | 7 | -------------------------------------------------------------------------------- /server/database/scripts/009_remove-task-ids-from-projects.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | 3 | ALTER TABLE projects DROP COLUMN task_ids; 4 | 5 | INSERT INTO db_versions VALUES('009'); 6 | 7 | END TRANSACTION; -------------------------------------------------------------------------------- /server/config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "server-url": "http://127.0.0.1:8080", 3 | "client-auth-redirect-url": "http://localhost:4200/oauth-landing", 4 | "debug-logging": true, 5 | "max-task-per-project": 100 6 | } -------------------------------------------------------------------------------- /server/database/scripts/002_project-description.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | 3 | ALTER TABLE projects ADD COLUMN description TEXT NOT NULL DEFAULT ''; 4 | 5 | INSERT INTO db_versions VALUES('002'); 6 | 7 | END TRANSACTION; -------------------------------------------------------------------------------- /client/src/app/common/components/map/map.component.html: -------------------------------------------------------------------------------- 1 | 6 |
7 | -------------------------------------------------------------------------------- /client/src/app/ui/icon-button/icon-button.component.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/config/prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "server-url": "https://stm.hauke-stieler.de/api/", 3 | "client-auth-redirect-url": "https://stm.hauke-stieler.de/oauth-landing", 4 | "debug-logging": false, 5 | "max-task-per-project": 2000 6 | } 7 | -------------------------------------------------------------------------------- /client/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80 default_server; 3 | listen [::]:80 default_server; 4 | 5 | location / { 6 | root /usr/share/nginx/html; 7 | index index.html; 8 | try_files $uri $uri/ /index.html; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /client/src/app/config/config.ts: -------------------------------------------------------------------------------- 1 | export class Config { 2 | public sourceRepoUrl = ''; 3 | public maxTasksPerProject = 0; 4 | public maxDescriptionLength = 0; 5 | public testEnvironment = false; 6 | public osmApiUrl = ''; 7 | } 8 | -------------------------------------------------------------------------------- /client/src/app/project-creation/task-edit/task-edit.component.html: -------------------------------------------------------------------------------- 1 |
2 | {{'name' | translate}} 3 | 4 |
5 | -------------------------------------------------------------------------------- /client/src/app/ui/language-selection/language-selection.component.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /server/config/local.json: -------------------------------------------------------------------------------- 1 | { 2 | "server-url": "http://localhost", 3 | "client-auth-redirect-url": "http://localhost:4200/oauth-landing", 4 | "osm-base-url": "http://localhost:9000", 5 | "osm-api-url": "http://localhost:9000/api/0.6", 6 | "debug-logging": true 7 | } -------------------------------------------------------------------------------- /server/database/scripts/000_init.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | 3 | CREATE TABLE IF NOT EXISTS db_versions( 4 | version TEXT NOT NULL 5 | ); 6 | 7 | -- Store version of the database scheme 8 | INSERT INTO db_versions VALUES('000'); 9 | 10 | END TRANSACTION; -------------------------------------------------------------------------------- /client/src/app/project-creation/shape-remote/shape-remote.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../../styles"; 2 | 3 | .url-input-heading { 4 | margin-bottom: styles.$space-base; 5 | } 6 | 7 | .url-input { 8 | width: 100%; 9 | margin-bottom: styles.$space-base; 10 | } 11 | -------------------------------------------------------------------------------- /server/database/scripts/010_add-project-creation-date.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | 3 | -- Can be null for old data before adding this column 4 | ALTER TABLE projects ADD COLUMN creation_date TIMESTAMP; 5 | 6 | INSERT INTO db_versions VALUES('010'); 7 | 8 | END TRANSACTION; -------------------------------------------------------------------------------- /server/test/test-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "server-url": "https://unit-test.stm.org", 3 | "client-auth-redirect-url": "https://unit-test.stm.org/oauth-landing", 4 | "db-database": "stm_test", 5 | "oauth2-client-id": "oAuTH_cliENt-iD", 6 | "oauth2-secret": "seCreT" 7 | } 8 | -------------------------------------------------------------------------------- /client/.htaccess-stm-test: -------------------------------------------------------------------------------- 1 | 2 | RewriteEngine On 3 | RewriteBase /stm-test 4 | RewriteRule ^index\.html$ - [L] 5 | RewriteCond %{REQUEST_FILENAME} !-f 6 | RewriteCond %{REQUEST_FILENAME} !-d 7 | RewriteRule . index.html [L] 8 | 9 | -------------------------------------------------------------------------------- /client/src/assets/i18n/changelog.it.html: -------------------------------------------------------------------------------- 1 |

Novità in 1.7.0:

2 | 3 | (Traduzione automatica) 4 | 5 | -------------------------------------------------------------------------------- /client/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/app", 5 | "types": [] 6 | }, 7 | "files": [ 8 | "src/main.ts" 9 | ], 10 | "include": [ 11 | "src/**/*.d.ts" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /client/src/assets/i18n/changelog.es.html: -------------------------------------------------------------------------------- 1 |

Cambios en 1.7.0:

2 | 3 | (Traducción automática) 4 | 5 | -------------------------------------------------------------------------------- /client/src/assets/i18n/changelog.fr.html: -------------------------------------------------------------------------------- 1 |

Modifications en 1.7.0 :

2 | 3 | (Traduction automatique) 4 | 5 | -------------------------------------------------------------------------------- /client/src/app/app.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 | 6 |
7 | -------------------------------------------------------------------------------- /server/oauth2/entity.go: -------------------------------------------------------------------------------- 1 | package oauth2 2 | 3 | // Osm is a struct used when requesting user information 4 | type Osm struct { 5 | User OsmUser `xml:"user"` 6 | } 7 | 8 | type OsmUser struct { 9 | DisplayName string `xml:"display_name,attr"` 10 | UserId string `xml:"id,attr"` 11 | } 12 | -------------------------------------------------------------------------------- /client/src/app/ui/zoom-control/zoom-control.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /server/database/scripts/006_project-task-relation.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | 3 | ALTER TABLE tasks ADD COLUMN project_id int; 4 | ALTER TABLE tasks ADD CONSTRAINT fk_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; 5 | 6 | INSERT INTO db_versions VALUES('006'); 7 | 8 | END TRANSACTION; -------------------------------------------------------------------------------- /client/.vimrc: -------------------------------------------------------------------------------- 1 | autocmd BufEnter * call SetTabStop() 2 | 3 | " For .ts files: Use 2 spaced for indentation 4 | fun! SetTabStop() 5 | if buffer_name("%") =~ '\.ts$' 6 | set tabstop=2 7 | set softtabstop=0 8 | set expandtab 9 | else 10 | set tabstop=4 11 | set noexpandtab 12 | endif 13 | endfun 14 | 15 | -------------------------------------------------------------------------------- /client/src/app/project-creation/copy-project/copy-project.component.scss: -------------------------------------------------------------------------------- 1 | @use '../../../styles'; 2 | 3 | .project-list { 4 | overflow-y: auto; 5 | } 6 | 7 | .project-list-row { 8 | display: flex; 9 | justify-content: space-between; 10 | } 11 | 12 | .import-button { 13 | margin-top: styles.$space-base; 14 | } 15 | -------------------------------------------------------------------------------- /client/src/app/ui/toolbar/toolbar.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-toolbar', 5 | templateUrl: './toolbar.component.html', 6 | styleUrls: ['./toolbar.component.scss'], 7 | standalone: false 8 | }) 9 | export class ToolbarComponent { 10 | } 11 | -------------------------------------------------------------------------------- /client/src/app/ui/footer/footer.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../../styles"; 2 | @use "../../../colors"; 3 | 4 | .version-label { 5 | margin: styles.$space-large; 6 | } 7 | 8 | .sep { 9 | margin-left: styles.$space-large; 10 | margin-right: styles.$space-large; 11 | border-right: 1px solid colors.$color-gray-mid; 12 | } 13 | -------------------------------------------------------------------------------- /server/config/root_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // This is just a helper file because there are multiple test files in this package. 4 | 5 | import ( 6 | "stm/test" 7 | "testing" 8 | ) 9 | 10 | var ( 11 | h *test.Helper 12 | ) 13 | 14 | func TestMain(m *testing.M) { 15 | h = &test.Helper{} 16 | m.Run() 17 | } 18 | -------------------------------------------------------------------------------- /client/src/app/user/user-invitation/user-invitation.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
5 | -------------------------------------------------------------------------------- /client/src/app/ui/progress-bar/progress-bar.component.html: -------------------------------------------------------------------------------- 1 | {{getProcessPointPercentage()}} % 2 |
3 |
4 |
5 | -------------------------------------------------------------------------------- /server/database/scripts/013_josm-data-source.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | 3 | ALTER TABLE projects ADD COLUMN josm_data_source TEXT; 4 | 5 | -- Set default value to "Overpass" since it's the current behavior 6 | UPDATE projects SET josm_data_source='OVERPASS'; 7 | 8 | INSERT INTO db_versions VALUES ('013'); 9 | 10 | END TRANSACTION; -------------------------------------------------------------------------------- /client/src/app/ui/tabs/tabs.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |
6 |
7 | 8 |
9 | -------------------------------------------------------------------------------- /client/src/app/project-creation/project-properties/project-properties.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../../styles"; 2 | 3 | .description-text-area { 4 | width: 100%; 5 | height: 120px; 6 | } 7 | 8 | .description-text-area-head { 9 | display: flex; 10 | flex-direction: row; 11 | justify-content: space-between; 12 | align-items: baseline; 13 | } 14 | -------------------------------------------------------------------------------- /client/src/app/ui/icon-button/icon-button.component.scss: -------------------------------------------------------------------------------- 1 | @use "sass:math"; 2 | @use "../../../styles"; 3 | @use "../../../colors"; 4 | @use "../../../icons"; 5 | 6 | :host { 7 | display: inline-block; 8 | } 9 | 10 | .button { 11 | display: flex; 12 | align-items: center; 13 | 14 | .lnr { 15 | margin-right: styles.$space-base; 16 | } 17 | } -------------------------------------------------------------------------------- /client/jest/worker-mock.ts: -------------------------------------------------------------------------------- 1 | export default class Worker { 2 | public onmessage: (msg: string) => void; 3 | 4 | constructor(public url: string) { 5 | this.onmessage = () => {}; 6 | } 7 | 8 | postMessage(msg: string): void { 9 | this.onmessage(msg); 10 | } 11 | 12 | addEventListener(event: string, fn: () => void): void {} 13 | } 14 | -------------------------------------------------------------------------------- /client/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /doc/operation/stm-backup.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=STM database backup creation 3 | 4 | [Service] 5 | WorkingDirectory=/home/stm/simple-task-manager 6 | Type=oneshot 7 | PrivateTmp=true 8 | EnvironmentFile=/home/stm/simple-task-manager/.env 9 | ExecStart=/home/stm/simple-task-manager/create-backup.sh 10 | 11 | [Install] 12 | WantedBy=multi-user.target 13 | -------------------------------------------------------------------------------- /client/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/spec", 5 | "types": [ 6 | "jest" 7 | ], 8 | "module": "commonjs", 9 | "emitDecoratorMetadata": true, 10 | "allowJs": true 11 | }, 12 | "include": [ 13 | "src/**/*.spec.ts", 14 | "src/**/*.d.ts" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /client/src/app/common/components/map/map.component.scss: -------------------------------------------------------------------------------- 1 | @use '../../../../styles'; 2 | 3 | :host { 4 | position: relative; 5 | } 6 | 7 | .zoom-control { 8 | position: absolute; 9 | z-index: 5; // Because of OpenLayers claiming some z-indices 10 | left: 0px; 11 | top: 0px; 12 | margin: styles.$space-base; 13 | } 14 | 15 | #map { 16 | height: 100%; 17 | } 18 | -------------------------------------------------------------------------------- /client/src/app/project-creation/project-properties.ts: -------------------------------------------------------------------------------- 1 | import { JosmDataSource } from '../common/entities/josm-data-source'; 2 | 3 | export class ProjectProperties { 4 | constructor( 5 | public projectName: string, 6 | public maxProcessPoints: number, 7 | public projectDescription: string, 8 | public josmDataSource: JosmDataSource 9 | ) { 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /client/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SimpleTaskManager 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/src/app/project-creation/task-draft-list/task-draft-list.component.html: -------------------------------------------------------------------------------- 1 |
2 | {{'project-creation.no-task-yet-notice' | translate}} 3 |
4 |
7 |
8 | {{t.name}} 9 |
10 |
11 | -------------------------------------------------------------------------------- /client/src/app/user/user-list/user-list.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{ u.name }} 4 | 9 |
10 |
11 | -------------------------------------------------------------------------------- /server/database/scripts/007_remove-stale-tasks.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | 3 | DELETE FROM tasks WHERE NOT 4 | ( 5 | id = ANY 6 | ( 7 | ( 8 | SELECT array_agg(c) FROM 9 | ( 10 | SELECT unnest(task_ids) FROM projects 11 | ) AS dt(c) 12 | )::INT[] 13 | ) 14 | ); 15 | 16 | INSERT INTO db_versions VALUES('007'); 17 | 18 | END TRANSACTION; -------------------------------------------------------------------------------- /client/src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule) 12 | .catch(err => console.error(err)); 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Server side 2 | server/server 3 | server/stm 4 | server/server.debug 5 | server/coverage.out 6 | server/go.sum 7 | server/test.log 8 | 9 | # Migration files 10 | server/database/.tmp.* 11 | 12 | # Client caches and stuff 13 | client/.angular/ 14 | 15 | # IDE stuff 16 | **.idea 17 | client/.run 18 | 19 | # Docker stuff 20 | postgres-data 21 | .env 22 | 23 | # Backups 24 | *.sql.gz 25 | 26 | # Example files 27 | example* 28 | -------------------------------------------------------------------------------- /client/src/app/common/mock-router.ts: -------------------------------------------------------------------------------- 1 | import { Observable, of } from 'rxjs'; 2 | import { Event } from '@angular/router'; 3 | 4 | export class MockRouter { 5 | public events = new Observable(); 6 | 7 | navigate(commands: any[]): Promise { 8 | return of(true).toPromise(); 9 | } 10 | 11 | navigateByUrl(url: string): Promise { 12 | return of(true).toPromise(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /create-backup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | echo "Start backup job" 6 | 7 | DIR=backups 8 | YESTERDAY=$(date --date="yesterday" +%Y-%m-%d) 9 | 10 | echo "Create output folder" 11 | mkdir -p $DIR 12 | echo "Done" 13 | 14 | echo "Create compressed database dump" 15 | pg_dump -h $STM_DB_HOST -U $STM_DB_USERNAME --create -d stm | gzip -9 > "$DIR/stm-db-backup_$YESTERDAY.sql.gz" 16 | echo "Done" 17 | 18 | echo "Finished backup job" 19 | -------------------------------------------------------------------------------- /client/src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | @use "../styles"; 2 | @use '../colors'; 3 | 4 | :host { 5 | width: 100%; 6 | height: 100%; 7 | display: block; 8 | } 9 | 10 | .host-in-test-mode { 11 | height: calc(100% - #{styles.$space-large}); 12 | } 13 | .host-in-prod-mode { 14 | height: 100%; 15 | } 16 | 17 | .test-label { 18 | display: flex; 19 | justify-content: center; 20 | background-color: colors.$color-warn; 21 | height: styles.$space-large; 22 | } 23 | -------------------------------------------------------------------------------- /client/src/app/common/entities/websocket-message.ts: -------------------------------------------------------------------------------- 1 | export class WebsocketMessage { 2 | constructor( 3 | public type: WebsocketMessageType, 4 | public id: string 5 | ) { 6 | } 7 | } 8 | 9 | export enum WebsocketMessageType { 10 | MessageType_ProjectAdded = 'project_added', 11 | MessageType_ProjectUpdated = 'project_updated', 12 | MessageType_ProjectDeleted = 'project_deleted', 13 | MessageType_ProjectUserRemoved = 'project_user_removed', 14 | } 15 | -------------------------------------------------------------------------------- /client/src/app/project-creation/project-import/project-import.component.html: -------------------------------------------------------------------------------- 1 |

{{'project-creation.import-heading' | translate}}

2 | 3 |
{{'project-creation.import-notice' | translate}}
4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /client/src/app/task/task-title.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | import { Task } from './task.material'; 3 | 4 | @Pipe({ 5 | name: 'taskTitle', 6 | standalone: false 7 | }) 8 | export class TaskTitlePipe implements PipeTransform { 9 | transform(value: Task | undefined, ...args: unknown[]): unknown { 10 | if (!value) { 11 | return ''; 12 | } 13 | 14 | const task = value ; 15 | return !task.name ? task.id : task.name; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /doc/operation/certbot.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Certbot 3 | Documentation=file:///usr/share/doc/python-certbot-doc/html/index.html 4 | Documentation=https://letsencrypt.readthedocs.io/en/latest/ 5 | 6 | [Service] 7 | Type=oneshot 8 | PrivateTmp=true 9 | ExecStart=/usr/bin/certbot renew --pre-hook "bash -c \"cd /home/stm/simple-task-manager && docker-compose stop\"" --post-hook "bash -c \"cd /home/stm/simple-task-manager && docker-compose start\"" 10 | 11 | [Install] 12 | WantedBy=multi-user.target 13 | -------------------------------------------------------------------------------- /client/src/app/error-handler.ts: -------------------------------------------------------------------------------- 1 | import { ErrorHandler, Injectable } from '@angular/core'; 2 | import { NotificationService } from './common/services/notification.service'; 3 | 4 | @Injectable() 5 | export class GlobalErrorHandler implements ErrorHandler { 6 | 7 | constructor(private notificationService: NotificationService) { 8 | } 9 | 10 | handleError(error: any): void { 11 | this.notificationService.addError('Unexcpected error occured: ' + error); 12 | console.error(error); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /client/src/app/project-creation/shape-upload/shape-upload.component.html: -------------------------------------------------------------------------------- 1 |

{{'project-creation.upload' | translate}}

2 | 3 |
{{'project-creation.upload-notice' | translate}}
4 | 5 | 7 | 9 | -------------------------------------------------------------------------------- /doc/operation/README.md: -------------------------------------------------------------------------------- 1 | Here you find resources on how to run STM on a server. 2 | 3 | * [linux.md](linux.md) : Linux setup, i.e. user management, firewall, etc. 4 | * [stm.md](stm.md) : STM-Server setup 5 | * [docker.md](docker.md) : Docker architecture, deployment and logging 6 | * [logging.md](logging.md) : Structure of journald log entries and how to use them 7 | * [ssl-cert.md](ssl-cert.md) : Setup, configuration and updating of SSL-certificates 8 | * [automatic-backups.md](automatic-backups.md) : Setup of automatic backups creation 9 | -------------------------------------------------------------------------------- /client/src/app/common/unsubscriber.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, OnDestroy } from '@angular/core'; 2 | import { Subscription } from 'rxjs'; 3 | 4 | @Injectable({ 5 | providedIn: 'root' 6 | }) 7 | export class Unsubscriber implements OnDestroy { 8 | private subscriptions: Subscription[] = []; 9 | 10 | public unsubscribeLater(...subject: Subscription[]): void { 11 | this.subscriptions.push(...subject); 12 | } 13 | 14 | public ngOnDestroy(): void { 15 | this.subscriptions.forEach(s => s.unsubscribe()); 16 | this.subscriptions = []; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /client/jest/setup-jest.ts: -------------------------------------------------------------------------------- 1 | import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone/index'; 2 | 3 | window.URL.createObjectURL = () => ''; 4 | 5 | // Mock the RBush for OpenLayers. Otherwise, the RBush constructor is somehow unavailable. 6 | jest.mock('ol/structs/RBush'); 7 | 8 | window.fail = (reason: any) => { 9 | throw new Error(reason + ''); 10 | }; 11 | 12 | window.ResizeObserver = jest.fn().mockImplementation(() => ({ 13 | observe: jest.fn(), 14 | unobserve: jest.fn(), 15 | disconnect: jest.fn(), 16 | })); 17 | 18 | setupZoneTestEnv(); 19 | -------------------------------------------------------------------------------- /client/src/app/ui/toolbar/toolbar.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../../styles"; 2 | @use "../../../colors"; 3 | 4 | .toolbar { 5 | left: 0px; 6 | padding-left: styles.$space-large; 7 | padding-right: styles.$space-large; 8 | padding-top: styles.$space-base; 9 | padding-bottom: styles.$space-base; 10 | color: colors.$color-white; 11 | background-color: colors.$color-mid; 12 | height: styles.$space-huge; 13 | display: flex; 14 | align-items: center; 15 | justify-content: space-between; 16 | } 17 | 18 | .toolbar > p { 19 | color: colors.$color-gray-light; 20 | } 21 | -------------------------------------------------------------------------------- /client/src/app/ui/zoom-control/zoom-control.component.scss: -------------------------------------------------------------------------------- 1 | @use '../../../styles'; 2 | @use "../../../colors"; 3 | @use '../../../icons'; 4 | 5 | :host { 6 | display: flex; 7 | flex-direction: column; 8 | } 9 | 10 | button { 11 | background-color: colors.$color-white-transparent; 12 | width: styles.$space-huge; 13 | height: styles.$space-huge; 14 | padding: unset; 15 | margin-bottom: -1px; // Get rid of double border 16 | } 17 | 18 | button:hover { 19 | background-color: colors.$color-very-light-transparent; 20 | } 21 | button:hover { 22 | z-index: 1; 23 | } 24 | -------------------------------------------------------------------------------- /client/src/app/comments/comment.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Comment, CommentDto } from './comment.material'; 3 | import { User } from '../user/user.material'; 4 | 5 | @Injectable({ 6 | providedIn: 'root' 7 | }) 8 | export class CommentService { 9 | public toCommentsWithUserMap(dtos: CommentDto[], userMap: Map): Comment[] { 10 | return dtos.map(dto => new Comment( 11 | dto.id, 12 | dto.text, 13 | userMap.get(dto.authorId) as User, 14 | new Date(dto.creationDate) 15 | )); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /server/task/api.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | type DraftDto struct { 4 | MaxProcessPoints int `json:"maxProcessPoints"` // The maximum amount of process points of this task. Must be larger than zero. 5 | ProcessPoints int `json:"processPoints"` // The amount of process points that have been set by the user. It applies that "0 <= processPoints <= maxProcessPoints". 6 | Geometry string `json:"geometry"` // A GeoJson feature with a polygon or multi-polygon geometry. If the feature properties contain the field "name", then this will be used as the name of the task. 7 | } 8 | -------------------------------------------------------------------------------- /client/src/app/ui/footer/footer.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, ViewEncapsulation } from '@angular/core'; 2 | import packageInfo from '../../../../package.json'; 3 | 4 | @Component({ 5 | selector: 'app-footer', 6 | templateUrl: './footer.component.html', 7 | styleUrls: ['./footer.component.scss'], 8 | encapsulation: ViewEncapsulation.None, 9 | standalone: false 10 | }) 11 | export class FooterComponent implements OnInit { 12 | public version: string = packageInfo.version; 13 | 14 | constructor() { 15 | } 16 | 17 | ngOnInit(): void { 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /client/src/app/ui/icon-button/icon-button.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, Output } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-icon-button', 5 | templateUrl: './icon-button.component.html', 6 | styleUrls: ['./icon-button.component.scss'], 7 | standalone: false 8 | }) 9 | export class IconButtonComponent { 10 | @Input() 11 | public icon: string; 12 | 13 | @Input() 14 | public textKey: string; 15 | 16 | @Input() 17 | public disabled = false; 18 | 19 | @Output() 20 | public clicked = new EventEmitter(); 21 | } 22 | -------------------------------------------------------------------------------- /client/src/app/project-creation/shape-divide/shape-divide.component.scss: -------------------------------------------------------------------------------- 1 | @use '../../../styles'; 2 | @use '../../../colors'; 3 | @use '../../../icons'; 4 | 5 | .space-between { 6 | justify-content: space-between; 7 | } 8 | 9 | .meter-input { 10 | min-width: 0px; 11 | width: 100%; 12 | } 13 | 14 | .meter-label, 15 | .divide-button { 16 | margin-left: styles.$space-base; 17 | } 18 | 19 | .lnr-warning { 20 | margin-right: styles.$space-small; 21 | } 22 | 23 | button.selected:hover, 24 | .selected { 25 | border-left: 2px solid colors.$color-mid; 26 | background-color: colors.$color-lighter; 27 | } -------------------------------------------------------------------------------- /client/.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # You can see what browsers were selected by your queries by running: 6 | # npx browserslist 7 | 8 | last 2 Chrome versions 9 | last 1 Firefox version 10 | last 2 Edge major versions 11 | last 2 Safari major versions 12 | last 2 iOS major versions 13 | Firefox ESR 14 | not dead 15 | not IE 9-11 # For IE 9-11 support, remove 'not'. -------------------------------------------------------------------------------- /client/src/app/ui/progress-bar/progress-bar.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../../styles"; 2 | @use "../../../colors"; 3 | 4 | :host { 5 | display: flex; 6 | align-items: center; 7 | } 8 | 9 | .percentage-label { 10 | color: colors.$color-gray-mid; 11 | text-align: end; 12 | } 13 | 14 | .border-bar { 15 | width: 100px; 16 | padding: 2px; 17 | margin-left: styles.$space-base; 18 | border: 1px solid colors.$color-light; 19 | display: flex; 20 | justify-content: flex-start; 21 | align-items: center; 22 | } 23 | 24 | .color-bar { 25 | height: styles.$space-base - 4px; 26 | border: 1px solid; 27 | } 28 | -------------------------------------------------------------------------------- /client/src/app/comments/comment.material.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../user/user.material'; 2 | 3 | export class CommentDto { 4 | constructor(public id: number, 5 | public text: string, 6 | public authorId: string, 7 | public creationDate: string) { 8 | } 9 | } 10 | 11 | export class CommentDraftDto { 12 | constructor(public text: string) { 13 | } 14 | } 15 | 16 | export class Comment { 17 | constructor(public id: number, 18 | public text: string, 19 | public author: User, 20 | public creationDate: Date) { 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /client/src/app/auth/oauth-landing/oauth-landing.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { ActivatedRoute } from '@angular/router'; 3 | 4 | @Component({ 5 | selector: 'app-oauth-landing', 6 | templateUrl: './oauth-landing.component.html', 7 | styleUrls: ['./oauth-landing.component.scss'], 8 | standalone: false 9 | }) 10 | export class OauthLandingComponent { 11 | constructor(private route: ActivatedRoute) { 12 | this.route.queryParams.subscribe(params => { 13 | localStorage.setItem('auth_token', params.token); 14 | window.close(); 15 | }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/src/app/project-creation/drawing-toolbar/drawing-toolbar.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /server/comment/entity.go: -------------------------------------------------------------------------------- 1 | package comment 2 | 3 | import "time" 4 | 5 | type Comment struct { 6 | Id string `json:"id"` // The ID of the task. 7 | Text string `json:"text"` // The name of the task. If the properties of the geometry feature contain the field "name", this field is used here. If no name has been set, this field will be empty. 8 | AuthorId string `json:"authorId"` // The user-ID of the user who is currently assigned to this task. Will never be NULL but might be empty. 9 | CreationDate *time.Time `json:"creationDate"` // The time this comment was created at. 10 | } 11 | -------------------------------------------------------------------------------- /client/src/app/common/selected-language.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { CanActivate, CanLoad } from '@angular/router'; 3 | import { LanguageService } from './services/language.service'; 4 | import { environment } from '../../environments/environment'; 5 | 6 | @Injectable() 7 | export class SelectedLanguageGuard implements CanActivate { 8 | constructor(private languageService: LanguageService) { 9 | } 10 | 11 | public canActivate(): boolean { 12 | // Don't care about language redirect when working locally (when "production === false") 13 | return this.languageService.loadLanguageFromLocalStorage(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /client/src/app/project/project-settings/project-settings.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../../styles"; 2 | @use "../../../colors"; 3 | @use "../../../icons"; 4 | 5 | .export-project-button, .save-button { 6 | margin-top: styles.$space-base; 7 | } 8 | 9 | .space-right { 10 | margin-right: styles.$space-base; 11 | } 12 | 13 | .description-text-area { 14 | width: 100%; 15 | height: 120px; 16 | } 17 | 18 | .name-input { 19 | width: 100%; 20 | } 21 | 22 | .josm-data-source-input { 23 | width: 100%; 24 | } 25 | 26 | // Also apply style to divs in child components using ::ng-deep 27 | .project-properties-container ::ng-deep .form-entry { 28 | margin-bottom: styles.$space-base 29 | } 30 | -------------------------------------------------------------------------------- /server/database/scripts/001_basic-tables.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | 3 | -- Define tables "projects", "tasks", "db_version" 4 | CREATE TABLE projects( 5 | id SERIAL PRIMARY KEY NOT NULL, 6 | name TEXT NOT NULL, 7 | task_ids TEXT NOT NULL, 8 | users TEXT NOT NULL, 9 | owner TEXT NOT NULL 10 | ); 11 | 12 | CREATE TABLE tasks( 13 | id SERIAL PRIMARY KEY NOT NULL, 14 | process_points INT, 15 | max_process_points INT, 16 | geometry TEXT, 17 | assigned_user TEXT 18 | ); 19 | 20 | INSERT INTO db_versions VALUES('001'); 21 | 22 | END TRANSACTION; -------------------------------------------------------------------------------- /server/export/entity.go: -------------------------------------------------------------------------------- 1 | package export 2 | 3 | import "time" 4 | 5 | type ProjectExport struct { 6 | Name string `json:"name"` 7 | Users []string `json:"users"` 8 | Owner string `json:"owner"` 9 | Description string `json:"description"` 10 | CreationDate *time.Time `json:"creationDate"` 11 | Tasks []*TaskExport `json:"tasks"` 12 | } 13 | 14 | type TaskExport struct { 15 | Name string `json:"name"` 16 | ProcessPoints int `json:"processPoints"` 17 | MaxProcessPoints int `json:"maxProcessPoints"` 18 | Geometry string `json:"geometry"` 19 | // TODO Use "Id" as suffix? 20 | AssignedUser string `json:"assignedUser"` 21 | } 22 | -------------------------------------------------------------------------------- /client/src/app/user/user-list/user-list.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../../styles"; 2 | @use "../../../colors"; 3 | 4 | .list-item { 5 | padding-right: 0px; 6 | } 7 | 8 | .user-without-name { 9 | font-style: italic; 10 | color: colors.$color-gray-light; 11 | } 12 | 13 | .item-content { 14 | display: flex; 15 | justify-content: space-between; 16 | } 17 | 18 | .remove-button { 19 | border: none; 20 | border-left: 1px solid colors.$color-light; 21 | width: 70px; 22 | background: none; 23 | display: flex; 24 | justify-content: center; 25 | align-items: center; 26 | } 27 | .remove-button:hover { 28 | border: none; 29 | border-left: 1px solid colors.$color-mid; 30 | background-color: colors.$color-very-light; 31 | } 32 | -------------------------------------------------------------------------------- /client/src/app/ui/toolbar/toolbar.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ToolbarComponent } from './toolbar.component'; 2 | import { MockBuilder, MockedComponentFixture, MockRender } from 'ng-mocks'; 3 | import { AppModule } from '../../app.module'; 4 | 5 | describe(ToolbarComponent.name, () => { 6 | let component: ToolbarComponent; 7 | let fixture: MockedComponentFixture; 8 | 9 | beforeEach(() => MockBuilder(ToolbarComponent, AppModule)); 10 | 11 | beforeEach(() => { 12 | fixture = MockRender(ToolbarComponent); 13 | component = fixture.point.componentInstance; 14 | fixture.detectChanges(); 15 | }); 16 | 17 | it('should create', () => { 18 | expect(component).toBeTruthy(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /client/src/app/project-creation/drawing-toolbar/drawing-toolbar.component.scss: -------------------------------------------------------------------------------- 1 | @use '../../../styles'; 2 | @use '../../../colors'; 3 | @use '../../../icons'; 4 | 5 | :host { 6 | display: flex; 7 | flex-direction: column; 8 | } 9 | 10 | button { 11 | background-color: colors.$color-white-transparent; 12 | width: styles.$space-huge; 13 | height: styles.$space-huge; 14 | padding: unset; 15 | margin-bottom: -1px; // Get rid of double border 16 | } 17 | 18 | button:hover { 19 | background-color: colors.$color-very-light-transparent; 20 | } 21 | button:hover { 22 | z-index: 1; 23 | } 24 | 25 | button.selected:hover, 26 | .selected { 27 | border-left: 2px solid colors.$color-mid; 28 | background-color: colors.$color-very-light; 29 | } 30 | -------------------------------------------------------------------------------- /server/util/random.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/sha256" 6 | "fmt" 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | func GetRandomString() (string, error) { 11 | randomBytes, err := GetRandomBytes(64) 12 | if err != nil { 13 | return "", err 14 | } 15 | 16 | return fmt.Sprintf("%x", sha256.Sum256(randomBytes)), nil 17 | } 18 | 19 | func GetRandomBytes(count int) ([]byte, error) { 20 | bytes := make([]byte, count) 21 | 22 | n, err := rand.Read(bytes) 23 | 24 | if n != count { 25 | return nil, errors.New(fmt.Sprintf("Could not read all %d random bytes", count)) 26 | } 27 | if err != nil { 28 | return nil, errors.Wrap(err, "Unable to read random bytes") 29 | } 30 | 31 | return bytes, nil 32 | } 33 | -------------------------------------------------------------------------------- /client/Dockerfile: -------------------------------------------------------------------------------- 1 | # 2 | # Stage 1: Buil 3 | # 4 | # https://hub.docker.com/_/node 5 | # 6 | FROM node:22-alpine AS builder 7 | 8 | COPY . /stm-client 9 | WORKDIR /stm-client/ 10 | 11 | COPY package.json /stm-client/package.json 12 | COPY package-lock.json /stm-client/package-lock.json 13 | 14 | RUN npm install 15 | 16 | RUN NODE_OPTIONS="--max_old_space_size=4096" npm run build 17 | 18 | # 19 | # Stage 2: Run 20 | # 21 | # https://hub.docker.com/_/nginx 22 | # 23 | FROM nginx:1.29-alpine 24 | 25 | RUN rm -rf /usr/share/nginx/html/* 26 | COPY --from=builder /stm-client/dist/simple-task-manager/browser /usr/share/nginx/html 27 | COPY --from=builder /stm-client/nginx.conf /etc/nginx/conf.d/default.conf 28 | 29 | ENTRYPOINT ["nginx", "-g", "daemon off;"] 30 | -------------------------------------------------------------------------------- /client/src/app/project/project-list/project-list.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../../styles"; 2 | @use "../../../colors"; 3 | 4 | :host { 5 | display: flex; 6 | flex-direction: column; 7 | } 8 | 9 | .list-item { 10 | display: flex; 11 | justify-content: space-between; 12 | } 13 | 14 | .list-item-column { 15 | display: flex; 16 | align-items: center; 17 | } 18 | 19 | .no-wrap { 20 | white-space: nowrap; 21 | } 22 | 23 | .light-label { 24 | color: colors.$color-gray-mid; 25 | } 26 | 27 | .progress-bar { 28 | width: 180px; 29 | justify-content: end; 30 | margin-right: styles.$space-small; 31 | } 32 | 33 | .admin-label { 34 | min-width: 10px; 35 | } 36 | 37 | .ownership-notice { 38 | margin-top: styles.$space-base; 39 | align-self: end; 40 | } 41 | -------------------------------------------------------------------------------- /client/src/app/project-creation/project-properties/project-properties.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { ProjectProperties } from '../project-properties'; 3 | import { ControlContainer, NgForm } from '@angular/forms'; 4 | import { ConfigProvider } from '../../config/config.provider'; 5 | 6 | @Component({ 7 | selector: 'app-project-properties', 8 | templateUrl: './project-properties.component.html', 9 | styleUrls: ['./project-properties.component.scss'], 10 | viewProviders: [{ provide: ControlContainer, useExisting: NgForm }], 11 | standalone: false 12 | }) 13 | export class ProjectPropertiesComponent { 14 | @Input() projectProperties: ProjectProperties; 15 | 16 | constructor(public config: ConfigProvider) { 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /server/comment/service.go: -------------------------------------------------------------------------------- 1 | package comment 2 | 3 | import ( 4 | "fmt" 5 | "github.com/pkg/errors" 6 | "stm/config" 7 | "stm/util" 8 | "time" 9 | ) 10 | 11 | type Service struct { 12 | *util.Logger 13 | store *Store 14 | } 15 | 16 | func Init(logger *util.Logger, store *Store) *Service { 17 | return &Service{ 18 | Logger: logger, 19 | store: store, 20 | } 21 | } 22 | 23 | func (s *Service) AddComment(listId string, commentDraft *DraftDto, authorId string) error { 24 | if len(commentDraft.Text) > config.Conf.MaxCommentLength { 25 | return errors.New(fmt.Sprintf("Comment too long. Allowed are %d characters but found %d.", config.Conf.MaxCommentLength, len(commentDraft.Text))) 26 | } 27 | 28 | return s.store.addComment(listId, commentDraft.Text, authorId, time.Now().UTC()) 29 | } 30 | -------------------------------------------------------------------------------- /client/src/app/ui/zoom-control/zoom-control.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, OnInit, Output } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-zoom-control', 5 | templateUrl: './zoom-control.component.html', 6 | styleUrls: ['./zoom-control.component.scss'], 7 | standalone: false 8 | }) 9 | export class ZoomControlComponent implements OnInit { 10 | 11 | @Output() public buttonZoomIn: EventEmitter = new EventEmitter(); 12 | @Output() public buttonZoomOut: EventEmitter = new EventEmitter(); 13 | 14 | constructor() { } 15 | 16 | ngOnInit(): void { 17 | } 18 | 19 | public onButtonZoomIn() { 20 | this.buttonZoomIn.emit(); 21 | } 22 | 23 | public onButtonZoomOut() { 24 | this.buttonZoomOut.emit(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /client/src/app/project-creation/task-draft-list/task-draft-list.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { TaskDraft } from '../../task/task.material'; 3 | import { TaskDraftService } from '../task-draft.service'; 4 | 5 | @Component({ 6 | selector: 'app-task-draft-list', 7 | templateUrl: './task-draft-list.component.html', 8 | styleUrls: ['./task-draft-list.component.scss'], 9 | standalone: false 10 | }) 11 | export class TaskDraftListComponent { 12 | @Input() public tasks: TaskDraft[]; 13 | @Input() public selectedTask: TaskDraft | undefined; 14 | 15 | constructor( 16 | private taskDraftService: TaskDraftService 17 | ) { 18 | } 19 | 20 | public onTaskClicked(id: string): void { 21 | this.taskDraftService.selectTask(id); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /client/src/app/project-creation/shape-remote/shape-remote.component.html: -------------------------------------------------------------------------------- 1 |

{{'project-creation.remote-heading' | translate}}

2 | 3 |
{{'project-creation.url-file-format-notice' | translate}}
4 | 5 | 6 | 7 |
8 | 9 |

{{'project-creation.overpass-heading' | translate}}

10 | 11 |

{{'project-creation.loading-title' | translate}}

12 |
    13 |
  1. {{'project-creation.load-li-1' | translate}}
  2. 14 |
  3. {{'project-creation.load-li-2' | translate}}
  4. 15 |
  5. {{'project-creation.load-li-3' | translate}}
  6. 16 |
  7. {{'project-creation.load-li-4' | translate}}
  8. 17 |
18 | -------------------------------------------------------------------------------- /server/database/scripts/008_migrate-project-task-relation.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | source scripts/common.sh 4 | 5 | OUTPUT_FILE=".tmp.migrate-project-task-relation.sql" 6 | RAW_DATA=$(psql -h $STM_DB_HOST -U $STM_DB_USERNAME -t -A -c "SELECT id,task_ids FROM projects;" $STM_DB_DATABASE | tr -d "{" | tr -d "}") 7 | 8 | begin_tx 9 | 10 | IFS=$'\n' 11 | for ROW in $RAW_DATA 12 | do 13 | IFS='|' read -ra ROW_ARRAY <<< "$ROW" 14 | 15 | PROJECT_ID=${ROW_ARRAY[0]} 16 | TASK_IDS=${ROW_ARRAY[1]} 17 | 18 | IFS=$',' 19 | for TASK_ID in $TASK_IDS 20 | do 21 | echo "UPDATE tasks SET project_id = $PROJECT_ID WHERE id = $TASK_ID;" >> $OUTPUT_FILE 22 | done 23 | 24 | IFS=$'\n' 25 | done 26 | 27 | # 28 | # Set version 29 | # 30 | set_version "008" 31 | 32 | # 33 | # Generate the SQL script 34 | # 35 | end_tx 36 | 37 | execute -------------------------------------------------------------------------------- /client/src/app/user/current-user.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { CurrentUserService } from './current-user.service'; 2 | 3 | describe(CurrentUserService.name, () => { 4 | let service: CurrentUserService; 5 | 6 | beforeEach(() => { 7 | service = new CurrentUserService(); 8 | }); 9 | 10 | it('should set and get correctly', () => { 11 | expect(service).toBeTruthy(); 12 | 13 | service.setUser('test-user', '12345'); 14 | 15 | expect(service.getUserName()).toEqual('test-user'); 16 | expect(service.getUserId()).toEqual('12345'); 17 | }); 18 | 19 | it('should reset correctly', () => { 20 | expect(service).toBeTruthy(); 21 | service.setUser('test-user', '12345'); 22 | 23 | service.resetUser(); 24 | 25 | expect(service.getUserName()).toEqual(undefined); 26 | expect(service.getUserId()).toEqual(undefined); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /client/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "jest-preset-angular", 3 | setupFilesAfterEnv: [ 4 | "/jest/setup-jest.ts" 5 | ], 6 | testPathIgnorePatterns: [ 7 | "/node_modules/", 8 | "/dist/", 9 | "/jest/", 10 | ], 11 | transformIgnorePatterns: [ 12 | "/node_modules/(?!ol|@angular|@ngx-translate|geotiff|observable-fns|quick-lru|color-space|color-rgba|color-parse|rbush|quickselect|pbf).+\.(mjs|js)$" 13 | ], 14 | transform: { 15 | "^.+\\.{ts}$": [ 16 | 'ts-jest', 17 | { 18 | tsconfig: "/tsconfig.spec.json", 19 | stringifyContentPathRegex: "\\.html$" 20 | } 21 | ] 22 | }, 23 | modulePaths: [ 24 | "" 25 | ], 26 | collectCoverage: true, 27 | coverageReporters: ["html", "text"], 28 | cacheDirectory: "./jestCache" 29 | }; -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | # 2 | # Stage 1: Build 3 | # 4 | # https://hub.docker.com/_/golang 5 | # 6 | FROM golang:1.25-alpine AS builder 7 | 8 | COPY . /stm-server/ 9 | WORKDIR /stm-server/ 10 | 11 | RUN go build -o ./server . 12 | 13 | # 14 | # Stage 2: Run 15 | # 16 | # https://hub.docker.com/_/alpine 17 | # 18 | FROM alpine:3.22 19 | 20 | RUN mkdir /stm-server 21 | WORKDIR /stm-server/ 22 | 23 | COPY --from=builder /stm-server/server ./ 24 | COPY --from=builder /stm-server/database/scripts ./database/scripts 25 | COPY --from=builder /stm-server/database/init-db.sh ./database/init-db.sh 26 | COPY --from=builder /stm-server/config/default.json ./config.json 27 | 28 | RUN apk add --no-cache bash grep postgresql17-client libc6-compat 29 | # "libc6-compat" needed to make go-binary executable 30 | 31 | ENTRYPOINT cd database && ./init-db.sh && cd .. && ./server -c ./config.json 32 | -------------------------------------------------------------------------------- /server/database/scripts/011_comments.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | 3 | CREATE TABLE comment_lists 4 | ( 5 | id SERIAL PRIMARY KEY NOT NULL 6 | ); 7 | 8 | CREATE TABLE comments 9 | ( 10 | id SERIAL PRIMARY KEY NOT NULL, 11 | comment_list_id INT NOT NULL, 12 | text TEXT NOT NULL, 13 | creation_date TIMESTAMP NOT NULL, 14 | author_id TEXT NOT NULL 15 | ); 16 | 17 | ALTER TABLE tasks ADD COLUMN comment_list_id INT; 18 | ALTER TABLE projects ADD COLUMN comment_list_id INT; 19 | 20 | ALTER TABLE tasks ADD FOREIGN KEY (comment_list_id) REFERENCES comment_lists; 21 | ALTER TABLE projects ADD FOREIGN KEY (comment_list_id) REFERENCES comment_lists; 22 | ALTER TABLE comments ADD FOREIGN KEY (comment_list_id) REFERENCES comment_lists; 23 | 24 | INSERT INTO db_versions VALUES ('011'); 25 | 26 | END TRANSACTION; -------------------------------------------------------------------------------- /client/src/app/task/task-list/task-list.component.html: -------------------------------------------------------------------------------- 1 |

{{ 'tasks' | translate }}

2 | 3 |
4 |
5 |
6 |
7 | {{ t | taskTitle }} 8 | ({{ t.processPoints }} / {{ t.maxProcessPoints }}) 9 | {{ 'done-button' | translate }} 10 |
11 | {{ t.assignedUser.name }} 13 | {{ 'task-list.you' | translate }} 14 |
15 | 16 |
17 |
18 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | # jest 8 | /jestCache 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # profiling files 14 | chrome-profiler-events*.json 15 | speed-measure-plugin*.json 16 | 17 | # IDEs and editors 18 | /.idea 19 | .project 20 | .classpath 21 | .c9/ 22 | *.launch 23 | .settings/ 24 | *.sublime-workspace 25 | 26 | # IDE - VSCode 27 | .vscode/* 28 | !.vscode/settings.json 29 | !.vscode/tasks.json 30 | !.vscode/launch.json 31 | !.vscode/extensions.json 32 | .history/* 33 | 34 | # misc 35 | /.sass-cache 36 | /connect.lock 37 | /coverage 38 | /libpeerconnection.log 39 | npm-debug.log 40 | yarn-error.log 41 | testem.log 42 | /typings 43 | tags 44 | .nx 45 | 46 | # System Files 47 | .DS_Store 48 | Thumbs.db 49 | 50 | # Image files and assets 51 | src/favicon.png 52 | -------------------------------------------------------------------------------- /client/src/app/config/config.provider.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Config } from './config'; 3 | 4 | /** 5 | * This config provider is a simple class providing the configurations received from the server (s. the ConfigResolver class). These 6 | * configurations are used to ensure valid requests to the server (e.g. to not exceed the maximum length for a project description). 7 | */ 8 | @Injectable({ 9 | providedIn: 'root' 10 | }) 11 | export class ConfigProvider extends Config { 12 | constructor() { 13 | super(); 14 | } 15 | 16 | public apply(config: Config): void { 17 | this.sourceRepoUrl = config.sourceRepoUrl; 18 | this.maxTasksPerProject = config.maxTasksPerProject; 19 | this.maxDescriptionLength = config.maxDescriptionLength; 20 | this.testEnvironment = config.testEnvironment; 21 | this.osmApiUrl = config.osmApiUrl; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /client/src/app/project-creation/task-edit/task-edit.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core'; 2 | import { TaskDraft } from '../../task/task.material'; 3 | import { TaskDraftService } from '../task-draft.service'; 4 | 5 | @Component({ 6 | selector: 'app-task-edit', 7 | templateUrl: './task-edit.component.html', 8 | styleUrls: ['./task-edit.component.scss'], 9 | standalone: false 10 | }) 11 | export class TaskEditComponent implements OnInit { 12 | @Input() task: TaskDraft; 13 | 14 | constructor( 15 | private taskDraftService: TaskDraftService 16 | ) { 17 | } 18 | 19 | ngOnInit(): void { 20 | } 21 | 22 | onTaskNameChanged(evt: Event): void { 23 | const taskId = this.task.id; 24 | if (!taskId) { 25 | return; 26 | } 27 | 28 | // @ts-ignore 29 | this.taskDraftService.changeTaskName(taskId, evt.target?.value); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /client/src/app/common/services/shortcut.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { ShortcutService } from './shortcut.service'; 2 | import { EventManager } from '@angular/platform-browser'; 3 | 4 | describe(ShortcutService.name, () => { 5 | let service: ShortcutService; 6 | let eventManager: EventManager; 7 | let document: any; 8 | 9 | beforeEach(() => { 10 | eventManager = {} as EventManager; 11 | document = {} as Document; 12 | 13 | service = new ShortcutService(eventManager, document); 14 | }); 15 | 16 | it('should be created', () => { 17 | expect(service).toBeTruthy(); 18 | }); 19 | 20 | it('should call event dashboard', () => { 21 | eventManager.addEventListener = jest.fn(); 22 | 23 | service.add('shift.d').subscribe(); 24 | 25 | // @ts-ignore 26 | expect(eventManager.addEventListener).toHaveBeenCalledWith(window.document.documentElement, 'keydown.shift.d', expect.any(Function)); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /client/src/app/common/services/websocket-client.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { WebsocketClientService } from './websocket-client.service'; 2 | import { CurrentUserService } from '../../user/current-user.service'; 3 | import { Observable, of } from 'rxjs'; 4 | import { User } from '../../user/user.material'; 5 | import { NgZone } from '@angular/core'; 6 | 7 | describe(WebsocketClientService.name, () => { 8 | let service: WebsocketClientService; 9 | let currentUserService: CurrentUserService; 10 | let ngZone: NgZone; 11 | 12 | beforeEach(() => { 13 | currentUserService = { 14 | onUserChanged: new Observable(), 15 | } as CurrentUserService; 16 | ngZone = {} as NgZone; 17 | ngZone.runOutsideAngular = jest.fn(); 18 | service = new WebsocketClientService(currentUserService, ngZone); 19 | }); 20 | 21 | it('should be created', () => { 22 | expect(service).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /client/src/app/ui/max-validator.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, Input } from '@angular/core'; 2 | import { UntypedFormControl, NG_VALIDATORS, Validator } from '@angular/forms'; 3 | 4 | @Directive({ 5 | selector: '[appMaxValidator][ngModel]', 6 | providers: [{ provide: NG_VALIDATORS, useExisting: MaxValidatorDirective, multi: true }], 7 | standalone: false 8 | }) 9 | export class MaxValidatorDirective implements Validator { 10 | private appMaxValidatorNumber: number; 11 | 12 | constructor() { 13 | } 14 | 15 | @Input() 16 | set appMaxValidator(value: number | string) { 17 | this.appMaxValidatorNumber = +value; 18 | } 19 | 20 | public validate(c: UntypedFormControl): { [appMaxValidator: string]: any } | null { 21 | const v = ('' + c.value).trim(); 22 | return v.match('[-\+]?[0-9]+') 23 | && (+v <= this.appMaxValidatorNumber) 24 | ? null 25 | : {appMaxValidator: true}; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /client/src/app/ui/min-validator.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, Input } from '@angular/core'; 2 | import { UntypedFormControl, NG_VALIDATORS, Validator } from '@angular/forms'; 3 | 4 | @Directive({ 5 | selector: '[appMinValidator][ngModel]', 6 | providers: [{ provide: NG_VALIDATORS, useExisting: MinValidatorDirective, multi: true }], 7 | standalone: false 8 | }) 9 | export class MinValidatorDirective implements Validator { 10 | private appMinValidatorNumber: number; 11 | 12 | constructor() { 13 | } 14 | 15 | @Input() 16 | set appMinValidator(value: number | string) { 17 | this.appMinValidatorNumber = +value; 18 | } 19 | 20 | public validate(c: UntypedFormControl): { [appMinValidator: string]: any } | null { 21 | const v = ('' + c.value).trim(); 22 | return v.match('[-\+]?[0-9]+') 23 | && (this.appMinValidatorNumber <= +v) 24 | ? null 25 | : {appMinValidator: true}; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /doc/README.md: -------------------------------------------------------------------------------- 1 | The documentation is ordered into few categories: 2 | 3 | ### [api](api) 4 | * Link to general information about the API 5 | * Generating swagger UI 6 | * Versions and API changes 7 | 8 | ### [architecture](architecture) 9 | * Client and server code architecture 10 | 11 | ### [authentication](authentication) 12 | * Technical in-depth description of the authentication and authorization process 13 | * Token creation, handling and validation 14 | 15 | ### [development](development) 16 | * Steps to get started 17 | * Git workflow 18 | * Overview over client, server, docker and further links 19 | 20 | ### [operation](operation) 21 | * Setup and configuration of the STM server 22 | * Setup of a linux machine 23 | * HTTPS 24 | * Create SSL certificates via let's encrypt 25 | * Automatic renewal of SSL certs 26 | * Automatic backups 27 | 28 | ### [testing](testing) 29 | * Write server and client tests 30 | * Run these tests -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "resolveJsonModule": true, 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "sourceMap": true, 8 | "esModuleInterop": true, 9 | "declaration": false, 10 | "experimentalDecorators": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "target": "ES2022", 14 | "module": "ES2022", 15 | "lib": [ 16 | "ES2021", 17 | "dom" 18 | ], 19 | "strict": true, 20 | "strictPropertyInitialization": false, 21 | "skipLibCheck": true, 22 | "useDefineForClassFields": false 23 | }, 24 | "angularCompilerOptions": { 25 | "fullTemplateTypeCheck": true, 26 | "strictInjectionParameters": true, 27 | "strictTemplates": true, 28 | "extendedDiagnostics": { 29 | "checks": { 30 | "optionalChainNotNullable": "suppress" 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /server/config/api.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type Dto struct { 4 | SourceRepoURL string `json:"sourceRepoUrl"` // URL to the source code repository. 5 | MaxTasksPerProject int `json:"maxTasksPerProject"` // Maximum amount of tasks allowed for a project. 6 | MaxDescriptionLength int `json:"maxDescriptionLength"` // Maximum length for the project description in characters. Default: 1000. 7 | TestEnvironment bool `json:"testEnvironment"` // True when the server runs in an test environment 8 | OsmApiUrl string `json:"osmApiUrl"` // The base-URL to the OSM server. 9 | } 10 | 11 | func GetConfigDto() *Dto { 12 | return &Dto{ 13 | SourceRepoURL: Conf.SourceRepoURL, 14 | MaxTasksPerProject: Conf.MaxTasksPerProject, 15 | MaxDescriptionLength: Conf.MaxDescriptionLength, 16 | TestEnvironment: Conf.TestEnvironment, 17 | OsmApiUrl: Conf.OsmApiUrl, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /server/database/scripts/common.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function begin_tx() 4 | { 5 | echo "BEGIN TRANSACTION;" > $OUTPUT_FILE 6 | } 7 | 8 | function end_tx() 9 | { 10 | echo "END TRANSACTION;" >> $OUTPUT_FILE 11 | } 12 | 13 | # 14 | # Adds the parameter $1 to the db_version table 15 | # 16 | function set_version() 17 | { 18 | echo "INSERT INTO db_versions VALUES('$1');" >> $OUTPUT_FILE 19 | } 20 | 21 | # 22 | # Executes the SQL-script stored at $OUTPUT_FILE. 23 | # 24 | function execute() 25 | { 26 | echo "Execute SQL..." 27 | 28 | psql -q -v ON_ERROR_STOP=1 -h $STM_DB_HOST -U $STM_DB_USERNAME -f $OUTPUT_FILE $STM_DB_DATABASE 29 | OK=$? 30 | if [ $OK -ne 0 ] 31 | then 32 | echo 33 | echo "Migration FAILED!" 34 | echo 35 | echo "Exit code: $OK" 36 | echo "See the error log and the '$OUTPUT_FILE' for details." 37 | exit 1 38 | fi 39 | 40 | echo "Migration DONE" 41 | 42 | echo "Remove '$OUTPUT_FILE'" 43 | rm $OUTPUT_FILE 44 | } -------------------------------------------------------------------------------- /client/src/app/project-creation/project-properties/project-properties.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ProjectPropertiesComponent } from './project-properties.component'; 2 | import { ProjectProperties } from '../project-properties'; 3 | import { MockBuilder, MockedComponentFixture, MockRender } from 'ng-mocks'; 4 | import { AppModule } from '../../app.module'; 5 | 6 | describe(ProjectPropertiesComponent.name, () => { 7 | let component: ProjectPropertiesComponent; 8 | let fixture: MockedComponentFixture; 9 | 10 | beforeEach(() => MockBuilder(ProjectPropertiesComponent, AppModule)); 11 | 12 | beforeEach(() => { 13 | fixture = MockRender(ProjectPropertiesComponent, {projectProperties: new ProjectProperties('', 100, '', 'OSM')}); 14 | component = fixture.point.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /client/src/app/config/config.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { Config } from './config'; 4 | import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; 5 | import { Observable } from 'rxjs'; 6 | import { environment } from '../../environments/environment'; 7 | import { ConfigProvider } from './config.provider'; 8 | import { tap } from 'rxjs/operators'; 9 | 10 | @Injectable({ 11 | providedIn: 'root' 12 | }) 13 | export class ConfigResolver implements Resolve { 14 | constructor(private httpClient: HttpClient, 15 | private configProvider: ConfigProvider) { 16 | } 17 | 18 | resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { 19 | return this.httpClient.get(environment.url_config).pipe(tap(config => { 20 | this.configProvider.apply(config); 21 | })); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /client/src/app/project-creation/project-import.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { ProjectImportService } from './project-import.service'; 2 | import { TaskDraftService } from './task-draft.service'; 3 | import { ProjectService } from '../project/project.service'; 4 | import { NotificationService } from '../common/services/notification.service'; 5 | 6 | describe(ProjectImportService.name, () => { 7 | let service: ProjectImportService; 8 | let taskDraftService: TaskDraftService; 9 | let projectService: ProjectService; 10 | let notificationService: NotificationService; 11 | 12 | beforeEach(() => { 13 | taskDraftService = {} as TaskDraftService; 14 | projectService = {} as ProjectService; 15 | notificationService = {} as NotificationService; 16 | 17 | service = new ProjectImportService(taskDraftService, projectService, notificationService); 18 | }); 19 | 20 | it('should be created', () => { 21 | expect(service).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /client/src/app/common/services/loading.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { NavigationCancel, NavigationEnd, NavigationError, NavigationStart, Router } from '@angular/router'; 3 | 4 | @Injectable({ 5 | providedIn: 'root' 6 | }) 7 | export class LoadingService { 8 | private loading: boolean; 9 | 10 | constructor(private router: Router) { 11 | this.loading = false; 12 | 13 | router.events.subscribe(event => { 14 | if (event instanceof NavigationStart) { 15 | this.loading = true; 16 | } else if (event instanceof NavigationEnd || 17 | event instanceof NavigationCancel || 18 | event instanceof NavigationError) { 19 | this.loading = false; 20 | } 21 | }); 22 | } 23 | 24 | public isLoading(): boolean { 25 | return this.loading; 26 | } 27 | 28 | public start(): void { 29 | this.loading = true; 30 | } 31 | 32 | public end(): void { 33 | this.loading = false; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /client/src/app/common/services/process-point-color.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { ProcessPointColorService } from './process-point-color.service'; 2 | 3 | describe(ProcessPointColorService.name, () => { 4 | let service: ProcessPointColorService; 5 | 6 | beforeEach(() => { 7 | service = new ProcessPointColorService(); 8 | }); 9 | 10 | it('should be created', () => { 11 | expect(service).toBeTruthy(); 12 | }); 13 | 14 | it('should return correct color', () => { 15 | expect(service.getProcessPointsColor(0, 120)).toEqual('#e60000'); 16 | expect(service.getProcessPointsColor(59, 120)).toEqual('#d2cf00'); 17 | expect(service.getProcessPointsColor(60, 120)).toEqual('#d2d200'); 18 | expect(service.getProcessPointsColor(120, 120)).toEqual('#00be00'); 19 | expect(() => service.getProcessPointsColor(0, 0)).toThrow(); 20 | expect(() => service.getProcessPointsColor(-5, 10)).toThrow(); 21 | expect(() => service.getProcessPointsColor(5, -10)).toThrow(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /client/src/app/dashboard/dashboard.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles"; 2 | @use "../../colors"; 3 | @use "../../icons"; 4 | 5 | :host { 6 | height: 100%; 7 | display: flex; 8 | flex-direction: column; 9 | justify-content: space-between; 10 | } 11 | 12 | .root-container { 13 | max-width: 600px; 14 | margin-left: auto; 15 | margin-right: auto; 16 | margin-top: styles.$space-large; 17 | margin-bottom: styles.$space-large; 18 | } 19 | 20 | .toolbar-left { 21 | display: flex; 22 | flex-direction: row; 23 | } 24 | 25 | .language-selection { 26 | display: flex; 27 | flex-direction: row; 28 | align-items: center; 29 | } 30 | 31 | .language-selection-label { 32 | margin-right: styles.$space-base; 33 | } 34 | 35 | .separator { 36 | margin-right: styles.$space-large; 37 | margin-left: styles.$space-large; 38 | border-left: solid 1px colors.$color-gray-very-light; 39 | } 40 | 41 | .new-project-buttons-container { 42 | display: flex; 43 | 44 | .import-button { 45 | margin-left: styles.$space-base; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /client/src/app/project/project-list/project-list.component.html: -------------------------------------------------------------------------------- 1 |

{{'project-list.projects' | translate}}

2 |
3 |
4 | {{p.name}} 5 |
6 | ({{p.doneProcessPoints}} / {{p.totalProcessPoints}}) 7 | 8 |
9 | * 10 |
11 |
12 |
13 |
14 | 15 |
16 |

{{'project-list.no-project-notice' | translate}}

17 |
18 | 19 | {{'project-list.you-own-this-project-notice' | translate}} 20 | -------------------------------------------------------------------------------- /client/src/app/task/task-title.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { TaskTitlePipe } from './task-title.pipe'; 2 | import { Task } from './task.material'; 3 | import { Feature } from 'ol'; 4 | 5 | describe(TaskTitlePipe.name, () => { 6 | let pipe: TaskTitlePipe; 7 | 8 | beforeEach(() => { 9 | pipe = new TaskTitlePipe(); 10 | }); 11 | 12 | it('create an instance', () => { 13 | expect(pipe).toBeTruthy(); 14 | }); 15 | 16 | it('should return empty string on falsy value', () => { 17 | // @ts-ignore 18 | expect(pipe.transform(undefined)).toEqual(''); 19 | // @ts-ignore 20 | expect(pipe.transform(null)).toEqual(''); 21 | }); 22 | 23 | it('should return correct title', () => { 24 | expect(pipe.transform(new Task('123', '', 0, 0, new Feature(), []))).toEqual('123'); 25 | // @ts-ignore 26 | expect(pipe.transform(new Task(undefined, 'bar', 0, 0, new Feature(), []))).toEqual('bar'); 27 | expect(pipe.transform(new Task('234', 'foo', 0, 0, new Feature(), []))).toEqual('foo'); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /client/src/app/task/task.material.spec.ts: -------------------------------------------------------------------------------- 1 | import { Task, TestTaskFeature } from './task.material'; 2 | import { User } from '../user/user.material'; 3 | 4 | describe(Task.name, () => { 5 | it('should create an instance without assigned User', () => { 6 | expect(new Task('t-0', '', 0, 100, TestTaskFeature, [])).toBeTruthy(); 7 | }); 8 | 9 | it('should create an instance', () => { 10 | expect(new Task('t-0', '', 0, 100, TestTaskFeature, [], new User('peter', '2'))).toBeTruthy(); 11 | }); 12 | 13 | it('should determine done state correctly', () => { 14 | expect(new Task('t-0', '', 0, 100, TestTaskFeature, [], new User('peter', '2')).isDone).toEqual(false); 15 | expect(new Task('t-1', '', 50, 100, TestTaskFeature, [], new User('peter', '2')).isDone).toEqual(false); 16 | expect(new Task('t-2', '', 99, 100, TestTaskFeature, [], new User('peter', '2')).isDone).toEqual(false); 17 | expect(new Task('t-3', '', 100, 100, TestTaskFeature, [], new User('peter', '2')).isDone).toEqual(true); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /server/go.mod: -------------------------------------------------------------------------------- 1 | module stm 2 | 3 | go 1.22.0 4 | 5 | toolchain go1.23.4 6 | 7 | require ( 8 | github.com/alecthomas/kong v1.6.1 9 | github.com/gorilla/mux v1.8.1 10 | github.com/gorilla/websocket v1.5.3 11 | github.com/hauke96/sigolo v1.1.0 12 | github.com/lib/pq v1.10.9 13 | github.com/paulmach/go.geojson v1.5.0 14 | github.com/pkg/errors v0.9.1 15 | github.com/swaggo/http-swagger v1.3.4 16 | github.com/swaggo/swag v1.16.4 17 | golang.org/x/oauth2 v0.25.0 18 | ) 19 | 20 | require ( 21 | github.com/KyleBanks/depth v1.2.1 // indirect 22 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 23 | github.com/go-openapi/jsonreference v0.21.0 // indirect 24 | github.com/go-openapi/spec v0.21.0 // indirect 25 | github.com/go-openapi/swag v0.23.0 // indirect 26 | github.com/josharian/intern v1.0.0 // indirect 27 | github.com/mailru/easyjson v0.9.0 // indirect 28 | github.com/swaggo/files v1.0.1 // indirect 29 | golang.org/x/net v0.34.0 // indirect 30 | golang.org/x/tools v0.29.0 // indirect 31 | gopkg.in/yaml.v3 v3.0.1 // indirect 32 | ) 33 | -------------------------------------------------------------------------------- /client/src/app/user/current-user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { User } from './user.material'; 3 | import { Observable, Subject } from 'rxjs'; 4 | 5 | @Injectable({ 6 | providedIn: 'root' 7 | }) 8 | export class CurrentUserService { 9 | private currentUser?: User; 10 | private $userChanged: Subject = new Subject(); 11 | 12 | constructor() { 13 | } 14 | 15 | get onUserChanged(): Observable { 16 | return this.$userChanged.asObservable(); 17 | } 18 | 19 | public setUser(userName: string, uid: string): void { 20 | this.currentUser = new User(userName, uid); 21 | this.$userChanged.next(this.currentUser); 22 | } 23 | 24 | public resetUser(): void { 25 | this.currentUser = undefined; 26 | this.$userChanged.next(undefined); 27 | } 28 | 29 | public getUserName(): string | undefined { 30 | return this.currentUser?.name; 31 | } 32 | 33 | public getUserId(): string | undefined { 34 | return this.currentUser?.uid; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /server/config/api_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "github.com/pkg/errors" 6 | "testing" 7 | ) 8 | 9 | func TestGetConfigDto(t *testing.T) { 10 | h.Run(t, func() error { 11 | Conf = &Config{} 12 | Conf.SourceRepoURL = "https://some.url/my/repo" 13 | Conf.MaxDescriptionLength = 200 14 | Conf.MaxTasksPerProject = 345 15 | 16 | dto := GetConfigDto() 17 | 18 | if dto.SourceRepoURL != Conf.SourceRepoURL { 19 | return errors.New(fmt.Sprintf("Dto value of 'SourceRepoURL' wrong: Wanted %s but was %s", Conf.SourceRepoURL, dto.SourceRepoURL)) 20 | } 21 | if dto.MaxDescriptionLength != Conf.MaxDescriptionLength { 22 | return errors.New(fmt.Sprintf("Dto value of 'MaxDescriptionLength' wrong: Wanted %d but was %d", Conf.MaxDescriptionLength, dto.MaxDescriptionLength)) 23 | } 24 | if dto.MaxTasksPerProject != Conf.MaxTasksPerProject { 25 | return errors.New(fmt.Sprintf("Dto value of 'MaxTasksPerProject' wrong: Wanted %d but was %d", Conf.MaxTasksPerProject, dto.MaxTasksPerProject)) 26 | } 27 | 28 | return nil 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /server/database/scripts/003_string-to-array-migration.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | 3 | ALTER TABLE projects ADD COLUMN task_id_array TEXT[] NOT NULL DEFAULT '{}'; 4 | 5 | -- Turn every "task_ids" field into an array and store it in "task_id_array" 6 | UPDATE projects SET task_id_array=subquery.regexp_split_to_array FROM (SELECT id,(regexp_split_to_array(task_ids, ',')) FROM projects) AS subquery WHERE projects.id=subquery.id; 7 | 8 | 9 | -- Remove the old "task_ids" and rename the new column 10 | ALTER TABLE projects DROP COLUMN task_ids; 11 | ALTER TABLE projects RENAME COLUMN task_id_array TO task_ids; 12 | 13 | -- Same as above, now for the users: 14 | ALTER TABLE projects ADD COLUMN user_array TEXT[] NOT NULL DEFAULT '{}'; 15 | UPDATE projects SET user_array=subquery.regexp_split_to_array FROM (SELECT id,(regexp_split_to_array(users, ',')) FROM projects) AS subquery WHERE projects.id=subquery.id; 16 | ALTER TABLE projects DROP COLUMN users; 17 | ALTER TABLE projects RENAME COLUMN user_array TO users; 18 | 19 | INSERT INTO db_versions VALUES('003'); 20 | 21 | END TRANSACTION; -------------------------------------------------------------------------------- /client/src/app/ui/tabs/tabs.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TabsComponent } from './tabs.component'; 2 | import { MockBuilder, MockedComponentFixture, MockRender } from 'ng-mocks'; 3 | import { AppModule } from '../../app.module'; 4 | 5 | describe(TabsComponent.name, () => { 6 | let component: TabsComponent; 7 | let fixture: MockedComponentFixture; 8 | 9 | beforeEach(() => MockBuilder(TabsComponent, AppModule)); 10 | 11 | beforeEach(() => { 12 | fixture = MockRender(TabsComponent, { 13 | tabs: ['tab 1', 'tab 2'] 14 | }); 15 | component = fixture.point.componentInstance; 16 | fixture.detectChanges(); 17 | }); 18 | 19 | it('should create', () => { 20 | expect(component).toBeTruthy(); 21 | }); 22 | 23 | it('should select tab correctly', () => { 24 | const eventSpy = jest.fn(); 25 | component.tabSelected.subscribe(eventSpy); 26 | 27 | component.selectTab(0); 28 | expect(eventSpy).toHaveBeenCalledWith(0); 29 | 30 | component.selectTab(1); 31 | expect(eventSpy).toHaveBeenCalledWith(1); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /client/src/app/user/user-list/user-list.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; 2 | import { CurrentUserService } from '../current-user.service'; 3 | import { User } from '../user.material'; 4 | 5 | @Component({ 6 | selector: 'app-user-list', 7 | templateUrl: './user-list.component.html', 8 | styleUrls: ['./user-list.component.scss'], 9 | standalone: false 10 | }) 11 | export class UserListComponent implements OnInit { 12 | @Input() public users: User[]; 13 | @Input() public ownerUid: string; 14 | 15 | @Output() public userRemoved: EventEmitter = new EventEmitter(); 16 | 17 | constructor( 18 | private currentUserService: CurrentUserService 19 | ) { 20 | } 21 | 22 | ngOnInit(): void { 23 | } 24 | 25 | public onRemoveUserClicked(user: string) { 26 | this.userRemoved.emit(user); 27 | } 28 | 29 | public canRemove(user: string): boolean { 30 | return this.ownerUid === this.currentUserService.getUserId() && user !== this.currentUserService.getUserId(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /client/src/app/ui/language-selection/language-selection.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { ActivatedRoute } from '@angular/router'; 3 | import { Language } from '../../common/entities/language'; 4 | import { LanguageService } from '../../common/services/language.service'; 5 | 6 | @Component({ 7 | selector: 'app-language-selection', 8 | templateUrl: './language-selection.component.html', 9 | styleUrls: ['./language-selection.component.scss'], 10 | standalone: false 11 | }) 12 | export class LanguageSelectionComponent implements OnInit { 13 | languages: Language[] = []; 14 | 15 | constructor(private route: ActivatedRoute, private languageService: LanguageService) { 16 | } 17 | 18 | ngOnInit(): void { 19 | this.languages = this.languageService.getKnownLanguages(); 20 | } 21 | 22 | get selectedLanguage(): Language | undefined { 23 | return this.languageService.getSelectedLanguage(); 24 | } 25 | 26 | onLanguageChange(event: any): void { 27 | this.languageService.selectLanguageByCode(event.target.value); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /server/task/entity.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import "stm/comment" 4 | 5 | type Task struct { 6 | Id string `json:"id"` // The ID of the task. 7 | Name string `json:"name"` // The name of the task. If the properties of the geometry feature contain the field "name", this field is used here. If no name has been set, this field will be empty. 8 | ProcessPoints int `json:"processPoints"` // The amount of process points that have been set by the user. It applies that "0 <= processPoints <= maxProcessPoints". 9 | MaxProcessPoints int `json:"maxProcessPoints"` // The maximum amount of process points of this task. Is larger than zero. 10 | Geometry string `json:"geometry"` // A GeoJson feature of the task wit a polygon or multipolygon geometry. Will never be NULL or empty. 11 | // TODO Use "Id" as suffix? 12 | AssignedUser string `json:"assignedUser"` // The user-ID of the user who is currently assigned to this task. Will never be NULL but might be empty. 13 | Comments []comment.Comment `json:"comments"` 14 | } 15 | -------------------------------------------------------------------------------- /client/src/app/project-creation/copy-project/copy-project.component.html: -------------------------------------------------------------------------------- 1 |

{{'project-creation.copy' | translate}}

2 | 3 |
{{'project-creation.copy-notice' | translate}}
4 | 5 | 6 |

{{'project-creation.loading-projects' | translate}}

7 |
8 | 9 | 10 |
11 |

{{'project-creation.no-project-notice' | translate}}

12 |
13 |
14 |

{{'project-creation.available-projects' | translate}}

15 |
16 |
19 | {{p.name}} 20 |
21 |
22 | 24 |
25 |
26 | -------------------------------------------------------------------------------- /client/src/app/ui/progress-bar/progress-bar.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core'; 2 | import { ProcessPointColorService } from '../../common/services/process-point-color.service'; 3 | 4 | @Component({ 5 | selector: 'app-progress-bar', 6 | templateUrl: './progress-bar.component.html', 7 | styleUrls: ['./progress-bar.component.scss'], 8 | standalone: false 9 | }) 10 | export class ProgressBarComponent implements OnInit { 11 | @Input() progressPoints: number; 12 | @Input() totalPoints: number; 13 | 14 | constructor( 15 | private processPointColorService: ProcessPointColorService 16 | ) { 17 | } 18 | 19 | ngOnInit(): void { 20 | } 21 | 22 | getProcessPointColor(): string { 23 | return this.processPointColorService.getProcessPointsColor(this.progressPoints, this.totalPoints); 24 | } 25 | 26 | getProcessPointWidth(): string { 27 | return Math.floor(this.progressPoints / this.totalPoints * 100) + 'px'; 28 | } 29 | 30 | getProcessPointPercentage(): number { 31 | return Math.round(this.progressPoints / this.totalPoints * 100); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /client/src/app/ui/tabs/tabs.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, Output } from '@angular/core'; 2 | 3 | export interface TabItem { 4 | index: number; 5 | title: string; 6 | } 7 | 8 | @Component({ 9 | selector: 'app-tabs', 10 | templateUrl: './tabs.component.html', 11 | styleUrls: ['./tabs.component.scss'], 12 | standalone: false 13 | }) 14 | export class TabsComponent { 15 | /** 16 | * When set to true, there'll be only a border between the tabs and the content but no border around the content. 17 | */ 18 | @Input() borderless = false; 19 | 20 | @Output() tabSelected = new EventEmitter(); 21 | 22 | public selectedTabIndex = 0; 23 | 24 | private currentTabs: TabItem[]; 25 | 26 | public get tabs(): TabItem[] { 27 | return this.currentTabs; 28 | } 29 | 30 | @Input() 31 | public set tabs(titles: string[]) { 32 | this.currentTabs = titles.map((title, index) => ({index, title} as TabItem)); 33 | } 34 | 35 | public selectTab(tabIndex: number): void { 36 | this.selectedTabIndex = tabIndex; 37 | this.tabSelected.emit(this.selectedTabIndex); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /server/database/scripts/012_initial-comment-migration.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | source scripts/common.sh 4 | 5 | OUTPUT_FILE=".tmp.initial-comment-migration.sql" 6 | TASK_IDS=$(psql -h $STM_DB_HOST -U $STM_DB_USERNAME -t -A -c "SELECT id FROM tasks;" $STM_DB_DATABASE) 7 | PROJECT_IDS=$(psql -h $STM_DB_HOST -U $STM_DB_USERNAME -t -A -c "SELECT id FROM projects;" $STM_DB_DATABASE) 8 | 9 | begin_tx 10 | 11 | for TASK_ID in $TASK_IDS 12 | do 13 | echo "WITH new_comment_list AS (INSERT INTO comment_lists DEFAULT VALUES RETURNING id, $TASK_ID as tid) UPDATE tasks t SET comment_list_id = n.id FROM new_comment_list n WHERE t.id = n.tid;" >> $OUTPUT_FILE 14 | done 15 | for PROJECT_ID in $PROJECT_IDS 16 | do 17 | echo "WITH new_comment_list AS (INSERT INTO comment_lists DEFAULT VALUES RETURNING id, $PROJECT_ID as pid) UPDATE projects p SET comment_list_id = n.id FROM new_comment_list n WHERE p.id = n.pid;" >> $OUTPUT_FILE 18 | done 19 | 20 | echo "ALTER TABLE tasks ALTER COLUMN comment_list_id SET NOT NULL;" >> $OUTPUT_FILE 21 | echo "ALTER TABLE projects ALTER COLUMN comment_list_id SET NOT NULL;" >> $OUTPUT_FILE 22 | 23 | set_version "012" 24 | 25 | end_tx 26 | 27 | execute -------------------------------------------------------------------------------- /server/util/logger.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "github.com/hauke96/sigolo" 6 | "strings" 7 | ) 8 | 9 | var ( 10 | nextTraceId = 0 11 | ) 12 | 13 | func NewLogger() *Logger { 14 | defer func() { nextTraceId++ }() // Just increase trace-ID counter after return statement 15 | return &Logger{LogTraceId: nextTraceId} 16 | } 17 | 18 | type Logger struct { 19 | LogTraceId int 20 | } 21 | 22 | func (l *Logger) Log(format string, args ...interface{}) { 23 | sigolo.Infob(1, "#%x | %s", l.LogTraceId, fmt.Sprintf(format, args...)) 24 | } 25 | 26 | func (l *Logger) Err(format string, args ...interface{}) { 27 | sigolo.Errorb(1, "#%x | %s", l.LogTraceId, fmt.Sprintf(format, args...)) 28 | } 29 | 30 | func (l *Logger) Debug(format string, args ...interface{}) { 31 | sigolo.Debugb(1, "#%x | %s", l.LogTraceId, fmt.Sprintf(format, args...)) 32 | } 33 | 34 | func (l *Logger) Stack(err error) { 35 | sigolo.Stackb(1, err) 36 | } 37 | 38 | func (l *Logger) LogQuery(query string, args ...interface{}) { 39 | for i, a := range args { 40 | query = strings.ReplaceAll(query, fmt.Sprintf("$%d", i+1), fmt.Sprintf("%v", a)) 41 | } 42 | 43 | sigolo.Debugb(1, query) 44 | } 45 | -------------------------------------------------------------------------------- /client/src/app/task/task-details/task-details.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../../styles"; 2 | 3 | .assign-container { 4 | display: flex; 5 | flex-direction: row; 6 | justify-content: space-between; 7 | } 8 | 9 | .assign-button-container { 10 | display: flex; 11 | flex-direction: row; 12 | align-items: center; 13 | } 14 | 15 | .process-point-container { 16 | display: flex; 17 | align-items: flex-start; 18 | height: 40px; // To stay equally high, regardless of what the content is 19 | margin-bottom: styles.$space-base; 20 | } 21 | 22 | .task-metadata-container { 23 | display: flex; 24 | flex-direction: column; 25 | } 26 | 27 | .process-point-input { 28 | max-width: 70px; 29 | } 30 | 31 | .of-process-point-label { 32 | margin-left: styles.$space-base; 33 | white-space: nowrap; 34 | } 35 | 36 | .save-button-row { 37 | display: flex; 38 | flex-direction: row; 39 | justify-content: flex-end; 40 | width: 100%; 41 | } 42 | 43 | .save-button, 44 | .assign-button-container > button { 45 | margin-left: styles.$space-large; 46 | } 47 | 48 | .open-button-row { 49 | margin-top: styles.$space-large; 50 | } 51 | 52 | .open-osm-button, 53 | .done-button { 54 | margin-left: styles.$space-base; 55 | } 56 | -------------------------------------------------------------------------------- /client/src/app/config/config.provider.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConfigProvider } from './config.provider'; 2 | import { Config } from './config'; 3 | 4 | describe(ConfigProvider.name, () => { 5 | let provider: ConfigProvider; 6 | 7 | beforeEach(() => { 8 | provider = new ConfigProvider(); 9 | }); 10 | 11 | it('should be created', () => { 12 | expect(provider).toBeTruthy(); 13 | }); 14 | 15 | it('should map all fields', () => { 16 | const config: Config = new Config(); 17 | config.sourceRepoUrl = 'http://my.repo/project'; 18 | config.maxTasksPerProject = 123; 19 | config.maxDescriptionLength = 234; 20 | config.testEnvironment = true; 21 | config.osmApiUrl = 'https://test.api.org'; 22 | expect(Object.keys(config).length).toEqual(5); // All fields filled 23 | 24 | provider.apply(config); 25 | 26 | expect(provider.sourceRepoUrl).toEqual(config.sourceRepoUrl); 27 | expect(provider.maxTasksPerProject).toEqual(config.maxTasksPerProject); 28 | expect(provider.maxDescriptionLength).toEqual(config.maxDescriptionLength); 29 | expect(provider.testEnvironment).toEqual(config.testEnvironment); 30 | expect(provider.osmApiUrl).toEqual(config.osmApiUrl); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /client/src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { AppComponent } from './app.component'; 2 | import { MockBuilder, MockedComponentFixture, MockRender } from 'ng-mocks'; 3 | import { AppModule } from './app.module'; 4 | import { ConfigProvider } from './config/config.provider'; 5 | 6 | describe(AppComponent.name, () => { 7 | let component: AppComponent; 8 | let fixture: MockedComponentFixture; 9 | let configProvider: ConfigProvider; 10 | 11 | beforeEach(() => { 12 | configProvider = {} as ConfigProvider; 13 | 14 | return MockBuilder(AppComponent, AppModule) 15 | .provide({provide: ConfigProvider, useFactory: () => configProvider}); 16 | }); 17 | 18 | beforeEach(() => { 19 | fixture = MockRender(AppComponent); 20 | component = fixture.point.componentInstance; 21 | fixture.detectChanges(); 22 | }); 23 | 24 | it('should create the app', () => { 25 | expect(component).toBeTruthy(); 26 | }); 27 | 28 | it('should return config test environment value', () => { 29 | configProvider.testEnvironment = false; 30 | expect(component.isInTestMode).toEqual(false); 31 | 32 | configProvider.testEnvironment = true; 33 | expect(component.isInTestMode).toEqual(true); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /client/src/app/project/project.resolver.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@angular/core'; 2 | import { ActivatedRouteSnapshot, ResolveFn } from '@angular/router'; 3 | import { Project } from './project.material'; 4 | import { ProjectService } from './project.service'; 5 | import { of } from 'rxjs'; 6 | import { catchError } from 'rxjs/operators'; 7 | import { HttpErrorResponse } from '@angular/common/http'; 8 | import { NotificationService } from '../common/services/notification.service'; 9 | import { TranslateService } from '@ngx-translate/core'; 10 | 11 | export const projectResolver: ResolveFn = (route: ActivatedRouteSnapshot, _) => { 12 | const projectService = inject(ProjectService); 13 | const notificationService = inject(NotificationService); 14 | const translateService = inject(TranslateService); 15 | 16 | if (!route.paramMap.has('id')) { 17 | return of(); 18 | } 19 | 20 | // @ts-ignore 21 | return projectService.getProject(route.paramMap.get('id')).pipe( 22 | catchError((e: HttpErrorResponse) => { 23 | const message = translateService.instant('project.could-not-load-project', {projectId: route.paramMap.get('id')}); 24 | notificationService.addError(message); 25 | throw e; 26 | }) 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /client/src/app/ui/zoom-control/zoom-control.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ZoomControlComponent } from './zoom-control.component'; 2 | import { MockBuilder, MockedComponentFixture, MockRender } from 'ng-mocks'; 3 | import { AppModule } from '../../app.module'; 4 | 5 | describe(ZoomControlComponent.name, () => { 6 | let component: ZoomControlComponent; 7 | let fixture: MockedComponentFixture; 8 | 9 | beforeEach(() => MockBuilder(ZoomControlComponent, AppModule)); 10 | 11 | beforeEach(() => { 12 | fixture = MockRender(ZoomControlComponent); 13 | component = fixture.point.componentInstance; 14 | fixture.detectChanges(); 15 | }); 16 | 17 | it('should create', () => { 18 | expect(component).toBeTruthy(); 19 | }); 20 | 21 | it('should fire zoom in event', () => { 22 | const zoomSpy = jest.fn(); 23 | component.buttonZoomIn.subscribe(zoomSpy); 24 | 25 | component.onButtonZoomIn(); 26 | 27 | expect(zoomSpy).toHaveBeenCalled(); 28 | }); 29 | 30 | it('should fire zoom out event', () => { 31 | const zoomSpy = jest.fn(); 32 | component.buttonZoomOut.subscribe(zoomSpy); 33 | 34 | component.onButtonZoomOut(); 35 | 36 | expect(zoomSpy).toHaveBeenCalled(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /server/project/api.go: -------------------------------------------------------------------------------- 1 | package project 2 | 3 | import "stm/task" 4 | 5 | type AddDto struct { 6 | Project DraftDto `json:"project"` 7 | Tasks []task.DraftDto `json:"tasks"` 8 | } 9 | 10 | type DraftDto struct { 11 | Name string `json:"name"` // Name of the project. Must not be NULL or empty. 12 | Description string `json:"description"` // Description of the project. Must not be NULL but cam be empty. 13 | Users []string `json:"users"` // A non-empty list of user-IDs. At least the owner should be in here. 14 | Owner string `json:"owner"` // The user-ID who created this project. Must not be NULL or empty. 15 | JosmDataSource JosmDataSource `json:"josmDataSource"` // The source JOSM should load the data from when opening a task in JOSM. 16 | } 17 | 18 | type UpdateDto struct { 19 | Name string `json:"name"` // Name of the project. Must not be NULL or empty. 20 | Description string `json:"description"` // Description of the project. Must not be NULL but cam be empty. 21 | JosmDataSource JosmDataSource `json:"josmDataSource"` // The source JOSM should load the data from when opening a task in JOSM. 22 | } 23 | -------------------------------------------------------------------------------- /client/src/app/ui/tabs/tabs.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../../styles"; 2 | @use "../../../colors"; 3 | 4 | :host { 5 | display: flex; 6 | flex-direction: column; 7 | flex-grow: 1; 8 | min-height: 0; // Needed to make scrolling right. Otherwise content might exceed the page height and doesn't scroll within its container. 9 | } 10 | 11 | button { 12 | position: relative; // needed to set z-Index on hover 13 | background-color: white; 14 | margin-right: -1px; // get rid of douple sized border between two buttons 15 | } 16 | button.selected:hover, 17 | .selected { 18 | border-bottom: 2px solid colors.$color-mid; 19 | background-color: colors.$color-very-light; 20 | } 21 | button:hover { 22 | z-index: 1; 23 | } 24 | 25 | .tab-list { 26 | display: flex; 27 | flex-direction: row; 28 | padding-left: styles.$space-small; 29 | } 30 | 31 | .tab-content { 32 | margin-top: -1px; 33 | border-top: 1px solid colors.$color-light; 34 | min-height: 0px; 35 | display: flex; 36 | flex-direction: column; 37 | overflow: auto; 38 | flex-grow: 1; 39 | height: 100%; 40 | } 41 | 42 | .tab-content-border { 43 | border-right: 1px solid colors.$color-light; 44 | border-left: 1px solid colors.$color-light; 45 | border-bottom: 1px solid colors.$color-light; 46 | } 47 | -------------------------------------------------------------------------------- /client/src/app/dashboard/dashboard.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |

{{'dashboard.welcome' | translate: {userName: userName} }}

4 |
5 |
6 | 7 | {{'language-selection' | translate}} 8 | 9 |
10 |
11 | 12 |
13 |
14 |
15 | 16 |
17 | 18 | 19 | 20 |
21 |
22 |
23 | 24 | 25 | -------------------------------------------------------------------------------- /client/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { ConfigProvider } from './config/config.provider'; 3 | import { TranslateService } from '@ngx-translate/core'; 4 | import { registerLocaleData } from '@angular/common'; 5 | import localeEn from '@angular/common/locales/en'; 6 | import localeDe from '@angular/common/locales/de'; 7 | import localeEs from '@angular/common/locales/es'; 8 | import localeFr from '@angular/common/locales/fr'; 9 | 10 | @Component({ 11 | selector: 'app-root', 12 | templateUrl: './app.component.html', 13 | styleUrls: ['./app.component.scss'], 14 | standalone: false 15 | }) 16 | export class AppComponent { 17 | constructor(private config: ConfigProvider, private translate: TranslateService) { 18 | translate.addLangs(['de', 'en-US', 'es', 'fr', 'it']); 19 | translate.setDefaultLang('en-US'); 20 | 21 | // To make locale usages (e.g. in date pipe) work 22 | registerLocaleData(localeEn, 'en-US'); 23 | registerLocaleData(localeDe, 'de'); 24 | registerLocaleData(localeEs, 'es'); 25 | registerLocaleData(localeFr, 'fr'); 26 | registerLocaleData(localeFr, 'it'); 27 | } 28 | 29 | get isInTestMode(): boolean { 30 | return this.config.testEnvironment; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /client/src/app/project/all-projects.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; 3 | import { Project } from './project.material'; 4 | import { ProjectService } from './project.service'; 5 | import { Observable } from 'rxjs'; 6 | import { catchError } from 'rxjs/operators'; 7 | import { HttpErrorResponse } from '@angular/common/http'; 8 | import { NotificationService } from '../common/services/notification.service'; 9 | import { TranslateService } from '@ngx-translate/core'; 10 | 11 | @Injectable({providedIn: 'root'}) 12 | export class AllProjectsResolver implements Resolve { 13 | constructor( 14 | private projectService: ProjectService, 15 | private notificationService: NotificationService, 16 | private translationService: TranslateService 17 | ) { 18 | } 19 | 20 | resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { 21 | return this.projectService.getProjects().pipe( 22 | catchError((e: HttpErrorResponse) => { 23 | this.notificationService.addError(this.translationService.instant('project.could-not-load-projects')); 24 | throw e; 25 | }) 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /client/src/colors.scss: -------------------------------------------------------------------------------- 1 | // Use material design colors: http://hauke-stieler.de/public/color-palette/ 2 | 3 | // Colors: 4 | $color-very-very-light: #e7faf9; // lighter #50 teal 5 | $color-very-light: #e0f2f1; // #50 teal 6 | $color-very-light-transparent: #e0f2f1c0; // #50 teal 7 | $color-lighter: #bedfdc; // #100 teal (with slightly less saturation) 8 | $color-light: #80cbc4; // #200 teal 9 | $color-mid: #26a69a; // #400 teal 10 | $color-dark: #00796b; // #700 teal 11 | $color-very-dark: #004d40; // #900 teal 12 | 13 | // Grey-scale: 14 | $color-white: #ffffff; 15 | $color-white-transparent: #ffffffc0; 16 | $color-gray-very-light: #eeeeee; // #200 17 | $color-gray-light: #d2d2d2; // a bit darker than #300 18 | $color-gray-mid: #9e9e9e; // #400 19 | $color-gray-dark: #616161; // between #600 and #700 20 | $color-gray-very-dark: #424242; // #900 21 | 22 | // Others: 23 | $color-error: #ef5350; // #400 red 24 | $color-error-dark: #d32f2f; // #700 red 25 | $color-error-very-light: #ffcdd2; // #100 red 26 | $color-error-very-light-transparent: #ffcdd2c0; // #100 red 27 | 28 | $color-warn: #ffca28; // #400 orange 29 | $color-warn-dark: #ff6f00; // #700 orange 30 | $color-warn-very-light: #ffecb3; // #100 orange 31 | $color-warn-very-light-transparent: #ffecb3c0; // #100 orange 32 | -------------------------------------------------------------------------------- /client/src/app/auth/oauth-landing/oauth-landing.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { OauthLandingComponent } from './oauth-landing.component'; 2 | import { ActivatedRoute } from '@angular/router'; 3 | import { of } from 'rxjs'; 4 | import { MockBuilder, MockedComponentFixture, MockRender } from 'ng-mocks'; 5 | import { AppModule } from '../../app.module'; 6 | 7 | describe(OauthLandingComponent.name, () => { 8 | let component: OauthLandingComponent; 9 | let fixture: MockedComponentFixture; 10 | 11 | beforeEach(() => { 12 | const activeRoute = { 13 | queryParams: of([{ 14 | auth_token: 'abc123' 15 | }]) 16 | }; 17 | 18 | window.close = jest.fn(); 19 | 20 | return MockBuilder(OauthLandingComponent, AppModule) 21 | .provide({ 22 | provide: ActivatedRoute, 23 | useFactory: () => activeRoute 24 | }); 25 | }); 26 | 27 | beforeEach(() => { 28 | fixture = MockRender(OauthLandingComponent); 29 | component = fixture.point.componentInstance; 30 | fixture.detectChanges(); 31 | }); 32 | 33 | it('should create', () => { 34 | expect(component).toBeTruthy(); 35 | }); 36 | 37 | it('should call close on window', () => { 38 | expect(window.close).toHaveBeenCalled(); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /client/src/app/project-creation/task-draft-list/task-draft-list.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TaskDraftListComponent } from './task-draft-list.component'; 2 | import { TaskDraftService } from '../task-draft.service'; 3 | import { MockBuilder, MockedComponentFixture, MockRender } from 'ng-mocks'; 4 | import { AppModule } from '../../app.module'; 5 | 6 | describe(TaskDraftListComponent.name, () => { 7 | let component: TaskDraftListComponent; 8 | let fixture: MockedComponentFixture; 9 | let taskDraftService: TaskDraftService; 10 | 11 | beforeEach(() => { 12 | taskDraftService = {} as TaskDraftService; 13 | 14 | return MockBuilder(TaskDraftListComponent, AppModule) 15 | .provide({provide: TaskDraftService, useFactory: () => taskDraftService}); 16 | }); 17 | 18 | beforeEach(() => { 19 | fixture = MockRender(TaskDraftListComponent); 20 | component = fixture.point.componentInstance; 21 | fixture.detectChanges(); 22 | }); 23 | 24 | it('should create', () => { 25 | expect(component).toBeTruthy(); 26 | }); 27 | 28 | it('should select task on service', () => { 29 | taskDraftService.selectTask = jest.fn(); 30 | 31 | component.onTaskClicked('123'); 32 | 33 | expect(taskDraftService.selectTask).toHaveBeenCalledWith('123'); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /client/src/app/comments/comment/comment.component.html: -------------------------------------------------------------------------------- 1 |

{{ title }}

2 | 3 |
4 |
5 |
{{ c.text }}
6 | 14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | 29 |
31 |
-------------------------------------------------------------------------------- /server/comment/service_test.go: -------------------------------------------------------------------------------- 1 | package comment 2 | 3 | import ( 4 | "database/sql" 5 | "github.com/hauke96/sigolo" 6 | "stm/config" 7 | "stm/test" 8 | "stm/util" 9 | "testing" 10 | ) 11 | 12 | var ( 13 | tx *sql.Tx 14 | s *Service 15 | h *test.Helper 16 | ) 17 | 18 | func TestMain(m *testing.M) { 19 | h = test.NewTestHelper(setup) 20 | m.Run() 21 | } 22 | 23 | func setup() { 24 | sigolo.LogLevel = sigolo.LOG_DEBUG 25 | config.LoadConfig("../test/test-config.json") 26 | h.InitWithDummyData(config.Conf.DbUsername, config.Conf.DbPassword, config.Conf.DbDatabase) 27 | tx = h.NewTransaction() 28 | 29 | logger := util.NewLogger() 30 | 31 | s = Init(logger, GetStore(tx, logger)) 32 | } 33 | 34 | // 35 | //func TestGetProjects(t *testing.T) { 36 | // h.Run(t, func() error { 37 | // comments, err := s.GetComments("2") 38 | // if err != nil { 39 | // return err 40 | // } 41 | // 42 | // if len(comments) != 2 { 43 | // return errors.Errorf("Expected %d comment for project %d but found %d", 3, 1, len(comments)) 44 | // } 45 | // 46 | // if comments[0].Id != "1" { 47 | // return errors.Errorf("Expected comment ID %s but got %s", "1", comments[0].Id) 48 | // } 49 | // if comments[1].Id != "2" { 50 | // return errors.Errorf("Expected comment ID %s but got %s", "2", comments[1].Id) 51 | // } 52 | // 53 | // return nil 54 | // }) 55 | //} 56 | -------------------------------------------------------------------------------- /client/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | const baseUrl = document.location.protocol + '//' + document.location.hostname + ':8080'; 2 | const usedApi = 'v2.9'; 3 | 4 | export const environment = { 5 | production: false, 6 | oauth_landing: document.location.origin + '/oauth-landing', 7 | 8 | base_url: baseUrl, 9 | url_auth: baseUrl + '/oauth2/login', 10 | url_config: baseUrl + '/' + usedApi + '/config', 11 | url_projects: baseUrl + '/' + usedApi + '/projects', 12 | url_projects_by_id: baseUrl + '/' + usedApi + '/projects/{id}', 13 | url_projects_update: baseUrl + '/' + usedApi + '/projects/{id}', 14 | url_projects_users: baseUrl + '/' + usedApi + '/projects/{id}/users', 15 | url_projects_export: baseUrl + '/' + usedApi + '/projects/{id}/export', 16 | url_projects_import: baseUrl + '/' + usedApi + '/projects/import', 17 | url_projects_comments: baseUrl + '/' + usedApi + '/projects/{id}/comments', 18 | url_tasks: baseUrl + '/' + usedApi + '/tasks', 19 | url_task: baseUrl + '/' + usedApi + '/tasks/{id}', 20 | url_task_assignedUser: baseUrl + '/' + usedApi + '/tasks/{id}/assignedUser', 21 | url_task_processPoints: baseUrl + '/' + usedApi + '/tasks/{id}/processPoints', 22 | url_task_comments: baseUrl + '/' + usedApi + '/tasks/{id}/comments', 23 | url_updates: 'ws://' + document.location.hostname + ':8080' + '/' + usedApi + '/updates' 24 | }; 25 | -------------------------------------------------------------------------------- /client/src/app/common/services/process-point-color.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | @Injectable({ 4 | providedIn: 'root' 5 | }) 6 | export class ProcessPointColorService { 7 | constructor() { 8 | } 9 | 10 | public getProcessPointsColor(processPoints: number, maxProcessPoints: number): string { 11 | if (processPoints < 0 || maxProcessPoints <= 0) { 12 | throw new Error('Input values [' + processPoints + ' / ' + maxProcessPoints + '] out of range'); 13 | } 14 | 15 | const processPointRatio = processPoints / maxProcessPoints; 16 | 17 | // This makes red a bit lighter than green: Reduce color value the more points the task has 18 | const maxChannelValue = 230 - 40 * processPointRatio; 19 | 20 | const rValue = Math.min(maxChannelValue, maxChannelValue * 2 * (1 - processPointRatio)); 21 | const gValue = Math.min(maxChannelValue, maxChannelValue * 2 * processPointRatio); 22 | const bValue = 0; 23 | 24 | let r = Math.round(rValue).toString(16); 25 | let g = Math.round(gValue).toString(16); 26 | let b = Math.round(bValue).toString(16); 27 | 28 | if (r.length === 1) { 29 | r = '0' + r; 30 | } 31 | if (g.length === 1) { 32 | g = '0' + g; 33 | } 34 | if (b.length === 1) { 35 | b = '0' + b; 36 | } 37 | 38 | return '#' + r + g + b; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /client/src/environments/environment.local.ts: -------------------------------------------------------------------------------- 1 | const baseUrl = document.location.protocol + '//' + document.location.hostname + ':8080'; 2 | const usedApi = 'v2.9'; 3 | 4 | export const environment = { 5 | production: false, 6 | oauth_landing: document.location.origin + '/oauth-landing', 7 | 8 | base_url: baseUrl, 9 | url_auth: baseUrl + '/oauth2/login', 10 | url_config: baseUrl + '/' + usedApi + '/config', 11 | url_projects: baseUrl + '/' + usedApi + '/projects', 12 | url_projects_by_id: baseUrl + '/' + usedApi + '/projects/{id}', 13 | url_projects_update: baseUrl + '/' + usedApi + '/projects/{id}', 14 | url_projects_users: baseUrl + '/' + usedApi + '/projects/{id}/users', 15 | url_projects_export: baseUrl + '/' + usedApi + '/projects/{id}/export', 16 | url_projects_import: baseUrl + '/' + usedApi + '/projects/import', 17 | url_projects_comments: baseUrl + '/' + usedApi + '/projects/{id}/comments', 18 | url_tasks: baseUrl + '/' + usedApi + '/tasks', 19 | url_task: baseUrl + '/' + usedApi + '/tasks/{id}', 20 | url_task_assignedUser: baseUrl + '/' + usedApi + '/tasks/{id}/assignedUser', 21 | url_task_processPoints: baseUrl + '/' + usedApi + '/tasks/{id}/processPoints', 22 | url_task_comments: baseUrl + '/' + usedApi + '/tasks/{id}/comments', 23 | url_updates: 'ws://' + document.location.hostname + ':8080' + '/' + usedApi + '/updates' 24 | }; 25 | -------------------------------------------------------------------------------- /client/src/app/config/config.resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConfigResolver } from './config.resolver'; 2 | import { ConfigProvider } from './config.provider'; 3 | import { HttpClient } from '@angular/common/http'; 4 | import { Config } from './config'; 5 | import { of } from 'rxjs'; 6 | import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; 7 | 8 | describe(ConfigResolver.name, () => { 9 | let service: ConfigResolver; 10 | let httpClient: HttpClient; 11 | let configProvider: ConfigProvider; 12 | 13 | beforeEach(() => { 14 | httpClient = {} as HttpClient; 15 | configProvider = {} as ConfigProvider; 16 | 17 | service = new ConfigResolver(httpClient, configProvider); 18 | }); 19 | 20 | it('should be created', () => { 21 | expect(service).toBeTruthy(); 22 | }); 23 | 24 | it('should apply config to config provider', done => { 25 | const configFromServer = { 26 | sourceRepoUrl: 'foo', 27 | maxTasksPerProject: 2, 28 | maxDescriptionLength: 3 29 | } as Config; 30 | httpClient.get = jest.fn().mockReturnValue(of(configFromServer)); 31 | const configProviderSpy = configProvider.apply = jest.fn(); 32 | 33 | service.resolve({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot).subscribe(() => { 34 | expect(configProviderSpy).toHaveBeenCalledWith(configFromServer); 35 | done(); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /client/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | const baseUrl = document.location.protocol + '//' + document.location.hostname; 2 | const usedApi = 'v2.9'; 3 | 4 | export const environment = { 5 | production: true, 6 | oauth_landing: document.location.origin + '/oauth-landing', 7 | 8 | base_url: baseUrl, 9 | url_auth: baseUrl + '/api/oauth2/login', 10 | url_config: baseUrl + '/api/' + usedApi + '/config', 11 | url_projects: baseUrl + '/api/' + usedApi + '/projects', 12 | url_projects_by_id: baseUrl + '/api/' + usedApi + '/projects/{id}', 13 | url_projects_update: baseUrl + '/api/' + usedApi + '/projects/{id}', 14 | url_projects_users: baseUrl + '/api/' + usedApi + '/projects/{id}/users', 15 | url_projects_export: baseUrl + '/api/' + usedApi + '/projects/{id}/export', 16 | url_projects_import: baseUrl + '/api/' + usedApi + '/projects/import', 17 | url_projects_comments: baseUrl + '/api/' + usedApi + '/projects/{id}/comments', 18 | url_tasks: baseUrl + '/api/' + usedApi + '/tasks', 19 | url_task: baseUrl + '/api/' + usedApi + '/tasks/{id}', 20 | url_task_assignedUser: baseUrl + '/api/' + usedApi + '/tasks/{id}/assignedUser', 21 | url_task_processPoints: baseUrl + '/api/' + usedApi + '/tasks/{id}/processPoints', 22 | url_task_comments: baseUrl + '/api/' + usedApi + '/tasks/{id}/comments', 23 | url_updates: 'wss://' + document.location.hostname + '/api/' + usedApi + '/updates' 24 | }; 25 | -------------------------------------------------------------------------------- /server/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "github.com/pkg/errors" 6 | "net/http" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | const ( 12 | VERSION = "1.7.0" 13 | ) 14 | 15 | func GetParam(param string, r *http.Request) (string, error) { 16 | value := r.FormValue(param) 17 | if strings.TrimSpace(value) == "" { 18 | return "", errors.New(fmt.Sprintf("parameter '%s' not specified", param)) 19 | } 20 | 21 | return value, nil 22 | } 23 | 24 | func GetIntParam(param string, r *http.Request) (int, error) { 25 | valueString, err := GetParam(param, r) 26 | if err != nil { 27 | return 0, err 28 | } 29 | 30 | return strconv.Atoi(valueString) 31 | } 32 | 33 | func ResponseBadRequest(w http.ResponseWriter, logger *Logger, err error) { 34 | ErrorResponse(w, logger, err, http.StatusBadRequest) 35 | } 36 | 37 | func ResponseInternalError(w http.ResponseWriter, logger *Logger, err error) { 38 | ErrorResponse(w, logger, err, http.StatusInternalServerError) 39 | } 40 | 41 | func ResponseUnauthorized(w http.ResponseWriter, logger *Logger, err error) { 42 | ErrorResponse(w, logger, err, http.StatusUnauthorized) 43 | } 44 | 45 | func ErrorResponse(w http.ResponseWriter, logger *Logger, err error, status int) { 46 | if logger != nil { 47 | logger.Err("ErrorResponse with status %d: %s", status, err.Error()) 48 | } 49 | w.WriteHeader(status) 50 | w.Write([]byte(err.Error())) 51 | } 52 | -------------------------------------------------------------------------------- /client/tslint-to-eslint-config.log: -------------------------------------------------------------------------------- 1 | 8 ESLint rules behave differently from their TSLint counterparts: 2 | * arrow-body-style: 3 | - ESLint will throw an error if the function body is multiline yet has a one-line return on it. 4 | * no-console: 5 | - Custom console methods, if they exist, will no longer be allowed. 6 | * space-before-function-paren: 7 | - Option "constructor" is not supported by ESLint. 8 | - Option "method" is not supported by ESLint. 9 | * no-underscore-dangle: 10 | - Leading or trailing underscores (_) on identifiers will now be forbidden. 11 | * no-invalid-this: 12 | - Functions in methods will no longer be ignored. 13 | * @typescript-eslint/no-unused-expressions: 14 | - The TSLint optional config "allow-new" is the default ESLint behavior and will no longer be ignored. 15 | * prefer-arrow/prefer-arrow-functions: 16 | - ESLint (eslint-plugin-prefer-arrow plugin) does not support allowing named functions defined with the function keyword. 17 | * eqeqeq: 18 | - Option "smart" allows for comparing two literal values, evaluating the value of typeof and null comparisons. 19 | 20 | 2 rules are not known by tslint-to-eslint-config to have ESLint equivalents: 21 | * tslint-to-eslint-config does not know the ESLint equivalent for TSLint's "import-spacing". 22 | * tslint-to-eslint-config does not know the ESLint equivalent for TSLint's "whitespace". 23 | 24 | -------------------------------------------------------------------------------- /client/src/app/project-creation/project-properties/project-properties.component.html: -------------------------------------------------------------------------------- 1 |
2 | {{'name' | translate}} 3 | 4 |
5 |
6 | {{'project-creation.max-points-label' | translate}} 7 | 12 |
13 |
14 | {{'project-creation.josm-data-source-label' | translate}} 15 | 21 |
22 |
23 |
24 |

{{'description' | translate}}

25 | {{descriptionInput.textLength}} / {{config.maxDescriptionLength}} 26 |
27 | 31 |
32 | -------------------------------------------------------------------------------- /client/src/app/task/task-list/task-list.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../../styles"; 2 | @use "../../../colors"; 3 | @use "../../../icons"; 4 | 5 | .list-container { 6 | overflow-y: auto; 7 | } 8 | 9 | .list-item-container { 10 | display: flex; 11 | flex-direction: row; 12 | justify-content: space-between; 13 | } 14 | 15 | .list-item { 16 | display: flex; 17 | flex-direction: row; 18 | } 19 | 20 | .list-item-inner { 21 | display: flex; 22 | flex-direction: row; 23 | justify-content: space-between; 24 | flex-grow: 1; 25 | margin-right: styles.$space-base; 26 | } 27 | 28 | .list-item-comment { 29 | align-self: center; 30 | height: styles.$space-base * 2.5; 31 | line-height: styles.$space-base * 2.2; // Move icon a bit more up so it's visually centered 32 | border: 1px solid transparent; 33 | color: colors.$color-gray-dark; 34 | padding-left: styles.$space-base; 35 | padding-right: styles.$space-base; 36 | } 37 | 38 | .list-item-comment:hover { 39 | border: 1px solid colors.$color-light; 40 | border-radius: styles.$space-base * 1.25; 41 | color: black; 42 | cursor: pointer; 43 | } 44 | 45 | .selected { 46 | .list-item-comment:hover { 47 | border: 1px solid colors.$color-light; 48 | border-radius: styles.$space-base * 1.25; 49 | } 50 | } 51 | 52 | .selected { 53 | background-color: colors.$color-very-light; 54 | } 55 | 56 | .done-label { 57 | color: green; 58 | margin-left: styles.$space-base; 59 | } 60 | -------------------------------------------------------------------------------- /client/src/app/common/services/shortcut.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | import { EventManager } from '@angular/platform-browser'; 4 | import { DOCUMENT } from '@angular/common'; 5 | 6 | @Injectable({ 7 | providedIn: 'root' 8 | }) 9 | export class ShortcutService { 10 | 11 | constructor(private eventManager: EventManager, 12 | @Inject(DOCUMENT) document: Document) { 13 | } 14 | 15 | // Ignore shortcuts on these node types 16 | private ignoredNodeNames = ['input', 'textarea', 'select', 'option']; 17 | 18 | /** 19 | * @param shortcutString A string defining the shortcut. E.g. "shift.d" should say that the "shift" and "d" key must be pressed. 20 | */ 21 | add(shortcutString: string): Observable { 22 | const eventString = `keydown.${shortcutString}`; 23 | 24 | return new Observable(observer => { 25 | const handler = (e: KeyboardEvent) => { 26 | if (e.target instanceof HTMLElement && !this.ignoredNodeNames.includes(e.target.nodeName.toLowerCase())) { 27 | e.preventDefault(); 28 | observer.next(); 29 | } 30 | }; 31 | 32 | const removeHandlerCallback = this.eventManager.addEventListener( 33 | document.documentElement, eventString, handler 34 | ); 35 | 36 | return () => { 37 | removeHandlerCallback(); 38 | }; 39 | }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /client/src/app/auth/login/login.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |
6 |
7 | 8 |
9 |
10 |
11 | 12 | {{"language-selection" | translate}} 13 | 14 |
15 |
16 | 19 |
20 |
21 | 24 |
25 |
26 |

27 |
28 |
29 |

30 |
31 |
32 | 33 |
34 | 35 | -------------------------------------------------------------------------------- /client/src/app/comments/comment/comment.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, Output } from '@angular/core'; 2 | import { Comment } from '../comment.material'; 3 | import { CurrentUserService } from '../../user/current-user.service'; 4 | import { TranslateService } from '@ngx-translate/core'; 5 | 6 | @Component({ 7 | selector: 'app-comment', 8 | templateUrl: './comment.component.html', 9 | styleUrl: './comment.component.scss', 10 | standalone: false 11 | }) 12 | export class CommentComponent { 13 | 14 | public currentComments: Comment[] = []; 15 | 16 | @Input() 17 | public title: string; 18 | 19 | @Output() 20 | public commentSendClicked = new EventEmitter(); 21 | 22 | enteredComment: string; 23 | 24 | constructor(private currentUserService: CurrentUserService, private translateService: TranslateService) { 25 | } 26 | 27 | @Input() 28 | set comments(value: Comment[]) { 29 | this.currentComments = value; 30 | this.currentComments.sort((a, b) => b.creationDate.getTime() - a.creationDate.getTime()); 31 | } 32 | 33 | public get currentLocale(): string { 34 | return this.translateService.currentLang; 35 | } 36 | 37 | public isFromCurrentUser(comment: Comment): boolean { 38 | return comment.author.uid === this.currentUserService.getUserId(); 39 | } 40 | 41 | public onSendButtonClicked(): void { 42 | this.commentSendClicked.emit(this.enteredComment); 43 | this.enteredComment = ''; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /doc/operation/docker.md: -------------------------------------------------------------------------------- 1 | This project uses a lot of the docker feature, including docker-compose. 2 | 3 | I assume you're familiar with docker, so just take a look at the `docker-compose.yml` to get an idea of what container this project uses. 4 | 5 | # Structure 6 | 7 | We have the overall `docker-compose.yml` which contains four service definitions: 8 | 9 | * `stm-client`: The webclient 10 | * `stm-server`: The go-server application without the database 11 | * `stm-db`: The database 12 | 13 | See [stm.md](./stm.md) for a minimal example of a compose file when hosting STM yourself. 14 | 15 | ## Image versions 16 | 17 | The container use a specific version of an image (e.g. `postgres:17`) instead of general tags like `:latest`. 18 | This ensures that a specific version of the SimpleTaskManager still builds and runs in months or even years. 19 | 20 | # Docker hub 21 | 22 | To make deployment easy for everyone, pre-build images are uploaded to [docker hub](https://hub.docker.com/u/simpletaskmanager) which can be used to deploy your own instance of STM without the whole development setup on your server. 23 | 24 | The exact deployment process is described in the [linux.md](./linux.md) file. 25 | 26 | # Logging 27 | 28 | The containers are using the `journald` driver for logging. 29 | So accessing the logs is also possible via e.g. `journalctl CONTAINER_NAME=stm-db` and the logs are appended to the journal after restarting/rebuilding the container. 30 | For more logging commands see the [logging documentation file](./logging.md). -------------------------------------------------------------------------------- /client/src/app/comments/comment.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { CommentService } from './comment.service'; 2 | import { CommentDto } from './comment.material'; 3 | import { User } from '../user/user.material'; 4 | 5 | describe(CommentService.name, () => { 6 | let service: CommentService; 7 | 8 | beforeEach(() => { 9 | service = new CommentService(); 10 | }); 11 | 12 | it('should be created', () => { 13 | expect(service).toBeTruthy(); 14 | }); 15 | 16 | it('should correctly convert DTO', () => { 17 | const dtos = [ 18 | new CommentDto(123, 'some text', 'author1', '2024-03-27 10:00'), 19 | new CommentDto(234, 'some other text', 'author2', '2024-03-27 11:00') 20 | ]; 21 | const userMap = new Map([ 22 | ['author2', new User('Peter', 'author2')], 23 | ['author1', new User('Anna', 'author1')], 24 | ]); 25 | 26 | const comments = service.toCommentsWithUserMap(dtos, userMap); 27 | 28 | expect(comments.length).toEqual(2); 29 | 30 | expect(comments[0].id).toEqual(dtos[0].id); 31 | expect(comments[0].text).toEqual(dtos[0].text); 32 | expect(comments[0].author).toEqual(userMap.get('author1') as User); 33 | expect(comments[0].creationDate).toEqual(new Date(dtos[0].creationDate)); 34 | 35 | expect(comments[1].id).toEqual(dtos[1].id); 36 | expect(comments[1].text).toEqual(dtos[1].text); 37 | expect(comments[1].author).toEqual(userMap.get('author2') as User); 38 | expect(comments[1].creationDate).toEqual(new Date(dtos[1].creationDate)); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /client/src/app/project-creation/task-edit/task-edit.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TaskEditComponent } from './task-edit.component'; 2 | import { TaskDraftService } from '../task-draft.service'; 3 | import { TaskDraft } from '../../task/task.material'; 4 | import { Polygon } from 'ol/geom'; 5 | import { MockBuilder, MockedComponentFixture, MockRender } from 'ng-mocks'; 6 | import { AppModule } from '../../app.module'; 7 | 8 | describe(TaskEditComponent.name, () => { 9 | let component: TaskEditComponent; 10 | let fixture: MockedComponentFixture; 11 | let taskDraftService: TaskDraftService; 12 | 13 | beforeEach(() => { 14 | taskDraftService = {} as TaskDraftService; 15 | 16 | return MockBuilder(TaskEditComponent, AppModule) 17 | .provide({provide: TaskDraftService, useFactory: () => taskDraftService}); 18 | }); 19 | 20 | beforeEach(() => { 21 | fixture = MockRender(TaskEditComponent, {task: {}}); 22 | component = fixture.point.componentInstance; 23 | fixture.detectChanges(); 24 | }); 25 | 26 | it('should create', () => { 27 | expect(component).toBeTruthy(); 28 | }); 29 | 30 | it('should call rename function on service', () => { 31 | taskDraftService.changeTaskName = jest.fn(); 32 | 33 | component.task = new TaskDraft('123', 'some name', new Polygon([]), 0); 34 | component.onTaskNameChanged({target: {value: 'new name'} as unknown as EventTarget} as Event); 35 | 36 | expect(taskDraftService.changeTaskName).toHaveBeenCalledWith('123', 'new name'); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /client/src/app/common/services/map-layer.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { MapLayerService } from './map-layer.service'; 2 | import BaseLayer from 'ol/layer/Base'; 3 | 4 | describe(MapLayerService.name, () => { 5 | let service: MapLayerService; 6 | 7 | beforeEach(() => { 8 | service = new MapLayerService(); 9 | }); 10 | 11 | it('should be created', () => { 12 | expect(service).toBeTruthy(); 13 | }); 14 | 15 | it('should call layer added handler', () => { 16 | // Arrange 17 | const spy = jest.fn(); 18 | service.onLayerAdded.subscribe(spy); 19 | const layer = new BaseLayer({}); 20 | 21 | // Act 22 | service.addLayer(layer); 23 | 24 | // Assert 25 | expect(spy).toHaveBeenCalledTimes(1); 26 | expect(spy).toHaveBeenCalledWith(layer); 27 | }); 28 | 29 | it('should call fit view handler', () => { 30 | // Arrange 31 | const spy = jest.fn(); 32 | service.onFitView.subscribe(spy); 33 | const extent = [1, 2, 3, 4]; 34 | 35 | // Act 36 | service.fitView(extent); 37 | 38 | // Assert 39 | expect(spy).toHaveBeenCalledTimes(1); 40 | expect(spy).toHaveBeenCalledWith(extent); 41 | }); 42 | 43 | it('should call move to outside geometry handler', () => { 44 | // Arrange 45 | const spy = jest.fn(); 46 | service.onMoveToOutsideGeometry.subscribe(spy); 47 | const extent = [1, 2, 3, 4]; 48 | 49 | // Act 50 | service.moveToOutsideGeometry(extent); 51 | 52 | // Assert 53 | expect(spy).toHaveBeenCalledTimes(1); 54 | expect(spy).toHaveBeenCalledWith(extent); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /server/api/context.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "database/sql" 5 | "github.com/pkg/errors" 6 | "stm/comment" 7 | "stm/database" 8 | "stm/export" 9 | "stm/oauth2" 10 | "stm/permission" 11 | "stm/project" 12 | "stm/task" 13 | "stm/util" 14 | "stm/websocket" 15 | ) 16 | 17 | type Context struct { 18 | *util.Logger 19 | Token *oauth2.Token 20 | Transaction *sql.Tx 21 | ProjectService *project.Service 22 | TaskService *task.Service 23 | ExportService *export.Service 24 | WebsocketSender *websocket.Sender 25 | } 26 | 27 | // createContext starts a new Transaction and creates new service instances which use this new Transaction so that all 28 | // services (also those calling each other) are using the same Transaction. 29 | func createContext(token *oauth2.Token, logger *util.Logger) (*Context, error) { 30 | ctx := &Context{} 31 | ctx.Token = token 32 | ctx.Logger = logger 33 | 34 | tx, err := database.GetTransaction(logger) 35 | if err != nil { 36 | return nil, errors.Wrap(err, "error getting Transaction") 37 | } 38 | ctx.Transaction = tx 39 | 40 | permissionStore := permission.Init(tx, ctx.Logger) 41 | commentStore := comment.GetStore(tx, ctx.Logger) 42 | commentService := comment.Init(ctx.Logger, commentStore) 43 | 44 | ctx.TaskService = task.Init(tx, ctx.Logger, permissionStore, commentService, commentStore) 45 | ctx.ProjectService = project.Init(tx, ctx.Logger, ctx.TaskService, permissionStore, commentService, commentStore) 46 | ctx.ExportService = export.Init(logger, ctx.ProjectService) 47 | ctx.WebsocketSender = websocket.Init(ctx.Logger) 48 | 49 | return ctx, nil 50 | } 51 | -------------------------------------------------------------------------------- /client/src/app/user/user-invitation/user-invitation.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; 2 | import { NotificationService } from '../../common/services/notification.service'; 3 | import { UserService } from '../user.service'; 4 | import { User } from '../user.material'; 5 | import { TranslateService } from '@ngx-translate/core'; 6 | 7 | @Component({ 8 | selector: 'app-user-invitation', 9 | templateUrl: './user-invitation.component.html', 10 | styleUrls: ['./user-invitation.component.scss'], 11 | standalone: false 12 | }) 13 | export class UserInvitationComponent { 14 | @Input() public users: User[]; 15 | @Output() public userInvited: EventEmitter = new EventEmitter(); 16 | 17 | public enteredUserName: string; 18 | 19 | constructor( 20 | private userService: UserService, 21 | private notificationService: NotificationService, 22 | private translationService: TranslateService 23 | ) { 24 | } 25 | 26 | public onInvitationButtonClicked(): void { 27 | if (this.users.map(u => u.name).includes(this.enteredUserName)) { 28 | this.notificationService.addWarning(this.translationService.instant('user.already-member', {user: this.enteredUserName})); 29 | return; 30 | } 31 | 32 | this.userService.getUserByName(this.enteredUserName).subscribe( 33 | user => { 34 | this.userInvited.emit(user); 35 | }, err => { 36 | console.error(err); 37 | this.notificationService.addError(this.translationService.instant('user.unable-load-user', {user: this.enteredUserName})); 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /client/src/app/user/user-list/user-list.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { UserListComponent } from './user-list.component'; 2 | import { CurrentUserService } from '../current-user.service'; 3 | import { MockBuilder, MockedComponentFixture, MockRender } from 'ng-mocks'; 4 | import { AppModule } from '../../app.module'; 5 | 6 | describe(UserListComponent.name, () => { 7 | let component: UserListComponent; 8 | let fixture: MockedComponentFixture; 9 | let currentUserService: CurrentUserService; 10 | 11 | beforeEach(() => { 12 | currentUserService = {} as CurrentUserService; 13 | currentUserService.getUserId = jest.fn().mockReturnValue('123'); 14 | 15 | return MockBuilder(UserListComponent, AppModule) 16 | .provide({provide: CurrentUserService, useFactory: () => currentUserService}); 17 | }); 18 | 19 | beforeEach(() => { 20 | fixture = MockRender(UserListComponent); 21 | component = fixture.point.componentInstance; 22 | fixture.detectChanges(); 23 | }); 24 | 25 | it('should detect removable users', () => { 26 | component.ownerUid = '123'; 27 | expect(component).toBeTruthy(); 28 | 29 | expect(component.canRemove('123')).toEqual(false); 30 | expect(component.canRemove('234')).toEqual(true); 31 | expect(component.canRemove('345')).toEqual(true); 32 | }); 33 | 34 | it('should remove user correctly', () => { 35 | // Arrange 36 | const removeUserSpy = jest.fn(); 37 | component.userRemoved.subscribe(removeUserSpy); 38 | 39 | component.ownerUid = '123'; 40 | expect(component).toBeTruthy(); 41 | 42 | // Act 43 | component.onRemoveUserClicked('123'); 44 | 45 | // Assert 46 | expect(removeUserSpy).toHaveBeenCalledWith('123'); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /client/src/app/ui/max-validator.directive.spec.ts: -------------------------------------------------------------------------------- 1 | import { MaxValidatorDirective } from './max-validator.directive'; 2 | import { FormControl } from '@angular/forms'; 3 | 4 | describe(MaxValidatorDirective.name, () => { 5 | it('should create an instance', () => { 6 | const directive = new MaxValidatorDirective(); 7 | expect(directive).toBeTruthy(); 8 | }); 9 | 10 | it('should work on valid values', () => { 11 | const directive = new MaxValidatorDirective(); 12 | directive.appMaxValidator = 100; 13 | 14 | expect(directive.validate({value: 0} as FormControl)).toEqual(null); 15 | expect(directive.validate({value: 1} as FormControl)).toEqual(null); 16 | expect(directive.validate({value: 99} as FormControl)).toEqual(null); 17 | expect(directive.validate({value: 100} as FormControl)).toEqual(null); 18 | expect(directive.validate({value: '100'} as FormControl)).toEqual(null); 19 | expect(directive.validate({value: ' 100 '} as FormControl)).toEqual(null); 20 | expect(directive.validate({value: -1} as FormControl)).toEqual(null); 21 | }); 22 | 23 | it('should work on invalid values', () => { 24 | const directive = new MaxValidatorDirective(); 25 | directive.appMaxValidator = 100; 26 | 27 | expect(directive.validate({value: 101} as FormControl)).toEqual({appMaxValidator: true}); 28 | expect(directive.validate({value: ''} as FormControl)).toEqual({appMaxValidator: true}); 29 | expect(directive.validate({value: null} as FormControl)).toEqual({appMaxValidator: true}); // works 30 | expect(directive.validate({value: undefined} as FormControl)).toEqual({appMaxValidator: true}); // works 31 | expect(directive.validate({value: '1d'} as FormControl)).toEqual({appMaxValidator: true}); // works 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /client/src/assets/arrow-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 16 | 18 | 19 | 21 | image/svg+xml 22 | 24 | 25 | 26 | 27 | 29 | 49 | 54 | 55 | -------------------------------------------------------------------------------- /server/database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | _ "github.com/lib/pq" 7 | "github.com/pkg/errors" 8 | "stm/config" 9 | "stm/util" 10 | ) 11 | 12 | var ( 13 | db *sql.DB 14 | ) 15 | 16 | // GetTransaction tries to open to the database and creates a transaction. Only if the initial connection succeeds, 17 | // a reconnect loop starts. 18 | func GetTransaction(logger *util.Logger) (*sql.Tx, error) { 19 | if db == nil { // No database connection at all 20 | err := open() 21 | if err != nil { 22 | logger.Err("Opening initial DB connection failed") 23 | return nil, err 24 | } 25 | } else if err := db.Ping(); err != nil { // Check the DB connection is broken and try to reconnect 26 | logger.Err("DB ping check failed with error: %s", err.Error()) 27 | logger.Log("Try to reconnect...") 28 | 29 | err := open() 30 | if err != nil { 31 | logger.Err("Reconnect failed") 32 | return nil, err 33 | } 34 | 35 | logger.Log("Successfully created new database connection") 36 | } 37 | 38 | return db.Begin() 39 | } 40 | 41 | // open tries to open to the database and performs a simple health-check by using the "Ping" function on the database. 42 | // Only if the check was successful, the "db" variable is set. 43 | func open() error { 44 | dbConn, err := sql.Open("postgres", fmt.Sprintf("host=%s user=%s password=%s dbname=%s sslmode=disable", config.Conf.DbHost, config.Conf.DbUsername, config.Conf.DbPassword, config.Conf.DbDatabase)) 45 | if err != nil { 46 | return errors.Wrap(err, "unable to open database connection") 47 | } 48 | 49 | err = dbConn.Ping() 50 | if err != nil { 51 | return errors.Wrap(err, "ping on newly opened database connection failed") 52 | } 53 | 54 | db = dbConn 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /client/src/app/project-creation/project-import/project-import.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { ProjectExport } from '../../project/project.material'; 3 | import { ProjectImportService } from '../project-import.service'; 4 | import { NotificationService } from '../../common/services/notification.service'; 5 | import { TranslateService } from '@ngx-translate/core'; 6 | 7 | @Component({ 8 | selector: 'app-project-import', 9 | templateUrl: './project-import.component.html', 10 | styleUrls: ['./project-import.component.scss'], 11 | standalone: false 12 | }) 13 | export class ProjectImportComponent { 14 | 15 | constructor( 16 | private notificationService: NotificationService, 17 | private projectImportService: ProjectImportService, 18 | private translationService: TranslateService 19 | ) { 20 | } 21 | 22 | public onProjectSelected(event: any): void { 23 | this.uploadFile(event, (e) => this.addProjectExport(e)); 24 | } 25 | 26 | public addProjectExport(evt: Event): void { 27 | if (!evt || !evt.target) { 28 | return; 29 | } 30 | 31 | // @ts-ignore 32 | const project = JSON.parse(evt.target.result) as ProjectExport; 33 | this.projectImportService.importProjectAsNewProject(project); 34 | } 35 | 36 | private uploadFile(event: any, loadHandler: (evt: Event) => void): void { 37 | const reader = new FileReader(); 38 | const file = event.target.files[0]; 39 | 40 | reader.readAsText(file, 'UTF-8'); 41 | 42 | reader.onload = loadHandler; 43 | reader.onerror = (evt) => { 44 | console.error(evt); 45 | const message = this.translationService.instant('file-upload-error', {fileName: (evt.target as any).files[0]}); 46 | this.notificationService.addError(message); 47 | }; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /client/src/app/ui/min-validator.directive.spec.ts: -------------------------------------------------------------------------------- 1 | import { MinValidatorDirective } from './min-validator.directive'; 2 | import { FormControl } from '@angular/forms'; 3 | 4 | describe(MinValidatorDirective.name, () => { 5 | it('should create an instance', () => { 6 | const directive = new MinValidatorDirective(); 7 | expect(directive).toBeTruthy(); 8 | }); 9 | 10 | it('should work on valid values', () => { 11 | const directive = new MinValidatorDirective(); 12 | directive.appMinValidator = 100; 13 | 14 | expect(directive.validate({value: 100} as FormControl)).toEqual(null); 15 | expect(directive.validate({value: 101} as FormControl)).toEqual(null); 16 | expect(directive.validate({value: '100'} as FormControl)).toEqual(null); 17 | expect(directive.validate({value: ' 100 '} as FormControl)).toEqual(null); 18 | }); 19 | 20 | it('should work on invalid values', () => { 21 | const directive = new MinValidatorDirective(); 22 | directive.appMinValidator = 100; 23 | 24 | expect(directive.validate({value: 0} as FormControl)).toEqual({appMinValidator: true}); 25 | expect(directive.validate({value: 1} as FormControl)).toEqual({appMinValidator: true}); 26 | expect(directive.validate({value: 99} as FormControl)).toEqual({appMinValidator: true}); 27 | expect(directive.validate({value: -1} as FormControl)).toEqual({appMinValidator: true}); 28 | expect(directive.validate({value: ''} as FormControl)).toEqual({appMinValidator: true}); 29 | expect(directive.validate({value: null} as FormControl)).toEqual({appMinValidator: true}); // works 30 | expect(directive.validate({value: undefined} as FormControl)).toEqual({appMinValidator: true}); // works 31 | expect(directive.validate({value: '1d'} as FormControl)).toEqual({appMinValidator: true}); // works 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /doc/architecture/server.puml: -------------------------------------------------------------------------------- 1 | @startuml 2 | 3 | package main <> { 4 | class main.main 5 | } 6 | 7 | package util <> { 8 | class util.util 9 | } 10 | 11 | package config <> { 12 | class config.config 13 | } 14 | 15 | package websocket <> { 16 | class websocket.websocket 17 | } 18 | 19 | ' Alignment: 20 | main -[hidden]down- util 21 | main -[hidden]right- websocket 22 | util -[hidden]right- config 23 | 24 | package api <> { 25 | class api.api { 26 | } 27 | class api.api_vX { 28 | } 29 | 30 | api.api --> api.api_vX 31 | 32 | api.api_vX --> project.service 33 | api.api_vX --> task.service 34 | api.api_vX --> export.service 35 | } 36 | 37 | package auth <> { 38 | class auth.auth { 39 | } 40 | 41 | class auth.token { 42 | } 43 | 44 | auth.auth --> auth.token 45 | } 46 | 47 | package project <> { 48 | class project.service { 49 | } 50 | 51 | class project.store { 52 | } 53 | 54 | project.service --> store 55 | } 56 | 57 | package task <> { 58 | class task.service { 59 | } 60 | 61 | class task.store { 62 | } 63 | 64 | task.service --> store 65 | } 66 | 67 | package export <> { 68 | class export.service{ 69 | } 70 | } 71 | 72 | package permission <> { 73 | class permission.store{ 74 | } 75 | } 76 | 77 | 'main.main --> api.api : Init() 78 | 'main.main --> auth.auth : Init() 79 | 'main.main --> project : Init() 80 | 'main.main --> task : Init() 81 | 'main.main --> permission : Init() 82 | 83 | api.api --> auth.auth 84 | api.api_vX --> auth.auth 85 | 86 | task.service --> permission.store 87 | 88 | project.service --> task.service 89 | project.service --> permission.store 90 | project.store --> task.service 91 | 92 | export.service --> project.service 93 | 94 | @enduml -------------------------------------------------------------------------------- /server/project/entity.go: -------------------------------------------------------------------------------- 1 | package project 2 | 3 | import ( 4 | "stm/comment" 5 | "stm/task" 6 | "time" 7 | ) 8 | 9 | type JosmDataSource string 10 | 11 | const ( 12 | OSM JosmDataSource = "OSM" 13 | Overpass JosmDataSource = "OVERPASS" 14 | ) 15 | 16 | type Project struct { 17 | Id string `json:"id"` // The ID of the project. 18 | Name string `json:"name"` // The name of the project. Will not be NULL or empty. 19 | Tasks []*task.Task `json:"tasks"` // List of tasks of the project. Will not be NULL or empty. 20 | // TODO Use "Ids" as suffix? 21 | Users []string `json:"users"` // Array of user-IDs (=members of this project). Will not be NULL or empty. 22 | // TODO Use "Id" as suffix? 23 | Owner string `json:"owner"` // User-ID of the owner/creator of this project. Will not be NULL or empty. 24 | Description string `json:"description"` // Some description, can be empty. Will not be NULL but might be empty. 25 | NeedsAssignment bool `json:"needsAssignment"` // When "true", the tasks of this project need to have an assigned user. 26 | TotalProcessPoints int `json:"totalProcessPoints"` // Sum of all maximum process points of all tasks. 27 | DoneProcessPoints int `json:"doneProcessPoints"` // Sum of all process points that have been set. It applies "0 <= doneProcessPoints <= totalProcessPoints". 28 | CreationDate *time.Time `json:"creationDate"` // UTC Date in RFC 3339 format, can be NIL because of old data in the database. Example: "2006-01-02 15:04:05.999999999 -0700 MST" 29 | Comments []comment.Comment `json:"comments"` // The comment on the project. 30 | JosmDataSource JosmDataSource `json:"josmDataSource"` // The source JOSM should load the data from when opening a task in JOSM. 31 | } 32 | -------------------------------------------------------------------------------- /README.ja.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # SimpleTaskManager 4 | 5 | これは、例えば[OpenStreetMap(OSM)](https://openstreetmap.org)のためのシンプルな構造のタスクマネージャです。 6 | 最新バージョンは [stm.hauke-stieler.de](https://stm.hauke-stieler.de)で確認してください。 7 | 8 | このプロジェクトは、あらゆる種類の地理関連のもの(例えば、OSMでのマッピング、古い建物の写真など)のためのシンプルで汎用的なタスキングマネージャを作成することを目的としています。 9 | 10 | タスキングマネージャは、複数の人が同じ地域で互いに干渉することなく作業できるようにするアプリケーションです。 11 | 通常、そのような領域は正方形に分割され、一度に1つのマッパーだけが、その範囲を作業します。 12 | 13 | 14 | 15 | # どのように動作しますか? 16 | 17 | ユーザは、地図上の大きな領域(例:市区)で構成される**プロジェクト**を作成できます。 18 | この領域は、いわゆる**タスク**より小さな領域(たとえば、1x1kmの大きな正方形)に分割されています。 19 | ファイル(例えばGeoJSONファイル)から**ジオメトリ**をインポートして、タスクを作成することもできます。 20 | 21 | 一度に1人のユーザーがそのようなタスクの作業ができるようになりました。このユーザーは、**進捗ポイント**を設定することで、タスクの**進捗**を更新できます。 22 | 領域が完全にマッピングされると、ユーザは進捗ポイントを最大値(100%)に設定して終了し、次のタスクを開始することができます。 23 | 24 | プロジェクトの所有者は、他のユーザをプロジェクトに招待することもできます。 25 | あるユーザーがタスクで作業している場合、他のユーザーはそのタスクのプロセスを更新できません。 26 | 27 | # さらに別のタスクマネージャですか? 28 | *(つまり、HOT Tasking Managerの何が問題ですか?)* 29 | 30 | 個人的には、HOT Tasking Managerがそれほど好きではない理由がいくつかあります(直感的なUIでない、iD統合、一定時間後にタスクから自動的に割り当てられないなど) 31 | 32 | 別の方法としては、例えばMapCraft・タスキング・マネージャがあります。これは非常に古く、すでにリポジトリをコンパイルできない状況です。 33 | したがって、MapCraftを設定することはできません(依存関係が壊れている古いPHPコードに何時間も費やしたくない場合)。 34 | 35 | つまり、基本的にはこれは別のタスクマネージャですが、既存のもののクローンではありません。 36 | 37 | # ドキュメント 38 | 39 | ドキュメントは、[docフォルダ](doc)(deployment,api,security,architecture,operationなど)と、別の[client](client)および[server](server)フォルダ(主にセットアップと開発情報)にあります。 40 | 41 | 現在、エンドユーザーマニュアルやチュートリアルなどはありません。 42 | 43 | # 貢献 44 | 45 | 現在、実際のガイドラインはありません。**issue**または**プルリクエスト**を自由に作成してください。 46 | 47 | **コーディングを開始しますか?**開始する方法については、[doc/development/README.md](doc/development/README.md)をご覧ください。 -------------------------------------------------------------------------------- /client/src/app/auth/logged-in.interceptor.spec.ts: -------------------------------------------------------------------------------- 1 | import { LoggedInInterceptor } from './logged-in.interceptor'; 2 | import { AuthService } from './auth.service'; 3 | import { NotificationService } from '../common/services/notification.service'; 4 | import { HttpRequest } from '@angular/common/http'; 5 | import { environment } from '../../environments/environment'; 6 | import { of } from 'rxjs'; 7 | import { TranslateService } from '@ngx-translate/core'; 8 | 9 | describe(LoggedInInterceptor.name, () => { 10 | let interceptor: LoggedInInterceptor; 11 | let authService: AuthService; 12 | let notificationService: NotificationService; 13 | let translationService: TranslateService; 14 | 15 | beforeEach(() => { 16 | authService = {} as AuthService; 17 | notificationService = {} as NotificationService; 18 | translationService = {} as TranslateService; 19 | 20 | interceptor = new LoggedInInterceptor(authService, notificationService, translationService); 21 | }); 22 | 23 | it('should call next handler on unauthenticated URLs', () => { 24 | const nextHandler = { 25 | handle: jest.fn() 26 | }; 27 | 28 | const request = {url: 'https://foo.com/bar'} as HttpRequest; 29 | 30 | interceptor.intercept(request, nextHandler); 31 | 32 | expect(nextHandler.handle).toHaveBeenCalledWith(request); 33 | }); 34 | 35 | it('should add authentication token to request', () => { 36 | const authToken = 'FOO_BAR'; 37 | localStorage.setItem('auth_token', authToken); 38 | 39 | const nextHandler = { 40 | handle: jest.fn().mockReturnValue(of()) 41 | }; 42 | 43 | const request = {url: environment.base_url + '/foo/bar'} as HttpRequest; 44 | request.clone = jest.fn().mockReturnValue(request); 45 | 46 | interceptor.intercept(request, nextHandler); 47 | 48 | expect(request.clone).toHaveBeenCalled(); 49 | expect(nextHandler.handle).toHaveBeenCalled(); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /client/src/app/common/services/loading.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { LoadingService } from './loading.service'; 2 | import { ReplaySubject } from 'rxjs'; 3 | import { NavigationCancel, NavigationEnd, NavigationError, NavigationStart, Router, RouterEvent } from '@angular/router'; 4 | 5 | describe(LoadingService.name, () => { 6 | const routerEventSubject = new ReplaySubject(); 7 | 8 | let service: LoadingService; 9 | let router: Router; 10 | 11 | beforeEach(() => { 12 | router = { 13 | events: routerEventSubject.asObservable() 14 | } as Router; 15 | 16 | service = new LoadingService(router); 17 | }); 18 | 19 | it('should be created', () => { 20 | expect(service).toBeTruthy(); 21 | }); 22 | 23 | it('should set loading state correctly', () => { 24 | service.start(); 25 | expect(service.isLoading()).toEqual(true); 26 | 27 | service.end(); 28 | expect(service.isLoading()).toEqual(false); 29 | }); 30 | 31 | it('should start loading on routing start event', () => { 32 | routerEventSubject.next(new NavigationStart(1, '/foo/bar')); 33 | expect(service.isLoading()).toEqual(true); 34 | }); 35 | 36 | describe('navigation end events', () => { 37 | beforeEach(() => { 38 | service.start(); 39 | }); 40 | 41 | it('should end loading on navigation error', () => { 42 | routerEventSubject.next(new NavigationError(1, '/foo/bar', 'some error')); 43 | expect(service.isLoading()).toEqual(false); 44 | }); 45 | 46 | it('should end loading on navigation cancellation', () => { 47 | routerEventSubject.next(new NavigationCancel(1, '/foo/bar', 'some error')); 48 | expect(service.isLoading()).toEqual(false); 49 | }); 50 | 51 | it('should end loading on navigation end', () => { 52 | routerEventSubject.next(new NavigationEnd(1, '/foo/bar', '/some/other/url')); 53 | expect(service.isLoading()).toEqual(false); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /server/database/scripts/005_migrate-to-geo-json.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | OUTPUT_FILE=".tmp.migrate-task-geometries.sql" 4 | 5 | # The current stored coordinate list comes in between these strings and BOOM we have some GeoJSON to store :) 6 | # This GeoJSON format is exactly the same as the OpenLayers class "format.GeoJson" output for a feature. I actually 7 | # copied these strings from that class output. 8 | GEOJSON_HEAD="{\"type\":\"Feature\",\"geometry\":{\"type\":\"Polygon\",\"coordinates\":[" 9 | GEOJSON_FOOT="]},\"properties\":null}" 10 | 11 | RAW_DATA=$(psql -h $STM_DB_HOST -U $STM_DB_USERNAME -t -A -c "SELECT id,geometry FROM tasks;" $STM_DB_DATABASE) 12 | 13 | echo "BEGIN TRANSACTION;" > $OUTPUT_FILE 14 | 15 | IFS=$'\n' 16 | for ROW in $RAW_DATA 17 | do 18 | IFS='|' read -ra ROW_ARRAY <<< "$ROW" 19 | IFS=$'\n' 20 | 21 | TASK_ID=${ROW_ARRAY[0]} 22 | ORIGINAL_GEOMETRY=${ROW_ARRAY[1]} 23 | 24 | echo "====== TASK $TASK_ID ======" 25 | echo "Task ID : $TASK_ID" 26 | echo "Geometry : $ORIGINAL_GEOMETRY" 27 | echo 28 | 29 | echo "" >> $OUTPUT_FILE 30 | echo "-- Task: $TASK_ID" >> $OUTPUT_FILE 31 | 32 | GEOJSON_GEOMETRY="$GEOJSON_HEAD$ORIGINAL_GEOMETRY$GEOJSON_FOOT" 33 | echo "$GEOJSON_GEOMETRY" 34 | 35 | echo "UPDATE tasks SET geometry='$GEOJSON_GEOMETRY' WHERE id='$TASK_ID';" >> $OUTPUT_FILE 36 | done 37 | 38 | # 39 | # Set version 40 | # 41 | echo "INSERT INTO db_versions VALUES('005');" >> $OUTPUT_FILE 42 | 43 | # 44 | # Generate the SQL script 45 | # 46 | 47 | echo "END TRANSACTION;" >> $OUTPUT_FILE 48 | 49 | echo "Execute SQL..." 50 | 51 | psql -q -v ON_ERROR_STOP=1 -h $STM_DB_HOST -U $STM_DB_USERNAME -f $OUTPUT_FILE $STM_DB_DATABASE 52 | OK=$? 53 | if [ $OK -ne 0 ] 54 | then 55 | echo 56 | echo "Migration FAILED!" 57 | echo 58 | echo "Exit code: $OK" 59 | echo "See the error log and the '$OUTPUT_FILE' for details." 60 | exit 1 61 | fi 62 | 63 | echo "Migration DONE" 64 | echo "Remove '$OUTPUT_FILE'" 65 | rm $OUTPUT_FILE -------------------------------------------------------------------------------- /client/src/app/auth/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router'; 3 | import { AuthService } from './auth.service'; 4 | import { CurrentUserService } from '../user/current-user.service'; 5 | 6 | @Injectable() 7 | export class AuthGuard implements CanActivate { 8 | constructor( 9 | private router: Router, 10 | private authService: AuthService, 11 | private currentUserService: CurrentUserService 12 | ) { 13 | } 14 | 15 | canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean { 16 | // if already logged in, then get the user name and store it locally in the user service 17 | if (this.authService.isAuthenticated()) { 18 | // already logged in 19 | try { 20 | this.authService.setUserNameFromToken(); 21 | } catch { 22 | this.currentUserService.resetUser(); 23 | localStorage.removeItem('auth_token'); 24 | } 25 | } else { 26 | // not logged in 27 | this.currentUserService.resetUser(); 28 | localStorage.removeItem('auth_token'); 29 | } 30 | 31 | // The login component has the route '/' and therefore the path is '' 32 | const requestLoginComponent = route.routeConfig?.path === ''; 33 | 34 | if (requestLoginComponent && this.authService.isAuthenticated()) { 35 | // Token exists and login component should be loaded -> redirect to dashboard 36 | this.router.navigateByUrl('/dashboard'); 37 | return false; 38 | } else if (!requestLoginComponent && !this.authService.isAuthenticated()) { 39 | // No token -> not logged in -> redirect to login page 40 | this.router.navigateByUrl('/'); 41 | return false; 42 | } 43 | 44 | // We have a token and want to load a normal component -> ok 45 | // We don't have a token but want to load the login page -> ok 46 | return true; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /client/src/app/auth/logged-in.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; 3 | import { Observable } from 'rxjs'; 4 | import { catchError } from 'rxjs/operators'; 5 | import { AuthService } from './auth.service'; 6 | import { NotificationService } from '../common/services/notification.service'; 7 | import { environment } from '../../environments/environment'; 8 | import { TranslateService } from '@ngx-translate/core'; 9 | 10 | @Injectable() 11 | export class LoggedInInterceptor implements HttpInterceptor { 12 | private readonly unauthenticatedEndpoints = [ 13 | environment.url_config 14 | ]; 15 | 16 | constructor( 17 | private authService: AuthService, 18 | private notificationService: NotificationService, 19 | private translationService: TranslateService) { 20 | } 21 | 22 | intercept(request: HttpRequest, next: HttpHandler): Observable> { 23 | // Do not intercept unauthenticated URLs, for example: JOSM-Remote-Control or OSM-API 24 | const url = request.url; 25 | if (!url.startsWith(environment.base_url) || this.unauthenticatedEndpoints.some(endpoint => url.startsWith(endpoint))) { 26 | return next.handle(request); 27 | } 28 | 29 | const token = localStorage.getItem('auth_token'); 30 | request = request.clone({ 31 | setHeaders: { 32 | Authorization: token 33 | } 34 | } as unknown as HttpRequest); 35 | 36 | return next.handle(request) 37 | .pipe(catchError((e: HttpErrorResponse) => { 38 | console.error(e); 39 | if (e.status === 401) { 40 | console.error('Trigger logout: ' + e.message); 41 | this.notificationService.addWarning(this.translationService.instant('login-failed')); 42 | this.authService.logout(); 43 | } 44 | throw e; 45 | })); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /client/src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | import { LoginComponent } from './auth/login/login.component'; 4 | import { AuthGuard } from './auth/auth.guard'; 5 | import { OauthLandingComponent } from './auth/oauth-landing/oauth-landing.component'; 6 | import { DashboardComponent } from './dashboard/dashboard.component'; 7 | import { ProjectComponent } from './project/project/project.component'; 8 | import { ProjectCreationComponent } from './project-creation/project-creation/project-creation.component'; 9 | import { AllProjectsResolver } from './project/all-projects.resolver'; 10 | import { projectResolver } from './project/project.resolver'; 11 | import { SelectedLanguageGuard } from './common/selected-language.guard'; 12 | import { ConfigResolver } from './config/config.resolver'; 13 | 14 | const routes: Routes = [ 15 | { 16 | path: '', 17 | component: LoginComponent, 18 | canActivate: [AuthGuard, SelectedLanguageGuard], 19 | resolve: {config: ConfigResolver} 20 | }, 21 | { 22 | path: 'dashboard', 23 | component: DashboardComponent, 24 | canActivate: [AuthGuard, SelectedLanguageGuard], 25 | resolve: { 26 | projects: AllProjectsResolver, 27 | config: ConfigResolver 28 | } 29 | }, 30 | { 31 | path: 'project/:id', 32 | component: ProjectComponent, 33 | canActivate: [AuthGuard, SelectedLanguageGuard], 34 | resolve: { 35 | project: projectResolver, 36 | config: ConfigResolver 37 | } 38 | }, 39 | { 40 | path: 'new-project', 41 | component: ProjectCreationComponent, 42 | canActivate: [AuthGuard, SelectedLanguageGuard], 43 | resolve: {config: ConfigResolver} 44 | }, 45 | {path: 'oauth-landing', component: OauthLandingComponent}, 46 | {path: '**', redirectTo: ''}, 47 | ]; 48 | 49 | @NgModule({ 50 | imports: [RouterModule.forRoot(routes, {})], 51 | exports: [RouterModule] 52 | }) 53 | export class AppRoutingModule { 54 | } 55 | -------------------------------------------------------------------------------- /client/src/app/project-creation/shape-remote/shape-remote.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { NotificationService } from '../../common/services/notification.service'; 4 | import { GeometryService } from '../../common/services/geometry.service'; 5 | import { LoadingService } from '../../common/services/loading.service'; 6 | import { TaskDraftService } from '../task-draft.service'; 7 | import { TranslateService } from '@ngx-translate/core'; 8 | 9 | @Component({ 10 | selector: 'app-shape-remote', 11 | templateUrl: './shape-remote.component.html', 12 | styleUrls: ['./shape-remote.component.scss'], 13 | standalone: false 14 | }) 15 | export class ShapeRemoteComponent { 16 | public queryUrl: string; 17 | 18 | constructor( 19 | private http: HttpClient, 20 | private notificationService: NotificationService, 21 | private geometryService: GeometryService, 22 | private loadingService: LoadingService, 23 | private taskDraftService: TaskDraftService, 24 | private translationService: TranslateService 25 | ) { 26 | } 27 | 28 | onLoadButtonClicked(): void { 29 | this.loadingService.start(); 30 | 31 | this.http.get(this.queryUrl, {responseType: 'text'}).subscribe( 32 | data => { 33 | this.loadingService.end(); 34 | 35 | const features = this.geometryService.parseData(data); 36 | 37 | if (!!features && features.length !== 0) { 38 | this.taskDraftService.addTasks(features.map(f => this.taskDraftService.toTaskDraft(f))); 39 | } else { 40 | this.notificationService.addError(this.translationService.instant('feature-upload-error')); 41 | } 42 | }, e => { 43 | this.loadingService.end(); 44 | console.error('Error loading data from remote URL'); 45 | console.error(e); 46 | this.notificationService.addError(this.translationService.instant('project-creation.remote-url-error')); 47 | }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /client/src/app/project-creation/copy-project/copy-project.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { Project } from '../../project/project.material'; 3 | import { ProjectService } from '../../project/project.service'; 4 | import { NotificationService } from '../../common/services/notification.service'; 5 | import { ProjectImportService } from '../project-import.service'; 6 | import { TranslateService } from '@ngx-translate/core'; 7 | 8 | @Component({ 9 | selector: 'app-copy-project', 10 | templateUrl: './copy-project.component.html', 11 | styleUrls: ['./copy-project.component.scss'], 12 | standalone: false 13 | }) 14 | export class CopyProjectComponent { 15 | @Input() projects: Project[]; 16 | @Input() loading: boolean; 17 | 18 | public selectedProject: Project | undefined; 19 | 20 | constructor( 21 | private projectService: ProjectService, 22 | private notificationService: NotificationService, 23 | private projectImportService: ProjectImportService, 24 | private translateService: TranslateService 25 | ) { 26 | } 27 | 28 | onProjectClicked(project: Project): void { 29 | this.selectedProject = project !== this.selectedProject ? project : undefined; 30 | } 31 | 32 | onImportClicked(): void { 33 | if (!this.selectedProject) { 34 | return; 35 | } 36 | 37 | this.projectService 38 | .getProjectExport(this.selectedProject.id) 39 | .subscribe({ 40 | next: projectExport => { 41 | this.projectImportService.importProjectAsNewProject(projectExport); 42 | this.selectedProject = undefined; 43 | }, 44 | error: e => { 45 | console.error(e); 46 | const translationParams = {projectName: this.selectedProject?.name}; 47 | const message = this.translateService.instant('project-creation.could-not-import-project', translationParams); 48 | this.notificationService.addError(message); 49 | this.selectedProject = undefined; 50 | } 51 | }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /client/src/app/dashboard/dashboard.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { DashboardComponent } from './dashboard.component'; 2 | import { CurrentUserService } from '../user/current-user.service'; 3 | import { AuthService } from '../auth/auth.service'; 4 | import { MockBuilder, MockedComponentFixture, MockRender } from 'ng-mocks'; 5 | import { AppModule } from '../app.module'; 6 | import { TranslateService } from '@ngx-translate/core'; 7 | 8 | describe(DashboardComponent.name, () => { 9 | let component: DashboardComponent; 10 | let fixture: MockedComponentFixture; 11 | let currentUserService: CurrentUserService; 12 | let authService: AuthService; 13 | let translationService: TranslateService; 14 | 15 | beforeEach(() => { 16 | currentUserService = {} as CurrentUserService; 17 | currentUserService.getUserName = jest.fn(); 18 | authService = {} as AuthService; 19 | translationService = {} as TranslateService; 20 | 21 | return MockBuilder(DashboardComponent, AppModule) 22 | .provide({provide: CurrentUserService, useFactory: () => currentUserService}) 23 | .provide({provide: TranslateService, useFactory: () => translationService}) 24 | .provide({provide: AuthService, useFactory: () => authService}); 25 | }); 26 | 27 | beforeEach(() => { 28 | fixture = MockRender(DashboardComponent); 29 | component = fixture.point.componentInstance; 30 | fixture.detectChanges(); 31 | }); 32 | 33 | it('should create', () => { 34 | expect(component).toBeTruthy(); 35 | }); 36 | 37 | it('should get user name correctly', () => { 38 | localStorage.removeItem('auth_token'); 39 | expect(component.userName).toBeFalsy(); 40 | 41 | currentUserService.getUserName = jest.fn().mockReturnValue('test-user'); 42 | 43 | expect(component.userName).toEqual('test-user'); 44 | }); 45 | 46 | it('should logout correctly', () => { 47 | authService.logout = jest.fn(); 48 | 49 | component.onLogoutClicked(); 50 | 51 | expect(authService.logout).toHaveBeenCalled(); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /client/src/app/task/task.material.ts: -------------------------------------------------------------------------------- 1 | import { Feature } from 'ol'; 2 | import { Geometry, Polygon } from 'ol/geom'; 3 | import { User } from '../user/user.material'; 4 | import { Comment, CommentDto } from '../comments/comment.material'; 5 | 6 | export class TaskDraftDto { 7 | /** 8 | * @param maxProcessPoints Amount of process points to complete this task. 9 | * @param geometry Polygon feature encoded as GeoJSON. 10 | */ 11 | constructor( 12 | public maxProcessPoints: number, 13 | public processPoints: number, 14 | public geometry: string 15 | ) { 16 | } 17 | } 18 | 19 | export class TaskDraft { 20 | constructor( 21 | public id: string, 22 | public name: string, 23 | public geometry: Geometry, 24 | public processPoints: number 25 | ) { 26 | } 27 | } 28 | 29 | export class TaskDto { 30 | constructor( 31 | public id: string, 32 | public processPoints: number, 33 | public maxProcessPoints: number, 34 | public geometry: string, 35 | public comments: CommentDto[], 36 | public assignedUser?: string, 37 | public assignedUserName?: string 38 | ) { 39 | } 40 | } 41 | 42 | export class Task { 43 | constructor( 44 | public id: string, 45 | public name: string, 46 | public processPoints: number, 47 | public maxProcessPoints: number, 48 | public geometry: Feature, 49 | public comments: Comment[], 50 | public assignedUser?: User 51 | ) { 52 | } 53 | 54 | public get isDone(): boolean { 55 | return this.processPoints === this.maxProcessPoints; 56 | } 57 | } 58 | 59 | export class TaskExport { 60 | constructor( 61 | public name: string, 62 | public processPoints: number, 63 | public maxProcessPoints: number, 64 | public geometry: string, 65 | public assignedUser?: string) { 66 | } 67 | } 68 | 69 | export const TestTaskFeature = new Feature(new Polygon([[[0, 0], [1, 1], [1, 2]]])); 70 | export const TestTaskGeometry = '{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[0, 0], [1, 1], [1, 2]]]},"properties":null}'; 71 | -------------------------------------------------------------------------------- /client/src/app/ui/notification/notification.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{'notification.loading' | translate}} 4 |
5 |
6 | 7 | 8 |
9 |
10 |
11 |
{{'notification.error' | translate}}
  12 | {{currentErrorText}} 13 | {{ remainingErrors }} {{'notification.errors-left' | translate}} 15 |
16 | 17 |
18 |
19 | 20 | 21 |
22 |
23 |
24 |
{{'notification.warning' | translate}}
  25 | {{currentWarningText}} 26 | {{ remainingWarning }} {{'notification.warnings-left' | translate}} 28 |
29 | 30 |
31 |
32 | 33 | 34 |
35 |
36 |
37 | {{currentInfoText}} 38 | {{ remainingInfo }} {{'notification.notifications-left' | translate}} 40 |
41 | 42 |
43 |
44 |
45 | -------------------------------------------------------------------------------- /server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/alecthomas/kong" 6 | _ "github.com/lib/pq" // Make driver "postgres" usable 7 | "os" 8 | "stm/oauth2" 9 | 10 | "github.com/hauke96/sigolo" 11 | "stm/api" 12 | "stm/config" 13 | _ "stm/docs" 14 | "stm/util" 15 | ) 16 | 17 | var cli struct { 18 | Config string `help:"The config file. CLI argument override the settings from that file." short:"c" default:"./config/default.json"` 19 | Version bool `help:"Print the version of STM" short:"v"` 20 | Debug bool `help:"Use debug logging" short:"d"` 21 | } 22 | 23 | func configureCliArgs() { 24 | kong.Name("SimpleTaskManager") 25 | kong.Description("A tool dividing an area of the map into smaller tasks.") 26 | } 27 | 28 | func configureLogging() { 29 | if config.Conf.DebugLogging { 30 | sigolo.LogLevel = sigolo.LOG_DEBUG 31 | } else { 32 | sigolo.LogLevel = sigolo.LOG_INFO 33 | } 34 | } 35 | 36 | // @title SimpleTaskManager Server 37 | // @version 1.7.0 38 | // @description This is the SimpleTaskManager (STM) Server. See the GitHub repo '/doc/api/' for further details on authentication, websockets and changelogs. 39 | 40 | // @contact.name STM issue tracker 41 | // @contact.url https://github.com/hauke96/simple-task-manager/issues 42 | 43 | // @license.name GNU General Public License 3.0 44 | // @license.url https://github.com/hauke96/simple-task-manager/blob/master/LICENSE 45 | func main() { 46 | configureCliArgs() 47 | kong.Parse(&cli) 48 | 49 | if cli.Version { 50 | fmt.Printf("STM - SimpleTaskManager\nVersion %s\n", util.VERSION) 51 | return 52 | } 53 | 54 | if cli.Debug { 55 | sigolo.LogLevel = sigolo.LOG_DEBUG 56 | } 57 | 58 | // Load config an override with CLI args 59 | sigolo.Info("Init simple-task-manager server v" + util.VERSION) 60 | config.LoadConfig(cli.Config) 61 | config.PrintConfig() 62 | 63 | configureLogging() 64 | 65 | // Init of Config, Services, Storages, etc. 66 | oauth2.Init() 67 | sigolo.Info("Initializes services, storages, etc.") 68 | 69 | err := api.Init() 70 | if err != nil { 71 | sigolo.Stack(err) 72 | os.Exit(1) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-task-manager", 3 | "version": "1.7.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "dev": "ng serve --watch", 7 | "dev-local": "ng serve --watch -c local", 8 | "build": "ng build -c production", 9 | "test": "jest --detectOpenHandles", 10 | "test:coverage": "jest --coverage", 11 | "lint": "ng lint", 12 | "lint:fix": "ng lint --fix", 13 | "update": "npx npm-check-updates -u && npm install" 14 | }, 15 | "private": true, 16 | "dependencies": { 17 | "@angular/animations": "^19.2.1", 18 | "@angular/common": "^19.2.1", 19 | "@angular/compiler": "^19.2.1", 20 | "@angular/core": "^19.2.1", 21 | "@angular/forms": "^19.2.1", 22 | "@angular/localize": "^19.2.1", 23 | "@angular/platform-browser": "^19.2.1", 24 | "@angular/platform-browser-dynamic": "^19.2.1", 25 | "@angular/router": "^19.2.1", 26 | "@ngx-translate/core": "^16.0.4", 27 | "@ngx-translate/http-loader": "^16.0.1", 28 | "@turf/hex-grid": "^7.2.0", 29 | "@turf/square-grid": "^7.2.0", 30 | "@turf/triangle-grid": "^7.2.0", 31 | "ng-mocks": "^14.13.2", 32 | "ol": "^10.4.0", 33 | "rxjs": "^7.8.2", 34 | "tslib": "^2.8.1", 35 | "zone.js": "^0.15.0" 36 | }, 37 | "devDependencies": { 38 | "@angular-eslint/builder": "^19.2.0", 39 | "@angular-eslint/eslint-plugin": "^19.2.0", 40 | "@angular-eslint/eslint-plugin-template": "^19.2.0", 41 | "@angular-eslint/schematics": "^19.2.0", 42 | "@angular-eslint/template-parser": "^19.2.0", 43 | "@angular/build": "^19.2.1", 44 | "@angular/cli": "^19.2.1", 45 | "@angular/compiler-cli": "^19.2.1", 46 | "@types/jest": "^29.5.14", 47 | "@typescript-eslint/eslint-plugin": "^8.26.0", 48 | "@typescript-eslint/parser": "^8.26.0", 49 | "eslint": "^9.21.0", 50 | "eslint-plugin-import": "^2.31.0", 51 | "eslint-plugin-jsdoc": "^50.6.3", 52 | "eslint-plugin-prefer-arrow": "^1.2.3", 53 | "jasmine-core": "^5.6.0", 54 | "jest": "^29.7.0", 55 | "jest-preset-angular": "^14.5.3", 56 | "ng-mocks": "^14.13.2", 57 | "typescript": "^5.8.2" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /client/src/app/project-creation/project-creation/project-creation.component.scss: -------------------------------------------------------------------------------- 1 | @use '../../../styles'; 2 | @use '../../../colors'; 3 | @use '../../../icons'; 4 | 5 | :host { 6 | height: 100%; 7 | display: flex; 8 | flex-direction: column; 9 | } 10 | 11 | .toolbar-buttons-container { 12 | display: flex; 13 | } 14 | 15 | .root-container { 16 | display: flex; 17 | flex-direction: row; 18 | flex-grow: 1; 19 | min-height: 0px; // important for scrolling (for whatever reason) 20 | } 21 | 22 | .root-tabs, 23 | .root-form { 24 | height: 100%; 25 | } 26 | 27 | .tab-content { 28 | padding: styles.$space-base; 29 | } 30 | 31 | .cancel-button-icon { 32 | margin-right: styles.$space-small; 33 | } 34 | 35 | .project-properties-container { 36 | padding-top: styles.$space-base; 37 | min-width: 350px; 38 | max-width: 450px; 39 | width: 50%; 40 | border-right: 1px solid colors.$color-light; 41 | } 42 | 43 | // Also apply style to divs in child components using ::ng-deep 44 | .project-properties-container ::ng-deep .form-entry { 45 | margin-bottom: styles.$space-base 46 | } 47 | 48 | .properties-label { 49 | margin-top: 0px; 50 | } 51 | 52 | .map-container { 53 | width: 100%; 54 | display: flex; 55 | flex-direction: column; 56 | position: relative; 57 | } 58 | 59 | .drawing-toolbar { 60 | position: absolute; 61 | z-index: 5; // Because of OpenLayers claiming some z-indices 62 | left: 0px; 63 | top: 0px; 64 | margin: styles.$space-base; 65 | margin-top: 100px; // TODO move whole button menu into map 66 | } 67 | 68 | .zoom-control { 69 | margin-bottom: styles.$space-base; 70 | } 71 | 72 | .map { 73 | height: 100%; 74 | width: 100%; 75 | } 76 | 77 | .save-button { 78 | margin-left: styles.$space-base; 79 | } 80 | 81 | app-tabs { 82 | flex-grow: 1; 83 | } 84 | 85 | .tab-container { 86 | display: flex; 87 | flex-direction: column; 88 | min-height: 0px; // important for scrolling (for whatever reason, s. above) 89 | height: 100%; // To enable scrolling within the tab containers 90 | } 91 | 92 | .task-head { 93 | display: flex; 94 | flex-direction: row; 95 | justify-content: space-between; 96 | align-items: baseline; 97 | } 98 | -------------------------------------------------------------------------------- /server/run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | SLEEP=3 6 | 7 | # Export variable to be accessible within called scripts 8 | export STM_DB_DATABASE="stm_test" 9 | 10 | function wait() 11 | { 12 | echo "Wait... ($SLEEP s)" 13 | sleep $SLEEP 14 | } 15 | 16 | # If container "stm-db" exists, stop and remove it 17 | if ! docker container list | grep -q "stm-db" 18 | then 19 | cd ../ 20 | echo "Start new 'stm-db' container" 21 | docker-compose up -d --build stm-db 22 | wait 23 | cd server/ 24 | fi 25 | 26 | echo "Remove existing database" 27 | psql -h $STM_DB_HOST -U $STM_DB_USERNAME postgres -tc "DROP DATABASE IF EXISTS $STM_DB_DATABASE;" 28 | 29 | echo "Initialize new database" 30 | cd ./database 31 | ./init-db.sh 32 | 33 | # Switch from "./server/database" into "./server" folder 34 | cd .. 35 | echo "Execute tests" 36 | echo 37 | 38 | # go test github.com/hauke96/simple-task-manager/server/permission -coverprofile=coverage.out -v ./... -args -with-db true | tee test.log 39 | go test -p 1 -coverprofile=coverage.out -v ./... | tee test.log 40 | 41 | # Show failed functions with file and line number. This makes it a bit easier to find them. 42 | echo 43 | if cat test.log | grep -i -q "fail" 44 | then 45 | echo "Failed tests:" 46 | FAILED_FUNCTIONS=$(cat test.log | grep "FAIL:" | grep -o " [a-zA-Z0-9_]* " | sed 's/ //g' | tr '\n' ' ') 47 | for FUNC in $FAILED_FUNCTIONS 48 | do 49 | FUNC_DEF=$(grep --color=never -Hrn "$FUNC" | grep -o --color=never "[a-zA-Z\./_]*\.go:[[:digit:]]*:func $FUNC") 50 | echo " - $FUNC " | tr -d '\n' 51 | echo $FUNC_DEF | grep -o --color=never "[a-zA-Z\./_]*\.go" | tr -d '\n' 52 | echo " : " | tr -d '\n' 53 | echo $FUNC_DEF | grep -o --color=never "[[:digit:]]*" 54 | done 55 | echo 56 | echo "========================================" 57 | echo " FAIL" 58 | echo "========================================" 59 | else 60 | echo "Open coverage with:" 61 | echo " go tool cover -html=../coverage.out" 62 | echo 63 | echo "========================================" 64 | echo " PASSED" 65 | echo "========================================" 66 | fi 67 | 68 | rm -f test.log 69 | -------------------------------------------------------------------------------- /client/src/app/project-creation/shape-upload/shape-upload.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { NotificationService } from '../../common/services/notification.service'; 3 | import { GeometryService } from '../../common/services/geometry.service'; 4 | import { TaskDraftService } from '../task-draft.service'; 5 | import { TranslateService } from '@ngx-translate/core'; 6 | 7 | @Component({ 8 | selector: 'app-shape-upload', 9 | templateUrl: './shape-upload.component.html', 10 | styleUrls: ['./shape-upload.component.scss'], 11 | standalone: false 12 | }) 13 | export class ShapeUploadComponent { 14 | constructor( 15 | private notificationService: NotificationService, 16 | private geometryService: GeometryService, 17 | private taskDraftService: TaskDraftService, 18 | private translationService: TranslateService 19 | ) { 20 | } 21 | 22 | public onFileSelected(event: any): void { 23 | this.uploadFile(event, (e) => this.addTasks(e)); 24 | } 25 | 26 | public addTasks(evt: Event): void { 27 | if (!evt || !evt.target) { 28 | return; 29 | } 30 | 31 | try { 32 | // @ts-ignore 33 | const features = this.geometryService.parseData(evt.target.result); 34 | 35 | if (!!features && features.length !== 0) { 36 | this.taskDraftService.addTasks(features.map(f => this.taskDraftService.toTaskDraft(f))); 37 | } else { 38 | this.notificationService.addError(this.translationService.instant('feature-upload-error')); 39 | } 40 | } catch (e) { 41 | this.notificationService.addError('Error: ' + e); 42 | } 43 | } 44 | 45 | private uploadFile(event: any, loadHandler: (evt: Event) => void): void { 46 | const reader = new FileReader(); 47 | const file = event.target.files[0]; 48 | 49 | reader.readAsText(file, 'UTF-8'); 50 | 51 | reader.onload = loadHandler; 52 | reader.onerror = (evt) => { 53 | console.error(evt); 54 | const message = this.translationService.instant('file-upload-error', {fileName: (evt.target as any).files[0]}); 55 | this.notificationService.addError(message); 56 | }; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /client/src/app/auth/login/login.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { LoginComponent } from './login.component'; 2 | import { AuthService } from '../auth.service'; 3 | import { Router } from '@angular/router'; 4 | import { MockBuilder, MockedComponentFixture, MockRender } from 'ng-mocks'; 5 | import { AppModule } from '../../app.module'; 6 | import { TranslateService } from '@ngx-translate/core'; 7 | import { HttpClient } from '@angular/common/http'; 8 | import { of } from 'rxjs'; 9 | 10 | describe(LoginComponent.name, () => { 11 | let component: LoginComponent; 12 | let fixture: MockedComponentFixture; 13 | let authService: AuthService; 14 | let router: Router; 15 | let httpClient: HttpClient; 16 | let translationService: TranslateService; 17 | 18 | beforeEach(() => { 19 | router = {} as Router; 20 | router.navigate = jest.fn(); 21 | 22 | httpClient = {} as HttpClient; 23 | httpClient.get = jest.fn().mockReturnValue(of()); 24 | 25 | authService = {} as AuthService; 26 | translationService = { 27 | onLangChange: of(), 28 | } as unknown as TranslateService; 29 | 30 | return MockBuilder(LoginComponent, AppModule) 31 | .provide({provide: Router, useFactory: () => router}) 32 | .provide({provide: AuthService, useFactory: () => authService}) 33 | .provide({provide: HttpClient, useFactory: () => httpClient}) 34 | .provide({provide: TranslateService, useFactory: () => translationService}); 35 | }); 36 | 37 | beforeEach(() => { 38 | fixture = MockRender(LoginComponent); 39 | component = fixture.point.componentInstance; 40 | // @ts-ignore 41 | fixture.ngZone.run = (fn) => fn(); 42 | fixture.detectChanges(); 43 | }); 44 | 45 | it('should create', () => { 46 | expect(component).toBeTruthy(); 47 | }); 48 | 49 | it('should redirect user to dashboard after login', () => { 50 | authService.requestLogin = (fn) => fn(); 51 | router.navigate = jest.fn(); 52 | 53 | component.onLoginButtonClick(); 54 | 55 | expect(router.navigate).toHaveBeenCalledWith(['/dashboard']); 56 | }); 57 | 58 | // TODO test for language change to re-load template 59 | }); 60 | -------------------------------------------------------------------------------- /client/src/app/ui/notification/notification.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { LoadingService } from '../../common/services/loading.service'; 3 | import { NotificationService } from '../../common/services/notification.service'; 4 | 5 | @Component({ 6 | selector: 'app-notification', 7 | templateUrl: './notification.component.html', 8 | styleUrls: ['./notification.component.scss'], 9 | standalone: false 10 | }) 11 | export class NotificationComponent { 12 | constructor( 13 | private loadingService: LoadingService, 14 | private notificationService: NotificationService 15 | ) { 16 | } 17 | 18 | public get isLoading(): boolean { 19 | return this.loadingService.isLoading(); 20 | } 21 | 22 | // 23 | // Error 24 | // 25 | 26 | public get hasError(): boolean { 27 | return this.notificationService.hasError(); 28 | } 29 | 30 | public get remainingErrors(): number { 31 | return this.notificationService.remainingErrors(); 32 | } 33 | 34 | public get currentErrorText(): string | undefined { 35 | return this.notificationService.getError(); 36 | } 37 | 38 | public onCloseErrorButtonClicked(): void { 39 | this.notificationService.dropError(); 40 | } 41 | 42 | // 43 | // Warning 44 | // 45 | 46 | public get hasWarning(): boolean { 47 | return this.notificationService.hasWarning(); 48 | } 49 | 50 | public get remainingWarning(): number { 51 | return this.notificationService.remainingWarning(); 52 | } 53 | 54 | public get currentWarningText(): string | undefined { 55 | return this.notificationService.getWarning(); 56 | } 57 | 58 | public onCloseWarningButtonClicked(): void { 59 | this.notificationService.dropWarning(); 60 | } 61 | 62 | // 63 | // Info 64 | // 65 | 66 | public get hasInfo(): boolean { 67 | return this.notificationService.hasInfo(); 68 | } 69 | 70 | public get remainingInfo(): number { 71 | return this.notificationService.remainingInfo(); 72 | } 73 | 74 | public get currentInfoText(): string | undefined { 75 | return this.notificationService.getInfo(); 76 | } 77 | 78 | public onCloseInfoButtonClicked(): void { 79 | this.notificationService.dropInfo(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /client/src/app/auth/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { AuthService } from './auth.service'; 2 | import { CurrentUserService } from '../user/current-user.service'; 3 | import { NotificationService } from '../common/services/notification.service'; 4 | import { Router } from '@angular/router'; 5 | 6 | describe(AuthService.name, () => { 7 | let service: AuthService; 8 | let currentUserService: CurrentUserService; 9 | let router: Router; 10 | let notificationService: NotificationService; 11 | 12 | beforeEach(() => { 13 | currentUserService = {} as CurrentUserService; 14 | router = {} as Router; 15 | notificationService = {} as NotificationService; 16 | 17 | service = new AuthService(currentUserService, router, notificationService); 18 | }); 19 | 20 | it('should be created', () => { 21 | expect(service).toBeTruthy(); 22 | }); 23 | 24 | it('should determine authenticated state correctly', () => { 25 | localStorage.removeItem('auth_token'); 26 | expect(service.isAuthenticated()).toEqual(false); 27 | 28 | localStorage.setItem('auth_token', 'dummy-token'); 29 | expect(service.isAuthenticated()).toEqual(true); 30 | }); 31 | 32 | it('should interpret empty string as not authenticated', () => { 33 | localStorage.setItem('auth_token', ''); 34 | expect(service.isAuthenticated()).toEqual(false); 35 | }); 36 | 37 | it('should interpret null as not authenticated', () => { 38 | // @ts-ignore 39 | localStorage.setItem('auth_token', null); 40 | expect(service.isAuthenticated()).toEqual(false); 41 | }); 42 | 43 | it('should interpret undefined as not authenticated', () => { 44 | // @ts-ignore 45 | localStorage.setItem('auth_token', undefined); 46 | expect(service.isAuthenticated()).toEqual(false); 47 | }); 48 | 49 | it('constructor should set user name correctly', () => { 50 | currentUserService.setUser = jest.fn(); 51 | 52 | localStorage.setItem('auth_token', 'eyJ2YWxpZF91bnRpbCI6MjU4ODM3MTQ0MywidXNlciI6InRlc3QtdXNlciIsInVpZCI6IjEyMzQ1Iiwic2VjcmV0IjoiMHZWdkJZNHNRWDQrTTY5byt4TEhLSm5oYWZIekNkNlFDWWh3L2pzNlR0MD0ifQo='); 53 | 54 | service.setUserNameFromToken(); 55 | 56 | expect(currentUserService.setUser).toHaveBeenCalledWith('test-user', '12345'); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /client/src/app/comments/comment/comment.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { CommentComponent } from './comment.component'; 2 | import { MockBuilder, MockedComponentFixture, MockRender } from 'ng-mocks'; 3 | import { Comment } from '../comment.material'; 4 | import { User } from '../../user/user.material'; 5 | import { TranslateService } from '@ngx-translate/core'; 6 | import { AppModule } from '../../app.module'; 7 | 8 | describe(CommentComponent.name, () => { 9 | let component: CommentComponent; 10 | let fixture: MockedComponentFixture; 11 | let translationService: TranslateService; 12 | 13 | beforeEach(() => { 14 | translationService = {} as TranslateService; 15 | 16 | return MockBuilder(CommentComponent, AppModule) 17 | .provide({provide: TranslateService, useFactory: () => translationService}); 18 | }); 19 | 20 | beforeEach(() => { 21 | fixture = MockRender(CommentComponent, {comments: []}); 22 | component = fixture.point.componentInstance; 23 | fixture.detectChanges(); 24 | }); 25 | 26 | it('should create', () => { 27 | expect(component).toBeTruthy(); 28 | }); 29 | 30 | it('should sort comment correct', () => { 31 | component.comments = [ 32 | new Comment(1, 'first', new User('author', '100'), new Date('2024-03-27 00:10')), 33 | new Comment(3, 'third', new User('author', '100'), new Date('2024-03-27 00:30')), 34 | new Comment(4, 'fourth', new User('author', '100'), new Date('2024-03-27 00:40')), 35 | new Comment(2, 'second', new User('author', '100'), new Date('2024-03-27 00:20')), 36 | ]; 37 | 38 | expect(component.currentComments.length).toEqual(4); 39 | // Order is reverse (youngest first) due to the UI (s. SCSS file) 40 | expect(component.currentComments[0].id).toEqual(4); 41 | expect(component.currentComments[1].id).toEqual(3); 42 | expect(component.currentComments[2].id).toEqual(2); 43 | expect(component.currentComments[3].id).toEqual(1); 44 | }); 45 | 46 | it('should fires event on button click', () => { 47 | const sendButtonSpy = jest.fn(); 48 | component.commentSendClicked.subscribe(sendButtonSpy); 49 | 50 | component.onSendButtonClicked(); 51 | 52 | expect(sendButtonSpy).toHaveBeenCalled(); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /client/src/app/task/task-details/task-details.component.html: -------------------------------------------------------------------------------- 1 |
2 |

{{'task-details.title' | translate}} {{task | taskTitle}}

3 |
4 | 5 | 6 |
7 |
8 | 9 | 10 |
11 |
12 | {{'task-details.points' | translate}} 13 | 20 | / {{task?.maxProcessPoints}} 21 |
22 | 23 | 24 |
25 |
26 | 27 | 31 | 32 |
33 | 34 | 35 |
36 | 37 | 38 |
39 | -------------------------------------------------------------------------------- /client/src/app/dashboard/dashboard.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { CurrentUserService } from '../user/current-user.service'; 3 | import { AuthService } from '../auth/auth.service'; 4 | import { Router } from '@angular/router'; 5 | import { ProjectExport } from '../project/project.material'; 6 | import { ProjectImportService } from '../project-creation/project-import.service'; 7 | import { NotificationService } from '../common/services/notification.service'; 8 | import { TranslateService } from '@ngx-translate/core'; 9 | 10 | @Component({ 11 | selector: 'app-dashboard', 12 | templateUrl: './dashboard.component.html', 13 | styleUrls: ['./dashboard.component.scss'], 14 | standalone: false 15 | }) 16 | export class DashboardComponent { 17 | constructor( 18 | private router: Router, 19 | private authService: AuthService, 20 | private currentUserService: CurrentUserService, 21 | private projectImportService: ProjectImportService, 22 | private notificationService: NotificationService, 23 | private translationService: TranslateService 24 | ) { 25 | } 26 | 27 | public get userName(): string | undefined { 28 | return this.currentUserService.getUserName(); 29 | } 30 | 31 | public onLogoutClicked(): void { 32 | this.authService.logout(); 33 | } 34 | 35 | onImportProjectClicked(event: Event): void { 36 | this.uploadFile(event, (e) => this.addProjectExport(e)); 37 | } 38 | 39 | private addProjectExport(evt: Event): void { 40 | // @ts-ignore 41 | const project = JSON.parse(evt.target?.result) as ProjectExport; 42 | this.projectImportService.importProject(project); 43 | } 44 | 45 | private uploadFile(event: any, loadHandler: (evt: Event) => void): void { 46 | const reader = new FileReader(); 47 | const file = event.target.files[0]; 48 | 49 | reader.readAsText(file, 'UTF-8'); 50 | 51 | reader.onload = loadHandler; 52 | reader.onerror = (evt) => { 53 | console.error(evt); 54 | const message = this.translationService.instant('file-upload-error', {fileName: (evt.target as any).files[0]}); 55 | this.notificationService.addError(message); 56 | }; 57 | } 58 | 59 | public onUploadClicked(): void { 60 | document.getElementById('projectInput')?.click(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # 2 | # ==== THIS FILE IS NOT ACTIVELY USED OR MAINTAINED! ==== 3 | # 4 | # This config is an example when using STM without a reverse proxy. All hosted versions of STM use a reverse proxy, 5 | # which handles path rewrites and SSL certificates. See the hosting documentation for further information on the usage 6 | # of a reverse proxy. 7 | # 8 | 9 | services: 10 | stm-server: 11 | image: simpletaskmanager/stm-server:1.7.0 12 | container_name: stm-server 13 | environment: 14 | - STM_OAUTH2_CLIENT_ID 15 | - STM_OAUTH2_SECRET 16 | - STM_DB_USERNAME 17 | - STM_DB_PASSWORD 18 | - STM_DB_HOST 19 | network_mode: host 20 | restart: unless-stopped 21 | volumes: 22 | - /etc/letsencrypt:/etc/letsencrypt 23 | - $STM_SERVER_CONFIG:/stm-server/config.json 24 | # 2020-12-09 hauke96: See systemd issue below 25 | # depends_on: 26 | # - "stm-db" 27 | logging: 28 | driver: 'journald' 29 | stm-client: 30 | image: simpletaskmanager/stm-client:1.7.0 31 | container_name: stm-client 32 | network_mode: host 33 | restart: unless-stopped 34 | volumes: 35 | - /etc/letsencrypt:/etc/letsencrypt 36 | - $STM_NGINX_CONFIG:/etc/nginx/conf.d/default.conf 37 | # This allows you to show arbitrary notes on the login screen (e.g. to inform user about maintenance): 38 | #- ./notice.de.html:/usr/share/nginx/html/assets/i18n/notice.de.html 39 | logging: 40 | driver: 'journald' 41 | stm-db: 42 | image: postgres:17 43 | container_name: stm-db 44 | restart: unless-stopped 45 | network_mode: host 46 | environment: 47 | - POSTGRES_USER=${STM_DB_USERNAME} 48 | - POSTGRES_PASSWORD=${STM_DB_PASSWORD} 49 | volumes: 50 | - ./postgres-data:/var/lib/postgresql/data 51 | # 2020-12-09 hauke96: Because health checks are creating mount points, newer 52 | # systemd versions are flodding the logs with succeed-messages rendering the 53 | # logs more or less useless, because they are way too large. I disable health 54 | # checks here until there's a solution for that. 55 | # 56 | # healthcheck: 57 | # test: ["CMD-SHELL", "pg_isready -U ${STM_DB_USERNAME}"] 58 | # interval: 1s 59 | # timeout: 2s 60 | # retries: 30 61 | # start_period: 1s 62 | logging: 63 | driver: 'journald' 64 | -------------------------------------------------------------------------------- /client/src/app/ui/language-selection/language-selection.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { LanguageSelectionComponent } from './language-selection.component'; 2 | import { LanguageService } from '../../common/services/language.service'; 3 | import { Language } from '../../common/entities/language'; 4 | import { MockBuilder, MockedComponentFixture, MockRender } from 'ng-mocks'; 5 | import { AppModule } from '../../app.module'; 6 | import { ActivatedRoute } from '@angular/router'; 7 | 8 | describe(LanguageSelectionComponent.name, () => { 9 | let component: LanguageSelectionComponent; 10 | let fixture: MockedComponentFixture; 11 | let languageService: LanguageService; 12 | 13 | beforeEach(async () => { 14 | languageService = {} as LanguageService; 15 | languageService.getKnownLanguages = jest.fn(); 16 | languageService.getSelectedLanguage = jest.fn(); 17 | const activatedRoute = {} as unknown as ActivatedRoute; 18 | 19 | return MockBuilder(LanguageSelectionComponent, AppModule) 20 | .provide({provide: ActivatedRoute, useFactory: () => activatedRoute}) 21 | .provide({provide: LanguageService, useFactory: () => languageService}); 22 | }); 23 | 24 | beforeEach(() => { 25 | fixture = MockRender(LanguageSelectionComponent); 26 | component = fixture.point.componentInstance; 27 | fixture.detectChanges(); 28 | }); 29 | 30 | it('should create', () => { 31 | expect(component).toBeTruthy(); 32 | }); 33 | 34 | it('should call service to get languages', () => { 35 | const languages = [ 36 | new Language('en-US', 'English'), 37 | new Language('de', 'Deutsch'), 38 | new Language('test', 'Testish'), 39 | ]; 40 | component.languages = []; 41 | languageService.getKnownLanguages = jest.fn().mockReturnValue(languages); 42 | 43 | component.ngOnInit(); 44 | 45 | expect(component.languages).toEqual(languages); 46 | expect(component.languages.length).toEqual(languages.length); 47 | }); 48 | 49 | it('should call service to set language', () => { 50 | languageService.selectLanguageByCode = jest.fn(); 51 | // @ts-ignore 52 | languageService.selectedLanguage = new Language('en-US', 'English'); 53 | 54 | component.onLanguageChange({target: {value: 'de'}}); 55 | 56 | expect(languageService.selectLanguageByCode).toHaveBeenCalledWith('de'); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /client/src/app/common/services/notification.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | @Injectable({ 4 | providedIn: 'root' 5 | }) 6 | export class NotificationService { 7 | private errorMessages: Array; 8 | private infoMessages: Array; 9 | private warningMessages: Array; 10 | 11 | constructor() { 12 | this.errorMessages = new Array(); 13 | this.infoMessages = new Array(); 14 | this.warningMessages = new Array(); 15 | } 16 | 17 | // 18 | // Errors 19 | // 20 | 21 | public hasError(): boolean { 22 | return this.errorMessages.length > 0; 23 | } 24 | 25 | public remainingErrors(): number { 26 | return this.errorMessages.length; 27 | } 28 | 29 | // Returns the oldest message 30 | public getError(): string | undefined { 31 | return this.errorMessages[0]; 32 | } 33 | 34 | // Drops/removes the oldest error reported by "getError()" 35 | public dropError(): void { 36 | this.errorMessages.shift(); 37 | } 38 | 39 | public addError(message: any): void { 40 | this.errorMessages.push('' + message); 41 | } 42 | 43 | // 44 | // Info 45 | // 46 | 47 | public hasInfo(): boolean { 48 | return this.infoMessages.length > 0; 49 | } 50 | 51 | public remainingInfo(): number { 52 | return this.infoMessages.length; 53 | } 54 | 55 | // Returns the oldest message 56 | public getInfo(): string | undefined { 57 | return this.infoMessages[0]; 58 | } 59 | 60 | // Drops/removes the oldest error reported by "getInfo()" 61 | public dropInfo(): void { 62 | this.infoMessages.shift(); 63 | } 64 | 65 | public addInfo(message: any): void { 66 | this.infoMessages.push(message); 67 | } 68 | 69 | // 70 | // Warning 71 | // 72 | 73 | public hasWarning(): boolean { 74 | return this.warningMessages.length > 0; 75 | } 76 | 77 | public remainingWarning(): number { 78 | return this.warningMessages.length; 79 | } 80 | 81 | // Returns the oldest message 82 | public getWarning(): string | undefined { 83 | return this.warningMessages[0]; 84 | } 85 | 86 | // Drops/removes the oldest error reported by "getWarning()" 87 | public dropWarning(): void { 88 | this.warningMessages.shift(); 89 | } 90 | 91 | public addWarning(message: any): void { 92 | this.warningMessages.push(message); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /server/database/init-db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Global constants 4 | SCRIPT_PREFIX="./scripts/" 5 | 6 | function create_db() { 7 | echo "Create new database \"$STM_DB_DATABASE\"" 8 | 9 | createdb -h $STM_DB_HOST -U $STM_DB_USERNAME $STM_DB_DATABASE 10 | 11 | if [ $? -ne 0 ] 12 | then 13 | echo 14 | echo "Error during database creation." 15 | echo "Abort." 16 | exit 1 17 | fi 18 | 19 | echo "Ok" 20 | } 21 | 22 | function execute() { 23 | echo "==============================" 24 | echo "Execute file: $1" 25 | 26 | # Check what script-type we have (actually what file extension the script has) and execute the script accordingly 27 | if [[ "$1" == *".sql" ]] 28 | then 29 | psql -q -v ON_ERROR_STOP=1 -h $STM_DB_HOST -U $STM_DB_USERNAME -f $1 $STM_DB_DATABASE 30 | OK=$? 31 | elif [[ "$1" == *".sh" ]] 32 | then 33 | $1 34 | OK=$? 35 | fi 36 | 37 | # Check return value 38 | if [ $OK -ne 0 ] 39 | then 40 | echo "Error during script $1" 41 | echo "Abort." 42 | exit 1 43 | fi 44 | 45 | echo "Ok" 46 | } 47 | 48 | echo "Initialize database $STM_DB_DATABASE" 49 | 50 | # First check if database exists 51 | psql -h $STM_DB_HOST -U $STM_DB_USERNAME -lqt | cut -d \| -f 1 | grep -qw "$STM_DB_DATABASE" 52 | DATABASE_EXISTS=$? 53 | 54 | # Loop over all relevant files 55 | FILES=$(ls $SCRIPT_PREFIX | tr " " "\n" | grep --color=never -P "^[[:digit:]]{3}" | tr "\n" " ") 56 | 57 | for FILE in $FILES 58 | do 59 | VERSION=$(echo $FILE | grep --color=never -Po "^[[:digit:]]{3}") 60 | 61 | if [ $DATABASE_EXISTS -ne 0 ] && [ "$VERSION" == "000" ] 62 | then # Database does not exist and we're looking at the init script => so execute initial script 63 | echo "Database $STM_DB_DATABASE does not exist. I'll create it." 64 | create_db 65 | execute $SCRIPT_PREFIX$FILE 66 | else # Database does exist and we're not looking at the init script => check if this script needs to be executed 67 | VERSION_ALREADY_APPLIED=$(psql -h $STM_DB_HOST -U $STM_DB_USERNAME $STM_DB_DATABASE -tc "SELECT * FROM db_versions WHERE version='$VERSION';" | sed '/^$/d' | wc -l) 68 | if [ $VERSION_ALREADY_APPLIED -eq 0 ] 69 | then 70 | execute $SCRIPT_PREFIX$FILE 71 | else 72 | echo "==============================" 73 | echo "Skip $VERSION: File $FILE already applied" 74 | fi 75 | fi 76 | done 77 | 78 | echo "==============================" 79 | echo 80 | echo "Done." --------------------------------------------------------------------------------