From d3f77ea51dd72b055307ac259c3caf8091307aa6 Mon Sep 17 00:00:00 2001
From: Mole Shang <135e2@135e2.dev>
Date: Sun, 15 Dec 2024 00:00:13 +0800
Subject: init

---
 src/main/java/seu/se/ApiController.java      | 370 +++++++++++++++++++++++++++
 src/main/java/seu/se/Application.java        | 102 ++++++++
 src/main/java/seu/se/Exercise.java           | 125 +++++++++
 src/main/java/seu/se/TreeNode.java           | 215 ++++++++++++++++
 src/main/java/seu/se/TreeNodeRepository.java |   8 +
 src/main/java/seu/se/User.java               |  65 +++++
 src/main/java/seu/se/UserRepository.java     |  11 +
 7 files changed, 896 insertions(+)
 create mode 100644 src/main/java/seu/se/ApiController.java
 create mode 100644 src/main/java/seu/se/Application.java
 create mode 100644 src/main/java/seu/se/Exercise.java
 create mode 100644 src/main/java/seu/se/TreeNode.java
 create mode 100644 src/main/java/seu/se/TreeNodeRepository.java
 create mode 100644 src/main/java/seu/se/User.java
 create mode 100644 src/main/java/seu/se/UserRepository.java

(limited to 'src/main/java/seu/se')

