4 Commits
Author SHA1 Message Date
Krispire AdminandClaude Opus 5.5 d76505772a Admin-managed feedback categories
Categories move from a hard-coded list to a feedback_categories table
(migration 0062), seeded with the four existing keys, labels and
descriptions so production feedback is untouched; any other key already in
use gets an archived category. Keys are fixed, so renames relabel past
feedback. Archive hides a category from the form; only unused categories
can be deleted. Submissions are validated against active categories, and
feedback responses carry category_label.

New: GET /feedback/categories (public); /admin/feedback/categories CRUD +
reorder behind the new feedback:categories permission (granted to Admin).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:47:09 +05:30
Krispire Admin fa0255e9cd quiz grouping 2026-10-18 13:02:13 +05:30
Krispire Admin eb02d08f14 unique quiz constraint 2026-10-16 15:23:06 +05:30
Krispire Admin b1f78b1655 quiz resetc 2026-10-15 20:59:53 +05:30
24 changed files with 837 additions and 72 deletions
+114
View File
@@ -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)
}
+13 -4
View File
@@ -26,7 +26,7 @@ func writeQuizError(w http.ResponseWriter, err error) {
case errors.Is(err, repository.ErrQuizNotStarted), errors.Is(err, repository.ErrQuizNotFinished):
response.Error(w, http.StatusConflict, err.Error())
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())
case errors.Is(err, repository.ErrQuizBadOption), errors.Is(err, repository.ErrQuizChoiceCount),
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)
}
// 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 ─────────────────────────────────────────────────────────────────
// ForPath — GET /quizzes/for?path=… Optional auth: signed-out readers still
// see that a quiz exists; signed-in ones also get their progress. Returns
// null when the page has no quiz.
// ForPath — GET /quizzes/for?path=… Signed in only; returns the quiz on that
// page with the caller's progress, or null when the page has none.
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"))
if err != nil {
+1
View File
@@ -89,6 +89,7 @@ type AdminCollection struct {
type AdminFeedback struct {
ID string `json:"id"`
Category string `json:"category"`
CategoryLabel string `json:"category_label"`
Message string `json:"message"`
ContactEmail *string `json:"contact_email"`
CreatedAt time.Time `json:"created_at"`
+15
View File
@@ -18,6 +18,9 @@ type Feedback struct {
ID string `json:"id"`
UserID *string `json:"user_id,omitempty"`
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"`
ContactEmail *string `json:"contact_email,omitempty"`
CreatedAt time.Time `json:"created_at"`
@@ -50,3 +53,15 @@ type Feedback struct {
AuthorName *string `json:"author_name,omitempty"`
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"`
}
+2
View File
@@ -25,6 +25,8 @@ type Quiz struct {
TargetKind string `json:"target_kind"`
TargetPath *string `json:"target_path,omitempty"`
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"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
+2
View File
@@ -42,6 +42,7 @@ var Catalog = []Permission{
{"feedback:read", "Feedback", "View feedback"},
{"feedback:reply", "Feedback", "Reply to feedback"},
{"feedback:resolve", "Feedback", "Acknowledge and resolve feedback"},
{"feedback:categories", "Feedback", "Manage feedback categories"},
{"vani:read", "Vāṇī of the Day", "View the schedule"},
{"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:delete", "Quizzes", "Delete quizzes"},
{"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"},
}
+3 -2
View File
@@ -248,12 +248,13 @@ func (r *AdminRepo) ListFeedback(ctx context.Context, status string, limit, offs
status = "all"
}
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,
u.handle, u.name, u.email,
COUNT(*) OVER() AS total
FROM feedback f
LEFT JOIN users u ON u.id = f.user_id
LEFT JOIN feedback_categories fc ON fc.key = f.category
WHERE ($1 = 'all'
OR ($1 = 'open' AND f.resolved_at IS 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
for rows.Next() {
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.AuthorHandle, &f.AuthorName, &f.AuthorEmail, &total); err != nil {
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
}
+13 -10
View File
@@ -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) {
f := &model.Feedback{}
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,
u.handle, u.name, u.email
FROM feedback f
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,
).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.AuthorHandle, &f.AuthorName, &f.AuthorEmail)
if err != nil {
@@ -95,11 +96,12 @@ func (r *FeedbackRepo) ListByUser(ctx context.Context, userID string, limit int)
limit = 50
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT id, user_id, category, message, contact_email, created_at,
acknowledged_at, resolved_at, replied_at, reply_message
FROM feedback
WHERE user_id = $1
ORDER BY created_at DESC
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.replied_at, f.reply_message
FROM feedback f
LEFT JOIN feedback_categories fc ON fc.key = f.category
WHERE f.user_id = $1
ORDER BY f.created_at DESC
LIMIT $2`, userID, limit,
)
if err != nil {
@@ -109,7 +111,7 @@ func (r *FeedbackRepo) ListByUser(ctx context.Context, userID string, limit int)
out := []model.Feedback{}
for rows.Next() {
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 {
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
}
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.acknowledged_at, f.resolved_at,
u.handle, u.name
FROM feedback f
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
LIMIT $1`, limit,
)
@@ -148,7 +151,7 @@ func (r *FeedbackRepo) Recent(ctx context.Context, limit int) ([]model.Feedback,
out := []model.Feedback{}
for rows.Next() {
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.AcknowledgedAt, &f.ResolvedAt,
&f.AuthorHandle, &f.AuthorName); err != nil {
+48 -7
View File
@@ -17,6 +17,9 @@ var (
ErrQuizNotCurrent = errors.New("that isn't the current 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")
// 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")
ErrQuizNotFinished = errors.New("the quiz isn't finished yet")
)
@@ -32,6 +35,7 @@ type QuizInput struct {
TargetKind string
TargetPath *string
TargetTitle string
TargetYear *int
Questions []QuestionInput
}
@@ -48,12 +52,12 @@ type OptionInput struct {
}
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)`
func scanQuiz(row pgx.Row, q *model.Quiz, extra ...any) error {
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...)...)
}
@@ -144,10 +148,13 @@ func (r *QuizRepo) Create(ctx context.Context, in QuizInput, createdBy string) (
defer tx.Rollback(ctx) //nolint:errcheck
var id string
if err := tx.QueryRow(ctx, `
INSERT INTO quizzes (title, description, target_kind, target_path, target_title, created_by)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
in.Title, in.Description, in.TargetKind, in.TargetPath, in.TargetTitle, createdBy,
INSERT INTO quizzes (title, description, target_kind, target_path, target_title, target_year, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
in.Title, in.Description, in.TargetKind, in.TargetPath, in.TargetTitle, in.TargetYear, createdBy,
).Scan(&id); err != nil {
if isUniqueViolation(err, "quizzes_one_per_target") {
return "", ErrQuizTargetTaken
}
return "", fmt.Errorf("create quiz: %w", err)
}
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
tag, err := tx.Exec(ctx, `
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`,
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 isUniqueViolation(err, "quizzes_one_per_target") {
return ErrQuizTargetTaken
}
return fmt.Errorf("update quiz: %w", err)
}
if tag.RowsAffected() == 0 {
@@ -207,6 +217,23 @@ func insertQuestions(ctx context.Context, tx pgx.Tx, quizID string, qs []Questio
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) {
var n int
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
}
// 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 ─────────────────────────────────────────────────────────
// PublishedForPath returns the newest live quiz on a content page, or nil.
+20 -5
View File
@@ -76,6 +76,7 @@ type Server struct {
pushH *handler.PushHandler
reminderH *handler.ReminderHandler
feedbackH *handler.FeedbackHandler
feedbackCatH *handler.FeedbackCategoryHandler
statsH *handler.StatsHandler
adminH *handler.AdminHandler
broadcastH *handler.BroadcastHandler
@@ -343,8 +344,11 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
// the existing mailer instance.
feedbackRepo := repository.NewFeedbackRepo(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.feedbackCatH = handler.NewFeedbackCategoryHandler(service.NewFeedbackCategoryService(feedbackCatRepo))
s.statsH = handler.NewStatsHandler(statsRepo, feedbackRepo)
// 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.
r.Get("/vani/today", s.vaniH.Today)
// Quizzes. Browsing is open to signed-out readers (they see that a
// quiz exists); taking one, and seeing results, needs an account.
// Quizzes are for signed-in users: listing, finding one on a page,
// 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.Group(func(r chi.Router) {
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.Group(func(r chi.Router) {
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}/answer", s.quizH.Answer)
r.Get("/{id}/result", s.quizH.Result)
@@ -1109,6 +1116,8 @@ func (s *Server) setupRouter() {
r.HandleFunc("/feedback/mine", s.authUnavailable)
return
}
// The form's categories — public, like the form itself.
r.Get("/feedback/categories", s.feedbackCatH.Public)
// Submit — OptionalAuth (anonymous callers allowed).
r.Group(func(r chi.Router) {
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("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:resolve").Post("/feedback/{id}/acknowledge", s.adminH.AcknowledgeFeedback)
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/{userID}", s.quizH.ParticipantResult)
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/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)
}
}
+25 -17
View File
@@ -22,23 +22,22 @@ import (
type FeedbackService struct {
repo *repository.FeedbackRepo
// categories is the admin-managed list a submission must belong to.
categories *repository.FeedbackCategoryRepo
mail mailer.Mailer
admins []string
logger *zap.Logger
}
func NewFeedbackService(repo *repository.FeedbackRepo, mail mailer.Mailer, admins []string, logger *zap.Logger) *FeedbackService {
return &FeedbackService{repo: repo, mail: mail, admins: admins, logger: logger}
func NewFeedbackService(repo *repository.FeedbackRepo, categories *repository.FeedbackCategoryRepo, mail mailer.Mailer, admins []string, logger *zap.Logger) *FeedbackService {
return &FeedbackService{repo: repo, categories: categories, mail: mail, admins: admins, logger: logger}
}
// Allowed categories — kept lowercase in storage so dashboards group
// cleanly. The frontend dropdown sends one of these exact strings.
var feedbackCategories = map[string]bool{
"bug": true,
"feature": true,
"general": true,
"other": true,
}
// ErrFeedbackCategory: the submission names a category that doesn't exist
// or has been archived in the admin panel.
var ErrFeedbackCategory = errors.New("invalid category")
// 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).
func (s *FeedbackService) Submit(ctx context.Context, userID *string, category, message string, contactEmail *string) (*model.Feedback, error) {
category = strings.ToLower(strings.TrimSpace(category))
if !feedbackCategories[category] {
return nil, errors.New("invalid category")
if ok, err := s.categories.IsActive(ctx, category); err != nil {
return nil, err
} else if !ok {
return nil, ErrFeedbackCategory
}
message = strings.TrimSpace(message)
if message == "" {
@@ -76,16 +77,15 @@ func (s *FeedbackService) Submit(ctx context.Context, userID *string, category,
return nil, err
}
// Hydrate the joined author fields for the email template. Failing
// the lookup isn't worth failing the request — we fall back to
// "anonymous" rather than 500ing on the user.
if userID != nil {
// Hydrate the joined author fields and category label for the email
// template. Failing the lookup isn't worth failing the request — we fall
// back to "anonymous" and the category key rather than 500ing.
if full, err := s.repo.FindByID(ctx, f.ID); err == nil {
f = full
} else {
s.logger.Warn("feedback hydrate failed; sending email without author info",
zap.String("feedback_id", f.ID), zap.Error(err))
}
f.CategoryLabel = f.Category
}
s.notifyAdmins(f)
@@ -222,7 +222,7 @@ func (s *FeedbackService) notifyAdmins(f *model.Feedback) {
s.logger.Warn("render feedback email failed", zap.Error(err))
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 {
if err := s.mail.Send(to, subject, body); err != nil {
s.logger.Warn("feedback email send failed",
@@ -263,7 +263,7 @@ func renderFeedbackEmail(f *model.Feedback) (string, error) {
}
var buf bytes.Buffer
err := feedbackEmailTpl.Execute(&buf, map[string]any{
"Category": f.Category,
"Category": categoryLabel(f),
"Message": f.Message,
"From": from,
"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
}
// 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
}
+42 -6
View File
@@ -73,10 +73,10 @@ func NormalizeContentPath(raw string) string {
}
// 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)
if p == "" {
return model.QuizStandalone, nil, "", nil
return model.QuizStandalone, nil, "", nil, nil
}
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":
t, err := s.content.TranscriptBySlug(ctx, parts[1])
if err != nil {
return "", nil, "", ErrQuizTarget
return "", nil, "", nil, ErrQuizTarget
}
kind = model.QuizLecture
if t.Type == "conversation" {
kind = model.QuizConversation
}
title = t.Name
year = t.Year
case len(parts) >= 3 && parts[0] == "books":
kind = model.QuizChapter
@@ -110,13 +111,13 @@ func (s *QuizService) resolveTarget(ctx context.Context, raw, override string) (
}
default:
return "", nil, "", ErrQuizTarget
return "", nil, "", nil, ErrQuizTarget
}
if o := strings.TrimSpace(override); o != "" {
title = o
}
return kind, &p, title, nil
return kind, &p, title, year, nil
}
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
}
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
}
if withQuestions {
@@ -199,6 +200,9 @@ func (s *QuizService) Create(ctx context.Context, actorID string, d QuizDraft) (
if err != nil {
return nil, err
}
if err := s.ensureTargetFree(ctx, in, ""); err != nil {
return nil, err
}
id, err := s.quizzes.Create(ctx, in, actorID)
if err != nil {
return nil, err
@@ -223,12 +227,34 @@ func (s *QuizService) Update(ctx context.Context, id string, d QuizDraft) (*mode
if err != nil {
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 {
return nil, err
}
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) {
if published {
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)
}
// 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 ────────────────────────────────────────────────────────────────
// published loads a quiz that users may see.
+82
View File
@@ -3,8 +3,11 @@ package service
import (
"context"
"errors"
"strings"
"testing"
"github.com/google/uuid"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
)
@@ -278,6 +281,24 @@ func TestQuiz_TakingFlow(t *testing.T) {
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.
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)
@@ -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;
+5
View File
@@ -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));