diff --git a/backend/alembic/versions/869cfd49ebd5_initial.py b/backend/alembic/versions/869cfd49ebd5_initial.py index e75e4f4..c67a692 100644 --- a/backend/alembic/versions/869cfd49ebd5_initial.py +++ b/backend/alembic/versions/869cfd49ebd5_initial.py @@ -38,7 +38,7 @@ def upgrade() -> None: _ = op.create_table( "project", sa.Column("project_id", sa.String(length=26), nullable=False), - sa.Column("name", sa.Text(), nullable=True), + sa.Column("name", sa.Text(), nullable=False), sa.Column("creator_user_id", sa.String(length=26), nullable=False), sa.Column( "created_at", diff --git a/backend/src/interview_helper/context_manager/database.py b/backend/src/interview_helper/context_manager/database.py index 13447ca..3a049b9 100644 --- a/backend/src/interview_helper/context_manager/database.py +++ b/backend/src/interview_helper/context_manager/database.py @@ -18,6 +18,7 @@ from dataclasses import dataclass import interview_helper.context_manager.models as models from ulid import ULID +import logging class PersistentDatabase: @@ -366,6 +367,7 @@ class ProjectListing(TypedDict): id: str name: str creator_name: str + creator_user_id: str created_at: str @@ -374,12 +376,13 @@ def get_all_projects(db: PersistentDatabase) -> Sequence[ProjectListing]: Gets all projects with creator name and creation date, sorted by creation date (descending) """ with db.begin() as conn: - rows: Sequence[tuple[str, str, str, DateTime]] = ( + rows: Sequence[tuple[str, str, str, str, DateTime]] = ( conn.execute( sa.select( models.Project.project_id, models.Project.name, models.User.full_name, + models.Project.creator_user_id, models.Project.created_at, ) .join( @@ -392,12 +395,13 @@ def get_all_projects(db: PersistentDatabase) -> Sequence[ProjectListing]: ) projects: list[ProjectListing] = [] - for project_id, project_name, creator_name, created_at in rows: + for project_id, project_name, creator_name, creator_user_id, created_at in rows: projects.append( { "id": project_id, "name": project_name, "creator_name": creator_name, + "creator_user_id": creator_user_id, "created_at": created_at.isoformat(), # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType] } ) @@ -433,6 +437,7 @@ def create_new_project( "id": project_id, "name": project_name, "creator_name": user.full_name, + "creator_user_id": str(user.user_id), "created_at": created_at.isoformat(), # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType] } @@ -449,6 +454,7 @@ def get_project_by_id( models.Project.project_id, models.Project.name, models.User.full_name, + models.Project.creator_user_id, models.Project.created_at, ) .join(models.User, models.Project.creator_user_id == models.User.user_id) @@ -458,16 +464,49 @@ def get_project_by_id( if result is None: return None - project_id_str, project_name, creator_name, created_at = result.tuple() + project_id_str, project_name, creator_name, creator_user_id, created_at = ( + result.tuple() + ) return { "id": project_id_str, "name": project_name, "creator_name": creator_name, + "creator_user_id": creator_user_id, "created_at": created_at.isoformat(), # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType] } +@dataclass +class ProjectCreatorInfo: + creator_user_id: UserId + name: str + + +def get_project_creator_and_name( + db: PersistentDatabase, project_id: ProjectId +) -> ProjectCreatorInfo | None: + """ + Gets the creator user ID and project name for a project. + Returns None if the project doesn't exist. + """ + with db.begin() as conn: + result = conn.execute( + sa.select(models.Project.creator_user_id, models.Project.name).where( + models.Project.project_id == str(project_id) + ) + ).one_or_none() + + if result is None: + return None + + creator_user_id_str, project_name = result.tuple() + return ProjectCreatorInfo( + creator_user_id=UserId.from_str(creator_user_id_str), + name=project_name, + ) + + class AnalysisRow(BaseModel): analysis_id: str text: str @@ -847,3 +886,90 @@ def get_session_sequence_number( ).scalar_one() return result # pyright: ignore[reportAny] + + +def get_project_session_count(db: PersistentDatabase, project_id: ProjectId) -> int: + """ + Gets the number of sessions for a project + """ + with db.begin() as conn: + result = conn.execute( + sa.select(sa.func.count(models.Session.session_id)).where( + models.Session.project_id == str(project_id) + ) + ).scalar_one() + + return int(result) + + +def delete_project( + db: PersistentDatabase, project_id: ProjectId, audio_recordings_dir: str +) -> None: + """ + Deletes a project and all related data including: + - AI analyses + - Transcriptions + - Sessions (and their audio files) + - The project itself + + Args: + db: The database instance + project_id: The project ID to delete + audio_recordings_dir: The directory where audio recordings are stored + + Note: + This function first commits all database deletes, then deletes audio files. + This ensures transaction safety - if the DB delete fails, files remain intact. + If file deletion fails after DB commit, at least the DB is consistent. + """ + recordings_path = Path(audio_recordings_dir) + + # Collect session IDs within transaction, then commit DB deletes before touching filesystem + with db.begin() as conn: + # Get all session IDs for this project to delete audio files later + session_ids_result = conn.execute( + sa.select(models.Session.session_id).where( + models.Session.project_id == str(project_id) + ) + ).all() + + session_ids: list[str] = [str(row[0]) for row in session_ids_result] # pyright: ignore[reportAny] + + # Delete AI analyses + _ = conn.execute( + sa.delete(models.AIAnalysis).where( + models.AIAnalysis.project_id == str(project_id) + ) + ) + + # Delete transcriptions + _ = conn.execute( + sa.delete(models.Transcription).where( + models.Transcription.project_id == str(project_id) + ) + ) + + # Delete sessions + _ = conn.execute( + sa.delete(models.Session).where( + models.Session.project_id == str(project_id) + ) + ) + + # Delete the project itself + _ = conn.execute( + sa.delete(models.Project).where( + models.Project.project_id == str(project_id) + ) + ) + # Transaction commits here when exiting the context manager + + # Now that DB deletes are committed, delete audio files from filesystem + for session_id in session_ids: + audio_file = recordings_path / f"recording-{session_id}.wav" + if audio_file.exists(): + try: + audio_file.unlink() + except OSError as e: + # Log the error but don't fail - DB is already consistent + logging.warning(f"Failed to delete audio file {audio_file}: {e}") diff --git a/backend/src/main.py b/backend/src/main.py index cd83a55..2ca9a59 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -54,6 +54,9 @@ get_project_by_id, get_all_transcripts, get_all_ai_analyses, + get_project_session_count, + delete_project, + get_project_creator_and_name, ) from interview_helper.context_manager.types import ProjectId, TranscriptId @@ -422,6 +425,25 @@ async def websocket_endpoint( logger.info(f"Closed session {context.session_id} for user {ticket.user_id}") +@app.get("/user/me") +async def get_current_user(token: Annotated[str, Depends(oidc_scheme)]): + """ + Returns the current user's information + """ + clean_token = token.removeprefix("Bearer ") + user_claims = verify_jwt_token(clean_token, jwks_client, CLIENT_ID, signing_algos) + + user_info = await get_user_info_from_oidc_provider(clean_token, userinfo_endpoint) + name = f"{user_info.given_name or ''} {user_info.family_name or ''}".strip() + user = get_or_add_user_by_oidc_id(session_manager.db, user_claims.sub, name) + + return { + "user_id": str(user.user_id), + "full_name": user.full_name, + "oidc_id": user.oidc_id, + } + + @app.get("/project") async def list_all_projects(token: Annotated[str, Depends(oidc_scheme)]): """ @@ -456,6 +478,75 @@ async def create_project( return new_project +@app.delete("/project/{project_id}") +async def delete_project_endpoint( + project_id: str, confirmed_name: str, token: Annotated[str, Depends(oidc_scheme)] +): + """ + Deletes a project and all associated data (sessions, transcriptions, audio files, questions). + Only the project creator can delete the project. + Requires confirmation by providing the exact project name. + """ + clean_token = token.removeprefix("Bearer ") + user_claims = verify_jwt_token(clean_token, jwks_client, CLIENT_ID, signing_algos) + + # Get user info + user_info = await get_user_info_from_oidc_provider(clean_token, userinfo_endpoint) + name = f"{user_info.given_name or ''} {user_info.family_name or ''}".strip() + user_id = get_or_add_user_by_oidc_id( + session_manager.db, user_claims.sub, name + ).user_id + + # Verify project exists and get creator info + project_id_typed = ProjectId.from_str(project_id) + + project_info = get_project_creator_and_name(session_manager.db, project_id_typed) + if project_info is None: + raise HTTPException(status_code=404, detail="Project not found") + + # Check if user is the creator + if project_info.creator_user_id != user_id: + raise HTTPException( + status_code=403, detail="Only the project creator can delete this project" + ) + + # Verify the confirmed name matches + if confirmed_name != project_info.name: + raise HTTPException( + status_code=400, detail="Project name confirmation does not match" + ) + + # Delete the project and all related data + delete_project( + session_manager.db, + project_id_typed, + session_manager.get_settings().audio_recordings_dir, + ) + + return {"status": "success", "message": "Project deleted successfully"} + + +@app.get("/project/{project_id}/info") +async def get_project_info( + project_id: str, token: Annotated[str, Depends(oidc_scheme)] +): + """ + Gets project information including session count for delete confirmation + """ + clean_token = token.removeprefix("Bearer ") + _user_claims = verify_jwt_token(clean_token, jwks_client, CLIENT_ID, signing_algos) + + project_id_typed = ProjectId.from_str(project_id) + project = get_project_by_id(session_manager.db, project_id_typed) + + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + session_count = get_project_session_count(session_manager.db, project_id_typed) + + return {**project, "session_count": session_count} + + @app.get("/project/{project_id}/download/transcript") async def download_transcript( project_id: str, token: Annotated[str, Depends(oidc_scheme)] diff --git a/frontend/src/components/ProjectList.tsx b/frontend/src/components/ProjectList.tsx index e276d39..b913545 100644 --- a/frontend/src/components/ProjectList.tsx +++ b/frontend/src/components/ProjectList.tsx @@ -12,11 +12,21 @@ import { Title, Loader, Center, + ActionIcon, + Alert, + Menu, } from "@mantine/core"; +import { IconTrash, IconDots } from "@tabler/icons-react"; import { useNavigate } from "react-router-dom"; import { useAuth } from "react-oidc-context"; -import { fetchProjects, createProject } from "../lib/api"; -import type { ProjectListing } from "../lib/api"; +import { + fetchProjects, + createProject, + getProjectInfo, + deleteProject, + getCurrentUser, +} from "../lib/api"; +import type { ProjectListing, ProjectInfo, CurrentUser } from "../lib/api"; export default function ProjectList() { const [projects, setProjects] = useState([]); @@ -25,11 +35,22 @@ export default function ProjectList() { const [createModalOpen, setCreateModalOpen] = useState(false); const [newProjectName, setNewProjectName] = useState(""); const [creating, setCreating] = useState(false); + const [currentUser, setCurrentUser] = useState(null); + + // Delete modal state + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [projectToDelete, setProjectToDelete] = useState( + null, + ); + const [deleteConfirmName, setDeleteConfirmName] = useState(""); + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); + const navigate = useNavigate(); const auth = useAuth(); useEffect(() => { - const loadProjects = async () => { + const loadData = async () => { try { setLoading(true); setError(null); @@ -37,20 +58,23 @@ export default function ProjectList() { if (!token) { throw new Error("No access token available"); } - const data = await fetchProjects(token); - setProjects(data); + // Load both user info and projects + const [userData, projectsData] = await Promise.all([ + getCurrentUser(token), + fetchProjects(token), + ]); + setCurrentUser(userData); + setProjects(projectsData); } catch (err) { setError( - err instanceof Error - ? err.message - : "Failed to load projects", + err instanceof Error ? err.message : "Failed to load data", ); } finally { setLoading(false); } }; - loadProjects(); + loadData(); }, []); const handleCreateProject = async () => { @@ -84,6 +108,66 @@ export default function ProjectList() { navigate(`/project/${projectId}`); }; + const handleDeleteClick = async ( + e: React.MouseEvent, + project: ProjectListing, + ) => { + e.stopPropagation(); // Prevent card click + + try { + const token = auth.user?.access_token; + if (!token) { + throw new Error("No access token available"); + } + + // Fetch full project info including session count + const projectInfo = await getProjectInfo(project.id, token); + setProjectToDelete(projectInfo); + setDeleteModalOpen(true); + setDeleteConfirmName(""); + setDeleteError(null); + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Failed to load project info", + ); + } + }; + + const handleDeleteConfirm = async () => { + if (!projectToDelete) return; + + try { + setDeleting(true); + setDeleteError(null); + const token = auth.user?.access_token; + if (!token) { + throw new Error("No access token available"); + } + + await deleteProject(projectToDelete.id, deleteConfirmName, token); + + // Remove project from list + setProjects((prevProjects) => + prevProjects.filter((p) => p.id !== projectToDelete.id), + ); + + // Close modal + setDeleteModalOpen(false); + setProjectToDelete(null); + setDeleteConfirmName(""); + } catch (err) { + setDeleteError( + err instanceof Error ? err.message : "Failed to delete project", + ); + } finally { + setDeleting(false); + } + }; + + const isDeleteConfirmValid = deleteConfirmName === projectToDelete?.name; + if (loading) { return (
@@ -123,35 +207,87 @@ export default function ProjectList() { ) : ( - {projects.map((project) => ( - handleProjectClick(project.id)} - > - - - {project.name} - - - Created by {project.creator_name} - - - {new Date( - project.created_at, - ).toLocaleDateString("en-US", { - year: "numeric", - month: "long", - day: "numeric", - })} - - - - ))} + {projects.map((project) => { + const isOwnProject = + currentUser?.user_id === project.creator_user_id; + return ( + handleProjectClick(project.id)} + > + + + + {project.name} + + {isOwnProject && ( + + + + e.stopPropagation() + } + aria-label="Project options" + > + + + + + + } + onClick={(e) => + handleDeleteClick( + e, + project, + ) + } + > + Delete project + + + + )} + + + Created by {project.creator_name} + + + {new Date( + project.created_at, + ).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + })} + + + + ); + })} )} @@ -197,6 +333,78 @@ export default function ProjectList() { + + { + setDeleteModalOpen(false); + setProjectToDelete(null); + setDeleteConfirmName(""); + setDeleteError(null); + }} + title="Delete Project" + > + + {projectToDelete && ( + <> + + This action cannot be undone. This will + permanently delete the project{" "} + {projectToDelete.name} and all + associated data including: +
    +
  • + {projectToDelete.session_count} session + {projectToDelete.session_count !== 1 + ? "s" + : ""} +
  • +
  • All transcriptions
  • +
  • All audio recordings
  • +
  • All AI-generated questions
  • +
+
+ + + Please type{" "} + {projectToDelete.name} to + confirm deletion: + + + + setDeleteConfirmName(e.currentTarget.value) + } + error={deleteError} + /> + + + + + + + )} +
+
); } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 405d6da..34d976d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -4,9 +4,20 @@ export interface ProjectListing { id: string; name: string; creator_name: string; + creator_user_id: string; created_at: string; } +export interface CurrentUser { + user_id: string; + full_name: string; + oidc_id: string; +} + +export interface ProjectInfo extends ProjectListing { + session_count: number; +} + /** * Helper function to make authenticated API calls */ @@ -32,6 +43,13 @@ async function authenticatedFetch( return response.json(); } +/** + * Get current user information + */ +export async function getCurrentUser(token: string): Promise { + return authenticatedFetch("/user/me", token); +} + /** * Fetch all projects from the backend */ @@ -163,3 +181,59 @@ export async function downloadAudio( window.URL.revokeObjectURL(url); document.body.removeChild(a); } + +/** + * Get project info including session count + */ +export async function getProjectInfo( + projectId: string, + token: string, +): Promise { + return authenticatedFetch(`/project/${projectId}/info`, token); +} + +/** + * Delete a project + */ +export async function deleteProject( + projectId: string, + confirmedName: string, + token: string, +): Promise { + const response = await fetch( + `${BACKEND_URL}/project/${projectId}?confirmed_name=${encodeURIComponent(confirmedName)}`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + + if (!response.ok) { + let errorMessage = `Failed to delete project: ${response.status}`; + const contentType = response.headers.get("content-type") || ""; + + try { + if (contentType.includes("application/json")) { + const data = await response.json(); + if (data && typeof data === "object" && "detail" in data) { + errorMessage = + (data as { detail: string }).detail || errorMessage; + } else { + // Fallback: stringify JSON if no "detail" field is present + errorMessage = JSON.stringify(data); + } + } else { + const errorText = await response.text(); + if (errorText) { + errorMessage = errorText; + } + } + } catch { + // Ignore parsing errors and keep the default errorMessage + } + + throw new Error(errorMessage); + } +}