diff --git a/server/pkg/service/notes.go b/server/pkg/service/notes.go index 37b117d..26f4fad 100644 --- a/server/pkg/service/notes.go +++ b/server/pkg/service/notes.go @@ -1,19 +1,262 @@ package service import ( + "context" + "encoding/json" + "net/http" + "strconv" + + "git.umbrella.haus/ae/notatest/pkg/data" "github.com/go-chi/chi/v5" + "github.com/google/uuid" ) +type noteCtxKey struct{} + // Mockable database operations interface type NoteStore interface { - // TODO: implement + CreateNote(ctx context.Context, userID uuid.UUID) (data.Note, error) + DeleteNote(ctx context.Context, arg data.DeleteNoteParams) error + GetNote(ctx context.Context, arg data.GetNoteParams) (data.Note, error) + ListNotes(ctx context.Context, arg data.ListNotesParams) ([]data.Note, error) + CreateNoteVersion(ctx context.Context, arg data.CreateNoteVersionParams) (data.NoteVersion, error) + FindDuplicateContent(ctx context.Context, arg data.FindDuplicateContentParams) (bool, error) + GetNoteVersion(ctx context.Context, arg data.GetNoteVersionParams) (data.NoteVersion, error) + GetNoteVersions(ctx context.Context, arg data.GetNoteVersionsParams) ([]data.NoteVersion, error) } type notesResource struct { - Notes NoteStore + JWTSecret string + Notes NoteStore } func (rs notesResource) Routes() chi.Router { r := chi.NewRouter() + + r.Group(func(r chi.Router) { + r.Use(requireAccessToken(rs.JWTSecret)) + + r.Post("/", rs.CreateNote) // POST /notes - note creation + r.Get("/", rs.ListNotes) // GET /notes - get all notes + + r.Route("/{id}", func(r chi.Router) { + r.Use(noteCtx(rs.Notes)) + + r.Get("/", rs.GetNote) // GET /notes/{id} - get specific note + r.Delete("/", rs.DeleteNote) // DELETE /notes/{id} - delete specific note + + r.Route("/versions", func(r chi.Router) { + r.Post("/", rs.CreateNoteVersion) // POST /notes/{id}/versions - create new version + r.Get("/", rs.ListNoteVersions) // GET /notes/{id}/versions - get all existing versions + r.Get("/{version}", rs.GetNoteVersion) // GET /notes/{id}/versions/{version} - get specific version + }) + }) + }) + return r } + +func (rs *notesResource) CreateNote(w http.ResponseWriter, r *http.Request) { + user, ok := r.Context().Value(userCtxKey{}).(*userClaims) + if !ok { + respondError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + userID, err := uuid.Parse(user.Subject) + if err != nil { + respondError(w, http.StatusInternalServerError, "Invalid user ID") + return + } + + note, err := rs.Notes.CreateNote(r.Context(), userID) + if err != nil { + respondError(w, http.StatusInternalServerError, "Failed to create note") + return + } + + respondJSON(w, http.StatusCreated, note) +} + +func (rs *notesResource) ListNotes(w http.ResponseWriter, r *http.Request) { + user, ok := r.Context().Value(userCtxKey{}).(*userClaims) + if !ok { + respondError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + userID, err := uuid.Parse(user.Subject) + if err != nil { + respondError(w, http.StatusInternalServerError, "Invalid user ID") + return + } + + limit, offset := getPaginationParams(r) + + notes, err := rs.Notes.ListNotes(r.Context(), data.ListNotesParams{ + UserID: userID, + Limit: limit, + Offset: offset, + }) + if err != nil { + respondError(w, http.StatusInternalServerError, "Failed to retrieve notes") + return + } + + respondJSON(w, http.StatusOK, notes) +} + +func (rs *notesResource) GetNote(w http.ResponseWriter, r *http.Request) { + note, ok := r.Context().Value(noteCtxKey{}).(data.Note) + if !ok { + respondError(w, http.StatusNotFound, "Note not found") + return + } + + respondJSON(w, http.StatusOK, note) +} + +func (rs *notesResource) DeleteNote(w http.ResponseWriter, r *http.Request) { + note, ok := r.Context().Value(noteCtxKey{}).(data.Note) + if !ok { + respondError(w, http.StatusNotFound, "Note not found") + return + } + + user, ok := r.Context().Value(userCtxKey{}).(*userClaims) + if !ok { + respondError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + userID, err := uuid.Parse(user.Subject) + if err != nil { + respondError(w, http.StatusInternalServerError, "Invalid user ID") + return + } + + err = rs.Notes.DeleteNote(r.Context(), data.DeleteNoteParams{ + ID: note.ID, + UserID: userID, + }) + if err != nil { + respondError(w, http.StatusInternalServerError, "Failed to delete note") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (rs *notesResource) CreateNoteVersion(w http.ResponseWriter, r *http.Request) { + note, ok := r.Context().Value(noteCtxKey{}).(data.Note) + if !ok { + respondError(w, http.StatusNotFound, "Note not found") + return + } + + var req struct { + Title string `json:"title"` + Content string `json:"content"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request body") + return + } + + // De-duplication check + duplicate, err := rs.Notes.FindDuplicateContent(r.Context(), data.FindDuplicateContentParams{ + NoteID: note.ID, + Column2: []byte(req.Title), + Column3: []byte(req.Content), + }) + if err != nil { + respondError(w, http.StatusInternalServerError, "Failed to check for duplicate content") + return + } + if duplicate { + respondError(w, http.StatusConflict, "Duplicate content detected") + return + } + + version, err := rs.Notes.CreateNoteVersion(r.Context(), data.CreateNoteVersionParams{ + NoteID: note.ID, + Title: req.Title, + Content: req.Content, + }) + if err != nil { + respondError(w, http.StatusInternalServerError, "Failed to create note version") + return + } + + respondJSON(w, http.StatusCreated, version) +} + +func (rs *notesResource) ListNoteVersions(w http.ResponseWriter, r *http.Request) { + note, ok := r.Context().Value(noteCtxKey{}).(data.Note) + if !ok { + respondError(w, http.StatusNotFound, "Note not found") + return + } + + limit, offset := getPaginationParams(r) + + versions, err := rs.Notes.GetNoteVersions(r.Context(), data.GetNoteVersionsParams{ + NoteID: note.ID, + Limit: limit, + Offset: offset, + }) + if err != nil { + respondError(w, http.StatusInternalServerError, "Failed to retrieve versions") + return + } + + respondJSON(w, http.StatusOK, versions) +} + +func (rs *notesResource) GetNoteVersion(w http.ResponseWriter, r *http.Request) { + note, ok := r.Context().Value(noteCtxKey{}).(data.Note) + if !ok { + respondError(w, http.StatusNotFound, "Note not found") + return + } + + versionStr := chi.URLParam(r, "version") + versionNumber, err := strconv.ParseInt(versionStr, 10, 32) + if err != nil { + respondError(w, http.StatusBadRequest, "Invalid version number") + return + } + + version, err := rs.Notes.GetNoteVersion(r.Context(), data.GetNoteVersionParams{ + NoteID: note.ID, + VersionNumber: int32(versionNumber), + }) + if err != nil { + respondError(w, http.StatusNotFound, "Version not found") + return + } + + respondJSON(w, http.StatusOK, version) +} + +func getPaginationParams(r *http.Request) (limit int32, offset int32) { + defaultLimit := 50 + defaultOffset := 0 + + limitStr := r.URL.Query().Get("limit") + if limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { + defaultLimit = l + } + } + + offsetStr := r.URL.Query().Get("offset") + if offsetStr != "" { + if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 { + defaultOffset = o + } + } + + return int32(defaultLimit), int32(defaultOffset) +}