Add question ownership and sharing

Questions now have a created_by field linking to the user who created them.
Users only see questions they own or that have been shared with them.
Includes share dialog, user search, bulk sharing, and export/import
respects ownership.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 09:43:04 -04:00
parent 02fcbad9ba
commit 69992f1be9
15 changed files with 836 additions and 70 deletions

View File

@@ -80,9 +80,22 @@ class Question(db.Model):
end_time = db.Column(db.Integer, nullable=True) # End time in seconds end_time = db.Column(db.Integer, nullable=True) # End time in seconds
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
# Relationships # Relationships
creator = db.relationship('User', backref='questions')
game_questions = db.relationship('GameQuestion', back_populates='question', cascade='all, delete-orphan') game_questions = db.relationship('GameQuestion', back_populates='question', cascade='all, delete-orphan')
shares = db.relationship('QuestionShare', backref='question', cascade='all, delete-orphan')
def is_visible_to(self, user):
"""Check if this question is visible to the given user"""
if self.created_by == user.id:
return True
return any(s.shared_with_user_id == user.id for s in self.shares)
def is_owned_by(self, user):
"""Check if this question is owned by the given user"""
return self.created_by == user.id
def to_dict(self, include_answer=False): def to_dict(self, include_answer=False):
"""Convert question to dictionary""" """Convert question to dictionary"""
@@ -96,13 +109,43 @@ class Question(db.Model):
'start_time': self.start_time, 'start_time': self.start_time,
'end_time': self.end_time, 'end_time': self.end_time,
'category': self.category, 'category': self.category,
'created_at': self.created_at.isoformat() if self.created_at else None 'created_at': self.created_at.isoformat() if self.created_at else None,
'created_by': self.created_by,
'creator_name': (self.creator.name or self.creator.preferred_username) if self.creator else None,
} }
if include_answer: if include_answer:
data['answer'] = self.answer data['answer'] = self.answer
return data return data
class QuestionShare(db.Model):
"""Junction table for sharing questions between users"""
__tablename__ = 'question_shares'
id = db.Column(db.Integer, primary_key=True)
question_id = db.Column(db.Integer, db.ForeignKey('questions.id'), nullable=False)
shared_with_user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
shared_by_user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
shared_with_user = db.relationship('User', foreign_keys=[shared_with_user_id], backref='shared_questions')
shared_by_user = db.relationship('User', foreign_keys=[shared_by_user_id])
__table_args__ = (
db.UniqueConstraint('question_id', 'shared_with_user_id', name='unique_question_share'),
)
def to_dict(self):
return {
'id': self.id,
'question_id': self.question_id,
'shared_with_user_id': self.shared_with_user_id,
'shared_with_user_name': (self.shared_with_user.name or self.shared_with_user.preferred_username) if self.shared_with_user else None,
'shared_by_user_id': self.shared_by_user_id,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
class Game(db.Model): class Game(db.Model):
"""Game model representing a trivia game session""" """Game model representing a trivia game session"""
__tablename__ = 'games' __tablename__ = 'games'

View File

@@ -122,3 +122,34 @@ def logout():
def get_current_user(): def get_current_user():
"""Get current user info (requires auth)""" """Get current user info (requires auth)"""
return jsonify(g.current_user.to_dict()), 200 return jsonify(g.current_user.to_dict()), 200
@bp.route('/users/search')
@require_auth
def search_users():
"""Search users by name, username, or email for sharing
Query parameters:
- q: Search query (required, min 1 character)
"""
query = request.args.get('q', '').strip()
if not query:
return jsonify([]), 200
search_pattern = f'%{query}%'
users = User.query.filter(
User.id != g.current_user.id,
User.is_active == True,
db.or_(
User.name.ilike(search_pattern),
User.preferred_username.ilike(search_pattern),
User.email.ilike(search_pattern)
)
).limit(10).all()
return jsonify([{
'id': u.id,
'name': u.name,
'username': u.preferred_username,
'email': u.email
} for u in users]), 200

View File

@@ -1,7 +1,7 @@
"""Routes for exporting and importing trivia questions""" """Routes for exporting and importing trivia questions"""
import os import os
import tempfile import tempfile
from flask import Blueprint, jsonify, Response, request from flask import Blueprint, jsonify, Response, request, g
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from backend.auth.middleware import require_auth from backend.auth.middleware import require_auth
@@ -23,8 +23,8 @@ def export_data():
ZIP file containing manifest.json and all media files ZIP file containing manifest.json and all media files
""" """
try: try:
# Generate export ZIP # Generate export ZIP (only questions visible to current user)
zip_bytes, zip_filename = export_questions_to_zip() zip_bytes, zip_filename = export_questions_to_zip(user_id=g.current_user.id)
# Create response with ZIP file # Create response with ZIP file
response = Response( response = Response(
@@ -82,8 +82,8 @@ def import_data():
file_size = os.path.getsize(temp_path) file_size = os.path.getsize(temp_path)
print(f"Saved file size: {file_size} bytes") print(f"Saved file size: {file_size} bytes")
# Import from ZIP # Import from ZIP (set ownership to current user)
result = import_questions_from_zip(temp_path) result = import_questions_from_zip(temp_path, user_id=g.current_user.id)
return jsonify(result), 200 return jsonify(result), 200

View File

@@ -1,13 +1,28 @@
from flask import Blueprint, request, jsonify, current_app from flask import Blueprint, request, jsonify, current_app, g
from backend.models import db, Question, QuestionType from backend.models import db, Question, QuestionType, QuestionShare, User
from backend.services.image_service import save_image, delete_image from backend.services.image_service import save_image, delete_image
from backend.auth.middleware import require_auth
bp = Blueprint('questions', __name__, url_prefix='/api/questions') bp = Blueprint('questions', __name__, url_prefix='/api/questions')
def _visible_questions_query():
"""Return a base query filtered to questions visible to the current user."""
return Question.query.filter(
db.or_(
Question.created_by == g.current_user.id,
Question.id.in_(
db.session.query(QuestionShare.question_id)
.filter(QuestionShare.shared_with_user_id == g.current_user.id)
)
)
)
@bp.route('', methods=['GET']) @bp.route('', methods=['GET'])
@require_auth
def list_questions(): def list_questions():
"""Get all questions with optional filtering and sorting """Get all questions visible to the current user with optional filtering and sorting
Query parameters: Query parameters:
- search: Search in question content and answer (case-insensitive) - search: Search in question content and answer (case-insensitive)
@@ -15,8 +30,16 @@ def list_questions():
- type: Filter by question type (text, image, youtube_audio) - type: Filter by question type (text, image, youtube_audio)
- sort_by: Field to sort by (created_at, category, type, question_content, answer) - sort_by: Field to sort by (created_at, category, type, question_content, answer)
- sort_order: Sort direction (asc, desc) - default: desc - sort_order: Sort direction (asc, desc) - default: desc
- owner: Filter by ownership ('mine', 'shared', or omit for all visible)
""" """
query = Question.query query = _visible_questions_query()
# Owner filter
owner = request.args.get('owner', '').strip()
if owner == 'mine':
query = query.filter(Question.created_by == g.current_user.id)
elif owner == 'shared':
query = query.filter(Question.created_by != g.current_user.id)
# Search filter # Search filter
search = request.args.get('search', '').strip() search = request.args.get('search', '').strip()
@@ -70,13 +93,17 @@ def list_questions():
@bp.route('/<int:question_id>', methods=['GET']) @bp.route('/<int:question_id>', methods=['GET'])
@require_auth
def get_question(question_id): def get_question(question_id):
"""Get a single question by ID""" """Get a single question by ID (must be visible to current user)"""
question = Question.query.get_or_404(question_id) question = Question.query.get_or_404(question_id)
if not question.is_visible_to(g.current_user):
return jsonify({'error': 'Question not found'}), 404
return jsonify(question.to_dict(include_answer=True)), 200 return jsonify(question.to_dict(include_answer=True)), 200
@bp.route('', methods=['POST']) @bp.route('', methods=['POST'])
@require_auth
def create_question(): def create_question():
"""Create a new question""" """Create a new question"""
try: try:
@@ -171,7 +198,8 @@ def create_question():
audio_path=audio_path, # Will be None initially for YouTube audio_path=audio_path, # Will be None initially for YouTube
start_time=start_time, start_time=start_time,
end_time=end_time, end_time=end_time,
category=category if category else None category=category if category else None,
created_by=g.current_user.id
) )
db.session.add(question) db.session.add(question)
@@ -207,10 +235,14 @@ def create_question():
@bp.route('/<int:question_id>', methods=['PUT']) @bp.route('/<int:question_id>', methods=['PUT'])
@require_auth
def update_question(question_id): def update_question(question_id):
"""Update an existing question""" """Update an existing question (owner only)"""
question = Question.query.get_or_404(question_id) question = Question.query.get_or_404(question_id)
if not question.is_owned_by(g.current_user):
return jsonify({'error': 'You can only edit questions you created'}), 403
try: try:
# Handle multipart form data or JSON # Handle multipart form data or JSON
if request.content_type and 'multipart/form-data' in request.content_type: if request.content_type and 'multipart/form-data' in request.content_type:
@@ -268,10 +300,14 @@ def update_question(question_id):
@bp.route('/<int:question_id>', methods=['DELETE']) @bp.route('/<int:question_id>', methods=['DELETE'])
@require_auth
def delete_question(question_id): def delete_question(question_id):
"""Delete a question""" """Delete a question (owner only)"""
question = Question.query.get_or_404(question_id) question = Question.query.get_or_404(question_id)
if not question.is_owned_by(g.current_user):
return jsonify({'error': 'You can only delete questions you created'}), 403
try: try:
# Delete associated image if exists # Delete associated image if exists
if question.image_path: if question.image_path:
@@ -293,8 +329,9 @@ def delete_question(question_id):
@bp.route('/random', methods=['GET']) @bp.route('/random', methods=['GET'])
@require_auth
def get_random_questions(): def get_random_questions():
"""Get random questions by category """Get random questions by category (only from visible questions)
Query parameters: Query parameters:
- category: Category name (required) - category: Category name (required)
@@ -312,8 +349,8 @@ def get_random_questions():
return jsonify({'error': 'count must be a valid integer'}), 400 return jsonify({'error': 'count must be a valid integer'}), 400
try: try:
# Get all questions for the category # Get visible questions for the category
questions = Question.query.filter_by(category=category).all() questions = _visible_questions_query().filter_by(category=category).all()
if not questions: if not questions:
return jsonify({'error': f'No questions found for category: {category}'}), 404 return jsonify({'error': f'No questions found for category: {category}'}), 404
@@ -334,6 +371,7 @@ def get_random_questions():
@bp.route('/bulk', methods=['POST']) @bp.route('/bulk', methods=['POST'])
@require_auth
def bulk_create_questions(): def bulk_create_questions():
"""Bulk create questions """Bulk create questions
@@ -377,7 +415,8 @@ def bulk_create_questions():
question_content=q_data['question_content'], question_content=q_data['question_content'],
answer=q_data['answer'], answer=q_data['answer'],
category=q_data.get('category') if q_data.get('category') else None, category=q_data.get('category') if q_data.get('category') else None,
image_path=None # Bulk import doesn't support images image_path=None, # Bulk import doesn't support images
created_by=g.current_user.id
) )
db.session.add(question) db.session.add(question)
@@ -398,3 +437,139 @@ def bulk_create_questions():
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
# --- Sharing endpoints ---
@bp.route('/<int:question_id>/shares', methods=['GET'])
@require_auth
def get_shares(question_id):
"""Get list of users a question is shared with (owner only)"""
question = Question.query.get_or_404(question_id)
if not question.is_owned_by(g.current_user):
return jsonify({'error': 'Only the question owner can view shares'}), 403
return jsonify([s.to_dict() for s in question.shares]), 200
@bp.route('/<int:question_id>/share', methods=['POST'])
@require_auth
def share_question(question_id):
"""Share a question with another user (owner only)
Expected JSON: { "user_id": int }
"""
question = Question.query.get_or_404(question_id)
if not question.is_owned_by(g.current_user):
return jsonify({'error': 'Only the question owner can share it'}), 403
data = request.get_json()
if not data or 'user_id' not in data:
return jsonify({'error': 'user_id is required'}), 400
target_user_id = data['user_id']
if target_user_id == g.current_user.id:
return jsonify({'error': 'Cannot share a question with yourself'}), 400
target_user = User.query.get(target_user_id)
if not target_user:
return jsonify({'error': 'User not found'}), 404
# Check if already shared
existing = QuestionShare.query.filter_by(
question_id=question_id,
shared_with_user_id=target_user_id
).first()
if existing:
return jsonify({'error': 'Question already shared with this user'}), 409
share = QuestionShare(
question_id=question_id,
shared_with_user_id=target_user_id,
shared_by_user_id=g.current_user.id
)
db.session.add(share)
db.session.commit()
return jsonify(share.to_dict()), 201
@bp.route('/<int:question_id>/share/<int:user_id>', methods=['DELETE'])
@require_auth
def unshare_question(question_id, user_id):
"""Remove sharing of a question with a user (owner only)"""
question = Question.query.get_or_404(question_id)
if not question.is_owned_by(g.current_user):
return jsonify({'error': 'Only the question owner can manage shares'}), 403
share = QuestionShare.query.filter_by(
question_id=question_id,
shared_with_user_id=user_id
).first()
if not share:
return jsonify({'error': 'Share not found'}), 404
db.session.delete(share)
db.session.commit()
return jsonify({'message': 'Share removed'}), 200
@bp.route('/bulk-share', methods=['POST'])
@require_auth
def bulk_share_questions():
"""Share multiple questions with a user (owner only for each)
Expected JSON: { "question_ids": [int, ...], "user_id": int }
"""
data = request.get_json()
if not data or 'question_ids' not in data or 'user_id' not in data:
return jsonify({'error': 'question_ids and user_id are required'}), 400
target_user_id = data['user_id']
question_ids = data['question_ids']
if target_user_id == g.current_user.id:
return jsonify({'error': 'Cannot share questions with yourself'}), 400
target_user = User.query.get(target_user_id)
if not target_user:
return jsonify({'error': 'User not found'}), 404
shared = []
skipped = []
for qid in question_ids:
question = Question.query.get(qid)
if not question or not question.is_owned_by(g.current_user):
skipped.append({'id': qid, 'reason': 'not found or not owned'})
continue
existing = QuestionShare.query.filter_by(
question_id=qid,
shared_with_user_id=target_user_id
).first()
if existing:
skipped.append({'id': qid, 'reason': 'already shared'})
continue
share = QuestionShare(
question_id=qid,
shared_with_user_id=target_user_id,
shared_by_user_id=g.current_user.id
)
db.session.add(share)
shared.append(qid)
db.session.commit()
return jsonify({
'shared': shared,
'skipped': skipped
}), 200

