Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions backend/migrations/00072_create_learning_record_tables.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
-- +goose Up

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was there a specific reason that the version number of the scripts are starting at 00072? The next version number should be 00069?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we can, please use the next available number for consistency

CREATE TABLE learning_record_entries (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gtonye noting that the table should be qualified with the schema name

public.learning_record_entries

also where appropriate please make the change.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gtonye this table has created_at and updated_at, but it's missing the rest of our standard auditing set:

  • deleted_at TIMESTAMPTZ
  • create_user_id INTEGER — tracks who created the record
  • update_user_id INTEGER — tracks who last modified it

Can we add these to stay consistent with the rest of our tables (e.g. program_classes)?

Also please make sure that when saving or updating records the values are being set/saved correctly

id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
client_id TEXT NOT NULL,
is_draft BOOLEAN NOT NULL DEFAULT FALSE,
step_index INTEGER NOT NULL DEFAULT 0,
ui_phase TEXT NOT NULL DEFAULT 'survey',
editing_entry_id INTEGER REFERENCES learning_record_entries(id) ON DELETE SET NULL,
program_name TEXT NOT NULL DEFAULT '',
completion_date TEXT NOT NULL DEFAULT '',
confidence TEXT NOT NULL DEFAULT '',
summary TEXT NOT NULL DEFAULT '',
top_skills TEXT NOT NULL DEFAULT '[]',
barrier_to_completion TEXT NOT NULL DEFAULT '',
goal_connection TEXT NOT NULL DEFAULT '',
pride TEXT NOT NULL DEFAULT '',
standout_moment TEXT NOT NULL DEFAULT '',
advice_to_peer TEXT NOT NULL DEFAULT '',
challenge_toggle TEXT,
challenge_text TEXT NOT NULL DEFAULT '',
skill_tags_before TEXT NOT NULL DEFAULT '[]',
skill_tags_after TEXT NOT NULL DEFAULT '[]',
skill_reflection TEXT NOT NULL DEFAULT '',
growth_reflection TEXT NOT NULL DEFAULT '',
support_selections TEXT NOT NULL DEFAULT '[]',
next_step_selections TEXT NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_learning_record_entries_user_id
ON learning_record_entries(user_id);

CREATE UNIQUE INDEX IF NOT EXISTS idx_learning_record_entries_client_id
ON learning_record_entries(user_id, client_id);

-- +goose Down
DROP TABLE IF EXISTS learning_record_entries;
10 changes: 10 additions & 0 deletions backend/migrations/00073_add_learning_record_feature_flag.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- +goose Up

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gtonye Not sure why we need this extra migration file, we usually try to use just one migration file per PR. Sometimes we run into a transaction issue where 2 is required. For consistency within our codebase can this bit of code be put into 00072 file.

-- +goose NO TRANSACTION
ALTER TYPE feature ADD VALUE IF NOT EXISTS 'learning_record';

INSERT INTO public.feature_flags (name, enabled)
VALUES ('learning_record', FALSE)
ON CONFLICT (name) DO NOTHING;

-- +goose Down
DELETE FROM public.feature_flags WHERE name = 'learning_record';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Split DELETE and WHERE onto separate lines to satisfy SQLFluff.

Line 10 violates LT14 and can fail SQL lint gates.

Proposed fix
-DELETE FROM public.feature_flags WHERE name = 'learning_record';
+DELETE FROM public.feature_flags
+WHERE name = 'learning_record';
🧰 Tools
🪛 SQLFluff (4.2.1)

[error] 10-10: The 'WHERE' keyword should always start a new line.

(LT14)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/migrations/00073_add_learning_record_feature_flag.sql` at line 10,
The DELETE statement violates SQLFluff LT14; reformat the statement so the
DELETE keyword and the WHERE clause are on separate lines to satisfy the linter:
locate the DELETE targeting public.feature_flags where name = 'learning_record'
and split it into a DELETE FROM public.feature_flags line followed by a separate
WHERE name = 'learning_record' line so the clause is on its own line.

Source: Linters/SAST tools

88 changes: 88 additions & 0 deletions backend/src/database/learning_record.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package database

import (
"UnlockEdv2/src/models"
"errors"

"gorm.io/gorm"
"gorm.io/gorm/clause"
)

func (db *DB) GetLearningRecordEntries(userID uint) ([]models.LearningRecordEntry, error) {
entries := make([]models.LearningRecordEntry, 0)
if err := db.Where("user_id = ? AND is_draft = false", userID).
Order("created_at DESC").
Find(&entries).Error; err != nil {
return nil, newGetRecordsDBError(err, "learning_record_entries")
}
return entries, nil
}

func (db *DB) CreateLearningRecordEntry(entry *models.LearningRecordEntry) error {
if err := db.Create(entry).Error; err != nil {
return newCreateDBError(err, "learning_record_entries")
}
return nil
}

func (db *DB) UpdateLearningRecordEntry(entry *models.LearningRecordEntry) error {
result := db.Model(entry).
Where("id = ? AND user_id = ?", entry.ID, entry.UserID).
Updates(entry)
Comment on lines +29 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Missing match checks allow false-success updates/deletes.

Line 29-Line 31 (and similarly Line 39-Line 40, Line 83-Line 84) only check result.Error; when no row matches ownership filters, handlers still return success. Please treat RowsAffected == 0 as not-found/unauthorized for these mutations.

Suggested fix
 func (db *DB) UpdateLearningRecordEntry(entry *models.LearningRecordEntry) error {
-    result := db.Model(entry).
+    result := db.Model(&models.LearningRecordEntry{}).
         Where("id = ? AND user_id = ?", entry.ID, entry.UserID).
         Updates(entry)
     if result.Error != nil {
         return newUpdateDBError(result.Error, "learning_record_entries")
     }
+    if result.RowsAffected == 0 {
+        return newUpdateDBError(gorm.ErrRecordNotFound, "learning_record_entries")
+    }
     return nil
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/database/learning_record.go` around lines 29 - 31, The
update/delete DB calls that use result := db.Model(entry)... (e.g., the update
path where result is assigned and similar blocks around lines handling deletion
and bulk operations) only check result.Error; change these to also check
result.RowsAffected and treat RowsAffected == 0 as a not-found/unauthorized
outcome: after the DB call (the result variable from
db.Model(entry).Where(...).Updates(entry) and the corresponding delete calls),
if result.Error != nil return the error, else if result.RowsAffected == 0 return
a not-found/unauthorized error (same error type/status your handlers use for
missing ownership), so that non-matching ownership filters do not return
success.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Struct Updates(entry) drops zero-value writes.

Line 31 uses struct updates, so empty strings/zero numbers/false values are skipped by GORM. This breaks legitimate “clear this field” edits in learning record entries.

Suggested fix
-    result := db.Model(&models.LearningRecordEntry{}).
-        Where("id = ? AND user_id = ?", entry.ID, entry.UserID).
-        Updates(entry)
+    result := db.Model(&models.LearningRecordEntry{}).
+        Where("id = ? AND user_id = ?", entry.ID, entry.UserID).
+        Select("*").
+        Omit("id", "user_id", "created_at").
+        Updates(entry)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Updates(entry)
result := db.Model(&models.LearningRecordEntry{}).
Where("id = ? AND user_id = ?", entry.ID, entry.UserID).
Select("*").
Omit("id", "user_id", "created_at").
Updates(entry)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/database/learning_record.go` at line 31, The code is using GORM's
Updates(entry) which omits zero-value fields (so clearing fields fails); change
the call that currently uses Updates(entry) in learning_record.go to use
UpdateColumns(entry) on the same model/DB chain (or alternatively construct a
map[string]interface{} and pass that to Updates) so zero/empty values are
written; update the call site where Updates(entry) is invoked to use
UpdateColumns(entry) (or a map) to ensure zero-value fields are persisted.

if result.Error != nil {
return newUpdateDBError(result.Error, "learning_record_entries")
}
return nil
}

func (db *DB) DeleteLearningRecordEntry(id, userID uint) error {
if err := db.Where("id = ? AND user_id = ?", id, userID).
Delete(&models.LearningRecordEntry{}).Error; err != nil {
return newDeleteDBError(err, "learning_record_entries")
}
return nil
}

// GetLearningRecordDraft returns the most recently updated draft for the user, or nil.
func (db *DB) GetLearningRecordDraft(userID uint) (*models.LearningRecordEntry, error) {
var draft models.LearningRecordEntry
err := db.Where("user_id = ? AND is_draft = true", userID).
Order("updated_at DESC").
First(&draft).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, newGetRecordsDBError(err, "learning_record_entries")
}
return &draft, nil
}

// UpsertLearningRecordDraft inserts or updates a draft row keyed on (user_id, client_id).
func (db *DB) UpsertLearningRecordDraft(draft *models.LearningRecordEntry) error {
draft.IsDraft = true
if err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "client_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"is_draft", "step_index", "ui_phase", "editing_entry_id",
"program_name", "completion_date", "confidence", "summary",
"top_skills", "barrier_to_completion", "goal_connection",
"pride", "standout_moment", "advice_to_peer",
"challenge_toggle", "challenge_text",
"skill_tags_before", "skill_tags_after", "skill_reflection",
"growth_reflection", "support_selections", "next_step_selections",
"updated_at",
}),
}).Create(draft).Error; err != nil {
return newCreateDBError(err, "learning_record_entries")
}
return nil
}

func (db *DB) DeleteLearningRecordDraft(userID uint, clientID string) error {
if err := db.Where("user_id = ? AND client_id = ? AND is_draft = true", userID, clientID).
Delete(&models.LearningRecordEntry{}).Error; err != nil {
return newDeleteDBError(err, "learning_record_entries")
}
return nil
}
115 changes: 115 additions & 0 deletions backend/src/handlers/learning_record_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package handlers

import (
"UnlockEdv2/src/models"
"encoding/json"
"net/http"
"strconv"
)

func (srv *Server) registerLearningRecordRoutes() []routeDef {
axx := models.LearningRecordAccess
return []routeDef{
featureRoute("GET /api/learning-record/entries", srv.handleIndexLearningRecordEntries, axx),
featureRoute("POST /api/learning-record/entries", srv.handleCreateLearningRecordEntry, axx),
featureRoute("PUT /api/learning-record/entries/{id}", srv.handleUpdateLearningRecordEntry, axx),
featureRoute("DELETE /api/learning-record/entries/{id}", srv.handleDeleteLearningRecordEntry, axx),
featureRoute("GET /api/learning-record/draft", srv.handleGetLearningRecordDraft, axx),
featureRoute("PUT /api/learning-record/draft", srv.handleUpsertLearningRecordDraft, axx),
featureRoute("DELETE /api/learning-record/draft", srv.handleDeleteLearningRecordDraft, axx),
}
}

func (srv *Server) handleIndexLearningRecordEntries(w http.ResponseWriter, r *http.Request, log sLog) error {
userID := r.Context().Value(ClaimsKey).(*Claims).UserID
entries, err := srv.Db.GetLearningRecordEntries(userID)
if err != nil {
log.add("user_id", userID)
return newDatabaseServiceError(err)
}
return writeJsonResponse(w, http.StatusOK, entries)
}

func (srv *Server) handleCreateLearningRecordEntry(w http.ResponseWriter, r *http.Request, log sLog) error {
var entry models.LearningRecordEntry
if err := json.NewDecoder(r.Body).Decode(&entry); err != nil {
return newJSONReqBodyServiceError(err)
}
entry.UserID = r.Context().Value(ClaimsKey).(*Claims).UserID
if err := srv.Db.CreateLearningRecordEntry(&entry); err != nil {
Comment on lines +33 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

/entries handlers should force published state.

Line 33-Line 39 and Line 55-Line 57 let clients control is_draft. That allows draft-state payloads through published-entry endpoints, which breaks API semantics and can hide entries from the list query.

Suggested fix
 func (srv *Server) handleCreateLearningRecordEntry(w http.ResponseWriter, r *http.Request, log sLog) error {
     var entry models.LearningRecordEntry
     if err := json.NewDecoder(r.Body).Decode(&entry); err != nil {
         return newJSONReqBodyServiceError(err)
     }
     entry.UserID = r.Context().Value(ClaimsKey).(*Claims).UserID
+    entry.IsDraft = false
 func (srv *Server) handleUpdateLearningRecordEntry(w http.ResponseWriter, r *http.Request, log sLog) error {
@@
     entry.ID = uint(id)
     entry.UserID = r.Context().Value(ClaimsKey).(*Claims).UserID
+    entry.IsDraft = false

Also applies to: 55-57

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/handlers/learning_record_handler.go` around lines 33 - 39, The
create and update handlers are allowing clients to set the draft flag; force
published state by overriding the entry's draft field before persisting. In
handleCreateLearningRecordEntry (before calling
srv.Db.CreateLearningRecordEntry) set the LearningRecordEntry's IsDraft (or
is_draft) to false/unset so clients cannot submit drafts to the published
endpoint; do the same in the corresponding update handler (the code path that
calls srv.Db.UpdateLearningRecordEntry / UpdateLearningRecordEntry) to ensure
incoming payloads cannot toggle entries back to draft.

