diff --git a/.npmrc b/.npmrc
index b6f27f135954640c8cc5bfd7b8c9922ca6eb2aad..d5831dd5188814e064c8245b2eab9a5635826522 100644
--- a/.npmrc
+++ b/.npmrc
@@ -1 +1,2 @@
 engine-strict=true
+legacy-peer-deps=true
diff --git a/app/controllers/course/courseware.php b/app/controllers/course/courseware.php
index a6fadb54a7af6f34fcee282cfaeb3f00dead2a61..718f84389d6abf395e2e4abfa7af215189656031 100644
--- a/app/controllers/course/courseware.php
+++ b/app/controllers/course/courseware.php
@@ -78,6 +78,10 @@ class Course_CoursewareController extends CoursewareController
             $GLOBALS['user']->id
         );
         switch ($route) {
+            case 'peer-review-processes':
+                Navigation::activateItem('course/courseware/peer-review');
+                PageLayout::setTitle(_('Courseware: Peer-Review-Prozesse'));
+                break;
             default:
                 Navigation::activateItem('course/courseware/tasks');
                 PageLayout::setTitle(_('Courseware: Aufgaben'));
diff --git a/db/migrations/5.5.9_add_peer_review_tables.php b/db/migrations/5.5.9_add_peer_review_tables.php
new file mode 100644
index 0000000000000000000000000000000000000000..61c13a585c0a938081ee2811f7197f20144d4eb0
--- /dev/null
+++ b/db/migrations/5.5.9_add_peer_review_tables.php
@@ -0,0 +1,648 @@
+<?php
+class AddPeerReviewTables extends Migration
+{
+    public function description()
+    {
+        return "TODO";
+    }
+
+    public function up()
+    {
+        $db = \DBManager::get();
+
+        $db->exec(
+            "CREATE TABLE `cw_peer_review_processes`(
+                 `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
+                 `task_group_id` INT(11) NOT NULL,
+                 `owner_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+                 `configuration` MEDIUMTEXT NOT NULL,
+                 `review_start` INT(11) NOT NULL,
+                 `review_end` INT(11) NOT NULL,
+                 `pairingdate` INT(11) NULL,
+                 `mkdate` INT(11) NOT NULL,
+                 `chdate` INT(11) NOT NULL,
+                 PRIMARY KEY(`id`),
+                 INDEX index_task_group_id(`task_group_id`),
+                 INDEX index_owner_id(`owner_id`)
+             )"
+        );
+
+        $db->exec(
+            "CREATE TABLE `cw_peer_reviews`(
+                 `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
+                 `process_id` INT(11) NOT NULL,
+                 `task_id` INT(11) NOT NULL,
+                 `submitter_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+                 `reviewer_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+                 `reviewer_type` ENUM('autor', 'group') COLLATE latin1_bin,
+                 `assessment` TEXT,
+                 `mkdate` INT(11) NOT NULL,
+                 `chdate` INT(11) NOT NULL,
+                 PRIMARY KEY(`id`),
+                 INDEX index_process_id(`process_id`),
+                 INDEX index_task_id(`task_id`),
+                 INDEX index_submitter_id(`submitter_id`),
+                 INDEX index_reviewer_id(`reviewer_id`)
+             )"
+        );
+    }
+
+    public function down()
+    {
+        $db = \DBManager::get();
+        $db->exec('DROP TABLE IF EXISTS `cw_peer_reviews`');
+        $db->exec('DROP TABLE IF EXISTS `cw_peer_review_processes`');
+    }
+
+
+
+
+        /*
+-- phpMyAdmin SQL Dump
+-- version 5.2.0
+-- https://www.phpmyadmin.net/
+--
+-- Host: mysql
+-- Generation Time: Jun 16, 2023 at 08:53 AM
+-- Server version: 5.7.38
+-- PHP Version: 8.0.19
+
+SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
+START TRANSACTION;
+SET time_zone = "+00:00";
+
+--
+-- Database: `studip`
+--
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_answers`
+--
+
+CREATE TABLE `clk_answers` (
+  `answer_id` int(10) UNSIGNED NOT NULL,
+  `exercise_id` int(10) UNSIGNED NOT NULL,
+  `answer_user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `working_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `answer_text` text CHARACTER SET utf8mb4,
+  `chdate_answer` int(10) UNSIGNED DEFAULT NULL,
+  `notes` text CHARACTER SET utf8mb4,
+  `chdate_notes` int(10) UNSIGNED DEFAULT NULL,
+  `notes_user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `feedback` text CHARACTER SET utf8mb4,
+  `feedback_user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `chdate_feedback` int(10) UNSIGNED DEFAULT NULL,
+  `rating` tinyint(4) DEFAULT NULL,
+  `condition_reached` tinyint(4) DEFAULT NULL,
+  `mkdate` int(10) UNSIGNED NOT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_exercises`
+--
+
+CREATE TABLE `clk_exercises` (
+  `exercise_id` int(10) UNSIGNED NOT NULL,
+  `topic_id` int(10) UNSIGNED NOT NULL,
+  `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `exercise_title` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `description` text CHARACTER SET utf8mb4,
+  `mkdate` int(10) UNSIGNED NOT NULL,
+  `chdate` int(10) UNSIGNED DEFAULT NULL,
+  `visible` int(10) UNSIGNED DEFAULT NULL,
+  `deadline` int(10) UNSIGNED DEFAULT NULL,
+  `sort` smallint(6) DEFAULT NULL,
+  `review_end` int(10) UNSIGNED DEFAULT NULL,
+  `peer_review` tinyint(4) UNSIGNED DEFAULT NULL,
+  `dependency` varchar(32) CHARACTER SET utf8mb4 DEFAULT 'none',
+  `sample_solution` text CHARACTER SET utf8mb4,
+  `task` text CHARACTER SET utf8mb4,
+  `evaluation_id` int(10) UNSIGNED DEFAULT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_extensions`
+--
+
+CREATE TABLE `clk_extensions` (
+  `extension_id` int(10) UNSIGNED NOT NULL,
+  `exercise_id` int(10) UNSIGNED NOT NULL,
+  `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `working_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `extension_date` int(10) UNSIGNED DEFAULT NULL,
+  `extension_type` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT 'request',
+  `reason` text CHARACTER SET utf8mb4,
+  `mkdate` int(10) DEFAULT NULL,
+  `chdate` int(10) DEFAULT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_file_object`
+--
+
+CREATE TABLE `clk_file_object` (
+  `file_id` int(10) UNSIGNED NOT NULL,
+  `object_id` int(10) UNSIGNED NOT NULL,
+  `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `working_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `object_type` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `file_title` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `description` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `file_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `file_location` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `mime_type` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `file_size` int(11) DEFAULT NULL,
+  `mkdate` int(10) UNSIGNED DEFAULT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_group_excluded`
+--
+
+CREATE TABLE `clk_group_excluded` (
+  `course_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+  `group_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_options`
+--
+
+CREATE TABLE `clk_options` (
+  `option_id` int(10) UNSIGNED NOT NULL,
+  `course_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `description` text CHARACTER SET utf8mb4,
+  `topic_prefix` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `exercise_prefix` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `display_title` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT 'CloCked',
+  `groups_only` tinyint(4) DEFAULT NULL,
+  `peer_review_enabled` tinyint(4) DEFAULT NULL,
+  `speedgrader_enabled` tinyint(4) DEFAULT NULL,
+  `motivatoring_active` tinyint(4) DEFAULT NULL,
+  `rating_enabled` tinyint(4) DEFAULT '1',
+  `extensions_active` tinyint(4) DEFAULT '1',
+  `extensions_notification` char(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `feedback_salutation` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `feedback_greets` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_peer_options`
+--
+
+CREATE TABLE `clk_peer_options` (
+  `option_id` int(10) UNSIGNED NOT NULL,
+  `relation_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `relation_spec` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `peer_anonym` tinyint(4) DEFAULT '1',
+  `peer_review_type` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `peer_review_period` int(10) UNSIGNED DEFAULT NULL,
+  `peer_review_form` text COLLATE utf8mb4_unicode_ci,
+  `peer_tutorial` tinyint(4) DEFAULT '1'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_peer_pairing`
+--
+
+CREATE TABLE `clk_peer_pairing` (
+  `pairing_id` int(10) UNSIGNED NOT NULL,
+  `relation_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `author_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `reviewer_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `relation_spec` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_peer_review`
+--
+
+CREATE TABLE `clk_peer_review` (
+  `review_id` int(10) UNSIGNED NOT NULL,
+  `answer_id` int(10) UNSIGNED NOT NULL,
+  `working_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `review_user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `mkdate` int(10) UNSIGNED DEFAULT NULL,
+  `chdate` int(10) UNSIGNED DEFAULT NULL,
+  `review_end` int(10) UNSIGNED DEFAULT NULL,
+  `review_start` int(10) UNSIGNED DEFAULT NULL,
+  `peer_review` text CHARACTER SET utf8mb4,
+  `review_feedback` text COLLATE utf8mb4_unicode_ci,
+  `review_form` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `publish` tinyint(4) DEFAULT NULL,
+  `review_published` tinyint(2) DEFAULT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_rating_text`
+--
+
+CREATE TABLE `clk_rating_text` (
+  `rating` int(11) NOT NULL,
+  `rating_text` tinytext COLLATE utf8mb4_unicode_ci,
+  `course_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_sg_criteria`
+--
+
+CREATE TABLE `clk_sg_criteria` (
+  `criterium_id` int(10) UNSIGNED NOT NULL,
+  `evaluation_id` int(10) UNSIGNED NOT NULL,
+  `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `description` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `details` text CHARACTER SET utf8mb4,
+  `mkdate` int(10) UNSIGNED NOT NULL,
+  `chdate` int(10) UNSIGNED DEFAULT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_sg_evaluations`
+--
+
+CREATE TABLE `clk_sg_evaluations` (
+  `evaluation_id` int(10) UNSIGNED NOT NULL,
+  `topic_id` int(10) UNSIGNED NOT NULL,
+  `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `evaluation_title` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `course_id` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `exercise_id` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `visibility` tinyint(3) DEFAULT '0',
+  `mkdate` int(10) UNSIGNED NOT NULL,
+  `chdate` int(10) UNSIGNED DEFAULT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_sg_ratings`
+--
+
+CREATE TABLE `clk_sg_ratings` (
+  `rating_id` int(10) UNSIGNED NOT NULL,
+  `criterium_id` int(10) UNSIGNED NOT NULL,
+  `classification` decimal(9,2) DEFAULT NULL,
+  `description` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `details` text CHARACTER SET utf8mb4,
+  `mkdate` int(10) UNSIGNED NOT NULL,
+  `chdate` int(10) UNSIGNED DEFAULT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_sg_userratings`
+--
+
+CREATE TABLE `clk_sg_userratings` (
+  `userratings_id` int(10) UNSIGNED NOT NULL,
+  `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `evaluation_id` int(10) UNSIGNED DEFAULT NULL,
+  `exercise_id` int(10) UNSIGNED DEFAULT NULL,
+  `answer_id` int(10) UNSIGNED DEFAULT NULL,
+  `criterium_id` int(10) UNSIGNED DEFAULT NULL,
+  `classification` decimal(9,2) DEFAULT NULL,
+  `details` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `mkdate` int(10) UNSIGNED NOT NULL,
+  `chdate` int(10) UNSIGNED DEFAULT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_tan`
+--
+
+CREATE TABLE `clk_tan` (
+  `tan_id` int(10) UNSIGNED NOT NULL,
+  `tan` int(10) UNSIGNED DEFAULT NULL,
+  `relation_id` int(10) UNSIGNED DEFAULT NULL,
+  `relation` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `tan_working_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_topics`
+--
+
+CREATE TABLE `clk_topics` (
+  `topic_id` int(10) UNSIGNED NOT NULL,
+  `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `course_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `topic_title` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
+  `description` text CHARACTER SET utf8mb4,
+  `mkdate` int(10) UNSIGNED NOT NULL,
+  `chdate` int(10) UNSIGNED DEFAULT NULL,
+  `visible` int(10) UNSIGNED DEFAULT NULL,
+  `sort` smallint(6) DEFAULT NULL,
+  `evaluation_id` int(10) UNSIGNED DEFAULT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_topic_excluded`
+--
+
+CREATE TABLE `clk_topic_excluded` (
+  `topic_id` int(10) NOT NULL,
+  `working_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_user_options`
+--
+
+CREATE TABLE `clk_user_options` (
+  `option_id` int(10) UNSIGNED NOT NULL,
+  `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `course_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `show_progress` tinyint(4) DEFAULT NULL,
+  `deadline_notify` tinyint(4) DEFAULT NULL,
+  `show_result_overview` tinyint(4) DEFAULT NULL,
+  `result_list_type` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT 'items'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_visits`
+--
+
+CREATE TABLE `clk_visits` (
+  `object_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+  `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+  `visit_type` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL,
+  `last_visitdate` int(10) UNSIGNED NOT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+--
+-- Indexes for dumped tables
+--
+
+--
+-- Indexes for table `clk_answers`
+--
+ALTER TABLE `clk_answers`
+  ADD PRIMARY KEY (`answer_id`),
+  ADD KEY `ix_chdate_answer` (`chdate_answer`),
+  ADD KEY `ix_exercise_id` (`exercise_id`),
+  ADD KEY `ix_chdate_feedback` (`chdate_feedback`),
+  ADD KEY `ix_working_id` (`working_id`);
+
+--
+-- Indexes for table `clk_exercises`
+--
+ALTER TABLE `clk_exercises`
+  ADD PRIMARY KEY (`exercise_id`),
+  ADD KEY `ix_visible` (`visible`),
+  ADD KEY `ix_last_edit` (`deadline`),
+  ADD KEY `ix_user_id` (`user_id`);
+
+--
+-- Indexes for table `clk_extensions`
+--
+ALTER TABLE `clk_extensions`
+  ADD PRIMARY KEY (`extension_id`),
+  ADD KEY `IX_EXERCISE_ID` (`exercise_id`),
+  ADD KEY `IX_USER_ID` (`user_id`),
+  ADD KEY `IX_WORKING_ID` (`working_id`);
+
+--
+-- Indexes for table `clk_file_object`
+--
+ALTER TABLE `clk_file_object`
+  ADD PRIMARY KEY (`file_id`),
+  ADD KEY `ix_user_id` (`user_id`),
+  ADD KEY `ix_object_Id` (`object_id`),
+  ADD KEY `ix_working_id` (`working_id`);
+
+--
+-- Indexes for table `clk_group_excluded`
+--
+ALTER TABLE `clk_group_excluded`
+  ADD PRIMARY KEY (`course_id`,`group_id`);
+
+--
+-- Indexes for table `clk_options`
+--
+ALTER TABLE `clk_options`
+  ADD PRIMARY KEY (`option_id`),
+  ADD KEY `ix_course_id` (`course_id`);
+
+--
+-- Indexes for table `clk_peer_options`
+--
+ALTER TABLE `clk_peer_options`
+  ADD PRIMARY KEY (`option_id`),
+  ADD KEY `ix_relation_id` (`relation_id`);
+
+--
+-- Indexes for table `clk_peer_pairing`
+--
+ALTER TABLE `clk_peer_pairing`
+  ADD PRIMARY KEY (`pairing_id`),
+  ADD KEY `IX_COURSE_ID` (`relation_id`);
+
+--
+-- Indexes for table `clk_peer_review`
+--
+ALTER TABLE `clk_peer_review`
+  ADD PRIMARY KEY (`review_id`),
+  ADD KEY `IX_ANSWER_ID` (`answer_id`),
+  ADD KEY `IX_WORKING_ID` (`working_id`);
+
+--
+-- Indexes for table `clk_rating_text`
+--
+ALTER TABLE `clk_rating_text`
+  ADD PRIMARY KEY (`rating`,`course_id`);
+
+--
+-- Indexes for table `clk_sg_criteria`
+--
+ALTER TABLE `clk_sg_criteria`
+  ADD PRIMARY KEY (`criterium_id`);
+
+--
+-- Indexes for table `clk_sg_evaluations`
+--
+ALTER TABLE `clk_sg_evaluations`
+  ADD PRIMARY KEY (`evaluation_id`);
+
+--
+-- Indexes for table `clk_sg_ratings`
+--
+ALTER TABLE `clk_sg_ratings`
+  ADD PRIMARY KEY (`rating_id`);
+
+--
+-- Indexes for table `clk_sg_userratings`
+--
+ALTER TABLE `clk_sg_userratings`
+  ADD PRIMARY KEY (`userratings_id`);
+
+--
+-- Indexes for table `clk_tan`
+--
+ALTER TABLE `clk_tan`
+  ADD PRIMARY KEY (`tan_id`),
+  ADD KEY `ix_relation_id` (`relation_id`);
+
+--
+-- Indexes for table `clk_topics`
+--
+ALTER TABLE `clk_topics`
+  ADD PRIMARY KEY (`topic_id`),
+  ADD KEY `ix_user_id` (`user_id`),
+  ADD KEY `ix_course_id` (`course_id`),
+  ADD KEY `ix_visible_at` (`visible`);
+
+--
+-- Indexes for table `clk_topic_excluded`
+--
+ALTER TABLE `clk_topic_excluded`
+  ADD PRIMARY KEY (`topic_id`,`working_id`);
+
+--
+-- Indexes for table `clk_user_options`
+--
+ALTER TABLE `clk_user_options`
+  ADD PRIMARY KEY (`option_id`),
+  ADD KEY `ix_user_id` (`user_id`),
+  ADD KEY `ix_course_id` (`course_id`);
+
+--
+-- Indexes for table `clk_visits`
+--
+ALTER TABLE `clk_visits`
+  ADD PRIMARY KEY (`object_id`,`user_id`,`visit_type`),
+  ADD KEY `ix_last_visit` (`last_visitdate`),
+  ADD KEY `ix_user_id` (`user_id`);
+
+--
+-- AUTO_INCREMENT for dumped tables
+--
+
+--
+-- AUTO_INCREMENT for table `clk_answers`
+--
+ALTER TABLE `clk_answers`
+  MODIFY `answer_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT for table `clk_exercises`
+--
+ALTER TABLE `clk_exercises`
+  MODIFY `exercise_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT for table `clk_extensions`
+--
+ALTER TABLE `clk_extensions`
+  MODIFY `extension_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT for table `clk_file_object`
+--
+ALTER TABLE `clk_file_object`
+  MODIFY `file_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT for table `clk_options`
+--
+ALTER TABLE `clk_options`
+  MODIFY `option_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT for table `clk_peer_options`
+--
+ALTER TABLE `clk_peer_options`
+  MODIFY `option_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT for table `clk_peer_pairing`
+--
+ALTER TABLE `clk_peer_pairing`
+  MODIFY `pairing_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT for table `clk_peer_review`
+--
+ALTER TABLE `clk_peer_review`
+  MODIFY `review_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT for table `clk_sg_criteria`
+--
+ALTER TABLE `clk_sg_criteria`
+  MODIFY `criterium_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT for table `clk_sg_evaluations`
+--
+ALTER TABLE `clk_sg_evaluations`
+  MODIFY `evaluation_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT for table `clk_sg_ratings`
+--
+ALTER TABLE `clk_sg_ratings`
+  MODIFY `rating_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT for table `clk_sg_userratings`
+--
+ALTER TABLE `clk_sg_userratings`
+  MODIFY `userratings_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT for table `clk_tan`
+--
+ALTER TABLE `clk_tan`
+  MODIFY `tan_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT for table `clk_topics`
+--
+ALTER TABLE `clk_topics`
+  MODIFY `topic_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT for table `clk_user_options`
+--
+ALTER TABLE `clk_user_options`
+  MODIFY `option_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+COMMIT;
+
+         */
+}
diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php
index 28e9341e1db78c57b6e40fb01f5544ce87b27c57..c5a3cf981f103d777a3a2910b270269b80ec5f2e 100644
--- a/lib/classes/JsonApi/RouteMap.php
+++ b/lib/classes/JsonApi/RouteMap.php
@@ -527,6 +527,22 @@ class RouteMap
         $group->delete('/courseware-clipboards/{id}', Routes\Courseware\ClipboardsDelete::class);
 
         $group->post('/courseware-clipboards/{id}/insert', Routes\Courseware\ClipboardsInsert::class);
+
+        $group->get('/courseware-peer-review-processes', Routes\Courseware\PeerReview\ProcessesIndex::class);
+        $group->get('/courseware-peer-review-processes/{id}', Routes\Courseware\PeerReview\ProcessesShow::class);
+        $group->get('/courseware-peer-review-processes/{id}/peer-reviews', Routes\Courseware\PeerReview\ReviewsOfProcessesIndex::class);
+
+        $group->patch('/courseware-peer-review-processes/{id}', Routes\Courseware\PeerReview\ProcessesUpdate::class);
+        $group->delete('/courseware-peer-review-processes/{id}', Routes\Courseware\PeerReview\ProcessesDelete::class);
+
+        $group->post('/courseware-peer-review-processes', Routes\Courseware\PeerReview\ProcessesCreate::class);
+
+        $group->get('/courses/{id}/courseware-peer-reviews', Routes\Courseware\PeerReview\ReviewsIndex::class);
+        $group->get('/courseware-tasks/{id}/peer-reviews', Routes\Courseware\PeerReview\ReviewsByTaskIndex::class);
+
+        $group->post('/courseware-peer-reviews', Routes\Courseware\PeerReview\ReviewsCreate::class);
+        $group->patch('/courseware-peer-reviews/{id}', Routes\Courseware\PeerReview\ReviewsUpdate::class);
+        $group->delete('/courseware-peer-reviews/{id}', Routes\Courseware\PeerReview\ReviewsDelete::class);
     }
 
     private function addAuthenticatedFilesRoutes(RouteCollectorProxy $group): void
diff --git a/lib/classes/JsonApi/Routes/Courseware/Authority.php b/lib/classes/JsonApi/Routes/Courseware/Authority.php
index f7423d8733b2db4b7fcd5d980d4e70b4d44e93cf..d6e3a7db77fff67aed9c654310196050a72603b4 100644
--- a/lib/classes/JsonApi/Routes/Courseware/Authority.php
+++ b/lib/classes/JsonApi/Routes/Courseware/Authority.php
@@ -8,6 +8,8 @@ use Courseware\BlockFeedback;
 use Courseware\Clipboard;
 use Courseware\Container;
 use Courseware\Instance;
+use Courseware\PeerReview;
+use Courseware\PeerReviewProcess;
 use Courseware\StructuralElement;
 use Courseware\StructuralElementComment;
 use Courseware\StructuralElementFeedback;
@@ -324,7 +326,8 @@ class Authority
 
     public static function canShowTask(User $user, Task $resource): bool
     {
-        return self::canUpdateTask($user, $resource);
+        return ($resource->isPeerReviewed() && $resource->isPeerReviewedBy($user)) ||
+            self::canUpdateTask($user, $resource);
     }
 
     public static function canIndexTasks(User $user): bool
@@ -579,4 +582,89 @@ class Authority
         return $resource->user_id === $user->id;
     }
 
+    public static function canIndexPeerReviewProcesses(User $user): bool
+    {
+        return (bool) $user;
+    }
+
+    public static function canShowPeerReviewProcess(User $user, PeerReviewProcess $process): bool
+    {
+        return $GLOBALS['perm']->have_studip_perm('user', $process->task_group['seminar_id'], $user->getId());
+    }
+
+    public static function canCreatePeerReviewProcesses(User $user, TaskGroup $taskGroup): bool
+    {
+        return $GLOBALS['perm']->have_studip_perm('tutor', $taskGroup['seminar_id'], $user->getId());
+    }
+
+    public static function canUpdatePeerReviewProcess(User $user, PeerReviewProcess $process): bool
+    {
+        return self::canCreatePeerReviewProcesses($user, $process->task_group);
+    }
+
+    public static function canDeletePeerReviewProcess(User $user, PeerReviewProcess $process): bool
+    {
+        return self::canCreatePeerReviewProcess($user, $process->task_group);
+    }
+
+    public static function canIndexPeerReviews(User $user)
+    {
+        // TODO: Reicht das? Werden die in der Route gefiltert? Brauchen das nur Lehrende?
+        return (bool) $user;
+    }
+
+    public static function canShowPeerReview(User $user, PeerReview $review): bool
+    {
+        $cid = $review->process->task_group['seminar_id'];
+        if ($GLOBALS['perm']->have_studip_perm('tutor', $cid, $user->getId())) {
+            return true;
+        }
+
+        return $review->isReviewer($user);
+    }
+
+    public static function canShowPeerReviewSubmitter(User $user, PeerReview $review): bool
+    {
+        $cid = $review->process->task_group['seminar_id'];
+        if ($GLOBALS['perm']->have_studip_perm('tutor', $cid, $user->getId())) {
+            return true;
+        }
+
+        return $review->isReviewer($user) && !$review->isAnonymous();
+    }
+
+    public static function canShowPeerReviewAssessment(User $user, PeerReview $review): bool
+    {
+        if ($review->isReviewer($user)) {
+            return true;
+        }
+
+        $isTutor = $GLOBALS['perm']->have_studip_perm(
+            'tutor',
+            $review->process->task_group['seminar_id'],
+            $user->getId()
+        );
+
+        return $isTutor && $review->process->getCurrentState() === PeerReviewProcess::STATE_AFTER;
+    }
+
+    public static function canIndexReviewsOfProcesses(User $user, PeerReviewProcess $process): bool
+    {
+        return self::canShowPeerReviewProcess($user, $process);
+    }
+
+    public static function canUpdatePeerReview(User $user, PeerReview $review): bool
+    {
+        return $review->process->getCurrentState() === PeerReviewProcess::STATE_ACTIVE && $review->isReviewer($user);
+    }
+
+    public static function canCreatePeerReviews(User $user, PeerReviewProcess $process): bool
+    {
+        return self::canCreatePeerReviewProcesses($user, $process->task_group);
+    }
+
+    public static function canDeletePeerReview(User $user, PeerReview $review): bool
+    {
+        return self::canCreatePeerReviews($user, $review->process);
+    }
 }
diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesCreate.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesCreate.php
new file mode 100644
index 0000000000000000000000000000000000000000..8084711d4294ac4c4ffaf8a86a9a53300f227685
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesCreate.php
@@ -0,0 +1,126 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Courseware\PeerReviewProcess;
+use Courseware\TaskGroup;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\Courseware\Authority;
+use JsonApi\Routes\TimestampTrait;
+use JsonApi\Routes\ValidationTrait;
+use JsonApi\Schemas\Courseware\PeerReviewProcess as PeerReviewProcessSchema;
+use JsonApi\Schemas\Courseware\TaskGroup as TaskGroupSchema;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+
+/**
+ * Create a PeerReviewProcess.
+ *
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
+class ProcessesCreate extends JsonApiController
+{
+    use TimestampTrait;
+    use ValidationTrait;
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     *
+     * @param array $args
+     *
+     * @return Response
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $json = $this->validate($request);
+        $taskGroup = $this->getTaskGroupFromJson($json);
+        $user = $this->getUser($request);
+
+        if (!Authority::canCreatePeerReviewProcesses($user, $taskGroup)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $process = $this->create($user, $json);
+
+        return $this->getCreatedResponse($process);
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     *
+     * @param array $json
+     * @param mixed $data
+     *
+     * @return string|void
+     */
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data')) {
+            return 'Missing `data` member at document´s top level.';
+        }
+        if (PeerReviewProcessSchema::TYPE !== self::arrayGet($json, 'data.type')) {
+            return 'Invalid `type` of document´s `data`.';
+        }
+        if (self::arrayHas($json, 'data.id')) {
+            return 'New document must not have an `id`.';
+        }
+
+        if (!self::arrayHas($json, 'data.attributes.configuration')) {
+            return 'Missing `configuration` attribute.';
+        }
+        // TODO: validate configuration
+
+        if (!self::arrayHas($json, 'data.attributes.review-start')) {
+            return 'Missing `review-start` attribute.';
+        }
+        $startDate = self::arrayGet($json, 'data.attributes.review-start');
+        if (!self::isValidTimestamp($startDate)) {
+            return '`review-start` is not an ISO 8601 timestamp.';
+        }
+
+        if (!self::arrayHas($json, 'data.attributes.review-end')) {
+            return 'Missing `review-end` attribute.';
+        }
+        $endDate = self::arrayGet($json, 'data.attributes.review-end');
+        if (!self::isValidTimestamp($endDate)) {
+            return '`review-end` is not an ISO 8601 timestamp.';
+        }
+
+        if (!self::arrayHas($json, 'data.relationships.task-group')) {
+            return 'Missing `task-group` relationship.';
+        }
+        if (!$this->getTaskGroupFromJson($json)) {
+            return 'Invalid `task-group` relationship.';
+        }
+    }
+
+    private function getTaskGroupFromJson(array $json): ?TaskGroup
+    {
+        if (!$this->validateResourceObject($json, 'data.relationships.task-group', TaskGroupSchema::TYPE)) {
+            return null;
+        }
+        $resourceId = self::arrayGet($json, 'data.relationships.task-group.data.id');
+
+        return TaskGroup::find($resourceId);
+    }
+
+    private function create(\User $user, array $json): PeerReviewProcess
+    {
+        $taskGroup = $this->getTaskGroupFromJson($json);
+        $startDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.review-start'));
+        $endDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.review-end'));
+        $configuration = self::arrayGet($json, 'data.attributes.configuration');
+
+        /** @var PeerReviewProcess $process */
+        $process = PeerReviewProcess::create([
+            'task_group_id' => $taskGroup->getId(),
+            'owner_id' => $user->getId(),
+            'configuration' => $configuration,
+            'review_start' => $startDate->getTimestamp(),
+            'review_end' => $endDate->getTimestamp(),
+        ]);
+
+        return $process;
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesDelete.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesDelete.php
new file mode 100644
index 0000000000000000000000000000000000000000..b9ba42eb3ed4f6d54555765862b542ea50595863
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesDelete.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Courseware\PeerPreviewProcess;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\Courseware\Authority;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+
+/**
+ * Delete one PeerPreviewProcess.
+ */
+class ProcessesDelete extends JsonApiController
+{
+    /**
+     * @param array $args
+     * @return Response
+     *
+     * @SuppressWarnings(PHPMD.StaticAccess)
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        /** @var ?PeerPreviewProcess $resource */
+        $resource = PeerPreviewProcess::find($args['id']);
+        if (!$resource) {
+            throw new RecordNotFoundException();
+        }
+        if (!Authority::canDeletePeerReviewProcess($this->getUser($request), $resource)) {
+            throw new AuthorizationFailedException();
+        }
+        $resource->delete();
+
+        return $this->getCodeResponse(204);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesIndex.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesIndex.php
new file mode 100644
index 0000000000000000000000000000000000000000..42bee8a01385f30416b611629adfc682a8e58c6a
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesIndex.php
@@ -0,0 +1,108 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Course;
+use Courseware\PeerReviewProcess;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\BadRequestException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\Courses\Authority as CoursesAuthority;
+use JsonApi\Routes\Courseware\Authority;
+use JsonApi\Schemas\Courseware\PeerReviewProcess as ProcessSchema;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use User;
+
+/**
+ * Displays all visible PeerReviewProcesses.
+ *
+ * @SuppressWarnings(PHPMD.LongVariable)
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
+class ProcessesIndex extends JsonApiController
+{
+    protected $allowedFilteringParameters = ['cid'];
+
+    protected $allowedIncludePaths = [
+        ProcessSchema::REL_COURSE,
+        ProcessSchema::REL_OWNER,
+        ProcessSchema::REL_TASK_GROUP,
+    ];
+
+    protected $allowedPagingParameters = ['offset', 'limit'];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     *
+     * @param array $args
+     *
+     * @return Response
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $user = $this->getUser($request);
+        $filtering = $this->getQueryParameters()->getFilteringParameters() ?: [];
+
+        $this->validateFilters($filtering);
+        $this->authorize($user, $filtering);
+
+        $resources = empty($filtering) ? $this->findAllProcesses($user) : $this->filterProcesses($user, $filtering);
+
+        return $this->getPaginatedContentResponse(
+            array_slice($resources, ...$this->getOffsetAndLimit()),
+            count($resources)
+        );
+    }
+
+    /**
+     * @throws BadRequestException
+     */
+    private function validateFilters(array $filtering): void
+    {
+        if (isset($filtering['cid']) && !Course::exists($filtering['cid'])) {
+            throw new BadRequestException('Could not find a course matching this `filter[cid]`.');
+        }
+    }
+
+    /**
+     * @throws AuthorizationFailedException
+     */
+    private function authorize(User $user, array $filtering): void
+    {
+        if (!Authority::canIndexPeerReviewProcesses($user)) {
+            throw new AuthorizationFailedException();
+        }
+
+        if (isset($filtering['cid'])) {
+            if (
+                !CoursesAuthority::canShowCourse(
+                    $user,
+                    Course::find($filtering['cid']),
+                    CoursesAuthority::SCOPE_EXTENDED
+                )
+            ) {
+                throw new AuthorizationFailedException();
+            }
+        }
+    }
+
+    private function findAllProcesses(User $user): iterable
+    {
+        return PeerReviewProcess::findByUser($user);
+    }
+
+    private function filterProcesses(User $user, array $filtering): iterable
+    {
+        if (isset($filtering['cid'])) {
+            /** @var ?\Course $course */
+            $course = \Course::find($filtering['cid']);
+
+            return array_filter(PeerReviewProcess::findByCourse($course), function ($process) use ($user) {
+                return Authority::canShowPeerReviewProcess($user, $process);
+            });
+        }
+
+        return [];
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesShow.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesShow.php
new file mode 100644
index 0000000000000000000000000000000000000000..3d904217c758f9d1eb1891a9afc16384b525bd1f
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesShow.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Course;
+use Courseware\PeerReviewProcess;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Routes\Courseware\Authority;
+use JsonApi\Schemas\Courseware\PeerReviewProcess as ProcessSchema;
+use JsonApi\JsonApiController;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use User;
+
+/**
+ * Displays one PeerReviewProcess.
+ *
+ * @SuppressWarnings(PHPMD.LongVariable)
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
+class ProcessesShow extends JsonApiController
+{
+    protected $allowedIncludePaths = [
+        ProcessSchema::REL_COURSE,
+        ProcessSchema::REL_OWNER,
+        ProcessSchema::REL_TASK_GROUP,
+    ];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     * @param array $args
+     * @return Response
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        /** @var ?\Courseware\PeerReviewProcess $resource */
+        $resource = PeerReviewProcess::find($args['id']);
+        if (!$resource) {
+            throw new RecordNotFoundException();
+        }
+
+        if (!Authority::canShowPeerReviewProcess($this->getUser($request), $resource)) {
+            throw new AuthorizationFailedException();
+        }
+
+        return $this->getContentResponse($resource);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesUpdate.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesUpdate.php
new file mode 100644
index 0000000000000000000000000000000000000000..75ed1b8c47fefcaa59039738d0cfc9e9b69ea327
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesUpdate.php
@@ -0,0 +1,123 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Courseware\PeerReviewProcess;
+use Courseware\TaskGroup;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Routes\Courseware\Authority;
+use JsonApi\Schemas\Courseware\PeerReviewProcess as ProcessSchema;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\TimestampTrait;
+use JsonApi\Routes\ValidationTrait;
+use JsonApi\Schemas\Courseware\PeerReviewProcess as PeerReviewProcessSchema;
+use JsonApi\Schemas\Courseware\TaskGroup as TaskGroupSchema;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use User;
+
+/**
+ * Updates one PeerReviewProcess.
+ *
+ * @SuppressWarnings(PHPMD.LongVariable)
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
+class ProcessesUpdate extends JsonApiController
+{
+    use TimestampTrait;
+    use ValidationTrait;
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     * @param array $args
+     * @return Response
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        /** @var ?\Courseware\PeerReviewProcess $resource */
+        $resource = PeerReviewProcess::find($args['id']);
+        if (!$resource) {
+            throw new RecordNotFoundException();
+        }
+        $json = $this->validate($request, $resource);
+        $user = $this->getUser($request);
+        if (!Authority::canUpdatePeerReviewProcess($user, $resource)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $process = $this->update($user, $resource, $json);
+
+        return $this->getContentResponse($process);
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     *
+     * @param array $json
+     * @param mixed $data
+     *
+     * @return string|void
+     */
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data')) {
+            return 'Missing `data` member at document´s top level.';
+        }
+        if (PeerReviewProcessSchema::TYPE !== self::arrayGet($json, 'data.type')) {
+            return 'Invalid `type` of document´s `data`.';
+        }
+
+        if (!self::arrayHas($json, 'data.attributes.configuration')) {
+            return 'Missing `configuration` attribute.';
+        }
+        // TODO: validate configuration
+
+        if (!self::arrayHas($json, 'data.attributes.review-start')) {
+            return 'Missing `review-start` attribute.';
+        }
+        $startDate = self::arrayGet($json, 'data.attributes.review-start');
+        if (!self::isValidTimestamp($startDate)) {
+            return '`review-start` is not an ISO 8601 timestamp.';
+        }
+
+        if (!self::arrayHas($json, 'data.attributes.review-end')) {
+            return 'Missing `review-end` attribute.';
+        }
+        $endDate = self::arrayGet($json, 'data.attributes.review-end');
+        if (!self::isValidTimestamp($endDate)) {
+            return '`review-end` is not an ISO 8601 timestamp.';
+        }
+
+        if (self::arrayHas($json, 'data.relationships.task-group')) {
+            if (!$this->getTaskGroupFromJson($json)) {
+                return 'Invalid `task-group` relationship.';
+            }
+        }
+    }
+
+    private function getTaskGroupFromJson(array $json): ?TaskGroup
+    {
+        if (!$this->validateResourceObject($json, 'data.relationships.task-group', TaskGroupSchema::TYPE)) {
+            return null;
+        }
+        $resourceId = self::arrayGet($json, 'data.relationships.task-group.data.id');
+
+        return TaskGroup::find($resourceId);
+    }
+
+    private function update(User $user, PeerReviewProcess $process, array $json): PeerReviewProcess
+    {
+        $startDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.review-start'));
+        $endDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.review-end'));
+        $configuration = self::arrayGet($json, 'data.attributes.configuration');
+
+        $process->review_start = $startDate->getTimestamp();
+        $process->review_end = $endDate->getTimestamp();
+        $process->configuration = $configuration;
+
+        $process->store();
+
+        return $process;
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsByTaskIndex.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsByTaskIndex.php
new file mode 100644
index 0000000000000000000000000000000000000000..4c8441deffa094bd268207220eb77de498a50785
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsByTaskIndex.php
@@ -0,0 +1,80 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Courseware\PeerReview;
+use Courseware\PeerReviewProcess;
+use Courseware\Task;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\BadRequestException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\Courses\Authority as CoursesAuthority;
+use JsonApi\Routes\Courseware\Authority;
+use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema;
+use JsonApi\Schemas\Courseware\Task as TaskSchema;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use User;
+
+/**
+ * Displays all PeerReviews of a course.
+ *
+ * @SuppressWarnings(PHPMD.LongVariable)
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
+class ReviewsByTaskIndex extends JsonApiController
+{
+    protected $allowedIncludePaths = [
+        PeerReviewSchema::REL_PROCESS,
+        PeerReviewSchema::REL_REVIEWER,
+        // PeerReviewSchema::REL_SUBMITTER,
+        PeerReviewSchema::REL_TASK,
+        PeerReviewSchema::REL_TASK . '.' . TaskSchema::REL_STRUCTURAL_ELEMENT,
+        PeerReviewSchema::REL_TASK . '.' . TaskSchema::REL_TASK_GROUP,
+    ];
+
+    protected $allowedPagingParameters = ['offset', 'limit'];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     *
+     * @param array $args
+     *
+     * @return Response
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        /** @var ?Task $task */
+        $task = Task::find($args['id']);
+        if (!$task) {
+            throw new RecordNotFoundException();
+        }
+
+        $user = $this->getUser($request);
+        $this->authorize($user);
+
+        $resources = $this->findPeerReviews($task, $user);
+
+        return $this->getPaginatedContentResponse(
+            $resources->limit(...$this->getOffsetAndLimit()),
+            count($resources)
+        );
+    }
+
+    /**
+     * @throws AuthorizationFailedException
+     */
+    private function authorize(User $user): void
+    {
+        if (!Authority::canIndexPeerReviews($user)) {
+            throw new AuthorizationFailedException();
+        }
+    }
+
+    private function findPeerReviews(Task $task, User $user): iterable
+    {
+        return $task->peer_reviews->filter(function ($peerReview) use ($user) {
+            return Authority::canShowPeerReview($user, $peerReview);
+        });
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsCreate.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsCreate.php
new file mode 100644
index 0000000000000000000000000000000000000000..26e566d2cc7ddd0fb251670e3d6efbca1d4d8ea3
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsCreate.php
@@ -0,0 +1,184 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Courseware\PeerReview;
+use Courseware\PeerReviewProcess;
+use Courseware\Task;
+use Courseware\TaskGroup;
+use InvalidArgumentException;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\Courseware\Authority;
+use JsonApi\Routes\TimestampTrait;
+use JsonApi\Routes\ValidationTrait;
+use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema;
+use JsonApi\Schemas\Courseware\PeerReviewProcess as PeerReviewProcessSchema;
+use JsonApi\Schemas\StatusGroup as StatusGroupSchema;
+use JsonApi\Schemas\User as UserSchema;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Statusgruppen;
+use User;
+
+/**
+ * Create a PeerReview.
+ *
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
+class ReviewsCreate extends JsonApiController
+{
+    use ValidationTrait;
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     *
+     * @param array $args
+     *
+     * @return Response
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $json = $this->validate($request);
+        $process = $this->getProcessFromJson($json);
+        $user = $this->getUser($request);
+
+        if (!Authority::canCreatePeerReviews($user, $process)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $resource = $this->create($json);
+
+        return $this->getCreatedResponse($resource);
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     *
+     * @param array $json
+     * @param mixed $data
+     *
+     * @return string|void
+     */
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data')) {
+            return 'Missing `data` member at document´s top level.';
+        }
+        if (PeerReviewSchema::TYPE !== self::arrayGet($json, 'data.type')) {
+            return 'Invalid `type` of document´s `data`.';
+        }
+        if (self::arrayHas($json, 'data.id')) {
+            return 'New document must not have an `id`.';
+        }
+
+        // process
+        if (!self::arrayHas($json, 'data.relationships.process')) {
+            return 'Missing `process` relationship.';
+        }
+        if (!$this->getProcessFromJson($json)) {
+            return 'Invalid `process` relationship.';
+        }
+
+        // submitter
+        if (!self::arrayHas($json, 'data.relationships.submitter')) {
+            return 'Missing `submitter` relationship.';
+        }
+        if (!$this->getSubmitterFromJson($json)) {
+            return 'Invalid `submitter` relationship.';
+        }
+
+        // reviewer
+        if (!self::arrayHas($json, 'data.relationships.reviewer')) {
+            return 'Missing `reviewer` relationship.';
+        }
+        if (!$this->getReviewerFromJson($json)) {
+            return 'Invalid `reviewer` relationship.';
+        }
+    }
+
+    private function create(array $json): PeerReview
+    {
+        $process = $this->getProcessFromJson($json);
+        $reviewer = $this->getReviewerFromJson($json);
+        $submitter = $this->getSubmitterFromJson($json);
+
+        $task = $process['task_group']->findTaskBySolver($submitter);
+        $reviewerType = $this->getReviewerType($reviewer);
+
+        /** @var PeerReview $review */
+        $review = PeerReview::create([
+            'process_id' => $process->id,
+            'task_id' => $task->id,
+            'submitter_id' => $submitter->id,
+            'reviewer_id' => $reviewer->id,
+            'reviewer_type' => $reviewerType,
+        ]);
+
+        return $review;
+    }
+
+    /**
+     * @return User|Statusgruppen|null
+     */
+    private function getActorFromJson(array $json, string $relation)
+    {
+        $relationship = 'data.relationships.' . $relation;
+        if (
+            !(
+                $this->validateResourceObject($json, $relationship, UserSchema::TYPE) ||
+                $this->validateResourceObject($json, $relationship, StatusGroupSchema::TYPE)
+            )
+        ) {
+            return null;
+        }
+        $resourceId = self::arrayGet($json, $relationship . '.data.id');
+
+        switch (self::arrayGet($json, $relationship . '.data.type')) {
+            case UserSchema::TYPE:
+                return User::find($resourceId);
+            case StatusGroupSchema::TYPE:
+                return Statusgruppen::find($resourceId);
+        }
+
+        throw new InvalidArgumentException();
+    }
+
+    private function getProcessFromJson(array $json): ?PeerReviewProcess
+    {
+        if (!$this->validateResourceObject($json, 'data.relationships.process', PeerReviewProcessSchema::TYPE)) {
+            return null;
+        }
+        $resourceId = self::arrayGet($json, 'data.relationships.process.data.id');
+
+        return PeerReviewProcess::find($resourceId);
+    }
+
+    /**
+     * @return User|Statusgruppen|null
+     */
+    private function getReviewerFromJson(array $json)
+    {
+        return $this->getActorFromJson($json, 'reviewer');
+    }
+
+    private function getReviewerType($reviewer): string
+    {
+        if ($reviewer instanceof User) {
+            return 'autor';
+        }
+        if ($reviewer instanceof Statusgruppen) {
+            return 'group';
+        }
+
+        throw new InvalidArgumentException();
+    }
+
+    /**
+     * @return User|Statusgruppen|null
+     */
+    private function getSubmitterFromJson(array $json)
+    {
+        return $this->getActorFromJson($json, 'submitter');
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsDelete.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsDelete.php
new file mode 100644
index 0000000000000000000000000000000000000000..2b7edd60d47fd44a69feee22efe91aa0bda168b6
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsDelete.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Courseware\PeerReview;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\Courseware\Authority;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+
+/**
+ * Delete one PeerPreview.
+ */
+class ReviewsDelete extends JsonApiController
+{
+    /**
+     * @param array $args
+     * @return Response
+     *
+     * @SuppressWarnings(PHPMD.StaticAccess)
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        /** @var ?PeerReview $resource */
+        $resource = PeerReview::find($args['id']);
+        if (!$resource) {
+            throw new RecordNotFoundException();
+        }
+        if (!Authority::canDeletePeerReview($this->getUser($request), $resource)) {
+            throw new AuthorizationFailedException();
+        }
+        $resource->delete();
+
+        return $this->getCodeResponse(204);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsIndex.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsIndex.php
new file mode 100644
index 0000000000000000000000000000000000000000..54fb26baa0933b3c496c56cca30e8ccd6613635c
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsIndex.php
@@ -0,0 +1,80 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Course;
+use Courseware\PeerReview;
+use Courseware\PeerReviewProcess;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\BadRequestException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\Courses\Authority as CoursesAuthority;
+use JsonApi\Routes\Courseware\Authority;
+use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema;
+use JsonApi\Schemas\Courseware\Task as TaskSchema;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use User;
+
+/**
+ * Displays all PeerReviews of a course.
+ *
+ * @SuppressWarnings(PHPMD.LongVariable)
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
+class ReviewsIndex extends JsonApiController
+{
+    protected $allowedIncludePaths = [
+        PeerReviewSchema::REL_PROCESS,
+        PeerReviewSchema::REL_REVIEWER,
+        // PeerReviewSchema::REL_SUBMITTER,
+        PeerReviewSchema::REL_TASK,
+        PeerReviewSchema::REL_TASK . '.' . TaskSchema::REL_STRUCTURAL_ELEMENT,
+        PeerReviewSchema::REL_TASK . '.' . TaskSchema::REL_TASK_GROUP,
+    ];
+
+    protected $allowedPagingParameters = ['offset', 'limit'];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     *
+     * @param array $args
+     *
+     * @return Response
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        /** @var ?Course $course */
+        $course = Course::find($args['id']);
+        if (!$course) {
+            throw new RecordNotFoundException();
+        }
+
+        $user = $this->getUser($request);
+        $this->authorize($user);
+
+        $resources = $this->findPeerReviews($course, $user);
+
+        return $this->getPaginatedContentResponse(
+            array_slice($resources, ...$this->getOffsetAndLimit()),
+            count($resources)
+        );
+    }
+
+    /**
+     * @throws AuthorizationFailedException
+     */
+    private function authorize(User $user): void
+    {
+        if (!Authority::canIndexPeerReviews($user)) {
+            throw new AuthorizationFailedException();
+        }
+    }
+
+    private function findPeerReviews(Course $course, User $user): iterable
+    {
+        return array_filter(PeerReview::findByCourse($course), function ($peerReview) use ($user) {
+            return Authority::canShowPeerReview($user, $peerReview);
+        });
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsOfProcessesIndex.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsOfProcessesIndex.php
new file mode 100644
index 0000000000000000000000000000000000000000..2a0b04d922efb7441fd50ddde1b40cbac76f3bab
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsOfProcessesIndex.php
@@ -0,0 +1,77 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Course;
+use Courseware\PeerReview;
+use Courseware\PeerReviewProcess;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\BadRequestException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\Courses\Authority as CoursesAuthority;
+use JsonApi\Routes\Courseware\Authority;
+use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use User;
+
+/**
+ * Displays all visible PeerReviewProcesses.
+ *
+ * @SuppressWarnings(PHPMD.LongVariable)
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
+class ReviewsOfProcessesIndex extends JsonApiController
+{
+    protected $allowedIncludePaths = [
+        PeerReviewSchema::REL_PROCESS,
+        PeerReviewSchema::REL_REVIEWER,
+        PeerReviewSchema::REL_SUBMITTER,
+        PeerReviewSchema::REL_TASK,
+    ];
+
+    protected $allowedPagingParameters = ['offset', 'limit'];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     *
+     * @param array $args
+     *
+     * @return Response
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        /** @var ?PeerReviewProcess $process */
+        $process = PeerReviewProcess::find($args['id']);
+        if (!$process) {
+            throw new RecordNotFoundException();
+        }
+
+        $user = $this->getUser($request);
+        $this->authorize($user, $process);
+
+        $resources = $this->findReviews($user, $process);
+
+        return $this->getPaginatedContentResponse(
+            $resources->limit(...$this->getOffsetAndLimit()),
+            count($resources)
+        );
+    }
+
+    /**
+     * @throws AuthorizationFailedException
+     */
+    private function authorize(User $user, PeerReviewProcess $process): void
+    {
+        if (!Authority::canIndexReviewsOfProcesses($user, $process)) {
+            throw new AuthorizationFailedException();
+        }
+    }
+
+    private function findReviews(User $user, PeerReviewProcess $process): iterable
+    {
+        return $process->peer_reviews->filter(function ($peerReview) use ($user) {
+            return Authority::canShowPeerReview($user, $peerReview);
+        });
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsUpdate.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsUpdate.php
new file mode 100644
index 0000000000000000000000000000000000000000..cf3c6002ee652c575eaeec6e1a3dc28d6a3bb7c0
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsUpdate.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Courseware\PeerReview;
+use Courseware\TaskGroup;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\Courseware\Authority;
+use JsonApi\Routes\TimestampTrait;
+use JsonApi\Routes\ValidationTrait;
+use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use User;
+
+/**
+ * Updates one PeerReview.
+ *
+ * @SuppressWarnings(PHPMD.LongVariable)
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
+class ReviewsUpdate extends JsonApiController
+{
+    use TimestampTrait;
+    use ValidationTrait;
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     * @param array $args
+     * @return Response
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $resource = PeerReview::find($args['id']);
+        if (!$resource) {
+            throw new RecordNotFoundException();
+        }
+        $json = $this->validate($request, $resource);
+        $user = $this->getUser($request);
+        if (!Authority::canUpdatePeerReview($user, $resource)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $review = $this->update($resource, $json);
+
+        return $this->getContentResponse($review);
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     *
+     * @param array $json
+     * @param mixed $data
+     *
+     * @return string|void
+     */
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data')) {
+            return 'Missing `data` member at document´s top level.';
+        }
+        if (PeerReviewSchema::TYPE !== self::arrayGet($json, 'data.type')) {
+            return 'Invalid `type` of document´s `data`.';
+        }
+
+        if (!self::arrayHas($json, 'data.attributes.assessment')) {
+            return 'Missing `assessment` attribute.';
+        }
+
+        // TODO: validate assessment
+    }
+
+    private function update(PeerReview $review, array $json): PeerReview
+    {
+        $review->assessment = self::arrayGet($json, 'data.attributes.assessment');
+        $review->store();
+
+        return $review;
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/TaskGroupsShow.php b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsShow.php
index c8ebb86e31bcd1c3480111ee3514159ad7efa8cb..ff3fba44ecbd55ccd87a22045a28982b1a15fded 100644
--- a/lib/classes/JsonApi/Routes/Courseware/TaskGroupsShow.php
+++ b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsShow.php
@@ -18,6 +18,7 @@ class TaskGroupsShow extends JsonApiController
     protected $allowedIncludePaths = [
         TaskGroupSchema::REL_COURSE,
         TaskGroupSchema::REL_LECTURER,
+        TaskGroupSchema::REL_PEER_REVIEW_PROCESSES,
         TaskGroupSchema::REL_SOLVERS,
         TaskGroupSchema::REL_TARGET,
         TaskGroupSchema::REL_TASK_TEMPLATE,
diff --git a/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php b/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php
index 26a021c9682052271e9995c07867d91fded6d555..995243741ffd9c5260b0b9b1f20d804dbd55f6a9 100644
--- a/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php
+++ b/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php
@@ -25,6 +25,7 @@ class TasksIndex extends JsonApiController
         TaskSchema::REL_STRUCTURAL_ELEMENT,
         TaskSchema::REL_TASK_GROUP,
         TaskSchema::REL_TASK_GROUP . '.' . TaskGroupSchema::REL_LECTURER,
+        TaskSchema::REL_TASK_GROUP . '.' . TaskGroupSchema::REL_PEER_REVIEW_PROCESSES,
     ];
 
     /**
diff --git a/lib/classes/JsonApi/Routes/Courseware/TasksShow.php b/lib/classes/JsonApi/Routes/Courseware/TasksShow.php
index 619e7eab9e7f565def448dc604217e2c508abebc..419f950950541458af9dc7e202845b9ee418e41c 100644
--- a/lib/classes/JsonApi/Routes/Courseware/TasksShow.php
+++ b/lib/classes/JsonApi/Routes/Courseware/TasksShow.php
@@ -5,6 +5,7 @@ namespace JsonApi\Routes\Courseware;
 use Courseware\Task;
 use JsonApi\Errors\AuthorizationFailedException;
 use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema;
 use JsonApi\Schemas\Courseware\Task as TaskSchema;
 use JsonApi\Schemas\Courseware\TaskGroup as TaskGroupSchema;
 use JsonApi\JsonApiController;
@@ -18,10 +19,13 @@ class TasksShow extends JsonApiController
 {
     protected $allowedIncludePaths = [
         TaskSchema::REL_FEEDBACK,
+        TaskSchema::REL_PEER_REVIEWS,
+        TaskSchema::REL_PEER_REVIEWS . '.' . PeerReviewSchema::REL_PROCESS,
         TaskSchema::REL_SOLVER,
         TaskSchema::REL_STRUCTURAL_ELEMENT,
         TaskSchema::REL_TASK_GROUP,
         TaskSchema::REL_TASK_GROUP . '.' . TaskGroupSchema::REL_LECTURER,
+        TaskSchema::REL_TASK_GROUP . '.' . TaskGroupSchema::REL_PEER_REVIEW_PROCESSES,
     ];
 
     /**
diff --git a/lib/classes/JsonApi/SchemaMap.php b/lib/classes/JsonApi/SchemaMap.php
index 71aadf7124a99463ac5de3ca0e962e9c1d8c2d1f..541c7519723fffbe44299029694f71e323935870 100644
--- a/lib/classes/JsonApi/SchemaMap.php
+++ b/lib/classes/JsonApi/SchemaMap.php
@@ -59,6 +59,8 @@ class SchemaMap
             \Courseware\Clipboard::class => Schemas\Courseware\Clipboard::class,
             \Courseware\Container::class => Schemas\Courseware\Container::class,
             \Courseware\Instance::class => Schemas\Courseware\Instance::class,
+            \Courseware\PeerReview::class => Schemas\Courseware\PeerReview::class,
+            \Courseware\PeerReviewProcess::class => Schemas\Courseware\PeerReviewProcess::class,
             \Courseware\PublicLink::class => Schemas\Courseware\PublicLink::class,
             \Courseware\StructuralElement::class => Schemas\Courseware\StructuralElement::class,
             \Courseware\StructuralElementComment::class => Schemas\Courseware\StructuralElementComment::class,
diff --git a/lib/classes/JsonApi/Schemas/Courseware/PeerReview.php b/lib/classes/JsonApi/Schemas/Courseware/PeerReview.php
new file mode 100644
index 0000000000000000000000000000000000000000..d8e903cbb686a5caabbbf97260b794d56bba8f30
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/Courseware/PeerReview.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace JsonApi\Schemas\Courseware;
+
+use JsonApi\Routes\Courseware\Authority;
+use JsonApi\Schemas\SchemaProvider;
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+use Neomerx\JsonApi\Schema\Link;
+
+class PeerReview extends SchemaProvider
+{
+    public const TYPE = 'courseware-peer-reviews';
+
+    public const REL_PROCESS = 'process';
+    public const REL_REVIEWER = 'reviewer';
+    public const REL_SUBMITTER = 'submitter';
+    public const REL_TASK = 'task';
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getId($resource): ?string
+    {
+        return $resource->id;
+    }
+
+    /**
+     * {@inheritdoc}
+     * @SuppressWarnings(PHPMD.StaticAccess)
+     */
+    public function getAttributes($resource, ContextInterface $context): iterable
+    {
+        $user = $this->currentUser;
+        $assessment = null;
+        if ($resource->assessment && Authority::canShowPeerReviewAssessment($user, $resource)) {
+            $assessment = $resource->assessment->getIterator();
+        }
+        return [
+            'assessment' => $assessment,
+            'mkdate' => date('c', $resource['mkdate']),
+            'chdate' => date('c', $resource['chdate']),
+        ];
+    }
+
+    /**
+     * {@inheritdoc}
+     * @SuppressWarnings(PHPMD.StaticAccess)
+     */
+    public function getRelationships($resource, ContextInterface $context): iterable
+    {
+        $relationships = [];
+
+        $relationships[self::REL_PROCESS] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->createLinkToResource($resource->process),
+            ],
+            self::RELATIONSHIP_DATA => $resource->process,
+        ];
+
+        $reviewer = $resource->getReviewer();
+        $relationships[self::REL_REVIEWER] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->createLinkToResource($reviewer),
+            ],
+            self::RELATIONSHIP_DATA => $reviewer,
+        ];
+
+        $user = $this->currentUser;
+
+        if (Authority::canShowPeerReviewSubmitter($user, $resource)) {
+            $submitter = $resource->getSubmitter();
+            $relationships[self::REL_SUBMITTER] = [
+                self::RELATIONSHIP_LINKS => [
+                    Link::RELATED => $this->createLinkToResource($submitter),
+                ],
+                self::RELATIONSHIP_DATA => $submitter,
+            ];
+        }
+
+        $relationships[self::REL_TASK] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->createLinkToResource($resource->task),
+            ],
+            self::RELATIONSHIP_DATA => $resource->task,
+        ];
+
+        return $relationships;
+    }
+}
diff --git a/lib/classes/JsonApi/Schemas/Courseware/PeerReviewProcess.php b/lib/classes/JsonApi/Schemas/Courseware/PeerReviewProcess.php
new file mode 100644
index 0000000000000000000000000000000000000000..0eca67c523cca3d75f5d3f6d0844a5e529c77946
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/Courseware/PeerReviewProcess.php
@@ -0,0 +1,77 @@
+<?php
+
+namespace JsonApi\Schemas\Courseware;
+
+use JsonApi\Schemas\SchemaProvider;
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+use Neomerx\JsonApi\Schema\Link;
+
+class PeerReviewProcess extends SchemaProvider
+{
+    const TYPE = 'courseware-peer-review-processes';
+
+    const REL_COURSE = 'course';
+    const REL_OWNER = 'owner';
+    const REL_PEER_REVIEWS = 'reviews';
+    const REL_TASK_GROUP = 'task-group';
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getId($resource): ?string
+    {
+        return $resource->id;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getAttributes($resource, ContextInterface $context): iterable
+    {
+        return [
+            'configuration' => $resource['configuration']->getIterator(),
+            'review-start' => date('c', $resource['review_start']),
+            'review-end' => date('c', $resource['review_end']),
+            'mkdate' => date('c', $resource['mkdate']),
+            'chdate' => date('c', $resource['chdate']),
+        ];
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getRelationships($resource, ContextInterface $context): iterable
+    {
+        $relationships = [];
+
+        $course = $resource->getCourse();
+        $relationships[self::REL_COURSE] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->createLinkToResource($course),
+            ],
+            self::RELATIONSHIP_DATA => $course,
+        ];
+
+        $relationships[self::REL_OWNER] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->createLinkToResource($resource->owner),
+            ],
+            self::RELATIONSHIP_DATA => $resource->owner,
+        ];
+
+        $relationships[self::REL_PEER_REVIEWS] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_PEER_REVIEWS),
+            ],
+        ];
+
+        $relationships[self::REL_TASK_GROUP] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->createLinkToResource($resource->task_group),
+            ],
+            self::RELATIONSHIP_DATA => $resource->task_group,
+        ];
+
+        return $relationships;
+    }
+}
diff --git a/lib/classes/JsonApi/Schemas/Courseware/Task.php b/lib/classes/JsonApi/Schemas/Courseware/Task.php
index 2967043e76a37042f3412294d058ce3b05f2fb30..282e6226e148a8fb44b609536b31e6520718bf6e 100644
--- a/lib/classes/JsonApi/Schemas/Courseware/Task.php
+++ b/lib/classes/JsonApi/Schemas/Courseware/Task.php
@@ -13,6 +13,7 @@ class Task extends SchemaProvider
     const TYPE = 'courseware-tasks';
 
     const REL_FEEDBACK = 'task-feedback';
