From 69992f1be93edf5e97bc9c3687cb3cedf271469d Mon Sep 17 00:00:00 2001 From: Ryan Chen Date: Fri, 3 Apr 2026 09:43:04 -0400 Subject: [PATCH] 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 --- backend/models.py | 45 +++- backend/routes/auth.py | 31 +++ backend/routes/export_import.py | 10 +- backend/routes/questions.py | 199 +++++++++++++- backend/services/export_import_service.py | 28 +- .../questionbank/QuestionBankView.jsx | 144 +++++++--- .../components/questionbank/ShareDialog.jsx | 252 ++++++++++++++++++ .../frontend/src/contexts/AuthContext.jsx | 24 +- frontend/frontend/src/services/api.js | 11 + ..._add_turn_tracking_fields_to_game_model.py | 38 +++ ...b516_add_question_ownership_and_sharing.py | 59 ++++ ...4e5f6_add_current_turn_team_id_to_games.py | 16 +- .../versions/d2113a61fa42_merge_heads.py | 24 ++ pyproject.toml | 1 + uv.lock | 24 ++ 15 files changed, 836 insertions(+), 70 deletions(-) create mode 100644 frontend/frontend/src/components/questionbank/ShareDialog.jsx create mode 100644 migrations/versions/2937635f309a_add_turn_tracking_fields_to_game_model.py create mode 100644 migrations/versions/9a119272b516_add_question_ownership_and_sharing.py create mode 100644 migrations/versions/d2113a61fa42_merge_heads.py diff --git a/backend/models.py b/backend/models.py index 1399a3c..866abed 100644 --- a/backend/models.py +++ b/backend/models.py @@ -80,9 +80,22 @@ class Question(db.Model): end_time = db.Column(db.Integer, nullable=True) # End time in seconds created_at = db.Column(db.DateTime, default=datetime.utcnow) + created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # Relationships + creator = db.relationship('User', backref='questions') 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): """Convert question to dictionary""" @@ -96,13 +109,43 @@ class Question(db.Model): 'start_time': self.start_time, 'end_time': self.end_time, '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: data['answer'] = self.answer 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): """Game model representing a trivia game session""" __tablename__ = 'games' diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 2eb6b9e..03a74c6 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -122,3 +122,34 @@ def logout(): def get_current_user(): """Get current user info (requires auth)""" 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 diff --git a/backend/routes/export_import.py b/backend/routes/export_import.py index d2d3780..b1bafa3 100644 --- a/backend/routes/export_import.py +++ b/backend/routes/export_import.py @@ -1,7 +1,7 @@ """Routes for exporting and importing trivia questions""" import os import tempfile -from flask import Blueprint, jsonify, Response, request +from flask import Blueprint, jsonify, Response, request, g from werkzeug.utils import secure_filename from backend.auth.middleware import require_auth @@ -23,8 +23,8 @@ def export_data(): ZIP file containing manifest.json and all media files """ try: - # Generate export ZIP - zip_bytes, zip_filename = export_questions_to_zip() + # Generate export ZIP (only questions visible to current user) + zip_bytes, zip_filename = export_questions_to_zip(user_id=g.current_user.id) # Create response with ZIP file response = Response( @@ -82,8 +82,8 @@ def import_data(): file_size = os.path.getsize(temp_path) print(f"Saved file size: {file_size} bytes") - # Import from ZIP - result = import_questions_from_zip(temp_path) + # Import from ZIP (set ownership to current user) + result = import_questions_from_zip(temp_path, user_id=g.current_user.id) return jsonify(result), 200 diff --git a/backend/routes/questions.py b/backend/routes/questions.py index 3bc1481..2a51aa5 100644 --- a/backend/routes/questions.py +++ b/backend/routes/questions.py @@ -1,13 +1,28 @@ -from flask import Blueprint, request, jsonify, current_app -from backend.models import db, Question, QuestionType +from flask import Blueprint, request, jsonify, current_app, g +from backend.models import db, Question, QuestionType, QuestionShare, User 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') +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']) +@require_auth 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: - 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) - sort_by: Field to sort by (created_at, category, type, question_content, answer) - 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 = request.args.get('search', '').strip() @@ -70,13 +93,17 @@ def list_questions(): @bp.route('/', methods=['GET']) +@require_auth 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) + 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 @bp.route('', methods=['POST']) +@require_auth def create_question(): """Create a new question""" try: @@ -171,7 +198,8 @@ def create_question(): audio_path=audio_path, # Will be None initially for YouTube start_time=start_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) @@ -207,10 +235,14 @@ def create_question(): @bp.route('/', methods=['PUT']) +@require_auth def update_question(question_id): - """Update an existing question""" + """Update an existing question (owner only)""" 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: # Handle multipart form data or JSON if request.content_type and 'multipart/form-data' in request.content_type: @@ -268,10 +300,14 @@ def update_question(question_id): @bp.route('/', methods=['DELETE']) +@require_auth def delete_question(question_id): - """Delete a question""" + """Delete a question (owner only)""" 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: # Delete associated image if exists if question.image_path: @@ -293,8 +329,9 @@ def delete_question(question_id): @bp.route('/random', methods=['GET']) +@require_auth def get_random_questions(): - """Get random questions by category + """Get random questions by category (only from visible questions) Query parameters: - category: Category name (required) @@ -312,8 +349,8 @@ def get_random_questions(): return jsonify({'error': 'count must be a valid integer'}), 400 try: - # Get all questions for the category - questions = Question.query.filter_by(category=category).all() + # Get visible questions for the category + questions = _visible_questions_query().filter_by(category=category).all() if not questions: return jsonify({'error': f'No questions found for category: {category}'}), 404 @@ -334,6 +371,7 @@ def get_random_questions(): @bp.route('/bulk', methods=['POST']) +@require_auth def bulk_create_questions(): """Bulk create questions @@ -377,7 +415,8 @@ def bulk_create_questions(): question_content=q_data['question_content'], answer=q_data['answer'], 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) @@ -398,3 +437,139 @@ def bulk_create_questions(): except Exception as e: db.session.rollback() return jsonify({'error': str(e)}), 500 + + +# --- Sharing endpoints --- + +@bp.route('//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('//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('//share/', 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 diff --git a/backend/services/export_import_service.py b/backend/services/export_import_service.py index ce5ff30..e8a5404 100644 --- a/backend/services/export_import_service.py +++ b/backend/services/export_import_service.py @@ -9,18 +9,30 @@ from pathlib import Path from typing import Tuple, Optional 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: Tuple of (zip_bytes, filename) """ - # Query all data - questions = Question.query.order_by(Question.created_at).all() + # 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() categories = Category.query.order_by(Category.name).all() # Create temporary directory @@ -279,12 +291,13 @@ def clear_all_media_files(): 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. Args: zip_path: Path to ZIP file to import + user_id: If provided, set created_by on all imported questions Returns: Dict with import summary @@ -373,7 +386,8 @@ def import_questions_from_zip(zip_path: str) -> dict: audio_path=audio_path, youtube_url=q_data.get('youtube_url'), 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 diff --git a/frontend/frontend/src/components/questionbank/QuestionBankView.jsx b/frontend/frontend/src/components/questionbank/QuestionBankView.jsx index 0508820..a16603b 100644 --- a/frontend/frontend/src/components/questionbank/QuestionBankView.jsx +++ b/frontend/frontend/src/components/questionbank/QuestionBankView.jsx @@ -6,8 +6,11 @@ import { downloadJobsAPI, } from "../../services/api"; import AdminNavbar from "../common/AdminNavbar"; +import ShareDialog from "./ShareDialog"; +import { useAuth } from "../../contexts/AuthContext"; export default function QuestionBankView() { + const { dbUser } = useAuth(); const [questions, setQuestions] = useState([]); const [categories, setCategories] = useState([]); const [showForm, setShowForm] = useState(false); @@ -28,18 +31,20 @@ export default function QuestionBankView() { const [bulkCategory, setBulkCategory] = useState(""); const [downloadJob, setDownloadJob] = useState(null); const [downloadProgress, setDownloadProgress] = useState(0); + const [shareTarget, setShareTarget] = useState(null); // question or array of ids for sharing // Filter and sort state const [searchTerm, setSearchTerm] = useState(""); const [filterCategory, setFilterCategory] = useState(""); const [filterType, setFilterType] = useState(""); + const [filterOwner, setFilterOwner] = useState(""); const [sortBy, setSortBy] = useState("created_at"); const [sortOrder, setSortOrder] = useState("desc"); useEffect(() => { loadQuestions(); loadCategories(); - }, [searchTerm, filterCategory, filterType, sortBy, sortOrder]); + }, [searchTerm, filterCategory, filterType, filterOwner, sortBy, sortOrder]); const loadQuestions = async () => { try { @@ -50,6 +55,7 @@ export default function QuestionBankView() { if (searchTerm) params.search = searchTerm; if (filterCategory) params.category = filterCategory; if (filterType) params.type = filterType; + if (filterOwner) params.owner = filterOwner; const response = await questionsAPI.getAll(params); setQuestions(response.data); @@ -688,13 +694,32 @@ export default function QuestionBankView() { + {/* Owner Filter */} +
+ +
+ {/* Clear Filters */} - {(searchTerm || filterCategory || filterType || sortBy !== "created_at" || sortOrder !== "desc") && ( + {(searchTerm || filterCategory || filterType || filterOwner || sortBy !== "created_at" || sortOrder !== "desc") && ( + - + {q.created_by === dbUser?.id && ( + <> + + + + + )} @@ -1045,6 +1120,13 @@ export default function QuestionBankView() { )} + + {shareTarget && ( + setShareTarget(null)} + /> + )} ); } diff --git a/frontend/frontend/src/components/questionbank/ShareDialog.jsx b/frontend/frontend/src/components/questionbank/ShareDialog.jsx new file mode 100644 index 0000000..8082237 --- /dev/null +++ b/frontend/frontend/src/components/questionbank/ShareDialog.jsx @@ -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 ( +
+
e.stopPropagation()}> +
+

