package service import ( "context" "encoding/json" "fmt" "net/http" "git.umbrella.haus/ae/notatest/internal/data" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) const ( titleMaxLength = 150 initVersionTitle = "Untitled" initVersionContent = "" ) // Note object context key for incoming requests (handled my middlewares). Only `*data.GetFullNoteRow` // type objects should be stored behind this key for consistency. type noteCtxKey struct{} // Note version object context key for incoming requests (handled by middlewares). Only // `*data.GetVersionRow` type objects should be stored behind this key for consistency. type versionCtxKey struct{} // Mockable database operations interface type NoteStore interface { CreateNote(ctx context.Context, userID uuid.UUID) (data.Note, error) DeleteNote(ctx context.Context, arg data.DeleteNoteParams) error GetFullNote(ctx context.Context, noteID uuid.UUID) (data.GetFullNoteRow, error) ListNotes(ctx context.Context, arg data.ListNotesParams) ([]data.ListNotesRow, error) CreateNoteVersion(ctx context.Context, arg data.CreateNoteVersionParams) error GetVersion(ctx context.Context, arg data.GetVersionParams) (data.GetVersionRow, error) GetVersionHistory(ctx context.Context, arg data.GetVersionHistoryParams) ([]data.GetVersionHistoryRow, error) } // Chi HTTP router for notes related CRUD actions. type notesResource struct { Config SvcConfig Notes NoteStore } func (rs notesResource) Routes() chi.Router { r := chi.NewRouter() r.Group(func(r chi.Router) { r.Use(requireAccessToken(rs.Config.JWTSecret)) // JWT claims -> ctx. r.Post("/", rs.Create) // POST /notes - create new note r.Get("/", rs.ListMetadata) // GET /notes - get all notes (metadata + titles) /* Clients should utilize `rs.ListMetadata` to load index of user's available notes (e.g. sidebar view), use `rs.GetFullNote` to get full notes individually, and if request the versioning history with `rs.GetVersionHistory` if necessary (and similarly fetch each version individually if the client wants to view them). */ r.Route(fmt.Sprintf("/{%s}", noteUUIDCtxParameter), func(r chi.Router) { r.Use(uuidCtx(noteUUIDCtxParameter)) r.Use(noteCtx(rs.Notes)) // DB -> req. context (metadata + active version) r.Get("/", rs.GetFullNote) // GET /notes/{id} - get note from context r.Delete("/", rs.Delete) // DELETE /notes/{id} - delete note r.Get("/versions", rs.GetVersionHistory) // GET /notes/{id}/versions - get full versioning history r.Post("/versions", rs.CreateVersion) // POST /notes/{id}/versions - create new version r.Route(fmt.Sprintf("/{%s}", versionUUIDCtxParameter), func(r chi.Router) { r.Use(uuidCtx(versionUUIDCtxParameter)) r.Use(versionCtx(rs.Notes)) // DB -> req. context (scoped version) r.Get("/", rs.GetFullVersion) // GET /notes/{id}/{id} - get specific version's contents }) }) }) return r } // Handler for new note creation. Creates the parent metadata object (`notes` table) and an initial // placeholder content version (`note_versions` table), and returns the placeholder contents to the // caller in the HTTP response. func (rs *notesResource) Create(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.StatusUnauthorized, "Invalid user ID") return } // Metadata object (parent) note, err := rs.Notes.CreateNote(r.Context(), userID) if err != nil { respondError(w, http.StatusInternalServerError, "Failed to create note") return } // Initial (empty) placeholder version of the contents err = rs.Notes.CreateNoteVersion(r.Context(), data.CreateNoteVersionParams{ NoteID: note.ID, Title: initVersionTitle, Content: initVersionContent, ContentHash: sha1ContentHash(initVersionTitle, initVersionContent), }) if err != nil { respondError(w, http.StatusInternalServerError, "Failed to create initial version") return } // Placeholder contents are decided server-side, so we need to inform the client of them via a // one-time-use DTO respondJSON(w, http.StatusCreated, map[string]string{ "title": initVersionTitle, "content": initVersionContent, }) } // Handler for listing the metadata of all user's available notes. This metadata contains IDs of // the note and its owner, its title, and the time it was last updated. func (rs *notesResource) ListMetadata(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.StatusUnauthorized, "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 } for _, note := range notes { if userID != note.OwnerID { respondError(w, http.StatusForbidden, "Forbidden") return } } respondJSON(w, http.StatusOK, notes) } // Handler for returning the currently scoped (included to the request's context by a middleware) // full note object. func (rs *notesResource) GetFullNote(w http.ResponseWriter, r *http.Request) { fullNote, ok := r.Context().Value(noteCtxKey{}).(*data.GetFullNoteRow) if !ok { respondError(w, http.StatusNotFound, "Note not found") return } respondJSON(w, http.StatusOK, fullNote) } // Handler for hard deelting the currently scoped note (including its versions via database cascade). func (rs *notesResource) Delete(w http.ResponseWriter, r *http.Request) { fullNote, ok := r.Context().Value(noteCtxKey{}).(*data.GetFullNoteRow) 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.StatusUnauthorized, "Invalid user ID") return } err = rs.Notes.DeleteNote(r.Context(), data.DeleteNoteParams{ ID: fullNote.NoteID, UserID: userID, // NOTE: using `fullNote.userID` here'd be insecure }) if err != nil { respondError(w, http.StatusInternalServerError, "Failed to delete note") return } w.WriteHeader(http.StatusNoContent) } // Handler for listing the currently scoped note's version history. If pagination parameters // (`limit` and `offset`) aren't defined, limit of 50 versions (with offset 0) will be returned. func (rs *notesResource) GetVersionHistory(w http.ResponseWriter, r *http.Request) { fullNote, ok := r.Context().Value(noteCtxKey{}).(*data.GetFullNoteRow) if !ok { respondError(w, http.StatusNotFound, "Note not found") return } limit, offset := getPaginationParams(r) versions, err := rs.Notes.GetVersionHistory(r.Context(), data.GetVersionHistoryParams{ NoteID: fullNote.NoteID, Limit: limit, Offset: offset, }) if err != nil { respondError(w, http.StatusInternalServerError, "Failed to get version history") return } respondJSON(w, http.StatusOK, versions) } // Handler for creating a new content version for the currently scoped note. Will check the incoming // JSON object's integrity and perform a de-duplication check for identical versions stored in the // database (SHA-1 hash of version contents). If a duplicate version is found, it'll be placed as the // active version by swapping its version number to HEAD+1. func (rs *notesResource) CreateVersion(w http.ResponseWriter, r *http.Request) { fullNote, ok := r.Context().Value(noteCtxKey{}).(*data.GetFullNoteRow) 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 || req.Title == nil || req.Content == nil { respondError(w, http.StatusBadRequest, "Invalid request body") return } // Extra check for frontend readability reasons (max. length isn't specifically limited in the database) if len(*req.Title) > titleMaxLength { respondError(w, http.StatusBadRequest, fmt.Sprintf("Title must be shorter than %d characters", titleMaxLength)) return } /* The SQL query handles de-duplication checks and "intelligent" versioning increments, so we don't have to worry about them here (`latest_version` = highest version number that exists in this note's context; `current_version` = note's active content version): - New version's contents are a duplicate of a historical version: - Don't increment `latest_version` - Sync `current_version` with the `version_number` of the duplicate version - New version's contents are unique: - Increment `latest_version` - Sync `current_version` with `latest_version` */ err := rs.Notes.CreateNoteVersion(r.Context(), data.CreateNoteVersionParams{ NoteID: fullNote.NoteID, Title: *req.Title, Content: *req.Content, ContentHash: sha1ContentHash(*req.Title, *req.Content), }) if err != nil { respondError(w, http.StatusInternalServerError, "Failed to create note version") return } w.WriteHeader(http.StatusNoContent) } // Handler for returning full data of the currently scoped note version. Identical to the beginning // of the `RollbackNoteVersion` handler. func (rs *notesResource) GetFullVersion(w http.ResponseWriter, r *http.Request) { fullVersion, ok := r.Context().Value(noteCtxKey{}).(*data.GetVersionRow) if !ok { respondError(w, http.StatusNotFound, "Note not found") return } respondJSON(w, http.StatusOK, fullVersion) }