+    const REL_PEER_REVIEWS = 'peer-reviews';
     const REL_SOLVER = 'solver';
     const REL_STRUCTURAL_ELEMENT = 'structural-element';
     const REL_TASK_GROUP = 'task-group';
@@ -30,12 +31,15 @@ class Task extends SchemaProvider
      */
     public function getAttributes($resource, ContextInterface $context): iterable
     {
+        $user = $this->currentUser;
+
         return [
             'progress' => (float) $resource->getTaskProgress(),
             'submission-date' => date('c', $resource['submission_date']),
             'submitted' => (bool) $resource['submitted'],
             'renewal' => empty($resource['renewal']) ? null : (string) $resource['renewal'],
             'renewal-date' => date('c', $resource['renewal_date']),
+            'can-peer-review' => $resource->userIsAPeerReviewer($user),
             'mkdate' => date('c', $resource['mkdate']),
             'chdate' => date('c', $resource['chdate']),
         ];
@@ -57,6 +61,12 @@ class Task extends SchemaProvider
             ]
             : [self::RELATIONSHIP_DATA => null];
 
+        $relationships = $this->addPeerReviews(
+            $relationships,
+            $resource,
+            $this->shouldInclude($context, self::REL_PEER_REVIEWS)
+        );
+
         $relationships[self::REL_SOLVER] = $resource['solver_id']
             ? [
                 self::RELATIONSHIP_LINKS => [
@@ -84,4 +94,30 @@ class Task extends SchemaProvider
 
         return $relationships;
     }
+
+    /**
+     * @SuppressWarnings(PHPMD.StaticAccess)
+     */
+    private function addPeerReviews(array $relationships, TaskModel $resource, bool $includeData): array
+    {
+        $relationships[self::REL_PEER_REVIEWS] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_PEER_REVIEWS),
+            ],
+        ];
+
+        if ($includeData) {
+            $data = [];
+            $user = $this->currentUser;
+            if ($resource->isPeerReviewedBy($this->currentUser)) {
+                $data = $resource->peer_reviews->filter(function ($review) use ($user) {
+                    return CoursewareAuthority::canShowPeerReview($user, $review);
+                });
+            }
+
+            $relationships[self::REL_PEER_REVIEWS][self::RELATIONSHIP_DATA] = $data;
+        }
+
+        return $relationships;
+    }
 }
