Compare commits
4
Commits
4b38047c26
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d76505772a | ||
|
|
fa0255e9cd | ||
|
|
eb02d08f14 | ||
|
|
b1f78b1655 |
@@ -0,0 +1,114 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FeedbackCategoryHandler serves the /feedback form's categories: the
|
||||||
|
// public list, and their management in the admin panel.
|
||||||
|
type FeedbackCategoryHandler struct {
|
||||||
|
svc *service.FeedbackCategoryService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFeedbackCategoryHandler(svc *service.FeedbackCategoryService) *FeedbackCategoryHandler {
|
||||||
|
return &FeedbackCategoryHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeCategoryError(w http.ResponseWriter, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, repository.ErrFeedbackCategoryNotFound):
|
||||||
|
response.Error(w, http.StatusNotFound, err.Error())
|
||||||
|
case errors.Is(err, repository.ErrFeedbackCategoryInUse),
|
||||||
|
errors.Is(err, repository.ErrFeedbackCategoryExists),
|
||||||
|
errors.Is(err, service.ErrCategoryLastActive):
|
||||||
|
response.Error(w, http.StatusConflict, err.Error())
|
||||||
|
case errors.Is(err, service.ErrCategoryLabel), errors.Is(err, service.ErrCategoryDescription):
|
||||||
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
|
default:
|
||||||
|
response.Error(w, http.StatusInternalServerError, "feedback category request failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public — GET /feedback/categories: the form's options, in order.
|
||||||
|
func (h *FeedbackCategoryHandler) Public(w http.ResponseWriter, r *http.Request) {
|
||||||
|
list, err := h.svc.Active(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeCategoryError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List — GET /admin/feedback/categories: all, with usage counts.
|
||||||
|
func (h *FeedbackCategoryHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
list, err := h.svc.All(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeCategoryError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
type categoryReq struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Archived bool `json:"archived"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *FeedbackCategoryHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req categoryReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, err := h.svc.Create(r.Context(), req.Label, req.Description)
|
||||||
|
if err != nil {
|
||||||
|
writeCategoryError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusCreated, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *FeedbackCategoryHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req categoryReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.Update(r.Context(), chi.URLParam(r, "key"), req.Label, req.Description, req.Archived); err != nil {
|
||||||
|
writeCategoryError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *FeedbackCategoryHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := h.svc.Delete(r.Context(), chi.URLParam(r, "key")); err != nil {
|
||||||
|
writeCategoryError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reorder — PUT /admin/feedback/categories/order {"keys": [...]}.
|
||||||
|
func (h *FeedbackCategoryHandler) Reorder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Keys []string `json:"keys"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.Keys) == 0 {
|
||||||
|
response.Error(w, http.StatusBadRequest, "keys are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.Reorder(r.Context(), req.Keys); err != nil {
|
||||||
|
writeCategoryError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ func writeQuizError(w http.ResponseWriter, err error) {
|
|||||||
case errors.Is(err, repository.ErrQuizNotStarted), errors.Is(err, repository.ErrQuizNotFinished):
|
case errors.Is(err, repository.ErrQuizNotStarted), errors.Is(err, repository.ErrQuizNotFinished):
|
||||||
response.Error(w, http.StatusConflict, err.Error())
|
response.Error(w, http.StatusConflict, err.Error())
|
||||||
case errors.Is(err, repository.ErrQuizNotCurrent), errors.Is(err, repository.ErrQuizAlreadyAnswer),
|
case errors.Is(err, repository.ErrQuizNotCurrent), errors.Is(err, repository.ErrQuizAlreadyAnswer),
|
||||||
errors.Is(err, service.ErrQuizLocked):
|
errors.Is(err, service.ErrQuizLocked), errors.Is(err, repository.ErrQuizTargetTaken):
|
||||||
response.Error(w, http.StatusConflict, err.Error())
|
response.Error(w, http.StatusConflict, err.Error())
|
||||||
case errors.Is(err, repository.ErrQuizBadOption), errors.Is(err, repository.ErrQuizChoiceCount),
|
case errors.Is(err, repository.ErrQuizBadOption), errors.Is(err, repository.ErrQuizChoiceCount),
|
||||||
errors.Is(err, service.ErrQuizNoCorrect),
|
errors.Is(err, service.ErrQuizNoCorrect),
|
||||||
@@ -170,11 +170,20 @@ func (h *QuizHandler) ParticipantResult(w http.ResponseWriter, r *http.Request)
|
|||||||
response.JSON(w, http.StatusOK, res)
|
response.JSON(w, http.StatusOK, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetParticipant — DELETE /admin/quizzes/{id}/participants/{userID}. Deletes
|
||||||
|
// that person's attempt so the quiz is pending for them again.
|
||||||
|
func (h *QuizHandler) ResetParticipant(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := h.svc.ResetParticipant(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "userID")); err != nil {
|
||||||
|
writeQuizError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Users ─────────────────────────────────────────────────────────────────
|
// ── Users ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ForPath — GET /quizzes/for?path=… Optional auth: signed-out readers still
|
// ForPath — GET /quizzes/for?path=… Signed in only; returns the quiz on that
|
||||||
// see that a quiz exists; signed-in ones also get their progress. Returns
|
// page with the caller's progress, or null when the page has none.
|
||||||
// null when the page has no quiz.
|
|
||||||
func (h *QuizHandler) ForPath(w http.ResponseWriter, r *http.Request) {
|
func (h *QuizHandler) ForPath(w http.ResponseWriter, r *http.Request) {
|
||||||
card, err := h.svc.ForPath(r.Context(), middleware.UserID(r.Context()), r.URL.Query().Get("path"))
|
card, err := h.svc.ForPath(r.Context(), middleware.UserID(r.Context()), r.URL.Query().Get("path"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ type AdminCollection struct {
|
|||||||
type AdminFeedback struct {
|
type AdminFeedback struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
|
CategoryLabel string `json:"category_label"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
ContactEmail *string `json:"contact_email"`
|
ContactEmail *string `json:"contact_email"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ type Feedback struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID *string `json:"user_id,omitempty"`
|
UserID *string `json:"user_id,omitempty"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
|
// CategoryLabel is the category's current display name (falls back to
|
||||||
|
// the key if the category was ever removed).
|
||||||
|
CategoryLabel string `json:"category_label"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
ContactEmail *string `json:"contact_email,omitempty"`
|
ContactEmail *string `json:"contact_email,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
@@ -50,3 +53,15 @@ type Feedback struct {
|
|||||||
AuthorName *string `json:"author_name,omitempty"`
|
AuthorName *string `json:"author_name,omitempty"`
|
||||||
AuthorEmail *string `json:"-"` // server-side only; never serialised
|
AuthorEmail *string `json:"-"` // server-side only; never serialised
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FeedbackCategory is one option on the /feedback form, managed from the
|
||||||
|
// admin panel. Key is what feedback.category stores and never changes.
|
||||||
|
type FeedbackCategory struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
ArchivedAt *time.Time `json:"archived_at,omitempty"`
|
||||||
|
// UsageCount is admin-only: how much feedback is filed under it.
|
||||||
|
UsageCount int `json:"usage_count,omitempty"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ type Quiz struct {
|
|||||||
TargetKind string `json:"target_kind"`
|
TargetKind string `json:"target_kind"`
|
||||||
TargetPath *string `json:"target_path,omitempty"`
|
TargetPath *string `json:"target_path,omitempty"`
|
||||||
TargetTitle string `json:"target_title"`
|
TargetTitle string `json:"target_title"`
|
||||||
|
// TargetYear is the lecture or conversation's year, for grouping.
|
||||||
|
TargetYear *int `json:"target_year,omitempty"`
|
||||||
PublishedAt *time.Time `json:"published_at,omitempty"`
|
PublishedAt *time.Time `json:"published_at,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ var Catalog = []Permission{
|
|||||||
{"feedback:read", "Feedback", "View feedback"},
|
{"feedback:read", "Feedback", "View feedback"},
|
||||||
{"feedback:reply", "Feedback", "Reply to feedback"},
|
{"feedback:reply", "Feedback", "Reply to feedback"},
|
||||||
{"feedback:resolve", "Feedback", "Acknowledge and resolve feedback"},
|
{"feedback:resolve", "Feedback", "Acknowledge and resolve feedback"},
|
||||||
|
{"feedback:categories", "Feedback", "Manage feedback categories"},
|
||||||
|
|
||||||
{"vani:read", "Vāṇī of the Day", "View the schedule"},
|
{"vani:read", "Vāṇī of the Day", "View the schedule"},
|
||||||
{"vani:write", "Vāṇī of the Day", "Pin and clear days"},
|
{"vani:write", "Vāṇī of the Day", "Pin and clear days"},
|
||||||
@@ -59,6 +60,7 @@ var Catalog = []Permission{
|
|||||||
{"quizzes:write", "Quizzes", "Create, edit and publish quizzes"},
|
{"quizzes:write", "Quizzes", "Create, edit and publish quizzes"},
|
||||||
{"quizzes:delete", "Quizzes", "Delete quizzes"},
|
{"quizzes:delete", "Quizzes", "Delete quizzes"},
|
||||||
{"quizzes:results", "Quizzes", "View participants and responses"},
|
{"quizzes:results", "Quizzes", "View participants and responses"},
|
||||||
|
{"quizzes:reset", "Quizzes", "Reset a participant's attempt"},
|
||||||
|
|
||||||
{"short_urls:read", "Short URLs", "View short links and stats"},
|
{"short_urls:read", "Short URLs", "View short links and stats"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -248,12 +248,13 @@ func (r *AdminRepo) ListFeedback(ctx context.Context, status string, limit, offs
|
|||||||
status = "all"
|
status = "all"
|
||||||
}
|
}
|
||||||
rows, err := r.pg.Pool.Query(ctx, `
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
SELECT f.id, f.category, f.message, f.contact_email, f.created_at,
|
SELECT f.id, f.category, COALESCE(fc.label, f.category), f.message, f.contact_email, f.created_at,
|
||||||
f.acknowledged_at, f.resolved_at, f.replied_at, f.reply_message,
|
f.acknowledged_at, f.resolved_at, f.replied_at, f.reply_message,
|
||||||
u.handle, u.name, u.email,
|
u.handle, u.name, u.email,
|
||||||
COUNT(*) OVER() AS total
|
COUNT(*) OVER() AS total
|
||||||
FROM feedback f
|
FROM feedback f
|
||||||
LEFT JOIN users u ON u.id = f.user_id
|
LEFT JOIN users u ON u.id = f.user_id
|
||||||
|
LEFT JOIN feedback_categories fc ON fc.key = f.category
|
||||||
WHERE ($1 = 'all'
|
WHERE ($1 = 'all'
|
||||||
OR ($1 = 'open' AND f.resolved_at IS NULL)
|
OR ($1 = 'open' AND f.resolved_at IS NULL)
|
||||||
OR ($1 = 'resolved' AND f.resolved_at IS NOT NULL))
|
OR ($1 = 'resolved' AND f.resolved_at IS NOT NULL))
|
||||||
@@ -268,7 +269,7 @@ func (r *AdminRepo) ListFeedback(ctx context.Context, status string, limit, offs
|
|||||||
total := 0
|
total := 0
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var f model.AdminFeedback
|
var f model.AdminFeedback
|
||||||
if err := rows.Scan(&f.ID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
|
if err := rows.Scan(&f.ID, &f.Category, &f.CategoryLabel, &f.Message, &f.ContactEmail, &f.CreatedAt,
|
||||||
&f.AcknowledgedAt, &f.ResolvedAt, &f.RepliedAt, &f.ReplyMessage,
|
&f.AcknowledgedAt, &f.ResolvedAt, &f.RepliedAt, &f.ReplyMessage,
|
||||||
&f.AuthorHandle, &f.AuthorName, &f.AuthorEmail, &total); err != nil {
|
&f.AuthorHandle, &f.AuthorName, &f.AuthorEmail, &total); err != nil {
|
||||||
return nil, 0, fmt.Errorf("scan admin feedback: %w", err)
|
return nil, 0, fmt.Errorf("scan admin feedback: %w", err)
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrFeedbackCategoryNotFound = errors.New("feedback category not found")
|
||||||
|
ErrFeedbackCategoryInUse = errors.New("feedback is filed under this category; archive it instead")
|
||||||
|
ErrFeedbackCategoryExists = errors.New("a category with that name already exists")
|
||||||
|
)
|
||||||
|
|
||||||
|
// FeedbackCategoryRepo stores the /feedback form's categories.
|
||||||
|
type FeedbackCategoryRepo struct{ pg *Postgres }
|
||||||
|
|
||||||
|
func NewFeedbackCategoryRepo(pg *Postgres) *FeedbackCategoryRepo {
|
||||||
|
return &FeedbackCategoryRepo{pg: pg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns categories in form order. includeArchived is true for the
|
||||||
|
// admin (which also gets usage counts), false for the public form.
|
||||||
|
func (r *FeedbackCategoryRepo) List(ctx context.Context, includeArchived bool) ([]model.FeedbackCategory, error) {
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT c.key, c.label, c.description, c.sort_order, c.archived_at,
|
||||||
|
CASE WHEN $1 THEN (SELECT count(*) FROM feedback f WHERE f.category = c.key) ELSE 0 END
|
||||||
|
FROM feedback_categories c
|
||||||
|
WHERE $1 OR c.archived_at IS NULL
|
||||||
|
ORDER BY (c.archived_at IS NOT NULL), c.sort_order, c.label`, includeArchived)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list feedback categories: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []model.FeedbackCategory{}
|
||||||
|
for rows.Next() {
|
||||||
|
var c model.FeedbackCategory
|
||||||
|
if err := rows.Scan(&c.Key, &c.Label, &c.Description, &c.SortOrder, &c.ArchivedAt, &c.UsageCount); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan feedback category: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsActive reports whether new feedback may be filed under key.
|
||||||
|
func (r *FeedbackCategoryRepo) IsActive(ctx context.Context, key string) (bool, error) {
|
||||||
|
var ok bool
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
SELECT EXISTS (SELECT 1 FROM feedback_categories WHERE key = $1 AND archived_at IS NULL)`,
|
||||||
|
key).Scan(&ok)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("check feedback category: %w", err)
|
||||||
|
}
|
||||||
|
return ok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyTaken reports whether a key is already used, archived or not.
|
||||||
|
func (r *FeedbackCategoryRepo) KeyTaken(ctx context.Context, key string) (bool, error) {
|
||||||
|
var ok bool
|
||||||
|
err := r.pg.Pool.QueryRow(ctx,
|
||||||
|
`SELECT EXISTS (SELECT 1 FROM feedback_categories WHERE key = $1)`, key).Scan(&ok)
|
||||||
|
return ok, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create appends a category at the end of the form.
|
||||||
|
func (r *FeedbackCategoryRepo) Create(ctx context.Context, key, label, description string) (*model.FeedbackCategory, error) {
|
||||||
|
c := model.FeedbackCategory{Key: key, Label: label, Description: description}
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO feedback_categories (key, label, description, sort_order)
|
||||||
|
VALUES ($1, $2, $3, COALESCE((SELECT max(sort_order) + 1 FROM feedback_categories WHERE archived_at IS NULL), 0))
|
||||||
|
RETURNING sort_order`, key, label, description).Scan(&c.SortOrder)
|
||||||
|
if isUniqueViolation(err, "feedback_categories_pkey") {
|
||||||
|
return nil, ErrFeedbackCategoryExists
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create feedback category: %w", err)
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update changes a category's wording and archived state. Its key — what
|
||||||
|
// past feedback points at — is never changed.
|
||||||
|
func (r *FeedbackCategoryRepo) Update(ctx context.Context, key, label, description string, archived bool) error {
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
UPDATE feedback_categories
|
||||||
|
SET label = $2, description = $3,
|
||||||
|
archived_at = CASE WHEN $4 THEN COALESCE(archived_at, now()) ELSE NULL END
|
||||||
|
WHERE key = $1`, key, label, description, archived)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update feedback category: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrFeedbackCategoryNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reorder sets the form order to the given keys, first to last.
|
||||||
|
func (r *FeedbackCategoryRepo) Reorder(ctx context.Context, keys []string) error {
|
||||||
|
tx, err := r.pg.Pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin reorder: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx) //nolint:errcheck
|
||||||
|
for i, k := range keys {
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`UPDATE feedback_categories SET sort_order = $2 WHERE key = $1`, k, i); err != nil {
|
||||||
|
return fmt.Errorf("reorder feedback category: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a category nothing has been filed under. One that's in use
|
||||||
|
// can only be archived, so no submission is ever left without a label.
|
||||||
|
func (r *FeedbackCategoryRepo) Delete(ctx context.Context, key string) error {
|
||||||
|
var used bool
|
||||||
|
err := r.pg.Pool.QueryRow(ctx,
|
||||||
|
`SELECT EXISTS (SELECT 1 FROM feedback WHERE category = $1)`, key).Scan(&used)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check category usage: %w", err)
|
||||||
|
}
|
||||||
|
if used {
|
||||||
|
return ErrFeedbackCategoryInUse
|
||||||
|
}
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
DELETE FROM feedback_categories c WHERE c.key = $1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM feedback f WHERE f.category = c.key)`, key)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete feedback category: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
// Gone already, or feedback arrived between the check and the delete.
|
||||||
|
if ok, _ := r.KeyTaken(ctx, key); ok {
|
||||||
|
return ErrFeedbackCategoryInUse
|
||||||
|
}
|
||||||
|
return ErrFeedbackCategoryNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -37,13 +37,14 @@ func (r *FeedbackRepo) Create(ctx context.Context, userID *string, category, mes
|
|||||||
func (r *FeedbackRepo) FindByID(ctx context.Context, id string) (*model.Feedback, error) {
|
func (r *FeedbackRepo) FindByID(ctx context.Context, id string) (*model.Feedback, error) {
|
||||||
f := &model.Feedback{}
|
f := &model.Feedback{}
|
||||||
err := r.pg.Pool.QueryRow(ctx, `
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
SELECT f.id, f.user_id, f.category, f.message, f.contact_email, f.created_at,
|
SELECT f.id, f.user_id, f.category, COALESCE(fc.label, f.category), f.message, f.contact_email, f.created_at,
|
||||||
f.acknowledged_at, f.resolved_at,
|
f.acknowledged_at, f.resolved_at,
|
||||||
u.handle, u.name, u.email
|
u.handle, u.name, u.email
|
||||||
FROM feedback f
|
FROM feedback f
|
||||||
LEFT JOIN users u ON u.id = f.user_id
|
LEFT JOIN users u ON u.id = f.user_id
|
||||||
|
LEFT JOIN feedback_categories fc ON fc.key = f.category
|
||||||
WHERE f.id = $1`, id,
|
WHERE f.id = $1`, id,
|
||||||
).Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
|
).Scan(&f.ID, &f.UserID, &f.Category, &f.CategoryLabel, &f.Message, &f.ContactEmail, &f.CreatedAt,
|
||||||
&f.AcknowledgedAt, &f.ResolvedAt,
|
&f.AcknowledgedAt, &f.ResolvedAt,
|
||||||
&f.AuthorHandle, &f.AuthorName, &f.AuthorEmail)
|
&f.AuthorHandle, &f.AuthorName, &f.AuthorEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -95,11 +96,12 @@ func (r *FeedbackRepo) ListByUser(ctx context.Context, userID string, limit int)
|
|||||||
limit = 50
|
limit = 50
|
||||||
}
|
}
|
||||||
rows, err := r.pg.Pool.Query(ctx, `
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
SELECT id, user_id, category, message, contact_email, created_at,
|
SELECT f.id, f.user_id, f.category, COALESCE(fc.label, f.category), f.message, f.contact_email, f.created_at,
|
||||||
acknowledged_at, resolved_at, replied_at, reply_message
|
f.acknowledged_at, f.resolved_at, f.replied_at, f.reply_message
|
||||||
FROM feedback
|
FROM feedback f
|
||||||
WHERE user_id = $1
|
LEFT JOIN feedback_categories fc ON fc.key = f.category
|
||||||
ORDER BY created_at DESC
|
WHERE f.user_id = $1
|
||||||
|
ORDER BY f.created_at DESC
|
||||||
LIMIT $2`, userID, limit,
|
LIMIT $2`, userID, limit,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -109,7 +111,7 @@ func (r *FeedbackRepo) ListByUser(ctx context.Context, userID string, limit int)
|
|||||||
out := []model.Feedback{}
|
out := []model.Feedback{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var f model.Feedback
|
var f model.Feedback
|
||||||
if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
|
if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.CategoryLabel, &f.Message, &f.ContactEmail, &f.CreatedAt,
|
||||||
&f.AcknowledgedAt, &f.ResolvedAt, &f.RepliedAt, &f.ReplyMessage); err != nil {
|
&f.AcknowledgedAt, &f.ResolvedAt, &f.RepliedAt, &f.ReplyMessage); err != nil {
|
||||||
return nil, fmt.Errorf("scan user feedback: %w", err)
|
return nil, fmt.Errorf("scan user feedback: %w", err)
|
||||||
}
|
}
|
||||||
@@ -132,12 +134,13 @@ func (r *FeedbackRepo) Recent(ctx context.Context, limit int) ([]model.Feedback,
|
|||||||
limit = 25
|
limit = 25
|
||||||
}
|
}
|
||||||
rows, err := r.pg.Pool.Query(ctx, `
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
SELECT f.id, f.user_id, f.category, f.message, f.created_at,
|
SELECT f.id, f.user_id, f.category, COALESCE(fc.label, f.category), f.message, f.created_at,
|
||||||
(f.contact_email IS NOT NULL AND f.contact_email <> '') AS has_contact_email,
|
(f.contact_email IS NOT NULL AND f.contact_email <> '') AS has_contact_email,
|
||||||
f.acknowledged_at, f.resolved_at,
|
f.acknowledged_at, f.resolved_at,
|
||||||
u.handle, u.name
|
u.handle, u.name
|
||||||
FROM feedback f
|
FROM feedback f
|
||||||
LEFT JOIN users u ON u.id = f.user_id
|
LEFT JOIN users u ON u.id = f.user_id
|
||||||
|
LEFT JOIN feedback_categories fc ON fc.key = f.category
|
||||||
ORDER BY f.created_at DESC
|
ORDER BY f.created_at DESC
|
||||||
LIMIT $1`, limit,
|
LIMIT $1`, limit,
|
||||||
)
|
)
|
||||||
@@ -148,7 +151,7 @@ func (r *FeedbackRepo) Recent(ctx context.Context, limit int) ([]model.Feedback,
|
|||||||
out := []model.Feedback{}
|
out := []model.Feedback{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var f model.Feedback
|
var f model.Feedback
|
||||||
if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.CreatedAt,
|
if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.CategoryLabel, &f.Message, &f.CreatedAt,
|
||||||
&f.HasContactEmail,
|
&f.HasContactEmail,
|
||||||
&f.AcknowledgedAt, &f.ResolvedAt,
|
&f.AcknowledgedAt, &f.ResolvedAt,
|
||||||
&f.AuthorHandle, &f.AuthorName); err != nil {
|
&f.AuthorHandle, &f.AuthorName); err != nil {
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ var (
|
|||||||
ErrQuizNotCurrent = errors.New("that isn't the current question")
|
ErrQuizNotCurrent = errors.New("that isn't the current question")
|
||||||
ErrQuizBadOption = errors.New("that option isn't part of this question")
|
ErrQuizBadOption = errors.New("that option isn't part of this question")
|
||||||
ErrQuizChoiceCount = errors.New("choose one option, or at least one where several apply")
|
ErrQuizChoiceCount = errors.New("choose one option, or at least one where several apply")
|
||||||
|
// ErrQuizTargetTaken: that chapter, lecture or conversation already has a
|
||||||
|
// quiz (enforced by the quizzes_one_per_target index).
|
||||||
|
ErrQuizTargetTaken = errors.New("only one quiz is allowed per link")
|
||||||
ErrQuizAlreadyAnswer = errors.New("that question is already answered")
|
ErrQuizAlreadyAnswer = errors.New("that question is already answered")
|
||||||
ErrQuizNotFinished = errors.New("the quiz isn't finished yet")
|
ErrQuizNotFinished = errors.New("the quiz isn't finished yet")
|
||||||
)
|
)
|
||||||
@@ -32,6 +35,7 @@ type QuizInput struct {
|
|||||||
TargetKind string
|
TargetKind string
|
||||||
TargetPath *string
|
TargetPath *string
|
||||||
TargetTitle string
|
TargetTitle string
|
||||||
|
TargetYear *int
|
||||||
Questions []QuestionInput
|
Questions []QuestionInput
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,12 +52,12 @@ type OptionInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const quizColumns = `q.id, q.title, q.description, q.target_kind, q.target_path,
|
const quizColumns = `q.id, q.title, q.description, q.target_kind, q.target_path,
|
||||||
q.target_title, q.published_at, q.created_at, q.updated_at,
|
q.target_title, q.target_year, q.published_at, q.created_at, q.updated_at,
|
||||||
(SELECT count(*) FROM quiz_questions qq WHERE qq.quiz_id = q.id)`
|
(SELECT count(*) FROM quiz_questions qq WHERE qq.quiz_id = q.id)`
|
||||||
|
|
||||||
func scanQuiz(row pgx.Row, q *model.Quiz, extra ...any) error {
|
func scanQuiz(row pgx.Row, q *model.Quiz, extra ...any) error {
|
||||||
dest := []any{&q.ID, &q.Title, &q.Description, &q.TargetKind, &q.TargetPath,
|
dest := []any{&q.ID, &q.Title, &q.Description, &q.TargetKind, &q.TargetPath,
|
||||||
&q.TargetTitle, &q.PublishedAt, &q.CreatedAt, &q.UpdatedAt, &q.QuestionCount}
|
&q.TargetTitle, &q.TargetYear, &q.PublishedAt, &q.CreatedAt, &q.UpdatedAt, &q.QuestionCount}
|
||||||
return row.Scan(append(dest, extra...)...)
|
return row.Scan(append(dest, extra...)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,10 +148,13 @@ func (r *QuizRepo) Create(ctx context.Context, in QuizInput, createdBy string) (
|
|||||||
defer tx.Rollback(ctx) //nolint:errcheck
|
defer tx.Rollback(ctx) //nolint:errcheck
|
||||||
var id string
|
var id string
|
||||||
if err := tx.QueryRow(ctx, `
|
if err := tx.QueryRow(ctx, `
|
||||||
INSERT INTO quizzes (title, description, target_kind, target_path, target_title, created_by)
|
INSERT INTO quizzes (title, description, target_kind, target_path, target_title, target_year, created_by)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
|
||||||
in.Title, in.Description, in.TargetKind, in.TargetPath, in.TargetTitle, createdBy,
|
in.Title, in.Description, in.TargetKind, in.TargetPath, in.TargetTitle, in.TargetYear, createdBy,
|
||||||
).Scan(&id); err != nil {
|
).Scan(&id); err != nil {
|
||||||
|
if isUniqueViolation(err, "quizzes_one_per_target") {
|
||||||
|
return "", ErrQuizTargetTaken
|
||||||
|
}
|
||||||
return "", fmt.Errorf("create quiz: %w", err)
|
return "", fmt.Errorf("create quiz: %w", err)
|
||||||
}
|
}
|
||||||
if err := insertQuestions(ctx, tx, id, in.Questions); err != nil {
|
if err := insertQuestions(ctx, tx, id, in.Questions); err != nil {
|
||||||
@@ -167,10 +174,13 @@ func (r *QuizRepo) Update(ctx context.Context, id string, in QuizInput, replaceQ
|
|||||||
defer tx.Rollback(ctx) //nolint:errcheck
|
defer tx.Rollback(ctx) //nolint:errcheck
|
||||||
tag, err := tx.Exec(ctx, `
|
tag, err := tx.Exec(ctx, `
|
||||||
UPDATE quizzes SET title = $2, description = $3, target_kind = $4,
|
UPDATE quizzes SET title = $2, description = $3, target_kind = $4,
|
||||||
target_path = $5, target_title = $6, updated_at = now()
|
target_path = $5, target_title = $6, target_year = $7, updated_at = now()
|
||||||
WHERE id = $1 AND deleted_at IS NULL`,
|
WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
id, in.Title, in.Description, in.TargetKind, in.TargetPath, in.TargetTitle)
|
id, in.Title, in.Description, in.TargetKind, in.TargetPath, in.TargetTitle, in.TargetYear)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isUniqueViolation(err, "quizzes_one_per_target") {
|
||||||
|
return ErrQuizTargetTaken
|
||||||
|
}
|
||||||
return fmt.Errorf("update quiz: %w", err)
|
return fmt.Errorf("update quiz: %w", err)
|
||||||
}
|
}
|
||||||
if tag.RowsAffected() == 0 {
|
if tag.RowsAffected() == 0 {
|
||||||
@@ -207,6 +217,23 @@ func insertQuestions(ctx context.Context, tx pgx.Tx, quizID string, qs []Questio
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QuizOnTarget returns the live quiz (published or draft) already attached to
|
||||||
|
// a content path, other than exceptID — or "" when the path is free.
|
||||||
|
func (r *QuizRepo) QuizOnTarget(ctx context.Context, path, exceptID string) (id, title string, err error) {
|
||||||
|
err = r.pg.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, title FROM quizzes
|
||||||
|
WHERE target_path = $1 AND deleted_at IS NULL
|
||||||
|
AND ($2 = '' OR id::text <> $2)
|
||||||
|
LIMIT 1`, path, exceptID).Scan(&id, &title)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("quiz on target: %w", err)
|
||||||
|
}
|
||||||
|
return id, title, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *QuizRepo) AttemptCount(ctx context.Context, id string) (int, error) {
|
func (r *QuizRepo) AttemptCount(ctx context.Context, id string) (int, error) {
|
||||||
var n int
|
var n int
|
||||||
err := r.pg.Pool.QueryRow(ctx, `SELECT count(*) FROM quiz_attempts WHERE quiz_id = $1`, id).Scan(&n)
|
err := r.pg.Pool.QueryRow(ctx, `SELECT count(*) FROM quiz_attempts WHERE quiz_id = $1`, id).Scan(&n)
|
||||||
@@ -341,6 +368,20 @@ func (r *QuizRepo) QuestionStats(ctx context.Context, quizID string) ([]model.Qu
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetAttempt deletes one person's attempt — its answers go with it — so the
|
||||||
|
// quiz is pending for them again and they can take it afresh.
|
||||||
|
func (r *QuizRepo) ResetAttempt(ctx context.Context, quizID, userID string) error {
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx,
|
||||||
|
`DELETE FROM quiz_attempts WHERE quiz_id = $1 AND user_id = $2`, quizID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reset attempt: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrQuizNotStarted
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ── Taking a quiz ─────────────────────────────────────────────────────────
|
// ── Taking a quiz ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// PublishedForPath returns the newest live quiz on a content page, or nil.
|
// PublishedForPath returns the newest live quiz on a content page, or nil.
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ type Server struct {
|
|||||||
pushH *handler.PushHandler
|
pushH *handler.PushHandler
|
||||||
reminderH *handler.ReminderHandler
|
reminderH *handler.ReminderHandler
|
||||||
feedbackH *handler.FeedbackHandler
|
feedbackH *handler.FeedbackHandler
|
||||||
|
feedbackCatH *handler.FeedbackCategoryHandler
|
||||||
statsH *handler.StatsHandler
|
statsH *handler.StatsHandler
|
||||||
adminH *handler.AdminHandler
|
adminH *handler.AdminHandler
|
||||||
broadcastH *handler.BroadcastHandler
|
broadcastH *handler.BroadcastHandler
|
||||||
@@ -343,8 +344,11 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
|||||||
// the existing mailer instance.
|
// the existing mailer instance.
|
||||||
feedbackRepo := repository.NewFeedbackRepo(s.pg)
|
feedbackRepo := repository.NewFeedbackRepo(s.pg)
|
||||||
statsRepo := repository.NewStatsRepo(s.pg)
|
statsRepo := repository.NewStatsRepo(s.pg)
|
||||||
feedbackSvc := service.NewFeedbackService(feedbackRepo, mail, cfg.FeedbackAdminEmails, logger)
|
// Categories are admin-managed; submissions are checked against them.
|
||||||
|
feedbackCatRepo := repository.NewFeedbackCategoryRepo(s.pg)
|
||||||
|
feedbackSvc := service.NewFeedbackService(feedbackRepo, feedbackCatRepo, mail, cfg.FeedbackAdminEmails, logger)
|
||||||
s.feedbackH = handler.NewFeedbackHandler(feedbackSvc)
|
s.feedbackH = handler.NewFeedbackHandler(feedbackSvc)
|
||||||
|
s.feedbackCatH = handler.NewFeedbackCategoryHandler(service.NewFeedbackCategoryService(feedbackCatRepo))
|
||||||
s.statsH = handler.NewStatsHandler(statsRepo, feedbackRepo)
|
s.statsH = handler.NewStatsHandler(statsRepo, feedbackRepo)
|
||||||
|
|
||||||
// Admin panel — read-mostly listing + notify/reply/resolve actions.
|
// Admin panel — read-mostly listing + notify/reply/resolve actions.
|
||||||
@@ -725,17 +729,20 @@ func (s *Server) setupRouter() {
|
|||||||
// Vāṇī of the Day — public daily content pick for the home page.
|
// Vāṇī of the Day — public daily content pick for the home page.
|
||||||
r.Get("/vani/today", s.vaniH.Today)
|
r.Get("/vani/today", s.vaniH.Today)
|
||||||
|
|
||||||
// Quizzes. Browsing is open to signed-out readers (they see that a
|
// Quizzes are for signed-in users: listing, finding one on a page,
|
||||||
// quiz exists); taking one, and seeing results, needs an account.
|
// taking it and reading results all need an account. The one open
|
||||||
|
// endpoint is a quiz's own card — title, description, what it's
|
||||||
|
// attached to, never its questions — so link previews (OpenGraph)
|
||||||
|
// can name the quiz when it's shared.
|
||||||
r.Route("/quizzes", func(r chi.Router) {
|
r.Route("/quizzes", func(r chi.Router) {
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
||||||
r.Get("/", s.quizH.List)
|
|
||||||
r.Get("/for", s.quizH.ForPath)
|
|
||||||
r.Get("/{id}", s.quizH.Get)
|
r.Get("/{id}", s.quizH.Get)
|
||||||
})
|
})
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||||
|
r.Get("/", s.quizH.List)
|
||||||
|
r.Get("/for", s.quizH.ForPath)
|
||||||
r.Post("/{id}/start", s.quizH.Start)
|
r.Post("/{id}/start", s.quizH.Start)
|
||||||
r.Post("/{id}/answer", s.quizH.Answer)
|
r.Post("/{id}/answer", s.quizH.Answer)
|
||||||
r.Get("/{id}/result", s.quizH.Result)
|
r.Get("/{id}/result", s.quizH.Result)
|
||||||
@@ -1109,6 +1116,8 @@ func (s *Server) setupRouter() {
|
|||||||
r.HandleFunc("/feedback/mine", s.authUnavailable)
|
r.HandleFunc("/feedback/mine", s.authUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// The form's categories — public, like the form itself.
|
||||||
|
r.Get("/feedback/categories", s.feedbackCatH.Public)
|
||||||
// Submit — OptionalAuth (anonymous callers allowed).
|
// Submit — OptionalAuth (anonymous callers allowed).
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
||||||
@@ -1245,6 +1254,11 @@ func (s *Server) setupRouter() {
|
|||||||
can("collections:read").Get("/collections", s.adminH.ListCollections)
|
can("collections:read").Get("/collections", s.adminH.ListCollections)
|
||||||
|
|
||||||
can("feedback:read").Get("/feedback", s.adminH.ListFeedback)
|
can("feedback:read").Get("/feedback", s.adminH.ListFeedback)
|
||||||
|
can("feedback:read").Get("/feedback/categories", s.feedbackCatH.List)
|
||||||
|
can("feedback:categories").Post("/feedback/categories", s.feedbackCatH.Create)
|
||||||
|
can("feedback:categories").Put("/feedback/categories/order", s.feedbackCatH.Reorder)
|
||||||
|
can("feedback:categories").Put("/feedback/categories/{key}", s.feedbackCatH.Update)
|
||||||
|
can("feedback:categories").Delete("/feedback/categories/{key}", s.feedbackCatH.Delete)
|
||||||
can("feedback:reply").Post("/feedback/{id}/reply", s.adminH.ReplyFeedback)
|
can("feedback:reply").Post("/feedback/{id}/reply", s.adminH.ReplyFeedback)
|
||||||
can("feedback:resolve").Post("/feedback/{id}/acknowledge", s.adminH.AcknowledgeFeedback)
|
can("feedback:resolve").Post("/feedback/{id}/acknowledge", s.adminH.AcknowledgeFeedback)
|
||||||
can("feedback:resolve").Post("/feedback/{id}/resolve", s.adminH.ResolveFeedback)
|
can("feedback:resolve").Post("/feedback/{id}/resolve", s.adminH.ResolveFeedback)
|
||||||
@@ -1262,6 +1276,7 @@ func (s *Server) setupRouter() {
|
|||||||
can("quizzes:results").Get("/quizzes/{id}/participants", s.quizH.Participants)
|
can("quizzes:results").Get("/quizzes/{id}/participants", s.quizH.Participants)
|
||||||
can("quizzes:results").Get("/quizzes/{id}/participants/{userID}", s.quizH.ParticipantResult)
|
can("quizzes:results").Get("/quizzes/{id}/participants/{userID}", s.quizH.ParticipantResult)
|
||||||
can("quizzes:results").Get("/quizzes/{id}/responses", s.quizH.Responses)
|
can("quizzes:results").Get("/quizzes/{id}/responses", s.quizH.Responses)
|
||||||
|
can("quizzes:reset").Delete("/quizzes/{id}/participants/{userID}", s.quizH.ResetParticipant)
|
||||||
|
|
||||||
can("short_urls:read").Get("/short-urls", s.adminH.ListShortURLs)
|
can("short_urls:read").Get("/short-urls", s.adminH.ListShortURLs)
|
||||||
can("short_urls:read").Get("/short-urls/stats", s.adminH.ShortURLStats)
|
can("short_urls:read").Get("/short-urls/stats", s.adminH.ShortURLStats)
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxCategoryLabel = 60
|
||||||
|
maxCategoryDescription = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrCategoryLabel = fmt.Errorf("a category needs a name (up to %d characters)", maxCategoryLabel)
|
||||||
|
ErrCategoryDescription = fmt.Errorf("the description can be up to %d characters", maxCategoryDescription)
|
||||||
|
ErrCategoryLastActive = errors.New("the feedback form needs at least one category")
|
||||||
|
)
|
||||||
|
|
||||||
|
var categoryNonSlug = regexp.MustCompile(`[^a-z0-9]+`)
|
||||||
|
|
||||||
|
// FeedbackCategoryService manages the /feedback form's categories.
|
||||||
|
type FeedbackCategoryService struct {
|
||||||
|
repo *repository.FeedbackCategoryRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFeedbackCategoryService(repo *repository.FeedbackCategoryRepo) *FeedbackCategoryService {
|
||||||
|
return &FeedbackCategoryService{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active is what the /feedback form offers, in order.
|
||||||
|
func (s *FeedbackCategoryService) Active(ctx context.Context) ([]model.FeedbackCategory, error) {
|
||||||
|
return s.repo.List(ctx, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// All is the admin's view: archived ones too, with usage counts.
|
||||||
|
func (s *FeedbackCategoryService) All(ctx context.Context) ([]model.FeedbackCategory, error) {
|
||||||
|
return s.repo.List(ctx, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanCategory(label, description string) (string, string, error) {
|
||||||
|
label = strings.TrimSpace(label)
|
||||||
|
description = strings.TrimSpace(description)
|
||||||
|
if label == "" || len([]rune(label)) > maxCategoryLabel {
|
||||||
|
return "", "", ErrCategoryLabel
|
||||||
|
}
|
||||||
|
if len([]rune(description)) > maxCategoryDescription {
|
||||||
|
return "", "", ErrCategoryDescription
|
||||||
|
}
|
||||||
|
return label, description, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create adds a category to the end of the form. Its key is derived from
|
||||||
|
// the name ("Audio problem" → audio_problem) and fixed from then on, so it
|
||||||
|
// can be renamed freely later without touching past feedback.
|
||||||
|
func (s *FeedbackCategoryService) Create(ctx context.Context, label, description string) (*model.FeedbackCategory, error) {
|
||||||
|
label, description, err := cleanCategory(label, description)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
base := strings.Trim(categoryNonSlug.ReplaceAllString(strings.ToLower(label), "_"), "_")
|
||||||
|
if base == "" {
|
||||||
|
base = "category"
|
||||||
|
}
|
||||||
|
if len(base) > 40 {
|
||||||
|
base = strings.TrimRight(base[:40], "_")
|
||||||
|
}
|
||||||
|
key := base
|
||||||
|
for i := 2; ; i++ {
|
||||||
|
taken, err := s.repo.KeyTaken(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !taken {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
key = fmt.Sprintf("%s_%d", base, i)
|
||||||
|
}
|
||||||
|
return s.repo.Create(ctx, key, label, description)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update renames, rewords, archives or restores a category. The last active
|
||||||
|
// category can't be archived — the form would have nothing to offer.
|
||||||
|
func (s *FeedbackCategoryService) Update(ctx context.Context, key, label, description string, archived bool) error {
|
||||||
|
label, description, err := cleanCategory(label, description)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if archived {
|
||||||
|
if err := s.guardLastActive(ctx, key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.repo.Update(ctx, key, label, description, archived)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a category nothing is filed under (otherwise: archive it).
|
||||||
|
func (s *FeedbackCategoryService) Delete(ctx context.Context, key string) error {
|
||||||
|
if err := s.guardLastActive(ctx, key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.repo.Delete(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reorder sets the form's order.
|
||||||
|
func (s *FeedbackCategoryService) Reorder(ctx context.Context, keys []string) error {
|
||||||
|
return s.repo.Reorder(ctx, keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FeedbackCategoryService) guardLastActive(ctx context.Context, key string) error {
|
||||||
|
active, err := s.repo.List(ctx, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(active) == 1 && active[0].Key == key {
|
||||||
|
return ErrCategoryLastActive
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Categories are managed from the admin: a new one is offered on the form
|
||||||
|
// and accepts feedback; renaming relabels past feedback; archiving stops new
|
||||||
|
// submissions but keeps the label; in-use categories can't be deleted.
|
||||||
|
func TestFeedbackCategories(t *testing.T) {
|
||||||
|
_, _, pg := testChatService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := repository.NewFeedbackCategoryRepo(pg)
|
||||||
|
cats := NewFeedbackCategoryService(repo)
|
||||||
|
fb := NewFeedbackService(repository.NewFeedbackRepo(pg), repo, nil, nil, zap.NewNop())
|
||||||
|
user := chatUser(t, pg, "Feedback")
|
||||||
|
t.Cleanup(func() {
|
||||||
|
bg := context.Background()
|
||||||
|
_, _ = pg.Pool.Exec(bg, `DELETE FROM feedback WHERE user_id = $1`, user)
|
||||||
|
_, _ = pg.Pool.Exec(bg, `DELETE FROM feedback_categories WHERE key LIKE 'zz_qa_audio%'`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// The seeded categories production already uses are all on the form.
|
||||||
|
active, err := cats.Active(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, c := range active {
|
||||||
|
seen[c.Key] = true
|
||||||
|
}
|
||||||
|
for _, k := range []string{"general", "bug", "feature", "other"} {
|
||||||
|
if !seen[k] {
|
||||||
|
t.Errorf("seeded category %q missing from the form", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys come from the name, and never collide.
|
||||||
|
c1, err := cats.Create(ctx, "ZZ QA Audio problem", "Playback trouble")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c2, err := cats.Create(ctx, "ZZ QA audio problem!", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if c1.Key != "zz_qa_audio_problem" || c2.Key != "zz_qa_audio_problem_2" {
|
||||||
|
t.Errorf("keys = %q, %q", c1.Key, c2.Key)
|
||||||
|
}
|
||||||
|
if _, err := cats.Create(ctx, " ", ""); !errors.Is(err, ErrCategoryLabel) {
|
||||||
|
t.Errorf("blank name: %v, want ErrCategoryLabel", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New categories take feedback; unknown ones don't.
|
||||||
|
if _, err := fb.Submit(ctx, &user, c1.Key, "the audio stutters", nil); err != nil {
|
||||||
|
t.Fatalf("submit in a new category: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fb.Submit(ctx, &user, "no_such_category", "x", nil); !errors.Is(err, ErrFeedbackCategory) {
|
||||||
|
t.Errorf("unknown category: %v, want ErrFeedbackCategory", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renaming relabels what's already been filed.
|
||||||
|
if err := cats.Update(ctx, c1.Key, "ZZ QA Audio & playback", "Playback trouble", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mine, _ := fb.ListForUser(ctx, user, 10)
|
||||||
|
if len(mine) != 1 || mine[0].CategoryLabel != "ZZ QA Audio & playback" || mine[0].Category != c1.Key {
|
||||||
|
t.Errorf("past feedback after rename = %+v", mine)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archived: off the form, refuses new feedback, keeps its label.
|
||||||
|
if err := cats.Update(ctx, c1.Key, "ZZ QA Audio & playback", "", true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := fb.Submit(ctx, &user, c1.Key, "again", nil); !errors.Is(err, ErrFeedbackCategory) {
|
||||||
|
t.Errorf("submit to an archived category: %v, want ErrFeedbackCategory", err)
|
||||||
|
}
|
||||||
|
active, _ = cats.Active(ctx)
|
||||||
|
for _, c := range active {
|
||||||
|
if c.Key == c1.Key {
|
||||||
|
t.Error("archived category still on the form")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mine, _ := fb.ListForUser(ctx, user, 10); mine[0].CategoryLabel != "ZZ QA Audio & playback" {
|
||||||
|
t.Error("archiving lost the label on past feedback")
|
||||||
|
}
|
||||||
|
all, _ := cats.All(ctx)
|
||||||
|
for _, c := range all {
|
||||||
|
if c.Key == c1.Key && (c.ArchivedAt == nil || c.UsageCount != 1) {
|
||||||
|
t.Errorf("admin view of archived category = %+v", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In use → can't be deleted; unused → can.
|
||||||
|
if err := cats.Delete(ctx, c1.Key); !errors.Is(err, repository.ErrFeedbackCategoryInUse) {
|
||||||
|
t.Errorf("deleting an in-use category: %v, want ErrFeedbackCategoryInUse", err)
|
||||||
|
}
|
||||||
|
if err := cats.Delete(ctx, c2.Key); err != nil {
|
||||||
|
t.Errorf("deleting an unused category: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,23 +22,22 @@ import (
|
|||||||
|
|
||||||
type FeedbackService struct {
|
type FeedbackService struct {
|
||||||
repo *repository.FeedbackRepo
|
repo *repository.FeedbackRepo
|
||||||
|
// categories is the admin-managed list a submission must belong to.
|
||||||
|
categories *repository.FeedbackCategoryRepo
|
||||||
mail mailer.Mailer
|
mail mailer.Mailer
|
||||||
admins []string
|
admins []string
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFeedbackService(repo *repository.FeedbackRepo, mail mailer.Mailer, admins []string, logger *zap.Logger) *FeedbackService {
|
func NewFeedbackService(repo *repository.FeedbackRepo, categories *repository.FeedbackCategoryRepo, mail mailer.Mailer, admins []string, logger *zap.Logger) *FeedbackService {
|
||||||
return &FeedbackService{repo: repo, mail: mail, admins: admins, logger: logger}
|
return &FeedbackService{repo: repo, categories: categories, mail: mail, admins: admins, logger: logger}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allowed categories — kept lowercase in storage so dashboards group
|
// Allowed categories — kept lowercase in storage so dashboards group
|
||||||
// cleanly. The frontend dropdown sends one of these exact strings.
|
// cleanly. The frontend dropdown sends one of these exact strings.
|
||||||
var feedbackCategories = map[string]bool{
|
// ErrFeedbackCategory: the submission names a category that doesn't exist
|
||||||
"bug": true,
|
// or has been archived in the admin panel.
|
||||||
"feature": true,
|
var ErrFeedbackCategory = errors.New("invalid category")
|
||||||
"general": true,
|
|
||||||
"other": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Submit persists a feedback row and fires the admin email.
|
// Submit persists a feedback row and fires the admin email.
|
||||||
//
|
//
|
||||||
@@ -49,8 +48,10 @@ var feedbackCategories = map[string]bool{
|
|||||||
// trustworthy (the form can't lie about who's writing).
|
// trustworthy (the form can't lie about who's writing).
|
||||||
func (s *FeedbackService) Submit(ctx context.Context, userID *string, category, message string, contactEmail *string) (*model.Feedback, error) {
|
func (s *FeedbackService) Submit(ctx context.Context, userID *string, category, message string, contactEmail *string) (*model.Feedback, error) {
|
||||||
category = strings.ToLower(strings.TrimSpace(category))
|
category = strings.ToLower(strings.TrimSpace(category))
|
||||||
if !feedbackCategories[category] {
|
if ok, err := s.categories.IsActive(ctx, category); err != nil {
|
||||||
return nil, errors.New("invalid category")
|
return nil, err
|
||||||
|
} else if !ok {
|
||||||
|
return nil, ErrFeedbackCategory
|
||||||
}
|
}
|
||||||
message = strings.TrimSpace(message)
|
message = strings.TrimSpace(message)
|
||||||
if message == "" {
|
if message == "" {
|
||||||
@@ -76,16 +77,15 @@ func (s *FeedbackService) Submit(ctx context.Context, userID *string, category,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hydrate the joined author fields for the email template. Failing
|
// Hydrate the joined author fields and category label for the email
|
||||||
// the lookup isn't worth failing the request — we fall back to
|
// template. Failing the lookup isn't worth failing the request — we fall
|
||||||
// "anonymous" rather than 500ing on the user.
|
// back to "anonymous" and the category key rather than 500ing.
|
||||||
if userID != nil {
|
|
||||||
if full, err := s.repo.FindByID(ctx, f.ID); err == nil {
|
if full, err := s.repo.FindByID(ctx, f.ID); err == nil {
|
||||||
f = full
|
f = full
|
||||||
} else {
|
} else {
|
||||||
s.logger.Warn("feedback hydrate failed; sending email without author info",
|
s.logger.Warn("feedback hydrate failed; sending email without author info",
|
||||||
zap.String("feedback_id", f.ID), zap.Error(err))
|
zap.String("feedback_id", f.ID), zap.Error(err))
|
||||||
}
|
f.CategoryLabel = f.Category
|
||||||
}
|
}
|
||||||
|
|
||||||
s.notifyAdmins(f)
|
s.notifyAdmins(f)
|
||||||
@@ -222,7 +222,7 @@ func (s *FeedbackService) notifyAdmins(f *model.Feedback) {
|
|||||||
s.logger.Warn("render feedback email failed", zap.Error(err))
|
s.logger.Warn("render feedback email failed", zap.Error(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
subject := fmt.Sprintf("[Feedback · %s] %s", f.Category, summarize(f.Message, 60))
|
subject := fmt.Sprintf("[Feedback · %s] %s", categoryLabel(f), summarize(f.Message, 60))
|
||||||
for _, to := range s.admins {
|
for _, to := range s.admins {
|
||||||
if err := s.mail.Send(to, subject, body); err != nil {
|
if err := s.mail.Send(to, subject, body); err != nil {
|
||||||
s.logger.Warn("feedback email send failed",
|
s.logger.Warn("feedback email send failed",
|
||||||
@@ -263,7 +263,7 @@ func renderFeedbackEmail(f *model.Feedback) (string, error) {
|
|||||||
}
|
}
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err := feedbackEmailTpl.Execute(&buf, map[string]any{
|
err := feedbackEmailTpl.Execute(&buf, map[string]any{
|
||||||
"Category": f.Category,
|
"Category": categoryLabel(f),
|
||||||
"Message": f.Message,
|
"Message": f.Message,
|
||||||
"From": from,
|
"From": from,
|
||||||
"CreatedAt": f.CreatedAt.Format("2006-01-02 15:04:05 MST"),
|
"CreatedAt": f.CreatedAt.Format("2006-01-02 15:04:05 MST"),
|
||||||
@@ -360,3 +360,11 @@ func renderFeedbackResolvedEmail(f *model.Feedback) (string, error) {
|
|||||||
}
|
}
|
||||||
return buf.String(), nil
|
return buf.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// categoryLabel is the category's display name, or its key if unknown.
|
||||||
|
func categoryLabel(f *model.Feedback) string {
|
||||||
|
if f.CategoryLabel != "" {
|
||||||
|
return f.CategoryLabel
|
||||||
|
}
|
||||||
|
return f.Category
|
||||||
|
}
|
||||||
|
|||||||
@@ -73,10 +73,10 @@ func NormalizeContentPath(raw string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// resolveTarget works out what a link points at.
|
// resolveTarget works out what a link points at.
|
||||||
func (s *QuizService) resolveTarget(ctx context.Context, raw, override string) (kind string, path *string, title string, err error) {
|
func (s *QuizService) resolveTarget(ctx context.Context, raw, override string) (kind string, path *string, title string, year *int, err error) {
|
||||||
p := NormalizeContentPath(raw)
|
p := NormalizeContentPath(raw)
|
||||||
if p == "" {
|
if p == "" {
|
||||||
return model.QuizStandalone, nil, "", nil
|
return model.QuizStandalone, nil, "", nil, nil
|
||||||
}
|
}
|
||||||
parts := strings.Split(strings.Trim(p, "/"), "/")
|
parts := strings.Split(strings.Trim(p, "/"), "/")
|
||||||
|
|
||||||
@@ -84,13 +84,14 @@ func (s *QuizService) resolveTarget(ctx context.Context, raw, override string) (
|
|||||||
case len(parts) == 2 && parts[0] == "transcriptions":
|
case len(parts) == 2 && parts[0] == "transcriptions":
|
||||||
t, err := s.content.TranscriptBySlug(ctx, parts[1])
|
t, err := s.content.TranscriptBySlug(ctx, parts[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, "", ErrQuizTarget
|
return "", nil, "", nil, ErrQuizTarget
|
||||||
}
|
}
|
||||||
kind = model.QuizLecture
|
kind = model.QuizLecture
|
||||||
if t.Type == "conversation" {
|
if t.Type == "conversation" {
|
||||||
kind = model.QuizConversation
|
kind = model.QuizConversation
|
||||||
}
|
}
|
||||||
title = t.Name
|
title = t.Name
|
||||||
|
year = t.Year
|
||||||
|
|
||||||
case len(parts) >= 3 && parts[0] == "books":
|
case len(parts) >= 3 && parts[0] == "books":
|
||||||
kind = model.QuizChapter
|
kind = model.QuizChapter
|
||||||
@@ -110,13 +111,13 @@ func (s *QuizService) resolveTarget(ctx context.Context, raw, override string) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return "", nil, "", ErrQuizTarget
|
return "", nil, "", nil, ErrQuizTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
if o := strings.TrimSpace(override); o != "" {
|
if o := strings.TrimSpace(override); o != "" {
|
||||||
title = o
|
title = o
|
||||||
}
|
}
|
||||||
return kind, &p, title, nil
|
return kind, &p, title, year, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateQuestions(qs []repository.QuestionInput) ([]repository.QuestionInput, error) {
|
func validateQuestions(qs []repository.QuestionInput) ([]repository.QuestionInput, error) {
|
||||||
@@ -166,7 +167,7 @@ func (s *QuizService) toInput(ctx context.Context, d QuizDraft, withQuestions bo
|
|||||||
return in, ErrQuizTitle
|
return in, ErrQuizTitle
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
if in.TargetKind, in.TargetPath, in.TargetTitle, err = s.resolveTarget(ctx, d.TargetURL, d.TargetTitle); err != nil {
|
if in.TargetKind, in.TargetPath, in.TargetTitle, in.TargetYear, err = s.resolveTarget(ctx, d.TargetURL, d.TargetTitle); err != nil {
|
||||||
return in, err
|
return in, err
|
||||||
}
|
}
|
||||||
if withQuestions {
|
if withQuestions {
|
||||||
@@ -199,6 +200,9 @@ func (s *QuizService) Create(ctx context.Context, actorID string, d QuizDraft) (
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := s.ensureTargetFree(ctx, in, ""); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
id, err := s.quizzes.Create(ctx, in, actorID)
|
id, err := s.quizzes.Create(ctx, in, actorID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -223,12 +227,34 @@ func (s *QuizService) Update(ctx context.Context, id string, d QuizDraft) (*mode
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := s.ensureTargetFree(ctx, in, id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if err := s.quizzes.Update(ctx, id, in, !locked); err != nil {
|
if err := s.quizzes.Update(ctx, id, in, !locked); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.GetAdmin(ctx, id)
|
return s.GetAdmin(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureTargetFree refuses a second quiz on the same chapter, lecture or
|
||||||
|
// conversation, naming the one already there so the admin can edit it
|
||||||
|
// instead. Stand-alone quizzes are never limited. The unique index backs
|
||||||
|
// this up against a race between two saves.
|
||||||
|
func (s *QuizService) ensureTargetFree(ctx context.Context, in repository.QuizInput, selfID string) error {
|
||||||
|
if in.TargetPath == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, title, err := s.quizzes.QuizOnTarget(ctx, *in.TargetPath, selfID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if title != "" {
|
||||||
|
return fmt.Errorf("%w — %q is already linked to %s. Edit that quiz, or delete it first",
|
||||||
|
repository.ErrQuizTargetTaken, title, *in.TargetPath)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *QuizService) SetPublished(ctx context.Context, id string, published bool) (*model.Quiz, error) {
|
func (s *QuizService) SetPublished(ctx context.Context, id string, published bool) (*model.Quiz, error) {
|
||||||
if published {
|
if published {
|
||||||
q, err := s.quizzes.Get(ctx, id, false)
|
q, err := s.quizzes.Get(ctx, id, false)
|
||||||
@@ -269,6 +295,16 @@ func (s *QuizService) ParticipantResult(ctx context.Context, quizID, userID stri
|
|||||||
return s.quizzes.Result(ctx, quizID, userID, true)
|
return s.quizzes.Result(ctx, quizID, userID, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetParticipant wipes one participant's attempt; the quiz becomes pending
|
||||||
|
// for them. Works on unpublished quizzes too — staff may tidy results up
|
||||||
|
// while a quiz is offline.
|
||||||
|
func (s *QuizService) ResetParticipant(ctx context.Context, quizID, userID string) error {
|
||||||
|
if _, err := s.quizzes.Get(ctx, quizID, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.quizzes.ResetAttempt(ctx, quizID, userID)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Taking ────────────────────────────────────────────────────────────────
|
// ── Taking ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// published loads a quiz that users may see.
|
// published loads a quiz that users may see.
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
)
|
)
|
||||||
@@ -278,6 +281,24 @@ func TestQuiz_TakingFlow(t *testing.T) {
|
|||||||
t.Errorf("responses = %+v, %v", stats, err)
|
t.Errorf("responses = %+v, %v", stats, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Staff can reset one participant: their attempt and answers go, the quiz
|
||||||
|
// is pending for them again, and they can retake it from the start.
|
||||||
|
if err := svc.ResetParticipant(ctx, quiz.ID, other); !errors.Is(err, repository.ErrQuizNotStarted) {
|
||||||
|
t.Errorf("resetting someone with no attempt: %v, want ErrQuizNotStarted", err)
|
||||||
|
}
|
||||||
|
if err := svc.ResetParticipant(ctx, quiz.ID, user); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if card, _ := svc.Card(ctx, user, quiz.ID); card.My.State != model.QuizNotStarted {
|
||||||
|
t.Errorf("state after reset = %s, want not_started", card.My.State)
|
||||||
|
}
|
||||||
|
if parts, _ := svc.Participants(ctx, quiz.ID); len(parts) != 0 {
|
||||||
|
t.Errorf("participants after reset = %d, want 0", len(parts))
|
||||||
|
}
|
||||||
|
if again, err := svc.Start(ctx, user, quiz.ID); err != nil || again.Answered != 0 || again.Question == nil {
|
||||||
|
t.Errorf("retaking after reset: %+v, %v", again, err)
|
||||||
|
}
|
||||||
|
|
||||||
// Taken, so questions are frozen — but the title can still change.
|
// Taken, so questions are frozen — but the title can still change.
|
||||||
if _, err := svc.Update(ctx, quiz.ID, QuizDraft{Title: "x", Questions: []repository.QuestionInput{q("p", 0, "a", "b")}}); !errors.Is(err, ErrQuizLocked) {
|
if _, err := svc.Update(ctx, quiz.ID, QuizDraft{Title: "x", Questions: []repository.QuestionInput{q("p", 0, "a", "b")}}); !errors.Is(err, ErrQuizLocked) {
|
||||||
t.Errorf("editing questions after attempts: %v, want ErrQuizLocked", err)
|
t.Errorf("editing questions after attempts: %v, want ErrQuizLocked", err)
|
||||||
@@ -300,3 +321,64 @@ func TestNormalizeContentPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A chapter, lecture or conversation carries at most one live quiz. The
|
||||||
|
// service names the quiz already there; the unique index catches a race;
|
||||||
|
// deleting a quiz frees its link; stand-alone quizzes are unlimited.
|
||||||
|
func TestQuiz_OnePerTarget(t *testing.T) {
|
||||||
|
svc, pg := newQuizSvc(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
admin := chatUser(t, pg, "Admin")
|
||||||
|
repo := repository.NewQuizRepo(pg)
|
||||||
|
|
||||||
|
path := "/books/zz-test/" + uuid.NewString()[:8]
|
||||||
|
in := repository.QuizInput{
|
||||||
|
Title: "First", TargetKind: model.QuizChapter, TargetPath: &path,
|
||||||
|
Questions: []repository.QuestionInput{q("p", 0, "a", "b")},
|
||||||
|
}
|
||||||
|
first, err := repo.Create(ctx, in, admin)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cleanupQuiz(t, pg, first)
|
||||||
|
|
||||||
|
// The friendly pre-check names the quiz that holds the link…
|
||||||
|
err = svc.ensureTargetFree(ctx, in, "")
|
||||||
|
if !errors.Is(err, repository.ErrQuizTargetTaken) || !strings.Contains(err.Error(), `"First"`) {
|
||||||
|
t.Errorf("second quiz on a link: %v, want ErrQuizTargetTaken naming \"First\"", err)
|
||||||
|
}
|
||||||
|
// …but a quiz re-saving its own link is fine.
|
||||||
|
if err := svc.ensureTargetFree(ctx, in, first); err != nil {
|
||||||
|
t.Errorf("re-saving the same quiz: %v", err)
|
||||||
|
}
|
||||||
|
// The index refuses it even if the check is skipped (two saves racing).
|
||||||
|
in.Title = "Second"
|
||||||
|
if _, err := repo.Create(ctx, in, admin); !errors.Is(err, repository.ErrQuizTargetTaken) {
|
||||||
|
t.Errorf("create past the index: %v, want ErrQuizTargetTaken", err)
|
||||||
|
}
|
||||||
|
// Moving another quiz onto the link is refused too.
|
||||||
|
other := repository.QuizInput{Title: "Other", TargetKind: model.QuizStandalone,
|
||||||
|
Questions: []repository.QuestionInput{q("p", 0, "a", "b")}}
|
||||||
|
otherID, err := repo.Create(ctx, other, admin)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cleanupQuiz(t, pg, otherID)
|
||||||
|
other.TargetKind, other.TargetPath = model.QuizChapter, &path
|
||||||
|
if err := repo.Update(ctx, otherID, other, false); !errors.Is(err, repository.ErrQuizTargetTaken) {
|
||||||
|
t.Errorf("moving a quiz onto a taken link: %v, want ErrQuizTargetTaken", err)
|
||||||
|
}
|
||||||
|
// Stand-alone quizzes are unlimited.
|
||||||
|
if solo, err := svc.Create(ctx, admin, QuizDraft{Title: "Solo", Questions: []repository.QuestionInput{q("p", 0, "a", "b")}}); err != nil {
|
||||||
|
t.Errorf("a second stand-alone quiz: %v", err)
|
||||||
|
} else {
|
||||||
|
cleanupQuiz(t, pg, solo.ID)
|
||||||
|
}
|
||||||
|
// Deleting the first frees the link.
|
||||||
|
if err := svc.Delete(ctx, first); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := repo.Update(ctx, otherID, other, false); err != nil {
|
||||||
|
t.Errorf("taking a freed link: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
UPDATE admin_roles SET permissions = array_remove(permissions, 'quizzes:reset');
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- quizzes:reset — delete one participant's attempt so the quiz is pending
|
||||||
|
-- for them again. Granted to the seeded Admin role (Superadmin has '*';
|
||||||
|
-- Moderator and custom roles get it only when a superadmin adds it).
|
||||||
|
UPDATE admin_roles
|
||||||
|
SET permissions = array_append(permissions, 'quizzes:reset')
|
||||||
|
WHERE key = 'admin' AND NOT ('quizzes:reset' = ANY(permissions));
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP INDEX IF EXISTS quizzes_one_per_target;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- One quiz per piece of content. A chapter, lecture or conversation may carry
|
||||||
|
-- at most one live quiz; stand-alone quizzes (no path) are unlimited, and a
|
||||||
|
-- deleted quiz frees its link for a new one.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS quizzes_one_per_target
|
||||||
|
ON quizzes (target_path)
|
||||||
|
WHERE deleted_at IS NULL AND target_path IS NOT NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE quizzes DROP COLUMN IF EXISTS target_year;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- The year of a lecture or conversation a quiz is attached to, captured when
|
||||||
|
-- the quiz is saved, so the app can group quizzes by year without looking
|
||||||
|
-- each transcript up. NULL for chapters, stand-alone quizzes, and undated
|
||||||
|
-- recordings.
|
||||||
|
ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS target_year SMALLINT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
UPDATE admin_roles SET permissions = array_remove(permissions, 'feedback:categories');
|
||||||
|
DROP TABLE IF EXISTS feedback_categories;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
-- Feedback categories, managed from the admin panel instead of hard-coded in
|
||||||
|
-- the app and the BFF.
|
||||||
|
--
|
||||||
|
-- `key` is what feedback.category stores, and never changes once created —
|
||||||
|
-- so renaming a category in the admin relabels every past submission too.
|
||||||
|
-- Archiving hides a category from the /feedback form while existing feedback
|
||||||
|
-- keeps its label. There is deliberately no foreign key from feedback: the
|
||||||
|
-- column predates this table, and the admin only hard-deletes categories
|
||||||
|
-- nothing uses.
|
||||||
|
CREATE TABLE IF NOT EXISTS feedback_categories (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
archived_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The four categories the app offered until now, with the exact labels and
|
||||||
|
-- descriptions its form showed, in the form's order. Production feedback
|
||||||
|
-- already uses these keys, so they must stay byte-for-byte the same.
|
||||||
|
INSERT INTO feedback_categories (key, label, description, sort_order) VALUES
|
||||||
|
('general', 'General feedback', 'Thoughts, impressions, suggestions — anything else.', 0),
|
||||||
|
('bug', 'Bug report', 'Something is broken or behaving unexpectedly.', 1),
|
||||||
|
('feature', 'Feature request', 'An idea for something new on the site.', 2),
|
||||||
|
('other', 'Other', 'Doesn''t fit the above? Use this.', 3)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
-- Safety net: any other key already present in feedback (from an older
|
||||||
|
-- client) gets a category too — archived, so it isn't offered on the form,
|
||||||
|
-- but its submissions still carry a readable label.
|
||||||
|
INSERT INTO feedback_categories (key, label, sort_order, archived_at)
|
||||||
|
SELECT DISTINCT f.category, initcap(replace(f.category, '_', ' ')), 100, now()
|
||||||
|
FROM feedback f
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM feedback_categories c WHERE c.key = f.category)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
-- The Admin role may manage them; Superadmin already has '*'.
|
||||||
|
UPDATE admin_roles
|
||||||
|
SET permissions = array_append(permissions, 'feedback:categories')
|
||||||
|
WHERE key = 'admin' AND NOT ('feedback:categories' = ANY(permissions));
|
||||||
Reference in New Issue
Block a user