View File

@@ -9,17 +9,29 @@ from pathlib import Path
from typing import Tuple, Optional from typing import Tuple, Optional
from flask import current_app from flask import current_app
from backend.models import db, Question, Category, QuestionType, Score, Team, GameQuestion, Game from backend.models import db, Question, Category, QuestionType, Score, Team, GameQuestion, Game, QuestionShare
def export_questions_to_zip() -> Tuple[bytes, str]: def export_questions_to_zip(user_id=None) -> Tuple[bytes, str]:
""" """
Export all questions and categories to a ZIP file with images and audio. Export questions and categories to a ZIP file with images and audio.
If user_id is provided, only exports questions owned by or shared with that user.
Returns: Returns:
Tuple of (zip_bytes, filename) Tuple of (zip_bytes, filename)
""" """
# Query all data # Query data filtered by visibility
if user_id is not None:
questions = Question.query.filter(
db.or_(
Question.created_by == user_id,
Question.id.in_(
db.session.query(QuestionShare.question_id)
.filter(QuestionShare.shared_with_user_id == user_id)
)
)
).order_by(Question.created_at).all()
else:
questions = Question.query.order_by(Question.created_at).all() questions = Question.query.order_by(Question.created_at).all()
categories = Category.query.order_by(Category.name).all() categories = Category.query.order_by(Category.name).all()
@@ -279,12 +291,13 @@ def clear_all_media_files():
print(f"Warning: Failed to delete {file_path}: {e}") print(f"Warning: Failed to delete {file_path}: {e}")
def import_questions_from_zip(zip_path: str) -> dict: def import_questions_from_zip(zip_path: str, user_id=None) -> dict:
""" """
Import questions and media from a ZIP file. Import questions and media from a ZIP file.
Args: Args:
zip_path: Path to ZIP file to import zip_path: Path to ZIP file to import
user_id: If provided, set created_by on all imported questions
Returns: Returns:
Dict with import summary Dict with import summary
@@ -373,7 +386,8 @@ def import_questions_from_zip(zip_path: str) -> dict:
audio_path=audio_path, audio_path=audio_path,
youtube_url=q_data.get('youtube_url'), youtube_url=q_data.get('youtube_url'),
start_time=q_data.get('start_time'), start_time=q_data.get('start_time'),
end_time=q_data.get('end_time') end_time=q_data.get('end_time'),
created_by=user_id
) )
# Preserve created_at if provided # Preserve created_at if provided