diff --git a/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php b/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php
index c950671ea47bac8821dcf11bc523eb6c09f6d3a7..68706640cecb02739640d83eebcfd6062b3e65af 100644
--- a/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php
+++ b/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php
@@ -15,6 +15,7 @@ class TaskGroup extends SchemaProvider
 
     const REL_COURSE = 'course';
     const REL_LECTURER = 'lecturer';
+    const REL_PEER_REVIEW_PROCESSES = 'peer-review-processes';
     const REL_SOLVERS = 'solvers';
     const REL_TARGET = 'target';
     const REL_TASK_TEMPLATE = 'task-template';
@@ -68,6 +69,8 @@ class TaskGroup extends SchemaProvider
             ]
             : [self::RELATIONSHIP_DATA => null];
 
+        $relationships = $this->addPeerReviewProcessesRelationship($relationships, $resource, $context);
+
         $relationships[self::REL_SOLVERS] = [
             self::RELATIONSHIP_DATA => $resource->getSolvers(),
         ];
@@ -104,4 +107,19 @@ class TaskGroup extends SchemaProvider
 
         return $relationships;
     }
+
+    private function addPeerReviewProcessesRelationship(iterable $relationships, TaskGroupModel $resource, ContextInterface $context): iterable
+    {
+        $relationships[self::REL_PEER_REVIEW_PROCESSES] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_PEER_REVIEW_PROCESSES),
+            ],
+        ];
+
+        if ($this->shouldInclude($context, self::REL_PEER_REVIEW_PROCESSES)) {
+            $relationships[self::REL_PEER_REVIEW_PROCESSES][self::RELATIONSHIP_DATA] = $resource->peer_review_processes;
+        }
+
+        return $relationships;
+    }
 }
diff --git a/lib/models/Courseware/PeerReview.php b/lib/models/Courseware/PeerReview.php
new file mode 100644
index 0000000000000000000000000000000000000000..40a684b2714da7d004cae63582907639e96c3d0f
--- /dev/null
+++ b/lib/models/Courseware/PeerReview.php
@@ -0,0 +1,128 @@
+<?php
+
+namespace Courseware;
+
+use Course;
+use DBManager;
+use Statusgruppen;
+use User;
+
+/**
+ * Courseware's peer review instances.
+ *
+ * @since   Stud.IP 5.5
+ */
+class PeerReview extends \SimpleORMap
+{
+    protected static function configure($config = [])
+    {
+        $config['db_table'] = 'cw_peer_reviews';
+
+        $config['serialized_fields']['assessment'] = 'JSONArrayObject';
+
+        $config['belongs_to']['process'] = [
+            'class_name' => PeerReviewProcess::class,
+            'foreign_key' => 'process_id',
+        ];
+        $config['belongs_to']['task'] = [
+            'class_name' => Task::class,
+            'foreign_key' => 'task_id',
+        ];
+        $config['belongs_to']['submitter'] = [
+            'class_name' => \User::class,
+            'foreign_key' => 'submitter_id',
+        ];
+        $config['belongs_to']['reviewer'] = [
+            'class_name' => \User::class,
+            'foreign_key' => 'reviewer_id',
+        ];
+
+        parent::configure($config);
+    }
+
+    /*
+    public static function findByUser(User $user): iterable
+    {
+        $assignments = DBManager::get()->fetchFirst('SELECT id FROM cw_peer_reviews WHERE reviewer_id = ?', [
+            $user->getId(),
+        ]);
+
+        $createdReviews = DBManager::get()->fetchFirst(
+            'SELECT id FROM cw_peer_reviews
+               WHERE process_id IN (
+                 SELECT id FROM cw_peer_review_processes
+                   WHERE owner_id = ?)',
+            [$user->getId()]
+        );
+
+        $ids = array_merge($assignments, $createdReviews);
+
+        return self::findMany($ids);
+    }
+    */
+
+    public static function findByCourse(Course $course): iterable
+    {
+        return self::findMany(self::findIdsByCourse($course->getId()));
+    }
+
+    private static function findIdsByCourse(string $courseId): iterable
+    {
+        return DBManager::get()->fetchFirst(
+            'SELECT id FROM cw_peer_reviews
+                   WHERE process_id in (
+                     SELECT id FROM cw_peer_review_processes
+                       WHERE task_group_id IN (
+                         SELECT id FROM cw_task_groups
+                           WHERE cw_task_groups.seminar_id = ?))',
+            [$courseId]
+        );
+    }
+
+    public function getCourse(): Course
+    {
+        return $this->process->getCourse();
+    }
+
+    public function isAnonymous(): bool
+    {
+        return $this->process->isAnonymous();
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.StaticAccess)
+     */
+    public function isReviewer(\User $user): bool
+    {
+        switch ($this->reviewer_type) {
+            case 'autor':
+                return $this->reviewer_id === $user->getId();
+            case 'group':
+                return \Statusgruppen::isMemberOf($this->reviewer_id, $user->getId());
+        }
+    }
+
+    public function getReviewer()
+    {
+        switch ($this->reviewer_type) {
+            case 'autor':
+                return User::find($this->reviewer_id);
+            case 'group':
+                return Statusgruppen::find($this->reviewer_id);
+        }
+    }
+
+    /**
+     * @return User|Statusgruppen
+     */
+    public function getSubmitter()
+    {
+        $user = User::find($this->submitter_id);
+        if ($user) {
+            return $user;
+        }
+
+        $statusGroup = Statusgruppen::find($this->submitter_id);
+        return $statusGroup;
+    }
+}
diff --git a/lib/models/Courseware/PeerReviewProcess.php b/lib/models/Courseware/PeerReviewProcess.php
new file mode 100644
index 0000000000000000000000000000000000000000..51c3c848aeeb1770f677472239009030370e5d99
--- /dev/null
+++ b/lib/models/Courseware/PeerReviewProcess.php
@@ -0,0 +1,188 @@
+<?php
+
+namespace Courseware;
+
+use Course;
+use DBManager;
+use SimpleORMapCollection;
+use User;
+
+/**
+ * A PeerReviewProcess groups a set of PeerReviews.
+ *
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ *
+ * @since   Stud.IP 5.5
+ */
+class PeerReviewProcess extends \SimpleORMap
+{
+    public const DEFAULT_DURATION = 7;
+
+    public const STATE_BEFORE = 'before';
+    public const STATE_ACTIVE = 'active';
+    public const STATE_AFTER = 'after';
+
+    protected static function configure($config = [])
+    {
+        $config['db_table'] = 'cw_peer_review_processes';
+
+        $config['serialized_fields']['configuration'] = 'JSONArrayObject';
+
+        $config['belongs_to']['task_group'] = [
+            'class_name' => TaskGroup::class,
+            'foreign_key' => 'task_group_id',
+        ];
+        $config['belongs_to']['owner'] = [
+            'class_name' => User::class,
+            'foreign_key' => 'owner_id',
+        ];
+
+        $config['additional_fields']['peer_reviews'] = [
+            'get' => 'getPeerReviews',
+            'set' => false,
+        ];
+
+        $config['has_many']['_peer_reviews'] = [
+            'class_name' => PeerReview::class,
+            'assoc_foreign_key' => 'process_id',
+            'on_delete' => 'delete',
+            'on_store' => 'store',
+            'order_by' => 'ORDER BY mkdate',
+        ];
+
+        parent::configure($config);
+    }
+
+    public static function findByCourse(Course $course): iterable
+    {
+        return self::findBySQL('task_group_id IN (?) ORDER BY mkdate', [
+            DBManager::get()->fetchFirst('SELECT id FROM `cw_task_groups` WHERE seminar_id = ?', [$course->getId()]),
+        ]);
+    }
+
+    public static function findByUser(User $user): iterable
+    {
+        return self::findMany(
+            DBManager::get()->fetchFirst(
+                'SELECT id FROM cw_peer_review_processes
+                   WHERE task_group_id IN (
+                     SELECT id FROM cw_task_groups
+                       WHERE cw_task_groups.seminar_id IN (
+                         SELECT seminar_id FROM seminar_user WHERE user_id = ?))',
+                [$user->getId()]
+            )
+        );
+    }
+
+    public function getCourse(): Course
+    {
+        return $this->task_group->course;
+    }
+
+    public function getPeerReviews(): SimpleORMapCollection
+    {
+        $this->checkAutomaticPairing();
+
+        return SimpleORMapCollection::createFromArray(
+            PeerReview::findBySql('process_id = ? ORDER BY mkdate', [$this->getId()])
+        );
+    }
+
+    public function getDuration(): int
+    {
+        if (!isset($this->configuration['duration'])) {
+            return self::DEFAULT_DURATION;
+        }
+
+        return (int) $this->configuration['duration'];
+    }
+
+    public function isAnonymous(): bool
+    {
+        if (!isset($this->configuration['anonymous'])) {
+            return true;
+        }
+
+        return (bool) $this->configuration['automaticPairing'];
+    }
+
+    public function isAutomaticPairing(): bool
+    {
+        if (!isset($this->configuration['automaticPairing'])) {
+            return true;
+        }
+
+        return (bool) $this->configuration['automaticPairing'];
+    }
+
+    public function getCurrentState(int $date = null): string
+    {
+        if (is_null($date)) {
+            $date = time();
+        }
+
+        if ($this->review_end < $date) {
+            return self::STATE_AFTER;
+        }
+
+        if ($date < $this->review_start) {
+            return self::STATE_BEFORE;
+        }
+
+        return self::STATE_ACTIVE;
+    }
+
+    public function checkAutomaticPairing(): void
+    {
+        if ($this->isAutomaticPairing() && !$this->paired_at) {
+            $now = time();
+            if ($now > $this->review_start) {
+                $this->createAutomaticPairings();
+                $this->content['paired_at'] = $now;
+                $this->content_db['paired_at'] = $now;
+                $stmt = \DBManager::get()->prepare(
+                    'UPDATE `' . $this->db_table() . '` SET `paired_at` = ? WHERE id = ?'
+                );
+                $stmt->execute([$now, $this->getId()]);
+            }
+        }
+    }
+
+    public function createAutomaticPairings(): iterable
+    {
+        $taskGroup = $this->task_group;
+        $submitters = $taskGroup->getSubmitters();
+
+        if (count($submitters) < 2) {
+            return [];
+        }
+
+        shuffle($submitters);
+        $copy = $submitters;
+        array_push($copy, array_shift($copy));
+        $pairings = array_map(null, $submitters, $copy);
+
+        return array_map(function ($pairing) use ($taskGroup) {
+            list($submitter, $reviewer) = $pairing;
+            $task = $taskGroup->findTaskBySolver($submitter);
+
+            return PeerReview::create([
+                'process_id' => $this->getId(),
+                'task_id' => $task->getId(),
+                'submitter_id' => $submitter->getId(),
+                'reviewer_id' => $reviewer->getId(),
+                'reviewer_type' => $reviewer instanceof User ? 'autor' : 'group',
+            ]);
+        }, $pairings);
+    }
+
+    public function rescheduleTo(int $newStartDate): void
+    {
+        $newEndDate = $newStartDate + $this->getDuration() * (24 * 60 * 60);
+        $this->setData([
+            "review_start" => $newStartDate,
+            "review_end" => $newEndDate,
+        ]);
+        $this->store();
+    }
+}
diff --git a/lib/models/Courseware/StructuralElement.php b/lib/models/Courseware/StructuralElement.php
index 940547ec65cde61e5cbfbafa625af8185ad80b22..a8b342524841297cae4ff480c78bbc7c58a6ece7 100644
--- a/lib/models/Courseware/StructuralElement.php
+++ b/lib/models/Courseware/StructuralElement.php
@@ -389,7 +389,7 @@ class StructuralElement extends \SimpleORMap implements \PrivacyObject
                         return true;
                     }
 