log.add("user_id", entry.UserID)
return newDatabaseServiceError(err)
}
return writeJsonResponse(w, http.StatusCreated, entry)
}

func (srv *Server) handleUpdateLearningRecordEntry(w http.ResponseWriter, r *http.Request, log sLog) error {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
return newInvalidIdServiceError(err, "entry ID")
}
Comment on lines +47 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject non-positive path IDs before uint conversion.

Line 47-Line 50 and Line 66-Line 69 accept negative integers, then cast to uint, producing invalid large IDs. Validate id > 0 before using it.

Suggested fix
 id, err := strconv.Atoi(r.PathValue("id"))
 if err != nil {
     return newInvalidIdServiceError(err, "entry ID")
 }
+if id <= 0 {
+    return newBadRequestServiceError(nil, "entry ID must be a positive integer")
+}

Also applies to: 66-69

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/handlers/learning_record_handler.go` around lines 47 - 50, The
code currently parses path IDs with strconv.Atoi (via r.PathValue("id")) and
then allows negative values to be cast to uint, producing invalid large IDs;
update both parsing sites (the blocks using strconv.Atoi and
newInvalidIdServiceError) to reject non-positive IDs by checking if id <= 0 and
returning newInvalidIdServiceError(errOrCustom, "entry ID") (or a clear
invalid-id error) before casting to uint; ensure the uint conversion only occurs
after the positive check so functions using the resulting uint get a valid ID.

var entry models.LearningRecordEntry
if err := json.NewDecoder(r.Body).Decode(&entry); err != nil {
return newJSONReqBodyServiceError(err)
}
entry.ID = uint(id)
entry.UserID = r.Context().Value(ClaimsKey).(*Claims).UserID
if err := srv.Db.UpdateLearningRecordEntry(&entry); err != nil {
log.add("entry_id", id)
log.add("user_id", entry.UserID)
return newDatabaseServiceError(err)
}
return writeJsonResponse(w, http.StatusOK, entry)
}

func (srv *Server) handleDeleteLearningRecordEntry(w http.ResponseWriter, r *http.Request, log sLog) error {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
return newInvalidIdServiceError(err, "entry ID")
}
userID := r.Context().Value(ClaimsKey).(*Claims).UserID
if err := srv.Db.DeleteLearningRecordEntry(uint(id), userID); err != nil {
log.add("entry_id", id)
log.add("user_id", userID)
return newDatabaseServiceError(err)
}
return writeJsonResponse(w, http.StatusOK, "Entry deleted successfully")
}

func (srv *Server) handleGetLearningRecordDraft(w http.ResponseWriter, r *http.Request, log sLog) error {
userID := r.Context().Value(ClaimsKey).(*Claims).UserID
draft, err := srv.Db.GetLearningRecordDraft(userID)
if err != nil {
log.add("user_id", userID)
return newDatabaseServiceError(err)
}
return writeJsonResponse(w, http.StatusOK, draft)
}

func (srv *Server) handleUpsertLearningRecordDraft(w http.ResponseWriter, r *http.Request, log sLog) error {
var draft models.LearningRecordEntry
if err := json.NewDecoder(r.Body).Decode(&draft); err != nil {
return newJSONReqBodyServiceError(err)
}
draft.UserID = r.Context().Value(ClaimsKey).(*Claims).UserID
if err := srv.Db.UpsertLearningRecordDraft(&draft); err != nil {
log.add("user_id", draft.UserID)
log.add("client_id", draft.ClientID)
return newDatabaseServiceError(err)
}
return writeJsonResponse(w, http.StatusOK, draft)
}

func (srv *Server) handleDeleteLearningRecordDraft(w http.ResponseWriter, r *http.Request, log sLog) error {
userID := r.Context().Value(ClaimsKey).(*Claims).UserID
clientID := r.URL.Query().Get("client_id")
if clientID == "" {
return newBadRequestServiceError(nil, "client_id query parameter is required")
}
if err := srv.Db.DeleteLearningRecordDraft(userID, clientID); err != nil {
log.add("user_id", userID)
log.add("client_id", clientID)
return newDatabaseServiceError(err)
}
return writeJsonResponse(w, http.StatusOK, "Draft deleted successfully")
}
1 change: 1 addition & 0 deletions backend/src/handlers/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ func (srv *Server) RegisterRoutes() {
srv.registerOpenContentActivityRoutes,
srv.registerTagRoutes,
srv.registerReportsRoutes,
srv.registerLearningRecordRoutes,
} {
srv.register(route)
}
Expand Down
9 changes: 5 additions & 4 deletions backend/src/models/feature_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,18 @@ type (
)

const (
OpenContentAccess FeatureAccess = "open_content"
ProviderAccess FeatureAccess = "provider_platforms"
ProgramAccess FeatureAccess = "program_management"
OpenContentAccess FeatureAccess = "open_content"
ProviderAccess FeatureAccess = "provider_platforms"
ProgramAccess FeatureAccess = "program_management"
LearningRecordAccess FeatureAccess = "learning_record"

// these are the page level features
RequestContentAccess FeatureAccess = "request_content"
HelpfulLinksAccess FeatureAccess = "helpful_links"
UploadVideoAccess FeatureAccess = "upload_video"
)

var AllFeatures = []FeatureAccess{OpenContentAccess, ProviderAccess, ProgramAccess, RequestContentAccess, HelpfulLinksAccess, UploadVideoAccess}
var AllFeatures = []FeatureAccess{OpenContentAccess, ProviderAccess, ProgramAccess, LearningRecordAccess, RequestContentAccess, HelpfulLinksAccess, UploadVideoAccess}

func Feature(kinds ...FeatureAccess) []FeatureAccess {
return kinds
Expand Down
63 changes: 63 additions & 0 deletions backend/src/models/learning_record.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package models

import (
"database/sql/driver"
"encoding/json"
"time"
)

// StringSlice persists a Go string slice as a JSON text column.
type StringSlice []string

func (s StringSlice) Value() (driver.Value, error) {
if s == nil {
return "[]", nil
}
b, err := json.Marshal(s)
return string(b), err
}

func (s *StringSlice) Scan(value any) error {
var raw string
switch v := value.(type) {
case string:
raw = v
case []byte:
raw = string(v)
default:
raw = "[]"
}
return json.Unmarshal([]byte(raw), s)
}
Comment on lines +20 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Return an error for unexpected types in Scan().

The default case on line 28 silently falls back to "[]" for any unexpected database value type. This can hide data corruption, schema migration bugs, or type mismatches. When the database returns an unexpected type (e.g., numeric, NULL when not expected, or a custom type), the method should fail visibly rather than masking the problem with an empty array.

🛡️ Proposed fix to return an error for unexpected types
 func (s *StringSlice) Scan(value any) error {
 	var raw string
 	switch v := value.(type) {
 	case string:
 		raw = v
 	case []byte:
 		raw = string(v)
+	case nil:
+		raw = "[]"
 	default:
-		raw = "[]"
+		return fmt.Errorf("cannot scan type %T into StringSlice", value)
 	}
 	return json.Unmarshal([]byte(raw), s)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/models/learning_record.go` around lines 20 - 31, The Scan method