View File

@@ -6,8 +6,11 @@ import {
downloadJobsAPI, downloadJobsAPI,
} from "../../services/api"; } from "../../services/api";
import AdminNavbar from "../common/AdminNavbar"; import AdminNavbar from "../common/AdminNavbar";
import ShareDialog from "./ShareDialog";
import { useAuth } from "../../contexts/AuthContext";
export default function QuestionBankView() { export default function QuestionBankView() {
const { dbUser } = useAuth();
const [questions, setQuestions] = useState([]); const [questions, setQuestions] = useState([]);
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
@@ -28,18 +31,20 @@ export default function QuestionBankView() {
const [bulkCategory, setBulkCategory] = useState(""); const [bulkCategory, setBulkCategory] = useState("");
const [downloadJob, setDownloadJob] = useState(null); const [downloadJob, setDownloadJob] = useState(null);
const [downloadProgress, setDownloadProgress] = useState(0); const [downloadProgress, setDownloadProgress] = useState(0);
const [shareTarget, setShareTarget] = useState(null); // question or array of ids for sharing
// Filter and sort state // Filter and sort state
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [filterCategory, setFilterCategory] = useState(""); const [filterCategory, setFilterCategory] = useState("");
const [filterType, setFilterType] = useState(""); const [filterType, setFilterType] = useState("");
const [filterOwner, setFilterOwner] = useState("");
const [sortBy, setSortBy] = useState("created_at"); const [sortBy, setSortBy] = useState("created_at");
const [sortOrder, setSortOrder] = useState("desc"); const [sortOrder, setSortOrder] = useState("desc");
useEffect(() => { useEffect(() => {
loadQuestions(); loadQuestions();
loadCategories(); loadCategories();
}, [searchTerm, filterCategory, filterType, sortBy, sortOrder]); }, [searchTerm, filterCategory, filterType, filterOwner, sortBy, sortOrder]);
const loadQuestions = async () => { const loadQuestions = async () => {
try { try {
@@ -50,6 +55,7 @@ export default function QuestionBankView() {
if (searchTerm) params.search = searchTerm; if (searchTerm) params.search = searchTerm;
if (filterCategory) params.category = filterCategory; if (filterCategory) params.category = filterCategory;
if (filterType) params.type = filterType; if (filterType) params.type = filterType;
if (filterOwner) params.owner = filterOwner;
const response = await questionsAPI.getAll(params); const response = await questionsAPI.getAll(params);
setQuestions(response.data); setQuestions(response.data);
@@ -688,13 +694,32 @@ export default function QuestionBankView() {
</select> </select>
</div> </div>
{/* Owner Filter */}
<div>
<select
value={filterOwner}
onChange={(e) => setFilterOwner(e.target.value)}
style={{
padding: "0.5rem",
border: "1px solid #ddd",
borderRadius: "4px",
minWidth: "130px",
}}
>
<option value="">All Questions</option>
<option value="mine">My Questions</option>
<option value="shared">Shared with Me</option>
</select>
</div>
{/* Clear Filters */} {/* Clear Filters */}
{(searchTerm || filterCategory || filterType || sortBy !== "created_at" || sortOrder !== "desc") && ( {(searchTerm || filterCategory || filterType || filterOwner || sortBy !== "created_at" || sortOrder !== "desc") && (
<button <button
onClick={() => { onClick={() => {
setSearchTerm(""); setSearchTerm("");
setFilterCategory(""); setFilterCategory("");
setFilterType(""); setFilterType("");
setFilterOwner("");
setSortBy("created_at"); setSortBy("created_at");
setSortOrder("desc"); setSortOrder("desc");
}} }}
@@ -742,6 +767,19 @@ export default function QuestionBankView() {
> >
Assign Category Assign Category
</button> </button>
<button
onClick={() => setShareTarget(selectedQuestions)}
style={{
padding: "0.5rem 1rem",
background: "#9C27B0",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
}}
>
Share Selected
</button>
<button <button
onClick={handleBulkDelete} onClick={handleBulkDelete}
style={{ style={{
@@ -880,12 +918,21 @@ export default function QuestionBankView() {
Answer Answer
<SortIndicator column="answer" /> <SortIndicator column="answer" />
</th> </th>
<th
style={{
...sortableHeaderStyle,
width: "100px",
cursor: "default",
}}
>
Creator
</th>
<th <th
style={{ style={{
padding: "0.75rem", padding: "0.75rem",
textAlign: "center", textAlign: "center",
borderBottom: "2px solid #ddd", borderBottom: "2px solid #ddd",
width: "150px", width: "200px",
}} }}
> >
Actions Actions
@@ -954,6 +1001,16 @@ export default function QuestionBankView() {
<td style={{ padding: "0.75rem", fontWeight: "bold" }}> <td style={{ padding: "0.75rem", fontWeight: "bold" }}>
{q.answer} {q.answer}
</td> </td>
<td style={{ padding: "0.75rem" }}>
<span
style={{
fontSize: "0.8rem",
color: q.created_by === dbUser?.id ? "#1976d2" : "#666",
}}
>
{q.created_by === dbUser?.id ? "You" : (q.creator_name || "Unknown")}
</span>
</td>
<td style={{ padding: "0.75rem", textAlign: "center" }}> <td style={{ padding: "0.75rem", textAlign: "center" }}>
<div <div
style={{ style={{
@@ -962,6 +1019,8 @@ export default function QuestionBankView() {
justifyContent: "center", justifyContent: "center",
}} }}
> >
{q.created_by === dbUser?.id && (
<>
<button <button
onClick={() => handleEdit(q)} onClick={() => handleEdit(q)}
style={{ style={{
@@ -976,6 +1035,20 @@ export default function QuestionBankView() {
> >
Edit Edit
</button> </button>
<button
onClick={() => setShareTarget(q)}
style={{
padding: "0.4rem 0.8rem",
background: "#9C27B0",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "0.9rem",
}}
>
Share
</button>
<button <button
onClick={() => handleDelete(q.id)} onClick={() => handleDelete(q.id)}
style={{ style={{
@@ -990,6 +1063,8 @@ export default function QuestionBankView() {
> >
Delete Delete
</button> </button>
</>
)}
</div> </div>
</td> </td>
</tr> </tr>
@@ -1045,6 +1120,13 @@ export default function QuestionBankView() {
</div> </div>
)} )}
</div> </div>
{shareTarget && (
<ShareDialog
target={shareTarget}
onClose={() => setShareTarget(null)}
/>
)}
</> </>
); );
} }

View File

@@ -0,0 +1,252 @@
import { useState, useEffect, useRef } from "react";
import { questionsAPI, usersAPI } from "../../services/api";
export default function ShareDialog({ target, onClose }) {
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState([]);
const [currentShares, setCurrentShares] = useState([]);
const [isSearching, setIsSearching] = useState(false);
const [message, setMessage] = useState(null);
const searchTimeout = useRef(null);
// Determine if this is a single question or bulk share
const isBulk = Array.isArray(target);
const questionId = isBulk ? null : target.id;
// Load current shares for single question
useEffect(() => {
if (!isBulk && questionId) {
loadShares();
}
}, [questionId, isBulk]);
const loadShares = async () => {
try {
const response = await questionsAPI.getShares(questionId);
setCurrentShares(response.data);
} catch (error) {
console.error("Error loading shares:", error);
}
};
const handleSearchChange = (value) => {
setSearchQuery(value);
if (searchTimeout.current) clearTimeout(searchTimeout.current);
if (value.trim().length === 0) {
setSearchResults([]);
return;
}
searchTimeout.current = setTimeout(async () => {
setIsSearching(true);
try {
const response = await usersAPI.search(value.trim());
setSearchResults(response.data);
} catch (error) {
console.error("Error searching users:", error);
}
setIsSearching(false);
}, 300);
};
const handleShare = async (userId) => {
try {
if (isBulk) {
const response = await questionsAPI.bulkShare(target, userId);
const { shared, skipped } = response.data;
setMessage(`Shared ${shared.length} question(s). ${skipped.length > 0 ? `${skipped.length} skipped.` : ""}`);
} else {
await questionsAPI.share(questionId, userId);
setMessage("Question shared successfully.");
loadShares();
}
setSearchQuery("");
setSearchResults([]);
} catch (error) {
const msg = error.response?.data?.error || "Error sharing question";
setMessage(msg);
}
};
const handleUnshare = async (userId) => {
try {
await questionsAPI.unshare(questionId, userId);
loadShares();
setMessage("Share removed.");
} catch (error) {
console.error("Error removing share:", error);
}
};
const overlayStyle = {
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 2000,
};
const dialogStyle = {
background: "white",
borderRadius: "12px",
padding: "1.5rem",
width: "450px",
maxHeight: "80vh",
overflowY: "auto",
boxShadow: "0 8px 32px rgba(0,0,0,0.2)",
};
return (
<div style={overlayStyle} onClick={onClose}>
<div style={dialogStyle} onClick={(e) => e.stopPropagation()}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
<h3 style={{ margin: 0 }}>
{isBulk ? `Share ${target.length} Question(s)` : "Share Question"}
</h3>
<button
onClick={onClose}
style={{
background: "none",
border: "none",
fontSize: "1.5rem",
cursor: "pointer",
color: "#666",
}}
>
x
</button>
</div>
{!isBulk && (
<p style={{ fontSize: "0.9rem", color: "#666", margin: "0 0 1rem 0" }}>
{target.question_content?.substring(0, 80)}{target.question_content?.length > 80 ? "..." : ""}
</p>
)}
{/* User search */}
<div style={{ marginBottom: "1rem" }}>
<input
type="text"
placeholder="Search users by name, username, or email..."
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
style={{
width: "100%",
padding: "0.6rem",
border: "1px solid #ddd",
borderRadius: "6px",
fontSize: "0.95rem",
boxSizing: "border-box",
}}
autoFocus
/>
</div>
{/* Search results */}
{searchResults.length > 0 && (
<div style={{ marginBottom: "1rem", border: "1px solid #eee", borderRadius: "6px", overflow: "hidden" }}>
{searchResults.map((user) => {
const alreadyShared = currentShares.some(s => s.shared_with_user_id === user.id);
return (
<div
key={user.id}
style={{
padding: "0.6rem 0.8rem",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
borderBottom: "1px solid #f0f0f0",
}}
>
<div>
<div style={{ fontWeight: 500 }}>{user.name || user.username || "Unknown"}</div>
{user.email && (
<div style={{ fontSize: "0.8rem", color: "#888" }}>{user.email}</div>
)}
</div>
{alreadyShared ? (
<span style={{ fontSize: "0.85rem", color: "#4CAF50" }}>Shared</span>
) : (
<button
onClick={() => handleShare(user.id)}
style={{
padding: "0.3rem 0.8rem",
background: "#9C27B0",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "0.85rem",
}}
>
Share
</button>
)}
</div>
);
})}
</div>
)}
{isSearching && (
<p style={{ fontSize: "0.9rem", color: "#888", textAlign: "center" }}>Searching...</p>
)}
{/* Current shares (single question only) */}
{!isBulk && currentShares.length > 0 && (
<div>
<h4 style={{ margin: "0 0 0.5rem 0", fontSize: "0.95rem" }}>Shared with</h4>
{currentShares.map((share) => (
<div
key={share.id}
style={{
padding: "0.5rem 0.8rem",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
background: "#f9f9f9",
borderRadius: "6px",
marginBottom: "0.4rem",
}}
>
<span>{share.shared_with_user_name || `User ${share.shared_with_user_id}`}</span>
<button
onClick={() => handleUnshare(share.shared_with_user_id)}
style={{
padding: "0.2rem 0.6rem",
background: "#f44336",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "0.8rem",
}}
>
Remove
</button>
</div>
))}
</div>
)}
{!isBulk && currentShares.length === 0 && searchResults.length === 0 && !isSearching && (
<p style={{ fontSize: "0.9rem", color: "#888", textAlign: "center" }}>
Not shared with anyone yet. Search for a user above.
</p>
)}
{message && (
<p style={{ fontSize: "0.9rem", color: "#4CAF50", textAlign: "center", margin: "0.5rem 0 0 0" }}>
{message}
</p>
)}
</div>
</div>
);
}

View File

@@ -1,14 +1,30 @@
import { createContext, useContext, useState, useEffect } from 'react'; import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { jwtDecode } from 'jwt-decode'; import { jwtDecode } from 'jwt-decode';
const AuthContext = createContext(null); const AuthContext = createContext(null);
export function AuthProvider({ children }) { export function AuthProvider({ children }) {
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [dbUser, setDbUser] = useState(null); // User record from DB (has .id for ownership checks)
const [isAuthenticated, setIsAuthenticated] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [accessToken, setAccessToken] = useState(null); const [accessToken, setAccessToken] = useState(null);
// Fetch the DB user record (includes numeric id for ownership comparisons)
const fetchDbUser = useCallback(async (idToken) => {
try {
const response = await fetch('/api/auth/me', {
headers: { 'Authorization': `Bearer ${idToken}` }
});
if (response.ok) {
const data = await response.json();
setDbUser(data);
}
} catch (error) {
console.error('Failed to fetch DB user:', error);
}
}, []);
useEffect(() => { useEffect(() => {
// Check if user is already logged in (token in localStorage) // Check if user is already logged in (token in localStorage)
const storedToken = localStorage.getItem('access_token'); const storedToken = localStorage.getItem('access_token');
@@ -28,6 +44,7 @@ export function AuthProvider({ children }) {
setAccessToken(storedToken); setAccessToken(storedToken);
setUser({ profile: decoded }); setUser({ profile: decoded });
setIsAuthenticated(true); setIsAuthenticated(true);
fetchDbUser(storedIdToken);
} else { } else {
// Token expired, clear storage // Token expired, clear storage
console.log('AuthContext: Tokens expired'); console.log('AuthContext: Tokens expired');
@@ -43,7 +60,7 @@ export function AuthProvider({ children }) {
console.log('AuthContext: No tokens found in storage'); console.log('AuthContext: No tokens found in storage');
} }
setIsLoading(false); setIsLoading(false);
}, []); }, [fetchDbUser]);
const login = () => { const login = () => {
// Redirect to backend login endpoint // Redirect to backend login endpoint
@@ -84,6 +101,7 @@ export function AuthProvider({ children }) {
setAccessToken(access_token); setAccessToken(access_token);
setUser({ profile: decoded }); setUser({ profile: decoded });
setIsAuthenticated(true); setIsAuthenticated(true);
fetchDbUser(id_token);
console.log('handleCallback: Auth state updated, isAuthenticated=true'); console.log('handleCallback: Auth state updated, isAuthenticated=true');
@@ -104,6 +122,7 @@ export function AuthProvider({ children }) {
setAccessToken(null); setAccessToken(null);
setUser(null); setUser(null);
setDbUser(null);
setIsAuthenticated(false); setIsAuthenticated(false);
// Redirect to backend logout to clear cookies and get Authelia logout URL // Redirect to backend logout to clear cookies and get Authelia logout URL
@@ -138,6 +157,7 @@ export function AuthProvider({ children }) {
const value = { const value = {
user, user,
dbUser,
isAuthenticated, isAuthenticated,
isLoading, isLoading,
accessToken, accessToken,

View File

@@ -66,6 +66,12 @@ export const questionsAPI = {
headers: { "Content-Type": "multipart/form-data" }, headers: { "Content-Type": "multipart/form-data" },
}), }),
delete: (id) => api.delete(`/questions/${id}`), delete: (id) => api.delete(`/questions/${id}`),
// Sharing
getShares: (id) => api.get(`/questions/${id}/shares`),
share: (id, userId) => api.post(`/questions/${id}/share`, { user_id: userId }),
unshare: (id, userId) => api.delete(`/questions/${id}/share/${userId}`),
bulkShare: (questionIds, userId) =>
api.post("/questions/bulk-share", { question_ids: questionIds, user_id: userId }),
}; };
// Games API // Games API
@@ -135,4 +141,9 @@ export const audioControlAPI = {
api.post(`/admin/game/${gameId}/audio/seek`, { position }), api.post(`/admin/game/${gameId}/audio/seek`, { position }),
}; };
// Users API (for sharing)
export const usersAPI = {
search: (query) => api.get("/auth/users/search", { params: { q: query } }),
};
export default api; export default api;

View File

@@ -0,0 +1,38 @@
"""Add turn tracking fields to Game model
Revision ID: 2937635f309a
Revises: 90b81e097444
Create Date: 2026-01-13 08:56:51.426585
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2937635f309a'
down_revision = '90b81e097444'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('games', schema=None) as batch_op:
batch_op.add_column(sa.Column('current_turn_team_id', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('turn_order', sa.JSON(), nullable=True))
batch_op.add_column(sa.Column('is_steal_mode', sa.Boolean(), nullable=True))
batch_op.create_foreign_key('fk_games_current_turn_team', 'teams', ['current_turn_team_id'], ['id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('games', schema=None) as batch_op:
batch_op.drop_constraint('fk_games_current_turn_team', type_='foreignkey')
batch_op.drop_column('is_steal_mode')
batch_op.drop_column('turn_order')
batch_op.drop_column('current_turn_team_id')
# ### end Alembic commands ###

View File

@@ -0,0 +1,59 @@
"""Add question ownership and sharing
Revision ID: 9a119272b516
Revises: d2113a61fa42
Create Date: 2026-04-03 09:32:53.890510
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import sqlite
# revision identifiers, used by Alembic.
revision = '9a119272b516'
down_revision = 'd2113a61fa42'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('question_shares',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('question_id', sa.Integer(), nullable=False),
sa.Column('shared_with_user_id', sa.Integer(), nullable=False),
sa.Column('shared_by_user_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['question_id'], ['questions.id'], ),
sa.ForeignKeyConstraint(['shared_by_user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['shared_with_user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('question_id', 'shared_with_user_id', name='unique_question_share')
)
with op.batch_alter_table('games', schema=None) as batch_op:
batch_op.add_column(sa.Column('completed_at', sa.DateTime(), nullable=True))
batch_op.add_column(sa.Column('winners', sa.JSON(), nullable=True))
batch_op.drop_column('turn_order')
batch_op.drop_column('is_steal_mode')
with op.batch_alter_table('questions', schema=None) as batch_op:
batch_op.add_column(sa.Column('created_by', sa.Integer(), nullable=True))
batch_op.create_foreign_key('fk_questions_created_by', 'users', ['created_by'], ['id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('questions', schema=None) as batch_op:
batch_op.drop_constraint('fk_questions_created_by', type_='foreignkey')
batch_op.drop_column('created_by')
with op.batch_alter_table('games', schema=None) as batch_op:
batch_op.add_column(sa.Column('is_steal_mode', sa.BOOLEAN(), nullable=True))
batch_op.add_column(sa.Column('turn_order', sqlite.JSON(), nullable=True))
batch_op.drop_column('winners')
batch_op.drop_column('completed_at')
op.drop_table('question_shares')
# ### end Alembic commands ###

View File

@@ -17,18 +17,10 @@ depends_on = None
def upgrade(): def upgrade():
# ### commands auto generated by Alembic - please adjust! ### # No-op: columns already added by 2937635f309a
with op.batch_alter_table('games', schema=None) as batch_op: pass
batch_op.add_column(sa.Column('current_turn_team_id', sa.Integer(), nullable=True))
batch_op.create_foreign_key('fk_games_current_turn_team_id', 'teams', ['current_turn_team_id'], ['id'])
# ### end Alembic commands ###
def downgrade(): def downgrade():
# ### commands auto generated by Alembic - please adjust! ### # No-op: columns managed by 2937635f309a
with op.batch_alter_table('games', schema=None) as batch_op: pass
batch_op.drop_constraint('fk_games_current_turn_team_id', type_='foreignkey')
batch_op.drop_column('current_turn_team_id')
# ### end Alembic commands ###

View File

@@ -0,0 +1,24 @@
"""Merge heads
Revision ID: d2113a61fa42
Revises: 2937635f309a, a1b2c3d4e5f6
Create Date: 2026-04-03 09:32:19.765953
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd2113a61fa42'
down_revision = ('2937635f309a', 'a1b2c3d4e5f6')
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass

View File

@@ -21,6 +21,7 @@ dependencies = [
"authlib>=1.3.0", "authlib>=1.3.0",
"cryptography>=42.0.0", "cryptography>=42.0.0",
"requests>=2.31.0", "requests>=2.31.0",
"beautifulsoup4>=4.14.3",
] ]
[project.optional-dependencies] [project.optional-dependencies]

24
uv.lock generated
View File

@@ -40,6 +40,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" }, { url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" },
] ]
[[package]]
name = "beautifulsoup4"
version = "4.14.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
]
[[package]] [[package]]
name = "bidict" name = "bidict"
version = "0.23.1" version = "0.23.1"
@@ -853,6 +866,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
] ]
[[package]]
name = "soupsieve"
version = "2.8.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
]
[[package]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "2.0.44" version = "2.0.44"
@@ -888,6 +910,7 @@ version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "authlib" }, { name = "authlib" },
{ name = "beautifulsoup4" },
{ name = "celery" }, { name = "celery" },
{ name = "cryptography" }, { name = "cryptography" },
{ name = "eventlet" }, { name = "eventlet" },
@@ -915,6 +938,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "authlib", specifier = ">=1.3.0" }, { name = "authlib", specifier = ">=1.3.0" },
{ name = "beautifulsoup4", specifier = ">=4.14.3" },
{ name = "celery", specifier = ">=5.3.0" }, { name = "celery", specifier = ">=5.3.0" },
{ name = "cryptography", specifier = ">=42.0.0" }, { name = "cryptography", specifier = ">=42.0.0" },
{ name = "eventlet", specifier = ">=0.36.0" }, { name = "eventlet", specifier = ">=0.36.0" },