diff --git a/data/admin-content-draft.json b/data/admin-content-draft.json index 67b5e49..90c18ba 100644 --- a/data/admin-content-draft.json +++ b/data/admin-content-draft.json @@ -85,7 +85,12 @@ "Why does Paul begin with grace and peace?", "What tone does this create for the rest of the study?" ], - "releasedAt": "2026-11-09T00:00:00Z" + "checkpointPrompt": "", + "checkpointQuestions": [ + "Why does Paul begin with grace and peace?", + "What tone does this create for the rest of the study?" + ], + "releasedAt": "2026-05-06T00:00:00Z" }, { "id": "1-3-8", @@ -101,7 +106,9 @@ "What signs of gospel growth does Paul mention?", "How do faith, hope, and love work together here?" ], - "releasedAt": "2026-11-23T00:00:00Z" + "checkpointPrompt": "", + "checkpointQuestions": [], + "releasedAt": "2026-05-31T00:00:00Z" }, { "id": "1-9-14", @@ -117,6 +124,8 @@ "What does Paul ask God to produce?", "How does this prayer shape the goals of the study?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2026-12-07T00:00:00Z" }, { @@ -133,6 +142,8 @@ "What stands out most about Christ’s supremacy?", "Why is this passage central to the study?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2026-12-21T00:00:00Z" }, { @@ -149,6 +160,8 @@ "How does Paul describe the believer’s former condition?", "What does continuing in the faith look like?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-01-04T00:00:00Z" }, { @@ -165,6 +178,8 @@ "What is the goal of Paul’s ministry?", "Why is maturity such an important theme here?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-01-18T00:00:00Z" }, { @@ -181,6 +196,8 @@ "What does Paul want the believers to experience?", "Why does he emphasize encouragement and unity?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-02-01T00:00:00Z" }, { @@ -197,6 +214,8 @@ "What does it mean to continue in Christ?", "What kinds of deception should believers watch for?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-02-15T00:00:00Z" }, { @@ -213,6 +232,8 @@ "Why is fullness in Christ such an important correction?", "How does this guard against spiritual insecurity?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-03-01T00:00:00Z" }, { @@ -229,6 +250,8 @@ "What changes because of Christ’s work here?", "How does victory over the powers encourage believers today?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-03-15T00:00:00Z" }, { @@ -245,6 +268,8 @@ "What do shadows and substance mean here?", "Where do believers still feel pressure from external judgment?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-03-29T00:00:00Z" }, { @@ -261,6 +286,8 @@ "How does Paul describe false spirituality?", "Why is connection to Christ essential?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-04-12T00:00:00Z" }, { @@ -277,6 +304,8 @@ "Why do human regulations fail to transform the heart?", "What does real spiritual change require?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-04-26T00:00:00Z" }, { @@ -293,6 +322,8 @@ "What does it mean to seek the things above?", "How does hidden life in Christ shape daily priorities?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-05-10T00:00:00Z" }, { @@ -309,6 +340,8 @@ "Which old patterns does Paul name?", "Why is decisive change necessary?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-05-24T00:00:00Z" }, { @@ -325,6 +358,8 @@ "What does renewal in Christ produce?", "How does this reshape how believers see one another?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-06-07T00:00:00Z" }, { @@ -341,6 +376,8 @@ "Which virtues are most needed in your context?", "How does love bind these virtues together?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-06-21T00:00:00Z" }, { @@ -357,6 +394,8 @@ "What does it look like for Christ’s peace to rule?", "How can the word of Christ dwell richly in a church family?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-07-05T00:00:00Z" }, { @@ -373,6 +412,8 @@ "How do these instructions reflect Christ’s lordship?", "Which relationship is most challenging to apply today?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-07-19T00:00:00Z" }, { @@ -389,6 +430,8 @@ "How does serving the Lord reshape everyday work?", "What does Paul say about fairness and accountability?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-08-02T00:00:00Z" }, { @@ -405,6 +448,8 @@ "Why does Paul remind masters of their heavenly Master?", "What does fairness look like in this context?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-08-16T00:00:00Z" }, { @@ -421,6 +466,8 @@ "How are prayer and witness connected here?", "What does gracious speech look like in real life?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-08-30T00:00:00Z" }, { @@ -437,6 +484,8 @@ "Why do these messengers matter to the letter?", "What does this tell us about trusted coworkers?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-09-13T00:00:00Z" }, { @@ -453,6 +502,8 @@ "What stands out about Paul’s coworkers?", "How does shared ministry strengthen the church?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-09-27T00:00:00Z" }, { @@ -469,6 +520,8 @@ "What final responsibility does Paul place on the church?", "How does the closing reinforce the letter’s themes?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-10-11T00:00:00Z" } ] @@ -512,7 +565,12 @@ "Why does Paul begin with grace and peace?", "What tone does this create for the rest of the study?" ], - "releasedAt": "2026-11-09T00:00:00Z" + "checkpointPrompt": "", + "checkpointQuestions": [ + "Why does Paul begin with grace and peace?", + "What tone does this create for the rest of the study?" + ], + "releasedAt": "2026-05-06T00:00:00Z" }, { "id": "1-3-8", @@ -528,7 +586,9 @@ "What signs of gospel growth does Paul mention?", "How do faith, hope, and love work together here?" ], - "releasedAt": "2026-11-23T00:00:00Z" + "checkpointPrompt": "", + "checkpointQuestions": [], + "releasedAt": "2026-05-31T00:00:00Z" }, { "id": "1-9-14", @@ -544,6 +604,8 @@ "What does Paul ask God to produce?", "How does this prayer shape the goals of the study?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2026-12-07T00:00:00Z" }, { @@ -560,6 +622,8 @@ "What stands out most about Christ’s supremacy?", "Why is this passage central to the study?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2026-12-21T00:00:00Z" }, { @@ -576,6 +640,8 @@ "How does Paul describe the believer’s former condition?", "What does continuing in the faith look like?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-01-04T00:00:00Z" }, { @@ -592,6 +658,8 @@ "What is the goal of Paul’s ministry?", "Why is maturity such an important theme here?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-01-18T00:00:00Z" }, { @@ -608,6 +676,8 @@ "What does Paul want the believers to experience?", "Why does he emphasize encouragement and unity?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-02-01T00:00:00Z" }, { @@ -624,6 +694,8 @@ "What does it mean to continue in Christ?", "What kinds of deception should believers watch for?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-02-15T00:00:00Z" }, { @@ -640,6 +712,8 @@ "Why is fullness in Christ such an important correction?", "How does this guard against spiritual insecurity?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-03-01T00:00:00Z" }, { @@ -656,6 +730,8 @@ "What changes because of Christ’s work here?", "How does victory over the powers encourage believers today?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-03-15T00:00:00Z" }, { @@ -672,6 +748,8 @@ "What do shadows and substance mean here?", "Where do believers still feel pressure from external judgment?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-03-29T00:00:00Z" }, { @@ -688,6 +766,8 @@ "How does Paul describe false spirituality?", "Why is connection to Christ essential?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-04-12T00:00:00Z" }, { @@ -704,6 +784,8 @@ "Why do human regulations fail to transform the heart?", "What does real spiritual change require?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-04-26T00:00:00Z" }, { @@ -720,6 +802,8 @@ "What does it mean to seek the things above?", "How does hidden life in Christ shape daily priorities?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-05-10T00:00:00Z" }, { @@ -736,6 +820,8 @@ "Which old patterns does Paul name?", "Why is decisive change necessary?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-05-24T00:00:00Z" }, { @@ -752,6 +838,8 @@ "What does renewal in Christ produce?", "How does this reshape how believers see one another?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-06-07T00:00:00Z" }, { @@ -768,6 +856,8 @@ "Which virtues are most needed in your context?", "How does love bind these virtues together?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-06-21T00:00:00Z" }, { @@ -784,6 +874,8 @@ "What does it look like for Christ’s peace to rule?", "How can the word of Christ dwell richly in a church family?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-07-05T00:00:00Z" }, { @@ -800,6 +892,8 @@ "How do these instructions reflect Christ’s lordship?", "Which relationship is most challenging to apply today?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-07-19T00:00:00Z" }, { @@ -816,6 +910,8 @@ "How does serving the Lord reshape everyday work?", "What does Paul say about fairness and accountability?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-08-02T00:00:00Z" }, { @@ -832,6 +928,8 @@ "Why does Paul remind masters of their heavenly Master?", "What does fairness look like in this context?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-08-16T00:00:00Z" }, { @@ -848,6 +946,8 @@ "How are prayer and witness connected here?", "What does gracious speech look like in real life?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-08-30T00:00:00Z" }, { @@ -864,6 +964,8 @@ "Why do these messengers matter to the letter?", "What does this tell us about trusted coworkers?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-09-13T00:00:00Z" }, { @@ -880,6 +982,8 @@ "What stands out about Paul’s coworkers?", "How does shared ministry strengthen the church?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-09-27T00:00:00Z" }, { @@ -896,6 +1000,8 @@ "What final responsibility does Paul place on the church?", "How does the closing reinforce the letter’s themes?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-10-11T00:00:00Z" } ], @@ -1069,5 +1175,5 @@ ] } }, - "updatedAt": "2026-06-01T17:25:41.122Z" + "updatedAt": "2026-06-02T17:54:05.948Z" } \ No newline at end of file diff --git a/data/admin-content.json b/data/admin-content.json index a63b03f..63d5e42 100644 --- a/data/admin-content.json +++ b/data/admin-content.json @@ -85,7 +85,12 @@ "Why does Paul begin with grace and peace?", "What tone does this create for the rest of the study?" ], - "releasedAt": "2026-11-09T00:00:00Z" + "checkpointPrompt": "", + "checkpointQuestions": [ + "Why does Paul begin with grace and peace?", + "What tone does this create for the rest of the study?" + ], + "releasedAt": "2026-05-06T00:00:00Z" }, { "id": "1-3-8", @@ -101,7 +106,9 @@ "What signs of gospel growth does Paul mention?", "How do faith, hope, and love work together here?" ], - "releasedAt": "2026-11-23T00:00:00Z" + "checkpointPrompt": "", + "checkpointQuestions": [], + "releasedAt": "2026-05-31T00:00:00Z" }, { "id": "1-9-14", @@ -117,6 +124,8 @@ "What does Paul ask God to produce?", "How does this prayer shape the goals of the study?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2026-12-07T00:00:00Z" }, { @@ -133,6 +142,8 @@ "What stands out most about Christ’s supremacy?", "Why is this passage central to the study?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2026-12-21T00:00:00Z" }, { @@ -149,6 +160,8 @@ "How does Paul describe the believer’s former condition?", "What does continuing in the faith look like?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-01-04T00:00:00Z" }, { @@ -165,6 +178,8 @@ "What is the goal of Paul’s ministry?", "Why is maturity such an important theme here?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-01-18T00:00:00Z" }, { @@ -181,6 +196,8 @@ "What does Paul want the believers to experience?", "Why does he emphasize encouragement and unity?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-02-01T00:00:00Z" }, { @@ -197,6 +214,8 @@ "What does it mean to continue in Christ?", "What kinds of deception should believers watch for?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-02-15T00:00:00Z" }, { @@ -213,6 +232,8 @@ "Why is fullness in Christ such an important correction?", "How does this guard against spiritual insecurity?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-03-01T00:00:00Z" }, { @@ -229,6 +250,8 @@ "What changes because of Christ’s work here?", "How does victory over the powers encourage believers today?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-03-15T00:00:00Z" }, { @@ -245,6 +268,8 @@ "What do shadows and substance mean here?", "Where do believers still feel pressure from external judgment?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-03-29T00:00:00Z" }, { @@ -261,6 +286,8 @@ "How does Paul describe false spirituality?", "Why is connection to Christ essential?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-04-12T00:00:00Z" }, { @@ -277,6 +304,8 @@ "Why do human regulations fail to transform the heart?", "What does real spiritual change require?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-04-26T00:00:00Z" }, { @@ -293,6 +322,8 @@ "What does it mean to seek the things above?", "How does hidden life in Christ shape daily priorities?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-05-10T00:00:00Z" }, { @@ -309,6 +340,8 @@ "Which old patterns does Paul name?", "Why is decisive change necessary?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-05-24T00:00:00Z" }, { @@ -325,6 +358,8 @@ "What does renewal in Christ produce?", "How does this reshape how believers see one another?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-06-07T00:00:00Z" }, { @@ -341,6 +376,8 @@ "Which virtues are most needed in your context?", "How does love bind these virtues together?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-06-21T00:00:00Z" }, { @@ -357,6 +394,8 @@ "What does it look like for Christ’s peace to rule?", "How can the word of Christ dwell richly in a church family?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-07-05T00:00:00Z" }, { @@ -373,6 +412,8 @@ "How do these instructions reflect Christ’s lordship?", "Which relationship is most challenging to apply today?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-07-19T00:00:00Z" }, { @@ -389,6 +430,8 @@ "How does serving the Lord reshape everyday work?", "What does Paul say about fairness and accountability?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-08-02T00:00:00Z" }, { @@ -405,6 +448,8 @@ "Why does Paul remind masters of their heavenly Master?", "What does fairness look like in this context?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-08-16T00:00:00Z" }, { @@ -421,6 +466,8 @@ "How are prayer and witness connected here?", "What does gracious speech look like in real life?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-08-30T00:00:00Z" }, { @@ -437,6 +484,8 @@ "Why do these messengers matter to the letter?", "What does this tell us about trusted coworkers?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-09-13T00:00:00Z" }, { @@ -453,6 +502,8 @@ "What stands out about Paul’s coworkers?", "How does shared ministry strengthen the church?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-09-27T00:00:00Z" }, { @@ -469,6 +520,8 @@ "What final responsibility does Paul place on the church?", "How does the closing reinforce the letter’s themes?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-10-11T00:00:00Z" } ] @@ -512,7 +565,12 @@ "Why does Paul begin with grace and peace?", "What tone does this create for the rest of the study?" ], - "releasedAt": "2026-11-09T00:00:00Z" + "checkpointPrompt": "", + "checkpointQuestions": [ + "Why does Paul begin with grace and peace?", + "What tone does this create for the rest of the study?" + ], + "releasedAt": "2026-05-06T00:00:00Z" }, { "id": "1-3-8", @@ -528,7 +586,9 @@ "What signs of gospel growth does Paul mention?", "How do faith, hope, and love work together here?" ], - "releasedAt": "2026-11-23T00:00:00Z" + "checkpointPrompt": "", + "checkpointQuestions": [], + "releasedAt": "2026-05-31T00:00:00Z" }, { "id": "1-9-14", @@ -544,6 +604,8 @@ "What does Paul ask God to produce?", "How does this prayer shape the goals of the study?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2026-12-07T00:00:00Z" }, { @@ -560,6 +622,8 @@ "What stands out most about Christ’s supremacy?", "Why is this passage central to the study?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2026-12-21T00:00:00Z" }, { @@ -576,6 +640,8 @@ "How does Paul describe the believer’s former condition?", "What does continuing in the faith look like?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-01-04T00:00:00Z" }, { @@ -592,6 +658,8 @@ "What is the goal of Paul’s ministry?", "Why is maturity such an important theme here?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-01-18T00:00:00Z" }, { @@ -608,6 +676,8 @@ "What does Paul want the believers to experience?", "Why does he emphasize encouragement and unity?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-02-01T00:00:00Z" }, { @@ -624,6 +694,8 @@ "What does it mean to continue in Christ?", "What kinds of deception should believers watch for?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-02-15T00:00:00Z" }, { @@ -640,6 +712,8 @@ "Why is fullness in Christ such an important correction?", "How does this guard against spiritual insecurity?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-03-01T00:00:00Z" }, { @@ -656,6 +730,8 @@ "What changes because of Christ’s work here?", "How does victory over the powers encourage believers today?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-03-15T00:00:00Z" }, { @@ -672,6 +748,8 @@ "What do shadows and substance mean here?", "Where do believers still feel pressure from external judgment?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-03-29T00:00:00Z" }, { @@ -688,6 +766,8 @@ "How does Paul describe false spirituality?", "Why is connection to Christ essential?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-04-12T00:00:00Z" }, { @@ -704,6 +784,8 @@ "Why do human regulations fail to transform the heart?", "What does real spiritual change require?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-04-26T00:00:00Z" }, { @@ -720,6 +802,8 @@ "What does it mean to seek the things above?", "How does hidden life in Christ shape daily priorities?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-05-10T00:00:00Z" }, { @@ -736,6 +820,8 @@ "Which old patterns does Paul name?", "Why is decisive change necessary?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-05-24T00:00:00Z" }, { @@ -752,6 +838,8 @@ "What does renewal in Christ produce?", "How does this reshape how believers see one another?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-06-07T00:00:00Z" }, { @@ -768,6 +856,8 @@ "Which virtues are most needed in your context?", "How does love bind these virtues together?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-06-21T00:00:00Z" }, { @@ -784,6 +874,8 @@ "What does it look like for Christ’s peace to rule?", "How can the word of Christ dwell richly in a church family?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-07-05T00:00:00Z" }, { @@ -800,6 +892,8 @@ "How do these instructions reflect Christ’s lordship?", "Which relationship is most challenging to apply today?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-07-19T00:00:00Z" }, { @@ -816,6 +910,8 @@ "How does serving the Lord reshape everyday work?", "What does Paul say about fairness and accountability?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-08-02T00:00:00Z" }, { @@ -832,6 +928,8 @@ "Why does Paul remind masters of their heavenly Master?", "What does fairness look like in this context?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-08-16T00:00:00Z" }, { @@ -848,6 +946,8 @@ "How are prayer and witness connected here?", "What does gracious speech look like in real life?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-08-30T00:00:00Z" }, { @@ -864,6 +964,8 @@ "Why do these messengers matter to the letter?", "What does this tell us about trusted coworkers?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-09-13T00:00:00Z" }, { @@ -880,6 +982,8 @@ "What stands out about Paul’s coworkers?", "How does shared ministry strengthen the church?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-09-27T00:00:00Z" }, { @@ -896,6 +1000,8 @@ "What final responsibility does Paul place on the church?", "How does the closing reinforce the letter’s themes?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-10-11T00:00:00Z" } ], @@ -1069,5 +1175,5 @@ ] } }, - "updatedAt": "2026-06-01T17:25:41.133Z" + "updatedAt": "2026-06-02T17:54:06.010Z" } \ No newline at end of file diff --git a/data/questions.json b/data/questions.json index 1c0c9fe..1ab7a58 100644 --- a/data/questions.json +++ b/data/questions.json @@ -98,5 +98,5 @@ "topic": "Faith & Life" } ], - "updatedAt": "2026-06-01T17:25:41.133Z" + "updatedAt": "2026-06-02T17:54:06.010Z" } \ No newline at end of file diff --git a/data/study-community.json b/data/study-community.json new file mode 100644 index 0000000..01b7656 --- /dev/null +++ b/data/study-community.json @@ -0,0 +1,50 @@ +{ + "posts": [ + { + "id": "fd6f2d9c-8abc-43b2-8e93-c58c0cb1ff7b", + "studySlug": "colossians", + "sectionId": "", + "authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34", + "authorName": "Test Student", + "message": "reading", + "createdAt": "2026-06-02T17:41:48.684Z", + "replies": [] + }, + { + "id": "001f2545-e1ee-4748-902b-5da5c47554bb", + "studySlug": "colossians", + "sectionId": "", + "authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34", + "authorName": "Test Student", + "message": "sool", + "createdAt": "2026-06-02T17:41:41.608Z", + "replies": [] + }, + { + "id": "c40e4c10-47a3-42e8-88bd-78116af5babe", + "studySlug": "colossians", + "sectionId": "", + "authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34", + "authorName": "Test Student", + "message": "test", + "createdAt": "2026-06-02T17:41:16.795Z", + "replies": [ + { + "id": "efe0bf3d-66e8-4dad-98c1-2e8c742e3120", + "authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34", + "authorName": "Test Student", + "message": "test", + "createdAt": "2026-06-02T17:41:25.003Z" + }, + { + "id": "1d6c3bd1-5ada-4f98-8ca3-5ed15bd00a68", + "authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34", + "authorName": "Test Student", + "message": "test", + "createdAt": "2026-06-02T17:41:34.406Z" + } + ] + } + ], + "updatedAt": "2026-06-02T17:41:48.685Z" +} \ No newline at end of file diff --git a/data/study-notes/3307bdd7-a770-4fad-a28e-cf2d0c2a6e34.json b/data/study-notes/3307bdd7-a770-4fad-a28e-cf2d0c2a6e34.json new file mode 100644 index 0000000..a0d6277 --- /dev/null +++ b/data/study-notes/3307bdd7-a770-4fad-a28e-cf2d0c2a6e34.json @@ -0,0 +1,3 @@ +{ + "colossians--1-1-2": "test....teset" +} \ No newline at end of file diff --git a/data/study-progress/3307bdd7-a770-4fad-a28e-cf2d0c2a6e34.json b/data/study-progress/3307bdd7-a770-4fad-a28e-cf2d0c2a6e34.json new file mode 100644 index 0000000..3baee1a --- /dev/null +++ b/data/study-progress/3307bdd7-a770-4fad-a28e-cf2d0c2a6e34.json @@ -0,0 +1,16 @@ +{ + "byStudy": { + "colossians": { + "completedSectionIds": [ + "1-1-2" + ], + "quizAnswers": { + "1-1-2": [ + "test", + "test" + ] + } + } + }, + "updatedAt": "2026-06-02T17:53:29.839Z" +} \ No newline at end of file diff --git a/data/study-users.json b/data/study-users.json index 2467799..ef513f8 100644 --- a/data/study-users.json +++ b/data/study-users.json @@ -6,19 +6,31 @@ "passwordHash": "6500cc2e717d1d646f371dba1ec918d2f2f9fddbb8576c792df69a1f2340dcd9", "displayName": "Student Test", "subscribeNewsletter": false, - "pendingEmailChange": { - "newEmail": "student.test+new@versebyverse.local", - "tokenHash": "ce4671e74a12302744878f690af5bdfbb087c83bebdb9183c1212eacaef25554", - "expiresAt": 1779464255007, - "requestedAt": "2026-05-21T15:37:35.007Z" - }, + "avatarUrl": "", + "lastLoginAt": "2026-05-21T15:41:16.259Z" + }, + { + "id": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34", + "username": "test.user@versebyverse.local", + "passwordHash": "fc89481d72dbea9af1d4f054faad48813227fa018da39259272dd27b5e493bb5", + "displayName": "Test Student", + "subscribeNewsletter": false, + "avatarUrl": "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2288%22%20height%3D%2288%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20rx%3D%2218%22%20ry%3D%2218%22%20fill%3D%22%2311100d%22%2F%3E%3Ctext%20x%3D%2250%25%22%20y%3D%2250%25%22%20dominant-baseline%3D%22middle%22%20text-anchor%3D%22middle%22%20font-size%3D%2256%22%3E%F0%9F%A6%81%3C%2Ftext%3E%3C%2Fsvg%3E", + "lastLoginAt": "2026-06-02T17:56:40.769Z", + "updatedAt": "2026-06-02T17:57:04.582Z", "enrolledStudySlugs": [ "colossians" - ], - "createdAt": "2026-05-21T13:05:00.000Z", - "updatedAt": "2026-05-21T18:41:10.857Z", - "lastLoginAt": "2026-05-21T15:41:16.259Z" + ] + }, + { + "id": "81c01e32-d94e-4e19-93ea-e5cd7644ea6a", + "username": "test.avatar@versebyverse.local", + "passwordHash": "fc89481d72dbea9af1d4f054faad48813227fa018da39259272dd27b5e493bb5", + "displayName": "Avatar Test", + "subscribeNewsletter": false, + "avatarUrl": "data:image/svg+xml;utf8,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"88\" height=\"88\"%3E%3Crect width=\"100%\" height=\"100%\" rx=\"18\" ry=\"18\" fill=\"%2311100d\"/%3E%3Ctext x=\"50%\" y=\"50%\" dominant-baseline=\"middle\" text-anchor=\"middle\" font-size=\"56\"%3E🐱%3C/text%3E%3C/svg%3E", + "lastLoginAt": "2026-06-02T17:33:13.669Z" } ], - "updatedAt": "2026-05-21T18:41:10.858Z" + "updatedAt": "2026-06-02T17:57:04.583Z" } \ No newline at end of file diff --git a/server.js b/server.js index 09518b2..bdd3ec7 100644 --- a/server.js +++ b/server.js @@ -61,6 +61,7 @@ const DRAFT_QUESTIONS_FILE = path.join(DATA_DIR, 'questions-draft.json') const STUDY_USERS_FILE = path.join(DATA_DIR, 'study-users.json') const STUDY_NOTES_FILE = path.join(DATA_DIR, 'study-notes.json') // legacy — kept only for one-time migration const STUDY_NOTES_DIR = path.join(DATA_DIR, 'study-notes') +const STUDY_PROGRESS_DIR = path.join(DATA_DIR, 'study-progress') const STUDY_COMMUNITY_FILE = path.join(DATA_DIR, 'study-community.json') const REPLY_TEMPLATES_FILE = path.join(DATA_DIR, 'admin-reply-templates.json') const REPLY_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.json') @@ -634,6 +635,10 @@ let studyUsers = [] let studyUsersWritePromise = Promise.resolve() const studyNotesCache = new Map() // userId -> { [sectionId]: string } const studyNotesWriteQueues = new Map() // userId -> Promise +let studyReminders = { users: {}, updatedAt: new Date().toISOString() } +let studyRemindersWritePromise = Promise.resolve() +const studyProgressCache = new Map() // userId -> { byStudy: Record } +const studyProgressWriteQueues = new Map() // userId -> Promise let studyCommunityPosts = [] let studyCommunityWritePromise = Promise.resolve() let downloadCounts = {} @@ -2241,6 +2246,28 @@ function normalizeStudyUsername(value) { return value.trim().toLowerCase() } +function getStudyAvatarUrl(subject) { + let customAvatar = '' + let username = '' + + if (subject && typeof subject === 'object') { + customAvatar = typeof subject.avatarUrl === 'string' ? subject.avatarUrl.trim() : '' + username = normalizeStudyUsername(subject.username) + } else if (typeof subject === 'string') { + username = normalizeStudyUsername(subject) + } + + if (customAvatar) return customAvatar + if (!username) return '' + const hash = createHash('md5').update(username).digest('hex') + return `https://www.gravatar.com/avatar/${hash}?d=identicon&s=96` +} + +function findStudyUserById(userId) { + if (typeof userId !== 'string' || !userId.trim()) return undefined + return studyUsers.find(user => user.id === userId) +} + function normalizeStudySlug(value) { if (typeof value !== 'string') return '' const trimmed = value.trim().toLowerCase() @@ -2434,6 +2461,7 @@ function sanitizeStudyUsers(value) { : [] const displayName = typeof item?.displayName === 'string' ? item.displayName.trim().slice(0, 80) : '' const subscribeNewsletter = item?.subscribeNewsletter !== false + const studyRemindersEnabled = item?.studyRemindersEnabled === true const pendingEmailChange = item?.pendingEmailChange && typeof item.pendingEmailChange === 'object' && !Array.isArray(item.pendingEmailChange) ? { newEmail: isValidStudyUsername(normalizeStudyUsername(item.pendingEmailChange.newEmail)) @@ -2451,13 +2479,11 @@ function sanitizeStudyUsers(value) { passwordHash, displayName, subscribeNewsletter, - pendingEmailChange: pendingEmailChange?.newEmail && pendingEmailChange?.tokenHash && pendingEmailChange.expiresAt > Date.now() - ? pendingEmailChange - : null, + studyRemindersEnabled, enrolledStudySlugs, - createdAt: typeof item?.createdAt === 'string' ? item.createdAt : new Date().toISOString(), - updatedAt: typeof item?.updatedAt === 'string' ? item.updatedAt : new Date().toISOString(), + avatarUrl: typeof item?.avatarUrl === 'string' ? item.avatarUrl.trim() : '', lastLoginAt: typeof item?.lastLoginAt === 'string' ? item.lastLoginAt : null, + pendingEmailChange, }) } @@ -2581,6 +2607,195 @@ function queueUserNotesWrite(userId) { studyNotesWriteQueues.set(userId, next) } +function getUserProgressFilePath(userId) { + return path.join(STUDY_PROGRESS_DIR, `${userId}.json`) +} + +function sanitizeStudyProgress(value) { + const defaultResult = { byStudy: {}, updatedAt: new Date().toISOString() } + if (!value || typeof value !== 'object') return defaultResult + + const progress = { byStudy: {}, updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : new Date().toISOString() } + if (value.byStudy && typeof value.byStudy === 'object') { + for (const [studySlug, studyData] of Object.entries(value.byStudy)) { + if (typeof studySlug !== 'string' || !studySlug.trim()) continue + const completedSectionIds = Array.isArray(studyData?.completedSectionIds) + ? studyData.completedSectionIds.filter(id => typeof id === 'string' && id.trim()).map(id => id.trim()) + : [] + const quizAnswers = studyData?.quizAnswers && typeof studyData?.quizAnswers === 'object' && !Array.isArray(studyData.quizAnswers) + ? Object.fromEntries( + Object.entries(studyData.quizAnswers) + .filter(([sectionId]) => typeof sectionId === 'string' && sectionId.trim()) + .map(([sectionId, answers]) => [ + sectionId.trim(), + Array.isArray(answers) + ? answers.filter(answer => typeof answer === 'string').map(answer => answer.trim()) + : [], + ]) + ) + : {} + progress.byStudy[studySlug.trim().toLowerCase()] = { + completedSectionIds: Array.from(new Set(completedSectionIds)), + quizAnswers, + } + } + } + + return progress +} + +async function loadUserProgress(userId) { + if (studyProgressCache.has(userId)) return studyProgressCache.get(userId) + try { + const raw = await readFile(getUserProgressFilePath(userId), 'utf8') + const progress = sanitizeStudyProgress(JSON.parse(raw)) + studyProgressCache.set(userId, progress) + return progress + } catch { + const progress = { byStudy: {}, updatedAt: new Date().toISOString() } + studyProgressCache.set(userId, progress) + return progress + } +} + +function queueUserProgressWrite(userId) { + const prev = studyProgressWriteQueues.get(userId) ?? Promise.resolve() + const next = prev + .then(async () => { + const progress = studyProgressCache.get(userId) ?? { byStudy: {}, updatedAt: new Date().toISOString() } + await mkdir(STUDY_PROGRESS_DIR, { recursive: true }) + await writeFile(getUserProgressFilePath(userId), JSON.stringify(progress, null, 2), 'utf8') + }) + .catch(err => { + console.error(`[study-progress] failed to write progress for user ${userId}:`, err) + }) + studyProgressWriteQueues.set(userId, next) +} + +function sanitizeStudyReminders(value) { + const defaultResult = { users: {}, updatedAt: new Date().toISOString() } + if (!value || typeof value !== 'object' || Array.isArray(value)) return defaultResult + + const reminders = { users: {}, updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : new Date().toISOString() } + if (value.users && typeof value.users === 'object') { + for (const [userId, userData] of Object.entries(value.users)) { + if (typeof userId !== 'string' || !userId.trim()) continue + const studies = typeof userData === 'object' && userData && !Array.isArray(userData) + ? userData + : {} + const normalizedStudies = {} + for (const [studySlug, sectionIds] of Object.entries(studies)) { + if (typeof studySlug !== 'string' || !studySlug.trim()) continue + const ids = Array.isArray(sectionIds) + ? sectionIds.filter(id => typeof id === 'string' && id.trim()).map(id => id.trim()) + : [] + if (ids.length > 0) normalizedStudies[studySlug.trim().toLowerCase()] = Array.from(new Set(ids)) + } + reminders.users[userId.trim()] = normalizedStudies + } + } + + return reminders +} + +async function loadStudyRemindersFromDisk() { + try { + const raw = await readFile(STUDY_REMINDERS_FILE, 'utf8') + studyReminders = sanitizeStudyReminders(JSON.parse(raw)) + } catch { + studyReminders = { users: {}, updatedAt: new Date().toISOString() } + } +} + +function queueStudyRemindersWrite() { + studyRemindersWritePromise = studyRemindersWritePromise + .then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile(STUDY_REMINDERS_FILE, JSON.stringify(studyReminders, null, 2), 'utf8') + }) + .catch(err => { + console.error('[study-reminders] failed to write reminders:', err) + }) +} + +async function sendStudyReminderEmail(email, displayName, studyTitle, sectionTitle, sectionReference, sectionUrl) { + if (!process.env.RESEND_API_KEY) return + try { + const resend = new Resend(process.env.RESEND_API_KEY) + const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend' + const bodyHtml = ( + `