-                    return $task->userIsASolver($user);
+                    return $task->userIsASolver($user) || $task->userIsAPeerReviewer($user);
                 }
 
                 if ($this->canEdit($user)) {
diff --git a/lib/models/Courseware/Task.php b/lib/models/Courseware/Task.php
index 588bdbec6c91366cbd8c22ba3cc8ce9e65b41554..ef53334279c0a1da6dc8d3fb0f0b8a753af0e2c3 100644
--- a/lib/models/Courseware/Task.php
+++ b/lib/models/Courseware/Task.php
@@ -78,6 +78,14 @@ class Task extends \SimpleORMap
             'foreign_key' => 'feedback_id',
         ];
 
+        $config['has_many']['peer_reviews'] = [
+            'class_name' => PeerReview::class,
+            'assoc_foreign_key' => 'task_id',
+            'on_delete' => 'delete',
+            'on_store' => 'store',
+            'order_by' => 'ORDER BY mkdate',
+        ];
+
         $config['additional_fields']['solver'] = [
             'get' => 'getSolver',
             'set' => false,
@@ -161,6 +169,14 @@ class Task extends \SimpleORMap
         return false;
     }
 
+    /**
+     * @param \User|\Seminar_User $user
+     */
+    public function userIsAPeerReviewer($user): bool
+    {
+        return $this->isPeerReviewed() && $this->isPeerReviewedBy($user);
+    }
+
     /**
      * @return \User|\Statusgruppen|null the solver
      */
@@ -235,6 +251,32 @@ class Task extends \SimpleORMap
         $this->store();
     }
 
+    public function isPeerReviewed(): bool
+    {
+        return PeerReview::countBySql('task_id = ?', [$this->getId()]) !== 0;
+    }
+
+    /**
+     * @param \User|\Seminar_User $user
+     */
+    public function isPeerReviewedBy($user): bool
+    {
+        $userId = $user->getId();
+        $sql = 'task_id = ? AND reviewer_id = ? AND reviewer_type = "autor"';
+        if (PeerReview::countBySql($sql, [$this->getId(), $userId]) !== 0) {
+            return true;
+        }
+
+        $sql = 'SELECT reviewer_id FROM cw_peer_reviews WHERE task_id = ? AND reviewer_type = "group"';
+        foreach (\DBManager::get()->fetchFirst($sql, [$this->getId()]) as $reviewerId) {
+            if (\Statusgruppen::isMemberOf($reviewerId, $userId)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
     private function getStructuralElementProgress(StructuralElement $structural_element): float
     {
         $containers = Container::findBySQL('structural_element_id = ?', [intval($structural_element->id)]);
diff --git a/lib/models/Courseware/TaskGroup.php b/lib/models/Courseware/TaskGroup.php
index 4c7d447ede1cbd731453a0b42a4c6181306d2688..1e76ac35e1730fda6dc3b90441eee8f768d8fe63 100644
--- a/lib/models/Courseware/TaskGroup.php
+++ b/lib/models/Courseware/TaskGroup.php
@@ -27,6 +27,7 @@ use User;
  * @property \Course $course belongs_to \Course
  * @property \Courseware\StructuralElement $target belongs_to Courseware\StructuralElement
  * @property \SimpleORMapCollection $tasks has_many Courseware\Task
+ * @property \SimpleORMapCollection $peer_review_processes has_many Courseware\PeerReviewProcess
  *
  * @SuppressWarnings(PHPMD.StaticAccess)
  */
@@ -59,6 +60,16 @@ class TaskGroup extends \SimpleORMap implements \PrivacyObject
             'order_by' => 'ORDER BY mkdate',
         ];
 
+        $config['has_many']['peer_review_processes'] = [
+            'class_name' => PeerReviewProcess::class,
+            'assoc_foreign_key' => 'task_group_id',
+            'on_delete' => 'delete',
+            'on_store' => 'store',
+            'order_by' => 'ORDER BY mkdate',
+        ];
+
+        $config['registered_callbacks']['after_store'][] = 'cbAfterStore';
+
         parent::configure($config);
     }
 
@@ -100,6 +111,12 @@ class TaskGroup extends \SimpleORMap implements \PrivacyObject
             }
         );
     }
