feat: normalize email handling in signup, login, and verification processes; refactor event handling in task and reward components
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 50s
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 50s
This commit is contained in:
@@ -14,6 +14,7 @@ from api.error_codes import MISSING_FIELDS, EMAIL_EXISTS, MISSING_TOKEN, INVALID
|
|||||||
TOKEN_EXPIRED, ALREADY_VERIFIED, MISSING_EMAIL, USER_NOT_FOUND, MISSING_EMAIL_OR_PASSWORD, INVALID_CREDENTIALS, \
|
TOKEN_EXPIRED, ALREADY_VERIFIED, MISSING_EMAIL, USER_NOT_FOUND, MISSING_EMAIL_OR_PASSWORD, INVALID_CREDENTIALS, \
|
||||||
NOT_VERIFIED
|
NOT_VERIFIED
|
||||||
from db.db import users_db
|
from db.db import users_db
|
||||||
|
from api.utils import normalize_email
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
auth_api = Blueprint('auth_api', __name__)
|
auth_api = Blueprint('auth_api', __name__)
|
||||||
@@ -34,8 +35,10 @@ def signup():
|
|||||||
required_fields = ['first_name', 'last_name', 'email', 'password']
|
required_fields = ['first_name', 'last_name', 'email', 'password']
|
||||||
if not all(field in data for field in required_fields):
|
if not all(field in data for field in required_fields):
|
||||||
return jsonify({'error': 'Missing required fields', 'code': MISSING_FIELDS}), 400
|
return jsonify({'error': 'Missing required fields', 'code': MISSING_FIELDS}), 400
|
||||||
|
email = data.get('email', '')
|
||||||
|
norm_email = normalize_email(email)
|
||||||
|
|
||||||
if users_db.search(UserQuery.email == data['email']):
|
if users_db.search(UserQuery.email == norm_email):
|
||||||
return jsonify({'error': 'Email already exists', 'code': EMAIL_EXISTS}), 400
|
return jsonify({'error': 'Email already exists', 'code': EMAIL_EXISTS}), 400
|
||||||
|
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
@@ -43,7 +46,7 @@ def signup():
|
|||||||
user = User(
|
user = User(
|
||||||
first_name=data['first_name'],
|
first_name=data['first_name'],
|
||||||
last_name=data['last_name'],
|
last_name=data['last_name'],
|
||||||
email=data['email'],
|
email=norm_email,
|
||||||
password=data['password'], # Hash in production!
|
password=data['password'], # Hash in production!
|
||||||
verified=False,
|
verified=False,
|
||||||
verify_token=token,
|
verify_token=token,
|
||||||
@@ -51,7 +54,7 @@ def signup():
|
|||||||
image_id="boy01"
|
image_id="boy01"
|
||||||
)
|
)
|
||||||
users_db.insert(user.to_dict())
|
users_db.insert(user.to_dict())
|
||||||
send_verification_email(data['email'], token)
|
send_verification_email(norm_email, token)
|
||||||
return jsonify({'message': 'User created, verification email sent'}), 201
|
return jsonify({'message': 'User created, verification email sent'}), 201
|
||||||
|
|
||||||
@auth_api.route('/verify', methods=['GET'])
|
@auth_api.route('/verify', methods=['GET'])
|
||||||
@@ -105,11 +108,12 @@ def verify():
|
|||||||
@auth_api.route('/resend-verify', methods=['POST'])
|
@auth_api.route('/resend-verify', methods=['POST'])
|
||||||
def resend_verify():
|
def resend_verify():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
email = data.get('email')
|
email = data.get('email', '')
|
||||||
if not email:
|
if not email:
|
||||||
return jsonify({'error': 'Missing email', 'code': MISSING_EMAIL}), 400
|
return jsonify({'error': 'Missing email', 'code': MISSING_EMAIL}), 400
|
||||||
|
norm_email = normalize_email(email)
|
||||||
|
|
||||||
user_dict = users_db.get(UserQuery.email == email)
|
user_dict = users_db.get(UserQuery.email == norm_email)
|
||||||
user = User.from_dict(user_dict) if user_dict else None
|
user = User.from_dict(user_dict) if user_dict else None
|
||||||
if not user:
|
if not user:
|
||||||
return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404
|
return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404
|
||||||
@@ -121,19 +125,20 @@ def resend_verify():
|
|||||||
now_iso = datetime.utcnow().isoformat()
|
now_iso = datetime.utcnow().isoformat()
|
||||||
user.verify_token = token
|
user.verify_token = token
|
||||||
user.verify_token_created = now_iso
|
user.verify_token_created = now_iso
|
||||||
users_db.update(user.to_dict(), UserQuery.email == email)
|
users_db.update(user.to_dict(), UserQuery.email == norm_email)
|
||||||
send_verification_email(email, token)
|
send_verification_email(norm_email, token)
|
||||||
return jsonify({'message': 'Verification email resent'}), 200
|
return jsonify({'message': 'Verification email resent'}), 200
|
||||||
|
|
||||||
@auth_api.route('/login', methods=['POST'])
|
@auth_api.route('/login', methods=['POST'])
|
||||||
def login():
|
def login():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
email = data.get('email')
|
email = data.get('email', '')
|
||||||
password = data.get('password')
|
password = data.get('password')
|
||||||
if not email or not password:
|
if not email or not password:
|
||||||
return jsonify({'error': 'Missing email or password', 'code': MISSING_EMAIL_OR_PASSWORD}), 400
|
return jsonify({'error': 'Missing email or password', 'code': MISSING_EMAIL_OR_PASSWORD}), 400
|
||||||
|
norm_email = normalize_email(email)
|
||||||
|
|
||||||
user_dict = users_db.get(UserQuery.email == email)
|
user_dict = users_db.get(UserQuery.email == norm_email)
|
||||||
user = User.from_dict(user_dict) if user_dict else None
|
user = User.from_dict(user_dict) if user_dict else None
|
||||||
if not user or user.password != password:
|
if not user or user.password != password:
|
||||||
return jsonify({'error': 'Invalid credentials', 'code': INVALID_CREDENTIALS}), 401
|
return jsonify({'error': 'Invalid credentials', 'code': INVALID_CREDENTIALS}), 401
|
||||||
@@ -142,7 +147,7 @@ def login():
|
|||||||
return jsonify({'error': 'This account has not verified', 'code': NOT_VERIFIED}), 403
|
return jsonify({'error': 'This account has not verified', 'code': NOT_VERIFIED}), 403
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
'email': email,
|
'email': norm_email,
|
||||||
'exp': datetime.utcnow() + timedelta(hours=24*7)
|
'exp': datetime.utcnow() + timedelta(hours=24*7)
|
||||||
}
|
}
|
||||||
token = jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
|
token = jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
|
||||||
@@ -179,21 +184,23 @@ def me():
|
|||||||
@auth_api.route('/request-password-reset', methods=['POST'])
|
@auth_api.route('/request-password-reset', methods=['POST'])
|
||||||
def request_password_reset():
|
def request_password_reset():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
email = data.get('email')
|
email = data.get('email', '')
|
||||||
|
norm_email = normalize_email(email)
|
||||||
|
|
||||||
success_msg = 'If this email is registered, you will receive a password reset link shortly.'
|
success_msg = 'If this email is registered, you will receive a password reset link shortly.'
|
||||||
|
|
||||||
if not email:
|
if not email:
|
||||||
return jsonify({'error': 'Missing email', 'code': MISSING_EMAIL}), 400
|
return jsonify({'error': 'Missing email', 'code': MISSING_EMAIL}), 400
|
||||||
|
|
||||||
user_dict = users_db.get(UserQuery.email == email)
|
user_dict = users_db.get(UserQuery.email == norm_email)
|
||||||
user = User.from_dict(user_dict) if user_dict else None
|
user = User.from_dict(user_dict) if user_dict else None
|
||||||
if user:
|
if user:
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
now_iso = datetime.utcnow().isoformat()
|
now_iso = datetime.utcnow().isoformat()
|
||||||
user.reset_token = token
|
user.reset_token = token
|
||||||
user.reset_token_created = now_iso
|
user.reset_token_created = now_iso
|
||||||
users_db.update(user.to_dict(), UserQuery.email == email)
|
users_db.update(user.to_dict(), UserQuery.email == norm_email)
|
||||||
send_reset_password_email(email, token)
|
send_reset_password_email(norm_email, token)
|
||||||
|
|
||||||
return jsonify({'message': success_msg}), 200
|
return jsonify({'message': success_msg}), 200
|
||||||
|
|
||||||
|
|||||||
@@ -401,9 +401,7 @@ def set_child_rewards(id):
|
|||||||
|
|
||||||
# Replace rewards with validated IDs
|
# Replace rewards with validated IDs
|
||||||
child_db.update({'rewards': valid_reward_ids}, ChildQuery.id == id)
|
child_db.update({'rewards': valid_reward_ids}, ChildQuery.id == id)
|
||||||
resp = send_event_for_current_user(Event(EventType.CHILD_REWARDS_SET.value, ChildRewardsSet(id, valid_reward_ids)))
|
send_event_for_current_user(Event(EventType.CHILD_REWARDS_SET.value, ChildRewardsSet(id, valid_reward_ids)))
|
||||||
if resp:
|
|
||||||
return resp
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'message': f'Rewards set for child {id}.',
|
'message': f'Rewards set for child {id}.',
|
||||||
'reward_ids': valid_reward_ids,
|
'reward_ids': valid_reward_ids,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from flask import Blueprint, request, jsonify
|
|||||||
from tinydb import Query
|
from tinydb import Query
|
||||||
|
|
||||||
from api.utils import send_event_for_current_user
|
from api.utils import send_event_for_current_user
|
||||||
|
from backend.events.types.child_rewards_set import ChildRewardsSet
|
||||||
from db.db import reward_db, child_db
|
from db.db import reward_db, child_db
|
||||||
from events.types.event import Event
|
from events.types.event import Event
|
||||||
from events.types.event_types import EventType
|
from events.types.event_types import EventType
|
||||||
@@ -22,10 +23,7 @@ def add_reward():
|
|||||||
return jsonify({'error': 'Name, description, and cost are required'}), 400
|
return jsonify({'error': 'Name, description, and cost are required'}), 400
|
||||||
reward = Reward(name=name, description=description, cost=cost, image_id=image)
|
reward = Reward(name=name, description=description, cost=cost, image_id=image)
|
||||||
reward_db.insert(reward.to_dict())
|
reward_db.insert(reward.to_dict())
|
||||||
resp = send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value,
|
send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value, RewardModified(reward.id, RewardModified.OPERATION_ADD)))
|
||||||
RewardModified(reward.id, RewardModified.OPERATION_ADD)))
|
|
||||||
if resp:
|
|
||||||
return resp
|
|
||||||
return jsonify({'message': f'Reward {name} added.'}), 201
|
return jsonify({'message': f'Reward {name} added.'}), 201
|
||||||
|
|
||||||
|
|
||||||
@@ -40,7 +38,14 @@ def get_reward(id):
|
|||||||
|
|
||||||
@reward_api.route('/reward/list', methods=['GET'])
|
@reward_api.route('/reward/list', methods=['GET'])
|
||||||
def list_rewards():
|
def list_rewards():
|
||||||
|
ids_param = request.args.get('ids')
|
||||||
rewards = reward_db.all()
|
rewards = reward_db.all()
|
||||||
|
if ids_param is not None:
|
||||||
|
if ids_param.strip() == '':
|
||||||
|
rewards = []
|
||||||
|
else:
|
||||||
|
ids = set(ids_param.split(','))
|
||||||
|
rewards = [reward for reward in rewards if reward.get('id') in ids]
|
||||||
return jsonify({'rewards': rewards}), 200
|
return jsonify({'rewards': rewards}), 200
|
||||||
|
|
||||||
@reward_api.route('/reward/<id>', methods=['DELETE'])
|
@reward_api.route('/reward/<id>', methods=['DELETE'])
|
||||||
@@ -55,10 +60,8 @@ def delete_reward(id):
|
|||||||
if id in rewards:
|
if id in rewards:
|
||||||
rewards.remove(id)
|
rewards.remove(id)
|
||||||
child_db.update({'rewards': rewards}, ChildQuery.id == child.get('id'))
|
child_db.update({'rewards': rewards}, ChildQuery.id == child.get('id'))
|
||||||
resp = send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value,
|
send_event_for_current_user(Event(EventType.CHILD_REWARD_SET.value, ChildRewardsSet(id, rewards)))
|
||||||
RewardModified(id, RewardModified.OPERATION_DELETE)))
|
send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value, RewardModified(id, RewardModified.OPERATION_DELETE)))
|
||||||
if resp:
|
|
||||||
return resp
|
|
||||||
return jsonify({'message': f'Reward {id} deleted.'}), 200
|
return jsonify({'message': f'Reward {id} deleted.'}), 200
|
||||||
return jsonify({'error': 'Reward not found'}), 404
|
return jsonify({'error': 'Reward not found'}), 404
|
||||||
|
|
||||||
@@ -100,9 +103,7 @@ def edit_reward(id):
|
|||||||
|
|
||||||
reward_db.update(updates, RewardQuery.id == id)
|
reward_db.update(updates, RewardQuery.id == id)
|
||||||
updated = reward_db.get(RewardQuery.id == id)
|
updated = reward_db.get(RewardQuery.id == id)
|
||||||
resp = send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value,
|
send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value,
|
||||||
RewardModified(id, RewardModified.OPERATION_EDIT)))
|
RewardModified(id, RewardModified.OPERATION_EDIT)))
|
||||||
if resp:
|
|
||||||
return resp
|
|
||||||
|
|
||||||
return jsonify(updated), 200
|
return jsonify(updated), 200
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from flask import Blueprint, request, jsonify
|
|||||||
from tinydb import Query
|
from tinydb import Query
|
||||||
|
|
||||||
from api.utils import send_event_for_current_user
|
from api.utils import send_event_for_current_user
|
||||||
|
from backend.events.types.child_tasks_set import ChildTasksSet
|
||||||
from db.db import task_db, child_db
|
from db.db import task_db, child_db
|
||||||
from events.types.event import Event
|
from events.types.event import Event
|
||||||
from events.types.event_types import EventType
|
from events.types.event_types import EventType
|
||||||
@@ -22,10 +23,8 @@ def add_task():
|
|||||||
return jsonify({'error': 'Name, points, and is_good are required'}), 400
|
return jsonify({'error': 'Name, points, and is_good are required'}), 400
|
||||||
task = Task(name=name, points=points, is_good=is_good, image_id=image)
|
task = Task(name=name, points=points, is_good=is_good, image_id=image)
|
||||||
task_db.insert(task.to_dict())
|
task_db.insert(task.to_dict())
|
||||||
resp = send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
|
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
|
||||||
TaskModified(task.id, TaskModified.OPERATION_ADD)))
|
TaskModified(task.id, TaskModified.OPERATION_ADD)))
|
||||||
if resp:
|
|
||||||
return resp
|
|
||||||
return jsonify({'message': f'Task {name} added.'}), 201
|
return jsonify({'message': f'Task {name} added.'}), 201
|
||||||
|
|
||||||
@task_api.route('/task/<id>', methods=['GET'])
|
@task_api.route('/task/<id>', methods=['GET'])
|
||||||
@@ -40,9 +39,12 @@ def get_task(id):
|
|||||||
def list_tasks():
|
def list_tasks():
|
||||||
ids_param = request.args.get('ids')
|
ids_param = request.args.get('ids')
|
||||||
tasks = task_db.all()
|
tasks = task_db.all()
|
||||||
if ids_param:
|
if ids_param is not None:
|
||||||
ids = set(ids_param.split(','))
|
if ids_param.strip() == '':
|
||||||
tasks = [task for task in tasks if task.get('id') in ids]
|
tasks = []
|
||||||
|
else:
|
||||||
|
ids = set(ids_param.split(','))
|
||||||
|
tasks = [task for task in tasks if task.get('id') in ids]
|
||||||
return jsonify({'tasks': tasks}), 200
|
return jsonify({'tasks': tasks}), 200
|
||||||
|
|
||||||
@task_api.route('/task/<id>', methods=['DELETE'])
|
@task_api.route('/task/<id>', methods=['DELETE'])
|
||||||
@@ -53,15 +55,12 @@ def delete_task(id):
|
|||||||
# remove the task id from any child's task list
|
# remove the task id from any child's task list
|
||||||
ChildQuery = Query()
|
ChildQuery = Query()
|
||||||
for child in child_db.all():
|
for child in child_db.all():
|
||||||
tasks = child.get('tasks', [])
|
child_tasks = child.get('tasks', [])
|
||||||
if id in tasks:
|
if id in child_tasks:
|
||||||
tasks.remove(id)
|
child_tasks.remove(id)
|
||||||
child_db.update({'tasks': tasks}, ChildQuery.id == child.get('id'))
|
child_db.update({'tasks': child_tasks}, ChildQuery.id == child.get('id'))
|
||||||
resp = send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
|
send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, child_tasks)))
|
||||||
TaskModified(id, TaskModified.OPERATION_DELETE)))
|
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, TaskModified(id, TaskModified.OPERATION_DELETE)))
|
||||||
if resp:
|
|
||||||
return resp
|
|
||||||
|
|
||||||
return jsonify({'message': f'Task {id} deleted.'}), 200
|
return jsonify({'message': f'Task {id} deleted.'}), 200
|
||||||
return jsonify({'error': 'Task not found'}), 404
|
return jsonify({'error': 'Task not found'}), 404
|
||||||
|
|
||||||
@@ -103,8 +102,6 @@ def edit_task(id):
|
|||||||
|
|
||||||
task_db.update(updates, TaskQuery.id == id)
|
task_db.update(updates, TaskQuery.id == id)
|
||||||
updated = task_db.get(TaskQuery.id == id)
|
updated = task_db.get(TaskQuery.id == id)
|
||||||
resp = send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
|
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
|
||||||
TaskModified(id, TaskModified.OPERATION_EDIT)))
|
TaskModified(id, TaskModified.OPERATION_EDIT)))
|
||||||
if resp:
|
|
||||||
return resp
|
|
||||||
return jsonify(updated), 200
|
return jsonify(updated), 200
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
import jwt
|
import jwt
|
||||||
|
import re
|
||||||
from flask import request, current_app, jsonify
|
from flask import request, current_app, jsonify
|
||||||
|
|
||||||
from events.sse import send_event_to_user
|
from events.sse import send_event_to_user
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_email(email: str) -> str:
|
||||||
|
"""Normalize email for uniqueness checks (Gmail: remove dots and +aliases)."""
|
||||||
|
email = email.strip().lower()
|
||||||
|
if '@' not in email:
|
||||||
|
return email
|
||||||
|
local, domain = email.split('@', 1)
|
||||||
|
if domain in ('gmail.com', 'googlemail.com'):
|
||||||
|
local = local.split('+', 1)[0].replace('.', '')
|
||||||
|
return f"{local}@{domain}"
|
||||||
|
|
||||||
def sanitize_email(email):
|
def sanitize_email(email):
|
||||||
return email.replace('@', '_at_').replace('.', '_dot_')
|
return email.replace('@', '_at_').replace('.', '_dot_')
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import ModalDialog from '../shared/ModalDialog.vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import ChildDetailCard from './ChildDetailCard.vue'
|
import ChildDetailCard from './ChildDetailCard.vue'
|
||||||
import ScrollingList from '../shared/ScrollingList.vue'
|
import ScrollingList from '../shared/ScrollingList.vue'
|
||||||
|
import StatusMessage from '../shared/StatusMessage.vue'
|
||||||
import { eventBus } from '@/common/eventBus'
|
import { eventBus } from '@/common/eventBus'
|
||||||
import '@/assets/view-shared.css'
|
import '@/assets/view-shared.css'
|
||||||
import '@/assets/styles.css'
|
import '@/assets/styles.css'
|
||||||
@@ -176,7 +177,6 @@ const triggerReward = (reward: RewardStatus) => {
|
|||||||
(reward.points_needed <= 0 ? '' : `, You still need ${reward.points_needed} points.`)
|
(reward.points_needed <= 0 ? '' : `, You still need ${reward.points_needed} points.`)
|
||||||
const utter = new window.SpeechSynthesisUtterance(utterString)
|
const utter = new window.SpeechSynthesisUtterance(utterString)
|
||||||
window.speechSynthesis.speak(utter)
|
window.speechSynthesis.speak(utter)
|
||||||
console.log('Reward data is:', reward)
|
|
||||||
if (reward.redeeming) {
|
if (reward.redeeming) {
|
||||||
dialogReward.value = reward
|
dialogReward.value = reward
|
||||||
showCancelDialog.value = true
|
showCancelDialog.value = true
|
||||||
@@ -405,90 +405,6 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="layout">
|
|
||||||
<div class="main">
|
|
||||||
<ChildDetailCard :child="child" />
|
|
||||||
<ScrollingList
|
|
||||||
title="Chores"
|
|
||||||
ref="childChoreListRef"
|
|
||||||
:fetchBaseUrl="`/api/task/list?ids=${tasks.join(',')}`"
|
|
||||||
:ids="tasks"
|
|
||||||
itemKey="tasks"
|
|
||||||
imageField="image_id"
|
|
||||||
@trigger-item="triggerTask"
|
|
||||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
|
||||||
:filter-fn="
|
|
||||||
(item) => {
|
|
||||||
return item.is_good
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<template #item="{ item }">
|
|
||||||
<div class="item-name">{{ item.name }}</div>
|
|
||||||
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
|
|
||||||
<div
|
|
||||||
class="item-points"
|
|
||||||
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
|
|
||||||
>
|
|
||||||
{{ item.is_good ? item.points : -item.points }} Points
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ScrollingList>
|
|
||||||
<ScrollingList
|
|
||||||
title="Penalties"
|
|
||||||
ref="childPenaltyListRef"
|
|
||||||
:fetchBaseUrl="`/api/task/list?ids=${tasks.join(',')}`"
|
|
||||||
:ids="tasks"
|
|
||||||
itemKey="tasks"
|
|
||||||
imageField="image_id"
|
|
||||||
@trigger-item="triggerTask"
|
|
||||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
|
||||||
:filter-fn="
|
|
||||||
(item) => {
|
|
||||||
return !item.is_good
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<template #item="{ item }">
|
|
||||||
<div class="item-name">{{ item.name }}</div>
|
|
||||||
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
|
|
||||||
<div
|
|
||||||
class="item-points"
|
|
||||||
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
|
|
||||||
>
|
|
||||||
{{ item.is_good ? item.points : -item.points }} Points
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ScrollingList>
|
|
||||||
<ScrollingList
|
|
||||||
title="Rewards"
|
|
||||||
ref="childRewardListRef"
|
|
||||||
:fetchBaseUrl="`/api/child/${child?.id}/reward-status`"
|
|
||||||
itemKey="reward_status"
|
|
||||||
imageField="image_id"
|
|
||||||
@trigger-item="triggerReward"
|
|
||||||
:getItemClass="
|
|
||||||
(item) => ({ reward: true, disabled: hasPendingRewards && !item.redeeming })
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<template #item="{ item }: { item: RewardStatus }">
|
|
||||||
<div class="item-name">{{ item.name }}</div>
|
|
||||||
<img
|
|
||||||
v-if="item.image_url"
|
|
||||||
:src="item.image_url"
|
|
||||||
alt="Reward Image"
|
|
||||||
class="item-image"
|
|
||||||
/>
|
|
||||||
<div class="item-points">
|
|
||||||
<span v-if="item.redeeming" class="pending">PENDING</span>
|
|
||||||
<span v-if="item.points_needed <= 0" class="ready">REWARD READY</span>
|
|
||||||
<span v-else>{{ item.points_needed }} more points</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ScrollingList>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ModalDialog
|
<ModalDialog
|
||||||
v-if="showRewardDialog && dialogReward"
|
v-if="showRewardDialog && dialogReward"
|
||||||
:imageUrl="dialogReward?.image_url"
|
:imageUrl="dialogReward?.image_url"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import ModalDialog from '../shared/ModalDialog.vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import ChildDetailCard from './ChildDetailCard.vue'
|
import ChildDetailCard from './ChildDetailCard.vue'
|
||||||
import ScrollingList from '../shared/ScrollingList.vue'
|
import ScrollingList from '../shared/ScrollingList.vue'
|
||||||
|
import StatusMessage from '../shared/StatusMessage.vue'
|
||||||
import { eventBus } from '@/common/eventBus'
|
import { eventBus } from '@/common/eventBus'
|
||||||
import '@/assets/styles.css'
|
import '@/assets/styles.css'
|
||||||
import '@/assets/view-shared.css'
|
import '@/assets/view-shared.css'
|
||||||
@@ -232,7 +233,6 @@ function getPendingRewardIds(): string[] {
|
|||||||
const triggerTask = (task: Task) => {
|
const triggerTask = (task: Task) => {
|
||||||
selectedTask.value = task
|
selectedTask.value = task
|
||||||
const pendingRewardIds = getPendingRewardIds()
|
const pendingRewardIds = getPendingRewardIds()
|
||||||
console.log('Pending reward IDs:', pendingRewardIds)
|
|
||||||
if (pendingRewardIds.length > 0) {
|
if (pendingRewardIds.length > 0) {
|
||||||
showPendingRewardDialog.value = true
|
showPendingRewardDialog.value = true
|
||||||
return
|
return
|
||||||
@@ -263,7 +263,7 @@ async function cancelPendingReward() {
|
|||||||
try {
|
try {
|
||||||
const pendingRewardIds = getPendingRewardIds()
|
const pendingRewardIds = getPendingRewardIds()
|
||||||
await Promise.all(pendingRewardIds.map((id: string) => cancelRewardById(id)))
|
await Promise.all(pendingRewardIds.map((id: string) => cancelRewardById(id)))
|
||||||
childRewardListRef.value?.refresh()
|
//childRewardListRef.value?.refresh()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to cancel pending reward:', err)
|
console.error('Failed to cancel pending reward:', err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -296,6 +296,12 @@ const confirmTriggerTask = async () => {
|
|||||||
|
|
||||||
const triggerReward = (reward: RewardStatus) => {
|
const triggerReward = (reward: RewardStatus) => {
|
||||||
if (reward.points_needed > 0) return
|
if (reward.points_needed > 0) return
|
||||||
|
// If there is a pending reward and it's not this one, show the pending dialog
|
||||||
|
const pendingRewardIds = getPendingRewardIds()
|
||||||
|
if (pendingRewardIds.length > 0 && !reward.redeeming) {
|
||||||
|
showPendingRewardDialog.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
selectedReward.value = reward
|
selectedReward.value = reward
|
||||||
showRewardConfirm.value = true
|
showRewardConfirm.value = true
|
||||||
}
|
}
|
||||||
@@ -403,6 +409,7 @@ function goToAssignRewards() {
|
|||||||
:fetchBaseUrl="`/api/child/${child?.id}/reward-status`"
|
:fetchBaseUrl="`/api/child/${child?.id}/reward-status`"
|
||||||
itemKey="reward_status"
|
itemKey="reward_status"
|
||||||
imageField="image_id"
|
imageField="image_id"
|
||||||
|
:ids="rewards"
|
||||||
@trigger-item="triggerReward"
|
@trigger-item="triggerReward"
|
||||||
:getItemClass="(item) => ({ reward: true })"
|
:getItemClass="(item) => ({ reward: true })"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ function goToCreateTask() {
|
|||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
const selectedIds = taskListRef.value?.selectedItems ?? []
|
const selectedIds = taskListRef.value?.selectedItems ?? []
|
||||||
try {
|
try {
|
||||||
console.log('selectedIds:', selectedIds)
|
|
||||||
const resp = await fetch(`/api/child/${childId}/set-tasks`, {
|
const resp = await fetch(`/api/child/${childId}/set-tasks`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
:error="errorMsg"
|
:error="errorMsg"
|
||||||
:title="'User Profile'"
|
:title="'User Profile'"
|
||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
|
@cancel="router.back"
|
||||||
@add-image="onAddImage"
|
@add-image="onAddImage"
|
||||||
>
|
>
|
||||||
<template #custom-field-email="{ modelValue }">
|
<template #custom-field-email="{ modelValue }">
|
||||||
@@ -37,11 +38,11 @@
|
|||||||
v-if="showModal"
|
v-if="showModal"
|
||||||
:title="modalTitle"
|
:title="modalTitle"
|
||||||
:subtitle="modalSubtitle"
|
:subtitle="modalSubtitle"
|
||||||
@close="handleModalClose"
|
@close="handlePasswordModalClose"
|
||||||
>
|
>
|
||||||
<div class="modal-message">{{ modalMessage }}</div>
|
<div class="modal-message">{{ modalMessage }}</div>
|
||||||
<div class="modal-actions" v-if="!resetting">
|
<div class="modal-actions" v-if="!resetting">
|
||||||
<button class="btn btn-primary" @click="handleModalClose">OK</button>
|
<button class="btn btn-primary" @click="handlePasswordModalClose">OK</button>
|
||||||
</div>
|
</div>
|
||||||
</ModalDialog>
|
</ModalDialog>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,14 +181,8 @@ function handleSubmit(form: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleModalClose() {
|
async function handlePasswordModalClose() {
|
||||||
showModal.value = false
|
showModal.value = false
|
||||||
// Log out user and route to auth landing page
|
|
||||||
try {
|
|
||||||
await fetch('/api/logout', { method: 'POST' })
|
|
||||||
} catch {}
|
|
||||||
// Optionally clear any local auth state here if needed
|
|
||||||
router.push({ name: 'AuthLanding' })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetPassword() {
|
async function resetPassword() {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
<ItemList
|
<ItemList
|
||||||
v-else
|
v-else
|
||||||
|
ref="rewardListRef"
|
||||||
fetchUrl="/api/reward/list"
|
fetchUrl="/api/reward/list"
|
||||||
itemKey="rewards"
|
itemKey="rewards"
|
||||||
:itemFields="REWARD_FIELDS"
|
:itemFields="REWARD_FIELDS"
|
||||||
@@ -35,7 +36,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import ItemList from '../shared/ItemList.vue'
|
import ItemList from '../shared/ItemList.vue'
|
||||||
import MessageBlock from '@/components/shared/MessageBlock.vue'
|
import MessageBlock from '@/components/shared/MessageBlock.vue'
|
||||||
@@ -45,7 +46,7 @@ import DeleteModal from '../shared/DeleteModal.vue'
|
|||||||
import type { Reward } from '@/common/models'
|
import type { Reward } from '@/common/models'
|
||||||
import { REWARD_FIELDS } from '@/common/models'
|
import { REWARD_FIELDS } from '@/common/models'
|
||||||
|
|
||||||
import '@/assets/view-shared.css'
|
import { eventBus } from '@/common/eventBus'
|
||||||
|
|
||||||
const $router = useRouter()
|
const $router = useRouter()
|
||||||
|
|
||||||
@@ -54,20 +55,37 @@ const rewardToDelete = ref<string | null>(null)
|
|||||||
const rewardListRef = ref()
|
const rewardListRef = ref()
|
||||||
const rewardCountRef = ref<number>(-1)
|
const rewardCountRef = ref<number>(-1)
|
||||||
|
|
||||||
|
function handleRewardModified(event: any) {
|
||||||
|
// Always refresh the reward list on any add, edit, or delete
|
||||||
|
if (rewardListRef.value && typeof rewardListRef.value.refresh === 'function') {
|
||||||
|
rewardListRef.value.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
eventBus.on('reward_modified', handleRewardModified)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
eventBus.off('reward_modified', handleRewardModified)
|
||||||
|
})
|
||||||
function confirmDeleteReward(rewardId: string) {
|
function confirmDeleteReward(rewardId: string) {
|
||||||
rewardToDelete.value = rewardId
|
rewardToDelete.value = rewardId
|
||||||
showConfirm.value = true
|
showConfirm.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteReward = async () => {
|
const deleteReward = async () => {
|
||||||
if (!rewardToDelete.value) return
|
const id =
|
||||||
|
typeof rewardToDelete.value === 'object' && rewardToDelete.value !== null
|
||||||
|
? rewardToDelete.value.id
|
||||||
|
: rewardToDelete.value
|
||||||
|
if (!id) return
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/reward/${rewardToDelete.value}`, {
|
const resp = await fetch(`/api/reward/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
// Refresh the reward list after successful delete
|
// No need to refresh here; SSE will trigger refresh
|
||||||
rewardListRef.value?.refresh()
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete reward:', err)
|
console.error('Failed to delete reward:', err)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ const deletingChildId = ref<string | number | null>(null)
|
|||||||
const deleting = ref(false)
|
const deleting = ref(false)
|
||||||
|
|
||||||
const openChildEditor = (child: Child, evt?: Event) => {
|
const openChildEditor = (child: Child, evt?: Event) => {
|
||||||
console.log(' opening child editor for child id ', child.id)
|
|
||||||
evt?.stopPropagation()
|
evt?.stopPropagation()
|
||||||
router.push({ name: 'ChildEditView', params: { id: child.id } })
|
router.push({ name: 'ChildEditView', params: { id: child.id } })
|
||||||
}
|
}
|
||||||
@@ -137,7 +136,6 @@ const fetchChildren = async (): Promise<Child[]> => {
|
|||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
console.log(' fetched children list: ', childList)
|
|
||||||
return childList
|
return childList
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to fetch children'
|
error.value = err instanceof Error ? err.message : 'Failed to fetch children'
|
||||||
@@ -174,7 +172,6 @@ onUnmounted(() => {
|
|||||||
const shouldIgnoreNextCardClick = ref(false)
|
const shouldIgnoreNextCardClick = ref(false)
|
||||||
|
|
||||||
const onDocClick = (e: MouseEvent) => {
|
const onDocClick = (e: MouseEvent) => {
|
||||||
console.log(' document click detected ')
|
|
||||||
if (activeMenuFor.value !== null) {
|
if (activeMenuFor.value !== null) {
|
||||||
const path = (e.composedPath && e.composedPath()) || (e as any).path || []
|
const path = (e.composedPath && e.composedPath()) || (e as any).path || []
|
||||||
const clickedInsideKebab = path.some((node: unknown) => {
|
const clickedInsideKebab = path.some((node: unknown) => {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
|
import '@/assets/styles.css'
|
||||||
|
import '@/assets/colors.css'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
show: boolean
|
show: boolean
|
||||||
@@ -43,6 +45,17 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@import '@/assets/modal.css';
|
.actions {
|
||||||
@import '@/assets/actions-shared.css';
|
display: flex;
|
||||||
|
gap: 3rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
.actions .btn {
|
||||||
|
padding: 1rem 2.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -100,11 +100,12 @@ function onAddImage({ id, file }: { id: string; file: File }) {
|
|||||||
|
|
||||||
function onCancel() {
|
function onCancel() {
|
||||||
emit('cancel')
|
emit('cancel')
|
||||||
router.back()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
emit('submit', { ...formData.value })
|
emit('submit', { ...formData.value })
|
||||||
|
// After submit, reset isDirty so Save button is disabled until next change
|
||||||
|
isDirty.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Editable field names (exclude custom fields that are not editable)
|
// Editable field names (exclude custom fields that are not editable)
|
||||||
|
|||||||
@@ -22,22 +22,24 @@ const loading = ref(true)
|
|||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const selectedItems = ref<string[]>([])
|
const selectedItems = ref<string[]>([])
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
fetchItems()
|
||||||
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
items,
|
items,
|
||||||
selectedItems,
|
selectedItems,
|
||||||
|
refresh,
|
||||||
})
|
})
|
||||||
|
|
||||||
const fetchItems = async () => {
|
const fetchItems = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
console.log(`Fetching items from: ${props.fetchUrl}`)
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(props.fetchUrl)
|
const resp = await fetch(props.fetchUrl)
|
||||||
console.log(`Fetch response status: ${resp.status}`)
|
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
const data = await resp.json()
|
const data = await resp.json()
|
||||||
//console log all data
|
//console log all data
|
||||||
console.log('Fetched data:', data)
|
|
||||||
let itemList = data[props.itemKey || 'items'] || []
|
let itemList = data[props.itemKey || 'items'] || []
|
||||||
if (props.filterFn) itemList = itemList.filter(props.filterFn)
|
if (props.filterFn) itemList = itemList.filter(props.filterFn)
|
||||||
const initiallySelected: string[] = []
|
const initiallySelected: string[] = []
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const pin = ref('')
|
|||||||
const error = ref('')
|
const error = ref('')
|
||||||
const pinInput = ref<HTMLInputElement | null>(null)
|
const pinInput = ref<HTMLInputElement | null>(null)
|
||||||
const dropdownOpen = ref(false)
|
const dropdownOpen = ref(false)
|
||||||
|
const dropdownRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
const open = async () => {
|
const open = async () => {
|
||||||
// Check if user has a pin
|
// Check if user has a pin
|
||||||
@@ -83,6 +84,10 @@ function toggleDropdown() {
|
|||||||
dropdownOpen.value = !dropdownOpen.value
|
dropdownOpen.value = !dropdownOpen.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeDropdown() {
|
||||||
|
dropdownOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/logout', { method: 'POST' })
|
await fetch('/api/logout', { method: 'POST' })
|
||||||
@@ -91,18 +96,31 @@ async function signOut() {
|
|||||||
} catch {
|
} catch {
|
||||||
// Optionally show error
|
// Optionally show error
|
||||||
}
|
}
|
||||||
dropdownOpen.value = false
|
closeDropdown()
|
||||||
}
|
}
|
||||||
|
|
||||||
function goToProfile() {
|
function goToProfile() {
|
||||||
router.push('/parent/profile')
|
router.push('/parent/profile')
|
||||||
|
closeDropdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (
|
||||||
|
dropdownOpen.value &&
|
||||||
|
dropdownRef.value &&
|
||||||
|
!dropdownRef.value.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
closeDropdown()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
eventBus.on('open-login', open)
|
eventBus.on('open-login', open)
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
eventBus.off('open-login', open)
|
eventBus.off('open-login', open)
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -111,7 +129,7 @@ onUnmounted(() => {
|
|||||||
<button v-if="!isParentAuthenticated" @click="open" aria-label="Parent login" class="login-btn">
|
<button v-if="!isParentAuthenticated" @click="open" aria-label="Parent login" class="login-btn">
|
||||||
Parent
|
Parent
|
||||||
</button>
|
</button>
|
||||||
<div v-else style="display: inline-block; position: relative">
|
<div v-else style="display: inline-block; position: relative" ref="dropdownRef">
|
||||||
<button
|
<button
|
||||||
@click="toggleDropdown"
|
@click="toggleDropdown"
|
||||||
aria-label="Parent menu"
|
aria-label="Parent menu"
|
||||||
@@ -138,7 +156,16 @@ onUnmounted(() => {
|
|||||||
<button class="menu-item" @click="goToProfile" style="width: 100%; text-align: left">
|
<button class="menu-item" @click="goToProfile" style="width: 100%; text-align: left">
|
||||||
Profile
|
Profile
|
||||||
</button>
|
</button>
|
||||||
<button class="menu-item" @click="handleLogout" style="width: 100%; text-align: left">
|
<button
|
||||||
|
class="menu-item"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
handleLogout()
|
||||||
|
closeDropdown()
|
||||||
|
}
|
||||||
|
"
|
||||||
|
style="width: 100%; text-align: left"
|
||||||
|
>
|
||||||
Log out
|
Log out
|
||||||
</button>
|
</button>
|
||||||
<button class="menu-item danger" @click="signOut" style="width: 100%; text-align: left">
|
<button class="menu-item danger" @click="signOut" style="width: 100%; text-align: left">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
<ItemList
|
<ItemList
|
||||||
v-else
|
v-else
|
||||||
|
ref="taskListRef"
|
||||||
fetchUrl="/api/task/list"
|
fetchUrl="/api/task/list"
|
||||||
itemKey="tasks"
|
itemKey="tasks"
|
||||||
:itemFields="TASK_FIELDS"
|
:itemFields="TASK_FIELDS"
|
||||||
@@ -35,7 +36,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import ItemList from '../shared/ItemList.vue'
|
import ItemList from '../shared/ItemList.vue'
|
||||||
import MessageBlock from '@/components/shared/MessageBlock.vue'
|
import MessageBlock from '@/components/shared/MessageBlock.vue'
|
||||||
@@ -45,6 +46,8 @@ import DeleteModal from '../shared/DeleteModal.vue'
|
|||||||
import type { Task } from '@/common/models'
|
import type { Task } from '@/common/models'
|
||||||
import { TASK_FIELDS } from '@/common/models'
|
import { TASK_FIELDS } from '@/common/models'
|
||||||
|
|
||||||
|
import { eventBus } from '@/common/eventBus'
|
||||||
|
|
||||||
const $router = useRouter()
|
const $router = useRouter()
|
||||||
|
|
||||||
const showConfirm = ref(false)
|
const showConfirm = ref(false)
|
||||||
@@ -52,20 +55,39 @@ const taskToDelete = ref<string | null>(null)
|
|||||||
const taskListRef = ref()
|
const taskListRef = ref()
|
||||||
const taskCountRef = ref<number>(-1)
|
const taskCountRef = ref<number>(-1)
|
||||||
|
|
||||||
|
function handleTaskModified(event: any) {
|
||||||
|
// Always refresh the task list on any add, edit, or delete
|
||||||
|
if (taskListRef.value && typeof taskListRef.value.refresh === 'function') {
|
||||||
|
taskListRef.value.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
eventBus.on('task_modified', handleTaskModified)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
eventBus.off('task_modified', handleTaskModified)
|
||||||
|
})
|
||||||
|
|
||||||
function confirmDeleteTask(taskId: string) {
|
function confirmDeleteTask(taskId: string) {
|
||||||
taskToDelete.value = taskId
|
taskToDelete.value = taskId
|
||||||
showConfirm.value = true
|
showConfirm.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteTask = async () => {
|
const deleteTask = async () => {
|
||||||
if (!taskToDelete.value) return
|
// Ensure we use the string ID, not an object
|
||||||
|
const id =
|
||||||
|
typeof taskToDelete.value === 'object' && taskToDelete.value !== null
|
||||||
|
? taskToDelete.value.id
|
||||||
|
: taskToDelete.value
|
||||||
|
if (!id) return
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/task/${taskToDelete.value}`, {
|
const resp = await fetch(`/api/task/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
// Refresh the task list after successful delete
|
// No need to refresh here; SSE will trigger refresh
|
||||||
taskListRef.value?.refresh()
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete task:', err)
|
console.error('Failed to delete task:', err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -76,8 +98,6 @@ const deleteTask = async () => {
|
|||||||
|
|
||||||
// New function to handle task creation
|
// New function to handle task creation
|
||||||
const createTask = () => {
|
const createTask = () => {
|
||||||
// Route to your create task page or open a create dialog
|
|
||||||
// Example:
|
|
||||||
$router.push({ name: 'CreateTask' })
|
$router.push({ name: 'CreateTask' })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ onMounted(async () => {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<router-view />
|
<router-view :key="$route.fullPath" />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div v-if="appVersion" class="app-version">v{{ appVersion }}</div>
|
<div v-if="appVersion" class="app-version">v{{ appVersion }}</div>
|
||||||
|
|||||||
@@ -179,9 +179,16 @@ const router = createRouter({
|
|||||||
|
|
||||||
// Auth guard
|
// Auth guard
|
||||||
router.beforeEach(async (to, from, next) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
console.log('navigating to', to.fullPath, 'from', from.fullPath)
|
if (!isAuthReady.value) {
|
||||||
console.log('isParentAuthenticated:', isParentAuthenticated.value)
|
await new Promise((resolve) => {
|
||||||
console.trace()
|
const stop = watch(isAuthReady, (ready) => {
|
||||||
|
if (ready) {
|
||||||
|
stop()
|
||||||
|
resolve(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Always allow /auth and /parent/pin-setup
|
// Always allow /auth and /parent/pin-setup
|
||||||
if (to.path.startsWith('/auth') || to.name === 'ParentPinSetup') {
|
if (to.path.startsWith('/auth') || to.name === 'ParentPinSetup') {
|
||||||
|
|||||||
Reference in New Issue
Block a user