+ {isBulk ? `Share ${target.length} Question(s)` : "Share Question"} +

+ +
+ + {!isBulk && ( +

+ {target.question_content?.substring(0, 80)}{target.question_content?.length > 80 ? "..." : ""} +

+ )} + + {/* User search */} +
+ handleSearchChange(e.target.value)} + style={{ + width: "100%", + padding: "0.6rem", + border: "1px solid #ddd", + borderRadius: "6px", + fontSize: "0.95rem", + boxSizing: "border-box", + }} + autoFocus + /> +
+ + {/* Search results */} + {searchResults.length > 0 && ( +
+ {searchResults.map((user) => { + const alreadyShared = currentShares.some(s => s.shared_with_user_id === user.id); + return ( +
+
+
{user.name || user.username || "Unknown"}
+ {user.email && ( +
{user.email}
+ )} +
+ {alreadyShared ? ( + Shared + ) : ( + + )} +
+ ); + })} +
+ )} + + {isSearching && ( +

Searching...

+ )} + + {/* Current shares (single question only) */} + {!isBulk && currentShares.length > 0 && ( +
+

Shared with

+ {currentShares.map((share) => ( +
+ {share.shared_with_user_name || `User ${share.shared_with_user_id}`} + +
+ ))} +
+ )} + + {!isBulk && currentShares.length === 0 && searchResults.length === 0 && !isSearching && ( +

+ Not shared with anyone yet. Search for a user above. +

+ )} + + {message && ( +

+ {message} +

+ )} +
+
+ ); +} diff --git a/frontend/frontend/src/contexts/AuthContext.jsx b/frontend/frontend/src/contexts/AuthContext.jsx index 4d3dc1c..e59d34a 100644 --- a/frontend/frontend/src/contexts/AuthContext.jsx +++ b/frontend/frontend/src/contexts/AuthContext.jsx @@ -1,14 +1,30 @@ -import { createContext, useContext, useState, useEffect } from 'react'; +import { createContext, useContext, useState, useEffect, useCallback } from 'react'; import { jwtDecode } from 'jwt-decode'; const AuthContext = createContext(null); export function AuthProvider({ children }) { 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 [isLoading, setIsLoading] = useState(true); 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(() => { // Check if user is already logged in (token in localStorage) const storedToken = localStorage.getItem('access_token'); @@ -28,6 +44,7 @@ export function AuthProvider({ children }) { setAccessToken(storedToken); setUser({ profile: decoded }); setIsAuthenticated(true); + fetchDbUser(storedIdToken); } else { // Token expired, clear storage console.log('AuthContext: Tokens expired'); @@ -43,7 +60,7 @@ export function AuthProvider({ children }) { console.log('AuthContext: No tokens found in storage'); } setIsLoading(false); - }, []); + }, [fetchDbUser]); const login = () => { // Redirect to backend login endpoint @@ -84,6 +101,7 @@ export function AuthProvider({ children }) { setAccessToken(access_token); setUser({ profile: decoded }); setIsAuthenticated(true); + fetchDbUser(id_token); console.log('handleCallback: Auth state updated, isAuthenticated=true'); @@ -104,6 +122,7 @@ export function AuthProvider({ children }) { setAccessToken(null); setUser(null); + setDbUser(null); setIsAuthenticated(false); // Redirect to backend logout to clear cookies and get Authelia logout URL @@ -138,6 +157,7 @@ export function AuthProvider({ children }) { const value = { user, + dbUser, isAuthenticated, isLoading, accessToken, diff --git a/frontend/frontend/src/services/api.js b/frontend/frontend/src/services/api.js index d5e5415..67748eb 100644 --- a/frontend/frontend/src/services/api.js +++ b/frontend/frontend/src/services/api.js @@ -66,6 +66,12 @@ export const questionsAPI = { headers: { "Content-Type": "multipart/form-data" }, }), 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 @@ -135,4 +141,9 @@ export const audioControlAPI = { 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; diff --git a/migrations/versions/2937635f309a_add_turn_tracking_fields_to_game_model.py b/migrations/versions/2937635f309a_add_turn_tracking_fields_to_game_model.py new file mode 100644 index 0000000..064d5d5 --- /dev/null +++ b/migrations/versions/2937635f309a_add_turn_tracking_fields_to_game_model.py @@ -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 ### diff --git a/migrations/versions/9a119272b516_add_question_ownership_and_sharing.py b/migrations/versions/9a119272b516_add_question_ownership_and_sharing.py new file mode 100644 index 0000000..bfe3f99 --- /dev/null +++ b/migrations/versions/9a119272b516_add_question_ownership_and_sharing.py @@ -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 ### diff --git a/migrations/versions/a1b2c3d4e5f6_add_current_turn_team_id_to_games.py b/migrations/versions/a1b2c3d4e5f6_add_current_turn_team_id_to_games.py index c001383..987afed 100644 --- a/migrations/versions/a1b2c3d4e5f6_add_current_turn_team_id_to_games.py +++ b/migrations/versions/a1b2c3d4e5f6_add_current_turn_team_id_to_games.py @@ -17,18 +17,10 @@ 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.create_foreign_key('fk_games_current_turn_team_id', 'teams', ['current_turn_team_id'], ['id']) - - # ### end Alembic commands ### + # No-op: columns already added by 2937635f309a + pass 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_id', type_='foreignkey') - batch_op.drop_column('current_turn_team_id') - - # ### end Alembic commands ### + # No-op: columns managed by 2937635f309a + pass diff --git a/migrations/versions/d2113a61fa42_merge_heads.py b/migrations/versions/d2113a61fa42_merge_heads.py new file mode 100644 index 0000000..3e37c6d --- /dev/null +++ b/migrations/versions/d2113a61fa42_merge_heads.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 896ac91..02bf644 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "authlib>=1.3.0", "cryptography>=42.0.0", "requests>=2.31.0", + "beautifulsoup4>=4.14.3", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index ceb3f71..effd804 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, ] +[[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]] name = "bidict" 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" }, ] +[[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]] name = "sqlalchemy" version = "2.0.44" @@ -888,6 +910,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "authlib" }, + { name = "beautifulsoup4" }, { name = "celery" }, { name = "cryptography" }, { name = "eventlet" }, @@ -915,6 +938,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "authlib", specifier = ">=1.3.0" }, + { name = "beautifulsoup4", specifier = ">=4.14.3" }, { name = "celery", specifier = ">=5.3.0" }, { name = "cryptography", specifier = ">=42.0.0" }, { name = "eventlet", specifier = ">=0.36.0" },