Hi ${escapeHtml(namePart)},

` + + `

A new study lesson is available in ${escapeHtml(studyTitle)}.

` + + `

${escapeHtml(sectionTitle)} (${escapeHtml(sectionReference)}) is now unlocked.

` + ) + const footerHtml = `

Grace and peace,
Verse by Verse with Nate

` + const { error } = await resend.emails.send({ + from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate ', + to: [email], + subject: process.env.RESEND_REMINDER_SUBJECT ?? `New lesson available: ${sectionTitle}`, + text: + `Hi ${namePart},\n\n` + + `A new lesson is available in ${studyTitle}.\n` + + `${sectionTitle} (${sectionReference}) is now unlocked.\n\n` + + `Open it here: ${sectionUrl}\n\n` + + `Grace and peace,\nVerse by Verse with Nate`, + html: buildBrandedEmailHtml({ + title: 'New Lesson Available', + eyebrow: 'Study Reminder', + bodyHtml, + ctaLabel: 'Open the Lesson', + ctaUrl: sectionUrl, + footerHtml, + }), + }) + if (error) console.error('[study-reminder] send error:', error) + } catch (err) { + console.error('[study-reminder] send exception:', err) + } +} + +async function scheduleStudyReminders() { + if (!cachedSiteContent) return + const now = new Date() + const userIds = studyUsers.filter(user => user.studyRemindersEnabled === true).map(user => user.id) + if (userIds.length === 0) return + + for (const user of studyUsers) { + if (user.studyRemindersEnabled !== true) continue + const email = user.username + const displayName = user.displayName || email + const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : [] + if (enrolledStudySlugs.length === 0) continue + + const userSent = studyReminders.users[user.id] ?? {} + for (const studySlug of enrolledStudySlugs) { + const study = Array.isArray(cachedSiteContent.studies) + ? cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug) + : undefined + if (!study) continue + + for (const section of study.sections ?? []) { + const sectionId = section.id + const releaseDate = getSectionReleaseDate(section) + if (!releaseDate) continue + if (releaseDate > now) continue + const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : [] + if (sentForStudy.includes(sectionId)) continue + + const hoursSinceRelease = (now.getTime() - releaseDate.getTime()) / (1000 * 60 * 60) + if (hoursSinceRelease > 24) continue + + const sectionUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/${study.slug}/${section.id}`) + await sendStudyReminderEmail(email, displayName, study.title, section.title, section.reference, sectionUrl) + userSent[studySlug] = [...sentForStudy, sectionId] + studyReminders.users[user.id] = userSent + } + } + } + studyReminders.updatedAt = new Date().toISOString() + queueStudyRemindersWrite() +} + async function migrateStudyNotesIfNeeded() { try { const raw = await readFile(STUDY_NOTES_FILE, 'utf8') @@ -2724,6 +2939,8 @@ app.get('/api/study-auth/status', (req, res) => { username: user?.username ?? '', displayName: user?.displayName ?? '', subscribeNewsletter: user?.subscribeNewsletter !== false, + studyRemindersEnabled: user?.studyRemindersEnabled === true, + avatarUrl: user ? getStudyAvatarUrl(user) : '', enrolledStudySlugs: Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [], }) }) @@ -2756,6 +2973,7 @@ app.post('/api/study-auth/signup', studyAuthRateLimiter, (req, res) => { passwordHash: hashStudyPassword(password), displayName, subscribeNewsletter: subscribe, + studyRemindersEnabled: false, pendingEmailChange: null, enrolledStudySlugs: [], createdAt: now, @@ -2783,6 +3001,8 @@ app.post('/api/study-auth/signup', studyAuthRateLimiter, (req, res) => { username: user.username, displayName: user.displayName, subscribeNewsletter: user.subscribeNewsletter, + studyRemindersEnabled: user.studyRemindersEnabled === true, + avatarUrl: getStudyAvatarUrl(user.username), enrolledStudySlugs: user.enrolledStudySlugs, }) }) @@ -2817,6 +3037,8 @@ app.post('/api/study-auth/login', studyAuthRateLimiter, (req, res) => { username: user.username, displayName: user.displayName ?? '', subscribeNewsletter: user.subscribeNewsletter !== false, + studyRemindersEnabled: user.studyRemindersEnabled === true, + avatarUrl: getStudyAvatarUrl(user.username), enrolledStudySlugs: user.enrolledStudySlugs ?? [], }) }) @@ -2941,6 +3163,126 @@ app.put('/api/study-notes/:sectionId', requireStudyAuth, async (req, res) => { res.json({ ok: true, note }) }) +app.get('/api/study-progress/:studySlug', requireStudyAuth, async (req, res) => { + const user = req.studyUser + const studySlug = normalizeStudySlug(req.params.studySlug) + if (!studySlug) { + res.status(400).json({ message: 'Study slug is required.' }) + return + } + + if (!isStudyUserEnrolled(user, studySlug)) { + res.status(403).json({ message: 'Please enroll in this study to view progress.' }) + return + } + + const progress = await loadUserProgress(user.id) + const completedSectionIds = progress.byStudy[studySlug]?.completedSectionIds ?? [] + res.json({ studySlug, completedSectionIds }) +}) + +app.post('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => { + const user = req.studyUser + const studySlug = normalizeStudySlug(req.params.studySlug) + const sectionId = normalizeLessonSectionId(req.params.sectionId) + if (!studySlug || !sectionId) { + res.status(400).json({ message: 'Invalid study slug or section id.' }) + return + } + + if (!isStudyUserEnrolled(user, studySlug)) { + res.status(403).json({ message: 'Please enroll in this study to update progress.' }) + return + } + + const progress = await loadUserProgress(user.id) + const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [] } + if (!studyProgress.completedSectionIds.includes(sectionId)) { + studyProgress.completedSectionIds = [...studyProgress.completedSectionIds, sectionId] + } + progress.byStudy[studySlug] = studyProgress + progress.updatedAt = new Date().toISOString() + studyProgressCache.set(user.id, progress) + queueUserProgressWrite(user.id) + + res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds }) +}) + +app.get('/api/study-quiz/:studySlug/:sectionId', requireStudyAuth, async (req, res) => { + const user = req.studyUser + const studySlug = normalizeStudySlug(req.params.studySlug) + const sectionId = normalizeLessonSectionId(req.params.sectionId) + if (!studySlug || !sectionId) { + res.status(400).json({ message: 'Invalid study slug or section id.' }) + return + } + + if (!isStudyUserEnrolled(user, studySlug)) { + res.status(403).json({ message: 'Please enroll in this study to view quiz answers.' }) + return + } + + const progress = await loadUserProgress(user.id) + const quizAnswers = progress.byStudy[studySlug]?.quizAnswers?.[sectionId] ?? [] + res.json({ studySlug, sectionId, answers: quizAnswers }) +}) + +app.post('/api/study-quiz/:studySlug/:sectionId', requireStudyAuth, async (req, res) => { + const user = req.studyUser + const studySlug = normalizeStudySlug(req.params.studySlug) + const sectionId = normalizeLessonSectionId(req.params.sectionId) + if (!studySlug || !sectionId) { + res.status(400).json({ message: 'Invalid study slug or section id.' }) + return + } + + if (!isStudyUserEnrolled(user, studySlug)) { + res.status(403).json({ message: 'Please enroll in this study to save quiz answers.' }) + return + } + + const rawAnswers = req.body?.answers + const answers = Array.isArray(rawAnswers) + ? rawAnswers.map(answer => typeof answer === 'string' ? answer.trim() : '').filter(Boolean) + : [] + + const progress = await loadUserProgress(user.id) + const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [] } + studyProgress.quizAnswers = studyProgress.quizAnswers || {} + studyProgress.quizAnswers[sectionId] = answers + progress.byStudy[studySlug] = studyProgress + progress.updatedAt = new Date().toISOString() + studyProgressCache.set(user.id, progress) + queueUserProgressWrite(user.id) + + res.json({ ok: true, studySlug, sectionId, answers }) +}) + +app.delete('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => { + const user = req.studyUser + const studySlug = normalizeStudySlug(req.params.studySlug) + const sectionId = normalizeLessonSectionId(req.params.sectionId) + if (!studySlug || !sectionId) { + res.status(400).json({ message: 'Invalid study slug or section id.' }) + return + } + + if (!isStudyUserEnrolled(user, studySlug)) { + res.status(403).json({ message: 'Please enroll in this study to update progress.' }) + return + } + + const progress = await loadUserProgress(user.id) + const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [] } + studyProgress.completedSectionIds = studyProgress.completedSectionIds.filter(id => id !== sectionId) + progress.byStudy[studySlug] = studyProgress + progress.updatedAt = new Date().toISOString() + studyProgressCache.set(user.id, progress) + queueUserProgressWrite(user.id) + + res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds }) +}) + app.get('/api/study-community', requireStudyAuth, async (req, res) => { const user = req.studyUser const studySlug = normalizeStudySlug(typeof req.query?.studySlug === 'string' ? req.query.studySlug : '') @@ -2959,6 +3301,23 @@ app.get('/api/study-community', requireStudyAuth, async (req, res) => { .filter(post => post.studySlug === studySlug) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .slice(0, 50) + .map(post => { + const author = findStudyUserById(post.authorUserId) + const enrichedPost = { + ...post, + authorAvatarUrl: getStudyAvatarUrl(author || post.authorName || ''), + replies: Array.isArray(post.replies) + ? post.replies.map(reply => { + const replyAuthor = findStudyUserById(reply.authorUserId) + return { + ...reply, + authorAvatarUrl: getStudyAvatarUrl(replyAuthor || reply.authorName || ''), + } + }) + : [], + } + return enrichedPost + }) res.json({ studySlug, @@ -2990,6 +3349,7 @@ app.post('/api/study-community/posts', requireStudyAuth, async (req, res) => { sectionId, authorUserId: user.id, authorName, + authorAvatarUrl: getStudyAvatarUrl(user.username), message, createdAt: now, replies: [], @@ -3027,6 +3387,7 @@ app.post('/api/study-community/posts/:postId/replies', requireStudyAuth, async ( id: randomUUID(), authorUserId: user.id, authorName: user.displayName?.trim() || user.username, + authorAvatarUrl: getStudyAvatarUrl(user.username), message, createdAt: new Date().toISOString(), } @@ -3042,86 +3403,151 @@ app.post('/api/study-community/posts/:postId/replies', requireStudyAuth, async ( app.get('/api/study-account/export-notes', requireStudyAuth, async (req, res) => { const user = req.studyUser const notes = await loadUserNotes(user.id) + const progress = await loadUserProgress(user.id) - // Build a lookup of sectionId -> { title, reference, studyTitle } from cached content + // Build a lookup of sectionId -> { title, reference, studyTitle, description } from cached content const sectionMeta = {} const content = cachedSiteContent - if (content) { - const studies = Array.isArray(content.studies) && content.studies.length > 0 - ? content.studies - : [{ slug: 'colossians', title: 'Colossians: Rooted in Christ', sections: content.colossiansStudySections ?? [] }] - for (const study of studies) { - for (const section of (study.sections ?? [])) { - sectionMeta[`${study.slug}--${section.id}`] = { - studyTitle: study.title, - title: section.title, - reference: section.reference, - } + const studies = content && Array.isArray(content.studies) && content.studies.length > 0 + ? content.studies + : [{ slug: 'colossians', title: 'Colossians: Rooted in Christ', description: '', sections: content?.colossiansStudySections ?? [] }] + + for (const study of studies) { + for (const section of (study.sections ?? [])) { + sectionMeta[`${study.slug}--${section.id}`] = { + studyTitle: study.title, + studyDescription: typeof study.description === 'string' ? study.description : '', + title: section.title, + reference: section.reference, + studyQuestions: Array.isArray(section.studyQuestions) ? section.studyQuestions : [], } } } - // Group notes by study - const byStudy = {} + const studyEntries = {} + for (const [noteKey, noteText] of Object.entries(notes)) { if (!noteText?.trim()) continue const dashIndex = noteKey.indexOf('--') const studySlug = dashIndex >= 0 ? noteKey.slice(0, dashIndex) : 'unknown' - if (!byStudy[studySlug]) byStudy[studySlug] = [] - byStudy[studySlug].push({ noteKey, noteText }) + const sectionId = dashIndex >= 0 ? noteKey.slice(dashIndex + 2) : noteKey + if (!studyEntries[studySlug]) studyEntries[studySlug] = {} + studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {} + studyEntries[studySlug][sectionId].noteText = noteText.trim() } + for (const [studySlug, studyProgress] of Object.entries(progress.byStudy)) { + const quizAnswersBySection = studyProgress.quizAnswers || {} + for (const [sectionId, answers] of Object.entries(quizAnswersBySection)) { + if (!Array.isArray(answers) || answers.length === 0) continue + if (!studyEntries[studySlug]) studyEntries[studySlug] = {} + studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {} + studyEntries[studySlug][sectionId].quizAnswers = answers.filter(answer => typeof answer === 'string' && answer.trim()).map(answer => answer.trim()) + } + } + + const studySlugs = Array.from(new Set([ + ...Object.keys(studyEntries), + ...Object.values(studies).map(study => study.slug), + ])) + const docChildren = [ - new Paragraph({ - text: 'My Study Notes', - heading: HeadingLevel.TITLE, - }), - new Paragraph({ - children: [new TextRun({ text: `Exported ${new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}`, italics: true })], - spacing: { after: 400 }, - }), + new Paragraph({ text: 'Verse by Verse with Nate', heading: HeadingLevel.TITLE }), + new Paragraph({ text: 'My Study Export', heading: HeadingLevel.HEADING_1, spacing: { after: 240 } }), + new Paragraph({ text: `Student: ${user.displayName || user.username}`, spacing: { after: 120 } }), + new Paragraph({ text: `Exported ${new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}`, italics: true, spacing: { after: 400 } }), ] - for (const [studySlug, entries] of Object.entries(byStudy)) { - const studyTitle = entries[0] ? (sectionMeta[entries[0].noteKey]?.studyTitle ?? studySlug) : studySlug + for (const studySlug of studySlugs) { + const study = studies.find(item => normalizeStudySlug(item?.slug) === studySlug) + const studyTitle = study?.title || studySlug + const studyDescription = typeof study?.description === 'string' ? study.description : '' + const sectionIds = studyEntries[studySlug] ? Object.keys(studyEntries[studySlug]) : [] + + if (sectionIds.length === 0) continue + docChildren.push( new Paragraph({ text: studyTitle, heading: HeadingLevel.HEADING_1, spacing: { before: 400 } }), ) - for (const { noteKey, noteText } of entries) { - const meta = sectionMeta[noteKey] - const lessonTitle = meta?.title ?? noteKey - const reference = meta?.reference ?? '' + if (studyDescription) { docChildren.push( - new Paragraph({ text: lessonTitle, heading: HeadingLevel.HEADING_2, spacing: { before: 240 } }), + new Paragraph({ text: studyDescription, spacing: { after: 240 } }), ) - if (reference) { + } + const noteCount = sectionIds.filter(sectionId => studyEntries[studySlug][sectionId].noteText).length + const quizCount = sectionIds.filter(sectionId => Array.isArray(studyEntries[studySlug][sectionId].quizAnswers) && studyEntries[studySlug][sectionId].quizAnswers.length > 0).length + docChildren.push( + new Paragraph({ text: `Notes: ${noteCount} | Quiz sections: ${quizCount}`, italics: true, spacing: { after: 240 } }), + ) + + const orderedSectionIds = study?.sections?.map(section => section.id).filter(id => sectionIds.includes(id)) ?? sectionIds + for (const sectionId of orderedSectionIds) { + const entry = studyEntries[studySlug][sectionId] + if (!entry) continue + const meta = sectionMeta[`${studySlug}--${sectionId}`] || { title: sectionId, reference: '' } + docChildren.push( + new Paragraph({ text: meta.title, heading: HeadingLevel.HEADING_2, spacing: { before: 240 } }), + ) + if (meta.reference) { docChildren.push( - new Paragraph({ - children: [new TextRun({ text: reference, italics: true, color: '555555' })], - spacing: { after: 120 }, - }), + new Paragraph({ children: [new TextRun({ text: meta.reference, italics: true, color: '555555' })], spacing: { after: 120 } }), ) } - for (const line of noteText.split('\n')) { + if (entry.noteText) { docChildren.push( - new Paragraph({ text: line.trim(), spacing: { after: 80 } }), + new Paragraph({ text: 'Notes', heading: HeadingLevel.HEADING_3, spacing: { before: 120 } }), ) + for (const line of entry.noteText.split('\n')) { + docChildren.push(new Paragraph({ text: line.trim(), spacing: { after: 80 } })) + } + } + if (Array.isArray(meta.studyQuestions) && meta.studyQuestions.length > 0) { + docChildren.push( + new Paragraph({ text: 'Quiz Questions', heading: HeadingLevel.HEADING_3, spacing: { before: 160 } }), + ) + meta.studyQuestions.forEach((question, index) => { + docChildren.push( + new Paragraph({ + children: [ + new TextRun({ text: `${index + 1}. `, bold: true }), + new TextRun({ text: question }), + ], + spacing: { after: 80 }, + }), + ) + }) + } + if (Array.isArray(entry.quizAnswers) && entry.quizAnswers.length > 0) { + docChildren.push( + new Paragraph({ text: 'Quiz Answers', heading: HeadingLevel.HEADING_3, spacing: { before: 160 } }), + ) + entry.quizAnswers.forEach((answer, index) => { + docChildren.push( + new Paragraph({ + children: [ + new TextRun({ text: `Answer ${index + 1}: `, bold: true }), + new TextRun({ text: answer }), + ], + spacing: { after: 80 }, + }), + ) + }) } } } - if (docChildren.length <= 2) { - docChildren.push(new Paragraph({ text: 'No notes saved yet.', spacing: { before: 200 } })) + if (docChildren.length <= 4) { + docChildren.push(new Paragraph({ text: 'No notes or quiz answers saved yet.', spacing: { before: 200 } })) } const doc = new Document({ creator: 'Verse by Verse with Nate', - title: 'My Study Notes', + title: 'My Study Export', sections: [{ children: docChildren }], }) const buffer = await Packer.toBuffer(doc) - const filename = `my-study-notes-${new Date().toISOString().slice(0, 10)}.docx` + const filename = `my-study-export-${new Date().toISOString().slice(0, 10)}.docx` res.setHeader('Content-Disposition', `attachment; filename="${filename}"`) res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') res.send(buffer) @@ -3154,6 +3580,7 @@ app.post('/api/study-account/change-password', studyAuthRateLimiter, requireStud app.get('/api/study-account/overview', requireStudyAuth, async (req, res) => { const user = req.studyUser const notes = await loadUserNotes(user.id) + const progress = await loadUserProgress(user.id) const noteEntries = Object.entries(notes) const studies = getStudyCatalog().map(study => { @@ -3161,13 +3588,14 @@ app.get('/api/study-account/overview', requireStudyAuth, async (req, res) => { ? (cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === study.slug)?.sections?.length ?? 0) : 0 const noteCount = noteEntries.filter(([key, value]) => key.startsWith(`${study.slug}--`) && typeof value === 'string' && value.trim()).length + const completedLessons = progress.byStudy[study.slug]?.completedSectionIds?.length ?? 0 return { slug: study.slug, title: study.title, status: study.status, enrolled: isStudyUserEnrolled(user, study.slug), totalLessons, - completedLessons: noteCount, + completedLessons, noteCount, } }) @@ -3177,6 +3605,8 @@ app.get('/api/study-account/overview', requireStudyAuth, async (req, res) => { username: user.username, displayName: user.displayName ?? '', subscribeNewsletter: user.subscribeNewsletter !== false, + studyRemindersEnabled: user.studyRemindersEnabled === true, + avatarUrl: getStudyAvatarUrl(user), }, stats: { noteCount: Object.keys(notes).length, @@ -3190,16 +3620,59 @@ app.get('/api/study-account/overview', requireStudyAuth, async (req, res) => { app.post('/api/study-account/profile', requireStudyAuth, (req, res) => { const user = req.studyUser const displayName = typeof req.body?.displayName === 'string' ? req.body.displayName.trim().slice(0, 80) : '' + const avatarUrl = typeof req.body?.avatarUrl === 'string' ? req.body.avatarUrl.trim() : '' + if (avatarUrl && !/^https?:\/\//i.test(avatarUrl) && !avatarUrl.startsWith('/uploads/') && !avatarUrl.startsWith('data:image/')) { + res.status(400).json({ message: 'Avatar must be a valid uploaded image, data URI, or https URL.' }) + return + } + user.displayName = displayName + user.avatarUrl = avatarUrl user.updatedAt = new Date().toISOString() queueStudyUsersWrite() - res.json({ ok: true, displayName: user.displayName }) + res.json({ ok: true, displayName: user.displayName, avatarUrl: user.avatarUrl || getStudyAvatarUrl(user) }) +}) + +app.post('/api/study-account/avatar-upload', requireStudyAuth, async (req, res) => { + try { + const filename = typeof req.body?.filename === 'string' ? req.body.filename : '' + const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : '' + const ext = inferImageExtensionFromDataUrl(dataUrl) + + if (!ext) { + res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, or GIF data URL.' }) + return + } + + const base64 = dataUrl.split(',')[1] ?? '' + const buffer = Buffer.from(base64, 'base64') + if (buffer.length === 0 || buffer.length > (4 * 1024 * 1024)) { + res.status(400).json({ message: 'Upload must be between 1 byte and 4MB.' }) + return + } + + const baseName = normalizeAssetBaseName(filename.replace(/\.[a-z0-9]+$/i, '')) + const finalName = `${baseName || 'avatar'}-${Date.now()}${ext}` + + await mkdir(UPLOADS_DIR, { recursive: true }) + await writeFile(path.join(UPLOADS_DIR, finalName), buffer) + const metadata = await readUploadsMetadata() + metadata[finalName] = [] + await writeUploadsMetadata(metadata) + + res.json({ ok: true, url: `/uploads/${finalName}` }) + } catch (err) { + console.error('[study-account-avatar-upload] upload error:', err) + res.status(500).json({ message: 'Avatar upload failed.' }) + } }) app.patch('/api/study-account/preferences', requireStudyAuth, (req, res) => { const user = req.studyUser const subscribeNewsletter = req.body?.subscribeNewsletter === true + const studyRemindersEnabled = req.body?.studyRemindersEnabled === true user.subscribeNewsletter = subscribeNewsletter + user.studyRemindersEnabled = studyRemindersEnabled user.updatedAt = new Date().toISOString() queueStudyUsersWrite() @@ -3207,7 +3680,7 @@ app.patch('/api/study-account/preferences', requireStudyAuth, (req, res) => { syncContactToResend(user.displayName || user.username, user.username).catch(err => console.error('[study-account] resend sync error:', err)) } - res.json({ ok: true, subscribeNewsletter: user.subscribeNewsletter }) + res.json({ ok: true, subscribeNewsletter: user.subscribeNewsletter, studyRemindersEnabled: user.studyRemindersEnabled === true }) }) app.post('/api/study-account/request-email-change', studyAuthRateLimiter, requireStudyAuth, async (req, res) => { @@ -5023,6 +5496,7 @@ Promise.all([ loadDraftQuestionsFromDisk(), loadStudyUsersFromDisk(), loadStudyCommunityFromDisk(), + loadStudyRemindersFromDisk(), migrateStudyNotesIfNeeded(), loadDownloadCountsFromDisk(), loadPodcastChecklistFromDisk(), @@ -5045,6 +5519,14 @@ Promise.all([ } }, 60 * 60 * 1000) + // Send study reminder emails for newly released lessons every hour + setInterval(() => { + scheduleStudyReminders().catch(err => { + console.error('[study-reminders] failed to schedule reminders:', err) + }) + }, 60 * 60 * 1000) + void scheduleStudyReminders() + app.listen(PORT, () => { logResendEmailAlignmentWarnings() console.log(`Portfolio app listening on http://localhost:${PORT}`) diff --git a/server/helpers.js b/server/helpers.js index 1e5a067..7f186ba 100644 --- a/server/helpers.js +++ b/server/helpers.js @@ -257,10 +257,14 @@ function sanitizeColossiansStudySections(value) { studyQuestions: Array.isArray(item.studyQuestions) ? item.studyQuestions.filter(question => typeof question === 'string').map(question => question.trim()).filter(Boolean).slice(0, 20) : [], + checkpointPrompt: typeof item.checkpointPrompt === 'string' ? item.checkpointPrompt.trim().slice(0, 600) : '', + checkpointQuestions: Array.isArray(item.checkpointQuestions) + ? item.checkpointQuestions.filter(question => typeof question === 'string').map(question => question.trim()).filter(Boolean).slice(0, 20) + : [], ...(releasedAt ? { releasedAt } : {}), } }) - .filter(item => item.title || item.summary || item.commentary || item.greekNotes.length > 0 || item.studyQuestions.length > 0 || item.passageText) + .filter(item => item.title || item.summary || item.commentary || item.greekNotes.length > 0 || item.studyQuestions.length > 0 || item.passageText || item.checkpointPrompt || item.checkpointQuestions.length > 0) } function sanitizeStudies(value) { diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index d037931..1f2c201 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -79,6 +79,10 @@ function SortableLessonSection({ section, study, updateStudySection, removeStudy