Merge pull request 'Update logging level to INFO, refine chore update logic, and enhance invite acceptance flow' (#58) from ph4 into prod

Reviewed-on: #58
This commit is contained in:
mo 2025-06-07 22:09:00 +02:00
commit ef2caaee56
10 changed files with 626 additions and 2499 deletions

View File

@ -231,7 +231,7 @@ async def update_group_chore(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Chore's group_id if provided must match path group_id ({group_id}).") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Chore's group_id if provided must match path group_id ({group_id}).")
# Ensure chore_in has the correct type for the CRUD operation # Ensure chore_in has the correct type for the CRUD operation
chore_payload = chore_in.model_copy(update={"type": ChoreTypeEnum.group, "group_id": group_id} if chore_in.type is None else chore_in) chore_payload = chore_in.model_copy(update={"type": ChoreTypeEnum.group, "group_id": group_id} if chore_in.type is None else {"group_id": group_id})
try: try:
updated_chore = await crud_chore.update_chore(db=db, chore_id=chore_id, chore_in=chore_payload, user_id=current_user.id, group_id=group_id) updated_chore = await crud_chore.update_chore(db=db, chore_id=chore_id, chore_in=chore_payload, user_id=current_user.id, group_id=group_id)

View File

@ -8,6 +8,7 @@ from app.auth import current_active_user
from app.models import User as UserModel, UserRoleEnum from app.models import User as UserModel, UserRoleEnum
from app.schemas.invite import InviteAccept from app.schemas.invite import InviteAccept
from app.schemas.message import Message from app.schemas.message import Message
from app.schemas.group import GroupPublic
from app.crud import invite as crud_invite from app.crud import invite as crud_invite
from app.crud import group as crud_group from app.crud import group as crud_group
from app.core.exceptions import ( from app.core.exceptions import (
@ -16,7 +17,8 @@ from app.core.exceptions import (
InviteAlreadyUsedError, InviteAlreadyUsedError,
InviteCreationError, InviteCreationError,
GroupNotFoundError, GroupNotFoundError,
GroupMembershipError GroupMembershipError,
GroupOperationError
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -24,7 +26,7 @@ router = APIRouter()
@router.post( @router.post(
"/accept", # Route relative to prefix "/invites" "/accept", # Route relative to prefix "/invites"
response_model=Message, response_model=GroupPublic,
summary="Accept Group Invite", summary="Accept Group Invite",
tags=["Invites"] tags=["Invites"]
) )
@ -34,28 +36,19 @@ async def accept_invite(
current_user: UserModel = Depends(current_active_user), current_user: UserModel = Depends(current_active_user),
): ):
"""Accepts a group invite using the provided invite code.""" """Accepts a group invite using the provided invite code."""
logger.info(f"User {current_user.email} attempting to accept invite code: {invite_in.invite_code}") logger.info(f"User {current_user.email} attempting to accept invite code: {invite_in.code}")
# Get the invite # Get the invite - this function should only return valid, active invites
invite = await crud_invite.get_invite_by_code(db, invite_code=invite_in.invite_code) invite = await crud_invite.get_active_invite_by_code(db, code=invite_in.code)
if not invite: if not invite:
logger.warning(f"Invalid invite code attempted by user {current_user.email}: {invite_in.invite_code}") logger.warning(f"Invalid or inactive invite code attempted by user {current_user.email}: {invite_in.code}")
raise InviteNotFoundError(invite_in.invite_code) # We can use a more generic error or a specific one. InviteNotFound is reasonable.
raise InviteNotFoundError(invite_in.code)
# Check if invite is expired
if invite.is_expired():
logger.warning(f"Expired invite code attempted by user {current_user.email}: {invite_in.invite_code}")
raise InviteExpiredError(invite_in.invite_code)
# Check if invite has already been used
if invite.used_at:
logger.warning(f"Already used invite code attempted by user {current_user.email}: {invite_in.invite_code}")
raise InviteAlreadyUsedError(invite_in.invite_code)
# Check if group still exists # Check if group still exists
group = await crud_group.get_group_by_id(db, group_id=invite.group_id) group = await crud_group.get_group_by_id(db, group_id=invite.group_id)
if not group: if not group:
logger.error(f"Group {invite.group_id} not found for invite {invite_in.invite_code}") logger.error(f"Group {invite.group_id} not found for invite {invite_in.code}")
raise GroupNotFoundError(invite.group_id) raise GroupNotFoundError(invite.group_id)
# Check if user is already a member # Check if user is already a member
@ -64,11 +57,23 @@ async def accept_invite(
logger.warning(f"User {current_user.email} already a member of group {invite.group_id}") logger.warning(f"User {current_user.email} already a member of group {invite.group_id}")
raise GroupMembershipError(invite.group_id, "join (already a member)") raise GroupMembershipError(invite.group_id, "join (already a member)")
# Add user to group and mark invite as used # Add user to the group
success = await crud_invite.accept_invite(db, invite=invite, user_id=current_user.id) added_to_group = await crud_group.add_user_to_group(db, group_id=invite.group_id, user_id=current_user.id)
if not success: if not added_to_group:
logger.error(f"Failed to accept invite {invite_in.invite_code} for user {current_user.email}") logger.error(f"Failed to add user {current_user.email} to group {invite.group_id} during invite acceptance.")
raise InviteCreationError(invite.group_id) # This could be a race condition or other issue, treat as an operational error.
raise GroupOperationError("Failed to add user to group.")
logger.info(f"User {current_user.email} successfully joined group {invite.group_id} via invite {invite_in.invite_code}") # Deactivate the invite so it cannot be used again
return Message(detail="Successfully joined the group") await crud_invite.deactivate_invite(db, invite=invite)
logger.info(f"User {current_user.email} successfully joined group {invite.group_id} via invite {invite_in.code}")
# Re-fetch the group to get the updated member list
updated_group = await crud_group.get_group_by_id(db, group_id=invite.group_id)
if not updated_group:
# This should ideally not happen as we found it before
logger.error(f"Could not re-fetch group {invite.group_id} after user {current_user.email} joined.")
raise GroupNotFoundError(invite.group_id)
return updated_group

View File

@ -2,5 +2,5 @@
"$schema": "https://json.schemastore.org/prettierrc", "$schema": "https://json.schemastore.org/prettierrc",
"semi": false, "semi": false,
"singleQuote": true, "singleQuote": true,
"printWidth": 100 "printWidth": 150
} }

View File

@ -944,7 +944,7 @@ select.form-input {
max-width: 550px; max-width: 550px;
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
position: relative; position: relative;
/* overflow: hidden; */ overflow-y: scroll;
/* Can cause tooltip clipping */ /* Can cause tooltip clipping */
transform: scale(0.95) translateY(-20px); transform: scale(0.95) translateY(-20px);
transition: transform var(--transition-speed) var(--transition-ease-out); transition: transform var(--transition-speed) var(--transition-ease-out);

View File

@ -2,7 +2,7 @@
export const API_VERSION = 'v1' export const API_VERSION = 'v1'
// API Base URL // API Base URL
export const API_BASE_URL = (window as any).ENV?.VITE_API_URL || 'https://mitlistbe.mohamad.dev' export const API_BASE_URL = (window as any).ENV?.VITE_API_URL || 'http://localhost:8000'
// API Endpoints // API Endpoints
export const API_ENDPOINTS = { export const API_ENDPOINTS = {
@ -32,6 +32,8 @@ export const API_ENDPOINTS = {
LISTS: { LISTS: {
BASE: '/lists', BASE: '/lists',
BY_ID: (id: string) => `/lists/${id}`, BY_ID: (id: string) => `/lists/${id}`,
STATUS: (id: string) => `/lists/${id}/status`,
STATUSES: '/lists/statuses',
ITEMS: (listId: string) => `/lists/${listId}/items`, ITEMS: (listId: string) => `/lists/${listId}/items`,
ITEM: (listId: string, itemId: string) => `/lists/${listId}/items/${itemId}`, ITEM: (listId: string, itemId: string) => `/lists/${listId}/items/${itemId}`,
EXPENSES: (listId: string) => `/lists/${listId}/expenses`, EXPENSES: (listId: string) => `/lists/${listId}/expenses`,
@ -66,7 +68,7 @@ export const API_ENDPOINTS = {
INVITES: { INVITES: {
BASE: '/invites', BASE: '/invites',
BY_ID: (id: string) => `/invites/${id}`, BY_ID: (id: string) => `/invites/${id}`,
ACCEPT: (id: string) => `/invites/accept/${id}`, ACCEPT: '/invites/accept',
DECLINE: (id: string) => `/invites/decline/${id}`, DECLINE: (id: string) => `/invites/decline/${id}`,
REVOKE: (id: string) => `/invites/revoke/${id}`, REVOKE: (id: string) => `/invites/revoke/${id}`,
LIST: '/invites', LIST: '/invites',

View File

@ -90,95 +90,17 @@
}, },
"choresPage": { "choresPage": {
"title": "Chores", "title": "Chores",
"tabs": { "addChore": "+",
"overdue": "Overdue", "edit": "Edit",
"delete": "Delete",
"empty": {
"title": "No Chores Yet",
"message": "Get started by adding your first chore!",
"addFirstChore": "Add First Chore"
},
"today": "Today", "today": "Today",
"upcoming": "Upcoming", "completedToday": "Completed today",
"allPending": "All Pending", "completedOn": "Completed on {date}",
"completed": "Completed"
},
"viewToggle": {
"calendarLabel": "Calendar View",
"calendarText": "Calendar",
"listLabel": "List View",
"listText": "List"
},
"newChoreButtonLabel": "New Chore",
"newChoreButtonText": "New Chore",
"loadingState": {
"loadingChores": "Loading chores..."
},
"calendar": {
"prevMonthLabel": "Previous month",
"nextMonthLabel": "Next month",
"weekdays": {
"sun": "Sun",
"mon": "Mon",
"tue": "Tue",
"wed": "Wed",
"thu": "Thu",
"fri": "Fri",
"sat": "Sat"
},
"addChoreToDayLabel": "Add chore to this day",
"emptyState": "No chores to display for this period."
},
"listView": {
"choreTypePersonal": "Personal",
"choreTypeGroupFallback": "Group",
"completedDatePrefix": "Completed:",
"actions": {
"doneTitle": "Mark as Done",
"doneText": "Done",
"undoTitle": "Mark as Not Done",
"undoText": "Undo",
"editTitle": "Edit",
"editLabel": "Edit chore",
"editText": "Edit",
"deleteTitle": "Delete",
"deleteLabel": "Delete chore",
"deleteText": "Delete"
},
"emptyState": {
"message": "No chores in this view. Well done!",
"viewAllButton": "View All Pending"
}
},
"choreModal": {
"editTitle": "Edit Chore",
"newTitle": "New Chore",
"closeButtonLabel": "Close modal",
"nameLabel": "Name",
"namePlaceholder": "Enter chore name",
"typeLabel": "Type",
"typePersonal": "Personal",
"typeGroup": "Group",
"groupLabel": "Group",
"groupSelectDefault": "Select a group",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Add a description (optional)",
"frequencyLabel": "Frequency",
"intervalLabel": "Interval (days)",
"intervalPlaceholder": "e.g. 3",
"dueDateLabel": "Due Date",
"quickDueDateToday": "Today",
"quickDueDateTomorrow": "Tomorrow",
"quickDueDateNextWeek": "Next Week",
"cancelButton": "Cancel",
"saveButton": "Save"
},
"deleteDialog": {
"title": "Delete Chore",
"confirmationText": "Are you sure you want to delete this chore? This action cannot be undone.",
"deleteButton": "Delete"
},
"shortcutsModal": {
"title": "Keyboard Shortcuts",
"descNewChore": "New Chore",
"descToggleView": "Toggle View (List/Calendar)",
"descToggleShortcuts": "Show/Hide Shortcuts",
"descCloseModal": "Close any open Modal/Dialog"
},
"frequencyOptions": { "frequencyOptions": {
"oneTime": "One Time", "oneTime": "One Time",
"daily": "Daily", "daily": "Daily",
@ -186,34 +108,44 @@
"monthly": "Monthly", "monthly": "Monthly",
"custom": "Custom" "custom": "Custom"
}, },
"formatters": { "frequency": {
"noDueDate": "No due date", "customInterval": "Every {n} day | Every {n} days"
"dueToday": "Due Today", },
"dueTomorrow": "Due Tomorrow", "form": {
"overdueFull": "Overdue: {date}", "name": "Name",
"dueFull": "Due {date}", "description": "Description",
"invalidDate": "Invalid Date" "dueDate": "Due Date",
"frequency": "Frequency",
"interval": "Interval (days)",
"type": "Type",
"personal": "Personal",
"group": "Group",
"assignGroup": "Assign to Group",
"cancel": "Cancel",
"save": "Save Changes",
"create": "Create",
"editChore": "Edit Chore",
"createChore": "Create Chore"
},
"deleteConfirm": {
"title": "Confirm Deletion",
"message": "Really want to delete? This action cannot be undone.",
"cancel": "Cancel",
"delete": "Delete"
}, },
"notifications": { "notifications": {
"loadFailed": "Failed to load chores", "loadFailed": "Failed to load chores.",
"updateSuccess": "Chore '{name}' updated successfully", "loadGroupsFailed": "Failed to load groups.",
"createSuccess": "Chore '{name}' created successfully", "updateSuccess": "Chore updated successfully!",
"updateFailed": "Failed to update chore", "createSuccess": "Chore created successfully!",
"createFailed": "Failed to create chore", "saveFailed": "Failed to save the chore.",
"deleteSuccess": "Chore '{name}' deleted successfully", "deleteSuccess": "Chore deleted successfully.",
"deleteFailed": "Failed to delete chore", "deleteFailed": "Failed to delete chore.",
"markedDone": "{name} marked as done.", "completed": "Chore marked as complete!",
"markedNotDone": "{name} marked as not done.", "uncompleted": "Chore marked as incomplete.",
"statusUpdateFailed": "Failed to update chore status." "updateFailed": "Failed to update chore status.",
}, "createAssignmentFailed": "Failed to create assignment for chore."
"validation": { }
"nameRequired": "Chore name is required.",
"groupRequired": "Please select a group for group chores.",
"intervalRequired": "Custom interval must be at least 1 day.",
"dueDateRequired": "Due date is required.",
"invalidDueDate": "Invalid due date format."
},
"unsavedChangesConfirmation": "You have unsaved changes in the chore form. Are you sure you want to leave?"
}, },
"errorNotFoundPage": { "errorNotFoundPage": {
"errorCode": "404", "errorCode": "404",

File diff suppressed because it is too large Load Diff

View File

@ -302,7 +302,9 @@ const handleJoinGroup = async () => {
joinGroupFormError.value = null; joinGroupFormError.value = null;
joiningGroup.value = true; joiningGroup.value = true;
try { try {
const response = await apiClient.post(API_ENDPOINTS.INVITES.ACCEPT(inviteCodeToJoin.value)); const response = await apiClient.post(API_ENDPOINTS.INVITES.ACCEPT, {
code: inviteCodeToJoin.value.trim()
});
const joinedGroup = response.data as Group; // Adjust based on actual API response for joined group const joinedGroup = response.data as Group; // Adjust based on actual API response for joined group
if (joinedGroup && joinedGroup.id && joinedGroup.name) { if (joinedGroup && joinedGroup.id && joinedGroup.name) {
// Check if group already in list to prevent duplicates if API returns the group info // Check if group already in list to prevent duplicates if API returns the group info

View File

@ -34,7 +34,7 @@ export interface ChoreUpdate extends Partial<ChoreCreate> { }
export interface ChoreAssignment { export interface ChoreAssignment {
id: number id: number
chore_id: number chore_id: number
assigned_to_id: number assigned_to_user_id: number
assigned_by_id: number assigned_by_id: number
due_date: string due_date: string
is_complete: boolean is_complete: boolean
@ -47,7 +47,7 @@ export interface ChoreAssignment {
export interface ChoreAssignmentCreate { export interface ChoreAssignmentCreate {
chore_id: number chore_id: number
assigned_to_id: number assigned_to_user_id: number
due_date: string due_date: string
} }

View File

@ -20,7 +20,7 @@ const pwaOptions: Partial<VitePWAOptions> = {
name: 'mitlist', name: 'mitlist',
short_name: 'mitlist', short_name: 'mitlist',
description: 'mitlist pwa', description: 'mitlist pwa',
theme_color: '#ff7b54', theme_color: '#fff8f0',
background_color: '#f3f3f3', background_color: '#f3f3f3',
display: 'standalone', display: 'standalone',
orientation: 'portrait', orientation: 'portrait',