on StringSlice currently swallows unexpected DB value types by defaulting to
"[]"; update StringSlice.Scan to return a descriptive error in the default
branch (including the concrete type of value) instead of silently unmarshaling
"[]", and handle nil explicitly if needed (either treat nil as empty array or
return an error) so callers see type mismatches; reference the StringSlice.Scan
function and its switch on value.(type) to locate and change the default
behavior to return fmt.Errorf("unexpected type for StringSlice: %T", value) (or
similar).


type LearningRecordEntry struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null" json:"user_id"`
ClientID string `gorm:"not null" json:"client_id"`
IsDraft bool `gorm:"not null;default:false" json:"is_draft"`
StepIndex int ` json:"step_index"`
UiPhase string ` json:"ui_phase"`
EditingEntryID *uint ` json:"editing_entry_id"`
ProgramName string ` json:"program_name"`
CompletionDate string ` json:"completion_date"`
Confidence string ` json:"confidence"`
Summary string ` json:"summary"`
TopSkills StringSlice `gorm:"type:text" json:"top_skills"`
BarrierToCompletion string ` json:"barrier_to_completion"`
GoalConnection string ` json:"goal_connection"`
Pride string ` json:"pride"`
StandoutMoment string ` json:"standout_moment"`
AdviceToPeer string ` json:"advice_to_peer"`
ChallengeToggle *string ` json:"challenge_toggle"`
ChallengeText string ` json:"challenge_text"`
SkillTagsBefore StringSlice `gorm:"type:text" json:"skill_tags_before"`
SkillTagsAfter StringSlice `gorm:"type:text" json:"skill_tags_after"`
SkillReflection string ` json:"skill_reflection"`
GrowthReflection string ` json:"growth_reflection"`
SupportSelections StringSlice `gorm:"type:text" json:"support_selections"`
NextStepSelections StringSlice `gorm:"type:text" json:"next_step_selections"`
CreatedAt time.Time ` json:"created_at"`
UpdatedAt time.Time ` json:"updated_at"`
}