+
+    public function hasPeerReviewProcesses(): bool
+    {
+        return PeerReviewProcess::countBySql('task_group_id = ?', [$this->getId()]) > 0;
+    }
+
     /**
      * @param User|Statusgruppen $solver
      *
@@ -119,4 +136,19 @@ class TaskGroup extends \SimpleORMap implements \PrivacyObject
         return empty($row) ? null : Task::find($row['id']);
     }
 
+    public function cbAfterStore(): void
+    {
+        if ($this->isFieldDirty('end_date')) {
+            $this->reschedulePeerReviewProcesses();
+        }
+    }
+
+    private function reschedulePeerReviewProcesses(): void
+    {
+        if ($this->hasPeerReviewProcesses()) {
+            foreach ($this->peer_review_processes as $process) {
+                $process->rescheduleTo($this->end_date);
+            }
+        }
+    }
 }
diff --git a/lib/modules/CoursewareModule.class.php b/lib/modules/CoursewareModule.class.php
index 6766f8ce387f4aa4cb0230e1b767c998240c588b..e48f8b991885d761c502d06a8851bc798db15531 100644
--- a/lib/modules/CoursewareModule.class.php
+++ b/lib/modules/CoursewareModule.class.php
@@ -71,6 +71,10 @@ class CoursewareModule extends CorePlugin implements SystemPlugin, StudipModule
             'tasks',
             new Navigation(_('Aufgaben'), 'dispatch.php/course/courseware/tasks?cid=' . $courseId)
         );
+        $navigation->addSubNavigation(
+            'peer-review',
+            new Navigation(_('Peer-Reviews'), 'dispatch.php/course/courseware/tasks/peer-review-processes?cid=' . $courseId)
+        );
         $navigation->addSubNavigation(
             'comments',
             new Navigation(_('Kommentare und Feedback'), 'dispatch.php/course/courseware/comments_overview?cid=' . $courseId)
diff --git a/resources/vue/components/StudipArticle.vue b/resources/vue/components/StudipArticle.vue
new file mode 100644
index 0000000000000000000000000000000000000000..e91f69d39c4c371611719c52beb0a1e240138fa1
--- /dev/null
+++ b/resources/vue/components/StudipArticle.vue
@@ -0,0 +1,63 @@
+<template>
+    <article class="studip" :class="{ collapsable, collapsed }" v-bind="$attrs">
+        <header>
+            <h1 @click="doToggle">
+                <template v-if="collapsable">
+                    <StudipIcon class="studip-articles--icon" shape="arr_1right" v-if="collapsed" />
+                    <StudipIcon class="studip-articles--icon" shape="arr_1down" v-else />
+                </template>
+                <slot name="title" v-bind="{ isOpen: collapsed }"></slot>
+            </h1>
+            <slot v-if="$slots.titleplus" name="titleplus"></slot>
+        </header>
+        <section v-if="!collapsed">
+            <slot name="body"></slot>
+        </section>
+        <footer v-if="$slots.footer">
+            <slot name="footer"></slot>
+        </footer>
+    </article>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import StudipIcon from './StudipIcon.vue';
+
+export default Vue.extend({
+    props: {
+        collapsable: {
+            type: Boolean,
+            default: false,
+        },
+        closed: {
+            type: Boolean,
+            default: false,
+        },
+    },
+    components: { StudipIcon },
+    data() {
+        return { collapsed: this.closed };
+    },
+    methods: {
+        doToggle() {
+            if (this.collapsable) {
+                this.collapsed = !this.collapsed;
+            }
+        },
+    },
+});
+</script>
+<style scoped>
+article.studip.collapsable.collapsed {
+    padding-block-end: 0;
+}
+article.studip.collapsable.collapsed > header {
+    margin-block-end: 0;
+}
+article.studip.collapsable > header > h1 {
+    cursor: pointer;
+}
+
+.studip-articles--icon {
+}
+</style>
diff --git a/resources/vue/components/StudipContentBox.vue b/resources/vue/components/StudipContentBox.vue
new file mode 100644
index 0000000000000000000000000000000000000000..ab5bf9584c20ff267e6b0cba472940af5a0b1933
--- /dev/null
+++ b/resources/vue/components/StudipContentBox.vue
@@ -0,0 +1,46 @@
+<template>
+    <section class="contentbox">
+        <header v-if="title || $slots.header">
+            <slot name="header">
+                <h1>
+                    <StudipIcon v-if="icon" :shape="icon" />
+                    {{ title }}
+                </h1>
+            </slot>
+            <slot name="header-nav">
+                <nav v-if="items">
+                    <StudipActionMenu :items="items" />
+                </nav>
+            </slot>
+        </header>
+
+        <slot></slot>
+
+        <footer>
+            <slot name="footer"></slot>
+        </footer>
+    </section>
+</template>
+
+<script>
+import StudipActionMenu from './StudipActionMenu.vue';
+import StudipIcon from './StudipIcon.vue';
+
+export default {
+    components: { StudipActionMenu, StudipIcon },
+    props: {
+        icon: {
+            type: String,
+            required: false,
+        },
+        items: {
+            type: Array,
+            required: false,
+        },
+        title: {
+            type: String,
+            required: true,
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/StudipUserAvatar.vue b/resources/vue/components/StudipUserAvatar.vue
new file mode 100644
index 0000000000000000000000000000000000000000..eecd4a7b228afb9746ffa842306c8a2c163978f7
--- /dev/null
+++ b/resources/vue/components/StudipUserAvatar.vue
@@ -0,0 +1,32 @@
+<template>
+    <div class="studip-user-avatar">
+        <span>
+            <img :src="avatarUrl" />
+        </span>
+        <span>{{ formattedName }}</span>
+    </div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+export default Vue.extend({
+    props: {
+        avatarUrl: {
+            type: String,
+            required: true,
+        },
+        formattedName: {
+            type: String,
+            required: true,
+        },
+    },
+});
+</script>
+
+<style scoped>
+.studip-user-avatar {
+    align-items: center;
+    display: flex;
+    gap: 0.25rem;
+}
+</style>
diff --git a/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue b/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue
index dba1917eea9e0c1fc28b7caf02621e196b2c7f0d..1bb2a51fd27c9c4b391f1579cc7bf8f9223d35a8 100644
--- a/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue
+++ b/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue
@@ -34,8 +34,10 @@
                                 :title="structuralElement.attributes.title"
                             >
                                 <span>{{ structuralElement.attributes.title || "–" }}</span>
-                                <span v-if="isTask">[ {{ solverName }} ]</span>
-                                <template v-if="!userIsTeacher && inCourse">
+                                <span v-if="task">
+                                    [ {{ (userIsReviewer && isPeerReviewAnonymous) ? $gettext('anonym') : solverName }} ]
+                                </span>
+                                <template v-if="inCourse && !(userIsTeacher || userIsReviewer)">
                                     <studip-icon
                                         v-if="complete"
                                         shape="accept"
@@ -102,6 +104,18 @@
                                 </button>
                             </template>
                         </courseware-companion-box>
+                        <courseware-companion-box
+                            v-for="peerReview in peerReviews"
+                            :key="peerReview.id"
+                            mood="pointing"
+                            :msgCompanion="$gettext('Sie begutachten diese Seite im Rahmen eines Peer-Reviews.')"
+                            >
+                            <template #companionActions>
+                                <button class="button" @click="openPeerReviewForm(peerReview)">
+                                    {{ $gettext('Gutachten öffnen') }}
+                                </button>
+                            </template>
+                        </courseware-companion-box>
                         <courseware-empty-element-box
                             v-if="showEmptyElementBox"
                             :canEdit="canEdit"
@@ -630,6 +644,11 @@
                 <courseware-structural-element-dialog-import v-if="showImportDialog"/>
                 <courseware-structural-element-dialog-copy v-if="showCopyDialog" />
                 <courseware-structural-element-dialog-link v-if="showLinkDialog"/>
+                <PeerReviewAssessmentDialog
+                    v-model="showPeerReviewForm"
+                    v-if="selectedPeerReview"
+                    :review="selectedPeerReview"
+                    />
             </div>
             <div v-else>
                 <courseware-companion-box
@@ -659,6 +678,7 @@ import CoursewareStructuralElementDiscussion from './CoursewareStructuralElement
 import CoursewareStructuralElementPermissions from './CoursewareStructuralElementPermissions.vue';
 import CoursewareContentPermissions from '../CoursewareContentPermissions.vue';
 import CoursewareWelcomeScreen from './CoursewareWelcomeScreen.vue';
+import PeerReviewAssessmentDialog from '../tasks/peer-review/AssessmentDialog.vue';
 import CoursewareExport from '@/vue/mixins/courseware/export.js';
 import CoursewareOerMessage from '@/vue/mixins/courseware/oermessage.js';
 import colorMixin from '@/vue/mixins/courseware/colors.js';
@@ -681,6 +701,7 @@ export default {
         CoursewareContentPermissions,
         CoursewareWelcomeScreen,
         CoursewareDateInput,
+        PeerReviewAssessmentDialog,
         StockImageSelector,
         StudipDialog,
         draggable,
@@ -754,6 +775,8 @@ export default {
             uploadImageURL: null,
             showStockImageSelector: false,
             selectedStockImage: null,
+            showPeerReviewForm: false,
+            selectedPeerReview: null,
         };
     },
 
@@ -764,6 +787,8 @@ export default {
             consumeMode: 'consumeMode',
             containerById: 'courseware-containers/byId',
             relatedContainers: 'courseware-containers/related',
+            relatedPeerReviewProcesses: 'courseware-peer-review-processes/related',
+            relatedPeerReviews: 'courseware-peer-reviews/related',
             relatedStructuralElements: 'courseware-structural-elements/related',
             relatedTaskGroups: 'courseware-task-groups/related',
             relatedUsers: 'users/related',
@@ -1229,7 +1254,28 @@ export default {
                 'dispatch.php/course/courseware/courseware/' + this.context.unit,
                 {cid: this.context.id}
             );
-        }
+        },
+        userIsReviewer() {
+            return this.task ? this.task.attributes['can-peer-review'] : false;
+        },
+        peerReviews() {
+            if (this.isTask && this.userIsReviewer) {
+                return this.relatedPeerReviews({
+                    parent: { id: this.task.id, type: this.task.type },
+                    relationship: 'peer-reviews',
+                });
+            }
+            return [];
+        },
+        isPeerReviewAnonymous() {
+            return this.peerReviews.every(({ id, type }) => {
+                const process = this.relatedPeerReviewProcesses({
+                    parent: { id, type },
+                    relationship: 'process',
+                });
+                return process.attributes.configuration.anonymous;
+            });
+        },
     },
 
     methods: {
@@ -1694,6 +1740,10 @@ export default {
             this.showStockImageSelector = false;
             this.deletingPreviewImage = false;
         },
+        openPeerReviewForm(peerReview) {
+            this.showPeerReviewForm = true;
+            this.selectedPeerReview = peerReview;
+        },
     },
     created() {
         this.pluginManager.registerComponentsLocally(this);
diff --git a/resources/vue/components/courseware/structural-element/CoursewareTreeItem.vue b/resources/vue/components/courseware/structural-element/CoursewareTreeItem.vue
index a4bfa35e48cc43cf2bdd3b55f230a7540be27996..49c6cbd16421d26635a2db993ddd1df2727c8b9c 100644
--- a/resources/vue/components/courseware/structural-element/CoursewareTreeItem.vue
+++ b/resources/vue/components/courseware/structural-element/CoursewareTreeItem.vue
@@ -32,7 +32,7 @@
                     <studip-icon shape="edit" />
                 </button>
 
-                <span v-if="task">| {{ solverName }}</span>
+                <span v-if="task">| {{ userIsReviewer ? $gettext("anonym") : solverName }}</span>
                 <span
                     v-if="hasReleaseOrWithdrawDate"
                     class="cw-tree-item-flag-date"
@@ -48,7 +48,7 @@
                     class="cw-tree-item-flag-cant-read"
                     :title="$gettext('Diese Seite kann von Teilnehmenden nicht gesehen werden')"
                 ></span>
-                <template v-if="!userIsTeacher && inCourse">
+                <template v-if="!(userIsTeacher || userIsReviewer)  && inCourse">
                     <span
                         v-if="complete"
                         class="cw-tree-item-sequential cw-tree-item-sequential-complete"
@@ -328,6 +328,9 @@ export default {
         complete() {
             return this.itemProgress === 100;
         },
+        userIsReviewer() {
+            return this.task ? this.task.attributes['can-peer-review'] : false;
+        },
     },
     methods: {
         ...mapActions({
diff --git a/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue b/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue
index ee45bd9be8fbe8856f91371a46943870db04b6fe..77499aba5f5dcff85c8db820053bc788944fb5cd 100644
--- a/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue
+++ b/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue
@@ -25,6 +25,7 @@
                 :taskGroup="taskGroup"
                 :tasks="tasksByGroup[taskGroup.id]"
                 @add-feedback="onShowAddFeedback"
+                @add-peer-review-process="onShowPeerReviewProcessCreate"
                 @edit-feedback="onShowEditFeedback"
                 @solve-renewal="onShowSolveRenewal"
             />
@@ -48,6 +49,13 @@
             @close="closeDialogs"
         />
 
+        <PeerReviewProcessCreateDialog
+            v-if="showPeerReviewProcessCreate"
+            :taskGroup="taskGroup"
+            @create="onCreatePeerReviewProcess"
+            @close="closeDialogs"
+        />
+
         <RenewalDialog
             v-if="renewalTask"
             :renewalDate="renewalDate"
@@ -70,6 +78,7 @@ import CoursewareRibbon from '../structural-element/CoursewareRibbon.vue';
 import CoursewareTasksActionWidget from '../widgets/CoursewareTasksActionWidget.vue';
 import CoursewareTasksDialogDistribute from './CoursewareTasksDialogDistribute.vue';
 import EditFeedbackDialog from './EditFeedbackDialog.vue';
+import PeerReviewProcessCreateDialog from './peer-review/ProcessCreateDialog.vue';
 import RenewalDialog from './RenewalDialog.vue';
 import TaskGroup from './TaskGroup.vue';
 import TaskGroupsDeleteDialog from './TaskGroupsDeleteDialog.vue';
@@ -83,6 +92,7 @@ export default {
         CoursewareTasksActionWidget,
         CoursewareTasksDialogDistribute,
         EditFeedbackDialog,
+        PeerReviewProcessCreateDialog,
         RenewalDialog,
         TaskGroup,
         TaskGroupsDeleteDialog,
@@ -94,6 +104,7 @@ export default {
             currentDialogFeedback: {},
             renewalTask: null,
             showAddFeedbackDialog: false,
+            showPeerReviewProcessCreate: null,
             showEditFeedbackDialog: false,
         };
     },
@@ -127,6 +138,7 @@ export default {
         ...mapActions({
             companionError: 'companionError',
             companionSuccess: 'companionSuccess',
+            createPeerReviewProcess: 'tasks/createPeerReviewProcess',
             createTaskFeedback: 'createTaskFeedback',
             deleteTaskFeedback: 'deleteTaskFeedback',
             loadAllTasks: 'courseware-tasks/loadAll',
@@ -137,6 +149,7 @@ export default {
         closeDialogs() {
             this.showAddFeedbackDialog = false;
             this.showEditFeedbackDialog = false;
+            this.showPeerReviewProcessCreate = false;
 
             this.currentDialogFeedback = {};
             this.renewalTask = null;
@@ -152,6 +165,11 @@ export default {
             this.createTaskFeedback({ taskFeedback: this.currentDialogFeedback });
             this.closeDialogs();
         },
+        onCreatePeerReviewProcess(options) {
+            this.createPeerReviewProcess({ taskGroup: this.taskGroup, options })
+                .then(() => this.loadTaskGroup(this.taskGroup))
+                .then(() => this.closeDialogs());
+        },
         onShowAddFeedback(task) {
             this.currentDialogFeedback = {
                 attributes: { content: '' },
@@ -170,6 +188,9 @@ export default {
             this.currentDialogFeedback = _.cloneDeep(feedback);
             this.showEditFeedbackDialog = true;
         },
+        onShowPeerReviewProcessCreate() {
+            this.showPeerReviewProcessCreate = true;
+        },
         onShowSolveRenewal(task) {
             this.renewalTask = _.cloneDeep(task);
             this.renewalTask.attributes['renewal-date'] = new Date().toISOString();
diff --git a/resources/vue/components/courseware/tasks/TaskGroup.vue b/resources/vue/components/courseware/tasks/TaskGroup.vue
index d7bf787b3bb92dda37d75edad8387bd4785ffbe3..36e0ffc80359df2a22954826e6477ec4256813f7 100644
--- a/resources/vue/components/courseware/tasks/TaskGroup.vue
+++ b/resources/vue/components/courseware/tasks/TaskGroup.vue
@@ -43,6 +43,62 @@
                     />
                 </tbody>
             </table>
+
+            <table class="default">
+                <caption>
+                    {{
+                        $gettext('Peer-Review-Verfahren')
+                    }}
+                    <span class="actions" v-if="false && peerReviewActionMenuItems.length">
+                        <StudipActionMenu
+                            :collapseAt="false"
+                            :context="actionMenuContext"
+                            :items="peerReviewActionMenuItems"
+                            @add-peer-review-process="() => $emit('add-peer-review-process', this.taskGroup)"
+                        />
+                    </span>
+                </caption>
+                <thead>
+                    <tr>
+                        <th>{{ $gettext('Status') }}</th>
+                        <th>{{ $gettext('Bearbeitungszeit') }}</th>
+                        <th>{{ $gettext('Einstellungen') }}</th>
+                    </tr>
+                </thead>
+                <tbody v-if="hasPeerReviewProcesses">
+                    <ProcessListItem
+                        v-for="(process, index) in peerReviewProcesses"
+                        :key="process.id"
+                        :process="process"
+                    />
+                </tbody>
+                <tbody v-else>
+                    <tr class="nohover">
+                        <td colspan="3">
+                            <CompanionBox
+                                v-if="isAfter"
+                                :msgCompanion="
+                                    $gettext('Für diese Aufgabe wurde kein Peer-Review-Verfahren aktiviert.')
+                                "
+                            >
+                            </CompanionBox>
+                            <CompanionBox
+                                v-else
+                                mood="pointing"
+                                :msgCompanion="
+                                    $gettext('Für diese Aufgabe wurde noch kein Peer-Review-Verfahren aktiviert.')
+                                "
+                            >
+                                <template #companionActions>
+                                    <button class="button" @click="$emit('add-peer-review-process', taskGroup)">
+                                        {{ $gettext('Peer-Review-Verfahren aktivieren') }}
+                                    </button>
+                                </template>
+                            </CompanionBox>
+                        </td>
+                    </tr>
+                </tbody>
+            </table>
         </section>
         <div v-else>
             <CompanionBox mood="pointing" :msgCompanion="$gettext('Diese Aufgabe wurde an niemanden verteilt.')" />
@@ -56,26 +112,49 @@ import CompanionBox from '../layouts/CoursewareCompanionBox.vue';
 import StudipActionMenu from '../../StudipActionMenu.vue';
 import StudipDate from '../../StudipDate.vue';
 import TaskItem from './TaskGroupTaskItem.vue';
+import ProcessListItem from './TaskGroupPeerReviewProcessListItem.vue';
 import { getStatus } from './task-groups-helper.js';
 
 export default {
-    components: { CompanionBox, StudipActionMenu, StudipDate, TaskItem },
+    components: { CompanionBox, ProcessListItem, StudipActionMenu, StudipDate, TaskItem },
     props: ['taskGroup', 'tasks'],
     computed: {
         ...mapGetters({
             coursewareContext: 'context',
+            relatedPeerReviewProcesses: 'courseware-peer-review-processes/related',
         }),
         actionMenuContext() {
             return this.$gettextInterpolate(this.$gettext('Courseware-Aufgabe "%{ taskGroup }"'), {
                 taskGroup: this.taskGroup.attributes.title,
             });
         },
+        canAddPeerReviewProcess() {
+            return this.startDate < new Date();
+        },
         endDate() {
             return new Date(this.taskGroup.attributes['end-date']);
         },
+        hasPeerReviewProcesses() {
+            return !!this.peerReviewProcesses;
+        },
         isAfter() {
             return new Date() > this.endDate;
         },
+        peerReviewActionMenuItems() {
+            return this.isAfter
+                ? []
+                : [
+                      {
+                          emit: 'add-peer-review-process',
+                          icon: 'add',
+                          id: 'add-peer-review-processes',
+                          label: this.$gettext('Peer-Review aktivieren'),
+                      },
+                  ];
+        },
+        peerReviewProcesses() {
+            return this.relatedPeerReviewProcesses({ parent: this.taskGroup, relationship: 'peer-review-processes' });
+        },
         startDate() {
             return new Date(this.taskGroup.attributes['start-date']);
         },
diff --git a/resources/vue/components/courseware/tasks/TaskGroupPeerReviewProcessListItem.vue b/resources/vue/components/courseware/tasks/TaskGroupPeerReviewProcessListItem.vue
new file mode 100644
index 0000000000000000000000000000000000000000..830a30fc40a16e2e74d15623bfcc0a13993edf33
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/TaskGroupPeerReviewProcessListItem.vue
@@ -0,0 +1,68 @@
+<template>
+    <tr>
+        <td>
+            <StudipIcon
+                v-if="status.shape !== undefined"
+                :shape="status.shape"
+                :role="status.role"
+                :title="status.description"
+                aria-hidden="true"
+            />
+            <span class="sr-only">{{ status.description }}</span>
+        </td>
+        <td>
+            <router-link :to="{ name: 'peer-review-processes-show', params: { id: process.id } }">
+                <StudipDate :date="startDate" />
+                –
+                <StudipDate :date="endDate" />
+            </router-link>
+        </td>
+        <td>
+            {{ process.attributes.configuration.anonymous ? $gettext('anonym') : $gettext('nicht anonym') }},
+            {{ reviewType }},
+            {{
+                process.attributes.configuration.automaticPairing
+                    ? $gettext('automatische Zuordnung')
+                    : $gettext('manuelle Zuordnung')
+            }}
+        </td>
+    </tr>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { mapGetters } from 'vuex';
+import StudipDate from '../../StudipDate.vue';
+import StudipIcon from '../../StudipIcon.vue';
+import { AssessmentType, ASSESSMENT_TYPES } from './peer-review/process-configuration';
+import { getProcessStatus, JsonApiSchema, StatusDescriptor } from './peer-review/definitions';
+
+export default Vue.extend({
+    props: {
+        process: {
+            required: true,
+            type: Object,
+        },
+    },
+    components: { StudipDate, StudipIcon },
+    computed: {
+        ...mapGetters({ coursewareContext: 'context' }),
+        cid(): string {
+            return this.coursewareContext.id;
+        },
+        endDate(): Date {
+            return new Date(this.process.attributes['review-end']);
+        },
+        reviewType(): string {
+            const type = <AssessmentType>this.process.attributes.configuration.type;
+            return ASSESSMENT_TYPES[type].short;
+        },
+        startDate(): Date {
+            return new Date(this.process.attributes['review-start']);
+        },
+        status(): StatusDescriptor {
+            return getProcessStatus(<JsonApiSchema>this.process);
+        },
+    },
+});
+</script>
diff --git a/resources/vue/components/courseware/tasks/peer-review/AssessmentDialog.vue b/resources/vue/components/courseware/tasks/peer-review/AssessmentDialog.vue
new file mode 100644
index 0000000000000000000000000000000000000000..d80a31c2b27f4e7e89699f597aba4370d892f2a7
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentDialog.vue
@@ -0,0 +1,91 @@
+<template>
+    <StudipDialog
+        v-if="show"
+        :title="$gettext('Aufgabe begutachten')"
+        :confirmText="$gettext('Speichern')"
+        confirmClass="accept"
+        :closeText="$gettext('Schließen')"
+        closeClass="cancel"
+        height="420"
+        @close="onClose"
+        @confirm="onConfirm"
+    >
+        <template #dialogContent>
+            <component v-bind:is="assessmentComponent" :process="process" :review="review" @answer="onAnswer"></component>
+        </template>
+    </StudipDialog>
+</template>
+
+<script>
+import AssessmentTypeForm from './AssessmentTypeForm.vue';
+import AssessmentTypeFreetext from './AssessmentTypeFreetext.vue';
+import AssessmentTypeTable from './AssessmentTypeTable.vue';
+import StudipDialog from '../../../StudipDialog.vue';
+import { mapActions, mapGetters } from 'vuex';
+
+export default {
+    model: {
+        prop: 'show',
+        event: 'updateShow',
+    },
+    components: {
+        AssessmentTypeForm,
+        StudipDialog,
+    },
+    props: {
+        show: {
+            type: Boolean,
+            required: true,
+        },
+        review: {
+            type: Object,
+            required: true,
+        },
+    },
+    data: () => ({
+        assessment: {}
+    }),
+    computed: {
+        ...mapGetters({
+            relatedProcess: 'courseware-peer-review-processes/related',
+        }),
+        assessmentComponent() {
+            switch (this.configuration?.type) {
+                case 'form':
+                    return AssessmentTypeForm;
+                case 'freetext':
+                    return AssessmentTypeFreetext;
+                case 'table':
+                    return AssessmentTypeTable;
+                default:
+                    return null;
+            }
+        },
+        configuration() {
+            return this.process?.attributes?.configuration ?? {};
+        },
+        process() {
+            return this.relatedProcess({
+                parent: { id: this.review.id, type: this.review.type },
+                relationship: 'process',
+            });
+        },
+    },
+    methods: {
+        ...mapActions({
+            storeAssessment: 'tasks/storeAssessment',
+        }),
+        onAnswer(assessment) {
+            this.assessment = assessment;
+        },
+        onClose() {
+            this.$emit('updateShow', false);
+            this.assessment = {};
+        },
+        onConfirm() {
+            this.$emit('updateShow', false);
+            this.storeAssessment({ review: this.review, assessment: this.assessment });
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditorDialog.vue b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditorDialog.vue
new file mode 100644
index 0000000000000000000000000000000000000000..d01fb39407311ea952951286203aaea84e60ff0d
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditorDialog.vue
@@ -0,0 +1,86 @@
+<template>
+    <StudipDialog
+        v-if="show && process"
+        :title="$gettext('Gutachten-Form ändern')"
+        :confirmText="$gettext('Speichern')"
+        confirmClass="accept"
+        :closeText="$gettext('Schließen')"
+        closeClass="cancel"
+        height="420"
+        width="800"
+        @close="onClose"
+        @confirm="onConfirm"
+    >
+        <template #dialogContent>
+            <component v-bind:is="editorComponent" v-model="payload"></component>
+        </template>
+    </StudipDialog>
+</template>
+
+<script>
+import { mapGetters } from 'vuex';
+import EditorForm from './EditorForm.vue';
+import EditorTable from './EditorTable.vue';
+import StudipDialog from '../../../StudipDialog.vue';
+import { ASSESSMENT_TYPES } from './process-configuration';
+
+export default {
+    model: {
+        prop: 'show',
+        event: 'updateShow',
+    },
+    components: {
+        StudipDialog,
+    },
+    props: {
+        show: {
+            type: Boolean,
+            required: true,
+        },
+        process: {
+            type: Object,
+            default: null,
+        },
+    },
+    data() {
+        return { localPayload: _.cloneDeep(this.payload) };
+    },
+    computed: {
+        ...mapGetters({}),
+        editorComponent() {
+            switch (this.configuration?.type) {
+                case 'form':
+                    return EditorForm;
+                case 'freetext':
+                    return null;
+                case 'table':
+                    return EditorTable;
+                default:
+                    return null;
+            }
+        },
+        configuration() {
+            return this.process?.attributes?.configuration ?? {};
+        },
+        defaultPayload() {
+            return ASSESSMENT_TYPES[this.configuration.type].defaultPayload ?? {};
+        },
+        payload: {
+            get() {
+                return _.isEmpty(this.configuration.payload) ? this.defaultPayload : this.configuration.payload;
+            },
+            set(payload) {
+                this.localPayload = payload;
+            },
+        },
+    },
+    methods: {
+        onClose() {
+            this.$emit('updateShow', false);
+        },
+        onConfirm(...args) {
+            this.$emit('update', _.cloneDeep(this.localPayload));
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeForm.vue b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeForm.vue
new file mode 100644
index 0000000000000000000000000000000000000000..c5f9da2c8fa8d072d1104df0ef199b3ee82b7506
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeForm.vue
@@ -0,0 +1,96 @@
+<template>
+    <article>
+        <form class="default studipform">
+            <div class="formpart" v-for="(criterium, index) in criteria" :key="index">
+                <LabelRequired
+                    :id="`assessment-type-form-${index}`"
+                    :label="criterium.text"
+                    />
+                <p>{{ criterium.description }}</p>
+                <textarea
+                    :id="`assessment-type-form-${index}`"
+                    required
+                    aria-required="true"
+                    v-model="answers[index]"
+                    @change="changeAnswers" />
+            </div>
+        </form>
+    </article>
+</template>
+<script lang="ts">
+import Vue, { PropType } from 'vue';
+import LabelRequired from '../../../forms/LabelRequired.vue';
+import { JsonApiSchema } from './definitions';
+import { ProcessConfiguration, FormAssessmentPayload } from './process-configuration';
+
+interface PeerReviewProcessSchema {
+    id?: string;
+    type: string;
+    meta?: any;
+    relationships?: any;
+    attributes: {
+        configuration: ProcessConfiguration;
+        'review-start': string;
+        'review-end': string;
+        mkdate: string;
+        chdate: string;
+    };
+}
+
+interface PeerReviewSchema {
+    id?: string;
+    type: string;
+    meta?: any;
+    relationships?: any;
+    attributes: {
+        assessment: {
+            answers: string[];
+        };
+        mkdate: string;
+        chdate: string;
+    };
+}
+
+export default Vue.extend({
+    components: { LabelRequired },
+    props: {
+        process: {
+            type: Object as PropType<PeerReviewProcessSchema>,
+            required: true,
+        },
+        review: {
+            type: Object as PropType<PeerReviewSchema>,
+            required: true,
+        },
+    },
+    data() {
+        return {
+            answers: this.review.attributes.assessment?.answers ?? [],
+        };
+    },
+    computed: {
+        criteria() {
+            const payload = <FormAssessmentPayload>this.process.attributes.configuration.payload;
+            return payload.criteria ?? [];
+        },
+    },
+    methods: {
+        changeAnswers() {
+            const answers = this.criteria.map((_, index) => this.answers[index] ?? '');
+            this.$emit('answer', { answers });
+        },
+    },
+});
+</script>
+
+<style scoped>
+textarea {
+    min-height: 5em;
+    max-width: 48em;
+    width: 100%;
+}
+
+.formpart + .formpart {
+    margin-block-start: 1rem;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeFreetext.vue b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeFreetext.vue
new file mode 100644
index 0000000000000000000000000000000000000000..843b40820ee066220601dbd87da02e3bab1e19c3
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeFreetext.vue
@@ -0,0 +1,89 @@
+<template>
+    <article>
+        <form class="default studipform">
+            <div class="formpart">
+                <LabelRequired
+                    id="assessment-type-freetext"
+                    :label="$gettext('Bewertung')"
+                    />
+                <textarea
+                    id="assessment-type-freetext"
+                    required
+                    aria-required="true"
+                    v-model="answer"
+                    @change="changeAnswer" />
+            </div>
+        </form>
+    </article>
+</template>
+<script lang="ts">
+import Vue, { PropType } from 'vue';
+import LabelRequired from '../../../forms/LabelRequired.vue';
+import { JsonApiSchema } from './definitions';
+import { ProcessConfiguration } from './process-configuration';
+
+interface PeerReviewProcessSchema {
+    id?: string;
+    type: string;
+    meta?: any;
+    relationships?: any;
+    attributes: {
+        configuration: ProcessConfiguration;
+        'review-start': string;
+        'review-end': string;
+        mkdate: string;
+        chdate: string;
+    };
+}
+
+interface PeerReviewSchema {
+    id?: string;
+    type: string;
+    meta?: any;
+    relationships?: any;
+    attributes: {
+        assessment: {
+            answer: string;
+        };
+        mkdate: string;
+        chdate: string;
+    };
+}
+
+export default Vue.extend({
+    components: { LabelRequired },
+    props: {
+        process: {
+            type: Object as PropType<PeerReviewProcessSchema>,
+            required: true,
+        },
+        review: {
+            type: Object as PropType<PeerReviewSchema>,
+            required: true,
+        },
+    },
+    data() {
+        return {
+            answer: this.review.attributes.assessment?.answer ?? "",
+        };
+    },
+    methods: {
+        changeAnswer() {
+            const answer = this.answer ?? '';
+            this.$emit('answer', { answer });
+        },
+    },
+});
+</script>
+
+<style scoped>
+textarea {
+    min-height: 5em;
+    max-width: 48em;
+    width: 100%;
+}
+
+.formpart + .formpart {
+    margin-block-start: 1rem;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeTable.vue b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeTable.vue
new file mode 100644
index 0000000000000000000000000000000000000000..d67be8b353ceb3e107b322e599023e324ead3e8a
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeTable.vue
@@ -0,0 +1,138 @@
+<template>
+    <article>
+        <form class="default studipform">
+            <div class="formpart" v-for="(criterium, index) in criteria" :key="index">
+                <LabelRequired :id="`assessment-type-table-${index}`" :label="criterium.text" />
+                <section>
+                    <textarea
+                        :id="`assessment-type-table-${index}`"
+                        required
+                        aria-required="true"
+                        v-model="answers[index].text"
+                        @change="changeAnswers"
+                    />
+
+                    <div>
+                        <label v-for="(text, rating) in ratingLevels" :key="text"
+                            ><input
+                                v-model="answers[index].rating"
+                                :name="`rating-${index}`"
+                                type="radio"
+                                :value="rating + 1"
+                                @change="changeAnswers"
+                            />{{ text }}</label
+                        >
+                    </div>
+                </section>
+            </div>
+        </form>
+    </article>
+</template>
+<script lang="ts">
+import Vue, { PropType } from 'vue';
+import { $gettext } from '../../../../../assets/javascripts/lib/gettext';
+import LabelRequired from '../../../forms/LabelRequired.vue';
+import { JsonApiSchema } from './definitions';
+import { EditorTableCriterium, ProcessConfiguration, TableAssessmentPayload } from './process-configuration';
+
+interface PeerReviewProcessSchema {
+    id?: string;
+    type: string;
+    meta?: any;
+    relationships?: any;
+    attributes: {
+        configuration: ProcessConfiguration;
+        'review-start': string;
+        'review-end': string;
+        mkdate: string;
+        chdate: string;
+    };
+}
+
+type Answer = {
+    text: string;
+    rating: number;
+};
+
+type Assessment = {
+    answers: Answer[];
+};
+
+interface PeerReviewSchema {
+    id?: string;
+    type: string;
+    meta?: any;
+    relationships?: any;
+    attributes: {
+        assessment: Assessment;
+        mkdate: string;
+        chdate: string;
+    };
+}
+
+const emptyAssessment = (criteria: EditorTableCriterium[]): Assessment => {
+    return {
+        answers: criteria.map((_) => ({ text: '', rating: 0 })),
+    };
+};
+
+export default Vue.extend({
+    components: { LabelRequired },
+    props: {
+        process: {
+            type: Object as PropType<PeerReviewProcessSchema>,
+            required: true,
+        },
+        review: {
+            type: Object as PropType<PeerReviewSchema>,
+            required: true,
+        },
+    },
+    data() {
+        return {
+            answers: [] as Answer[],
+        };
+    },
+    computed: {
+        criteria(): EditorTableCriterium[] {
+            const payload = <TableAssessmentPayload>this.process.attributes.configuration.payload;
+            return payload.criteria ?? [];
+        },
+        ratingLevels() {
+            return [$gettext('gut'), $gettext('ok'), $gettext('schwach')];
+        },
+    },
+    methods: {
+        changeAnswers() {
+            this.$emit('answer', { answers: this.answers });
+        },
+    },
+    beforeMount() {
+        if (this.review.attributes.assessment && 'answers' in this.review.attributes.assessment) {
+            this.answers = this.review.attributes.assessment.answers;
+        } else {
+            this.answers = emptyAssessment(this.criteria).answers;
+        }
+    },
+});
+</script>
+
+<style scoped>
+textarea {
+    min-height: 5em;
+    max-width: 48em;
+    width: 100%;
+}
+
+.formpart + .formpart {
+    margin-block-start: 1rem;
+}
+
+.formpart > section {
+    display: flex;
+}
+
+.formpart > section label {
+    white-space: nowrap;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/EditorForm.vue b/resources/vue/components/courseware/tasks/peer-review/EditorForm.vue
new file mode 100644
index 0000000000000000000000000000000000000000..8a8b7c49474cdcff92920a861dbdab6615e0be90
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/EditorForm.vue
@@ -0,0 +1,153 @@
+<template>
+    <CoursewareTabs>
+        <CoursewareTab :name="$gettext('Editor')" :index="0" selected class="cw-peer-review-editor-form--editor">
+            <form class="default studipform">
+                <StudipArticle v-for="(criterium, index) in localCriteria" :key="index" collapsable>
+                    <template v-slot:title="{ isOpen }">
+                        <template v-if="isOpen">
+                            {{
+                                $gettextInterpolate($gettext('Kriterium %{ index }: "%{ text }"'), {
+                                    index: index + 1,
+                                    text: criterium.text,
+                                })
+                            }}
+                        </template>
+                        <template v-else>
+                            {{ $gettextInterpolate($gettext('Kriterium %{ index }'), { index: index + 1 }) }}
+                        </template>
+                    </template>
+                    <template #titleplus>
+                        <StudipActionMenu :items="actionItems(index)" :collapseAt="2" @trash="removeLine" />
+                    </template>
+                    <template #body>
+                        <div class="formpart criterium-text">
+                            <LabelRequired :id="`editor-form-text-${index}`" :label="$gettext('Kriterium')" />
+                            <input
+                                :id="`editor-form-text-${index}`"
+                                type="text"
+                                v-model="criterium.text"
+                                required
+                                aria-required="true"
+                            />
+                        </div>
+                        <div class="formpart criterium-description">
+                            <LabelRequired :id="`editor-form-description-${index}`" :label="$gettext('Beschreibung')" />
+                            <textarea
+                                :id="`editor-form-description-${index}`"
+                                v-model="criterium.description"
+                                required
+                                aria-required="true"
+                            ></textarea>
+                        </div>
+                    </template>
+                </StudipArticle>
+                <div class="formpart">
+                    <button class="button add" type="button" @click="addLine">
+                        <span>{{ $gettext('Kriterium hinzufügen') }}</span>
+                    </button>
+                </div>
+            </form>
+        </CoursewareTab>
+        <CoursewareTab :name="$gettext('Vorschau')" :index="1" class="cw-peer-review-editor-form--preview">
+            <article>
+                <section v-for="(criterium, index) in nonEmptyCriteria" :key="index">
+                    <strong>{{ criterium.text }}</strong>
+                    <p>{{ criterium.description }}</p>
+                    <textarea disabled />
+                </section>
+            </article>
+        </CoursewareTab>
+    </CoursewareTabs>
+</template>
+<script lang="ts">
+import Vue, { PropType } from 'vue';
+import StudipActionMenu from '../../../StudipActionMenu.vue';
+import StudipArticle from '../../../StudipArticle.vue';
+import LabelRequired from '../../../forms/LabelRequired.vue';
+import CoursewareTab from '../../layouts/CoursewareTab.vue';
+import CoursewareTabs from '../../layouts/CoursewareTabs.vue';
+import { EditorFormCriterium, FormAssessmentPayload } from './process-configuration';
+
+export default Vue.extend({
+    components: { CoursewareTab, CoursewareTabs, LabelRequired, StudipActionMenu, StudipArticle },
+    props: {
+        payload: {
+            type: Object as PropType<FormAssessmentPayload>,
+        },
+    },
+    model: {
+        prop: 'payload',
+        event: 'save',
+    },
+    data: () => ({ localCriteria: [] as EditorFormCriterium[] }),
+    computed: {
+        criteria() {
+            return this.payload.criteria;
+        },
+        nonEmptyCriteria() {
+            return this.localCriteria.filter(({ text }) => text.trim().length);
+        },
+    },
+    methods: {
+        actionItems(index: number) {
+            return this.localCriteria.length > 1
+                ? [
+                      {
+                          id: 1,
+                          label: this.$gettext('Kriterium entfernen'),
+                          icon: 'trash',
+                          emit: 'trash',
+                          emitArguments: [index],
+                      },
+                  ]
+                : [];
+        },
+        addLine() {
+            this.localCriteria.push({ text: '', description: '' });
+        },
+        removeLine(lineNumber: number) {
+            this.localCriteria = this.localCriteria.filter((item, index) => index !== lineNumber);
+        },
+        resetLocalState() {
+            this.localCriteria = this.criteria.map(({ text, description }) => ({ text, description }));
+        },
+    },
+    mounted() {
+        this.resetLocalState();
+    },
+    watch: {
+        payload() {
+            this.resetLocalState();
+        },
+        localCriteria: {
+            handler() {
+                this.$emit('save', { criteria: this.nonEmptyCriteria.map((c) => ({ ...c })) });
+            },
+            deep: true,
+        },
+    },
+});
+</script>
+
+<style scoped>
+.cw-peer-review-editor-form--editor form input {
+    max-width: 48em;
+}
+
+textarea {
+    min-height: 5em;
+    max-width: 48em;
+    width: 100%;
+}
+
+.cw-peer-review-editor-form--preview > article {
+    display: flex;
+    flex-direction: column;
+    gap: 1rem;
+}
+
+.cw-peer-review-editor-form--preview > article > * + * {
+    border-top: 1px solid var(--light-gray-color-40);
+    padding-block-start: 1rem;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/EditorTable.vue b/resources/vue/components/courseware/tasks/peer-review/EditorTable.vue
new file mode 100644
index 0000000000000000000000000000000000000000..351e96f39d6f6e849042fa58c50e2c144a52a46f
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/EditorTable.vue
@@ -0,0 +1,133 @@
+<template>
+    <CoursewareTabs>
+        <CoursewareTab :name="$gettext('Editor')" :index="0" selected class="cw-peer-review-editor-table-editor">
+            <form class="studip studipform">
+                <div class="formpart" v-for="(criterium, index) in localCriteria" :key="index">
+                    <LabelRequired :id="`editor-table-text-${index}`" :label="$gettext('Kriterium')" class="sr-only" />
+                    <input
+                        :id="`editor-table-text-${index}`"
+                        type="text"
+                        v-model="criterium.text"
+                        required
+                        aria-required="true"
+                    />
+                    <button
+                        class="button trash"
+                        type="button"
+                        @click="removeLine(index)"
+                        :disabled="criteria.length === 1"
+                    >
+                        <span class="sr-only">{{ $gettext('Kriterium entfernen') }}</span>
+                    </button>
+                </div>
+                <div class="formpart">
+                    <button class="button add" type="button" @click="addLine">
+                        <span>{{ $gettext('Kriterium hinzufügen') }}</span>
+                    </button>
+                </div>
+            </form>
+        </CoursewareTab>
+        <CoursewareTab :name="$gettext('Vorschau')" :index="1" class="cw-peer-review-editor-table--preview">
+            <table class="default">
+                <thead>
+                    <tr>
+                        <th>{{ $gettext('Kriterien') }}</th>
+                        <th>{{ $gettext('Bewertung') }}</th>
+                        <th>{{ $gettext('Kommentar') }}</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    <tr v-for="(criterium, index) in nonEmptyCriteria" :key="index">
+                        <td>{{ criterium.text }}</td>
+                        <td>
+                            <label v-for="text in [$gettext('gut'), $gettext('ok'), $gettext('schwach')]" :key="text">
+                                <input name="rating" type="radio" disabled />
+                                {{ text }}
+                            </label>
+                        </td>
+                        <td>
+                            <textarea disabled />
+                        </td>
+                    </tr>
+                </tbody>
+            </table>
+        </CoursewareTab>
+    </CoursewareTabs>
+</template>
+<script lang="ts">
+import Vue, { PropType } from 'vue';
+import LabelRequired from '../../../forms/LabelRequired.vue';
+import StudipArticle from '../../../StudipArticle.vue';
+import CoursewareTab from '../../layouts/CoursewareTab.vue';
+import CoursewareTabs from '../../layouts/CoursewareTabs.vue';
+import { EditorTableCriterium, TableAssessmentPayload } from './process-configuration';
+
+export default Vue.extend({
+    components: { CoursewareTab, CoursewareTabs, LabelRequired, StudipArticle },
+    props: {
+        payload: {
+            type: Object as PropType<TableAssessmentPayload>,
+        },
+    },
+    model: {
+        prop: 'payload',
+        event: 'save',
+    },
+    data: () => ({ localCriteria: [] as EditorTableCriterium[] }),
+    computed: {
+        criteria() {
+            return this.payload.criteria;
+        },
+        nonEmptyCriteria() {
+            return this.localCriteria.filter(({ text }) => text.trim().length);
+        },
+    },
+    methods: {
+        addLine() {
+            this.localCriteria.push({ text: '' });
+        },
+        removeLine(lineNumber: number) {
+            this.localCriteria = this.localCriteria.filter((item, index) => index !== lineNumber);
+        },
+        resetLocalState() {
+            this.localCriteria = this.criteria.map(({ text }) => ({ text }));
+        },
+    },
+    mounted() {
+        this.resetLocalState();
+    },
+    watch: {
+        payload() {
+            this.resetLocalState();
+        },
+        localCriteria: {
+            handler() {
+                this.$emit('save', { criteria: this.nonEmptyCriteria.map((c) => ({ ...c })) });
+            },
+            deep: true,
+        },
+    },
+});
+</script>
+
+<style scoped>
+form button.trash {
+    min-width: 2em;
+    width: 2em;
+}
+form input {
+    flex-grow: 1;
+    height: 1.7em;
+    max-width: 48em;
+}
+
+form .formpart {
+    display: flex;
+    align-items: center;
+    gap: 1em;
+}
+
+.cw-peer-review-editor-table--preview label {
+    display: block;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/PagesProcessesIndex.vue b/resources/vue/components/courseware/tasks/peer-review/PagesProcessesIndex.vue
new file mode 100644
index 0000000000000000000000000000000000000000..4abc75a0c8e5ae8adefb18578ce91f8819493a6c
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/PagesProcessesIndex.vue
@@ -0,0 +1,34 @@
+<template>
+    <div class="cw-peer-review-processes-wrapper">
+        <div v-if="userIsTeacher">
+            <ProcessesList />
+        </div>
+        <div v-else>
+            <PeerReviewAssignments />
+        </div>
+
+        <MountingPortal mountTo="#courseware-action-widget" name="sidebar-actions" v-if="userIsTeacher">
+            <SidebarActionWidget />
+        </MountingPortal>
+    </div>
+</template>
+
+<script>
+import PeerReviewAssignments from './PeerReviewAssignments.vue';
+import ProcessesList from './ProcessesList.vue';
+import SidebarActionWidget from './SidebarActionWidget.vue';
+import { mapGetters } from 'vuex';
+
+export default {
+    components: {
+        PeerReviewAssignments,
+        ProcessesList,
+        SidebarActionWidget,
+    },
+    computed: {
+        ...mapGetters({
+            userIsTeacher: 'userIsTeacher',
+        }),
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/peer-review/PagesProcessesShow.vue b/resources/vue/components/courseware/tasks/peer-review/PagesProcessesShow.vue
new file mode 100644
index 0000000000000000000000000000000000000000..2f906898ac8f05087c269614b7f7d22e4c81c8a4
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/PagesProcessesShow.vue
@@ -0,0 +1,311 @@
+<template>
+    <div class="cw-peer-review-processes-wrapper">
+        <div class="cw-peer-review-processes-list">
+            <CoursewareRibbon :isContentBar="true" :showToolbarButton="false">
+                <template #buttons>
+                    <router-link :to="{ name: 'peer-review-processes-index' }">
+                        <StudipIcon shape="category-task" :size="24" />
+                    </router-link>
+                </template>
+                <template #breadcrumbList>
+                    <li class="cw-peer-review-processes-breadcrumb-item">
+                        <router-link :to="{ name: 'peer-review-processes-index' }">
+                            {{ $gettext('Peer-Reviews') }}
+                        </router-link>
+                    </li>
+                    <li v-if="taskGroup" class="cw-peer-review-processes-breadcrumb-item">
+                        {{ $gettext('Peer-Review-Prozess') }}
+                    </li>
+                </template>
+            </CoursewareRibbon>
+            <div>
+                <CompanionBox
+                    v-if="!canChangeConfiguration"
+                    :msgCompanion="
+                        $gettext(
+                            'Der Peer-Review-Prozess hat bereits begonnen. Bis auf die Bearbeitungsdauer können die Einstellungen nicht geändert werden.'
+                        )
+                    "
+                >
+                </CompanionBox>
+
+                <section>
+                    <article>
+                        <header>
+                            <h2>{{ $gettext('Status') }}</h2>
+                        </header>
+                        <div class="cw-peer-review-processes-status">
+                            <ProcessStatus :process="process" />
+                            <span>{{ processStatus.description }}</span>
+                        </div>
+                        <div>
+                            <span>{{ $gettext('Bearbeitungszeit:') }}</span>
+                            <StudipDate :date="startDate" />–<StudipDate :date="endDate" />
+                        </div>
+                        <div v-if="canChangeDurationOnly">
+                            <button class="button" @click="onShowDuration">
+                                {{ $gettext('Bearbeitungszeit verlängern') }}
+                            </button>
+                        </div>
+                    </article>
+                    <article>
+                        <header>
+                            <h2>{{ $gettext('Aufgabe') }}</h2>
+                        </header>
+                        <div>
+                            <router-link :to="{ name: 'task-groups-show', params: { id: taskGroup.id } }">
+                                {{ taskGroup.attributes.title }}
+                            </router-link>
+                            <StudipDate :date="new Date(taskGroup.attributes['start-date'])" />–<StudipDate
+                                :date="new Date(taskGroup.attributes['end-date'])"
+                            />
+                        </div>
+                    </article>
+                    <article>
+                        <header>
+                            <h2>{{ $gettext('Einstellungen') }}</h2>
+                        </header>
+                        <div>
+                            <ProcessConfiguration :options="configuration" />
+                        </div>
+                        <div v-if="canChangeConfiguration" class="cw-peer-review-processes-configuration-buttons">
+                            <button class="button" @click="onShowConfiguration">
+                                {{ $gettext('Einstellungen ändern') }}
+                            </button>
+                            <button
+                                class="button"
+                                @click="onShowAssessmentTypeEditor"
+                                v-if="configuration.type === 'form' || configuration.type === 'table'"
+                            >
+                                {{ $gettext('Bewertungssystem konfigurieren') }}
+                            </button>
+                        </div>
+                    </article>
+
+                    <article>
+                        <header>
+                            <h2>{{ $gettext('Peer-Reviews') }}</h2>
+                        </header>
+                        <div>
+                            <template v-if="isBefore">
+                                <CompanionBox
+                                    v-if="isAutomaticPairing"
+                                    :msgCompanion="
+                                        $gettext(
+                                            'In diesem Peer-Review-Prozess werden die Paarungen automatisch verteilt, sobald der Bearbeitungszeitraum beginnt.'
+                                        )
+                                    "
+                                >
+                                </CompanionBox>
+                                <CompanionBox
+                                    v-else
+                                    mood="pointing"
+                                    :msgCompanion="
+                                        $gettext(
+                                            'In diesem Peer-Review-Prozess werden die Paarungen manuell verteilt, bevor der Bearbeitungszeitraum beginnt.'
+                                        )
+                                    "
+                                >
+                                    <template #companionActions>
+                                        <button class="button" @click="onShowPairingEditor">
+                                            {{ $gettext('Paarungen manuell festlegen') }}
+                                        </button>
+                                    </template>
+                                </CompanionBox>
+                            </template>
+                            <PeerReviewList :process="process" :task-group="taskGroup" />
+                        </div>
+                    </article>
+                </section>
+            </div>
+        </div>
+        <AssessmentTypeEditorDialog
+            v-model="showAssessmentTypeEditor"
+            :process="process"
+            @update="onUpdateAssessmentType"
+        />
+        <PairingEditorDialog v-model="showPairingEditor" :process="process" @update="onUpdatePairing" />
+        <ProcessEditDialog
+            v-if="showPeerReviewProcessEdit"
+            :process="process"
+            @update="onUpdatePeerReviewProcess"
+            @close="showPeerReviewProcessEdit = false"
+        />
+        <ProcessDurationDialog v-model="showPeerReviewProcessDuration" :process="process" @update="onUpdateDuration" />
+
+        <MountingPortal mountTo="#courseware-action-widget" name="sidebar-actions" v-if="userIsTeacher">
+            <SidebarActionWidget />
+        </MountingPortal>
+    </div>
+</template>
+
+<script>
+import { mapActions, mapGetters } from 'vuex';
+import AssessmentTypeEditorDialog from './AssessmentTypeEditorDialog.vue';
+import CompanionBox from '../../layouts/CoursewareCompanionBox.vue';
+import CoursewareRibbon from '../../structural-element/CoursewareRibbon.vue';
+import PairingEditorDialog from './PairingEditorDialog.vue';
+import PeerReviewList from './PeerReviewList.vue';
+import ProcessConfiguration from './ProcessConfiguration.vue';
+import ProcessDurationDialog from './ProcessDurationDialog.vue';
+import ProcessEditDialog from './ProcessEditDialog.vue';
+import ProcessStatus from './ProcessStatus.vue';
+import SidebarActionWidget from './SidebarActionWidget.vue';
+import StudipArticle from '../../../StudipArticle.vue';
+import StudipDate from '../../../StudipDate.vue';
+import StudipMessageBox from '../../../StudipMessageBox.vue';
+import { getProcessStatus, ProcessStatus as Status } from './definitions';
+
+export default {
+    components: {
+        AssessmentTypeEditorDialog,
+        CompanionBox,
+        CoursewareRibbon,
+        PairingEditorDialog,
+        PeerReviewList,
+        ProcessConfiguration,
+        ProcessDurationDialog,
+        ProcessEditDialog,
+        ProcessStatus,
+        SidebarActionWidget,
+        StudipArticle,
+        StudipDate,
+        StudipMessageBox,
+    },
+    props: {
+        id: {
+            type: String,
+            required: true,
+        },
+    },
+    data: () => ({
+        showAssessmentTypeEditor: false,
+        showPairingEditor: false,
+        showPeerReviewProcessDuration: false,
+        showPeerReviewProcessEdit: false,
+    }),
+    computed: {
+        ...mapGetters({
+            getProcess: 'courseware-peer-review-processes/byId',
+            relatedPeerReviews: 'courseware-peer-reviews/related',
+            relatedTasks: 'courseware-tasks/related',
+            relatedTaskGroups: 'courseware-task-groups/related',
+            relatedUsers: 'users/related',
+            userIsTeacher: 'userIsTeacher',
+        }),
+        canChangeConfiguration() {
+            return this.isBefore;
+        },
+        canChangeDurationOnly() {
+            return this.processStatus.status === Status.Active;
+        },
+        configuration() {
+            return this.process.attributes['configuration'];
+        },
+        endDate() {
+            return new Date(this.process.attributes['review-end']);
+        },
+        isBefore() {
+            return this.processStatus.status === Status.Before;
+        },
+        isAutomaticPairing() {
+            return this.configuration.automaticPairing;
+        },
+        owner() {
+            return this.relatedUsers({ parent: this.process, relationship: 'owner' });
+        },
+        peerReviews() {
+            const result = this.relatedPeerReviews({ parent: this.process, relationship: 'peer-reviews' });
+            console.debug(result);
+            return result;
+        },
+        process() {
+            return this.getProcess({ id: this.id });
+        },
+        processStatus() {
+            return getProcessStatus(this.process);
+        },
+        solvers() {
+            return this.taskGroup.relationships.solvers.data.map(({ id, type }) => {
+                return [id, type];
+            });
+        },
+        startDate() {
+            return new Date(this.process.attributes['review-start']);
+        },
+        taskGroup() {
+            return this.relatedTaskGroups({ parent: this.process, relationship: 'task-group' });
+        },
+        tasks() {
+            return this.relatedTasks({ parent: this.taskGroup, relationship: 'tasks' });
+        },
+    },
+    methods: {
+        ...mapActions({
+            loadRelatedPeerReviews: 'courseware-peer-reviews/loadRelated',
+            replacePairings: 'tasks/replacePairings',
+            updatePeerReviewProcess: 'tasks/updatePeerReviewProcess',
+        }),
+        loadPeerReviews() {
+            return this.loadRelatedPeerReviews({
+                parent: this.process,
+                relationship: 'peer-reviews',
+                options: { include: 'reviewer,task' },
+            });
+        },
+        onShowAssessmentTypeEditor() {
+            this.showAssessmentTypeEditor = true;
+        },
+        onShowConfiguration() {
+            this.showPeerReviewProcessEdit = true;
+        },
+        onShowDuration() {
+            this.showPeerReviewProcessDuration = true;
+        },
+        onShowPairingEditor() {
+            this.showPairingEditor = true;
+        },
+        onUpdateAssessmentType(payload) {
+            const configuration = this.process.attributes.configuration;
+            configuration.payload = payload;
+
+            this.updatePeerReviewProcess({ process: this.process, configuration }).then(
+                () => (this.showAssessmentTypeEditor = false)
+            );
+        },
+        onUpdateDuration(duration) {
+            const configuration = { ...this.process.attributes.configuration, duration };
+            this.updatePeerReviewProcess({ process: this.process, configuration }).then(
+                () => (this.showPeerReviewProcessDuration = false)
+            );
+        },
+        onUpdatePairing(pairings) {
+            this.replacePairings({ process: this.process, pairings })
+                .then(() => this.loadPeerReviews())
+                .then(() => (this.showPairingEditor = false))
+                .catch((error) => {
+                    console.error('Could not replace pairings.', error);
+                });
+        },
+        onUpdatePeerReviewProcess({ configuration }) {
+            this.updatePeerReviewProcess({ process: this.process, configuration }).then(
+                () => (this.showPeerReviewProcessEdit = false)
+            );
+        },
+    },
+    async mounted() {
+        await this.loadPeerReviews();
+    },
+};
+</script>
+
+<style>
+.cw-peer-review-processes-breadcrumb-item {
+    white-space: nowrap;
+}
+.cw-peer-review-processes-status,
+.cw-peer-review-processes-configuration-buttons {
+    display: flex;
+    gap: 0.25rem;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/PairingEditor.vue b/resources/vue/components/courseware/tasks/peer-review/PairingEditor.vue
new file mode 100644
index 0000000000000000000000000000000000000000..1b627848df9f49e1c6006538ce167366fe73b446
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/PairingEditor.vue
@@ -0,0 +1,224 @@
+<template>
+    <div>
+        <form class="default">
+            <div>
+                <label>
+                    {{ $gettext('verfasst von') }}
+                    <select v-model="selectedSubmitter" size="10">
+                        <option v-for="solver in selectableSubmitters" :value="solver">
+                            <span v-if="isUser(solver)">
+                                {{ solver.attributes['formatted-name'] }}
+                            </span>
+                            <span v-if="isStatusGroup(solver)">
+                                {{ solver.attributes.name }}
+                            </span>
+                        </option>
+                        <option v-if="!selectableSubmitters?.length" disabled>{{ $gettext('--leer--') }}</option>
+                    </select>
+                </label>
+            </div>
+            <div>
+                <label>
+                    {{ $gettext('begutachtet von') }}
+                    <select v-model="selectedReviewer" size="10">
+                        <option v-for="solver in selectableReviewers" :value="solver">
+                            <span v-if="isUser(solver)">
+                                {{ solver.attributes['formatted-name'] }}
+                            </span>
+                            <span v-if="isStatusGroup(solver)">
+                                {{ solver.attributes.name }}
+                            </span>
+                        </option>
+                        <option v-if="!selectableReviewers?.length" disabled>{{ $gettext('--leer--') }}</option>
+                    </select>
+                </label>
+            </div>
+            <div>
+                <div>
+                    <div>{{ $gettext('Paarungen') }}</div>
+                    <div>
+                        <button
+                            class="button button-icon"
+                            type="button"
+                            :disabled="!(selectedSubmitter && selectedReviewer)"
+                            @click="onAdd"
+                        >
+                            <StudipIcon shape="arr_2right" role="info_alt" />
+                            <StudipIcon shape="arr_2right" />
+                            <span class="sr-only">{{ $gettext('Hinzufügen') }}</span>
+                        </button>
+                        <table>
+                            <tr v-for="({ submitter, reviewer }, index) in localPairings" :key="index">
+                                <td>
+                                    <UserAvatar
+                                        v-if="submitter.type === 'users'"
+                                        :avatar-url="submitter.meta.avatar.small"
+                                        :formatted-name="submitter.attributes['formatted-name']"
+                                    />
+                                    <span v-if="submitter.type === 'status-groups'">
+                                        {{ submitter.attributes.name }}
+                                    </span>
+                                </td>
+
+                                <td><span>»</span></td>
+                                <td>
+                                    <UserAvatar
+                                        v-if="reviewer.type === 'users'"
+                                        :avatar-url="reviewer.meta.avatar.small"
+                                        :formatted-name="reviewer.attributes['formatted-name']"
+                                    />
+                                    <span v-if="reviewer.type === 'status-groups'">
+                                        {{ reviewer.attributes.name }}
+                                    </span>
+                                </td>
+                                <td>
+                                    <button @click="() => onTrash(index)" class="button button-icon" type="button">
+                                        <StudipIcon shape="trash" role="info_alt" />
+                                        <StudipIcon shape="trash" />
+                                        <span class="sr-only">{{ $gettext('Entfernen') }}</span>
+                                    </button>
+                                </td>
+                            </tr>
+                        </table>
+                    </div>
+                </div>
+            </div>
+        </form>
+    </div>
+</template>
+
+<script>
+import _ from 'lodash';
+import { mapGetters } from 'vuex';
+import StudipIcon from '../../../StudipIcon.vue';
+import UserAvatar from '../../../StudipUserAvatar.vue';
+
+export default {
+    model: {
+        prop: 'pairings',
+        event: 'update',
+    },
+    components: { StudipIcon, UserAvatar },
+    props: {
+        pairings: {
+            type: Array,
+            required: true,
+        },
+        solvers: {
+            type: Array,
+            default: [],
+        },
+    },
+    data() {
+        return {
+            localPairings: [],
+            selectedSubmitter: null,
+            selectedReviewer: null,
+        };
+    },
+    computed: {
+        selectableReviewers() {
+            const selected = this.localPairings.map(({ reviewer }) => reviewer.id);
+            return this.solvers.filter(({ id }) => !selected.includes(id));
+        },
+        selectableSubmitters() {
+            const selected = this.localPairings.map(({ submitter }) => submitter.id);
+            return this.solvers.filter(({ id }) => !selected.includes(id));
+        },
+    },
+    methods: {
+        isStatusGroup(object) {
+            return object.type === 'status-groups';
+        },
+        isUser(object) {
+            return object.type === 'users';
+        },
+        onAdd() {
+            this.localPairings.push({
+                reviewer: this.selectedReviewer,
+                submitter: this.selectedSubmitter,
+            });
+            this.selectedReviewer = null;
+            this.selectedSubmitter = null;
+        },
+        onTrash(index) {
+            this.localPairings = [...this.localPairings.slice(0, index), ...this.localPairings.slice(index + 1)];
+        },
+        resetLocalState() {
+            this.localPairings = [...this.pairings];
+        },
+    },
+    mounted() {
+        this.resetLocalState();
+    },
+    watch: {
+        localPairings(newP, oldP) {
+            if (!_.isEqual(this.localPairings, this.pairings)) {
+                this.$emit('update', [...this.localPairings]);
+            }
+        },
+        pairings() {
+            if (!_.isEqual(this.localPairings, this.pairings)) {
+                this.resetLocalState();
+            }
+        },
+        selectedReviewer() {
+            if (this.selectedReviewer === this.selectedSubmitter) {
+                this.selectedSubmitter = null;
+            }
+        },
+        selectedSubmitter() {
+            if (this.selectedReviewer === this.selectedSubmitter) {
+                this.selectedReviewer = null;
+            }
+        },
+    },
+};
+</script>
+
+<style scoped>
+form {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 1rem;
+}
+
+form > * {
+    flex-grow: 1;
+}
+
+form > :nth-child(1) select,
+form > :nth-child(2) select {
+    max-width: 15rem;
+}
+
+form > :nth-child(3) {
+    flex-basis: 15rem;
+}
+
+tr > :nth-child(2) {
+    padding-inline: 0.5rem;
+}
+
+button.button-icon {
+    min-width: auto;
+    line-height: 2rem;
+    padding: 0;
+    width: 2rem;
+}
+button.button-icon > img {
+    vertical-align: middle;
+}
+button.button-icon > img:first-child {
+    display: none;
+}
+button.button-icon:hover > img:first-child {
+    display: inline;
+}
+button.button-icon > img:first-child {
+    display: hidden;
+}
+button.button-icon:hover > img:nth-child(2) {
+    display: none;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/PairingEditorDialog.vue b/resources/vue/components/courseware/tasks/peer-review/PairingEditorDialog.vue
new file mode 100644
index 0000000000000000000000000000000000000000..008891a4739f6be786928223c5924c67d665cf89
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/PairingEditorDialog.vue
@@ -0,0 +1,89 @@
+<template>
+    <StudipDialog
+        v-if="show && process"
+        :title="$gettext('Zuordnungen festlegen')"
+        :confirmText="$gettext('Speichern')"
+        confirmClass="accept"
+        :closeText="$gettext('Schließen')"
+        closeClass="cancel"
+        height="800"
+        width="800"
+        @close="onClose"
+        @confirm="onConfirm"
+    >
+        <template #dialogContent>
+            <pre>{{ JSON.stringify(pairings) }}</pre>
+            <PairingEditor v-if="pairings" v-model="pairings" :solvers="solvers" />
+        </template>
+    </StudipDialog>
+</template>
+
+<script>
+import { mapGetters } from 'vuex';
+import PairingEditor from './PairingEditor.vue';
+import StudipDialog from '../../../StudipDialog.vue';
+
+const objId = ({ id, type }) => ({ id, type });
+
+export default {
+    model: {
+        prop: 'show',
+        event: 'updateShow',
+    },
+    components: {
+        PairingEditor,
+        StudipDialog,
+    },
+    props: {
+        show: {
+            type: Boolean,
+            required: true,
+        },
+        process: {
+            type: Object,
+            default: null,
+        },
+    },
+    data() {
+        return {
+            pairings: [],
+        };
+    },
+    computed: {
+        ...mapGetters({
+            relatedPeerReviews: 'courseware-peer-reviews/related',
+            relatedTaskGroups: 'courseware-task-groups/related',
+        }),
+        reviewPairs() {
+            return this.relatedPeerReviews({ parent: this.process, relationship: 'peer-reviews' }).map((review) => ({
+                reviewer: this.getObject(review.relationships.reviewer.data),
+                submitter: this.getObject(review.relationships.submitter.data),
+            }));
+        },
+        solvers() {
+            return this.taskGroup.relationships.solvers.data.map((solver) => this.getObject(solver));
+        },
+        taskGroup() {
+            return this.relatedTaskGroups({ parent: this.process, relationship: 'task-group' });
+        },
+    },
+    methods: {
+        getObject({ type, id }) {
+            return this.$store.getters[`${type}/byId`]({ id });
+        },
+        onClose() {
+            this.$emit('updateShow', false);
+        },
+        onConfirm() {
+            this.$emit('update', this.pairings);
+        },
+    },
+    watch: {
+        show() {
+            if (this.show) {
+                this.pairings = this.reviewPairs;
+            }
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/peer-review/PeerReviewAssignments.vue b/resources/vue/components/courseware/tasks/peer-review/PeerReviewAssignments.vue
new file mode 100644
index 0000000000000000000000000000000000000000..9c4b3bdcf8db2237cf5f3f06032c5f5760ba48b2
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/PeerReviewAssignments.vue
@@ -0,0 +1,102 @@
+<template>
+    <div>
+        <p>
+            Es gibt im Moment in diese Mannschaft, oh, einige Spieler vergessen ihnen Profi was sie sind. Ich lese nicht
+            sehr viele Zeitungen, aber ich habe gehört viele Situationen. Erstens: wir haben nicht offensiv gespielt. Es
+            gibt keine deutsche Mannschaft spielt offensiv und die Name offensiv wie Bayern. Letzte Spiel hatten wir in
+            Platz drei Spitzen: Elber, Jancka und dann Zickler. Wir müssen nicht vergessen Zickler. Zickler ist eine
+            Spitzen mehr, Mehmet eh mehr Basler.
+        </p>
+        <table class="default">
+            <thead>
+                <tr>
+                    <th>Aufgabe</th>
+                    <th>Bearbeitungszeit</th>
+                    <th><span class="sr-only">Aktionen</span></th>
+                </tr>
+            </thead>
+            <tbody>
+                <tr v-for="review in peerReviews" :key="review.id">
+                    <td>
+                        <a :href="elementUrls[review.id]">
+                            {{ taskGroups[review.id].attributes.title }}
+                        </a>
+                    </td>
+
+                    <td>
+                        <StudipDate :date="new Date(processes[review.id].attributes['review-start'])" />
+                        –
+                        <StudipDate :date="new Date(processes[review.id].attributes['review-end'])" />
+                    </td>
+                    <td class="actions">...</td>
+                </tr>
+            </tbody>
+        </table>
+    </div>
+</template>
+
+<script>
+import { mapActions, mapGetters } from 'vuex';
+import StudipDate from '../../../StudipDate.vue';
+import taskHelper from '../../../../mixins/courseware/task-helper.js';
+
+export default {
+    components: { StudipDate },
+    props: {},
+    mixins: [taskHelper],
+    computed: {
+        ...mapGetters({
+            context: 'context',
+            relatedPeerReviewProcesses: 'courseware-peer-review-processes/related',
+            relatedPeerReviews: 'courseware-peer-reviews/related',
+            relatedStructuralElement: 'courseware-structural-elements/related',
+            relatedTask: 'courseware-tasks/related',
+            relatedTaskGroups: 'courseware-task-groups/related',
+        }),
+        elementUrls() {
+            return this.peerReviews.reduce((memo, review) => {
+                const task = this.tasks[review.id];
+                const element = this.relatedStructuralElement({ parent: task, relationship: 'structural-element' });
+                memo[review.id] = this.getLinkToElement(element);
+                return memo;
+            }, {});
+        },
+        peerReviews() {
+            const course = { type: 'courses', id: this.context.id };
+            return this.relatedPeerReviews({ parent: course, relationship: 'courseware-peer-reviews' });
+        },
+        processes() {
+            return this.peerReviews.reduce((memo, review) => {
+                memo[review.id] = this.relatedPeerReviewProcesses({ parent: review, relationship: 'process' });
+                return memo;
+            }, {});
+        },
+        taskGroups() {
+            return this.peerReviews.reduce((memo, review) => {
+                const process = this.processes[review.id];
+                memo[review.id] = this.relatedTaskGroups({ parent: process, relationship: 'task-group' });
+                return memo;
+            }, {});
+        },
+        tasks() {
+            return this.peerReviews.reduce((memo, review) => {
+                memo[review.id] = this.relatedTask({ parent: review, relationship: 'task' });
+                return memo;
+            }, {});
+        },
+    },
+    methods: {
+        ...mapActions({
+            loadRelatedPeerReviews: 'courseware-peer-reviews/loadRelated',
+        }),
+    },
+    mounted() {
+        const parent = { type: 'courses', id: this.context.id };
+        const relationship = 'courseware-peer-reviews';
+        const options = {
+            include: 'process,task.structural-element,task.task-group',
+        };
+        this.loadRelatedPeerReviews({ parent, relationship, options });
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/peer-review/PeerReviewList.vue b/resources/vue/components/courseware/tasks/peer-review/PeerReviewList.vue
new file mode 100644
index 0000000000000000000000000000000000000000..ffd140b9c2d806360e6f39f31c323abd2d1feca1
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/PeerReviewList.vue
@@ -0,0 +1,53 @@
+<template>
+    <div v-if="peerReviews && peerReviews.length > 0">
+        <table class="default">
+            <thead>
+                <tr>
+                    <th>{{ $gettext("Aufgabe") }}</th>
+                    <th>{{ $gettext("verfasst von") }}</th>
+                    <th>{{ $gettext("begutachtet von") }}</th>
+                    <th>&nbsp;</th>
+                </tr>
+            </thead>
+            <tbody>
+                <PeerReviewListItem
+                    v-for="review in peerReviews"
+                    :review="review"
+                    :key="review.id"
+                    :process="process"
+                    :task-group="taskGroup"
+                    />
+            </tbody>
+        </table>
+    </div>
+</template>
+
+<script lang="ts">
+import Vue, { PropType } from 'vue';
+import { mapGetters } from 'vuex';
+import { JsonApiSchema } from './definitions';
+import PeerReviewListItem from './PeerReviewListItem.vue';
+import ProgressIndicator from '../../../StudipProgressIndicator.vue';
+
+export default Vue.extend({
+    components: { PeerReviewListItem, ProgressIndicator },
+    props: {
+        process: {
+            type: Object as PropType<JsonApiSchema>,
+            required: true,
+        },
+        taskGroup: {
+            type: Object as PropType<JsonApiSchema>,
+            required: true,
+        },
+    },
+    computed: {
+        ...mapGetters({
+            relatedPeerReviews: 'courseware-peer-reviews/related',
+        }),
+        peerReviews() {
+            return this.relatedPeerReviews({ parent: this.process, relationship: 'peer-reviews' });
+        },
+    },
+});
+</script>
diff --git a/resources/vue/components/courseware/tasks/peer-review/PeerReviewListItem.vue b/resources/vue/components/courseware/tasks/peer-review/PeerReviewListItem.vue
new file mode 100644
index 0000000000000000000000000000000000000000..2ef2ebd48f88318a7604037e023556934aebb2d2
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/PeerReviewListItem.vue
@@ -0,0 +1,118 @@
+<template>
+    <tr>
+        <td>
+            <a :href="getLinkToElement(element)">
+                {{ taskGroup.attributes.title }}
+            </a>
+        </td>
+        <td>
+            <a v-if="isUser(submitter)" :href="userProfile(submitter)">
+                <UserAvatar
+                    :avatar-url="submitter.meta.avatar.small"
+                    :formatted-name="submitter.attributes['formatted-name']"
+                />
+            </a>
+            <a v-else :href="statusGroupUrl(submitter)">
+                {{ submitter.attributes.name }}
+            </a>
+        </td>
+        <td>
+            <a v-if="isUser(submitter)" :href="userProfile(reviewer)">
+                <UserAvatar
+                    :avatar-url="reviewer.meta.avatar.small"
+                    :formatted-name="reviewer.attributes['formatted-name']"
+                />
+            </a>
+            <a v-else :href="statusGroupUrl(reviewer)">
+                {{ reviewer.attributes.name }}
+            </a>
+        </td>
+        <td>
+            <button class="button" @click="onShowAssessment" :disabled="canShowReview">{{ $gettext('Gutachten anzeigen') }}</button>
+        </td>
+    </tr>
+</template>
+
+<script>
+import { mapGetters } from 'vuex';
+import UserAvatar from '@/vue/components/StudipUserAvatar.vue';
+import taskHelper from '../../../../mixins/courseware/task-helper.js';
+import { getProcessStatus, ProcessStatus } from './definitions';
+
+export default {
+    mixins: [taskHelper],
+    props: {
+        process: {
+            type: Object,
+            required: true,
+        },
+        review: {
+            type: Object,
+            required: true,
+        },
+        taskGroup: {
+            type: Object,
+            required: true,
+        },
+    },
+    components: { UserAvatar },
+    computed: {
+        ...mapGetters({
+            context: 'context',
+            relatedStructuralElement: 'courseware-structural-elements/related',
+            relatedTasks: 'courseware-tasks/related',
+            relatedStatusGroups: 'status-groups/related',
+            relatedUsers: 'users/related',
+        }),
+        canShowReview() {
+            return getProcessStatus(this.process).status !== ProcessStatus.After;
+        },
+        element() {
+            const parent = { id: this.task.id, type: this.task.type };
+            const relationship = 'structural-element';
+            return this.relatedStructuralElement({ parent, relationship });
+        },
+        reviewer() {
+            const user = this.relatedUsers({ parent: this.review, relationship: 'reviewer' });
+            if (user) {
+                return user;
+            }
+            const statusGroup = this.relatedStatusGroups({ parent: this.review, relationship: 'reviewer' });
+            return statusGroup;
+        },
+        submitter() {
+            const user = this.relatedUsers({ parent: this.task, relationship: 'solver' });
+            if (user) {
+                return user;
+            }
+            const statusGroup = this.relatedStatusGroups({ parent: this.task, relationship: 'solver' });
+            return statusGroup;
+        },
+        task() {
+            const parent = { id: this.review.id, type: this.review.type };
+            const relationship = 'task';
+            return this.relatedTasks({ parent, relationship });
+        },
+    },
+    methods: {
+        isUser(object) {
+            return object.type === 'users';
+        },
+        onShowAssessment() {
+            console.debug('NYI');
+        },
+        statusGroupUrl(statusGroup) {
+            const cid = this.context.id;
+            return window.STUDIP.URLHelper.getURL(
+                'dispatch.php/course/statusgroups',
+                { cid, contentbox_open: statusGroup.id },
+                true
+            );
+        },
+        userProfile(user) {
+            const username = user.attributes.username;
+            return window.STUDIP.URLHelper.getURL('dispatch.php/profile', { username }, true);
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessConfiguration.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessConfiguration.vue
new file mode 100644
index 0000000000000000000000000000000000000000..637752800340e47eb526ecb91bcf77e4455afca8
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ProcessConfiguration.vue
@@ -0,0 +1,40 @@
+<template>
+    <ul>
+        <li v-if="options.anonymous">{{ $gettext('Anonymes Review') }}</li>
+        <li v-else>{{ $gettext('Offenes Review') }}</li>
+
+        <li>
+            {{
+                $gettextInterpolate($gettext('%{n} Tage Zeit für das Review'), {
+                    n: options.duration,
+                })
+            }}
+        </li>
+
+        <li>
+            {{ reviewTypes[options.type].long }}
+        </li>
+
+        <li v-if="options.automaticPairing">
+            {{ $gettext('Zusammenstellung der Review-Paarungen durch das Programm') }}
+        </li>
+        <li v-else>{{ $gettext('Zusammenstellung der Review-Paarungen durch die Lehrenden') }}</li>
+    </ul>
+</template>
+
+<script lang="ts">
+import Vue, { PropType } from 'vue';
+import { ProcessConfiguration, ASSESSMENT_TYPES } from './process-configuration';
+
+export default Vue.extend({
+    props: {
+        options: {
+            required: true,
+            type: Object as PropType<ProcessConfiguration>,
+        },
+    },
+    computed: {
+        reviewTypes: () => ASSESSMENT_TYPES,
+    },
+});
+</script>
diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessCreateDialog.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessCreateDialog.vue
new file mode 100644
index 0000000000000000000000000000000000000000..3bd551e7b58de10b7d1ab4b1d64e85254063e4c5
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ProcessCreateDialog.vue
@@ -0,0 +1,65 @@
+<template>
+    <StudipDialog
+        :title="$gettext('Peer-Review-Prozess anlegen')"
+        :confirmText="$gettext('Anlegen')"
+        confirmClass="accept"
+        :confirmDisabled="!changed"
+        :closeText="$gettext('Schließen')"
+        closeClass="cancel"
+        height="600"
+        width="800"
+        @close="$emit('close')"
+        @confirm="create"
+    >
+        <template #dialogContent>
+            <header>
+                <h2>
+                    {{
+                        $gettextInterpolate($gettext('Aufgabe "%{title}"'), {
+                            title: taskGroup.attributes.title,
+                        })
+                    }}
+                    <span>
+                        (<StudipDate :date="new Date(taskGroup.attributes['start-date'])" />
+                        –
+                        <StudipDate :date="new Date(taskGroup.attributes['end-date'])" />)
+                    </span>
+                </h2>
+            </header>
+
+            <ProcessCreateForm :configuration="configuration" @update="updateConfiguration" />
+        </template>
+    </StudipDialog>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import StudipDate from '../../../StudipDate.vue';
+import StudipDialog from '../../../StudipDialog.vue';
+import ProcessCreateForm from './ProcessCreateForm.vue';
+import { defaultConfiguration, ProcessConfiguration } from './process-configuration';
+
+export default Vue.extend({
+    components: { ProcessCreateForm, StudipDate, StudipDialog },
+    props: ['taskGroup'],
+    data: () => ({
+        changed: false,
+        configuration: defaultConfiguration(),
+    }),
+    methods: {
+        create() {
+            this.$emit('create', { ...this.configuration });
+        },
+        updateConfiguration(configuration: ProcessConfiguration) {
+            this.changed = true;
+            this.configuration = configuration;
+        },
+    },
+});
+</script>
+
+<style scoped>
+header {
+    margin-block-end: 2rem;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessCreateForm.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessCreateForm.vue
new file mode 100644
index 0000000000000000000000000000000000000000..0bf0b6482db0803889f2b39c3fa11c7054d97a1f
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ProcessCreateForm.vue
@@ -0,0 +1,258 @@
+<template>
+    <form class="default" @submit.prevent="">
+        <div class="peer-review-process-create-form-switcher">
+            <button
+                class="button"
+                :class="{ active: !showCustomConfiguration }"
+                @click="showCustomConfiguration = false"
+            >
+                {{ $gettext('Einfach') }}
+            </button>
+            <button class="button" :class="{ active: showCustomConfiguration }" @click="showCustomConfiguration = true">
+                {{ $gettext('Erweitert') }}
+            </button>
+        </div>
+
+        <section class="peer-review-process-create-form-type-cards" v-if="!showCustomConfiguration">
+            <article
+                v-for="(configurationSet, index) in configurationSets"
+                :key="index"
+                :class="{ selected: selectedConfigurationSet === index }"
+            >
+                <h2>{{ configurationSet.name }}</h2>
+
+                <button
+                    class="button"
+                    :class="{ accept: selectedConfigurationSet === index }"
+                    :disabled="selectedConfigurationSet === index"
+                    type="button"
+                    @click="selectConfigurationSet(index)"
+                >
+                    {{ selectedConfigurationSet === index ? $gettext('Ausgewählt') : $gettext('Auswählen') }}
+                </button>
+
+                <PeerReviewProcessConfiguration :options="configurationSet.configuration" />
+            </article>
+        </section>
+
+        <ContentBox
+            v-else
+            class="peer-review-process-create-form-custom-configuration"
+            :title="$gettext('Erweiterte Einstellungen')"
+        >
+            <div class="custom-configuration">
+                <div class="formpart">
+                    <LabelRequired
+                        :id="`peer-review-process-create-form-${uid}-anonymous`"
+                        :label="$gettext('Anonymes oder offenes Review:')"
+                    >
+                        <select
+                            v-model="localConfiguration.anonymous"
+                            :id="`peer-review-process-create-form-${uid}-anonymous`"
+                            @change="customizeConfiguration"
+                        >
+                            <option :value="true">{{ $gettext('anonym') }}</option>
+                            <option :value="false">{{ $gettext('offen') }}</option>
+                        </select>
+                    </LabelRequired>
+                </div>
+
+                <div class="formpart">
+                    <LabelRequired
+                        :id="`peer-review-process-create-form-${uid}-duration`"
+                        :label="$gettext('Bearbeitungszeitraum in Tagen:')"
+                    >
+                        <select
+                            v-model.number="localConfiguration.duration"
+                            :id="`peer-review-process-create-form-${uid}-duration`"
+                            @change="customizeConfiguration"
+                        >
+                            <option v-for="i in 21" :key="i">{{ i }}</option>
+                        </select>
+                    </LabelRequired>
+                </div>
+
+                <div class="formpart">
+                    <LabelRequired
+                        :id="`peer-review-process-create-form-${uid}-type`"
+                        :label="$gettext('Art des Reviews:')"
+                    >
+                        <select
+                            v-model="localConfiguration.type"
+                            :id="`peer-review-process-create-form-${uid}-type`"
+                            @change="onChangeType"
+                        >
+                            <option v-for="[key, { short }] in Object.entries(reviewTypes)" :key="key" :value="key">
+                                {{ short }}
+                            </option>
+                        </select>
+                    </LabelRequired>
+                </div>
+
+                <div class="formpart">
+                    <LabelRequired
+                        :id="`peer-true-process-create-form-${uid}-anonymous`"
+                        :label="$gettext('Review-Paarungen')"
+                    >
+                        <select
+                            v-model="localConfiguration.automaticPairing"
+                            :id="`peer-review-process-create-form-${uid}-automatic-pairing`"
+                            @change="customizeConfiguration"
+                        >
+                            <option :value="true">{{ $gettext('Zufall') }}</option>
+                            <option :value="false">{{ $gettext('Manuell') }}</option>
+                        </select>
+                    </LabelRequired>
+                </div>
+            </div>
+        </ContentBox>
+    </form>
+</template>
+
+<script lang="ts">
+import Vue, { PropType } from 'vue';
+import ContentBox from '../../../StudipContentBox.vue';
+import LabelRequired from '../../../forms/LabelRequired.vue';
+import PeerReviewProcessConfiguration from './ProcessConfiguration.vue';
+import { ASSESSMENT_TYPES, CONFIGURATION_SETS, ProcessConfiguration } from './process-configuration';
+
+let nextId = 0;
+
+export default Vue.extend({
+    components: { ContentBox, LabelRequired, PeerReviewProcessConfiguration },
+    props: {
+        configuration: {
+            required: true,
+            type: Object as PropType<ProcessConfiguration>,
+        },
+        custom: {
+            type: Boolean,
+            default: false,
+        },
+    },
+    data() {
+        return {
+            localConfiguration: { ...this.configuration },
+            selectedConfigurationSet: -1,
+            showCustomConfiguration: this.custom,
+            uid: nextId++,
+        };
+    },
+    computed: {
+        reviewTypes: () => ASSESSMENT_TYPES,
+        configurationSets: () => CONFIGURATION_SETS,
+    },
+    methods: {
+        customizeConfiguration() {
+            this.selectedConfigurationSet = -1;
+            this.update();
+        },
+        onChangeType() {
+            this.localConfiguration.payload =
+                this.localConfiguration.type === this.configuration.type
+                    ? this.configuration.payload
+                    : ASSESSMENT_TYPES[this.localConfiguration.type].defaultPayload;
+            this.customizeConfiguration();
+        },
+        resetData() {
+            this.localConfiguration = { ...this.configuration };
+        },
+        selectConfigurationSet(configurationSetIndex: number) {
+            this.selectedConfigurationSet = configurationSetIndex;
+            this.localConfiguration = CONFIGURATION_SETS[configurationSetIndex].configuration;
+            this.update();
+        },
+        update() {
+            this.$emit('update', this.localConfiguration);
+        },
+    },
+    watch: {
+        configuration() {
+            this.resetData();
+        },
+    },
+});
+</script>
+
+<style scoped lang="scss">
+.peer-review-process-create-form-type-cards {
+    box-sizing: border-box;
+    width: 100%;
+    margin-block: 1.5rem 0;
+
+    display: flex;
+    flex-wrap: wrap;
+    gap: 1.5rem;
+    --threshold: 45rem;
+
+    article {
+        flex-grow: 1;
+        flex-basis: calc((var(--threshold) - 100%) * 999);
+        box-sizing: border-box;
+        padding: 1rem;
+        border: 2px var(--dark-gray-color-20) solid;
+
+        &.selected {
+            border-color: var(--dark-gray-color-80);
+            border-width: 2px;
+        }
+
+        h2 {
+            font-weight: bold;
+            font-size: 1.2rem;
+            margin-block: 1rem 0;
+        }
+        button {
+            margin-block: 1.5rem;
+        }
+        ul {
+            padding-inline: 1em 0;
+        }
+        li {
+            padding-block: 0.5rem;
+        }
+    }
+
+    > :nth-last-child(n + 4),
+    > :nth-last-child(n + 4) ~ * {
+        flex-basis: 100%;
+    }
+}
+
+.peer-review-process-create-form-type-cards + section {
+    text-align: center;
+    margin-block-end: 1.5rem;
+}
+
+.peer-review-process-create-form-custom-configuration {
+    margin-block: 1.5rem;
+}
+
+.custom-configuration {
+    padding: 1rem;
+}
+
+.peer-review-process-create-form-switcher {
+    display: flex;
+    justify-content: center;
+}
+
+.peer-review-process-create-form-switcher button {
+    margin: 0;
+}
+
+.peer-review-process-create-form-switcher button + button {
+    border-left: none;
+}
+
+.peer-review-process-create-form-switcher button.active {
+    background: var(--base-color);
+    color: var(--white);
+    cursor: default;
+}
+
+.peer-review-process-create-form-switcher button:not(.active):hover {
+    background: var(--white);
+    color: var(--base-color);
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessDurationDialog.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessDurationDialog.vue
new file mode 100644
index 0000000000000000000000000000000000000000..af6756ff4bbca7793ddf7a26925ccf76f24946b7
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ProcessDurationDialog.vue
@@ -0,0 +1,122 @@
+<template>
+    <StudipDialog
+        v-if="show && process"
+        :title="$gettext('Bearbeitungszeit ändern')"
+        :confirmText="$gettext('Speichern')"
+        confirmClass="accept"
+        :closeText="$gettext('Schließen')"
+        closeClass="cancel"
+        @close="onClose"
+        @confirm="onConfirm"
+    >
+        <template #dialogContent>
+            <form class="default">
+                <p>
+                    {{ $gettext('Aktuelle Bearbeitungszeit:') }} <StudipDate :date="startDate" />–<StudipDate :date="endDate" />
+                    ({{ $gettextInterpolate($gettext('%{ count } Tage'), { count: oldDuration }) }})
+                </p>
+                <div class="formpart">
+                    <LabelRequired
+                        :id="`peer-review-process-${uid}`"
+                        :label="$gettext('Bearbeitungszeit verlängern bis zum:')"
+                    />
+                    <input
+                        :id="`peer-review-process-${uid}`"
+                        name="end-date"
+                        type="date"
+                        v-model="localEndDate"
+                        :min="endDateString"
+                        class="size-l"
+                        required
+                    />
+                    <div>({{ $gettextInterpolate($gettext('%{ count } Tage'), { count: newDuration }) }})</div>
+                </div>
+            </form>
+        </template>
+    </StudipDialog>
+</template>
+
+<script>
+import { mapGetters } from 'vuex';
+import LabelRequired from '../../../forms/LabelRequired.vue';
+import StudipDate from '../../../StudipDate.vue';
+import StudipDialog from '../../../StudipDialog.vue';
+
+const midnight = (_date) => {
+    const date = new Date(_date);
+    date.setHours(0);
+    date.setMinutes(0);
+    date.setSeconds(0);
+    date.setMilliseconds(0);
+    return date;
+};
+
+const dateString = (date) =>
+    `${date.getFullYear()}-${('' + (date.getMonth() + 1)).padStart(2, '0')}-${('' + date.getDate()).padStart(2, '0')}`;
+
+let nextUid = 0;
+
+export default {
+    model: {
+        prop: 'show',
+        event: 'updateShow',
+    },
+    components: {
+        LabelRequired,
+        StudipDate,
+        StudipDialog,
+    },
+    props: {
+        show: {
+            type: Boolean,
+            required: true,
+        },
+        process: {
+            type: Object,
+            default: null,
+        },
+    },
+    data: () => ({ localEndDate: null, uid: nextUid++ }),
+    computed: {
+        configuration() {
+            return this.process?.attributes?.configuration ?? {};
+        },
+        endDate() {
+            return midnight(this.process?.attributes?.['review-end'] ?? new Date());
+        },
+        endDateString() {
+            return dateString(this.endDate);
+        },
+        newDuration() {
+            return this.localEndDate
+                ? Math.floor((midnight(this.localEndDate) - midnight(this.startDate)) / (1000 * 60 * 60 * 24))
+                : 0;
+        },
+        oldDuration() {
+            return this.configuration.duration ?? '??';
+        },
+        startDate() {
+            return midnight(this.process.attributes['review-start']);
+        },
+    },
+    methods: {
+        onClose() {
+            this.$emit('updateShow', false);
+        },
+        onConfirm(...args) {
+            this.$emit('update', this.newDuration);
+        },
+        resetLocalVars() {
+            this.localEndDate = dateString(this.endDate ?? new Date());
+        },
+    },
+    mounted() {
+        this.resetLocalVars();
+    },
+    watch: {
+        process() {
+            this.resetLocalVars();
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessEditDialog.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessEditDialog.vue
new file mode 100644
index 0000000000000000000000000000000000000000..56a8cb74b67311a724699df6a4c8bdc958e6bede
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ProcessEditDialog.vue
@@ -0,0 +1,65 @@
+<template>
+    <StudipDialog
+        :title="title"
+        :confirmText="$gettext('Speichern')"
+        confirmClass="accept"
+        :confirmDisabled="!changed"
+        :closeText="$gettext('Schließen')"
+        closeClass="cancel"
+        height="600"
+        width="800"
+        @close="$emit('close')"
+        @confirm="confirm"
+    >
+        <template #dialogContent>
+            <ProcessCreateForm :configuration="process.attributes.configuration" custom @update="updateConfiguration" />
+        </template>
+    </StudipDialog>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { mapGetters } from 'vuex';
+import { $gettext, $gettextInterpolate } from '../../../../../assets/javascripts/lib/gettext';
+import StudipDialog from '../../../StudipDialog.vue';
+import ProcessCreateForm from './ProcessCreateForm.vue';
+import { defaultConfiguration, ProcessConfiguration } from './process-configuration';
+
+export default Vue.extend({
+    components: { ProcessCreateForm, StudipDialog },
+    props: ['process'],
+    data: () => ({
+        changed: false,
+        configuration: defaultConfiguration(),
+    }),
+    computed: {
+        ...mapGetters({
+            relatedTaskGroups: 'courseware-task-groups/related',
+        }),
+        title() {
+            const taskGroup = this.relatedTaskGroups({ parent: this.process, relationship: 'task-group' });
+            return $gettextInterpolate($gettext('Peer-Review-Prozess konfigurieren zur Aufgabe "%{title}"'), {
+                title: taskGroup.attributes.title,
+            });
+        },
+    },
+    methods: {
+        confirm() {
+            this.$emit('update', {
+                process: this.process,
+                configuration: { ...this.configuration },
+            });
+        },
+        updateConfiguration(configuration: ProcessConfiguration) {
+            this.changed = true;
+            this.configuration = configuration;
+        },
+    },
+});
+</script>
+
+<style scoped>
+header {
+    margin-block-end: 2rem;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessStatus.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessStatus.vue
new file mode 100644
index 0000000000000000000000000000000000000000..faf366634aebfaa9faef3397b90cc7b88bcd208e
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ProcessStatus.vue
@@ -0,0 +1,40 @@
+<template>
+    <span class="peer-review-process-status">
+        <StudipIcon
+            v-if="status.shape !== undefined"
+            :shape="status.shape"
+            :role="status.role"
+            :title="status.description"
+            aria-hidden="true"
+        />
+        <span class="sr-only">{{ status.description }}</span>
+    </span>
+</template>
+<script lang="ts">
+import Vue, { PropType } from 'vue';
+import StudipIcon from '../../../StudipIcon.vue';
+import { JsonApiSchema, getProcessStatus } from './definitions';
+
+export default Vue.extend({
+    components: { StudipIcon },
+    props: {
+        process: {
+            type: Object as PropType<JsonApiSchema>,
+            required: true,
+        },
+    },
+    computed: {
+        status() {
+            return getProcessStatus(this.process);
+        },
+    },
+});
+</script>
+
+<style scoped>
+.peer-review-process-status {
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessesList.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessesList.vue
new file mode 100644
index 0000000000000000000000000000000000000000..b808c68e0e39870fc706e3ad40c8a951fdb46147
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ProcessesList.vue
@@ -0,0 +1,88 @@
+<template>
+    <table class="default">
+        <thead>
+            <tr>
+                <th>{{ $gettext('Status') }}</th>
+                <th>{{ $gettext('Titel') }}</th>
+                <th>{{ $gettext('Bearbeitungszeit') }}</th>
+            </tr>
+        </thead>
+        <tbody>
+            <tr v-for="process in sortedProcesses" :key="process.id">
+                <td>
+                    <ProcessStatus :process="process" />
+                </td>
+                <td>
+                    <router-link :to="{ name: 'peer-review-processes-show', params: { id: process.id } }">
+                        {{
+                            $gettextInterpolate($gettext('Peer-Review-Prozess zur Aufgabe "%{ title }"'), {
+                                title: taskTitle(process),
+                            })
+                        }}
+                    </router-link>
+                </td>
+                <td><StudipDate :date="getStartDate(process)" />–<StudipDate :date="getEndDate(process)" /></td>
+            </tr>
+        </tbody>
+    </table>
+</template>
+
+<script>
+import _ from 'lodash';
+import { mapActions, mapGetters } from 'vuex';
+import ProcessStatus from './ProcessStatus.vue';
+import StudipDate from '../../../StudipDate.vue';
+
+export default {
+    components: {
+        ProcessStatus,
+        StudipDate,
+    },
+    computed: {
+        ...mapGetters({
+            context: 'context',
+            relatedProcesses: 'courseware-peer-review-processes/related',
+            relatedTaskGroups: 'courseware-task-groups/related',
+            taskGroups: 'courseware-task-groups/all',
+        }),
+        sortedProcesses() {
+            return _.sortBy(this.processes, [({ attributes }) => -new Date(attributes['review-end'])]);
+        },
+        processes() {
+            return this.taskGroups.reduce((memo, parent) => {
+                const result = memo.concat(
+                    this.relatedProcesses({ parent, relationship: 'peer-review-processes' }) ?? []
+                );
+                return result;
+            }, []);
+        },
+    },
+    methods: {
+        ...mapActions({
+            loadCourseMemberships: 'course-memberships/loadRelated',
+            loadCourseStatusGroups: 'status-groups/loadRelated',
+            loadTasks: 'tasks/loadTasksOfCourse',
+        }),
+        getEndDate(process) {
+            return new Date(process.attributes['review-end']);
+        },
+        getStartDate(process) {
+            return new Date(process.attributes['review-start']);
+        },
+        taskTitle(process) {
+            const taskGroup = this.relatedTaskGroups({ parent: process, relationship: 'task-group' });
+            return taskGroup?.attributes.title ?? '';
+        },
+    },
+    mounted() {
+        this.loadTasks({ cid: this.context.id });
+        const parent = { type: 'courses', id: this.context.id };
+        this.loadCourseMemberships({
+            parent,
+            relationship: 'memberships',
+            options: { include: 'user', 'page[offset]': 0, 'page[limit]': 10000, 'filter[permission]': 'autor' },
+        });
+        this.loadCourseStatusGroups({ parent, relationship: 'status-groups' });
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessesListItem.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessesListItem.vue
new file mode 100644
index 0000000000000000000000000000000000000000..0a17785803131b2b226829670204895d1eeac54b
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ProcessesListItem.vue
@@ -0,0 +1,230 @@
+<template>
+    <StudipArticle collapsable closed class="cw-peer-review-processes-list-item">
+        <template #title>
+            <ProcessStatus :process="process" />
+            <span class="peer-review-process-title">
+                {{
+                    $gettextInterpolate($gettext('Peer-Review-Prozess zur Aufgabe "%{ title }"'), {
+                        title: taskGroup.attributes.title,
+                    })
+                }}
+            </span>
+        </template>
+
+        <template #titleplus>
+            <span> {{ $gettext('Bearbeitungszeit:') }} <StudipDate :date="startDate" />–<StudipDate :date="endDate" /> </span>
+        </template>
+
+        <template #body>
+            <section>
+                <article>
+                    <header>
+                        <h2>Status</h2>
+                    </header>
+                    <div>
+                        <ProcessStatus :process="process" />
+                        {{ processStatus.description }}
+                    </div>
+                </article>
+                <article>
+                    <header>
+                        <h2>Owner</h2>
+                    </header>
+                    <div>
+                        <span>
+                            <img :src="owner.meta.avatar.small" />
+                        </span>
+                        {{ owner.attributes['formatted-name'] }}
+                    </div>
+                </article>
+                <article>
+                    <header>
+                        <h2>Aufgabe</h2>
+                    </header>
+                    <div>
+                        <a :href="`#task-groups-${taskGroup.id}`">{{ taskGroup.attributes.title }}</a>
+                        <StudipDate :date="new Date(taskGroup.attributes['start-date'])" />–<StudipDate :date="new Date(taskGroup.attributes['end-date'])" />
+                    </div>
+                </article>
+                <article>
+                    <header>
+                        <h2>{{ $gettext('Einstellungen') }}</h2>
+                    </header>
+                    <div v-if="!canChangeConfiguration">
+                        <StudipMessageBox hideClose>
+                            {{
+                                $gettext(
+                                    'Der Peer-Review-Prozess hat bereits begonnen. Bis auf die Bearbeitungsdauer können die Einstellungen nicht geändert werden.'
+                                )
+                            }}
+                        </StudipMessageBox>
+                    </div>
+                    <div>
+                        <ProcessConfiguration :options="configuration" />
+                    </div>
+                    <div v-if="canChangeConfiguration">
+                        <button class="button" @click="onShowConfiguration">
+                            {{ $gettext('Einstellungen ändern') }}
+                        </button>
+                        <div v-if="configuration.type === 'form' || configuration.type === 'table'">
+                            <button class="button" @click="onShowAssessmentTypeEditor">
+                                <span v-if="configuration.type === 'form'">
+                                    {{ $gettext('Formular ändern') }}
+                                </span>
+                                <span v-if="configuration.type === 'table'">
+                                    {{ $gettext('Tabelle ändern') }}
+                                </span>
+                            </button>
+                        </div>
+                    </div>
+                    <div v-if="canChangeDurationOnly">
+                        <button class="button" @click="onShowDuration">{{ $gettext('Bearbeitungszeit verlängern') }}</button>
+                    </div>
+                </article>
+
+                <StudipArticle collapsable closed>
+                    <template #title>
+                        {{ $gettext('Peer-Reviews') }}
+                    </template>
+                    <template #body>
+                        <PeerReviewList v-if="!isBefore" :process="process" :task-group="taskGroup" />
+                        <template v-else>
+                            <CompanionBox
+                                v-if="isAutomaticPairing"
+                                :msgCompanion="
+                                    $gettext(
+                                        'In diesem Peer-Review-Prozess werden die Paarungen automatisch verteilt, sobald der Bearbeitungszeitraum beginnt.'
+                                    )
+                                "
+                            >
+                            </CompanionBox>
+                            <CompanionBox
+                                v-else
+                                mood="pointing"
+                                :msgCompanion="
+                                    $gettext(
+                                        'In diesem Peer-Review-Prozess werden die Paarungen manuell verteilt, bevor der Bearbeitungszeitraum beginnt.'
+                                    )
+                                "
+                            >
+                                <template #companionActions>
+                                    <button class="button" @click="$emit('showPairingEditor', process)">
+                                        {{ $gettext('Paarungen manuell festlegen') }}
+                                    </button>
+                                </template>
+                            </CompanionBox>
+                        </template>
+                    </template>
+                </StudipArticle>
+
+                <StudipArticle collapsable closed>
+                    <template #title> Solvers </template>
+                    <template #body>
+                        <pre>{{ solvers }}</pre>
+                    </template>
+                </StudipArticle>
+            </section>
+        </template>
+    </StudipArticle>
+</template>
+
+<script>
+import { mapGetters } from 'vuex';
+import StudipArticle from '../../../StudipArticle.vue';
+import StudipDate from '../../../StudipDate.vue';
+import StudipMessageBox from '../../../StudipMessageBox.vue';
+import CompanionBox from '../../layouts/CoursewareCompanionBox.vue';
+import ProcessConfiguration from './ProcessConfiguration.vue';
+import ProcessStatus from './ProcessStatus.vue';
+import PeerReviewList from './PeerReviewList.vue';
+import { getProcessStatus, ProcessStatus as Status } from './definitions';
+
+export default {
+    components: {
+        CompanionBox,
+        PeerReviewList,
+        ProcessConfiguration,
+        ProcessStatus,
+        StudipArticle,
+        StudipDate,
+        StudipMessageBox,
+    },
+    props: {
+        process: {
+            type: Object,
+            required: true,
+        },
+    },
+    computed: {
+        ...mapGetters({
+            relatedPeerReviews: 'courseware-peer-reviews/related',
+            relatedTasks: 'courseware-tasks/related',
+            relatedTaskGroups: 'courseware-task-groups/related',
+            relatedUsers: 'users/related',
+        }),
+        canChangeConfiguration() {
+            return this.isBefore;
+        },
+        canChangeDurationOnly() {
+            return this.processStatus.status === Status.Active;
+        },
+        configuration() {
+            return this.process.attributes['configuration'];
+        },
+        endDate() {
+            return new Date(this.process.attributes['review-end']);
+        },
+        isBefore() {
+            return this.processStatus.status === Status.Before;
+        },
+        isAutomaticPairing() {
+            return this.configuration.automaticPairing;
+        },
+        owner() {
+            return this.relatedUsers({ parent: this.process, relationship: 'owner' });
+        },
+        peerReviews() {
+            const result = this.relatedPeerReviews({ parent: this.process, relationship: 'peer-reviews' });
+            console.debug(result);
+            return result;
+        },
+        processStatus() {
+            return getProcessStatus(this.process);
+        },
+        solvers() {
+            return this.taskGroup.relationships.solvers.data.map(({ id, type }) => {
+                return [id, type];
+            });
+        },
+        startDate() {
+            return new Date(this.process.attributes['review-start']);
+        },
+        taskGroup() {
+            return this.relatedTaskGroups({ parent: this.process, relationship: 'task-group' });
+        },
+        tasks() {
+            return this.relatedTasks({ parent: this.taskGroup, relationship: 'tasks' });
+        },
+    },
+    methods: {
+        onShowAssessmentTypeEditor() {
+            this.$emit('showAssessmentTypeEditor', this.process);
+        },
+        onShowConfiguration() {
+            this.$emit('showConfiguration', this.process);
+        },
+        onShowDuration() {
+            this.$emit('showDuration', this.process);
+        },
+    },
+};
+</script>
+
+<style>
+.cw-peer-review-processes-list-item:target {
+    border-color: var(--base-color);
+}
+.peer-review-process-title {
+    margin-inline-start: 0.5rem;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/SidebarActionWidget.vue b/resources/vue/components/courseware/tasks/peer-review/SidebarActionWidget.vue
new file mode 100644
index 0000000000000000000000000000000000000000..e814121ec3bf653da6f6fad0eeec4747b90b7bf0
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/SidebarActionWidget.vue
@@ -0,0 +1,21 @@
+<template>
+    <sidebar-widget id="courseware-peer-review-action-widget" :title="$gettext('Aktionen')">
+        <template #content>
+        </template>
+    </sidebar-widget>
+</template>
+
+<script>
+import SidebarWidget from '../../../SidebarWidget.vue';
+
+export default {
+    components: {
+        SidebarWidget,
+    },
+    props: [],
+    computed: {
+    },
+    methods: {
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/peer-review/definitions.ts b/resources/vue/components/courseware/tasks/peer-review/definitions.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2e6729edf4430a293258bad2909c9ecb5e9a6503
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/definitions.ts
@@ -0,0 +1,57 @@
+import { $gettext } from '../../../../../assets/javascripts/lib/gettext';
+
+export enum ProcessStatus {
+    Before = 'before',
+    After = 'after',
+    Active = 'active',
+}
+
+export interface StatusDescriptor {
+    status: ProcessStatus;
+    shape: string;
+    role: string;
+    description: string;
+}
+
+interface StringDict {
+    [key: string]: string;
+}
+
+export interface JsonApiSchema {
+    id?: string;
+    type: string;
+    attributes: StringDict;
+    meta?: StringDict;
+    relationships?: StringDict;
+}
+
+export function getProcessStatus(process: JsonApiSchema): StatusDescriptor {
+    const now = new Date();
+    const startDate = new Date(process.attributes['review-start']);
+    const endDate = new Date(process.attributes['review-end']);
+
+    if (now < startDate) {
+        return {
+            status: ProcessStatus.Before,
+            shape: 'span-empty',
+            role: 'status-yellow',
+            description: $gettext('Peer-Review-Process noch nicht aktiv'),
+        };
+    }
+
+    if (endDate < now) {
+        return {
+            status: ProcessStatus.After,
+            shape: 'span-full',
+            role: 'status-red',
+            description: $gettext('Peer-Review-Process beendet'),
+        };
+    }
+
+    return {
+        status: ProcessStatus.Active,
+        shape: 'span-empty',
+        role: 'status-green',
+        description: $gettext('Peer-Review-Prozess aktiv'),
+    };
+}
diff --git a/resources/vue/components/courseware/tasks/peer-review/process-configuration.ts b/resources/vue/components/courseware/tasks/peer-review/process-configuration.ts
new file mode 100644
index 0000000000000000000000000000000000000000..04b952a847775fdb0d6d56dcbcada076d5f621df
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/process-configuration.ts
@@ -0,0 +1,129 @@
+import { $gettext } from '../../../../../assets/javascripts/lib/gettext';
+
+export enum AssessmentType {
+    Form = 'form',
+    Freetext = 'freetext',
+    Table = 'table',
+}
+
+export interface EditorFormCriterium {
+    text: string;
+    description: string;
+}
+
+export interface EditorTableCriterium {
+    text: string;
+}
+
+export type FormAssessmentPayload = { criteria: EditorFormCriterium[] };
+export type TableAssessmentPayload = { criteria: EditorTableCriterium[] };
+export type FreetextAssessmentPayload = {};
+
+export type ProcessConfigurationPayload = FormAssessmentPayload | FreetextAssessmentPayload | TableAssessmentPayload;
+
+export interface ProcessConfiguration {
+    anonymous: boolean;
+    duration: number;
+    automaticPairing: boolean;
+    type: AssessmentType;
+    payload?: ProcessConfigurationPayload;
+}
+
+export interface ConfigurationSet {
+    name: string;
+    configuration: ProcessConfiguration;
+}
+
+export const ASSESSMENT_TYPES = {
+    [AssessmentType.Form]: {
+        short: $gettext('Formular'),
+        long: $gettext('Strukturiertes Bewertungssystem mit detailierten Fragen zur Begutachtung'),
+        defaultPayload: { criteria: defaultCriteriaForm() },
+    },
+    [AssessmentType.Freetext]: {
+        short: $gettext('Freitext'),
+        long: $gettext('Freitextliche Begutachtung'),
+        defaultPayload: { },
+    },
+    [AssessmentType.Table]: {
+        short: $gettext('Tabelle'),
+        long: $gettext('Einfaches Bewertungssystem mit 3 Bewertungsnoten und kurzer Erläuterung'),
+        defaultPayload: { criteria: defaultCriteriaTable() },
+    },
+};
+
+export const CONFIGURATION_SETS: Array<ConfigurationSet> = [
+    {
+        name: $gettext('Kurz und bündig'),
+        configuration: {
+            anonymous: true,
+            duration: 7,
+            automaticPairing: true,
+            type: AssessmentType.Table,
+            payload: ASSESSMENT_TYPES[AssessmentType.Table].defaultPayload,
+        },
+    },
+    {
+        name: $gettext('Strukturiert begleitet'),
+        configuration: {
+            anonymous: true,
+            duration: 7,
+            automaticPairing: true,
+            type: AssessmentType.Form,
+            payload: ASSESSMENT_TYPES[AssessmentType.Form].defaultPayload,
+        },
+    },
+    {
+        name: $gettext('Selbstbestimmt'),
+        configuration: {
+            anonymous: true,
+            duration: 7,
+            automaticPairing: true,
+            type: AssessmentType.Freetext,
+            payload: ASSESSMENT_TYPES[AssessmentType.Freetext].defaultPayload,
+        },
+    },
+];
+
+export function defaultConfiguration(): ProcessConfiguration {
+    return CONFIGURATION_SETS[0].configuration;
+}
+
+function defaultCriteriaForm() {
+    return [
+        {
+            text: $gettext('Aufbau'),
+            description: $gettext(
+                'Wo sind die grundlegenden Abschnitte (Einführung, Schlussfolgerung, Literatur, Zitate, usw.) und sind sie angemessen? Wenn nicht, was fehlt?\nHat der Schreiber verschiedene Überschriftenstile verwendet um die Abschnitte klar zu kennzeichnen? Kurze Erklärung.\nWie wurde der Inhalt geordnet? War er logisch, klar und leicht zu folgen? Kurze Erklärung.'
+            ),
+        },
+        {
+            text: $gettext('Grammatik und Stil'),
+            description: $gettext(
+                'Gibt es grammatische oder orthografische Probleme?\nIst der Schreibstil klar? Sind die Absätze und die enthaltenen Sätze zusammengehörig?'
+            ),
+        },
+        {
+            text: $gettext('Inhalt'),
+            description: $gettext(
+                'Hat der Autor hinreichend verdichtet und die Aufgabe diskutiert? Kurze Erklärung.\nHat der Autor umfassend Material aus Standardquellen benutzt? Wenn nicht, was fehlt?\nHat der Autor auch eigene Gedanken beigetragen, oder hat er mehrheitlich Zusammenfassungen von Veröffentlichungen/Daten zusammengetragen? Kurze Erklärung.'
+            ),
+        },
+        {
+            text: $gettext('Zitate'),
+            description: $gettext(
+                'Hat der Autor Zitatquellen passend und korrekt angebeben? Notiere unkorrekte Formatierungen.\nSind alle Zitate auch in dem Literaturhinweis zu finden? Notiere die Unstimmigkeiten.'
+            ),
+        },
+    ];
+}
+
+function defaultCriteriaTable() {
+    return [
+        { text: $gettext('These: Klarheit, Bedeutung') },
+        { text: $gettext('Belege: Relevanz, Glaubwürdigkeit, Aussagekraft') },
+        { text: $gettext('Aufbau: Anordnung des Inhalts, Nachvollziehbarkeit') },
+        { text: $gettext('Handwerk: Orthografie, Grammatik, Zeichensetzung') },
+        { text: $gettext('Gesamtwirkung') },
+    ];
+}
diff --git a/resources/vue/courseware-index-app.js b/resources/vue/courseware-index-app.js
index f942b4eceac49b2cf195fde30d7479bd869ddb4b..761b7f95e7987f6223f99701b3c7799acebbb3a0 100644
--- a/resources/vue/courseware-index-app.js
+++ b/resources/vue/courseware-index-app.js
@@ -95,6 +95,8 @@ const mountApp = async (STUDIP, createApp, element) => {
                     'courseware-containers',
                     'courseware-instances',
                     'courseware-public-links',
+                    'courseware-peer-reviews',
+                    'courseware-peer-review-processes',
                     'courseware-structural-elements',
                     'courseware-structural-element-comments',
                     'courseware-structural-element-feedback',
diff --git a/resources/vue/courseware-tasks-app.js b/resources/vue/courseware-tasks-app.js
index 9c01b7190f22bad0b06bdf078bf1b5a6aefcc83a..94dfef83fe38cf19a7e656525b0c30d74535f0ce 100644
--- a/resources/vue/courseware-tasks-app.js
+++ b/resources/vue/courseware-tasks-app.js
@@ -1,3 +1,5 @@
+import PeerReviewProcessesIndex from './components/courseware/tasks/peer-review/PagesProcessesIndex.vue';
+import PeerReviewProcessesShow from './components/courseware/tasks/peer-review/PagesProcessesShow.vue';
 import TaskGroupsIndex from './components/courseware/tasks/PagesTaskGroupsIndex.vue';
 import TaskGroupsShow from './components/courseware/tasks/PagesTaskGroupsShow.vue';
 import { mapResourceModules } from '@elan-ev/reststate-vuex';
@@ -31,6 +33,17 @@ const mountApp = async (STUDIP, createApp, element) => {
             component: TaskGroupsShow,
             props: true,
         },
+        {
+            path: '/peer-review-processes',
+            name: 'peer-review-processes-index',
+            component: PeerReviewProcessesIndex,
+        },
+        {
+            path: '/peer-review-processes/:id',
+            name: 'peer-review-processes-show',
+            component: PeerReviewProcessesShow,
+            props: true,
+        },
     ];
 
     const base = new URL(
@@ -69,6 +82,8 @@ const mountApp = async (STUDIP, createApp, element) => {
                     'courseware-block-feedback',
                     'courseware-containers',
                     'courseware-instances',
+                    'courseware-peer-reviews',
+                    'courseware-peer-review-processes',
                     'courseware-structural-elements',
                     'courseware-task-feedback',
                     'courseware-task-groups',
diff --git a/resources/vue/store/courseware/courseware-tasks.module.js b/resources/vue/store/courseware/courseware-tasks.module.js
index e9d153d15acfc1ecefa7d949eb0bbd1de6524ae7..96af9393dd675b8d1bdc049497072a47fa5c9cd3 100644
--- a/resources/vue/store/courseware/courseware-tasks.module.js
+++ b/resources/vue/store/courseware/courseware-tasks.module.js
@@ -1,3 +1,5 @@
+import { ASSESSMENT_TYPES } from '../../components/courseware/tasks/peer-review/process-configuration';
+
 const getDefaultState = () => {
     return {
         showTaskGroupsDeleteDialog: false,
@@ -55,14 +57,14 @@ export const actions = {
         const options = {
             'filter[cid]': cid,
             include:
-                'solver, structural-element, task-feedback, task-group, task-group.lecturer',
+                'solver, structural-element, task-feedback, task-group, task-group.lecturer, task-group.peer-review-processes',
         };
         return dispatch('courseware-tasks/loadAll', { options }, { root: true });
     },
 
     loadTaskGroup({ dispatch }, { id }) {
         const options = {
-            include: 'lecturer',
+            include: 'lecturer, peer-review-processes',
         };
         return dispatch('courseware-task-groups/loadById', { id, options }, { root: true });
     },
@@ -73,6 +75,78 @@ export const actions = {
         return dispatch('courseware-task-groups/update', taskGroup, { root: true });
     },
 
+    createPeerReviewProcess({ dispatch }, { taskGroup, options }) {
+        const { anonymous, duration, automaticPairing, type, payload } = options;
+        const startDate = new Date(taskGroup.attributes['end-date']);
+        const endDate = new Date(startDate);
+        endDate.setDate(endDate.getDate() + duration);
+
+        const data = {
+            attributes: {
+                configuration: { anonymous, duration, automaticPairing, type, payload },
+                'review-start': startDate.toISOString(),
+                'review-end': endDate.toISOString(),
+            },
+            relationships: {
+                'task-group': {
+                    data: {
+                        type: taskGroup.type,
+                        id: taskGroup.id,
+                    },
+                },
+            },
+        };
+
+        return dispatch('courseware-peer-review-processes/create', data, { root: true });
+    },
+    replacePairings({ dispatch, rootGetters }, { process, pairings }) {
+        const reviews = rootGetters['courseware-peer-reviews/related']({
+            parent: process,
+            relationship: 'peer-reviews',
+        });
+        const relation = ({ id, type }) => ({ data: { id, type } });
+        const deleteReview = (review) => dispatch('courseware-peer-reviews/delete', review, { root: true });
+        const createReview = (pairing) =>
+            dispatch(
+                'courseware-peer-reviews/create',
+                {
+                    type: 'courseware-peer-reviews',
+                    attributes: {},
+                    relationships: {
+                        process: relation(process),
+                        submitter: relation(pairing.submitter),
+                        reviewer: relation(pairing.reviewer),
+                    },
+                },
+                { root: true }
+            );
+
+        return Promise.all(reviews.map(deleteReview)).then(() => Promise.all(pairings.map(createReview)));
+    },
+    updatePeerReviewProcess({ dispatch, rootGetters }, { process, configuration }) {
+        const taskGroup = rootGetters['courseware-task-groups/related']({
+            parent: process,
+            relationship: 'task-group',
+        });
+
+        const startDate = new Date(taskGroup.attributes['end-date']);
+        const endDate = new Date(startDate);
+        endDate.setDate(endDate.getDate() + configuration.duration);
+
+        if (_.isEmpty(configuration.payload)) {
+            configuration.payload = ASSESSMENT_TYPES[configuration.type].defaultPayload;
+        }
+
+        process.attributes.configuration = configuration;
+        process.attributes['review-start'] = startDate.toISOString();
+        process.attributes['review-end'] = endDate.toISOString();
+
+        return dispatch('courseware-peer-review-processes/update', process, { root: true });
+    },
+    storeAssessment({ dispatch, rootGetters }, { review, assessment }) {
+        review.attributes.assessment = assessment;
+        return dispatch('courseware-peer-reviews/update', review, { root: true });
+    },
 };
 
 export const mutations = {
diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js
index 89d7da82d7ecdd27f0f8f0ae571e2443bf610fb5..ecf4664b72a541e7ed650787b65276b322845d43 100644
--- a/resources/vue/store/courseware/courseware.module.js
+++ b/resources/vue/store/courseware/courseware.module.js
@@ -1263,7 +1263,7 @@ export const actions = {
             {
                 id: taskId,
                 options: {
-                    include: 'solver,task-group,task-group.lecturer',
+                    include: 'solver,task-group,task-group.lecturer,peer-reviews.process',
                 },
             },
             { root: true }