diff --git a/src/main/java/seu/se/ApiController.java b/src/main/java/seu/se/ApiController.java
new file mode 100644
index 0000000..17a6702
--- /dev/null
+++ b/src/main/java/seu/se/ApiController.java
@@ -0,0 +1,370 @@
+package seu.se;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import java.util.HashSet;
+import java.util.List;
+
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+
+@RequestMapping("/api")
+@RestController
+public class ApiController {
+    public static abstract class DataObject {
+        public Boolean ok;
+        public String message;
+        public Object otherData;
+    }
+
+    public static abstract class ReturnObject {
+        public String status;
+        public DataObject dataObj;
+    }
+
+    @GetMapping(value = "/ping")
+    public String Ping() {
+        return "Pong!";
+    }
+
+    @RequestMapping("/api/user")
+    @RestController
+    static class UserController {
+        private final UserRepository uRepo;
+
+        UserController(UserRepository uRepo) {
+            this.uRepo = uRepo;
+        }
+
+        @ApiResponses(value = {
+                @ApiResponse(responseCode = "200", description = "Ok", content =
+                        {@Content(mediaType = "application/json", schema =
+                        @Schema(implementation = ReturnObject.class))}),
+                @ApiResponse(responseCode = "400", description = "Invalid userId supplied"),
+                @ApiResponse(responseCode = "500", description = "Internal server error")})
+        @PostMapping("/login")
+        public JsonNode Login(@RequestBody com.fasterxml.jackson.databind.JsonNode body) {
+            var id = body.get("userId").asInt();
+            var password = body.get("password").asText();
+            var user = uRepo.findById(id);
+            var retObj = new ObjectMapper().createObjectNode();
+            var dataObj = new ObjectMapper().createObjectNode();
+            String message;
+            Boolean ok;
+            if (user != null) {
+                if (password.equals(user.getPassword())) {
+                    ok = true;
+                    message = "Login Success";
+                    // save session
+                    ServletRequestAttributes attr = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
+                    attr.getRequest().getSession(true).setAttribute("SESSION", user);
+
+                } else {
+                    ok = false;
+                    message = "Wrong password";
+                }
+                dataObj = new ObjectMapper().valueToTree(user);
+            } else {
+                ok = false;
+                message = "User not found";
+            }
+            dataObj.put("ok", ok);
+            dataObj.put("message", message);
+            retObj.set("data", dataObj);
+            retObj.put("status", "ok");
+            return retObj;
+        }
+
+        @ApiResponses(value = {
+                @ApiResponse(responseCode = "200", description = "Ok", content =
+                        {@Content(mediaType = "application/json", schema =
+                        @Schema(implementation = ReturnObject.class))}),
+                @ApiResponse(responseCode = "500", description = "Internal server error")})
+        @PostMapping("/register")
+        public JsonNode Register(@RequestBody com.fasterxml.jackson.databind.JsonNode body) {
+            var username = body.get("username").asText();
+            var password = body.get("password").asText();
+            String message;
+            Boolean ok;
+            var retObj = new ObjectMapper().createObjectNode();
+            var dataObj = new ObjectMapper().createObjectNode();
+            try {
+                var user = uRepo.save(new User(username, password));
+                ok = true;
+                message = "Registered";
+                dataObj = new ObjectMapper().valueToTree(user);
+            } catch (User.UserException ue) {
+                ok = false;
+                message = ue.getMessage();
+            }
+            dataObj.put("ok", ok);
+            dataObj.put("message", message);
+            retObj.set("data", dataObj);
+            retObj.put("status", "ok");
+            return retObj;
+        }
+    }
+
+    @RequestMapping("/api/node")
+    @RestController
+    static class TreeNodeController {
+        private final TreeNodeRepository tnRepo;
+
+        TreeNodeController(TreeNodeRepository tnRepo) {
+            this.tnRepo = tnRepo;
+        }
+
+        @ApiResponses(value = {
+                @ApiResponse(responseCode = "200", description = "Ok", content =
+                        {@Content(mediaType = "application/json", schema =
+                        @Schema(implementation = ReturnObject.class))}),
+                @ApiResponse(responseCode = "500", description = "Internal server error")})
+        @GetMapping("/content/{nodeId}")
+        public JsonNode getContent(@PathVariable String nodeId) {
+            String message;
+            Boolean ok;
+            var retObj = new ObjectMapper().createObjectNode();
+            var dataObj = new ObjectMapper().createObjectNode();
+            var treeNode = tnRepo.findByNodeId(nodeId);
+            if (treeNode != null) {
+                ok = true;
+                message = "Success";
+                dataObj.put("nodeId", treeNode.getNodeId());
+                dataObj.put("content", treeNode.getContent());
+            } else {
+                ok = false;
+                message = "Node not found";
+            }
+            dataObj.put("ok", ok);
+            dataObj.put("message", message);
+            retObj.set("data", dataObj);
+            retObj.put("status", "ok");
+            return retObj;
+        }
+
+        @ApiResponses(value = {
+                @ApiResponse(responseCode = "200", description = "Ok", content =
+                        {@Content(mediaType = "application/json", schema =
+                        @Schema(implementation = ReturnObject.class))}),
+                @ApiResponse(responseCode = "500", description = "Internal server error")})
+        @GetMapping("/exercises/{nodeId}")
+        public JsonNode getExercises(@PathVariable String nodeId) {
+            String message;
+            Boolean ok;
+            var retObj = new ObjectMapper().createObjectNode();
+            var dataObj = new ObjectMapper().createObjectNode();
+            var treeNode = tnRepo.findByNodeId(nodeId);
+            if (treeNode != null) {
+                ok = true;
+                message = "Success";
+                dataObj.put("nodeId", treeNode.getNodeId());
+                var exercisesObj = new ObjectMapper().createArrayNode();
+                for (var i : treeNode.getExercises()) {
+                    var exerciseObj = new ObjectMapper().valueToTree(i);
+                    exercisesObj.add(exerciseObj);
+                }
+                dataObj.set("exercises", exercisesObj);
+            } else {
+                ok = false;
+                message = "Node not found";
+            }
+            dataObj.put("ok", ok);
+            dataObj.put("message", message);
+            retObj.set("data", dataObj);
+            retObj.put("status", "ok");
+
+            ServletRequestAttributes attr = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
+            System.out.println(attr.getRequest().getSession(true).getAttribute("SESSION"));
+
+            return retObj;
+        }
+
+        @ApiResponses(value = {
+                @ApiResponse(responseCode = "200", description = "Ok", content =
+                        {@Content(mediaType = "application/json", schema =
+                        @Schema(implementation = ReturnObject.class))}),
+                @ApiResponse(responseCode = "500", description = "Internal server error")})
+        @PostMapping("/submit/{nodeId}")
+        public JsonNode Submit(@PathVariable String nodeId, @RequestBody com.fasterxml.jackson.databind.JsonNode body) {
+            String message;
+            Boolean ok;
+            var retObj = new ObjectMapper().createObjectNode();
+            var dataObj = new ObjectMapper().createObjectNode();
+            var treeNode = tnRepo.findByNodeId(nodeId);
+            ServletRequestAttributes attr = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
+            var user = (User) attr.getRequest().getSession(true).getAttribute("SESSION");
+            if (treeNode == null) {
+                ok = false;
+                message = "Node not found";
+            } else if (user == null) {
+                ok = false;
+                message = "Login first";
+            } else {
+                var score = body.get("score").asDouble();
+                var usp = treeNode.getUserScorePair(user.getId(), nodeId);
+                if (usp != null) {
+                    usp.getScores().add(score);
+                } else {
+                    treeNode.getUserScores().add(new TreeNode.UserScorePair(treeNode.getNodeId(), user.getId(), List.of(score)));
+                }
+                tnRepo.save(treeNode);
+                ok = true;
+                message = "Success";
+                dataObj.put("userId", user.getId());
+                dataObj.put("nodeId", nodeId);
+            }
+            dataObj.put("ok", ok);
+            dataObj.put("message", message);
+            retObj.set("data", dataObj);
+            retObj.put("status", "ok");
+            return retObj;
+        }
+
+        @ApiResponses(value = {
+                @ApiResponse(responseCode = "200", description = "Ok", content =
+                        {@Content(mediaType = "application/json", schema =
+                        @Schema(implementation = ReturnObject.class))}),
+                @ApiResponse(responseCode = "500", description = "Internal server error")})
+        @GetMapping("/history/{nodeId}")
+        public JsonNode History(@PathVariable String nodeId) {
+            String message;
+            Boolean ok;
+            var retObj = new ObjectMapper().createObjectNode();
+            var dataObj = new ObjectMapper().createObjectNode();
+            var treeNode = tnRepo.findByNodeId(nodeId);
+            ServletRequestAttributes attr = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
+            var user = (User) attr.getRequest().getSession(true).getAttribute("SESSION");
+            if (treeNode == null) {
+                ok = false;
+                message = "Node not found";
+            } else if (user == null) {
+                ok = false;
+                message = "Login first";
+            } else {
+                var usp = treeNode.getUserScorePair(user.getId(), nodeId);
+                if (usp != null) {
+                    var uspObj = new ObjectMapper().valueToTree(usp);
+                    ok = true;
+                    message = "Success";
+                    dataObj.set("historyScores", uspObj);
+                } else {
+                    ok = false;
+                    message = "No score found";
+                }
+                dataObj.put("userId", user.getId());
+            }
+            dataObj.put("ok", ok);
+            dataObj.put("message", message);
+            retObj.set("data", dataObj);
+            retObj.put("status", "ok");
+            return retObj;
+        }
+
+        @ApiResponses(value = {
+                @ApiResponse(responseCode = "200", description = "Ok", content =
+                        {@Content(mediaType = "application/json", schema =
+                        @Schema(implementation = ReturnObject.class))}),
+                @ApiResponse(responseCode = "500", description = "Internal server error")})
+        @GetMapping("/home")
+        public JsonNode Home() {
+            var retObj = new ObjectMapper().createObjectNode();
+            var dataObj = new ObjectMapper().createObjectNode();
+            String message;
+            Boolean ok;
+            var nodes = tnRepo.findAll();
+            var roots = new HashSet<TreeNode>();
+            for (var n : nodes) {
+                if (n.getLevel() == 1) roots.add(n);
+            }
+            if (roots.isEmpty()) {
+                ok = false;
+                message = "No root found";
+            } else {
+                class RecursiveHelper {
+                    public final List<TreeNode> nodes;
+
+                    public RecursiveHelper(List<TreeNode> nodes) {
+                        this.nodes = nodes;
+                    }
+
+                    public ObjectNode RecursiveConstruct(TreeNode tn) {
+                        ObjectNode graphNodesObj = new ObjectMapper().valueToTree(tn);
+                        ArrayNode graphNodesIterateArr = new ObjectMapper().createArrayNode();
+                        var children = tn.getChildren(nodes);
+                        if (!children.isEmpty()) {
+                            children.forEach(e -> {
+                                graphNodesIterateArr.add(RecursiveConstruct(e));
+                            });
+                            graphNodesObj.set("children", graphNodesIterateArr);
+                        }
+                        return graphNodesObj;
+                    }
+                }
+                ArrayNode graphNodesIterateArr = new ObjectMapper().createArrayNode();
+                for (var root : roots) {
+                    graphNodesIterateArr.add(new RecursiveHelper(nodes).RecursiveConstruct(root));
+                }
+                dataObj.set("graphNodes", graphNodesIterateArr);
+                ok = true;
+                message = "Success";
+            }
+            dataObj.put("ok", ok);
+            dataObj.put("message", message);
+            retObj.set("data", dataObj);
+            retObj.put("status", "ok");
+            return retObj;
+        }
+
+        @ApiResponses(value = {
+                @ApiResponse(responseCode = "200", description = "Ok", content =
+                        {@Content(mediaType = "application/json", schema =
+                        @Schema(implementation = ReturnObject.class))}),
+                @ApiResponse(responseCode = "500", description = "Internal server error")})
+        @GetMapping("/review-suggestion")
+        public JsonNode ReviewSuggestion() {
+            String message;
+            Boolean ok;
+            var retObj = new ObjectMapper().createObjectNode();
+            var dataObj = new ObjectMapper().createObjectNode();
+            ServletRequestAttributes attr = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
+            var user = (User) attr.getRequest().getSession(true).getAttribute("SESSION");
+            if (user == null) {
+                ok = false;
+                message = "Login first";
+            } else {
+                var reviewSuggestionsArr = new ObjectMapper().createArrayNode();
+                var nodes = tnRepo.findAll();
+                for (var node : nodes) {
+                    var usp = node.getUserScorePair(user.getId(), node.getNodeId());
+                    if (usp == null) continue;
+                    var msl = TreeNode.UserScorePair.MasteryLevel.getMasteryLevel(usp.getAverageScore());
+                    if (msl.equals(TreeNode.UserScorePair.MasteryLevel.Average) || msl.equals(TreeNode.UserScorePair.MasteryLevel.Bad))
+                        reviewSuggestionsArr.add(node.getNodeId());
+                }
+                ok = true;
+                message = "Success";
+                dataObj.put("userId", user.getId());
+                dataObj.set("reviewSuggestions", reviewSuggestionsArr);
+            }
+            dataObj.put("ok", ok);
+            dataObj.put("message", message);
+            retObj.set("data", dataObj);
+            retObj.put("status", "ok");
+            return retObj;
+        }
+    }
+}
diff --git a/src/main/java/seu/se/Application.java b/src/main/java/seu/se/Application.java
new file mode 100644
index 0000000..928bc28
--- /dev/null
+++ b/src/main/java/seu/se/Application.java
@@ -0,0 +1,102 @@
+package seu.se;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.data.neo4j.repository.config.EnableNeo4jRepositories;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.session.MapSessionRepository;
+import org.springframework.session.config.annotation.web.http.EnableSpringHttpSession;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.servlet.NoHandlerFoundException;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.ConcurrentHashMap;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+@EnableTransactionManagement
+@EnableNeo4jRepositories
+@SpringBootApplication
+public class Application {
+    private static final Logger log = LoggerFactory.getLogger(Application.class);
+
+    public static void main(String[] args) {
+        SpringApplication.run(Application.class, args);
+    }
+
+    @ControllerAdvice
+    public static class ExHandler {
+        @ExceptionHandler(Exception.class)
+        public ResponseEntity<Object> handle(Exception ex, HttpServletRequest request, HttpServletResponse response) {
+            HttpStatus code = HttpStatus.INTERNAL_SERVER_ERROR;
+            String status = "internal server error";
+            if (ex instanceof NullPointerException) {
+                code = HttpStatus.BAD_REQUEST;
+                status = "bad request";
+            }
+            if (ex instanceof NoHandlerFoundException) {
+                code = HttpStatus.NOT_FOUND;
+                status = "not found";
+            }
+            var retObj = new ObjectMapper().createObjectNode();
+            retObj.put("status", status);
+            retObj.put("message", ex.getMessage());
+            return ResponseEntity.status(code).body(retObj);
+        }
+    }
+
+    // in-mem session
+    @EnableSpringHttpSession
+    @Configuration(proxyBeanMethods = false)
+    public static class SpringHttpSessionConfig {
+        @Bean
+        public MapSessionRepository sessionRepository() {
+            return new MapSessionRepository(new ConcurrentHashMap<>());
+        }
+    }
+
+    @Bean
+    public CommandLineRunner init(UserRepository ur, TreeNodeRepository tnr) {
+        return (args) -> {
+            // Load users
+            try {
+                List<User> users = new ObjectMapper().readValue(
+                        new ClassPathResource("users.json").getFile(),
+                        new TypeReference<>() {
+                        }
+                );
+                ur.saveAll(users);
+            } catch (IOException e) {
+                log.error("Error loading users from JSON file", e);
+            }
+
+            // Load tree nodes
+            TreeNode.tnRepo = tnr;
+            try {
+                List<TreeNode> treeNodes = new ObjectMapper().readValue(
+                        new ClassPathResource("treeNodes.json").getFile(),
+                        new TypeReference<>() {
+                        }
+                );
+                tnr.saveAll(treeNodes);
+            } catch (IOException e) {
+                log.error("Error loading tree nodes from JSON file", e);
+            }
+        };
+    }
+
+}
diff --git a/src/main/java/seu/se/Exercise.java b/src/main/java/seu/se/Exercise.java
new file mode 100644
index 0000000..6df9212
--- /dev/null
+++ b/src/main/java/seu/se/Exercise.java
@@ -0,0 +1,125 @@
+package seu.se;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.StdSerializer;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.annotation.PersistenceCreator;
+import org.springframework.data.neo4j.core.schema.Id;
+import org.springframework.data.neo4j.core.schema.Node;
+
+import java.io.IOException;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+
+@Node
+public class Exercise {
+    @Id
+    private String questionId;
+    private String question;
+    private List<OptionPair> options;
+    private String answer;
+
+    public interface Choice {
+        String A = "A";
+        String B = "B";
+        String C = "C";
+        String D = "D";
+    }
+
+    @Node
+    @JsonSerialize(using = OptionPair.OptionPairSerializer.class)
+    public static class OptionPair {
+        @Id
+        private final String id;
+        private final String key;
+        private final String value;
+
+        public static class OptionPairSerializer extends StdSerializer<OptionPair> {
+            public OptionPairSerializer() {
+                this(null);
+            }
+
+            public OptionPairSerializer(Class<OptionPair> t) {
+                super(t);
+            }
+
+            @Override
+            public void serialize(OptionPair value, JsonGenerator gen, SerializerProvider provider) throws IOException {
+                gen.writeStartObject();
+                gen.writeStringField(value.key, value.value);
+                gen.writeEndObject();
+            }
+        }
+
+        public OptionPair(String id, String key, String value) {
+            this.id = id;
+            this.key = key;
+            this.value = value;
+        }
+
+        public String value() {
+            return value;
+        }
+
+        public String key() {
+            return key;
+        }
+
+        public String getId() {
+            return id;
+        }
+    }
+
+    @JsonCreator
+    public Exercise(@JsonProperty("questionId") String questionId, @JsonProperty("question") String question, @JsonProperty("options") Map<String, String> options, @JsonProperty("answer") String answer) {
+        this.questionId = questionId;
+        this.question = question;
+        this.options = new LinkedList<OptionPair>();
+        options.forEach((k, v) -> this.options.add(new OptionPair(String.format("%s.%s", questionId, k), k, v)));
+        this.answer = answer;
+    }
+
+    @Autowired
+    @PersistenceCreator
+    // https://stackoverflow.com/questions/55827640/how-to-fix-failed-to-instantiate-classname-using-constructor-no-constructor
+    public Exercise(String questionId, String question, List<OptionPair> options, String answer) {
+        this.questionId = questionId;
+        this.question = question;
+        this.options = options;
+        this.answer = answer;
+    }
+
+    public String getQuestion() {
+        return question;
+    }
+
+    public void setQuestion(String question) {
+        this.question = question;
+    }
+
+    public String getQuestionId() {
+        return questionId;
+    }
+
+    public List<OptionPair> getOptions() {
+        return options;
+    }
+
+    public void setOptions(List<OptionPair> options) {
+        this.options = options;
+    }
+
+    public String getAnswer() {
+        return answer;
+    }
+
+    public void setAnswer(String answer) {
+        this.answer = answer;
+    }
+}
diff --git a/src/main/java/seu/se/TreeNode.java b/src/main/java/seu/se/TreeNode.java
new file mode 100644
index 0000000..f6e28b2
--- /dev/null
+++ b/src/main/java/seu/se/TreeNode.java
@@ -0,0 +1,215 @@
+package seu.se;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.ser.std.StdSerializer;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.annotation.PersistenceCreator;
+import org.springframework.data.neo4j.core.schema.Id;
+import org.springframework.data.neo4j.core.schema.Node;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+
+@Node
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class TreeNode {
+    @Id
+    final private String nodeId;
+    final private String label;
+    @JsonIgnore
+    final private String content;
+    final private String relation;
+    @JsonIgnore
+    final private TreeNode father;
+    @JsonIgnore
+    private List<Exercise> exercises;
+    @JsonIgnore
+    private List<UserScorePair> userScores;
+    public static TreeNodeRepository tnRepo;
+
+    @Node
+    public static class UserScorePair {
+        @Id
+        private final String id;
+        private final String nodeId;
+        private final Long userId;
+        private List<Double> scores;
+
+        public interface MasteryLevel {
+            String Excellent = "完美";
+            String Good = "很好";
+            String Average = "一般";
+            String Bad = "差";
+
+            static String getMasteryLevel(double averageScore) {
+                if (averageScore > 95)
+                    return Excellent;
+                else if (averageScore > 80)
+                    return Good;
+                else if (averageScore > 60)
+                    return Average;
+                else
+                    return Bad;
+            }
+        }
+
+        public static class UserScoreSerializer extends StdSerializer<UserScorePair> {
+            public UserScoreSerializer() {
+                this(null);
+            }
+
+            public UserScoreSerializer(Class<UserScorePair> t) {
+                super(t);
+            }
+
+            @Override
+            public void serialize(UserScorePair value, JsonGenerator gen, SerializerProvider provider) throws IOException {
+                gen.writeStartObject();
+                gen.writeStringField("nodeId", value.nodeId);
+                var avg = value.getAverageScore();
+                gen.writeNumberField("averageScore", avg);
+                gen.writeNumberField("lastScore", value.getLastScore());
+                gen.writeStringField("masteryLevel", MasteryLevel.getMasteryLevel(avg));
+                gen.writeEndObject();
+            }
+        }
+
+        public UserScorePair(String nodeId, Long userId, List<Double> scores) {
+            this(String.format("%s.%d", nodeId, userId), nodeId, userId, scores);
+        }
+
+        @Autowired
+        @PersistenceCreator
+        public UserScorePair(String id, String nodeId, Long userId, List<Double> scores) {
+            this.id = id;
+            this.nodeId = nodeId;
+            this.userId = userId;
+            this.scores = scores;
+        }
+
+        public String getId() {
+            return id;
+        }
+
+        public String getNodeId() {
+            return nodeId;
+        }
+
+        public Long getUserId() {
+            return userId;
+        }
+
+        public List<Double> getScores() {
+            return scores;
+        }
+
+        public Double getLastScore() {
+            return scores.getLast();
+        }
+
+        public Double getAverageScore() {
+            Double sum = 0.0;
+            for (var i : scores)
+                sum += i;
+            return scores.isEmpty() ? sum : sum / scores.size();
+        }
+
+        public void setScores(List<Double> scores) {
+            this.scores = scores;
+        }
+    }
+
+    // MUST only be called during deserialization!!
+    @JsonCreator
+    public TreeNode(@JsonProperty("nodeId") String nodeId, @JsonProperty("label") String label, @JsonProperty("content") String content, @JsonProperty("relation") String relation, @JsonProperty("fatherId") String fatherId, @JsonProperty("exercises") List<Exercise> exercises) {
+        this(nodeId, label, content, relation, tnRepo.findByNodeId(fatherId), exercises, new ArrayList<>());
+    }
+
+    public TreeNode(String nodeId, String label, String content, String relation, TreeNode father, List<Exercise> exercises) {
+        this(nodeId, label, content, relation, father, exercises, new ArrayList<>());
+    }
+
+    @Autowired
+    @PersistenceCreator
+    public TreeNode(String nodeId, String label, String content, String relation, TreeNode father, List<Exercise> exercises, List<UserScorePair> userScores) {
+        this.nodeId = nodeId;
+        this.label = label;
+        this.content = content;
+        this.relation = relation;
+        this.father = father;
+        this.exercises = exercises;
+        this.userScores = userScores;
+    }
+
+    public String getLabel() {
+        return label;
+    }
+
+    public String getNodeId() {
+        return nodeId;
+    }
+
+    public String getContent() {
+        return content;
+    }
+
+    public TreeNode getFather() {
+        return father;
+    }
+
+    public String getRelation() {
+        return relation;
+    }
+
+    public List<Exercise> getExercises() {
+        return exercises;
+    }
+
+    public void setExercises(List<Exercise> exercises) {
+        this.exercises = exercises;
+    }
+
+    public List<UserScorePair> getUserScores() {
+        return userScores;
+    }
+
+    public UserScorePair getUserScorePair(Long userId, String nodeId) {
+        var usp = userScores.stream().filter(e -> Objects.equals(e.getUserId(), userId) && e.getNodeId().equals(nodeId)).findFirst();
+        return usp.orElse(null);
+    }
+
+    public void setUserScores(List<UserScorePair> userScores) {
+        this.userScores = userScores;
+    }
+
+    public int getLevel() {
+        var tmp = this;
+        var level = 1;
+        while (tmp.father != null) {
+            tmp = tmp.father;
+            level++;
+        }
+        return level;
+    }
+
+    public Set<TreeNode> getChildren(List<TreeNode> nodes) {
+        var children = new HashSet<TreeNode>();
+        for (var n : nodes) {
+            if (n.getFather() == this) {
+                children.add(n);
+            }
+        }
+        return children;
+    }
+}
diff --git a/src/main/java/seu/se/TreeNodeRepository.java b/src/main/java/seu/se/TreeNodeRepository.java
new file mode 100644
index 0000000..d7aff83
--- /dev/null
+++ b/src/main/java/seu/se/TreeNodeRepository.java
@@ -0,0 +1,8 @@
+package seu.se;
+
+import org.springframework.data.repository.ListCrudRepository;
+import org.springframework.data.repository.PagingAndSortingRepository;
+
+public interface TreeNodeRepository extends PagingAndSortingRepository<TreeNode, String>, ListCrudRepository<TreeNode, String> {
+    TreeNode findByNodeId(String nodeId);
+}
diff --git a/src/main/java/seu/se/User.java b/src/main/java/seu/se/User.java
new file mode 100644
index 0000000..aa33038
--- /dev/null
+++ b/src/main/java/seu/se/User.java
@@ -0,0 +1,65 @@
+package seu.se;
+
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import org.springframework.data.neo4j.core.schema.GeneratedValue;
+import org.springframework.data.neo4j.core.schema.Id;
+import org.springframework.data.neo4j.core.schema.Node;
+
+@Node
+public class User {
+    @Id
+    @GeneratedValue
+    private Long id;
+    private String name;
+    @JsonIgnore
+    private String password;
+
+    @JsonCreator
+    public User(@JsonProperty("name") String name, @JsonProperty("password") String password) throws UserException {
+        setName(name);
+        setPassword(password);
+    }
+
+    static public class UserException extends Exception {
+        public UserException() {
+        }
+
+        public UserException(String message) {
+            super(message);
+        }
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public Long getId() {
+        return id;
+    }
+
+    public String getPassword() {
+        return password;
+    }
+
+    @Override
+    public String toString() {
+        return String.format("User id: %d, name: %s", id, name);
+    }
+
+    public void setName(String name) throws UserException {
+        if (name.length() > 22)
+            throw new UserException("Username too long!");
+        this.name = name;
+    }
+
+    public void setPassword(String password) throws UserException {
+        if (password.length() < 8)
+            throw new UserException("Password too short!");
+        this.password = password;
+    }
+}
+
diff --git a/src/main/java/seu/se/UserRepository.java b/src/main/java/seu/se/UserRepository.java
new file mode 100644
index 0000000..6e55754
--- /dev/null
+++ b/src/main/java/seu/se/UserRepository.java
@@ -0,0 +1,11 @@
+package seu.se;
+
+import org.springframework.data.repository.ListCrudRepository;
+import org.springframework.data.repository.PagingAndSortingRepository;
+
+import java.util.List;
+
+public interface UserRepository extends PagingAndSortingRepository<User, Long>, ListCrudRepository<User, Long> {
+    List<User> findByName(String name);
+    User findById(long id);
+}
-- 
cgit v1.2.3