func (LearningRecordEntry) TableName() string { return "learning_record_entries" }
5 changes: 3 additions & 2 deletions config/dev.nginx.conf

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please revert this file back to its original form, this causes local testing to fail

Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ server {
proxy_headers_hash_bucket_size 256;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
resolver 127.0.0.11 ipv6=off valid=10s;

location /api/ {
proxy_pass http://server:8080;
proxy_pass http://host.docker.internal:8080;
proxy_buffering on;
proxy_cache_methods GET HEAD;
proxy_http_version 1.1;
Expand Down Expand Up @@ -62,7 +63,7 @@ server {
}

location / {
proxy_pass http://frontend:5173;
proxy_pass http://host.docker.internal:5173;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ services:
volumes:
- ./config/dev.nginx.conf:/etc/nginx/conf.d/default.conf
- logs:/var/log/nginx/
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- intranet
restart: unless-stopped
Expand Down
3 changes: 3 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"embla-carousel-react": "^8.6.0",
"html2canvas": "^1.4.1",
"html2pdf.js": "^0.14.0",
"input-otp": "^1.4.2",
"jspdf": "^4.2.1",
Comment on lines +49 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Search for html2pdf.js imports/usage in the codebase

rg -n --type=ts --type=tsx 'html2pdf' -g '!package.json' -g '!package-lock.json'

Repository: UnlockedLabs/UnlockEdv2

Length of output: 95


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Search for any reference to html2pdf in source files (ts/tsx/js/jsx) under frontend/
rg -n --hidden \
  --glob '!**/node_modules/**' \
  --glob '!**/package-lock.json' \
  --glob '!**/package.json' \
  'html2pdf' frontend || true

# 2) Locate and inspect the referenced file
fd -a 'downloadLearningRecordPdf\.(ts|tsx|js|jsx)$' frontend/src frontend || true
echo "----"
FILE="$(fd -a 'downloadLearningRecordPdf\.(ts|tsx|js|jsx)$' frontend/src frontend | head -n 1 || true)"
if [ -n "$FILE" ]; then
  echo "Inspecting: $FILE"
  # show imports and whole file if not too large
  wc -l "$FILE"
  if [ "$(wc -l < "$FILE")" -le 250 ]; then
    cat -n "$FILE"
  else
    sed -n '1,120p' "$FILE"
    echo "..."
    sed -n '120,240p' "$FILE"
  fi
else
  echo "downloadLearningRecordPdf.* not found"
fi

Repository: UnlockedLabs/UnlockEdv2

Length of output: 8712


Remove html2pdf.js if it’s not referenced in source code.

frontend/src/utils/downloadLearningRecordPdf.ts imports and uses html2canvas and jspdf directly (no html2pdf import/usage). A repo-wide search for html2pdf under frontend/ only finds html2pdf.js in frontend/yarn.lock, so html2pdf.js appears unused in the code and can likely be dropped from frontend/package.json.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/package.json` around lines 49 - 52, Remove the unused html2pdf.js
dependency from frontend/package.json: confirm there are no runtime imports
(e.g., check frontend/src/utils/downloadLearningRecordPdf.ts uses only
html2canvas and jspdf), remove the "html2pdf.js" entry from the dependencies,
then run the package manager (yarn/npm install) to update node_modules and
regenerate the lockfile; optionally run a repo-wide search for "html2pdf" to
verify nothing else references it before committing.

"lucide-react": "^0.487.0",
"moment": "^2.30.1",
"next-themes": "^0.4.6",
Expand Down
Loading
Loading